mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
feat: inline forms, transfer redesign, patient fixes, UI polish
- Appointment/enquiry forms reverted to inline rendering (not modals) - Forms: flat scrollable section with pinned footer, no card wrapper - Appointment form: DatePicker component, date prefilled, removed Returning Patient checkbox - Enquiry form: removed disposition dropdown, lead status defaults to CONTACTED - Transfer dialog: agent picker with live status, doctor list with department, select-then-connect flow - Transfer: removed external number input, moved Cancel/Connect to pinned header row - Button mutual exclusivity: Book Appt / Enquiry / Transfer close each other - Patient name write-back: appointment + enquiry forms update patient fullName after save - Caller cache invalidation: POST /api/caller/invalidate after name update - Follow-up fix (#513): assignedAgent, patientId, date validation in createFollowUp - Patients page: removed status filters + column, added pagination (15/page) - Pending badge removed from call desk header - Table resize handles visible (bg-tertiary pill) - Sim call button: dev-only (import.meta.env.DEV) - CallControlStrip component (reusable, not currently mounted) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,49 +1,157 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faXmark } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { faPhone, faUserDoctor, faHeadset, faShieldCheck, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { faIcon } from '@/lib/icon-wrapper';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
const SearchIcon = faIcon(faMagnifyingGlass);
|
||||
|
||||
type TransferTarget = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'agent' | 'supervisor' | 'doctor';
|
||||
department?: string;
|
||||
phoneNumber: string;
|
||||
status?: 'ready' | 'busy' | 'offline' | 'on-call' | 'break';
|
||||
};
|
||||
|
||||
type TransferDialogProps = {
|
||||
ucid: string;
|
||||
currentAgentId?: string;
|
||||
onClose: () => void;
|
||||
onTransferred: () => void;
|
||||
};
|
||||
|
||||
export const TransferDialog = ({ ucid, onClose, onTransferred }: TransferDialogProps) => {
|
||||
const [number, setNumber] = useState('');
|
||||
const [transferring, setTransferring] = useState(false);
|
||||
const [stage, setStage] = useState<'input' | 'connected'>('input');
|
||||
const statusConfig: Record<string, { label: string; dotClass: string }> = {
|
||||
ready: { label: 'Ready', dotClass: 'bg-success-solid' },
|
||||
'on-call': { label: 'On Call', dotClass: 'bg-error-solid' },
|
||||
'in-call': { label: 'On Call', dotClass: 'bg-error-solid' },
|
||||
busy: { label: 'Busy', dotClass: 'bg-warning-solid' },
|
||||
acw: { label: 'Wrapping', dotClass: 'bg-warning-solid' },
|
||||
break: { label: 'Break', dotClass: 'bg-tertiary' },
|
||||
training: { label: 'Training', dotClass: 'bg-tertiary' },
|
||||
offline: { label: 'Offline', dotClass: 'bg-quaternary' },
|
||||
};
|
||||
|
||||
const handleConference = async () => {
|
||||
if (!number.trim()) return;
|
||||
const typeIcons = {
|
||||
agent: faHeadset,
|
||||
supervisor: faShieldCheck,
|
||||
doctor: faUserDoctor,
|
||||
};
|
||||
|
||||
export const TransferDialog = ({ ucid, currentAgentId, onClose, onTransferred }: TransferDialogProps) => {
|
||||
const [targets, setTargets] = useState<TransferTarget[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [transferring, setTransferring] = useState(false);
|
||||
const [selectedTarget, setSelectedTarget] = useState<TransferTarget | null>(null);
|
||||
const [connectedTarget, setConnectedTarget] = useState<TransferTarget | null>(null);
|
||||
|
||||
// Fetch transfer targets
|
||||
useEffect(() => {
|
||||
const fetchTargets = async () => {
|
||||
try {
|
||||
const [agentsRes, doctorsRes] = await Promise.all([
|
||||
apiClient.graphql<any>(`{ agents(first: 20) { edges { node { id name ozonetelagentid sipextension } } } }`),
|
||||
apiClient.graphql<any>(`{ doctors(first: 20) { edges { node { id name department phone { primaryPhoneNumber } } } } }`),
|
||||
]);
|
||||
|
||||
const agents: TransferTarget[] = (agentsRes.agents?.edges ?? [])
|
||||
.map((e: any) => e.node)
|
||||
.filter((a: any) => a.ozonetelagentid !== currentAgentId)
|
||||
.map((a: any) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
type: 'agent' as const,
|
||||
phoneNumber: `0${a.sipextension}`,
|
||||
status: 'offline' as const,
|
||||
}));
|
||||
|
||||
const doctors: TransferTarget[] = (doctorsRes.doctors?.edges ?? [])
|
||||
.map((e: any) => e.node)
|
||||
.filter((d: any) => d.phone?.primaryPhoneNumber)
|
||||
.map((d: any) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
type: 'doctor' as const,
|
||||
department: d.department?.replace(/_/g, ' '),
|
||||
phoneNumber: `0${d.phone.primaryPhoneNumber}`,
|
||||
}));
|
||||
|
||||
setTargets([...agents, ...doctors]);
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch transfer targets:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchTargets();
|
||||
}, [currentAgentId]);
|
||||
|
||||
// Subscribe to agent state via SSE for live status
|
||||
useEffect(() => {
|
||||
const agentTargets = targets.filter(t => t.type === 'agent');
|
||||
if (agentTargets.length === 0) return;
|
||||
|
||||
// Poll agent states from the supervisor endpoint
|
||||
const fetchStates = async () => {
|
||||
for (const agent of agentTargets) {
|
||||
try {
|
||||
const res = await apiClient.get<any>(`/api/supervisor/agent-state/${agent.phoneNumber.replace(/^0/, '')}`, { silent: true });
|
||||
if (res?.state) {
|
||||
setTargets(prev => prev.map(t =>
|
||||
t.id === agent.id ? { ...t, status: res.state } : t,
|
||||
));
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
};
|
||||
fetchStates();
|
||||
const interval = setInterval(fetchStates, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [targets.length]);
|
||||
|
||||
const filtered = search.trim()
|
||||
? targets.filter(t => t.name.toLowerCase().includes(search.toLowerCase()) || (t.department ?? '').toLowerCase().includes(search.toLowerCase()))
|
||||
: targets;
|
||||
|
||||
const agents = filtered.filter(t => t.type === 'agent');
|
||||
const doctors = filtered.filter(t => t.type === 'doctor');
|
||||
|
||||
const handleConnect = async () => {
|
||||
const target = selectedTarget;
|
||||
if (!target) return;
|
||||
setTransferring(true);
|
||||
try {
|
||||
await apiClient.post('/api/ozonetel/call-control', {
|
||||
action: 'CONFERENCE',
|
||||
ucid,
|
||||
conferenceNumber: `0${number.replace(/\D/g, '')}`,
|
||||
conferenceNumber: target.phoneNumber,
|
||||
});
|
||||
notify.success('Connected', 'Third party connected. Click Complete to transfer.');
|
||||
setStage('connected');
|
||||
setConnectedTarget(target);
|
||||
notify.success('Connected', `Speaking with ${target.name}. Click Complete to transfer.`);
|
||||
} catch {
|
||||
notify.error('Transfer Failed', 'Could not connect to the target number');
|
||||
notify.error('Transfer Failed', `Could not connect to ${target.name}`);
|
||||
} finally {
|
||||
setTransferring(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (!connectedTarget) return;
|
||||
setTransferring(true);
|
||||
try {
|
||||
await apiClient.post('/api/ozonetel/call-control', {
|
||||
action: 'KICK_CALL',
|
||||
ucid,
|
||||
conferenceNumber: `0${number.replace(/\D/g, '')}`,
|
||||
conferenceNumber: connectedTarget.phoneNumber,
|
||||
});
|
||||
notify.success('Transferred', 'Call transferred successfully');
|
||||
notify.success('Transferred', `Call transferred to ${connectedTarget.name}`);
|
||||
onTransferred();
|
||||
} catch {
|
||||
notify.error('Transfer Failed', 'Could not complete transfer');
|
||||
@@ -52,40 +160,138 @@ export const TransferDialog = ({ ucid, onClose, onTransferred }: TransferDialogP
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-lg border border-secondary bg-secondary p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-secondary">Transfer Call</span>
|
||||
<button onClick={onClose} className="text-fg-quaternary hover:text-fg-secondary transition duration-100 ease-linear">
|
||||
<FontAwesomeIcon icon={faXmark} className="size-3" />
|
||||
</button>
|
||||
const handleCancel = async () => {
|
||||
if (!connectedTarget) { onClose(); return; }
|
||||
// Disconnect the third party, keep the caller
|
||||
setTransferring(true);
|
||||
try {
|
||||
await apiClient.post('/api/ozonetel/call-control', {
|
||||
action: 'KICK_CALL',
|
||||
ucid,
|
||||
conferenceNumber: connectedTarget.phoneNumber,
|
||||
});
|
||||
setConnectedTarget(null);
|
||||
notify.info('Cancelled', 'Transfer cancelled, caller reconnected');
|
||||
} catch {
|
||||
notify.error('Error', 'Could not disconnect third party');
|
||||
} finally {
|
||||
setTransferring(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Connected state — show target + complete/cancel buttons
|
||||
if (connectedTarget) {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex items-center justify-center gap-3 py-8">
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-success-secondary">
|
||||
<FontAwesomeIcon icon={typeIcons[connectedTarget.type] ?? faPhone} className="size-4 text-fg-success-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-primary">Connected to {connectedTarget.name}</p>
|
||||
<p className="text-xs text-tertiary">Speak privately, then complete the transfer</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center justify-center gap-3 pt-4 border-t border-secondary">
|
||||
<Button size="sm" color="secondary" onClick={handleCancel} isLoading={transferring}>Cancel</Button>
|
||||
<Button size="sm" color="primary" onClick={handleComplete} isLoading={transferring}>Complete Transfer</Button>
|
||||
</div>
|
||||
</div>
|
||||
{stage === 'input' ? (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
size="sm"
|
||||
placeholder="Enter phone number"
|
||||
value={number}
|
||||
onChange={setNumber}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
isLoading={transferring}
|
||||
onClick={handleConference}
|
||||
isDisabled={!number.trim()}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Target selection
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{/* Search + actions — pinned */}
|
||||
<div className="shrink-0 flex items-center gap-2 mb-3">
|
||||
<div className="flex-1">
|
||||
<Input size="sm" placeholder="Search agent, doctor..." icon={SearchIcon} value={search} onChange={setSearch} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-tertiary">Connected to {number}</span>
|
||||
<Button size="sm" color="primary" isLoading={transferring} onClick={handleComplete}>
|
||||
Complete Transfer
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Button size="sm" color="secondary" onClick={onClose}>Cancel</Button>
|
||||
<Button size="sm" color="primary" isLoading={transferring} isDisabled={!selectedTarget} onClick={handleConnect}>
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable target list */}
|
||||
<div className="flex-1 overflow-y-auto space-y-4">
|
||||
{loading ? (
|
||||
<p className="text-xs text-tertiary text-center py-4">Loading...</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Agents */}
|
||||
{agents.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-tertiary mb-2">Agents</p>
|
||||
<div className="space-y-1">
|
||||
{agents.map(agent => {
|
||||
const st = statusConfig[agent.status ?? 'offline'] ?? statusConfig.offline;
|
||||
const isSelected = selectedTarget?.id === agent.id;
|
||||
return (
|
||||
<button
|
||||
key={agent.id}
|
||||
onClick={() => setSelectedTarget(agent)}
|
||||
disabled={transferring}
|
||||
className={cx(
|
||||
'flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-left transition duration-100 ease-linear',
|
||||
isSelected ? 'bg-brand-secondary ring-2 ring-brand' : 'hover:bg-secondary cursor-pointer',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<FontAwesomeIcon icon={faHeadset} className="size-3.5 text-fg-quaternary" />
|
||||
<span className="text-sm font-medium text-primary">{agent.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cx('size-2 rounded-full', st.dotClass)} />
|
||||
<span className="text-xs text-tertiary">{st.label}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Doctors */}
|
||||
{doctors.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-tertiary mb-2">Doctors</p>
|
||||
<div className="space-y-1">
|
||||
{doctors.map(doc => {
|
||||
const isSelected = selectedTarget?.id === doc.id;
|
||||
return (
|
||||
<button
|
||||
key={doc.id}
|
||||
onClick={() => setSelectedTarget(doc)}
|
||||
disabled={transferring}
|
||||
className={cx(
|
||||
'flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-left transition duration-100 ease-linear',
|
||||
isSelected ? 'bg-brand-secondary ring-2 ring-brand' : 'hover:bg-secondary cursor-pointer',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<FontAwesomeIcon icon={faUserDoctor} className="size-3.5 text-fg-quaternary" />
|
||||
<div>
|
||||
<span className="text-sm font-medium text-primary">{doc.name}</span>
|
||||
{doc.department && <span className="ml-2 text-xs text-tertiary">{doc.department}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{filtered.length === 0 && !loading && (
|
||||
<p className="text-xs text-quaternary text-center py-4">No matching targets</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user