mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
fix: P2 defect batch + context-panel edit takeover
Layout (P1-adjacent):
- context-panel switches to an edit-only layout when editingAppointment
is set. Previously AppointmentForm rendered inline BELOW the AI panel,
crushing the AI area into a ~2-line strip that made the returning-
patient summary + quick actions unusable. Edit view gets full height
with a "Back to context" button.
P2s:
- Remove Attempted sub-tab from Missed Calls worklist (Pending only).
- Add CALL_DROPPED disposition option + propagate through every
per-disposition Record<CallDisposition,...> map (incoming-call-card,
call-log, call-history, agent-detail, patient-360).
- Block SLA-gaming on unanswered calls: Book Appt / Enquiry / Transfer
buttons on active-call-card are disabled until the call reaches the
answered state (wasAnsweredRef). The disposition filter was already
in place; this closes the upstream entry.
- Data labels on performance charts: my-performance bar chart shows
value on top of each bar; donut shows {d}% slice labels; team-
performance day trend line shows per-point values.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -299,12 +299,15 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
|
|
||||||
<Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'}
|
<Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'}
|
||||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
|
||||||
|
isDisabled={!wasAnsweredRef.current}
|
||||||
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>Book Appt</Button>
|
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>Book Appt</Button>
|
||||||
<Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'}
|
<Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'}
|
||||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
|
||||||
|
isDisabled={!wasAnsweredRef.current}
|
||||||
onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); setTransferOpen(false); }}>Enquiry</Button>
|
onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); setTransferOpen(false); }}>Enquiry</Button>
|
||||||
<Button size="sm" color={transferOpen ? 'primary' : 'secondary'}
|
<Button size="sm" color={transferOpen ? 'primary' : 'secondary'}
|
||||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
|
||||||
|
isDisabled={!wasAnsweredRef.current}
|
||||||
onClick={() => { setTransferOpen(!transferOpen); setAppointmentOpen(false); setEnquiryOpen(false); }}>Transfer</Button>
|
onClick={() => { setTransferOpen(!transferOpen); setAppointmentOpen(false); setEnquiryOpen(false); }}>Transfer</Button>
|
||||||
|
|
||||||
<Button size="sm" color="primary-destructive" className="ml-auto"
|
<Button size="sm" color="primary-destructive" className="ml-auto"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const dispositionConfig: Record<CallDisposition, { label: string; color: 'succes
|
|||||||
WRONG_NUMBER: { label: 'Wrong #', color: 'gray' },
|
WRONG_NUMBER: { label: 'Wrong #', color: 'gray' },
|
||||||
NOT_INTERESTED: { label: 'Not Interested', color: 'error' },
|
NOT_INTERESTED: { label: 'Not Interested', color: 'error' },
|
||||||
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
|
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
|
||||||
|
CALL_DROPPED: { label: 'Call Dropped', color: 'gray' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDuration = (seconds: number | null): string => {
|
const formatDuration = (seconds: number | null): string => {
|
||||||
|
|||||||
@@ -122,6 +122,43 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
|
|||||||
|
|
||||||
const hasContext = !!(lead?.aiSummary || leadCalls.length || leadFollowUps.length || leadAppointments.length || leadActivities.length);
|
const hasContext = !!(lead?.aiSummary || leadCalls.length || leadFollowUps.length || leadAppointments.length || leadActivities.length);
|
||||||
|
|
||||||
|
// Edit mode takes over the whole right panel — otherwise the
|
||||||
|
// AppointmentForm competes with the AI panel + context blocks for
|
||||||
|
// vertical space and gets crushed into a tiny strip at the bottom.
|
||||||
|
if (editingAppointment) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="shrink-0 border-b border-secondary px-3 py-2 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-semibold text-primary">Edit Appointment</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingAppointment(null)}
|
||||||
|
className="text-xs font-medium text-tertiary hover:text-primary transition duration-100 ease-linear"
|
||||||
|
>
|
||||||
|
Back to context
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<AppointmentForm
|
||||||
|
isOpen={true}
|
||||||
|
onOpenChange={(open) => { if (!open) setEditingAppointment(null); }}
|
||||||
|
callerNumber={callerPhone}
|
||||||
|
leadName={fullName}
|
||||||
|
leadId={lead?.id}
|
||||||
|
patientId={editingAppointment.patientId}
|
||||||
|
existingAppointment={{
|
||||||
|
id: editingAppointment.id,
|
||||||
|
scheduledAt: editingAppointment.scheduledAt ?? '',
|
||||||
|
doctorName: editingAppointment.doctorName ?? '',
|
||||||
|
doctorId: editingAppointment.doctorId ?? undefined,
|
||||||
|
department: editingAppointment.department ?? '',
|
||||||
|
reasonForVisit: editingAppointment.reasonForVisit ?? undefined,
|
||||||
|
status: editingAppointment.appointmentStatus ?? 'SCHEDULED',
|
||||||
|
}}
|
||||||
|
onSaved={() => setEditingAppointment(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* Lead header — always visible */}
|
{/* Lead header — always visible */}
|
||||||
@@ -316,28 +353,6 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
|
|||||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||||
<AiChatPanel callerContext={callerContext} onChatStart={handleChatStart} />
|
<AiChatPanel callerContext={callerContext} onChatStart={handleChatStart} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Appointment edit form */}
|
|
||||||
{editingAppointment && (
|
|
||||||
<AppointmentForm
|
|
||||||
isOpen={!!editingAppointment}
|
|
||||||
onOpenChange={(open) => { if (!open) setEditingAppointment(null); }}
|
|
||||||
callerNumber={callerPhone}
|
|
||||||
leadName={fullName}
|
|
||||||
leadId={lead?.id}
|
|
||||||
patientId={editingAppointment.patientId}
|
|
||||||
existingAppointment={{
|
|
||||||
id: editingAppointment.id,
|
|
||||||
scheduledAt: editingAppointment.scheduledAt ?? '',
|
|
||||||
doctorName: editingAppointment.doctorName ?? '',
|
|
||||||
doctorId: editingAppointment.doctorId ?? undefined,
|
|
||||||
department: editingAppointment.department ?? '',
|
|
||||||
reasonForVisit: editingAppointment.reasonForVisit ?? undefined,
|
|
||||||
status: editingAppointment.appointmentStatus ?? 'SCHEDULED',
|
|
||||||
}}
|
|
||||||
onSaved={() => setEditingAppointment(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -102,6 +102,12 @@ const dispositionOptions: Array<{
|
|||||||
activeClass: 'bg-utility-blue-600 text-white border-transparent',
|
activeClass: 'bg-utility-blue-600 text-white border-transparent',
|
||||||
defaultClass: 'bg-utility-blue-50 text-utility-blue-700 border-utility-blue-200',
|
defaultClass: 'bg-utility-blue-50 text-utility-blue-700 border-utility-blue-200',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: 'CALL_DROPPED',
|
||||||
|
label: 'Call Dropped',
|
||||||
|
activeClass: 'bg-secondary-solid text-white border-transparent',
|
||||||
|
defaultClass: 'bg-secondary text-secondary border-secondary',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
type DispositionModalProps = {
|
type DispositionModalProps = {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const dispositionLabels: Record<CallDisposition, string> = {
|
|||||||
WRONG_NUMBER: 'Wrong Number',
|
WRONG_NUMBER: 'Wrong Number',
|
||||||
NOT_INTERESTED: 'Not Interested',
|
NOT_INTERESTED: 'Not Interested',
|
||||||
CALLBACK_REQUESTED: 'Callback Requested',
|
CALLBACK_REQUESTED: 'Callback Requested',
|
||||||
|
CALL_DROPPED: 'Call Dropped',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const IncomingCallCard = ({ callState, lead, activities, campaigns, onDisposition, completedDisposition }: IncomingCallCardProps) => {
|
export const IncomingCallCard = ({ callState, lead, activities, campaigns, onDisposition, completedDisposition }: IncomingCallCardProps) => {
|
||||||
|
|||||||
@@ -289,7 +289,10 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
|||||||
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectLead, selectedLeadId, onDialMissedCall }: WorklistPanelProps) => {
|
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectLead, selectedLeadId, onDialMissedCall }: WorklistPanelProps) => {
|
||||||
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');
|
// Missed tab always shows PENDING callbacks. Attempted/Completed/Invalid
|
||||||
|
// sub-tabs were removed per QA feedback — pending callbacks are the only
|
||||||
|
// ones agents need to act on from the worklist.
|
||||||
|
const missedSubTab: MissedSubTab = 'pending';
|
||||||
// Default SLA sort is ascending — the bucket-sorted result puts the
|
// Default SLA sort is ascending — the bucket-sorted result puts the
|
||||||
// most-urgent rows at the top (overdue → oldest reactive → soonest due).
|
// most-urgent rows at the top (overdue → oldest reactive → soonest due).
|
||||||
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'sla', direction: 'ascending' });
|
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'sla', direction: 'ascending' });
|
||||||
@@ -439,30 +442,9 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Missed call status sub-tabs */}
|
{/* Missed-call sub-tabs removed per QA feedback — the Missed tab
|
||||||
{tab === 'missed' && (
|
now only shows pending callbacks. Attempted is redundant once
|
||||||
<div className="flex shrink-0 gap-1 px-5 py-2 border-b border-secondary">
|
the worklist is the single source of truth. */}
|
||||||
{(['pending', 'attempted'] as MissedSubTab[]).map(sub => (
|
|
||||||
<button
|
|
||||||
key={sub}
|
|
||||||
onClick={() => { setMissedSubTab(sub); setPage(1); }}
|
|
||||||
className={cx(
|
|
||||||
'px-3 py-1 text-xs font-medium rounded-md capitalize transition duration-100 ease-linear',
|
|
||||||
missedSubTab === sub
|
|
||||||
? 'bg-brand-50 text-brand-700 border border-brand-200'
|
|
||||||
: 'text-tertiary hover:text-secondary hover:bg-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{sub}
|
|
||||||
{sub === 'pending' && missedByStatus.pending.length > 0 && (
|
|
||||||
<span className="ml-1.5 bg-error-50 text-error-700 text-xs px-1.5 py-0.5 rounded-full">
|
|
||||||
{missedByStatus.pending.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{filteredRows.length === 0 ? (
|
{filteredRows.length === 0 ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ const dispositionConfig: Record<CallDisposition, { label: string; color: 'succes
|
|||||||
WRONG_NUMBER: { label: 'Wrong Number', color: 'gray' },
|
WRONG_NUMBER: { label: 'Wrong Number', color: 'gray' },
|
||||||
NOT_INTERESTED: { label: 'Not Interested', color: 'error' },
|
NOT_INTERESTED: { label: 'Not Interested', color: 'error' },
|
||||||
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
|
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
|
||||||
|
CALL_DROPPED: { label: 'Call Dropped', color: 'gray' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const DirectionIcon = ({ direction, status }: { direction: CallDirection | null; status: Call['callStatus'] }) => {
|
const DirectionIcon = ({ direction, status }: { direction: CallDirection | null; status: Call['callStatus'] }) => {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const dispositionConfig: Record<CallDisposition, { label: string; color: 'succes
|
|||||||
WRONG_NUMBER: { label: 'Wrong Number', color: 'gray' },
|
WRONG_NUMBER: { label: 'Wrong Number', color: 'gray' },
|
||||||
NOT_INTERESTED: { label: 'Not Interested', color: 'error' },
|
NOT_INTERESTED: { label: 'Not Interested', color: 'error' },
|
||||||
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
|
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
|
||||||
|
CALL_DROPPED: { label: 'Call Dropped', color: 'gray' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDuration = (seconds: number | null): string => {
|
const formatDuration = (seconds: number | null): string => {
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ export const MyPerformancePage = () => {
|
|||||||
],
|
],
|
||||||
barWidth: '50%',
|
barWidth: '50%',
|
||||||
itemStyle: { borderRadius: [4, 4, 0, 0] },
|
itemStyle: { borderRadius: [4, 4, 0, 0] },
|
||||||
|
label: { show: true, position: 'top', fontSize: 11, color: '#344054', fontWeight: 600 },
|
||||||
}],
|
}],
|
||||||
}}
|
}}
|
||||||
style={{ height: 240 }}
|
style={{ height: 240 }}
|
||||||
@@ -244,8 +245,9 @@ export const MyPerformancePage = () => {
|
|||||||
type: 'pie',
|
type: 'pie',
|
||||||
radius: ['45%', '70%'],
|
radius: ['45%', '70%'],
|
||||||
center: ['35%', '50%'],
|
center: ['35%', '50%'],
|
||||||
avoidLabelOverlap: false,
|
avoidLabelOverlap: true,
|
||||||
label: { show: false },
|
label: { show: true, formatter: '{d}%', fontSize: 11, color: '#344054', fontWeight: 600 },
|
||||||
|
labelLine: { show: true, length: 6, length2: 6 },
|
||||||
data: Object.entries(data.dispositions).map(([name, value], i) => ({
|
data: Object.entries(data.dispositions).map(([name, value], i) => ({
|
||||||
name,
|
name,
|
||||||
value,
|
value,
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const DISPOSITION_COLORS: Record<CallDisposition, 'success' | 'brand' | 'blue' |
|
|||||||
NO_ANSWER: 'warning',
|
NO_ANSWER: 'warning',
|
||||||
NOT_INTERESTED: 'error',
|
NOT_INTERESTED: 'error',
|
||||||
CALLBACK_REQUESTED: 'gray',
|
CALLBACK_REQUESTED: 'gray',
|
||||||
|
CALL_DROPPED: 'gray',
|
||||||
};
|
};
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
|
|||||||
@@ -234,8 +234,8 @@ export const TeamPerformancePage = () => {
|
|||||||
xAxis: { type: 'category', data: days },
|
xAxis: { type: 'category', data: days },
|
||||||
yAxis: { type: 'value' },
|
yAxis: { type: 'value' },
|
||||||
series: [
|
series: [
|
||||||
{ name: 'Inbound', type: 'line', data: days.map(d => dayMap[d].inbound), smooth: true, color: '#2060A0' },
|
{ name: 'Inbound', type: 'line', data: days.map(d => dayMap[d].inbound), smooth: true, color: '#2060A0', label: { show: true, fontSize: 10, color: '#344054', fontWeight: 600, position: 'top' } },
|
||||||
{ name: 'Outbound', type: 'line', data: days.map(d => dayMap[d].outbound), smooth: true, color: '#E88C30' },
|
{ name: 'Outbound', type: 'line', data: days.map(d => dayMap[d].outbound), smooth: true, color: '#E88C30', label: { show: true, fontSize: 10, color: '#344054', fontWeight: 600, position: 'top' } },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}, [allCalls]);
|
}, [allCalls]);
|
||||||
|
|||||||
@@ -257,7 +257,8 @@ export type CallDisposition =
|
|||||||
| 'WRONG_NUMBER'
|
| 'WRONG_NUMBER'
|
||||||
| 'NO_ANSWER'
|
| 'NO_ANSWER'
|
||||||
| 'NOT_INTERESTED'
|
| 'NOT_INTERESTED'
|
||||||
| 'CALLBACK_REQUESTED';
|
| 'CALLBACK_REQUESTED'
|
||||||
|
| 'CALL_DROPPED';
|
||||||
|
|
||||||
export type Call = {
|
export type Call = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user