mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
fix: appointment/enquiry modals + team performance fallback
- Appointment form: converted from inline to modal dialog, removed Returning Patient checkbox - Enquiry form: converted from inline to modal dialog - Active call card: removed max-h-[50vh] scroll container, forms render as modals - Team Performance: fallback agent list from call records when Ozonetel unavailable - NPS/Time sections show placeholder when data unavailable Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -287,31 +287,26 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable: expanded forms */}
|
<AppointmentForm
|
||||||
{(appointmentOpen || enquiryOpen) && (
|
isOpen={appointmentOpen}
|
||||||
<div className="min-h-0 max-h-[50vh] overflow-y-auto border-t border-secondary px-4 pb-4">
|
onOpenChange={setAppointmentOpen}
|
||||||
<AppointmentForm
|
callerNumber={callerPhone}
|
||||||
isOpen={appointmentOpen}
|
leadName={fullName || null}
|
||||||
onOpenChange={setAppointmentOpen}
|
leadId={lead?.id ?? null}
|
||||||
callerNumber={callerPhone}
|
patientId={(lead as any)?.patientId ?? null}
|
||||||
leadName={fullName || null}
|
onSaved={handleAppointmentSaved}
|
||||||
leadId={lead?.id ?? null}
|
/>
|
||||||
patientId={(lead as any)?.patientId ?? null}
|
|
||||||
onSaved={handleAppointmentSaved}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EnquiryForm
|
<EnquiryForm
|
||||||
isOpen={enquiryOpen}
|
isOpen={enquiryOpen}
|
||||||
onOpenChange={setEnquiryOpen}
|
onOpenChange={setEnquiryOpen}
|
||||||
callerPhone={callerPhone}
|
callerPhone={callerPhone}
|
||||||
onSaved={() => {
|
onSaved={() => {
|
||||||
setEnquiryOpen(false);
|
setEnquiryOpen(false);
|
||||||
setSuggestedDisposition('INFO_PROVIDED');
|
setSuggestedDisposition('INFO_PROVIDED');
|
||||||
notify.success('Enquiry Logged');
|
notify.success('Enquiry Logged');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Disposition Modal — the ONLY path to end a call */}
|
{/* Disposition Modal — the ONLY path to end a call */}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ const XClose = faIcon(faXmark);
|
|||||||
import { Input } from '@/components/base/input/input';
|
import { Input } from '@/components/base/input/input';
|
||||||
import { Select } from '@/components/base/select/select';
|
import { Select } from '@/components/base/select/select';
|
||||||
import { TextArea } from '@/components/base/textarea/textarea';
|
import { TextArea } from '@/components/base/textarea/textarea';
|
||||||
import { Checkbox } from '@/components/base/checkbox/checkbox';
|
|
||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { ModalOverlay, Modal, Dialog } from '@/components/application/modals/modal';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from '@/utils/cx';
|
||||||
import { notify } from '@/lib/toast';
|
import { notify } from '@/lib/toast';
|
||||||
@@ -100,7 +100,6 @@ export const AppointmentForm = ({
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
const [chiefComplaint, setChiefComplaint] = useState(existingAppointment?.reasonForVisit ?? '');
|
const [chiefComplaint, setChiefComplaint] = useState(existingAppointment?.reasonForVisit ?? '');
|
||||||
const [isReturning, setIsReturning] = useState(false);
|
|
||||||
const [source, setSource] = useState('Inbound Call');
|
const [source, setSource] = useState('Inbound Call');
|
||||||
const [agentNotes, setAgentNotes] = useState('');
|
const [agentNotes, setAgentNotes] = useState('');
|
||||||
|
|
||||||
@@ -298,10 +297,11 @@ export const AppointmentForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
<ModalOverlay isOpen={isOpen} onOpenChange={onOpenChange} isDismissable>
|
||||||
|
<Modal className="max-w-2xl">
|
||||||
|
<Dialog>
|
||||||
|
<div className="p-4">
|
||||||
{/* Header with close button */}
|
{/* Header with close button */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -474,13 +474,6 @@ export const AppointmentForm = ({
|
|||||||
<>
|
<>
|
||||||
<div className="border-t border-secondary" />
|
<div className="border-t border-secondary" />
|
||||||
|
|
||||||
<Checkbox
|
|
||||||
isSelected={isReturning}
|
|
||||||
onChange={setIsReturning}
|
|
||||||
label="Returning Patient"
|
|
||||||
hint="Check if the patient has visited before"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="Source / Referral"
|
label="Source / Referral"
|
||||||
placeholder="How did the patient reach us?"
|
placeholder="How did the patient reach us?"
|
||||||
@@ -523,6 +516,9 @@ export const AppointmentForm = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Modal>
|
||||||
|
</ModalOverlay>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Select } from '@/components/base/select/select';
|
|||||||
import { TextArea } from '@/components/base/textarea/textarea';
|
import { TextArea } from '@/components/base/textarea/textarea';
|
||||||
import { Checkbox } from '@/components/base/checkbox/checkbox';
|
import { Checkbox } from '@/components/base/checkbox/checkbox';
|
||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { ModalOverlay, Modal, Dialog } from '@/components/application/modals/modal';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { notify } from '@/lib/toast';
|
import { notify } from '@/lib/toast';
|
||||||
|
|
||||||
@@ -138,10 +139,11 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
<ModalOverlay isOpen={isOpen} onOpenChange={onOpenChange} isDismissable>
|
||||||
|
<Modal className="max-w-xl">
|
||||||
|
<Dialog>
|
||||||
|
<div className="p-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex size-8 items-center justify-center rounded-lg bg-warning-secondary">
|
<div className="flex size-8 items-center justify-center rounded-lg bg-warning-secondary">
|
||||||
@@ -208,6 +210,9 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
|
|||||||
{isSaving ? 'Saving...' : 'Log Enquiry'}
|
{isSaving ? 'Saving...' : 'Log Enquiry'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Modal>
|
||||||
|
</ModalOverlay>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -107,42 +107,78 @@ export const TeamPerformancePage = () => {
|
|||||||
setAllAppointments(appts);
|
setAllAppointments(appts);
|
||||||
|
|
||||||
// Build per-agent metrics
|
// Build per-agent metrics
|
||||||
const agentPerfs: AgentPerf[] = teamAgents.map((agent: any) => {
|
let agentPerfs: AgentPerf[];
|
||||||
const agentCalls = calls.filter((c: any) => c.agentName === agent.name || c.agentName === agent.ozonetelagentid);
|
|
||||||
const agentLeads = leads.filter((l: any) => l.assignedAgent === agent.name);
|
|
||||||
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === agent.name);
|
|
||||||
const agentAppts = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length; // approximate
|
|
||||||
const totalCalls = agentCalls.length;
|
|
||||||
const inbound = agentCalls.filter((c: any) => c.direction === 'INBOUND').length;
|
|
||||||
const missed = agentCalls.filter((c: any) => c.callStatus === 'MISSED').length;
|
|
||||||
|
|
||||||
const tb = agent.timeBreakdown;
|
if (teamAgents.length > 0) {
|
||||||
const idleSec = tb ? parseTime(tb.totalIdleTime ?? '0:0:0') : 0;
|
// Real Ozonetel data available
|
||||||
const activeSec = tb ? parseTime(tb.totalBusyTime ?? '0:0:0') : 0;
|
agentPerfs = teamAgents.map((agent: any) => {
|
||||||
const wrapSec = tb ? parseTime(tb.totalWrapupTime ?? '0:0:0') : 0;
|
const agentCalls = calls.filter((c: any) => c.agentName === agent.name || c.agentName === agent.ozonetelagentid);
|
||||||
const breakSec = tb ? parseTime(tb.totalPauseTime ?? '0:0:0') : 0;
|
const agentLeads = leads.filter((l: any) => l.assignedAgent === agent.name);
|
||||||
|
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === agent.name);
|
||||||
|
const agentAppts = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
|
||||||
|
const totalCalls = agentCalls.length;
|
||||||
|
const inbound = agentCalls.filter((c: any) => c.direction === 'INBOUND').length;
|
||||||
|
const missed = agentCalls.filter((c: any) => c.callStatus === 'MISSED').length;
|
||||||
|
|
||||||
return {
|
const tb = agent.timeBreakdown;
|
||||||
name: agent.name ?? agent.ozonetelagentid,
|
const idleSec = tb ? parseTime(tb.totalIdleTime ?? '0:0:0') : 0;
|
||||||
ozonetelagentid: agent.ozonetelagentid,
|
const activeSec = tb ? parseTime(tb.totalBusyTime ?? '0:0:0') : 0;
|
||||||
npsscore: agent.npsscore,
|
const wrapSec = tb ? parseTime(tb.totalWrapupTime ?? '0:0:0') : 0;
|
||||||
maxidleminutes: agent.maxidleminutes,
|
const breakSec = tb ? parseTime(tb.totalPauseTime ?? '0:0:0') : 0;
|
||||||
minnpsthreshold: agent.minnpsthreshold,
|
|
||||||
minconversionpercent: agent.minconversionpercent,
|
return {
|
||||||
calls: totalCalls,
|
name: agent.name ?? agent.ozonetelagentid,
|
||||||
inbound,
|
ozonetelagentid: agent.ozonetelagentid,
|
||||||
missed,
|
npsscore: agent.npsscore,
|
||||||
followUps: agentFollowUps.length,
|
maxidleminutes: agent.maxidleminutes,
|
||||||
leads: agentLeads.length,
|
minnpsthreshold: agent.minnpsthreshold,
|
||||||
appointments: agentAppts,
|
minconversionpercent: agent.minconversionpercent,
|
||||||
convPercent: totalCalls > 0 ? Math.round((agentAppts / totalCalls) * 100) : 0,
|
calls: totalCalls,
|
||||||
idleMinutes: Math.round(idleSec / 60),
|
inbound,
|
||||||
activeMinutes: Math.round(activeSec / 60),
|
missed,
|
||||||
wrapMinutes: Math.round(wrapSec / 60),
|
followUps: agentFollowUps.length,
|
||||||
breakMinutes: Math.round(breakSec / 60),
|
leads: agentLeads.length,
|
||||||
timeBreakdown: tb,
|
appointments: agentAppts,
|
||||||
};
|
convPercent: totalCalls > 0 ? Math.round((agentAppts / totalCalls) * 100) : 0,
|
||||||
});
|
idleMinutes: Math.round(idleSec / 60),
|
||||||
|
activeMinutes: Math.round(activeSec / 60),
|
||||||
|
wrapMinutes: Math.round(wrapSec / 60),
|
||||||
|
breakMinutes: Math.round(breakSec / 60),
|
||||||
|
timeBreakdown: tb,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback: build agent list from call records
|
||||||
|
const agentNames = [...new Set(calls.map((c: any) => c.agentName).filter(Boolean))] as string[];
|
||||||
|
agentPerfs = agentNames.map((name) => {
|
||||||
|
const agentCalls = calls.filter((c: any) => c.agentName === name);
|
||||||
|
const agentLeads = leads.filter((l: any) => l.assignedAgent === name);
|
||||||
|
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === name);
|
||||||
|
const completed = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
|
||||||
|
const totalCalls = agentCalls.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
ozonetelagentid: name,
|
||||||
|
npsscore: null,
|
||||||
|
maxidleminutes: null,
|
||||||
|
minnpsthreshold: null,
|
||||||
|
minconversionpercent: null,
|
||||||
|
calls: totalCalls,
|
||||||
|
inbound: agentCalls.filter((c: any) => c.direction === 'INBOUND').length,
|
||||||
|
missed: agentCalls.filter((c: any) => c.callStatus === 'MISSED').length,
|
||||||
|
followUps: agentFollowUps.length,
|
||||||
|
leads: agentLeads.length,
|
||||||
|
appointments: completed,
|
||||||
|
convPercent: totalCalls > 0 ? Math.round((completed / totalCalls) * 100) : 0,
|
||||||
|
idleMinutes: 0,
|
||||||
|
activeMinutes: 0,
|
||||||
|
wrapMinutes: 0,
|
||||||
|
breakMinutes: 0,
|
||||||
|
timeBreakdown: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setAgents(agentPerfs);
|
setAgents(agentPerfs);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -329,6 +365,9 @@ export const TeamPerformancePage = () => {
|
|||||||
<div className="px-6 pt-6">
|
<div className="px-6 pt-6">
|
||||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||||
<h3 className="text-sm font-semibold text-secondary mb-3">Time Breakdown</h3>
|
<h3 className="text-sm font-semibold text-secondary mb-3">Time Breakdown</h3>
|
||||||
|
{teamAvg.active === 0 && teamAvg.idle === 0 && teamAvg.wrap === 0 && teamAvg.break_ === 0 && (
|
||||||
|
<p className="text-xs text-tertiary mb-3">Time utilisation data unavailable — requires Ozonetel agent session data.</p>
|
||||||
|
)}
|
||||||
<div className="flex gap-6 mb-4 px-2">
|
<div className="flex gap-6 mb-4 px-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="size-3 rounded-sm bg-success-solid" />
|
<div className="size-3 rounded-sm bg-success-solid" />
|
||||||
@@ -378,6 +417,12 @@ 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) ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<p className="text-xs text-tertiary">NPS data unavailable — configure NPS scores on agent profiles.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<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 => (
|
||||||
@@ -390,6 +435,8 @@ export const TeamPerformancePage = () => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<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-3">Conversion Metrics</h3>
|
<h3 className="text-sm font-semibold text-secondary mb-3">Conversion Metrics</h3>
|
||||||
|
|||||||
Reference in New Issue
Block a user