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