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

@@ -12,6 +12,7 @@ import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom } from '@/state/
import { setOutboundPending } from '@/state/sip-manager';
import { useSip } from '@/providers/sip-provider';
import { DispositionModal } from './disposition-modal';
import type { CallAction } from './disposition-modal';
import { AppointmentForm } from './appointment-form';
import { TransferDialog } from './transfer-dialog';
import { EnquiryForm } from './enquiry-form';
@@ -48,7 +49,18 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
const [enquiryOpen, setEnquiryOpen] = useState(false);
const [dispositionOpen, setDispositionOpen] = useState(false);
const [callerDisconnected, setCallerDisconnected] = useState(false);
const [suggestedDisposition, setSuggestedDisposition] = useState<CallDisposition | null>(null);
// Actions actually recorded during this call. Drives the disposition
// modal's priority-lock: if the agent booked an appointment and logged
// an enquiry, both badges render and the primary disposition is
// locked to APPOINTMENT_BOOKED.
const [actionsTaken, setActionsTaken] = useState<CallAction[]>([]);
const addActions = (...newActions: CallAction[]) => {
setActionsTaken((prev) => {
const next = new Set(prev);
for (const a of newActions) next.add(a);
return Array.from(next);
});
};
const agentConfig = localStorage.getItem('helix_agent_config');
const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
@@ -104,6 +116,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
direction: callDirectionRef.current,
durationSec: callDuration,
leadId: lead?.id ?? null,
leadName: fullName || null,
notes,
missedCallId: missedCallId ?? undefined,
};
@@ -115,24 +128,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
console.warn('[DISPOSE] No callUcid — skipping disposition');
}
// Side effects
if (disposition === 'FOLLOW_UP_SCHEDULED') {
try {
await apiClient.graphql(`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`, {
data: {
name: `Follow-up — ${fullName || phoneDisplay}`,
typeCustom: 'CALLBACK',
status: 'PENDING',
assignedAgent: null,
priority: 'NORMAL',
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
},
}, { silent: true });
notify.success('Follow-up Created', 'Callback scheduled for tomorrow');
} catch {
notify.info('Follow-up', 'Could not auto-create follow-up');
}
}
// Follow-ups are created by the enquiry form (where the agent picks
// the date + context). No second creation here — that was causing
// duplicate entries on every FOLLOW_UP_SCHEDULED call.
// Clear persisted UCID — disposition was submitted, no need for sendBeacon fallback
localStorage.removeItem('helix_active_ucid');
@@ -141,15 +139,24 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
handleReset();
};
const handleAppointmentSaved = () => {
const handleAppointmentSaved = (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => {
setAppointmentOpen(false);
setSuggestedDisposition('APPOINTMENT_BOOKED');
notify.success('Appointment Booked', 'Payment link will be sent to the patient');
if (outcome === 'RESCHEDULED') {
addActions('RESCHEDULE');
notify.success('Appointment Rescheduled');
} else if (outcome === 'CANCELLED') {
addActions('CANCEL');
notify.success('Appointment Cancelled');
} else {
addActions('APPOINTMENT');
notify.success('Appointment Booked', 'Payment link will be sent to the patient');
}
};
const handleReset = () => {
setDispositionOpen(false);
setCallerDisconnected(false);
setActionsTaken([]);
setCallState('idle');
setCallerNumber(null);
setCallUcid(null);
@@ -213,7 +220,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
return (
<div className="rounded-xl border border-secondary bg-primary p-4 text-center">
<FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" />
<p className="text-sm font-semibold text-primary">Missed Call</p>
<p className="text-sm font-semibold text-primary">{fullName || 'Missed Call'}</p>
<p className="text-xs text-tertiary mt-1">{phoneDisplay} not answered</p>
<Button size="sm" color="secondary" className="mt-3" onClick={handleReset}>
Back to Worklist
@@ -317,7 +324,10 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
onClose={() => setTransferOpen(false)}
onTransferred={() => {
setTransferOpen(false);
setSuggestedDisposition('FOLLOW_UP_SCHEDULED');
// A transfer implies the original agent handed the call
// off — treat that as a follow-up action so the
// disposition pre-locks to FOLLOW_UP_SCHEDULED.
addActions('FOLLOWUP');
setDispositionOpen(true);
}}
/>
@@ -340,10 +350,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
leadId={lead?.id ?? null}
patientId={(lead as any)?.patientId ?? null}
agentName={user.name}
onSaved={() => {
onSaved={(actions) => {
setEnquiryOpen(false);
setSuggestedDisposition('INFO_PROVIDED');
notify.success('Enquiry Logged');
addActions(...actions);
}}
/>
</div>
@@ -355,7 +364,13 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
isOpen={dispositionOpen}
callerName={fullName || phoneDisplay}
callerDisconnected={callerDisconnected}
defaultDisposition={suggestedDisposition}
// wasAnsweredRef only flips true once callState reaches
// 'active'. Outbound callbacks that never connect keep
// this false, which narrows the disposition options to
// no-answer outcomes and prevents SLA-gaming dispositions
// like Info Provided on a call the customer never took.
callAnswered={wasAnsweredRef.current}
actionsTaken={actionsTaken}
onSubmit={handleDisposition}
onDismiss={() => {
// Agent wants to continue the call — close modal, call stays active

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCircle, faChevronDown } from '@fortawesome/pro-duotone-svg-icons';
import { faCircle, faChevronDown, faSpinnerThird } from '@fortawesome/pro-duotone-svg-icons';
import { useAgentState } from '@/hooks/use-agent-state';
import type { OzonetelState } from '@/hooks/use-agent-state';
import { apiClient } from '@/lib/api-client';
@@ -50,6 +50,15 @@ export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatu
console.log('[AGENT-STATE] Ready response:', JSON.stringify(res));
} else {
const pauseReason = newStatus === 'break' ? 'Break' : 'Training';
// Ozonetel rejects Pause→Pause (Break↔Training) — the agent must
// transit through Ready. Insert a Ready hop whenever we're
// moving between two paused sub-states.
const isPauseToPause = ozonetelState === 'break' || ozonetelState === 'training';
if (isPauseToPause) {
console.log(`[AGENT-STATE] ${ozonetelState}${newStatus}: sending Ready first, then Pause(${pauseReason})`);
await apiClient.post('/api/ozonetel/agent-state', { agentId, state: 'Ready' });
await new Promise(resolve => setTimeout(resolve, 400));
}
console.log(`[AGENT-STATE] Changing to Pause: ${pauseReason}`);
const res = await apiClient.post('/api/ozonetel/agent-state', { agentId, state: 'Pause', pauseReason });
console.log('[AGENT-STATE] Pause response:', JSON.stringify(res));
@@ -89,13 +98,18 @@ export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatu
disabled={changing || !canToggle}
className={cx(
'flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1 transition duration-100 ease-linear',
canToggle ? 'hover:bg-secondary_hover cursor-pointer' : 'cursor-default',
changing && 'opacity-50',
canToggle && !changing ? 'hover:bg-secondary_hover cursor-pointer' : 'cursor-default',
)}
>
<FontAwesomeIcon icon={faCircle} className={cx('size-2', current.dotColor)} />
<span className={cx('text-xs font-medium', current.color)}>{current.label}</span>
{canToggle && <FontAwesomeIcon icon={faChevronDown} className="size-2.5 text-fg-quaternary" />}
{changing ? (
<FontAwesomeIcon icon={faSpinnerThird} spin className="size-2.5 text-fg-brand-primary" />
) : (
<FontAwesomeIcon icon={faCircle} className={cx('size-2', current.dotColor)} />
)}
<span className={cx('text-xs font-medium', changing ? 'text-brand-secondary' : current.color)}>
{changing ? 'Changing…' : current.label}
</span>
{canToggle && !changing && <FontAwesomeIcon icon={faChevronDown} className="size-2.5 text-fg-quaternary" />}
</button>
{menuOpen && (

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 {

View File

@@ -14,11 +14,14 @@ interface CallLogProps {
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
APPOINTMENT_BOOKED: { label: 'Booked', color: 'success' },
APPOINTMENT_RESCHEDULED: { label: 'Rescheduled', color: 'warning' },
APPOINTMENT_CANCELLED: { label: 'Cancelled', color: 'error' },
FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' },
INFO_PROVIDED: { label: 'Info', color: 'blue-light' },
NO_ANSWER: { label: 'No Answer', color: 'warning' },
WRONG_NUMBER: { label: 'Wrong #', color: 'gray' },
CALLBACK_REQUESTED: { label: 'Not Interested', color: 'error' },
NOT_INTERESTED: { label: 'Not Interested', color: 'error' },
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
};
const formatDuration = (seconds: number | null): string => {

View File

@@ -20,6 +20,18 @@ const dispositionOptions: Array<{
activeClass: 'bg-success-solid text-white ring-transparent',
defaultClass: 'bg-success-primary text-success-primary border-success',
},
{
value: 'APPOINTMENT_RESCHEDULED',
label: 'Appt Rescheduled',
activeClass: 'bg-warning-solid text-white ring-transparent',
defaultClass: 'bg-warning-primary text-warning-primary border-warning',
},
{
value: 'APPOINTMENT_CANCELLED',
label: 'Appt Cancelled',
activeClass: 'bg-error-solid text-white ring-transparent',
defaultClass: 'bg-error-primary text-error-primary border-error',
},
{
value: 'FOLLOW_UP_SCHEDULED',
label: 'Follow-up Needed',
@@ -45,11 +57,17 @@ const dispositionOptions: Array<{
defaultClass: 'bg-secondary text-secondary border-secondary',
},
{
value: 'CALLBACK_REQUESTED',
value: 'NOT_INTERESTED',
label: 'Not Interested',
activeClass: 'bg-error-solid text-white ring-transparent',
defaultClass: 'bg-error-primary text-error-primary border-error',
},
{
value: 'CALLBACK_REQUESTED',
label: 'Callback Requested',
activeClass: 'bg-utility-blue-600 text-white ring-transparent',
defaultClass: 'bg-utility-blue-50 text-utility-blue-700 border-utility-blue-200',
},
];
export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFormProps) => {

View File

@@ -1,13 +1,43 @@
import { useState, useRef } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPhoneHangup } from '@fortawesome/pro-duotone-svg-icons';
import { faPhoneHangup, faCalendarCheck, faCalendarXmark, faCalendarArrowDown, faClipboardCheck, faClockRotateLeft } from '@fortawesome/pro-duotone-svg-icons';
import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
import { Badge } from '@/components/base/badges/badges';
import { TextArea } from '@/components/base/textarea/textarea';
import type { FC } from 'react';
import type { CallDisposition } from '@/types/entities';
import { cx } from '@/utils/cx';
export type CallAction = 'APPOINTMENT' | 'RESCHEDULE' | 'CANCEL' | 'FOLLOWUP' | 'ENQUIRY';
// Maps a recorded action to the disposition it implies. The first action in
// the priority list (highest-ranked entry in actionsTaken) becomes the
// primary disposition. When any action is present, all other dispositions
// are locked out — an agent can't mark a call as "Not Interested" after
// they've already booked an appointment.
const ACTION_TO_DISPOSITION: Record<CallAction, CallDisposition> = {
APPOINTMENT: 'APPOINTMENT_BOOKED',
RESCHEDULE: 'APPOINTMENT_RESCHEDULED',
CANCEL: 'APPOINTMENT_CANCELLED',
FOLLOWUP: 'FOLLOW_UP_SCHEDULED',
ENQUIRY: 'INFO_PROVIDED',
};
const ACTION_META: Record<CallAction, { label: string; icon: typeof faCalendarCheck; color: 'success' | 'warning' | 'error' | 'brand' | 'blue-light' }> = {
APPOINTMENT: { label: 'Appointment booked', icon: faCalendarCheck, color: 'success' },
RESCHEDULE: { label: 'Appointment rescheduled', icon: faCalendarArrowDown, color: 'warning' },
CANCEL: { label: 'Appointment cancelled', icon: faCalendarXmark, color: 'error' },
FOLLOWUP: { label: 'Follow-up scheduled', icon: faClockRotateLeft, color: 'brand' },
ENQUIRY: { label: 'Enquiry logged', icon: faClipboardCheck, color: 'blue-light' },
};
// Priority order — highest-rank action wins when multiple are taken. Booked
// > Rescheduled > Cancelled > Follow-up > Enquiry. A cancel inherently means
// no booking, so it ranks below booking/rescheduling; but above a follow-up
// because cancellation is a definitive outcome on this call.
const ACTION_PRIORITY: CallAction[] = ['APPOINTMENT', 'RESCHEDULE', 'CANCEL', 'FOLLOWUP', 'ENQUIRY'];
const PhoneHangUpIcon: FC<{ className?: string }> = ({ className }) => (
<FontAwesomeIcon icon={faPhoneHangup} className={className} />
);
@@ -24,6 +54,18 @@ const dispositionOptions: Array<{
activeClass: 'bg-success-solid text-white border-transparent',
defaultClass: 'bg-success-primary text-success-primary border-success',
},
{
value: 'APPOINTMENT_RESCHEDULED',
label: 'Appt Rescheduled',
activeClass: 'bg-warning-solid text-white border-transparent',
defaultClass: 'bg-warning-primary text-warning-primary border-warning',
},
{
value: 'APPOINTMENT_CANCELLED',
label: 'Appt Cancelled',
activeClass: 'bg-error-solid text-white border-transparent',
defaultClass: 'bg-error-primary text-error-primary border-error',
},
{
value: 'FOLLOW_UP_SCHEDULED',
label: 'Follow-up Needed',
@@ -49,31 +91,68 @@ const dispositionOptions: Array<{
defaultClass: 'bg-secondary text-secondary border-secondary',
},
{
value: 'CALLBACK_REQUESTED',
value: 'NOT_INTERESTED',
label: 'Not Interested',
activeClass: 'bg-error-solid text-white border-transparent',
defaultClass: 'bg-error-primary text-error-primary border-error',
},
{
value: 'CALLBACK_REQUESTED',
label: 'Callback Requested',
activeClass: 'bg-utility-blue-600 text-white border-transparent',
defaultClass: 'bg-utility-blue-50 text-utility-blue-700 border-utility-blue-200',
},
];
type DispositionModalProps = {
isOpen: boolean;
callerName: string;
callerDisconnected: boolean;
defaultDisposition?: CallDisposition | null;
// True once the call reached the active (answered) state. When false,
// the customer never picked up — only no-answer dispositions are
// valid; conversation-implying ones (Info Provided, Appointment
// Booked, Follow-up, Not Interested) are disabled. Defaults to
// true so existing callers don't accidentally lock everything out.
callAnswered?: boolean;
// Actions actually performed during the call (appointment booked, enquiry
// logged, follow-up scheduled). Drives the priority-based disposition
// lock — when any action is present, the primary disposition is forced
// and the other options are disabled.
actionsTaken?: CallAction[];
onSubmit: (disposition: CallDisposition, notes: string) => void;
onDismiss?: () => void;
};
export const DispositionModal = ({ isOpen, callerName, callerDisconnected, defaultDisposition, onSubmit, onDismiss }: DispositionModalProps) => {
// Dispositions that only make sense when the customer actually connected.
// Selecting these on an unanswered call would misrepresent SLA and
// conversation metrics.
const ANSWERED_ONLY_DISPOSITIONS: ReadonlySet<CallDisposition> = new Set([
'INFO_PROVIDED',
'APPOINTMENT_BOOKED',
'APPOINTMENT_RESCHEDULED',
'APPOINTMENT_CANCELLED',
'FOLLOW_UP_SCHEDULED',
'NOT_INTERESTED',
]);
export const DispositionModal = ({ isOpen, callerName, callerDisconnected, callAnswered = true, actionsTaken, onSubmit, onDismiss }: DispositionModalProps) => {
const [selected, setSelected] = useState<CallDisposition | null>(null);
const [notes, setNotes] = useState('');
const appliedDefaultRef = useRef<CallDisposition | null | undefined>(undefined);
const appliedLockRef = useRef<CallDisposition | null | undefined>(undefined);
// Pre-select when modal opens with a suggestion
if (isOpen && defaultDisposition && appliedDefaultRef.current !== defaultDisposition) {
appliedDefaultRef.current = defaultDisposition;
setSelected(defaultDisposition);
// Rank actionsTaken to pick the primary (highest-priority) action. When
// any action is present, that action's disposition becomes locked —
// the agent cannot override it to a contradictory outcome.
const primaryAction = actionsTaken && actionsTaken.length > 0
? ACTION_PRIORITY.find((a) => actionsTaken.includes(a)) ?? null
: null;
const lockedDisposition = primaryAction ? ACTION_TO_DISPOSITION[primaryAction] : null;
// Apply the lock once per open — agent can still re-select the same
// option, but switching to another value is prevented in the click handler.
if (isOpen && lockedDisposition && appliedLockRef.current !== lockedDisposition) {
appliedLockRef.current = lockedDisposition;
setSelected(lockedDisposition);
}
const handleSubmit = () => {
@@ -81,11 +160,20 @@ export const DispositionModal = ({ isOpen, callerName, callerDisconnected, defau
onSubmit(selected, notes);
setSelected(null);
setNotes('');
appliedDefaultRef.current = undefined;
appliedLockRef.current = undefined;
};
return (
<ModalOverlay isOpen={isOpen} isDismissable onOpenChange={(open) => { if (!open && onDismiss) onDismiss(); }}>
<ModalOverlay
isOpen={isOpen}
// When the caller disconnected on their own, dismissing the
// modal discards the call without any disposition — no record,
// no SLA signal. Force a selection in that path. When the
// agent opened the modal via End Call (callerDisconnected=false),
// dismissing just returns to the active call, so it's safe.
isDismissable={!callerDisconnected}
onOpenChange={(open) => { if (!open && onDismiss) onDismiss(); }}
>
<Modal className="sm:max-w-md">
<Dialog>
{() => (
@@ -108,16 +196,47 @@ export const DispositionModal = ({ isOpen, callerName, callerDisconnected, defau
{/* Disposition options */}
<div className="px-6 pb-4">
{actionsTaken && actionsTaken.length > 0 && (
<div className="mb-3 flex flex-col gap-2 rounded-lg bg-secondary p-3">
<span className="text-xs font-semibold uppercase tracking-wide text-tertiary">
Actions taken on this call
</span>
<div className="flex flex-wrap gap-1.5">
{ACTION_PRIORITY.filter((a) => actionsTaken.includes(a)).map((action) => {
const meta = ACTION_META[action];
return (
<Badge key={action} size="sm" color={meta.color} type="pill-color">
<FontAwesomeIcon icon={meta.icon} className="size-3 mr-1" />
{meta.label}
</Badge>
);
})}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-2">
{dispositionOptions.map((option) => {
const isSelected = selected === option.value;
// Two reasons an option can be disabled:
// (1) action lock — the agent already booked / scheduled
// something, so only the matching disposition is valid.
// (2) unanswered call — dispositions that imply the customer
// actually spoke with the agent (Info Provided, etc.)
// are disabled to prevent SLA-gaming.
const isLockedOut = lockedDisposition !== null && option.value !== lockedDisposition;
const isAnsweredOnlyBlocked = !callAnswered && ANSWERED_ONLY_DISPOSITIONS.has(option.value);
const isDisabled = isLockedOut || isAnsweredOnlyBlocked;
return (
<button
key={option.value}
type="button"
onClick={() => setSelected(option.value)}
disabled={isDisabled}
onClick={() => !isDisabled && setSelected(option.value)}
className={cx(
'cursor-pointer rounded-xl border-2 p-3 text-sm font-semibold transition duration-100 ease-linear',
'rounded-xl border-2 p-3 text-sm font-semibold transition duration-100 ease-linear',
isDisabled && 'cursor-not-allowed opacity-40',
!isDisabled && 'cursor-pointer',
isSelected
? cx(option.activeClass, 'ring-2 ring-brand')
: option.defaultClass,

View File

@@ -22,7 +22,11 @@ type EnquiryFormProps = {
leadId?: string | null;
patientId?: string | null;
agentName?: string | null;
onSaved?: () => void;
// Called after a successful save. Passes back the list of actions that
// were actually recorded — the parent uses this to drive the disposition
// priority + lock logic. Always includes 'ENQUIRY'; adds 'FOLLOWUP' when
// the agent scheduled a callback.
onSaved?: (actions: Array<'ENQUIRY' | 'FOLLOWUP'>) => void;
};
@@ -79,17 +83,20 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
setError(null);
try {
// Use passed leadId or resolve from phone
// Resolve caller. Resolver returns isNew=true when no Lead/
// Patient exists for this phone — in that case we create both
// records inline with the typed name. Otherwise we update the
// existing records.
let leadId: string | null = propLeadId ?? null;
if (!leadId && registeredPhone) {
const resolved = await apiClient.post<{ leadId: string; patientId: string }>('/api/caller/resolve', { phone: registeredPhone }, { silent: true });
leadId = resolved.leadId;
let resolvedPatientId: string | null = patientId || null;
let isNew = false;
if ((!leadId || !resolvedPatientId) && registeredPhone) {
const resolved = await apiClient.post<{ leadId: string; patientId: string; isNew: boolean }>('/api/caller/resolve', { phone: registeredPhone }, { silent: true });
leadId = leadId || resolved.leadId || null;
resolvedPatientId = resolvedPatientId || resolved.patientId || null;
isNew = !!resolved.isNew && !leadId;
}
// 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;
const nameParts = {
@@ -97,10 +104,48 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
lastName: trimmedName.split(' ').slice(1).join(' ') || '',
};
if (leadId) {
// Update existing lead with enquiry details. Only touches
// contactName if the agent explicitly renamed — otherwise
// we leave the existing caller identity alone.
if (isNew) {
// Net-new caller — create Patient + Lead with the typed
// name. Name is required (validated above).
if (!trimmedName) {
setError('Please enter the patient name.');
setIsSaving(false);
return;
}
try {
const phoneE164 = registeredPhone ? `+91${registeredPhone.replace(/^\+?91/, '').replace(/\D/g, '').slice(-10)}` : undefined;
const patientData: Record<string, any> = {
fullName: nameParts,
patientType: 'NEW',
};
if (phoneE164) patientData.phones = { primaryPhoneNumber: phoneE164 };
const pResult = await apiClient.graphql<{ createPatient: { id: string } }>(
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
{ data: patientData },
);
resolvedPatientId = pResult.createPatient.id;
} catch (err) {
console.warn('Failed to create patient:', err);
}
const leadData: Record<string, any> = {
name: `Enquiry — ${trimmedName}`,
contactName: nameParts,
source: 'PHONE',
status: 'CONTACTED',
interestedService: queryAsked.substring(0, 100),
};
if (registeredPhone) leadData.contactPhone = { primaryPhoneNumber: registeredPhone };
if (resolvedPatientId) leadData.patientId = resolvedPatientId;
const lResult = await apiClient.graphql<{ createLead: { id: string } }>(
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
{ data: leadData },
);
leadId = lResult.createLead.id;
} else if (leadId) {
// Existing lead — update with enquiry details. Only touch
// contactName when the agent explicitly renamed (the name
// field is locked behind the Edit confirm modal for
// existing records).
await apiClient.graphql(
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
{
@@ -114,34 +159,16 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
},
},
);
} else {
// No matched lead — create a fresh one. For net-new leads
// we always populate contactName from the typed value
// (there's no existing record to protect).
await apiClient.graphql(
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
{
data: {
name: `Enquiry — ${trimmedName || 'Unknown caller'}`,
contactName: nameParts,
contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined,
source: 'PHONE',
status: 'CONTACTED',
interestedService: queryAsked.substring(0, 100),
},
},
);
}
// Update linked patient's name ONLY if the agent explicitly
// renamed. Fixes the long-standing bug where typing a name
// into this form silently overwrote the existing patient
// record.
if (nameChanged && patientId) {
// Update linked patient's name when the agent renamed (edit
// confirm path) on an existing record. Skipped for isNew
// because the patient was just created with the right name.
if (!isNew && nameChanged && resolvedPatientId && trimmedName) {
await apiClient.graphql(
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
{
id: patientId,
id: resolvedPatientId,
data: {
fullName: nameParts,
},
@@ -149,14 +176,10 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
).catch((err: unknown) => console.warn('Failed to update patient name:', err));
}
// Post-save side-effects. If the agent actually renamed the
// patient, kick off AI summary regen + cache invalidation.
// Otherwise just invalidate the cache so the status update
// propagates.
// Post-save side-effect. If the agent actually renamed the
// patient, kick off AI summary regen. Fire-and-forget.
if (nameChanged && leadId) {
apiClient.post(`/api/lead/${leadId}/enrich`, { phone: callerPhone ?? undefined }, { silent: true }).catch(() => {});
} else if (callerPhone) {
apiClient.post('/api/caller/invalidate', { phone: callerPhone }, { silent: true }).catch(() => {});
}
// Create follow-up if needed
@@ -166,6 +189,12 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
setIsSaving(false);
return;
}
const today = new Date().toISOString().split('T')[0];
if (followUpDate < today) {
setError('Follow-up date cannot be in the past.');
setIsSaving(false);
return;
}
await apiClient.graphql(
`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`,
{
@@ -176,7 +205,7 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
priority: 'NORMAL',
assignedAgent: agentName ?? undefined,
scheduledAt: new Date(`${followUpDate}T09:00:00`).toISOString(),
patientId: patientId ?? undefined,
patientId: resolvedPatientId || undefined,
},
},
{ silent: true },
@@ -184,7 +213,9 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
}
notify.success('Enquiry Logged', 'Contact details and query captured');
onSaved?.();
const actions: Array<'ENQUIRY' | 'FOLLOWUP'> = ['ENQUIRY'];
if (followUpNeeded) actions.push('FOLLOWUP');
onSaved?.(actions);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save enquiry');
} finally {
@@ -251,11 +282,14 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
</Select>
</div>
<Checkbox isSelected={followUpNeeded} onChange={setFollowUpNeeded} label="Follow-up Needed" />
{followUpNeeded && (
<Input label="Follow-up Date" type="date" value={followUpDate} onChange={setFollowUpDate} isRequired />
)}
<div className="flex items-center gap-3">
<Checkbox isSelected={followUpNeeded} onChange={setFollowUpNeeded} label="Follow-up Needed" />
{followUpNeeded && (
<div className="flex-1 max-w-[180px]">
<Input type="date" value={followUpDate} onChange={setFollowUpDate} isRequired aria-label="Follow-up Date" />
</div>
)}
</div>
{error && (
<div className="rounded-lg bg-error-primary p-3 text-sm text-error-primary">{error}</div>

View File

@@ -51,11 +51,14 @@ const ActivityIcon = ({ type }: { type: string }) => {
const dispositionLabels: Record<CallDisposition, string> = {
APPOINTMENT_BOOKED: 'Appointment Booked',
APPOINTMENT_RESCHEDULED: 'Appointment Rescheduled',
APPOINTMENT_CANCELLED: 'Appointment Cancelled',
FOLLOW_UP_SCHEDULED: 'Follow-up Needed',
INFO_PROVIDED: 'Info Provided',
NO_ANSWER: 'No Answer',
WRONG_NUMBER: 'Wrong Number',
CALLBACK_REQUESTED: 'Not Interested',
NOT_INTERESTED: 'Not Interested',
CALLBACK_REQUESTED: 'Callback Requested',
};
export const IncomingCallCard = ({ callState, lead, activities, campaigns, onDisposition, completedDisposition }: IncomingCallCardProps) => {

View File

@@ -56,18 +56,18 @@ export const TransferDialog = ({ ucid, currentAgentId, onClose, onTransferred }:
const fetchTargets = async () => {
try {
const [agentsRes, doctorsRes] = await Promise.all([
apiClient.graphql<any>(`{ agents(first: 20) { edges { node { id name ozonetelagentid sipextension } } } }`),
apiClient.graphql<any>(`{ agents(first: 20) { edges { node { id name ozonetelAgentId sipExtension } } } }`),
apiClient.graphql<any>(`{ doctors(first: 20) { edges { node { id name department phone { primaryPhoneNumber } } } } }`),
]);
const agents: TransferTarget[] = (agentsRes.agents?.edges ?? [])
.map((e: any) => e.node)
.filter((a: any) => a.ozonetelagentid !== currentAgentId)
.filter((a: any) => a.ozonetelAgentId !== currentAgentId)
.map((a: any) => ({
id: a.id,
name: a.name,
type: 'agent' as const,
phoneNumber: `0${a.sipextension}`,
phoneNumber: `0${a.sipExtension}`,
status: 'offline' as const,
}));

View File

@@ -36,6 +36,8 @@ type WorklistFollowUp = {
followUpStatus: string | null;
scheduledAt: string | null;
priority: string | null;
patientName?: string;
patientPhone?: string;
};
type MissedCall = {
@@ -45,11 +47,12 @@ type MissedCall = {
callerNumber: { number: string; callingCode: string }[] | null;
startedAt: string | null;
leadId: string | null;
leadName: string | null;
disposition: string | null;
callbackstatus: string | null;
callsourcenumber: string | null;
missedcallcount: number | null;
callbackattemptedat: string | null;
callbackStatus: string | null;
callSourceNumber: string | null;
missedCallCount: number | null;
callbackAttemptedAt: string | null;
};
type MissedSubTab = 'pending' | 'attempted' | 'completed' | 'invalid';
@@ -107,7 +110,9 @@ const followUpLabel: Record<string, string> = {
REVIEW_REQUEST: 'Review',
};
const computeSla = (dateStr: string): { label: string; color: 'success' | 'warning' | 'error' } => {
// SLA for reactive work — missed calls / unanswered leads. Measures time
// elapsed since the trigger: longer wait = worse SLA.
const computeReactiveSla = (dateStr: string): { label: string; color: 'success' | 'warning' | 'error' } => {
const minutes = Math.max(0, Math.round((Date.now() - new Date(dateStr).getTime()) / 60000));
if (minutes < 1) return { label: '<1m', color: 'success' };
if (minutes < 15) return { label: `${minutes}m`, color: 'success' };
@@ -118,6 +123,34 @@ const computeSla = (dateStr: string): { label: string; color: 'success' | 'warni
return { label: `${Math.floor(hours / 24)}d`, color: 'error' };
};
// SLA for scheduled work — follow-ups / callbacks. Measures time remaining
// until the scheduled slot. Green when comfortably ahead, warning when
// due soon, error when overdue.
const computeScheduledSla = (scheduledAt: string): { label: string; color: 'success' | 'warning' | 'error' } => {
const minutes = Math.round((new Date(scheduledAt).getTime() - Date.now()) / 60000);
if (minutes < 0) {
const overdueMins = -minutes;
if (overdueMins < 60) return { label: `Overdue ${overdueMins}m`, color: 'error' };
const overdueHrs = Math.floor(overdueMins / 60);
if (overdueHrs < 24) return { label: `Overdue ${overdueHrs}h`, color: 'error' };
return { label: `Overdue ${Math.floor(overdueHrs / 24)}d`, color: 'error' };
}
if (minutes < 60) return { label: `Due in ${minutes}m`, color: 'warning' };
const hours = Math.floor(minutes / 60);
if (hours < 24) return { label: `Due in ${hours}h`, color: hours < 4 ? 'warning' : 'success' };
return { label: `Due in ${Math.floor(hours / 24)}d`, color: 'success' };
};
const computeSla = (
row: Pick<WorklistRow, 'type' | 'lastContactedAt' | 'createdAt'>,
): { label: string; color: 'success' | 'warning' | 'error' } => {
if (row.type === 'follow-up' || row.type === 'callback') {
// scheduledAt was written into lastContactedAt during row construction.
return computeScheduledSla(row.lastContactedAt ?? row.createdAt);
}
return computeReactiveSla(row.lastContactedAt ?? row.createdAt);
};
const formatTimeAgo = (dateStr: string): string => {
const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000);
if (minutes < 1) return 'Just now';
@@ -150,13 +183,13 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
for (const call of missedCalls) {
const phone = call.callerNumber?.[0];
const countBadge = call.missedcallcount && call.missedcallcount > 1 ? ` (${call.missedcallcount}x)` : '';
const sourceSuffix = call.callsourcenumber ? `${call.callsourcenumber}` : '';
const countBadge = call.missedCallCount && call.missedCallCount > 1 ? ` (${call.missedCallCount}x)` : '';
const sourceSuffix = call.callSourceNumber ? `${call.callSourceNumber}` : '';
rows.push({
id: `mc-${call.id}`,
type: 'missed',
priority: 'HIGH',
name: (phone ? formatPhone(phone) : 'Unknown') + countBadge,
name: (call.leadName || (phone ? formatPhone(phone) : 'Unknown')) + countBadge,
phone: phone ? formatPhone(phone) : '',
phoneRaw: phone?.number ?? '',
direction: call.callDirection === 'OUTBOUND' ? 'outbound' : 'inbound',
@@ -165,12 +198,12 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
? `Missed at ${formatTimeOnly(call.startedAt)}${sourceSuffix}`
: 'Missed call',
createdAt: call.createdAt,
taskState: call.callbackstatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING',
taskState: call.callbackStatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING',
leadId: call.leadId,
originalLead: null,
lastContactedAt: call.callbackattemptedat ?? call.startedAt ?? call.createdAt,
lastContactedAt: call.callbackAttemptedAt ?? call.startedAt ?? call.createdAt,
contactAttempts: 0,
source: call.callsourcenumber ?? null,
source: call.callSourceNumber ?? null,
lastDisposition: call.disposition ?? null,
missedCallId: call.id,
});
@@ -179,13 +212,20 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
for (const fu of followUps) {
const isOverdue = fu.followUpStatus === 'OVERDUE' || (fu.scheduledAt !== null && new Date(fu.scheduledAt) < new Date());
const label = followUpLabel[fu.followUpType ?? ''] ?? fu.followUpType ?? 'Follow-up';
// Sidecar enriches follow-ups with patient name/phone when a
// patientId is linked. Fall back to the generic type label when
// no patient is attached.
const displayName = fu.patientName?.trim() || label;
const phoneFormatted = fu.patientPhone
? formatPhone({ number: fu.patientPhone, callingCode: '+91' })
: '';
rows.push({
id: `fu-${fu.id}`,
type: fu.followUpType === 'CALLBACK' ? 'callback' : 'follow-up',
priority: (fu.priority as WorklistRow['priority']) ?? (isOverdue ? 'HIGH' : 'NORMAL'),
name: label,
phone: '',
phoneRaw: '',
name: displayName,
phone: phoneFormatted,
phoneRaw: fu.patientPhone ?? '',
direction: null,
typeLabel: fu.followUpType === 'CALLBACK' ? 'Callback' : 'Follow-up',
reason: fu.scheduledAt
@@ -230,8 +270,9 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
});
}
// Remove rows without a phone number — agent can't act on them
const actionableRows = rows.filter(r => r.phoneRaw);
// Keep all rows — follow-ups may have no phone and still need to be visible.
// The PhoneActionCell renders a "No phone" placeholder when phoneRaw is empty.
const actionableRows = rows;
// Sort by rules engine score if available, otherwise by priority + createdAt
actionableRows.sort((a, b) => {
@@ -249,13 +290,15 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
const [tab, setTab] = useState<TabKey>('all');
const [search, setSearch] = useState('');
const [missedSubTab, setMissedSubTab] = useState<MissedSubTab>('pending');
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'sla', direction: 'descending' });
// Default SLA sort is ascending — the bucket-sorted result puts the
// most-urgent rows at the top (overdue → oldest reactive → soonest due).
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'sla', direction: 'ascending' });
const missedByStatus = useMemo(() => ({
pending: missedCalls.filter(c => c.callbackstatus === 'PENDING_CALLBACK' || !c.callbackstatus),
attempted: missedCalls.filter(c => c.callbackstatus === 'CALLBACK_ATTEMPTED'),
completed: missedCalls.filter(c => c.callbackstatus === 'CALLBACK_COMPLETED' || c.callbackstatus === 'WRONG_NUMBER'),
invalid: missedCalls.filter(c => c.callbackstatus === 'INVALID'),
pending: missedCalls.filter(c => c.callbackStatus === 'PENDING_CALLBACK' || !c.callbackStatus),
attempted: missedCalls.filter(c => c.callbackStatus === 'CALLBACK_ATTEMPTED'),
completed: missedCalls.filter(c => c.callbackStatus === 'CALLBACK_COMPLETED' || c.callbackStatus === 'WRONG_NUMBER'),
invalid: missedCalls.filter(c => c.callbackStatus === 'INVALID'),
}), [missedCalls]);
const allRows = useMemo(
@@ -273,7 +316,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
let rows = allRows;
if (tab === 'missed') rows = missedSubTabRows;
else if (tab === 'leads') rows = rows.filter((r) => r.type === 'lead');
else if (tab === 'follow-ups') rows = rows.filter((r) => r.type === 'follow-up');
else if (tab === 'follow-ups') rows = rows.filter((r) => r.type === 'follow-up' || r.type === 'callback');
if (search.trim()) {
const q = search.toLowerCase();
@@ -295,8 +338,27 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
case 'name':
return a.name.localeCompare(b.name) * dir;
case 'sla': {
// Mixed SLA sort: SLA means different things by row type
// (elapsed for reactive, remaining for scheduled). Bucket
// rows by urgency, then sort within bucket — Overdue
// first, then reactive (oldest-first), then scheduled
// (soonest-due first). `dir` flips the whole ordering
// so the user can still toggle ascending/descending.
const urgencyBucket = (row: WorklistRow): number => {
const isScheduled = row.type === 'follow-up' || row.type === 'callback';
if (isScheduled) {
const t = new Date(row.lastContactedAt ?? row.createdAt).getTime();
return t < Date.now() ? 0 : 2; // 0 = overdue, 2 = upcoming
}
return 1; // reactive (missed / lead)
};
const ba = urgencyBucket(a);
const bb = urgencyBucket(b);
if (ba !== bb) return (ba - bb) * dir;
const ta = new Date(a.lastContactedAt ?? a.createdAt).getTime();
const tb = new Date(b.lastContactedAt ?? b.createdAt).getTime();
// Within a bucket, ascending time = most urgent first
// (oldest overdue, oldest reactive, soonest upcoming).
return (ta - tb) * dir;
}
default:
@@ -310,7 +372,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
const missedCount = allRows.filter((r) => r.type === 'missed').length;
const leadCount = allRows.filter((r) => r.type === 'lead').length;
const followUpCount = allRows.filter((r) => r.type === 'follow-up').length;
const followUpCount = allRows.filter((r) => r.type === 'follow-up' || r.type === 'callback').length;
// Notification for new missed calls
const prevMissedCount = useRef(missedCount);
@@ -380,7 +442,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
{/* Missed call status sub-tabs */}
{tab === 'missed' && (
<div className="flex shrink-0 gap-1 px-5 py-2 border-b border-secondary">
{(['pending', 'attempted', 'completed', 'invalid'] as MissedSubTab[]).map(sub => (
{(['pending', 'attempted'] as MissedSubTab[]).map(sub => (
<button
key={sub}
onClick={() => { setMissedSubTab(sub); setPage(1); }}
@@ -421,7 +483,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
<Table.Body items={pagedRows}>
{(row) => {
const priority = priorityConfig[row.priority] ?? priorityConfig.NORMAL;
const sla = computeSla(row.lastContactedAt ?? row.createdAt);
const sla = computeSla(row);
const isSelected = row.originalLead !== null && row.originalLead.id === selectedLeadId;
// Sub-line: last interaction context

View File

@@ -280,7 +280,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
<div>
<h3 className="text-lg font-semibold text-primary">Sign out?</h3>
<p className="mt-1 text-sm text-tertiary">
You will be logged out of Helix Engage and your Ozonetel agent session will end. Any active calls will be disconnected.
You will be logged out of Helix Engage and your telephony account. Any active calls will be disconnected.
</p>
</div>
<div className="flex w-full gap-3">

View File

@@ -52,7 +52,7 @@ export const useAgentState = (agentId: string | null): { state: OzonetelState; s
localStorage.removeItem('helix_agent_config');
localStorage.removeItem('helix_user');
import('@/state/sip-manager').then(({ disconnectSip }) => disconnectSip()).catch(() => {});
import('@/state/sip-manager').then(({ disconnectSip }) => disconnectSip(false, 'agent-state-offline')).catch(() => {});
setTimeout(() => { window.location.href = '/login'; }, 1500);
return;

View File

@@ -47,7 +47,7 @@ export const usePerformanceAlerts = () => {
let idx = 0;
for (const agent of teamPerf.agents) {
const agentCalls = calls.filter(c => c.agentName === agent.name || c.agentName === agent.ozonetelagentid);
const agentCalls = calls.filter(c => c.agentName === agent.name || c.agentName === agent.ozonetelAgentId);
const totalCalls = agentCalls.length;
const agentAppts = agentCalls.filter((c: any) => c.disposition === 'APPOINTMENT_BOOKED').length;
const convPercent = totalCalls > 0 ? Math.round((agentAppts / totalCalls) * 100) : 0;
@@ -55,14 +55,14 @@ export const usePerformanceAlerts = () => {
const tb = agent.timeBreakdown;
const idleMinutes = tb ? Math.round(parseTime(tb.totalIdleTime ?? '0:0:0') / 60) : 0;
if (agent.maxidleminutes && idleMinutes > agent.maxidleminutes) {
list.push({ id: `idle-${idx++}`, agent: agent.name ?? agent.ozonetelagentid, type: 'Excessive Idle Time', value: `${idleMinutes}m`, severity: 'error', dismissed: false });
if (agent.maxIdleMinutes && idleMinutes > agent.maxIdleMinutes) {
list.push({ id: `idle-${idx++}`, agent: agent.name ?? agent.ozonetelAgentId, type: 'Excessive Idle Time', value: `${idleMinutes}m`, severity: 'error', dismissed: false });
}
if (agent.minnpsthreshold && (agent.npsscore ?? 100) < agent.minnpsthreshold) {
list.push({ id: `nps-${idx++}`, agent: agent.name ?? agent.ozonetelagentid, type: 'Low NPS', value: String(agent.npsscore ?? 0), severity: 'warning', dismissed: false });
if (agent.minNpsThreshold && (agent.npsScore ?? 100) < agent.minNpsThreshold) {
list.push({ id: `nps-${idx++}`, agent: agent.name ?? agent.ozonetelAgentId, type: 'Low NPS', value: String(agent.npsScore ?? 0), severity: 'warning', dismissed: false });
}
if (agent.minconversionpercent && convPercent < agent.minconversionpercent) {
list.push({ id: `conv-${idx++}`, agent: agent.name ?? agent.ozonetelagentid, type: 'Low Conversion', value: `${convPercent}%`, severity: 'warning', dismissed: false });
if (agent.minConversionPercent && convPercent < agent.minConversionPercent) {
list.push({ id: `conv-${idx++}`, agent: agent.name ?? agent.ozonetelAgentId, type: 'Low Conversion', value: `${convPercent}%`, severity: 'warning', dismissed: false });
}
}

View File

@@ -15,10 +15,11 @@ type MissedCall = {
disposition: string | null;
callNotes: string | null;
leadId: string | null;
callbackstatus: string | null;
callsourcenumber: string | null;
missedcallcount: number | null;
callbackattemptedat: string | null;
leadName: string | null;
callbackStatus: string | null;
callSourceNumber: string | null;
missedCallCount: number | null;
callbackAttemptedAt: string | null;
};
type WorklistFollowUp = {
@@ -32,6 +33,8 @@ type WorklistFollowUp = {
assignedAgent: string | null;
patientId: string | null;
callId: string | null;
patientName?: string;
patientPhone?: string;
};
type WorklistLead = {

View File

@@ -7,6 +7,10 @@ export class SIPClient {
private ua: JsSIP.UA | null = null;
private currentSession: RTCSession | null = null;
private audioElement: HTMLAudioElement | null = null;
// Watchdog that alerts if REGISTER never completes after a connect.
// Cleared on 'registered' / 'registrationFailed' / disconnect.
private registrationWatchdog: number | null = null;
private readonly REGISTRATION_TIMEOUT_MS = 15_000;
constructor(
private config: SIPConfig,
@@ -36,28 +40,43 @@ export class SIPClient {
this.ua = new JsSIP.UA(configuration);
console.log(`[SIP] start() uri=${this.config.uri} ws=${this.config.wsServer} expires=${configuration.register_expires}s`);
this.ua.on('connecting', () => {
console.log('[SIP] WebSocket connecting…');
});
this.ua.on('connected', () => {
console.log('[SIP] WebSocket connected');
console.log('[SIP] WebSocket connected — waiting for REGISTER');
this.onConnectionChange('connected');
});
this.ua.on('disconnected', () => {
console.log('[SIP] WebSocket disconnected');
this.ua.on('disconnected', (e: any) => {
const code = e?.code ?? 'n/a';
const reason = e?.reason ?? 'unknown';
console.log(`[SIP] WebSocket disconnected — code=${code} reason=${reason}`);
this.clearRegistrationWatchdog();
this.onConnectionChange('disconnected');
});
this.ua.on('registered', () => {
console.log('[SIP] Registered successfully');
this.clearRegistrationWatchdog();
this.onConnectionChange('registered');
});
this.ua.on('unregistered', () => {
console.log('[SIP] Unregistered');
this.clearRegistrationWatchdog();
this.onConnectionChange('disconnected');
});
this.ua.on('registrationFailed', () => {
console.error('[SIP] Registration failed');
this.ua.on('registrationFailed', (e: any) => {
const cause = e?.cause ?? 'unknown';
const statusCode = e?.response?.status_code ?? 'n/a';
const reasonPhrase = e?.response?.reason_phrase ?? '';
console.error(`[SIP] Registration failed — cause=${cause} status=${statusCode} ${reasonPhrase}`);
this.clearRegistrationWatchdog();
this.onConnectionChange('error');
});
@@ -125,9 +144,25 @@ export class SIPClient {
});
this.ua.start();
// Arm the registration watchdog. If we don't hear 'registered' or
// 'registrationFailed' within the timeout, surface a visible error so
// the user isn't left staring at "Connecting to telephony…" forever.
this.registrationWatchdog = window.setTimeout(() => {
console.error(`[SIP] Registration timeout — no REGISTER response after ${this.REGISTRATION_TIMEOUT_MS}ms. Check SIP credentials / WebSocket reachability.`);
this.onConnectionChange('error');
}, this.REGISTRATION_TIMEOUT_MS);
}
private clearRegistrationWatchdog(): void {
if (this.registrationWatchdog !== null) {
window.clearTimeout(this.registrationWatchdog);
this.registrationWatchdog = null;
}
}
disconnect(): void {
this.clearRegistrationWatchdog();
this.hangup();
if (this.ua) {
this.ua.stop();

View File

@@ -65,10 +65,13 @@ const formatPhoneDisplay = (call: Call): string => {
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
APPOINTMENT_BOOKED: { label: 'Appt Booked', color: 'success' },
APPOINTMENT_RESCHEDULED: { label: 'Appt Rescheduled', color: 'warning' },
APPOINTMENT_CANCELLED: { label: 'Appt Cancelled', color: 'error' },
FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' },
INFO_PROVIDED: { label: 'Info Provided', color: 'blue-light' },
NO_ANSWER: { label: 'No Answer', color: 'warning' },
WRONG_NUMBER: { label: 'Wrong Number', color: 'gray' },
NOT_INTERESTED: { label: 'Not Interested', color: 'error' },
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
};

View File

@@ -1,6 +1,6 @@
import type { FC } from 'react';
import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router';
import { useSearchParams, useNavigate } from 'react-router';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowLeft, faArrowDownToLine, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
@@ -38,6 +38,7 @@ const PAGE_SIZE = 15;
export const AllLeadsPage = () => {
const { user } = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const initialSource = searchParams.get('source') as LeadSource | null;
const [tab, setTab] = useState<TabKey>('new');
@@ -231,11 +232,11 @@ export const AllLeadsPage = () => {
<div className="flex shrink-0 items-center justify-between px-6 py-3 border-b border-secondary">
<div className="flex items-center gap-3">
<Button
href="/"
onClick={() => navigate(-1)}
color="secondary"
size="sm"
iconLeading={ArrowLeft}
aria-label="Back to workspace"
aria-label="Back"
/>
<Tabs selectedKey={tab} onSelectionChange={handleTabChange}>

View File

@@ -217,7 +217,7 @@ export const AppointmentsPage = () => {
<span className="text-xs text-tertiary">{appt.department ?? '—'}</span>
</Table.Cell>
<Table.Cell>
<span className="text-xs text-tertiary truncate block max-w-[130px]">{branch}</span>
<span className="text-xs text-tertiary truncate block max-w-[180px]" title={branch}>{branch}</span>
</Table.Cell>
<Table.Cell>
<Badge size="sm" color={statusColor} type="pill-color">

View File

@@ -35,10 +35,13 @@ const filterItems = [
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
APPOINTMENT_BOOKED: { label: 'Appt Booked', color: 'success' },
APPOINTMENT_RESCHEDULED: { label: 'Appt Rescheduled', color: 'warning' },
APPOINTMENT_CANCELLED: { label: 'Appt Cancelled', color: 'error' },
FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' },
INFO_PROVIDED: { label: 'Info Provided', color: 'blue-light' },
NO_ANSWER: { label: 'No Answer', color: 'warning' },
WRONG_NUMBER: { label: 'Wrong Number', color: 'gray' },
NOT_INTERESTED: { label: 'Not Interested', color: 'error' },
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
};
@@ -139,8 +142,9 @@ export const CallHistoryPage = () => {
return dateB - dateA;
});
// Direction / status filter
if (filter === 'inbound') result = result.filter((c) => c.callDirection === 'INBOUND');
// Direction / status filter. "Inbound" shows answered inbound only — missed
// calls have their own dedicated filter so they don't double-appear.
if (filter === 'inbound') result = result.filter((c) => c.callDirection === 'INBOUND' && c.callStatus !== 'MISSED');
else if (filter === 'outbound') result = result.filter((c) => c.callDirection === 'OUTBOUND');
else if (filter === 'missed') result = result.filter((c) => c.callStatus === 'MISSED');

View File

@@ -22,10 +22,10 @@ type MissedCallRecord = {
callerNumber: { primaryPhoneNumber: string } | null;
agentName: string | null;
startedAt: string | null;
callsourcenumber: string | null;
callbackstatus: string | null;
missedcallcount: number | null;
callbackattemptedat: string | null;
callSourceNumber: string | null;
callbackStatus: string | null;
missedCallCount: number | null;
callbackAttemptedAt: string | null;
sla: number | null;
};
@@ -35,7 +35,7 @@ const QUERY = `{ calls(first: 200, filter: {
callStatus: { eq: MISSED }
}, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id callerNumber { primaryPhoneNumber } agentName
startedAt callsourcenumber callbackstatus missedcallcount callbackattemptedat sla
startedAt callSourceNumber callbackStatus missedCallCount callbackAttemptedAt sla
} } } }`;
const PAGE_SIZE = 15;
@@ -92,7 +92,7 @@ export const MissedCallsPage = () => {
const statusCounts = useMemo(() => {
const counts: Record<string, number> = {};
for (const c of calls) {
const s = c.callbackstatus ?? 'PENDING_CALLBACK';
const s = c.callbackStatus ?? 'PENDING_CALLBACK';
counts[s] = (counts[s] ?? 0) + 1;
}
return counts;
@@ -100,16 +100,16 @@ export const MissedCallsPage = () => {
const filtered = useMemo(() => {
let rows = calls;
if (tab === 'PENDING_CALLBACK') rows = rows.filter(c => c.callbackstatus === 'PENDING_CALLBACK' || !c.callbackstatus);
else if (tab === 'CALLBACK_ATTEMPTED') rows = rows.filter(c => c.callbackstatus === 'CALLBACK_ATTEMPTED');
else if (tab === 'CALLBACK_COMPLETED') rows = rows.filter(c => c.callbackstatus === 'CALLBACK_COMPLETED' || c.callbackstatus === 'WRONG_NUMBER');
if (tab === 'PENDING_CALLBACK') rows = rows.filter(c => c.callbackStatus === 'PENDING_CALLBACK' || !c.callbackStatus);
else if (tab === 'CALLBACK_ATTEMPTED') rows = rows.filter(c => c.callbackStatus === 'CALLBACK_ATTEMPTED');
else if (tab === 'CALLBACK_COMPLETED') rows = rows.filter(c => c.callbackStatus === 'CALLBACK_COMPLETED' || c.callbackStatus === 'WRONG_NUMBER');
if (search.trim()) {
const q = search.toLowerCase();
rows = rows.filter(c =>
(c.callerNumber?.primaryPhoneNumber ?? '').includes(q) ||
(c.agentName ?? '').toLowerCase().includes(q) ||
(c.callsourcenumber ?? '').toLowerCase().includes(q),
(c.callSourceNumber ?? '').toLowerCase().includes(q),
);
}
@@ -122,7 +122,7 @@ export const MissedCallsPage = () => {
const tb = b.startedAt ? new Date(b.startedAt).getTime() : 0;
return (ta - tb) * dir;
}
case 'count': return ((a.missedcallcount ?? 1) - (b.missedcallcount ?? 1)) * dir;
case 'count': return ((a.missedCallCount ?? 1) - (b.missedCallCount ?? 1)) * dir;
case 'sla': return ((a.sla ?? 0) - (b.sla ?? 0)) * dir;
case 'agent': return (a.agentName ?? '').localeCompare(b.agentName ?? '') * dir;
default: return 0;
@@ -190,7 +190,7 @@ export const MissedCallsPage = () => {
<Table.Body items={pagedRows}>
{(call) => {
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
const status = call.callbackstatus ?? 'PENDING_CALLBACK';
const status = call.callbackStatus ?? 'PENDING_CALLBACK';
return (
<Table.Row id={call.id}>
@@ -213,7 +213,7 @@ export const MissedCallsPage = () => {
)}
{visibleColumns.has('branch') && (
<Table.Cell>
<span className="text-xs text-tertiary">{call.callsourcenumber || '—'}</span>
<span className="text-xs text-tertiary">{call.callSourceNumber || '—'}</span>
</Table.Cell>
)}
{visibleColumns.has('agent') && (
@@ -223,8 +223,8 @@ export const MissedCallsPage = () => {
)}
{visibleColumns.has('count') && (
<Table.Cell>
{call.missedcallcount && call.missedcallcount > 1 ? (
<Badge size="sm" color="warning" type="pill-color">{call.missedcallcount}x</Badge>
{call.missedCallCount && call.missedCallCount > 1 ? (
<Badge size="sm" color="warning" type="pill-color">{call.missedCallCount}x</Badge>
) : <span className="text-xs text-quaternary">1</span>}
</Table.Cell>
)}
@@ -256,10 +256,10 @@ export const MissedCallsPage = () => {
)}
{visibleColumns.has('callback') && (
<Table.Cell>
{call.callbackattemptedat ? (
{call.callbackAttemptedAt ? (
<div>
<span className="text-sm text-primary">{formatDateOrdinal(call.callbackattemptedat)}</span>
<span className="block text-xs text-tertiary">{formatTimeOnly(call.callbackattemptedat)}</span>
<span className="text-sm text-primary">{formatDateOrdinal(call.callbackAttemptedAt)}</span>
<span className="block text-xs text-tertiary">{formatTimeOnly(call.callbackAttemptedAt)}</span>
</div>
) : <span className="text-xs text-quaternary"></span>}
</Table.Cell>

View File

@@ -51,10 +51,13 @@ const DEFAULT_CONFIG: ActivityConfig = { icon: '📌', dotClass: 'bg-tertiary',
const DISPOSITION_COLORS: Record<CallDisposition, 'success' | 'brand' | 'blue' | 'error' | 'warning' | 'gray'> = {
APPOINTMENT_BOOKED: 'success',
APPOINTMENT_RESCHEDULED: 'warning',
APPOINTMENT_CANCELLED: 'error',
FOLLOW_UP_SCHEDULED: 'brand',
INFO_PROVIDED: 'blue',
WRONG_NUMBER: 'error',
NO_ANSWER: 'warning',
NOT_INTERESTED: 'error',
CALLBACK_REQUESTED: 'gray',
};

View File

@@ -11,7 +11,7 @@ import { Badge } from '@/components/base/badges/badges';
import { Input } from '@/components/base/input/input';
import { Table, TableCard } from '@/components/application/table/table';
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
import { TopBar } from '@/components/layout/top-bar';
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
import { PatientProfilePanel } from '@/components/shared/patient-profile-panel';
import { useData } from '@/providers/data-provider';
@@ -86,8 +86,6 @@ export const PatientsPage = () => {
return (
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Patients" subtitle={`${filteredPatients.length} patients`} />
<div className="flex flex-1 overflow-hidden">
<div className="flex flex-1 flex-col overflow-y-auto p-7">
<TableCard.Root size="sm">
@@ -141,7 +139,7 @@ export const PatientsPage = () => {
<Table.Head label="AGE" />
<Table.Head label="ACTIONS" />
</Table.Header>
<Table.Body items={pagedPatients}>
<Table.Body items={pagedPatients} dependencies={[selectedPatient?.id]}>
{(patient) => {
const displayName = getPatientDisplayName(patient);
const age = computeAge(patient.dateOfBirth);

View File

@@ -33,11 +33,11 @@ const parseTime = (timeStr: string): number => {
type AgentPerf = {
name: string;
ozonetelagentid: string;
npsscore: number | null;
maxidleminutes: number | null;
minnpsthreshold: number | null;
minconversionpercent: number | null;
ozonetelAgentId: string;
npsScore: number | null;
maxIdleMinutes: number | null;
minNpsThreshold: number | null;
minConversionPercent: number | null;
calls: number;
inbound: number;
missed: number;
@@ -112,7 +112,7 @@ export const TeamPerformancePage = () => {
if (teamAgents.length > 0) {
// Real Ozonetel data available
agentPerfs = teamAgents.map((agent: any) => {
const agentCalls = calls.filter((c: any) => c.agentName === agent.name || c.agentName === agent.ozonetelagentid);
const agentCalls = calls.filter((c: any) => c.agentName === agent.name || c.agentName === agent.ozonetelAgentId);
const agentLeads = leads.filter((l: any) => l.assignedAgent === agent.name);
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === agent.name);
const agentAppts = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
@@ -127,12 +127,12 @@ export const TeamPerformancePage = () => {
const breakSec = tb ? parseTime(tb.totalPauseTime ?? '0:0:0') : 0;
return {
name: agent.name ?? agent.ozonetelagentid,
ozonetelagentid: agent.ozonetelagentid,
npsscore: agent.npsscore,
maxidleminutes: agent.maxidleminutes,
minnpsthreshold: agent.minnpsthreshold,
minconversionpercent: agent.minconversionpercent,
name: agent.name ?? agent.ozonetelAgentId,
ozonetelAgentId: agent.ozonetelAgentId,
npsScore: agent.npsScore,
maxIdleMinutes: agent.maxIdleMinutes,
minNpsThreshold: agent.minNpsThreshold,
minConversionPercent: agent.minConversionPercent,
calls: totalCalls,
inbound,
missed,
@@ -159,11 +159,11 @@ export const TeamPerformancePage = () => {
return {
name,
ozonetelagentid: name,
npsscore: null,
maxidleminutes: null,
minnpsthreshold: null,
minconversionpercent: null,
ozonetelAgentId: name,
npsScore: null,
maxIdleMinutes: null,
minNpsThreshold: null,
minConversionPercent: null,
calls: totalCalls,
inbound: agentCalls.filter((c: any) => c.direction === 'INBOUND').length,
missed: agentCalls.filter((c: any) => c.callStatus === 'MISSED').length,
@@ -223,9 +223,9 @@ export const TeamPerformancePage = () => {
// NPS
const avgNps = useMemo(() => {
const withNps = agents.filter(a => a.npsscore != null);
const withNps = agents.filter(a => a.npsScore != null);
if (withNps.length === 0) return 0;
return Math.round(withNps.reduce((sum, a) => sum + (a.npsscore ?? 0), 0) / withNps.length);
return Math.round(withNps.reduce((sum, a) => sum + (a.npsScore ?? 0), 0) / withNps.length);
}, [agents]);
const npsOption = useMemo(() => ({
@@ -246,13 +246,13 @@ export const TeamPerformancePage = () => {
const alerts = useMemo(() => {
const list: { agent: string; type: string; value: string; severity: 'error' | 'warning' }[] = [];
for (const a of agents) {
if (a.maxidleminutes && a.idleMinutes > a.maxidleminutes) {
if (a.maxIdleMinutes && a.idleMinutes > a.maxIdleMinutes) {
list.push({ agent: a.name, type: 'Excessive Idle Time', value: `${a.idleMinutes}m`, severity: 'error' });
}
if (a.minnpsthreshold && (a.npsscore ?? 100) < a.minnpsthreshold) {
list.push({ agent: a.name, type: 'Low NPS', value: String(a.npsscore ?? 0), severity: 'warning' });
if (a.minNpsThreshold && (a.npsScore ?? 100) < a.minNpsThreshold) {
list.push({ agent: a.name, type: 'Low NPS', value: String(a.npsScore ?? 0), severity: 'warning' });
}
if (a.minconversionpercent && a.convPercent < a.minconversionpercent) {
if (a.minConversionPercent && a.convPercent < a.minConversionPercent) {
list.push({ agent: a.name, type: 'Low Conversion', value: `${a.convPercent}%`, severity: 'warning' });
}
}
@@ -332,7 +332,7 @@ export const TeamPerformancePage = () => {
</Table.Header>
<Table.Body items={agents}>
{(agent) => (
<Table.Row id={agent.ozonetelagentid || agent.name}>
<Table.Row id={agent.ozonetelAgentId || agent.name}>
<Table.Cell><span className="text-sm font-medium text-primary">{agent.name}</span></Table.Cell>
<Table.Cell><span className="text-sm text-primary">{agent.calls}</span></Table.Cell>
<Table.Cell><span className="text-sm text-primary">{agent.inbound}</span></Table.Cell>
@@ -345,12 +345,12 @@ export const TeamPerformancePage = () => {
</span>
</Table.Cell>
<Table.Cell>
<span className={cx('text-sm font-bold', (agent.npsscore ?? 0) >= 70 ? 'text-success-primary' : (agent.npsscore ?? 0) >= 50 ? 'text-warning-primary' : 'text-error-primary')}>
{agent.npsscore ?? '—'}
<span className={cx('text-sm font-bold', (agent.npsScore ?? 0) >= 70 ? 'text-success-primary' : (agent.npsScore ?? 0) >= 50 ? 'text-warning-primary' : 'text-error-primary')}>
{agent.npsScore ?? '—'}
</span>
</Table.Cell>
<Table.Cell>
<span className={cx('text-sm', agent.maxidleminutes && agent.idleMinutes > agent.maxidleminutes ? 'text-error-primary font-bold' : 'text-primary')}>
<span className={cx('text-sm', agent.maxIdleMinutes && agent.idleMinutes > agent.maxIdleMinutes ? 'text-error-primary font-bold' : 'text-primary')}>
{agent.idleMinutes}m
</span>
</Table.Cell>
@@ -389,7 +389,7 @@ export const TeamPerformancePage = () => {
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{agents.map(agent => {
const total = agent.activeMinutes + agent.wrapMinutes + agent.idleMinutes + agent.breakMinutes || 1;
const isHighIdle = agent.maxidleminutes && agent.idleMinutes > agent.maxidleminutes;
const isHighIdle = agent.maxIdleMinutes && agent.idleMinutes > agent.maxIdleMinutes;
return (
<div key={agent.name} className={cx('rounded-lg border p-3', isHighIdle ? 'border-error bg-error-secondary' : 'border-secondary')}>
<p className="text-xs font-semibold text-primary mb-2">{agent.name}</p>
@@ -417,7 +417,7 @@ export const TeamPerformancePage = () => {
<div className="flex gap-4">
<div className="flex-1 rounded-xl border border-secondary bg-primary p-4">
<h3 className="text-sm font-semibold text-secondary mb-2">Overall NPS</h3>
{agents.every(a => a.npsscore == null) ? (
{agents.every(a => a.npsScore == null) ? (
<div className="flex items-center justify-center py-8">
<p className="text-xs text-tertiary">NPS data unavailable configure NPS scores on agent profiles.</p>
</div>
@@ -425,13 +425,13 @@ export const TeamPerformancePage = () => {
<>
<ReactECharts option={npsOption} style={{ height: 150 }} />
<div className="space-y-1 mt-2">
{agents.filter(a => a.npsscore != null).map(a => (
{agents.filter(a => a.npsScore != null).map(a => (
<div key={a.name} className="flex items-center gap-2">
<span className="text-xs text-secondary w-28 truncate">{a.name}</span>
<div className="flex-1 h-2 rounded-full bg-tertiary overflow-hidden">
<div className={cx('h-full rounded-full', (a.npsscore ?? 0) >= 70 ? 'bg-success-solid' : (a.npsscore ?? 0) >= 50 ? 'bg-warning-solid' : 'bg-error-solid')} style={{ width: `${a.npsscore ?? 0}%` }} />
<div className={cx('h-full rounded-full', (a.npsScore ?? 0) >= 70 ? 'bg-success-solid' : (a.npsScore ?? 0) >= 50 ? 'bg-warning-solid' : 'bg-error-solid')} style={{ width: `${a.npsScore ?? 0}%` }} />
</div>
<span className="text-xs font-bold text-primary w-8 text-right">{a.npsscore}</span>
<span className="text-xs font-bold text-primary w-8 text-right">{a.npsScore}</span>
</div>
))}
</div>

View File

@@ -108,7 +108,7 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
// Disconnect SIP before logout
try {
disconnectSip(true);
disconnectSip(true, 'logout');
} catch {}
// Notify sidecar to unlock Redis session + Ozonetel logout — await before clearing tokens

View File

@@ -116,6 +116,12 @@ export const DataProvider = ({ children }: DataProviderProps) => {
useEffect(() => {
fetchData();
// Poll every 30 seconds for fresh data (calls, leads, appointments)
const interval = setInterval(() => {
console.log('[DATA-PROVIDER] Polling for fresh data');
fetchData();
}, 30_000);
return () => clearInterval(interval);
}, [fetchData]);
const updateLead = (id: string, updates: Partial<Lead>) => {

View File

@@ -12,6 +12,7 @@ import {
} from '@/state/sip-state';
import { registerSipStateUpdater, connectSip, disconnectSip, getSipClient, setOutboundPending } from '@/state/sip-manager';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import type { SIPConfig } from '@/types/sip';
// SIP config comes exclusively from the Agent entity (stored on login).
@@ -125,14 +126,14 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
}
};
const handleUnload = () => disconnectSip(true);
const handleUnload = () => disconnectSip(true, 'page-unload');
window.addEventListener('beforeunload', handleBeforeUnload);
window.addEventListener('unload', handleUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
window.removeEventListener('unload', handleUnload);
disconnectSip(true); // force — component is unmounting
disconnectSip(true, 'sip-provider-unmount'); // force — component is unmounting
};
}, []); // empty deps — runs once on mount, cleanup only on unmount
@@ -156,6 +157,17 @@ export const useSip = () => {
// Ozonetel outbound dial — single path for all outbound calls
const dialOutbound = useCallback(async (phoneNumber: string): Promise<void> => {
// Hard guard — no dial is valid when SIP isn't registered, because
// the audio leg can't be established. Every entry point (worklist
// row, click-to-call, phone-action-cell, patient 360, etc.) funnels
// through this callback, so gating here is the single source of
// truth for "can this agent place a call right now?"
if (connectionStatus !== 'registered') {
notify.error('Telephony unavailable', 'Cannot place call — SIP is not registered. Check your connection.');
console.warn(`[DIAL] Blocked — SIP not registered (status=${connectionStatus})`);
return;
}
// Block outbound calls when agent is on Break or Training
const agentCfg = localStorage.getItem('helix_agent_config');
if (agentCfg) {
@@ -166,7 +178,6 @@ export const useSip = () => {
const stateRes = await fetch(`/api/supervisor/agent-state?agentId=${agentId}`);
const stateData = await stateRes.json();
if (stateData.state === 'break' || stateData.state === 'training') {
const { notify } = await import('@/lib/toast');
notify.info('Status: ' + stateData.state, 'Change status to Ready before placing calls');
return;
}
@@ -204,7 +215,7 @@ export const useSip = () => {
setCallerNumber(null);
throw new Error('Dial failed');
}
}, [setCallState, setCallerNumber, setCallUcid]);
}, [setCallState, setCallerNumber, setCallUcid, connectionStatus]);
const answer = useCallback(() => getSipClient()?.answer(), []);
const reject = useCallback(() => getSipClient()?.reject(), []);

View File

@@ -83,6 +83,7 @@ export function connectSip(config: SIPConfig): void {
if (ucid) stateUpdater?.setCallUcid(ucid);
if (state === 'ended' || state === 'failed') {
sipClient?.unmute(); // clear any mute state so it doesn't persist to next call
outboundActive = false;
outboundPending = false;
}
@@ -92,16 +93,16 @@ export function connectSip(config: SIPConfig): void {
sipClient.connect();
}
export function disconnectSip(force = false): void {
export function disconnectSip(force = false, reason = 'unspecified'): void {
// Guard: don't disconnect SIP during an active or pending call
// unless explicitly forced (e.g., logout, page unload).
// This prevents React re-render cycles from killing the
// SIP WebSocket mid-dial.
if (!force && (outboundPending || outboundActive)) {
console.log('[SIP-MGR] Disconnect blocked — call in progress');
console.log(`[SIP-MGR] Disconnect blocked — call in progress (reason=${reason})`);
return;
}
console.log(`[SIP] Disconnecting agent=${activeAgentId}` + (force ? ' (forced)' : ''));
console.log(`[SIP] Disconnecting agent=${activeAgentId} reason=${reason}` + (force ? ' (forced)' : ''));
sipClient?.disconnect();
sipClient = null;
connected = false;

View File

@@ -250,10 +250,13 @@ export type CallDirection = 'INBOUND' | 'OUTBOUND';
export type CallStatus = 'RINGING' | 'IN_PROGRESS' | 'COMPLETED' | 'MISSED' | 'VOICEMAIL';
export type CallDisposition =
| 'APPOINTMENT_BOOKED'
| 'APPOINTMENT_RESCHEDULED'
| 'APPOINTMENT_CANCELLED'
| 'FOLLOW_UP_SCHEDULED'
| 'INFO_PROVIDED'
| 'WRONG_NUMBER'
| 'NO_ANSWER'
| 'NOT_INTERESTED'
| 'CALLBACK_REQUESTED';
export type Call = {