mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 10:23:27 +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>
|
||||
|
||||
{/* Scrollable: expanded forms */}
|
||||
{(appointmentOpen || enquiryOpen) && (
|
||||
<div className="min-h-0 max-h-[50vh] overflow-y-auto border-t border-secondary px-4 pb-4">
|
||||
<AppointmentForm
|
||||
isOpen={appointmentOpen}
|
||||
onOpenChange={setAppointmentOpen}
|
||||
callerNumber={callerPhone}
|
||||
leadName={fullName || null}
|
||||
leadId={lead?.id ?? null}
|
||||
patientId={(lead as any)?.patientId ?? null}
|
||||
onSaved={handleAppointmentSaved}
|
||||
/>
|
||||
<AppointmentForm
|
||||
isOpen={appointmentOpen}
|
||||
onOpenChange={setAppointmentOpen}
|
||||
callerNumber={callerPhone}
|
||||
leadName={fullName || null}
|
||||
leadId={lead?.id ?? null}
|
||||
patientId={(lead as any)?.patientId ?? null}
|
||||
onSaved={handleAppointmentSaved}
|
||||
/>
|
||||
|
||||
<EnquiryForm
|
||||
isOpen={enquiryOpen}
|
||||
onOpenChange={setEnquiryOpen}
|
||||
callerPhone={callerPhone}
|
||||
onSaved={() => {
|
||||
setEnquiryOpen(false);
|
||||
setSuggestedDisposition('INFO_PROVIDED');
|
||||
notify.success('Enquiry Logged');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<EnquiryForm
|
||||
isOpen={enquiryOpen}
|
||||
onOpenChange={setEnquiryOpen}
|
||||
callerPhone={callerPhone}
|
||||
onSaved={() => {
|
||||
setEnquiryOpen(false);
|
||||
setSuggestedDisposition('INFO_PROVIDED');
|
||||
notify.success('Enquiry Logged');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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 { Select } from '@/components/base/select/select';
|
||||
import { TextArea } from '@/components/base/textarea/textarea';
|
||||
import { Checkbox } from '@/components/base/checkbox/checkbox';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { ModalOverlay, Modal, Dialog } from '@/components/application/modals/modal';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { notify } from '@/lib/toast';
|
||||
@@ -100,7 +100,6 @@ export const AppointmentForm = ({
|
||||
return null;
|
||||
});
|
||||
const [chiefComplaint, setChiefComplaint] = useState(existingAppointment?.reasonForVisit ?? '');
|
||||
const [isReturning, setIsReturning] = useState(false);
|
||||
const [source, setSource] = useState('Inbound Call');
|
||||
const [agentNotes, setAgentNotes] = useState('');
|
||||
|
||||
@@ -298,10 +297,11 @@ export const AppointmentForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
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 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -474,13 +474,6 @@ export const AppointmentForm = ({
|
||||
<>
|
||||
<div className="border-t border-secondary" />
|
||||
|
||||
<Checkbox
|
||||
isSelected={isReturning}
|
||||
onChange={setIsReturning}
|
||||
label="Returning Patient"
|
||||
hint="Check if the patient has visited before"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Source / Referral"
|
||||
placeholder="How did the patient reach us?"
|
||||
@@ -523,6 +516,9 @@ export const AppointmentForm = ({
|
||||
</Button>
|
||||
</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 { Checkbox } from '@/components/base/checkbox/checkbox';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { ModalOverlay, Modal, Dialog } from '@/components/application/modals/modal';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
|
||||
@@ -138,10 +139,11 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
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 gap-3">
|
||||
<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'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -107,42 +107,78 @@ export const TeamPerformancePage = () => {
|
||||
setAllAppointments(appts);
|
||||
|
||||
// Build per-agent metrics
|
||||
const agentPerfs: AgentPerf[] = teamAgents.map((agent: any) => {
|
||||
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;
|
||||
let agentPerfs: AgentPerf[];
|
||||
|
||||
const tb = agent.timeBreakdown;
|
||||
const idleSec = tb ? parseTime(tb.totalIdleTime ?? '0:0:0') : 0;
|
||||
const activeSec = tb ? parseTime(tb.totalBusyTime ?? '0:0:0') : 0;
|
||||
const wrapSec = tb ? parseTime(tb.totalWrapupTime ?? '0:0:0') : 0;
|
||||
const breakSec = tb ? parseTime(tb.totalPauseTime ?? '0:0:0') : 0;
|
||||
if (teamAgents.length > 0) {
|
||||
// Real Ozonetel data available
|
||||
agentPerfs = teamAgents.map((agent: any) => {
|
||||
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;
|
||||
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 {
|
||||
name: agent.name ?? agent.ozonetelagentid,
|
||||
ozonetelagentid: agent.ozonetelagentid,
|
||||
npsscore: agent.npsscore,
|
||||
maxidleminutes: agent.maxidleminutes,
|
||||
minnpsthreshold: agent.minnpsthreshold,
|
||||
minconversionpercent: agent.minconversionpercent,
|
||||
calls: totalCalls,
|
||||
inbound,
|
||||
missed,
|
||||
followUps: agentFollowUps.length,
|
||||
leads: agentLeads.length,
|
||||
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,
|
||||
};
|
||||
});
|
||||
const tb = agent.timeBreakdown;
|
||||
const idleSec = tb ? parseTime(tb.totalIdleTime ?? '0:0:0') : 0;
|
||||
const activeSec = tb ? parseTime(tb.totalBusyTime ?? '0:0:0') : 0;
|
||||
const wrapSec = tb ? parseTime(tb.totalWrapupTime ?? '0:0:0') : 0;
|
||||
const breakSec = tb ? parseTime(tb.totalPauseTime ?? '0:0:0') : 0;
|
||||
|
||||
return {
|
||||
name: agent.name ?? agent.ozonetelagentid,
|
||||
ozonetelagentid: agent.ozonetelagentid,
|
||||
npsscore: agent.npsscore,
|
||||
maxidleminutes: agent.maxidleminutes,
|
||||
minnpsthreshold: agent.minnpsthreshold,
|
||||
minconversionpercent: agent.minconversionpercent,
|
||||
calls: totalCalls,
|
||||
inbound,
|
||||
missed,
|
||||
followUps: agentFollowUps.length,
|
||||
leads: agentLeads.length,
|
||||
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);
|
||||
} catch (err) {
|
||||
@@ -329,6 +365,9 @@ export const TeamPerformancePage = () => {
|
||||
<div className="px-6 pt-6">
|
||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||
<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 items-center gap-2">
|
||||
<div className="size-3 rounded-sm bg-success-solid" />
|
||||
@@ -378,6 +417,12 @@ export const TeamPerformancePage = () => {
|
||||
<div className="flex gap-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>
|
||||
{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 }} />
|
||||
<div className="space-y-1 mt-2">
|
||||
{agents.filter(a => a.npsscore != null).map(a => (
|
||||
@@ -390,6 +435,8 @@ export const TeamPerformancePage = () => {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user