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'}
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
|
||||
isDisabled={!wasAnsweredRef.current}
|
||||
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>Book Appt</Button>
|
||||
<Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'}
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
|
||||
isDisabled={!wasAnsweredRef.current}
|
||||
onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); setTransferOpen(false); }}>Enquiry</Button>
|
||||
<Button size="sm" color={transferOpen ? 'primary' : 'secondary'}
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
|
||||
isDisabled={!wasAnsweredRef.current}
|
||||
onClick={() => { setTransferOpen(!transferOpen); setAppointmentOpen(false); setEnquiryOpen(false); }}>Transfer</Button>
|
||||
|
||||
<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' },
|
||||
NOT_INTERESTED: { label: 'Not Interested', color: 'error' },
|
||||
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
|
||||
CALL_DROPPED: { label: 'Call Dropped', color: 'gray' },
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
// 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 (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 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">
|
||||
<AiChatPanel callerContext={callerContext} onChatStart={handleChatStart} />
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -102,6 +102,12 @@ const dispositionOptions: Array<{
|
||||
activeClass: 'bg-utility-blue-600 text-white border-transparent',
|
||||
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 = {
|
||||
|
||||
@@ -59,6 +59,7 @@ const dispositionLabels: Record<CallDisposition, string> = {
|
||||
WRONG_NUMBER: 'Wrong Number',
|
||||
NOT_INTERESTED: 'Not Interested',
|
||||
CALLBACK_REQUESTED: 'Callback Requested',
|
||||
CALL_DROPPED: 'Call Dropped',
|
||||
};
|
||||
|
||||
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) => {
|
||||
const [tab, setTab] = useState<TabKey>('all');
|
||||
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
|
||||
// most-urgent rows at the top (overdue → oldest reactive → soonest due).
|
||||
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'sla', direction: 'ascending' });
|
||||
@@ -439,30 +442,9 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Missed call status sub-tabs */}
|
||||
{tab === 'missed' && (
|
||||
<div className="flex shrink-0 gap-1 px-5 py-2 border-b border-secondary">
|
||||
{(['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>
|
||||
)}
|
||||
{/* Missed-call sub-tabs removed per QA feedback — the Missed tab
|
||||
now only shows pending callbacks. Attempted is redundant once
|
||||
the worklist is the single source of truth. */}
|
||||
|
||||
{filteredRows.length === 0 ? (
|
||||
<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' },
|
||||
NOT_INTERESTED: { label: 'Not Interested', color: 'error' },
|
||||
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
|
||||
CALL_DROPPED: { label: 'Call Dropped', color: 'gray' },
|
||||
};
|
||||
|
||||
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' },
|
||||
NOT_INTERESTED: { label: 'Not Interested', color: 'error' },
|
||||
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
|
||||
CALL_DROPPED: { label: 'Call Dropped', color: 'gray' },
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number | null): string => {
|
||||
|
||||
@@ -217,6 +217,7 @@ export const MyPerformancePage = () => {
|
||||
],
|
||||
barWidth: '50%',
|
||||
itemStyle: { borderRadius: [4, 4, 0, 0] },
|
||||
label: { show: true, position: 'top', fontSize: 11, color: '#344054', fontWeight: 600 },
|
||||
}],
|
||||
}}
|
||||
style={{ height: 240 }}
|
||||
@@ -244,8 +245,9 @@ export const MyPerformancePage = () => {
|
||||
type: 'pie',
|
||||
radius: ['45%', '70%'],
|
||||
center: ['35%', '50%'],
|
||||
avoidLabelOverlap: false,
|
||||
label: { show: false },
|
||||
avoidLabelOverlap: true,
|
||||
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) => ({
|
||||
name,
|
||||
value,
|
||||
|
||||
@@ -59,6 +59,7 @@ const DISPOSITION_COLORS: Record<CallDisposition, 'success' | 'brand' | 'blue' |
|
||||
NO_ANSWER: 'warning',
|
||||
NOT_INTERESTED: 'error',
|
||||
CALLBACK_REQUESTED: 'gray',
|
||||
CALL_DROPPED: 'gray',
|
||||
};
|
||||
|
||||
const TABS = [
|
||||
|
||||
@@ -234,8 +234,8 @@ export const TeamPerformancePage = () => {
|
||||
xAxis: { type: 'category', data: days },
|
||||
yAxis: { type: 'value' },
|
||||
series: [
|
||||
{ name: 'Inbound', type: 'line', data: days.map(d => dayMap[d].inbound), smooth: true, color: '#2060A0' },
|
||||
{ name: 'Outbound', type: 'line', data: days.map(d => dayMap[d].outbound), smooth: true, color: '#E88C30' },
|
||||
{ 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', label: { show: true, fontSize: 10, color: '#344054', fontWeight: 600, position: 'top' } },
|
||||
],
|
||||
};
|
||||
}, [allCalls]);
|
||||
|
||||
@@ -257,7 +257,8 @@ export type CallDisposition =
|
||||
| 'WRONG_NUMBER'
|
||||
| 'NO_ANSWER'
|
||||
| 'NOT_INTERESTED'
|
||||
| 'CALLBACK_REQUESTED';
|
||||
| 'CALLBACK_REQUESTED'
|
||||
| 'CALL_DROPPED';
|
||||
|
||||
export type Call = {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user