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:
2026-04-01 16:51:53 +05:30
parent 4f5370abdc
commit 442a581c8a
4 changed files with 118 additions and 75 deletions

View File

@@ -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 */}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>