mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
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:
@@ -12,6 +12,7 @@ import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom } from '@/state/
|
|||||||
import { setOutboundPending } from '@/state/sip-manager';
|
import { setOutboundPending } from '@/state/sip-manager';
|
||||||
import { useSip } from '@/providers/sip-provider';
|
import { useSip } from '@/providers/sip-provider';
|
||||||
import { DispositionModal } from './disposition-modal';
|
import { DispositionModal } from './disposition-modal';
|
||||||
|
import type { CallAction } from './disposition-modal';
|
||||||
import { AppointmentForm } from './appointment-form';
|
import { AppointmentForm } from './appointment-form';
|
||||||
import { TransferDialog } from './transfer-dialog';
|
import { TransferDialog } from './transfer-dialog';
|
||||||
import { EnquiryForm } from './enquiry-form';
|
import { EnquiryForm } from './enquiry-form';
|
||||||
@@ -48,7 +49,18 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
const [enquiryOpen, setEnquiryOpen] = useState(false);
|
const [enquiryOpen, setEnquiryOpen] = useState(false);
|
||||||
const [dispositionOpen, setDispositionOpen] = useState(false);
|
const [dispositionOpen, setDispositionOpen] = useState(false);
|
||||||
const [callerDisconnected, setCallerDisconnected] = 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 agentConfig = localStorage.getItem('helix_agent_config');
|
||||||
const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
|
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,
|
direction: callDirectionRef.current,
|
||||||
durationSec: callDuration,
|
durationSec: callDuration,
|
||||||
leadId: lead?.id ?? null,
|
leadId: lead?.id ?? null,
|
||||||
|
leadName: fullName || null,
|
||||||
notes,
|
notes,
|
||||||
missedCallId: missedCallId ?? undefined,
|
missedCallId: missedCallId ?? undefined,
|
||||||
};
|
};
|
||||||
@@ -115,24 +128,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
console.warn('[DISPOSE] No callUcid — skipping disposition');
|
console.warn('[DISPOSE] No callUcid — skipping disposition');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Side effects
|
// Follow-ups are created by the enquiry form (where the agent picks
|
||||||
if (disposition === 'FOLLOW_UP_SCHEDULED') {
|
// the date + context). No second creation here — that was causing
|
||||||
try {
|
// duplicate entries on every FOLLOW_UP_SCHEDULED call.
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear persisted UCID — disposition was submitted, no need for sendBeacon fallback
|
// Clear persisted UCID — disposition was submitted, no need for sendBeacon fallback
|
||||||
localStorage.removeItem('helix_active_ucid');
|
localStorage.removeItem('helix_active_ucid');
|
||||||
@@ -141,15 +139,24 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
handleReset();
|
handleReset();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAppointmentSaved = () => {
|
const handleAppointmentSaved = (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => {
|
||||||
setAppointmentOpen(false);
|
setAppointmentOpen(false);
|
||||||
setSuggestedDisposition('APPOINTMENT_BOOKED');
|
if (outcome === 'RESCHEDULED') {
|
||||||
notify.success('Appointment Booked', 'Payment link will be sent to the patient');
|
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 = () => {
|
const handleReset = () => {
|
||||||
setDispositionOpen(false);
|
setDispositionOpen(false);
|
||||||
setCallerDisconnected(false);
|
setCallerDisconnected(false);
|
||||||
|
setActionsTaken([]);
|
||||||
setCallState('idle');
|
setCallState('idle');
|
||||||
setCallerNumber(null);
|
setCallerNumber(null);
|
||||||
setCallUcid(null);
|
setCallUcid(null);
|
||||||
@@ -213,7 +220,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-secondary bg-primary p-4 text-center">
|
<div className="rounded-xl border border-secondary bg-primary p-4 text-center">
|
||||||
<FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" />
|
<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>
|
<p className="text-xs text-tertiary mt-1">{phoneDisplay} — not answered</p>
|
||||||
<Button size="sm" color="secondary" className="mt-3" onClick={handleReset}>
|
<Button size="sm" color="secondary" className="mt-3" onClick={handleReset}>
|
||||||
Back to Worklist
|
Back to Worklist
|
||||||
@@ -317,7 +324,10 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
onClose={() => setTransferOpen(false)}
|
onClose={() => setTransferOpen(false)}
|
||||||
onTransferred={() => {
|
onTransferred={() => {
|
||||||
setTransferOpen(false);
|
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);
|
setDispositionOpen(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -340,10 +350,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
leadId={lead?.id ?? null}
|
leadId={lead?.id ?? null}
|
||||||
patientId={(lead as any)?.patientId ?? null}
|
patientId={(lead as any)?.patientId ?? null}
|
||||||
agentName={user.name}
|
agentName={user.name}
|
||||||
onSaved={() => {
|
onSaved={(actions) => {
|
||||||
setEnquiryOpen(false);
|
setEnquiryOpen(false);
|
||||||
setSuggestedDisposition('INFO_PROVIDED');
|
addActions(...actions);
|
||||||
notify.success('Enquiry Logged');
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -355,7 +364,13 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
isOpen={dispositionOpen}
|
isOpen={dispositionOpen}
|
||||||
callerName={fullName || phoneDisplay}
|
callerName={fullName || phoneDisplay}
|
||||||
callerDisconnected={callerDisconnected}
|
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}
|
onSubmit={handleDisposition}
|
||||||
onDismiss={() => {
|
onDismiss={() => {
|
||||||
// Agent wants to continue the call — close modal, call stays active
|
// Agent wants to continue the call — close modal, call stays active
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
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 { useAgentState } from '@/hooks/use-agent-state';
|
||||||
import type { OzonetelState } from '@/hooks/use-agent-state';
|
import type { OzonetelState } from '@/hooks/use-agent-state';
|
||||||
import { apiClient } from '@/lib/api-client';
|
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));
|
console.log('[AGENT-STATE] Ready response:', JSON.stringify(res));
|
||||||
} else {
|
} else {
|
||||||
const pauseReason = newStatus === 'break' ? 'Break' : 'Training';
|
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}`);
|
console.log(`[AGENT-STATE] Changing to Pause: ${pauseReason}`);
|
||||||
const res = await apiClient.post('/api/ozonetel/agent-state', { agentId, state: 'Pause', pauseReason });
|
const res = await apiClient.post('/api/ozonetel/agent-state', { agentId, state: 'Pause', pauseReason });
|
||||||
console.log('[AGENT-STATE] Pause response:', JSON.stringify(res));
|
console.log('[AGENT-STATE] Pause response:', JSON.stringify(res));
|
||||||
@@ -89,13 +98,18 @@ export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatu
|
|||||||
disabled={changing || !canToggle}
|
disabled={changing || !canToggle}
|
||||||
className={cx(
|
className={cx(
|
||||||
'flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1 transition duration-100 ease-linear',
|
'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',
|
canToggle && !changing ? 'hover:bg-secondary_hover cursor-pointer' : 'cursor-default',
|
||||||
changing && 'opacity-50',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faCircle} className={cx('size-2', current.dotColor)} />
|
{changing ? (
|
||||||
<span className={cx('text-xs font-medium', current.color)}>{current.label}</span>
|
<FontAwesomeIcon icon={faSpinnerThird} spin className="size-2.5 text-fg-brand-primary" />
|
||||||
{canToggle && <FontAwesomeIcon icon={faChevronDown} className="size-2.5 text-fg-quaternary" />}
|
) : (
|
||||||
|
<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>
|
</button>
|
||||||
|
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ type AppointmentFormProps = {
|
|||||||
leadName?: string | null;
|
leadName?: string | null;
|
||||||
leadId?: string | null;
|
leadId?: string | null;
|
||||||
patientId?: 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;
|
existingAppointment?: ExistingAppointment | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -241,7 +244,9 @@ export const AppointmentForm = ({
|
|||||||
const selectedDoctor = doctors.find(d => d.id === doctor);
|
const selectedDoctor = doctors.find(d => d.id === doctor);
|
||||||
|
|
||||||
if (isEditMode && existingAppointment) {
|
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(
|
await apiClient.graphql(
|
||||||
`mutation UpdateAppointment($id: UUID!, $data: AppointmentUpdateInput!) {
|
`mutation UpdateAppointment($id: UUID!, $data: AppointmentUpdateInput!) {
|
||||||
updateAppointment(id: $id, data: $data) { id }
|
updateAppointment(id: $id, data: $data) { id }
|
||||||
@@ -254,9 +259,32 @@ export const AppointmentForm = ({
|
|||||||
department: selectedDoctor?.department ?? '',
|
department: selectedDoctor?.department ?? '',
|
||||||
doctorId: doctor,
|
doctorId: doctor,
|
||||||
reasonForVisit: chiefComplaint || null,
|
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');
|
notify.success('Appointment Updated');
|
||||||
} else {
|
} else {
|
||||||
// If no patient record exists yet (new caller), create one now
|
// 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 phoneDigits = callerNumber.replace(/\D/g, '').slice(-10);
|
||||||
const phoneE164 = `+91${phoneDigits}`;
|
const phoneE164 = `+91${phoneDigits}`;
|
||||||
try {
|
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 } }>(
|
const created = await apiClient.graphql<{ createPatient: { id: string } }>(
|
||||||
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
{ data: { fullName: nameParts, phones: { primaryPhoneNumber: phoneE164 }, patientType: 'NEW' } },
|
{ data: patientData },
|
||||||
);
|
);
|
||||||
resolvedPatientId = created.createPatient.id;
|
resolvedPatientId = created.createPatient.id;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -282,24 +317,26 @@ export const AppointmentForm = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create appointment
|
// 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(
|
await apiClient.graphql(
|
||||||
`mutation CreateAppointment($data: AppointmentCreateInput!) {
|
`mutation CreateAppointment($data: AppointmentCreateInput!) {
|
||||||
createAppointment(data: $data) { id }
|
createAppointment(data: $data) { id }
|
||||||
}`,
|
}`,
|
||||||
{
|
{ data: appointmentData },
|
||||||
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 } : {}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Determine whether the agent actually renamed the patient.
|
// Determine whether the agent actually renamed the patient.
|
||||||
@@ -309,11 +346,13 @@ export const AppointmentForm = ({
|
|||||||
const trimmedName = patientName.trim();
|
const trimmedName = patientName.trim();
|
||||||
const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName;
|
const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName;
|
||||||
|
|
||||||
// Update patient name only when it was empty (new caller with no name).
|
// Update patient name when the agent explicitly renamed.
|
||||||
// Don't overwrite an existing patient name — that would
|
// `nameChanged` already requires isNameEditable=true (the
|
||||||
// retroactively change the name on all past appointments.
|
// agent went through EditPatientConfirmModal), so the
|
||||||
// Bug #527: only set name on patients with no existing name.
|
// rename intent is unambiguous. Bug #527's silent-overwrite
|
||||||
if (nameChanged && patientId && initialLeadName.length === 0) {
|
// 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(' ') || '' };
|
const nameParts = { firstName: trimmedName.split(' ')[0], lastName: trimmedName.split(' ').slice(1).join(' ') || '' };
|
||||||
apiClient.graphql(
|
apiClient.graphql(
|
||||||
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
|
`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
|
// If the agent actually renamed the patient, kick off the
|
||||||
// side-effect chain: regenerate the AI summary against the
|
// side-effect chain: regenerate the AI summary against the
|
||||||
// corrected identity AND invalidate the Redis caller
|
// corrected identity. Fire-and-forget; the save toast
|
||||||
// resolution cache so the next incoming call from this
|
// fires immediately regardless.
|
||||||
// phone picks up fresh data. Both are fire-and-forget —
|
|
||||||
// the save toast fires immediately either way.
|
|
||||||
if (nameChanged && leadId) {
|
if (nameChanged && leadId) {
|
||||||
apiClient.post(`/api/lead/${leadId}/enrich`, { phone: callerNumber ?? undefined }, { silent: true }).catch(() => {});
|
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) {
|
} catch (err) {
|
||||||
console.error('Failed to save appointment:', err);
|
console.error('Failed to save appointment:', err);
|
||||||
setError(err instanceof Error ? err.message : 'Failed to save appointment. Please try again.');
|
setError(err instanceof Error ? err.message : 'Failed to save appointment. Please try again.');
|
||||||
@@ -383,7 +415,7 @@ export const AppointmentForm = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
notify.success('Appointment Cancelled');
|
notify.success('Appointment Cancelled');
|
||||||
onSaved?.();
|
onSaved?.('CANCELLED');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to cancel appointment');
|
setError(err instanceof Error ? err.message : 'Failed to cancel appointment');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -14,11 +14,14 @@ interface CallLogProps {
|
|||||||
|
|
||||||
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
|
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
|
||||||
APPOINTMENT_BOOKED: { label: 'Booked', color: 'success' },
|
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' },
|
FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' },
|
||||||
INFO_PROVIDED: { label: 'Info', color: 'blue-light' },
|
INFO_PROVIDED: { label: 'Info', color: 'blue-light' },
|
||||||
NO_ANSWER: { label: 'No Answer', color: 'warning' },
|
NO_ANSWER: { label: 'No Answer', color: 'warning' },
|
||||||
WRONG_NUMBER: { label: 'Wrong #', color: 'gray' },
|
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 => {
|
const formatDuration = (seconds: number | null): string => {
|
||||||
|
|||||||
@@ -20,6 +20,18 @@ const dispositionOptions: Array<{
|
|||||||
activeClass: 'bg-success-solid text-white ring-transparent',
|
activeClass: 'bg-success-solid text-white ring-transparent',
|
||||||
defaultClass: 'bg-success-primary text-success-primary border-success',
|
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',
|
value: 'FOLLOW_UP_SCHEDULED',
|
||||||
label: 'Follow-up Needed',
|
label: 'Follow-up Needed',
|
||||||
@@ -45,11 +57,17 @@ const dispositionOptions: Array<{
|
|||||||
defaultClass: 'bg-secondary text-secondary border-secondary',
|
defaultClass: 'bg-secondary text-secondary border-secondary',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'CALLBACK_REQUESTED',
|
value: 'NOT_INTERESTED',
|
||||||
label: 'Not Interested',
|
label: 'Not Interested',
|
||||||
activeClass: 'bg-error-solid text-white ring-transparent',
|
activeClass: 'bg-error-solid text-white ring-transparent',
|
||||||
defaultClass: 'bg-error-primary text-error-primary border-error',
|
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) => {
|
export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFormProps) => {
|
||||||
|
|||||||
@@ -1,13 +1,43 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
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 { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
|
||||||
import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
|
import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
|
||||||
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
import { TextArea } from '@/components/base/textarea/textarea';
|
import { TextArea } from '@/components/base/textarea/textarea';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import type { CallDisposition } from '@/types/entities';
|
import type { CallDisposition } from '@/types/entities';
|
||||||
import { cx } from '@/utils/cx';
|
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 }) => (
|
const PhoneHangUpIcon: FC<{ className?: string }> = ({ className }) => (
|
||||||
<FontAwesomeIcon icon={faPhoneHangup} className={className} />
|
<FontAwesomeIcon icon={faPhoneHangup} className={className} />
|
||||||
);
|
);
|
||||||
@@ -24,6 +54,18 @@ const dispositionOptions: Array<{
|
|||||||
activeClass: 'bg-success-solid text-white border-transparent',
|
activeClass: 'bg-success-solid text-white border-transparent',
|
||||||
defaultClass: 'bg-success-primary text-success-primary border-success',
|
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',
|
value: 'FOLLOW_UP_SCHEDULED',
|
||||||
label: 'Follow-up Needed',
|
label: 'Follow-up Needed',
|
||||||
@@ -49,31 +91,68 @@ const dispositionOptions: Array<{
|
|||||||
defaultClass: 'bg-secondary text-secondary border-secondary',
|
defaultClass: 'bg-secondary text-secondary border-secondary',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'CALLBACK_REQUESTED',
|
value: 'NOT_INTERESTED',
|
||||||
label: 'Not Interested',
|
label: 'Not Interested',
|
||||||
activeClass: 'bg-error-solid text-white border-transparent',
|
activeClass: 'bg-error-solid text-white border-transparent',
|
||||||
defaultClass: 'bg-error-primary text-error-primary border-error',
|
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 = {
|
type DispositionModalProps = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
callerName: string;
|
callerName: string;
|
||||||
callerDisconnected: boolean;
|
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;
|
onSubmit: (disposition: CallDisposition, notes: string) => void;
|
||||||
onDismiss?: () => 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 [selected, setSelected] = useState<CallDisposition | null>(null);
|
||||||
const [notes, setNotes] = useState('');
|
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
|
// Rank actionsTaken to pick the primary (highest-priority) action. When
|
||||||
if (isOpen && defaultDisposition && appliedDefaultRef.current !== defaultDisposition) {
|
// any action is present, that action's disposition becomes locked —
|
||||||
appliedDefaultRef.current = defaultDisposition;
|
// the agent cannot override it to a contradictory outcome.
|
||||||
setSelected(defaultDisposition);
|
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 = () => {
|
const handleSubmit = () => {
|
||||||
@@ -81,11 +160,20 @@ export const DispositionModal = ({ isOpen, callerName, callerDisconnected, defau
|
|||||||
onSubmit(selected, notes);
|
onSubmit(selected, notes);
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
setNotes('');
|
setNotes('');
|
||||||
appliedDefaultRef.current = undefined;
|
appliedLockRef.current = undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<Modal className="sm:max-w-md">
|
||||||
<Dialog>
|
<Dialog>
|
||||||
{() => (
|
{() => (
|
||||||
@@ -108,16 +196,47 @@ export const DispositionModal = ({ isOpen, callerName, callerDisconnected, defau
|
|||||||
|
|
||||||
{/* Disposition options */}
|
{/* Disposition options */}
|
||||||
<div className="px-6 pb-4">
|
<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">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{dispositionOptions.map((option) => {
|
{dispositionOptions.map((option) => {
|
||||||
const isSelected = selected === option.value;
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelected(option.value)}
|
disabled={isDisabled}
|
||||||
|
onClick={() => !isDisabled && setSelected(option.value)}
|
||||||
className={cx(
|
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
|
isSelected
|
||||||
? cx(option.activeClass, 'ring-2 ring-brand')
|
? cx(option.activeClass, 'ring-2 ring-brand')
|
||||||
: option.defaultClass,
|
: option.defaultClass,
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ type EnquiryFormProps = {
|
|||||||
leadId?: string | null;
|
leadId?: string | null;
|
||||||
patientId?: string | null;
|
patientId?: string | null;
|
||||||
agentName?: 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);
|
setError(null);
|
||||||
|
|
||||||
try {
|
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;
|
let leadId: string | null = propLeadId ?? null;
|
||||||
if (!leadId && registeredPhone) {
|
let resolvedPatientId: string | null = patientId || null;
|
||||||
const resolved = await apiClient.post<{ leadId: string; patientId: string }>('/api/caller/resolve', { phone: registeredPhone }, { silent: true });
|
let isNew = false;
|
||||||
leadId = resolved.leadId;
|
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 trimmedName = patientName.trim();
|
||||||
const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName;
|
const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName;
|
||||||
const nameParts = {
|
const nameParts = {
|
||||||
@@ -97,10 +104,48 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
|
|||||||
lastName: trimmedName.split(' ').slice(1).join(' ') || '',
|
lastName: trimmedName.split(' ').slice(1).join(' ') || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (leadId) {
|
if (isNew) {
|
||||||
// Update existing lead with enquiry details. Only touches
|
// Net-new caller — create Patient + Lead with the typed
|
||||||
// contactName if the agent explicitly renamed — otherwise
|
// name. Name is required (validated above).
|
||||||
// we leave the existing caller identity alone.
|
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(
|
await apiClient.graphql(
|
||||||
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
`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
|
// Update linked patient's name when the agent renamed (edit
|
||||||
// renamed. Fixes the long-standing bug where typing a name
|
// confirm path) on an existing record. Skipped for isNew
|
||||||
// into this form silently overwrote the existing patient
|
// because the patient was just created with the right name.
|
||||||
// record.
|
if (!isNew && nameChanged && resolvedPatientId && trimmedName) {
|
||||||
if (nameChanged && patientId) {
|
|
||||||
await apiClient.graphql(
|
await apiClient.graphql(
|
||||||
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
|
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
|
||||||
{
|
{
|
||||||
id: patientId,
|
id: resolvedPatientId,
|
||||||
data: {
|
data: {
|
||||||
fullName: nameParts,
|
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));
|
).catch((err: unknown) => console.warn('Failed to update patient name:', err));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post-save side-effects. If the agent actually renamed the
|
// Post-save side-effect. If the agent actually renamed the
|
||||||
// patient, kick off AI summary regen + cache invalidation.
|
// patient, kick off AI summary regen. Fire-and-forget.
|
||||||
// Otherwise just invalidate the cache so the status update
|
|
||||||
// propagates.
|
|
||||||
if (nameChanged && leadId) {
|
if (nameChanged && leadId) {
|
||||||
apiClient.post(`/api/lead/${leadId}/enrich`, { phone: callerPhone ?? undefined }, { silent: true }).catch(() => {});
|
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
|
// Create follow-up if needed
|
||||||
@@ -166,6 +189,12 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
|
|||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
return;
|
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(
|
await apiClient.graphql(
|
||||||
`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`,
|
`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`,
|
||||||
{
|
{
|
||||||
@@ -176,7 +205,7 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
|
|||||||
priority: 'NORMAL',
|
priority: 'NORMAL',
|
||||||
assignedAgent: agentName ?? undefined,
|
assignedAgent: agentName ?? undefined,
|
||||||
scheduledAt: new Date(`${followUpDate}T09:00:00`).toISOString(),
|
scheduledAt: new Date(`${followUpDate}T09:00:00`).toISOString(),
|
||||||
patientId: patientId ?? undefined,
|
patientId: resolvedPatientId || undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ silent: true },
|
{ silent: true },
|
||||||
@@ -184,7 +213,9 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
|
|||||||
}
|
}
|
||||||
|
|
||||||
notify.success('Enquiry Logged', 'Contact details and query captured');
|
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) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to save enquiry');
|
setError(err instanceof Error ? err.message : 'Failed to save enquiry');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -251,11 +282,14 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Checkbox isSelected={followUpNeeded} onChange={setFollowUpNeeded} label="Follow-up Needed" />
|
<div className="flex items-center gap-3">
|
||||||
|
<Checkbox isSelected={followUpNeeded} onChange={setFollowUpNeeded} label="Follow-up Needed" />
|
||||||
{followUpNeeded && (
|
{followUpNeeded && (
|
||||||
<Input label="Follow-up Date" type="date" value={followUpDate} onChange={setFollowUpDate} isRequired />
|
<div className="flex-1 max-w-[180px]">
|
||||||
)}
|
<Input type="date" value={followUpDate} onChange={setFollowUpDate} isRequired aria-label="Follow-up Date" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-lg bg-error-primary p-3 text-sm text-error-primary">{error}</div>
|
<div className="rounded-lg bg-error-primary p-3 text-sm text-error-primary">{error}</div>
|
||||||
|
|||||||
@@ -51,11 +51,14 @@ const ActivityIcon = ({ type }: { type: string }) => {
|
|||||||
|
|
||||||
const dispositionLabels: Record<CallDisposition, string> = {
|
const dispositionLabels: Record<CallDisposition, string> = {
|
||||||
APPOINTMENT_BOOKED: 'Appointment Booked',
|
APPOINTMENT_BOOKED: 'Appointment Booked',
|
||||||
|
APPOINTMENT_RESCHEDULED: 'Appointment Rescheduled',
|
||||||
|
APPOINTMENT_CANCELLED: 'Appointment Cancelled',
|
||||||
FOLLOW_UP_SCHEDULED: 'Follow-up Needed',
|
FOLLOW_UP_SCHEDULED: 'Follow-up Needed',
|
||||||
INFO_PROVIDED: 'Info Provided',
|
INFO_PROVIDED: 'Info Provided',
|
||||||
NO_ANSWER: 'No Answer',
|
NO_ANSWER: 'No Answer',
|
||||||
WRONG_NUMBER: 'Wrong Number',
|
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) => {
|
export const IncomingCallCard = ({ callState, lead, activities, campaigns, onDisposition, completedDisposition }: IncomingCallCardProps) => {
|
||||||
|
|||||||
@@ -56,18 +56,18 @@ export const TransferDialog = ({ ucid, currentAgentId, onClose, onTransferred }:
|
|||||||
const fetchTargets = async () => {
|
const fetchTargets = async () => {
|
||||||
try {
|
try {
|
||||||
const [agentsRes, doctorsRes] = await Promise.all([
|
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 } } } } }`),
|
apiClient.graphql<any>(`{ doctors(first: 20) { edges { node { id name department phone { primaryPhoneNumber } } } } }`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const agents: TransferTarget[] = (agentsRes.agents?.edges ?? [])
|
const agents: TransferTarget[] = (agentsRes.agents?.edges ?? [])
|
||||||
.map((e: any) => e.node)
|
.map((e: any) => e.node)
|
||||||
.filter((a: any) => a.ozonetelagentid !== currentAgentId)
|
.filter((a: any) => a.ozonetelAgentId !== currentAgentId)
|
||||||
.map((a: any) => ({
|
.map((a: any) => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
name: a.name,
|
name: a.name,
|
||||||
type: 'agent' as const,
|
type: 'agent' as const,
|
||||||
phoneNumber: `0${a.sipextension}`,
|
phoneNumber: `0${a.sipExtension}`,
|
||||||
status: 'offline' as const,
|
status: 'offline' as const,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ type WorklistFollowUp = {
|
|||||||
followUpStatus: string | null;
|
followUpStatus: string | null;
|
||||||
scheduledAt: string | null;
|
scheduledAt: string | null;
|
||||||
priority: string | null;
|
priority: string | null;
|
||||||
|
patientName?: string;
|
||||||
|
patientPhone?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MissedCall = {
|
type MissedCall = {
|
||||||
@@ -45,11 +47,12 @@ type MissedCall = {
|
|||||||
callerNumber: { number: string; callingCode: string }[] | null;
|
callerNumber: { number: string; callingCode: string }[] | null;
|
||||||
startedAt: string | null;
|
startedAt: string | null;
|
||||||
leadId: string | null;
|
leadId: string | null;
|
||||||
|
leadName: string | null;
|
||||||
disposition: string | null;
|
disposition: string | null;
|
||||||
callbackstatus: string | null;
|
callbackStatus: string | null;
|
||||||
callsourcenumber: string | null;
|
callSourceNumber: string | null;
|
||||||
missedcallcount: number | null;
|
missedCallCount: number | null;
|
||||||
callbackattemptedat: string | null;
|
callbackAttemptedAt: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MissedSubTab = 'pending' | 'attempted' | 'completed' | 'invalid';
|
type MissedSubTab = 'pending' | 'attempted' | 'completed' | 'invalid';
|
||||||
@@ -107,7 +110,9 @@ const followUpLabel: Record<string, string> = {
|
|||||||
REVIEW_REQUEST: 'Review',
|
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));
|
const minutes = Math.max(0, Math.round((Date.now() - new Date(dateStr).getTime()) / 60000));
|
||||||
if (minutes < 1) return { label: '<1m', color: 'success' };
|
if (minutes < 1) return { label: '<1m', color: 'success' };
|
||||||
if (minutes < 15) return { label: `${minutes}m`, 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' };
|
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 formatTimeAgo = (dateStr: string): string => {
|
||||||
const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000);
|
const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000);
|
||||||
if (minutes < 1) return 'Just now';
|
if (minutes < 1) return 'Just now';
|
||||||
@@ -150,13 +183,13 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
|||||||
|
|
||||||
for (const call of missedCalls) {
|
for (const call of missedCalls) {
|
||||||
const phone = call.callerNumber?.[0];
|
const phone = call.callerNumber?.[0];
|
||||||
const countBadge = call.missedcallcount && call.missedcallcount > 1 ? ` (${call.missedcallcount}x)` : '';
|
const countBadge = call.missedCallCount && call.missedCallCount > 1 ? ` (${call.missedCallCount}x)` : '';
|
||||||
const sourceSuffix = call.callsourcenumber ? ` • ${call.callsourcenumber}` : '';
|
const sourceSuffix = call.callSourceNumber ? ` • ${call.callSourceNumber}` : '';
|
||||||
rows.push({
|
rows.push({
|
||||||
id: `mc-${call.id}`,
|
id: `mc-${call.id}`,
|
||||||
type: 'missed',
|
type: 'missed',
|
||||||
priority: 'HIGH',
|
priority: 'HIGH',
|
||||||
name: (phone ? formatPhone(phone) : 'Unknown') + countBadge,
|
name: (call.leadName || (phone ? formatPhone(phone) : 'Unknown')) + countBadge,
|
||||||
phone: phone ? formatPhone(phone) : '',
|
phone: phone ? formatPhone(phone) : '',
|
||||||
phoneRaw: phone?.number ?? '',
|
phoneRaw: phone?.number ?? '',
|
||||||
direction: call.callDirection === 'OUTBOUND' ? 'outbound' : 'inbound',
|
direction: call.callDirection === 'OUTBOUND' ? 'outbound' : 'inbound',
|
||||||
@@ -165,12 +198,12 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
|||||||
? `Missed at ${formatTimeOnly(call.startedAt)}${sourceSuffix}`
|
? `Missed at ${formatTimeOnly(call.startedAt)}${sourceSuffix}`
|
||||||
: 'Missed call',
|
: 'Missed call',
|
||||||
createdAt: call.createdAt,
|
createdAt: call.createdAt,
|
||||||
taskState: call.callbackstatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING',
|
taskState: call.callbackStatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING',
|
||||||
leadId: call.leadId,
|
leadId: call.leadId,
|
||||||
originalLead: null,
|
originalLead: null,
|
||||||
lastContactedAt: call.callbackattemptedat ?? call.startedAt ?? call.createdAt,
|
lastContactedAt: call.callbackAttemptedAt ?? call.startedAt ?? call.createdAt,
|
||||||
contactAttempts: 0,
|
contactAttempts: 0,
|
||||||
source: call.callsourcenumber ?? null,
|
source: call.callSourceNumber ?? null,
|
||||||
lastDisposition: call.disposition ?? null,
|
lastDisposition: call.disposition ?? null,
|
||||||
missedCallId: call.id,
|
missedCallId: call.id,
|
||||||
});
|
});
|
||||||
@@ -179,13 +212,20 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
|||||||
for (const fu of followUps) {
|
for (const fu of followUps) {
|
||||||
const isOverdue = fu.followUpStatus === 'OVERDUE' || (fu.scheduledAt !== null && new Date(fu.scheduledAt) < new Date());
|
const isOverdue = fu.followUpStatus === 'OVERDUE' || (fu.scheduledAt !== null && new Date(fu.scheduledAt) < new Date());
|
||||||
const label = followUpLabel[fu.followUpType ?? ''] ?? fu.followUpType ?? 'Follow-up';
|
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({
|
rows.push({
|
||||||
id: `fu-${fu.id}`,
|
id: `fu-${fu.id}`,
|
||||||
type: fu.followUpType === 'CALLBACK' ? 'callback' : 'follow-up',
|
type: fu.followUpType === 'CALLBACK' ? 'callback' : 'follow-up',
|
||||||
priority: (fu.priority as WorklistRow['priority']) ?? (isOverdue ? 'HIGH' : 'NORMAL'),
|
priority: (fu.priority as WorklistRow['priority']) ?? (isOverdue ? 'HIGH' : 'NORMAL'),
|
||||||
name: label,
|
name: displayName,
|
||||||
phone: '',
|
phone: phoneFormatted,
|
||||||
phoneRaw: '',
|
phoneRaw: fu.patientPhone ?? '',
|
||||||
direction: null,
|
direction: null,
|
||||||
typeLabel: fu.followUpType === 'CALLBACK' ? 'Callback' : 'Follow-up',
|
typeLabel: fu.followUpType === 'CALLBACK' ? 'Callback' : 'Follow-up',
|
||||||
reason: fu.scheduledAt
|
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
|
// Keep all rows — follow-ups may have no phone and still need to be visible.
|
||||||
const actionableRows = rows.filter(r => r.phoneRaw);
|
// 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
|
// Sort by rules engine score if available, otherwise by priority + createdAt
|
||||||
actionableRows.sort((a, b) => {
|
actionableRows.sort((a, b) => {
|
||||||
@@ -249,13 +290,15 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
const [tab, setTab] = useState<TabKey>('all');
|
const [tab, setTab] = useState<TabKey>('all');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [missedSubTab, setMissedSubTab] = useState<MissedSubTab>('pending');
|
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(() => ({
|
const missedByStatus = useMemo(() => ({
|
||||||
pending: missedCalls.filter(c => c.callbackstatus === 'PENDING_CALLBACK' || !c.callbackstatus),
|
pending: missedCalls.filter(c => c.callbackStatus === 'PENDING_CALLBACK' || !c.callbackStatus),
|
||||||
attempted: missedCalls.filter(c => c.callbackstatus === 'CALLBACK_ATTEMPTED'),
|
attempted: missedCalls.filter(c => c.callbackStatus === 'CALLBACK_ATTEMPTED'),
|
||||||
completed: missedCalls.filter(c => c.callbackstatus === 'CALLBACK_COMPLETED' || c.callbackstatus === 'WRONG_NUMBER'),
|
completed: missedCalls.filter(c => c.callbackStatus === 'CALLBACK_COMPLETED' || c.callbackStatus === 'WRONG_NUMBER'),
|
||||||
invalid: missedCalls.filter(c => c.callbackstatus === 'INVALID'),
|
invalid: missedCalls.filter(c => c.callbackStatus === 'INVALID'),
|
||||||
}), [missedCalls]);
|
}), [missedCalls]);
|
||||||
|
|
||||||
const allRows = useMemo(
|
const allRows = useMemo(
|
||||||
@@ -273,7 +316,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
let rows = allRows;
|
let rows = allRows;
|
||||||
if (tab === 'missed') rows = missedSubTabRows;
|
if (tab === 'missed') rows = missedSubTabRows;
|
||||||
else if (tab === 'leads') rows = rows.filter((r) => r.type === 'lead');
|
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()) {
|
if (search.trim()) {
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
@@ -295,8 +338,27 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
case 'name':
|
case 'name':
|
||||||
return a.name.localeCompare(b.name) * dir;
|
return a.name.localeCompare(b.name) * dir;
|
||||||
case 'sla': {
|
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 ta = new Date(a.lastContactedAt ?? a.createdAt).getTime();
|
||||||
const tb = new Date(b.lastContactedAt ?? b.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;
|
return (ta - tb) * dir;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -310,7 +372,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
|
|
||||||
const missedCount = allRows.filter((r) => r.type === 'missed').length;
|
const missedCount = allRows.filter((r) => r.type === 'missed').length;
|
||||||
const leadCount = allRows.filter((r) => r.type === 'lead').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
|
// Notification for new missed calls
|
||||||
const prevMissedCount = useRef(missedCount);
|
const prevMissedCount = useRef(missedCount);
|
||||||
@@ -380,7 +442,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
{/* Missed call status sub-tabs */}
|
{/* Missed call status sub-tabs */}
|
||||||
{tab === 'missed' && (
|
{tab === 'missed' && (
|
||||||
<div className="flex shrink-0 gap-1 px-5 py-2 border-b border-secondary">
|
<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
|
<button
|
||||||
key={sub}
|
key={sub}
|
||||||
onClick={() => { setMissedSubTab(sub); setPage(1); }}
|
onClick={() => { setMissedSubTab(sub); setPage(1); }}
|
||||||
@@ -421,7 +483,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
<Table.Body items={pagedRows}>
|
<Table.Body items={pagedRows}>
|
||||||
{(row) => {
|
{(row) => {
|
||||||
const priority = priorityConfig[row.priority] ?? priorityConfig.NORMAL;
|
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;
|
const isSelected = row.originalLead !== null && row.originalLead.id === selectedLeadId;
|
||||||
|
|
||||||
// Sub-line: last interaction context
|
// Sub-line: last interaction context
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-primary">Sign out?</h3>
|
<h3 className="text-lg font-semibold text-primary">Sign out?</h3>
|
||||||
<p className="mt-1 text-sm text-tertiary">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full gap-3">
|
<div className="flex w-full gap-3">
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export const useAgentState = (agentId: string | null): { state: OzonetelState; s
|
|||||||
localStorage.removeItem('helix_agent_config');
|
localStorage.removeItem('helix_agent_config');
|
||||||
localStorage.removeItem('helix_user');
|
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);
|
setTimeout(() => { window.location.href = '/login'; }, 1500);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const usePerformanceAlerts = () => {
|
|||||||
let idx = 0;
|
let idx = 0;
|
||||||
|
|
||||||
for (const agent of teamPerf.agents) {
|
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 totalCalls = agentCalls.length;
|
||||||
const agentAppts = agentCalls.filter((c: any) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
const agentAppts = agentCalls.filter((c: any) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
||||||
const convPercent = totalCalls > 0 ? Math.round((agentAppts / totalCalls) * 100) : 0;
|
const convPercent = totalCalls > 0 ? Math.round((agentAppts / totalCalls) * 100) : 0;
|
||||||
@@ -55,14 +55,14 @@ export const usePerformanceAlerts = () => {
|
|||||||
const tb = agent.timeBreakdown;
|
const tb = agent.timeBreakdown;
|
||||||
const idleMinutes = tb ? Math.round(parseTime(tb.totalIdleTime ?? '0:0:0') / 60) : 0;
|
const idleMinutes = tb ? Math.round(parseTime(tb.totalIdleTime ?? '0:0:0') / 60) : 0;
|
||||||
|
|
||||||
if (agent.maxidleminutes && idleMinutes > agent.maxidleminutes) {
|
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 });
|
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) {
|
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 });
|
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) {
|
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 });
|
list.push({ id: `conv-${idx++}`, agent: agent.name ?? agent.ozonetelAgentId, type: 'Low Conversion', value: `${convPercent}%`, severity: 'warning', dismissed: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ type MissedCall = {
|
|||||||
disposition: string | null;
|
disposition: string | null;
|
||||||
callNotes: string | null;
|
callNotes: string | null;
|
||||||
leadId: string | null;
|
leadId: string | null;
|
||||||
callbackstatus: string | null;
|
leadName: string | null;
|
||||||
callsourcenumber: string | null;
|
callbackStatus: string | null;
|
||||||
missedcallcount: number | null;
|
callSourceNumber: string | null;
|
||||||
callbackattemptedat: string | null;
|
missedCallCount: number | null;
|
||||||
|
callbackAttemptedAt: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type WorklistFollowUp = {
|
type WorklistFollowUp = {
|
||||||
@@ -32,6 +33,8 @@ type WorklistFollowUp = {
|
|||||||
assignedAgent: string | null;
|
assignedAgent: string | null;
|
||||||
patientId: string | null;
|
patientId: string | null;
|
||||||
callId: string | null;
|
callId: string | null;
|
||||||
|
patientName?: string;
|
||||||
|
patientPhone?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type WorklistLead = {
|
type WorklistLead = {
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ export class SIPClient {
|
|||||||
private ua: JsSIP.UA | null = null;
|
private ua: JsSIP.UA | null = null;
|
||||||
private currentSession: RTCSession | null = null;
|
private currentSession: RTCSession | null = null;
|
||||||
private audioElement: HTMLAudioElement | 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(
|
constructor(
|
||||||
private config: SIPConfig,
|
private config: SIPConfig,
|
||||||
@@ -36,28 +40,43 @@ export class SIPClient {
|
|||||||
|
|
||||||
this.ua = new JsSIP.UA(configuration);
|
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', () => {
|
this.ua.on('connected', () => {
|
||||||
console.log('[SIP] WebSocket connected');
|
console.log('[SIP] WebSocket connected — waiting for REGISTER');
|
||||||
this.onConnectionChange('connected');
|
this.onConnectionChange('connected');
|
||||||
});
|
});
|
||||||
|
|
||||||
this.ua.on('disconnected', () => {
|
this.ua.on('disconnected', (e: any) => {
|
||||||
console.log('[SIP] WebSocket disconnected');
|
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.onConnectionChange('disconnected');
|
||||||
});
|
});
|
||||||
|
|
||||||
this.ua.on('registered', () => {
|
this.ua.on('registered', () => {
|
||||||
console.log('[SIP] Registered successfully');
|
console.log('[SIP] Registered successfully');
|
||||||
|
this.clearRegistrationWatchdog();
|
||||||
this.onConnectionChange('registered');
|
this.onConnectionChange('registered');
|
||||||
});
|
});
|
||||||
|
|
||||||
this.ua.on('unregistered', () => {
|
this.ua.on('unregistered', () => {
|
||||||
console.log('[SIP] Unregistered');
|
console.log('[SIP] Unregistered');
|
||||||
|
this.clearRegistrationWatchdog();
|
||||||
this.onConnectionChange('disconnected');
|
this.onConnectionChange('disconnected');
|
||||||
});
|
});
|
||||||
|
|
||||||
this.ua.on('registrationFailed', () => {
|
this.ua.on('registrationFailed', (e: any) => {
|
||||||
console.error('[SIP] Registration failed');
|
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');
|
this.onConnectionChange('error');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,9 +144,25 @@ export class SIPClient {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.ua.start();
|
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 {
|
disconnect(): void {
|
||||||
|
this.clearRegistrationWatchdog();
|
||||||
this.hangup();
|
this.hangup();
|
||||||
if (this.ua) {
|
if (this.ua) {
|
||||||
this.ua.stop();
|
this.ua.stop();
|
||||||
|
|||||||
@@ -65,10 +65,13 @@ const formatPhoneDisplay = (call: Call): string => {
|
|||||||
|
|
||||||
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
|
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
|
||||||
APPOINTMENT_BOOKED: { label: 'Appt Booked', color: 'success' },
|
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' },
|
FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' },
|
||||||
INFO_PROVIDED: { label: 'Info Provided', color: 'blue-light' },
|
INFO_PROVIDED: { label: 'Info Provided', color: 'blue-light' },
|
||||||
NO_ANSWER: { label: 'No Answer', color: 'warning' },
|
NO_ANSWER: { label: 'No Answer', color: 'warning' },
|
||||||
WRONG_NUMBER: { label: 'Wrong Number', color: 'gray' },
|
WRONG_NUMBER: { label: 'Wrong Number', color: 'gray' },
|
||||||
|
NOT_INTERESTED: { label: 'Not Interested', color: 'error' },
|
||||||
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
|
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useMemo, useState } 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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faArrowLeft, faArrowDownToLine, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
|
import { faArrowLeft, faArrowDownToLine, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ const PAGE_SIZE = 15;
|
|||||||
|
|
||||||
export const AllLeadsPage = () => {
|
export const AllLeadsPage = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const initialSource = searchParams.get('source') as LeadSource | null;
|
const initialSource = searchParams.get('source') as LeadSource | null;
|
||||||
const [tab, setTab] = useState<TabKey>('new');
|
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 shrink-0 items-center justify-between px-6 py-3 border-b border-secondary">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button
|
<Button
|
||||||
href="/"
|
onClick={() => navigate(-1)}
|
||||||
color="secondary"
|
color="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
iconLeading={ArrowLeft}
|
iconLeading={ArrowLeft}
|
||||||
aria-label="Back to workspace"
|
aria-label="Back"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tabs selectedKey={tab} onSelectionChange={handleTabChange}>
|
<Tabs selectedKey={tab} onSelectionChange={handleTabChange}>
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ export const AppointmentsPage = () => {
|
|||||||
<span className="text-xs text-tertiary">{appt.department ?? '—'}</span>
|
<span className="text-xs text-tertiary">{appt.department ?? '—'}</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<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>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Badge size="sm" color={statusColor} type="pill-color">
|
<Badge size="sm" color={statusColor} type="pill-color">
|
||||||
|
|||||||
@@ -35,10 +35,13 @@ const filterItems = [
|
|||||||
|
|
||||||
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
|
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
|
||||||
APPOINTMENT_BOOKED: { label: 'Appt Booked', color: 'success' },
|
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' },
|
FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' },
|
||||||
INFO_PROVIDED: { label: 'Info Provided', color: 'blue-light' },
|
INFO_PROVIDED: { label: 'Info Provided', color: 'blue-light' },
|
||||||
NO_ANSWER: { label: 'No Answer', color: 'warning' },
|
NO_ANSWER: { label: 'No Answer', color: 'warning' },
|
||||||
WRONG_NUMBER: { label: 'Wrong Number', color: 'gray' },
|
WRONG_NUMBER: { label: 'Wrong Number', color: 'gray' },
|
||||||
|
NOT_INTERESTED: { label: 'Not Interested', color: 'error' },
|
||||||
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
|
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -139,8 +142,9 @@ export const CallHistoryPage = () => {
|
|||||||
return dateB - dateA;
|
return dateB - dateA;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Direction / status filter
|
// Direction / status filter. "Inbound" shows answered inbound only — missed
|
||||||
if (filter === 'inbound') result = result.filter((c) => c.callDirection === 'INBOUND');
|
// 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 === 'outbound') result = result.filter((c) => c.callDirection === 'OUTBOUND');
|
||||||
else if (filter === 'missed') result = result.filter((c) => c.callStatus === 'MISSED');
|
else if (filter === 'missed') result = result.filter((c) => c.callStatus === 'MISSED');
|
||||||
|
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ type MissedCallRecord = {
|
|||||||
callerNumber: { primaryPhoneNumber: string } | null;
|
callerNumber: { primaryPhoneNumber: string } | null;
|
||||||
agentName: string | null;
|
agentName: string | null;
|
||||||
startedAt: string | null;
|
startedAt: string | null;
|
||||||
callsourcenumber: string | null;
|
callSourceNumber: string | null;
|
||||||
callbackstatus: string | null;
|
callbackStatus: string | null;
|
||||||
missedcallcount: number | null;
|
missedCallCount: number | null;
|
||||||
callbackattemptedat: string | null;
|
callbackAttemptedAt: string | null;
|
||||||
sla: number | null;
|
sla: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ const QUERY = `{ calls(first: 200, filter: {
|
|||||||
callStatus: { eq: MISSED }
|
callStatus: { eq: MISSED }
|
||||||
}, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
}, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
||||||
id callerNumber { primaryPhoneNumber } agentName
|
id callerNumber { primaryPhoneNumber } agentName
|
||||||
startedAt callsourcenumber callbackstatus missedcallcount callbackattemptedat sla
|
startedAt callSourceNumber callbackStatus missedCallCount callbackAttemptedAt sla
|
||||||
} } } }`;
|
} } } }`;
|
||||||
|
|
||||||
const PAGE_SIZE = 15;
|
const PAGE_SIZE = 15;
|
||||||
@@ -92,7 +92,7 @@ export const MissedCallsPage = () => {
|
|||||||
const statusCounts = useMemo(() => {
|
const statusCounts = useMemo(() => {
|
||||||
const counts: Record<string, number> = {};
|
const counts: Record<string, number> = {};
|
||||||
for (const c of calls) {
|
for (const c of calls) {
|
||||||
const s = c.callbackstatus ?? 'PENDING_CALLBACK';
|
const s = c.callbackStatus ?? 'PENDING_CALLBACK';
|
||||||
counts[s] = (counts[s] ?? 0) + 1;
|
counts[s] = (counts[s] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
@@ -100,16 +100,16 @@ export const MissedCallsPage = () => {
|
|||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
let rows = calls;
|
let rows = calls;
|
||||||
if (tab === 'PENDING_CALLBACK') rows = rows.filter(c => c.callbackstatus === 'PENDING_CALLBACK' || !c.callbackstatus);
|
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_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');
|
else if (tab === 'CALLBACK_COMPLETED') rows = rows.filter(c => c.callbackStatus === 'CALLBACK_COMPLETED' || c.callbackStatus === 'WRONG_NUMBER');
|
||||||
|
|
||||||
if (search.trim()) {
|
if (search.trim()) {
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
rows = rows.filter(c =>
|
rows = rows.filter(c =>
|
||||||
(c.callerNumber?.primaryPhoneNumber ?? '').includes(q) ||
|
(c.callerNumber?.primaryPhoneNumber ?? '').includes(q) ||
|
||||||
(c.agentName ?? '').toLowerCase().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;
|
const tb = b.startedAt ? new Date(b.startedAt).getTime() : 0;
|
||||||
return (ta - tb) * dir;
|
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 'sla': return ((a.sla ?? 0) - (b.sla ?? 0)) * dir;
|
||||||
case 'agent': return (a.agentName ?? '').localeCompare(b.agentName ?? '') * dir;
|
case 'agent': return (a.agentName ?? '').localeCompare(b.agentName ?? '') * dir;
|
||||||
default: return 0;
|
default: return 0;
|
||||||
@@ -190,7 +190,7 @@ export const MissedCallsPage = () => {
|
|||||||
<Table.Body items={pagedRows}>
|
<Table.Body items={pagedRows}>
|
||||||
{(call) => {
|
{(call) => {
|
||||||
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
|
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
|
||||||
const status = call.callbackstatus ?? 'PENDING_CALLBACK';
|
const status = call.callbackStatus ?? 'PENDING_CALLBACK';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table.Row id={call.id}>
|
<Table.Row id={call.id}>
|
||||||
@@ -213,7 +213,7 @@ export const MissedCallsPage = () => {
|
|||||||
)}
|
)}
|
||||||
{visibleColumns.has('branch') && (
|
{visibleColumns.has('branch') && (
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<span className="text-xs text-tertiary">{call.callsourcenumber || '—'}</span>
|
<span className="text-xs text-tertiary">{call.callSourceNumber || '—'}</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
)}
|
)}
|
||||||
{visibleColumns.has('agent') && (
|
{visibleColumns.has('agent') && (
|
||||||
@@ -223,8 +223,8 @@ export const MissedCallsPage = () => {
|
|||||||
)}
|
)}
|
||||||
{visibleColumns.has('count') && (
|
{visibleColumns.has('count') && (
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{call.missedcallcount && call.missedcallcount > 1 ? (
|
{call.missedCallCount && call.missedCallCount > 1 ? (
|
||||||
<Badge size="sm" color="warning" type="pill-color">{call.missedcallcount}x</Badge>
|
<Badge size="sm" color="warning" type="pill-color">{call.missedCallCount}x</Badge>
|
||||||
) : <span className="text-xs text-quaternary">1</span>}
|
) : <span className="text-xs text-quaternary">1</span>}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
)}
|
)}
|
||||||
@@ -256,10 +256,10 @@ export const MissedCallsPage = () => {
|
|||||||
)}
|
)}
|
||||||
{visibleColumns.has('callback') && (
|
{visibleColumns.has('callback') && (
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{call.callbackattemptedat ? (
|
{call.callbackAttemptedAt ? (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm text-primary">{formatDateOrdinal(call.callbackattemptedat)}</span>
|
<span className="text-sm text-primary">{formatDateOrdinal(call.callbackAttemptedAt)}</span>
|
||||||
<span className="block text-xs text-tertiary">{formatTimeOnly(call.callbackattemptedat)}</span>
|
<span className="block text-xs text-tertiary">{formatTimeOnly(call.callbackAttemptedAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
) : <span className="text-xs text-quaternary">—</span>}
|
) : <span className="text-xs text-quaternary">—</span>}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
|||||||
@@ -51,10 +51,13 @@ const DEFAULT_CONFIG: ActivityConfig = { icon: '📌', dotClass: 'bg-tertiary',
|
|||||||
|
|
||||||
const DISPOSITION_COLORS: Record<CallDisposition, 'success' | 'brand' | 'blue' | 'error' | 'warning' | 'gray'> = {
|
const DISPOSITION_COLORS: Record<CallDisposition, 'success' | 'brand' | 'blue' | 'error' | 'warning' | 'gray'> = {
|
||||||
APPOINTMENT_BOOKED: 'success',
|
APPOINTMENT_BOOKED: 'success',
|
||||||
|
APPOINTMENT_RESCHEDULED: 'warning',
|
||||||
|
APPOINTMENT_CANCELLED: 'error',
|
||||||
FOLLOW_UP_SCHEDULED: 'brand',
|
FOLLOW_UP_SCHEDULED: 'brand',
|
||||||
INFO_PROVIDED: 'blue',
|
INFO_PROVIDED: 'blue',
|
||||||
WRONG_NUMBER: 'error',
|
WRONG_NUMBER: 'error',
|
||||||
NO_ANSWER: 'warning',
|
NO_ANSWER: 'warning',
|
||||||
|
NOT_INTERESTED: 'error',
|
||||||
CALLBACK_REQUESTED: 'gray',
|
CALLBACK_REQUESTED: 'gray',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Badge } from '@/components/base/badges/badges';
|
|||||||
import { Input } from '@/components/base/input/input';
|
import { Input } from '@/components/base/input/input';
|
||||||
import { Table, TableCard } from '@/components/application/table/table';
|
import { Table, TableCard } from '@/components/application/table/table';
|
||||||
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
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 { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
|
||||||
import { PatientProfilePanel } from '@/components/shared/patient-profile-panel';
|
import { PatientProfilePanel } from '@/components/shared/patient-profile-panel';
|
||||||
import { useData } from '@/providers/data-provider';
|
import { useData } from '@/providers/data-provider';
|
||||||
@@ -86,8 +86,6 @@ export const PatientsPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<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 overflow-hidden">
|
||||||
<div className="flex flex-1 flex-col overflow-y-auto p-7">
|
<div className="flex flex-1 flex-col overflow-y-auto p-7">
|
||||||
<TableCard.Root size="sm">
|
<TableCard.Root size="sm">
|
||||||
@@ -141,7 +139,7 @@ export const PatientsPage = () => {
|
|||||||
<Table.Head label="AGE" />
|
<Table.Head label="AGE" />
|
||||||
<Table.Head label="ACTIONS" />
|
<Table.Head label="ACTIONS" />
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body items={pagedPatients}>
|
<Table.Body items={pagedPatients} dependencies={[selectedPatient?.id]}>
|
||||||
{(patient) => {
|
{(patient) => {
|
||||||
const displayName = getPatientDisplayName(patient);
|
const displayName = getPatientDisplayName(patient);
|
||||||
const age = computeAge(patient.dateOfBirth);
|
const age = computeAge(patient.dateOfBirth);
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ const parseTime = (timeStr: string): number => {
|
|||||||
|
|
||||||
type AgentPerf = {
|
type AgentPerf = {
|
||||||
name: string;
|
name: string;
|
||||||
ozonetelagentid: string;
|
ozonetelAgentId: string;
|
||||||
npsscore: number | null;
|
npsScore: number | null;
|
||||||
maxidleminutes: number | null;
|
maxIdleMinutes: number | null;
|
||||||
minnpsthreshold: number | null;
|
minNpsThreshold: number | null;
|
||||||
minconversionpercent: number | null;
|
minConversionPercent: number | null;
|
||||||
calls: number;
|
calls: number;
|
||||||
inbound: number;
|
inbound: number;
|
||||||
missed: number;
|
missed: number;
|
||||||
@@ -112,7 +112,7 @@ export const TeamPerformancePage = () => {
|
|||||||
if (teamAgents.length > 0) {
|
if (teamAgents.length > 0) {
|
||||||
// Real Ozonetel data available
|
// Real Ozonetel data available
|
||||||
agentPerfs = teamAgents.map((agent: any) => {
|
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 agentLeads = leads.filter((l: any) => l.assignedAgent === agent.name);
|
||||||
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === agent.name);
|
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === agent.name);
|
||||||
const agentAppts = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
|
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;
|
const breakSec = tb ? parseTime(tb.totalPauseTime ?? '0:0:0') : 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: agent.name ?? agent.ozonetelagentid,
|
name: agent.name ?? agent.ozonetelAgentId,
|
||||||
ozonetelagentid: agent.ozonetelagentid,
|
ozonetelAgentId: agent.ozonetelAgentId,
|
||||||
npsscore: agent.npsscore,
|
npsScore: agent.npsScore,
|
||||||
maxidleminutes: agent.maxidleminutes,
|
maxIdleMinutes: agent.maxIdleMinutes,
|
||||||
minnpsthreshold: agent.minnpsthreshold,
|
minNpsThreshold: agent.minNpsThreshold,
|
||||||
minconversionpercent: agent.minconversionpercent,
|
minConversionPercent: agent.minConversionPercent,
|
||||||
calls: totalCalls,
|
calls: totalCalls,
|
||||||
inbound,
|
inbound,
|
||||||
missed,
|
missed,
|
||||||
@@ -159,11 +159,11 @@ export const TeamPerformancePage = () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
ozonetelagentid: name,
|
ozonetelAgentId: name,
|
||||||
npsscore: null,
|
npsScore: null,
|
||||||
maxidleminutes: null,
|
maxIdleMinutes: null,
|
||||||
minnpsthreshold: null,
|
minNpsThreshold: null,
|
||||||
minconversionpercent: null,
|
minConversionPercent: null,
|
||||||
calls: totalCalls,
|
calls: totalCalls,
|
||||||
inbound: agentCalls.filter((c: any) => c.direction === 'INBOUND').length,
|
inbound: agentCalls.filter((c: any) => c.direction === 'INBOUND').length,
|
||||||
missed: agentCalls.filter((c: any) => c.callStatus === 'MISSED').length,
|
missed: agentCalls.filter((c: any) => c.callStatus === 'MISSED').length,
|
||||||
@@ -223,9 +223,9 @@ export const TeamPerformancePage = () => {
|
|||||||
|
|
||||||
// NPS
|
// NPS
|
||||||
const avgNps = useMemo(() => {
|
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;
|
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]);
|
}, [agents]);
|
||||||
|
|
||||||
const npsOption = useMemo(() => ({
|
const npsOption = useMemo(() => ({
|
||||||
@@ -246,13 +246,13 @@ export const TeamPerformancePage = () => {
|
|||||||
const alerts = useMemo(() => {
|
const alerts = useMemo(() => {
|
||||||
const list: { agent: string; type: string; value: string; severity: 'error' | 'warning' }[] = [];
|
const list: { agent: string; type: string; value: string; severity: 'error' | 'warning' }[] = [];
|
||||||
for (const a of agents) {
|
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' });
|
list.push({ agent: a.name, type: 'Excessive Idle Time', value: `${a.idleMinutes}m`, severity: 'error' });
|
||||||
}
|
}
|
||||||
if (a.minnpsthreshold && (a.npsscore ?? 100) < a.minnpsthreshold) {
|
if (a.minNpsThreshold && (a.npsScore ?? 100) < a.minNpsThreshold) {
|
||||||
list.push({ agent: a.name, type: 'Low NPS', value: String(a.npsscore ?? 0), severity: 'warning' });
|
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' });
|
list.push({ agent: a.name, type: 'Low Conversion', value: `${a.convPercent}%`, severity: 'warning' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -332,7 +332,7 @@ export const TeamPerformancePage = () => {
|
|||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body items={agents}>
|
<Table.Body items={agents}>
|
||||||
{(agent) => (
|
{(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 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.calls}</span></Table.Cell>
|
||||||
<Table.Cell><span className="text-sm text-primary">{agent.inbound}</span></Table.Cell>
|
<Table.Cell><span className="text-sm text-primary">{agent.inbound}</span></Table.Cell>
|
||||||
@@ -345,12 +345,12 @@ export const TeamPerformancePage = () => {
|
|||||||
</span>
|
</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<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')}>
|
<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 ?? '—'}
|
{agent.npsScore ?? '—'}
|
||||||
</span>
|
</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<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
|
{agent.idleMinutes}m
|
||||||
</span>
|
</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
@@ -389,7 +389,7 @@ export const TeamPerformancePage = () => {
|
|||||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
|
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
{agents.map(agent => {
|
{agents.map(agent => {
|
||||||
const total = agent.activeMinutes + agent.wrapMinutes + agent.idleMinutes + agent.breakMinutes || 1;
|
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 (
|
return (
|
||||||
<div key={agent.name} className={cx('rounded-lg border p-3', isHighIdle ? 'border-error bg-error-secondary' : 'border-secondary')}>
|
<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>
|
<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 gap-4">
|
||||||
<div className="flex-1 rounded-xl border border-secondary bg-primary p-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>
|
<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">
|
<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>
|
<p className="text-xs text-tertiary">NPS data unavailable — configure NPS scores on agent profiles.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -425,13 +425,13 @@ export const TeamPerformancePage = () => {
|
|||||||
<>
|
<>
|
||||||
<ReactECharts option={npsOption} style={{ height: 150 }} />
|
<ReactECharts option={npsOption} style={{ height: 150 }} />
|
||||||
<div className="space-y-1 mt-2">
|
<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">
|
<div key={a.name} className="flex items-center gap-2">
|
||||||
<span className="text-xs text-secondary w-28 truncate">{a.name}</span>
|
<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="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>
|
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
|
|||||||
|
|
||||||
// Disconnect SIP before logout
|
// Disconnect SIP before logout
|
||||||
try {
|
try {
|
||||||
disconnectSip(true);
|
disconnectSip(true, 'logout');
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
// Notify sidecar to unlock Redis session + Ozonetel logout — await before clearing tokens
|
// Notify sidecar to unlock Redis session + Ozonetel logout — await before clearing tokens
|
||||||
|
|||||||
@@ -116,6 +116,12 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
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]);
|
}, [fetchData]);
|
||||||
|
|
||||||
const updateLead = (id: string, updates: Partial<Lead>) => {
|
const updateLead = (id: string, updates: Partial<Lead>) => {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from '@/state/sip-state';
|
} from '@/state/sip-state';
|
||||||
import { registerSipStateUpdater, connectSip, disconnectSip, getSipClient, setOutboundPending } from '@/state/sip-manager';
|
import { registerSipStateUpdater, connectSip, disconnectSip, getSipClient, setOutboundPending } from '@/state/sip-manager';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { notify } from '@/lib/toast';
|
||||||
import type { SIPConfig } from '@/types/sip';
|
import type { SIPConfig } from '@/types/sip';
|
||||||
|
|
||||||
// SIP config comes exclusively from the Agent entity (stored on login).
|
// 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('beforeunload', handleBeforeUnload);
|
||||||
window.addEventListener('unload', handleUnload);
|
window.addEventListener('unload', handleUnload);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
window.removeEventListener('unload', handleUnload);
|
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
|
}, []); // 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
|
// Ozonetel outbound dial — single path for all outbound calls
|
||||||
const dialOutbound = useCallback(async (phoneNumber: string): Promise<void> => {
|
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
|
// Block outbound calls when agent is on Break or Training
|
||||||
const agentCfg = localStorage.getItem('helix_agent_config');
|
const agentCfg = localStorage.getItem('helix_agent_config');
|
||||||
if (agentCfg) {
|
if (agentCfg) {
|
||||||
@@ -166,7 +178,6 @@ export const useSip = () => {
|
|||||||
const stateRes = await fetch(`/api/supervisor/agent-state?agentId=${agentId}`);
|
const stateRes = await fetch(`/api/supervisor/agent-state?agentId=${agentId}`);
|
||||||
const stateData = await stateRes.json();
|
const stateData = await stateRes.json();
|
||||||
if (stateData.state === 'break' || stateData.state === 'training') {
|
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');
|
notify.info('Status: ' + stateData.state, 'Change status to Ready before placing calls');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -204,7 +215,7 @@ export const useSip = () => {
|
|||||||
setCallerNumber(null);
|
setCallerNumber(null);
|
||||||
throw new Error('Dial failed');
|
throw new Error('Dial failed');
|
||||||
}
|
}
|
||||||
}, [setCallState, setCallerNumber, setCallUcid]);
|
}, [setCallState, setCallerNumber, setCallUcid, connectionStatus]);
|
||||||
|
|
||||||
const answer = useCallback(() => getSipClient()?.answer(), []);
|
const answer = useCallback(() => getSipClient()?.answer(), []);
|
||||||
const reject = useCallback(() => getSipClient()?.reject(), []);
|
const reject = useCallback(() => getSipClient()?.reject(), []);
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export function connectSip(config: SIPConfig): void {
|
|||||||
if (ucid) stateUpdater?.setCallUcid(ucid);
|
if (ucid) stateUpdater?.setCallUcid(ucid);
|
||||||
|
|
||||||
if (state === 'ended' || state === 'failed') {
|
if (state === 'ended' || state === 'failed') {
|
||||||
|
sipClient?.unmute(); // clear any mute state so it doesn't persist to next call
|
||||||
outboundActive = false;
|
outboundActive = false;
|
||||||
outboundPending = false;
|
outboundPending = false;
|
||||||
}
|
}
|
||||||
@@ -92,16 +93,16 @@ export function connectSip(config: SIPConfig): void {
|
|||||||
sipClient.connect();
|
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
|
// Guard: don't disconnect SIP during an active or pending call
|
||||||
// unless explicitly forced (e.g., logout, page unload).
|
// unless explicitly forced (e.g., logout, page unload).
|
||||||
// This prevents React re-render cycles from killing the
|
// This prevents React re-render cycles from killing the
|
||||||
// SIP WebSocket mid-dial.
|
// SIP WebSocket mid-dial.
|
||||||
if (!force && (outboundPending || outboundActive)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
console.log(`[SIP] Disconnecting agent=${activeAgentId}` + (force ? ' (forced)' : ''));
|
console.log(`[SIP] Disconnecting agent=${activeAgentId} reason=${reason}` + (force ? ' (forced)' : ''));
|
||||||
sipClient?.disconnect();
|
sipClient?.disconnect();
|
||||||
sipClient = null;
|
sipClient = null;
|
||||||
connected = false;
|
connected = false;
|
||||||
|
|||||||
@@ -250,10 +250,13 @@ export type CallDirection = 'INBOUND' | 'OUTBOUND';
|
|||||||
export type CallStatus = 'RINGING' | 'IN_PROGRESS' | 'COMPLETED' | 'MISSED' | 'VOICEMAIL';
|
export type CallStatus = 'RINGING' | 'IN_PROGRESS' | 'COMPLETED' | 'MISSED' | 'VOICEMAIL';
|
||||||
export type CallDisposition =
|
export type CallDisposition =
|
||||||
| 'APPOINTMENT_BOOKED'
|
| 'APPOINTMENT_BOOKED'
|
||||||
|
| 'APPOINTMENT_RESCHEDULED'
|
||||||
|
| 'APPOINTMENT_CANCELLED'
|
||||||
| 'FOLLOW_UP_SCHEDULED'
|
| 'FOLLOW_UP_SCHEDULED'
|
||||||
| 'INFO_PROVIDED'
|
| 'INFO_PROVIDED'
|
||||||
| 'WRONG_NUMBER'
|
| 'WRONG_NUMBER'
|
||||||
| 'NO_ANSWER'
|
| 'NO_ANSWER'
|
||||||
|
| 'NOT_INTERESTED'
|
||||||
| 'CALLBACK_REQUESTED';
|
| 'CALLBACK_REQUESTED';
|
||||||
|
|
||||||
export type Call = {
|
export type Call = {
|
||||||
|
|||||||
Reference in New Issue
Block a user