mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 10:23:27 +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:
@@ -212,7 +212,7 @@ const TableHead = ({ className, tooltip, label, children, resizable = true, ...p
|
||||
|
||||
{resizable && (
|
||||
<AriaColumnResizer
|
||||
className="absolute right-0 top-0 bottom-0 w-px bg-transparent cursor-col-resize touch-none box-border px-[3px] bg-clip-content hover:bg-brand-solid focus-visible:bg-brand-solid"
|
||||
className="absolute right-0 top-1 bottom-1 w-[3px] rounded-full bg-tertiary cursor-col-resize touch-none hover:bg-brand-solid focus-visible:bg-brand-solid transition-colors duration-100"
|
||||
/>
|
||||
)}
|
||||
</AriaGroup>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { TransferDialog } from './transfer-dialog';
|
||||
import { EnquiryForm } from './enquiry-form';
|
||||
import { formatPhone } from '@/lib/format';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { notify } from '@/lib/toast';
|
||||
import type { Lead, CallDisposition } from '@/types/entities';
|
||||
@@ -35,6 +36,7 @@ const formatDuration = (seconds: number): string => {
|
||||
};
|
||||
|
||||
export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete }: ActiveCallCardProps) => {
|
||||
const { user } = useAuth();
|
||||
const { callState, callDuration, callUcid, isMuted, isOnHold, answer, reject, hangup, toggleMute, toggleHold } = useSip();
|
||||
const setCallState = useSetAtom(sipCallStateAtom);
|
||||
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
||||
@@ -203,7 +205,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
wasAnsweredRef.current = true;
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col rounded-xl border border-brand bg-primary overflow-hidden">
|
||||
<div className={cx('flex flex-col rounded-xl border border-brand bg-primary overflow-hidden', (appointmentOpen || enquiryOpen || transferOpen) && 'flex-1 min-h-0')}>
|
||||
{/* Pinned: caller info + controls */}
|
||||
<div className="shrink-0 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -260,23 +262,28 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
|
||||
<Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'}
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
|
||||
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); }}>Book Appt</Button>
|
||||
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} />}
|
||||
onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); }}>Enquiry</Button>
|
||||
<Button size="sm" color="secondary"
|
||||
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} />}
|
||||
onClick={() => setTransferOpen(!transferOpen)}>Transfer</Button>
|
||||
onClick={() => { setTransferOpen(!transferOpen); setAppointmentOpen(false); setEnquiryOpen(false); }}>Transfer</Button>
|
||||
|
||||
<Button size="sm" color="primary-destructive" className="ml-auto"
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneHangup} className={className} {...rest} />}
|
||||
onClick={() => setDispositionOpen(true)}>End Call</Button>
|
||||
</div>
|
||||
|
||||
{/* Transfer dialog */}
|
||||
</div>
|
||||
|
||||
{/* Scrollable: expanded forms + transfer */}
|
||||
{(appointmentOpen || enquiryOpen || transferOpen) && (
|
||||
<div className="flex flex-col min-h-0 flex-1 border-t border-secondary px-4 pb-4 pt-4">
|
||||
{transferOpen && callUcid && (
|
||||
<TransferDialog
|
||||
ucid={callUcid}
|
||||
currentAgentId={JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}').ozonetelAgentId}
|
||||
onClose={() => setTransferOpen(false)}
|
||||
onTransferred={() => {
|
||||
setTransferOpen(false);
|
||||
@@ -285,8 +292,6 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AppointmentForm
|
||||
isOpen={appointmentOpen}
|
||||
onOpenChange={setAppointmentOpen}
|
||||
@@ -301,6 +306,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
isOpen={enquiryOpen}
|
||||
onOpenChange={setEnquiryOpen}
|
||||
callerPhone={callerPhone}
|
||||
leadId={lead?.id ?? null}
|
||||
patientId={(lead as any)?.patientId ?? null}
|
||||
agentName={user.name}
|
||||
onSaved={() => {
|
||||
setEnquiryOpen(false);
|
||||
setSuggestedDisposition('INFO_PROVIDED');
|
||||
@@ -308,6 +316,8 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Disposition Modal — the ONLY path to end a call */}
|
||||
<DispositionModal
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { faCalendarPlus, faXmark } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { faIcon } from '@/lib/icon-wrapper';
|
||||
|
||||
const CalendarPlus02 = faIcon(faCalendarPlus);
|
||||
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 { Button } from '@/components/base/buttons/button';
|
||||
import { ModalOverlay, Modal, Dialog } from '@/components/application/modals/modal';
|
||||
import { DatePicker } from '@/components/application/date-picker/date-picker';
|
||||
import { parseDate } from '@internationalized/date';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { notify } from '@/lib/toast';
|
||||
@@ -90,7 +86,7 @@ export const AppointmentForm = ({
|
||||
const [doctor, setDoctor] = useState<string | null>(existingAppointment?.doctorId ?? null);
|
||||
const [date, setDate] = useState(() => {
|
||||
if (existingAppointment?.scheduledAt) return existingAppointment.scheduledAt.split('T')[0];
|
||||
return '';
|
||||
return new Date().toISOString().split('T')[0];
|
||||
});
|
||||
const [timeSlot, setTimeSlot] = useState<string | null>(() => {
|
||||
if (existingAppointment?.scheduledAt) {
|
||||
@@ -249,7 +245,22 @@ export const AppointmentForm = ({
|
||||
},
|
||||
);
|
||||
|
||||
// Update lead status if we have a matched lead
|
||||
// Update patient name if we have a name and a linked patient
|
||||
if (patientId && patientName.trim()) {
|
||||
await apiClient.graphql(
|
||||
`mutation UpdatePatient($id: UUID!, $data: PatientUpdateInput!) {
|
||||
updatePatient(id: $id, data: $data) { id }
|
||||
}`,
|
||||
{
|
||||
id: patientId,
|
||||
data: {
|
||||
fullName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
|
||||
},
|
||||
},
|
||||
).catch((err: unknown) => console.warn('Failed to update patient name:', err));
|
||||
}
|
||||
|
||||
// Update lead status + name if we have a matched lead
|
||||
if (leadId) {
|
||||
await apiClient.graphql(
|
||||
`mutation UpdateLead($id: UUID!, $data: LeadUpdateInput!) {
|
||||
@@ -260,10 +271,16 @@ export const AppointmentForm = ({
|
||||
data: {
|
||||
leadStatus: 'APPOINTMENT_SET',
|
||||
lastContactedAt: new Date().toISOString(),
|
||||
...(patientName.trim() ? { contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' } } : {}),
|
||||
},
|
||||
},
|
||||
).catch((err: unknown) => console.warn('Failed to update lead:', err));
|
||||
}
|
||||
|
||||
// Invalidate caller cache so next lookup gets the real name
|
||||
if (callerNumber) {
|
||||
apiClient.post('/api/caller/invalidate', { phone: callerNumber }, { silent: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
onSaved?.();
|
||||
@@ -297,35 +314,12 @@ export const AppointmentForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-brand-secondary">
|
||||
<CalendarPlus02 className="size-4 text-fg-brand-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-primary">
|
||||
{isEditMode ? 'Edit Appointment' : 'Book Appointment'}
|
||||
</h3>
|
||||
<p className="text-xs text-tertiary">
|
||||
{isEditMode ? 'Modify or cancel this appointment' : 'Schedule a new patient visit'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex size-7 items-center justify-center rounded-md text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||
>
|
||||
<XClose className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
if (!isOpen) return null;
|
||||
|
||||
{/* Form fields */}
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{/* Form fields — scrollable */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Patient Info — only for new appointments */}
|
||||
{!isEditMode && (
|
||||
@@ -416,13 +410,14 @@ export const AppointmentForm = ({
|
||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
label="Date"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={setDate}
|
||||
isRequired
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-secondary">Date <span className="text-error-primary">*</span></span>
|
||||
<DatePicker
|
||||
value={date ? parseDate(date) : null}
|
||||
onChange={(val) => setDate(val ? val.toString() : '')}
|
||||
granularity="day"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Time slot grid */}
|
||||
{doctor && date && (
|
||||
@@ -497,9 +492,10 @@ export const AppointmentForm = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer buttons */}
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-secondary">
|
||||
{/* Footer — pinned */}
|
||||
<div className="shrink-0 flex items-center justify-between pt-4 border-t border-secondary">
|
||||
<div>
|
||||
{isEditMode && (
|
||||
<Button size="sm" color="primary-destructive" isLoading={isSaving} onClick={handleCancel}>
|
||||
@@ -517,8 +513,5 @@ export const AppointmentForm = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
);
|
||||
};
|
||||
|
||||
61
src/components/call-desk/call-control-strip.tsx
Normal file
61
src/components/call-desk/call-control-strip.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faMicrophone, faMicrophoneSlash,
|
||||
faPause, faPlay, faPhoneHangup,
|
||||
} from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { useSip } from '@/providers/sip-provider';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
|
||||
const s = (seconds % 60).toString().padStart(2, '0');
|
||||
return `${m}:${s}`;
|
||||
};
|
||||
|
||||
export const CallControlStrip = () => {
|
||||
const { callState, callDuration, isMuted, isOnHold, toggleMute, toggleHold, hangup } = useSip();
|
||||
|
||||
if (callState !== 'active' && callState !== 'ringing-out') return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-lg bg-success-secondary px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative flex size-2">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-success-solid opacity-75" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-success-solid" />
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-success-primary">Live Call</span>
|
||||
<span className="text-xs font-bold tabular-nums text-success-primary">{formatDuration(callDuration)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
title={isMuted ? 'Unmute' : 'Mute'}
|
||||
className={cx(
|
||||
'flex size-7 items-center justify-center rounded-md transition duration-100 ease-linear',
|
||||
isMuted ? 'bg-error-solid text-white' : 'bg-primary text-fg-quaternary hover:text-fg-secondary',
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={isMuted ? faMicrophoneSlash : faMicrophone} className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleHold}
|
||||
title={isOnHold ? 'Resume' : 'Hold'}
|
||||
className={cx(
|
||||
'flex size-7 items-center justify-center rounded-md transition duration-100 ease-linear',
|
||||
isOnHold ? 'bg-warning-solid text-white' : 'bg-primary text-fg-quaternary hover:text-fg-secondary',
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={isOnHold ? faPlay : faPause} className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={hangup}
|
||||
title="End Call"
|
||||
className="flex size-7 items-center justify-center rounded-md bg-error-solid text-white hover:bg-error-solid_hover transition duration-100 ease-linear"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPhoneHangup} className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faClipboardQuestion, faXmark } from '@fortawesome/pro-duotone-svg-icons';
|
||||
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 { notify } from '@/lib/toast';
|
||||
|
||||
@@ -14,19 +11,14 @@ type EnquiryFormProps = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
callerPhone?: string | null;
|
||||
leadId?: string | null;
|
||||
patientId?: string | null;
|
||||
agentName?: string | null;
|
||||
onSaved?: () => void;
|
||||
};
|
||||
|
||||
const dispositionItems = [
|
||||
{ id: 'CONVERTED', label: 'Converted' },
|
||||
{ id: 'FOLLOW_UP', label: 'Follow-up Needed' },
|
||||
{ id: 'GENERAL_QUERY', label: 'General Query' },
|
||||
{ id: 'NO_ANSWER', label: 'No Answer' },
|
||||
{ id: 'INVALID_NUMBER', label: 'Invalid Number' },
|
||||
{ id: 'CALL_DROPPED', label: 'Call Dropped' },
|
||||
];
|
||||
|
||||
export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: EnquiryFormProps) => {
|
||||
export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadId: propLeadId, patientId, agentName, onSaved }: EnquiryFormProps) => {
|
||||
const [patientName, setPatientName] = useState('');
|
||||
const [source, setSource] = useState('Phone Inquiry');
|
||||
const [queryAsked, setQueryAsked] = useState('');
|
||||
@@ -36,7 +28,6 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
|
||||
const [doctor, setDoctor] = useState<string | null>(null);
|
||||
const [followUpNeeded, setFollowUpNeeded] = useState(false);
|
||||
const [followUpDate, setFollowUpDate] = useState('');
|
||||
const [disposition, setDisposition] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -65,8 +56,8 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
|
||||
const doctorItems = filteredDoctors.map(d => ({ id: d.id, label: d.name }));
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!patientName.trim() || !queryAsked.trim() || !disposition) {
|
||||
setError('Please fill in required fields: patient name, query, and disposition.');
|
||||
if (!patientName.trim() || !queryAsked.trim()) {
|
||||
setError('Please fill in required fields: patient name and query.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -74,9 +65,9 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Resolve caller — ensures lead+patient pair exists, returns IDs
|
||||
let leadId: string | null = null;
|
||||
if (registeredPhone) {
|
||||
// Use passed leadId or resolve from phone
|
||||
let leadId: string | null = propLeadId ?? null;
|
||||
if (!leadId && registeredPhone) {
|
||||
const resolved = await apiClient.post<{ leadId: string; patientId: string }>('/api/caller/resolve', { phone: registeredPhone }, { silent: true });
|
||||
leadId = resolved.leadId;
|
||||
}
|
||||
@@ -91,7 +82,7 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
|
||||
name: `Enquiry — ${patientName}`,
|
||||
contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
|
||||
source: 'PHONE',
|
||||
status: disposition === 'CONVERTED' ? 'CONVERTED' : 'NEW',
|
||||
status: 'CONTACTED',
|
||||
interestedService: queryAsked.substring(0, 100),
|
||||
},
|
||||
},
|
||||
@@ -106,15 +97,38 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
|
||||
contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
|
||||
contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined,
|
||||
source: 'PHONE',
|
||||
status: disposition === 'CONVERTED' ? 'CONVERTED' : 'NEW',
|
||||
status: 'CONTACTED',
|
||||
interestedService: queryAsked.substring(0, 100),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Update patient name if we have a name and a linked patient
|
||||
if (patientId && patientName.trim()) {
|
||||
await apiClient.graphql(
|
||||
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
|
||||
{
|
||||
id: patientId,
|
||||
data: {
|
||||
fullName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
|
||||
},
|
||||
},
|
||||
).catch((err: unknown) => console.warn('Failed to update patient name:', err));
|
||||
}
|
||||
|
||||
// Invalidate caller cache so next lookup gets the real name
|
||||
if (callerPhone) {
|
||||
apiClient.post('/api/caller/invalidate', { phone: callerPhone }, { silent: true }).catch(() => {});
|
||||
}
|
||||
|
||||
// Create follow-up if needed
|
||||
if (followUpNeeded && followUpDate) {
|
||||
if (followUpNeeded) {
|
||||
if (!followUpDate) {
|
||||
setError('Please select a follow-up date.');
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
await apiClient.graphql(
|
||||
`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`,
|
||||
{
|
||||
@@ -123,7 +137,9 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
|
||||
typeCustom: 'CALLBACK',
|
||||
status: 'PENDING',
|
||||
priority: 'NORMAL',
|
||||
assignedAgent: agentName ?? undefined,
|
||||
scheduledAt: new Date(`${followUpDate}T09:00:00`).toISOString(),
|
||||
patientId: patientId ?? undefined,
|
||||
},
|
||||
},
|
||||
{ silent: true },
|
||||
@@ -139,29 +155,12 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
<FontAwesomeIcon icon={faClipboardQuestion} className="size-4 text-fg-warning-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-primary">Log Enquiry</h3>
|
||||
<p className="text-xs text-tertiary">Capture caller's question and details</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex size-7 items-center justify-center rounded-md text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{/* Form fields — scrollable */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Input label="Patient Name" placeholder="Full name" value={patientName} onChange={setPatientName} isRequired />
|
||||
|
||||
@@ -194,25 +193,19 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
|
||||
<Input label="Follow-up Date" type="date" value={followUpDate} onChange={setFollowUpDate} isRequired />
|
||||
)}
|
||||
|
||||
<Select label="Disposition" placeholder="Select outcome" items={dispositionItems} selectedKey={disposition}
|
||||
onSelectionChange={(key) => setDisposition(key as string)} isRequired>
|
||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||
</Select>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-error-primary p-3 text-sm text-error-primary">{error}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 mt-4 pt-4 border-t border-secondary">
|
||||
{/* Footer — pinned */}
|
||||
<div className="shrink-0 flex items-center justify-end gap-3 pt-4 border-t border-secondary">
|
||||
<Button size="sm" color="secondary" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}>
|
||||
{isSaving ? 'Saving...' : 'Log Enquiry'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
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="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>
|
||||
<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>
|
||||
{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()}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// 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>
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
<>
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSidebarFlip, faSidebar, faPhone, faXmark, faDeleteLeft } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { faSidebarFlip, faSidebar, faPhone, faXmark, faDeleteLeft, faFlask } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom, sipCallDurationAtom } from '@/state/sip-state';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { useWorklist } from '@/hooks/use-worklist';
|
||||
@@ -10,7 +12,6 @@ import type { WorklistLead } from '@/components/call-desk/worklist-panel';
|
||||
import { ContextPanel } from '@/components/call-desk/context-panel';
|
||||
import { ActiveCallCard } from '@/components/call-desk/active-call-card';
|
||||
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { cx } from '@/utils/cx';
|
||||
@@ -19,7 +20,7 @@ export const CallDeskPage = () => {
|
||||
const { user } = useAuth();
|
||||
const { leadActivities, calls, followUps: dataFollowUps, patients, appointments } = useData();
|
||||
const { callState, callerNumber, callUcid, dialOutbound } = useSip();
|
||||
const { missedCalls, followUps, marketingLeads, totalPending, loading } = useWorklist();
|
||||
const { missedCalls, followUps, marketingLeads, loading } = useWorklist();
|
||||
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
|
||||
const [contextOpen, setContextOpen] = useState(true);
|
||||
const [activeMissedCallId, setActiveMissedCallId] = useState<string | null>(null);
|
||||
@@ -28,6 +29,29 @@ export const CallDeskPage = () => {
|
||||
const [dialNumber, setDialNumber] = useState('');
|
||||
const [dialling, setDialling] = useState(false);
|
||||
|
||||
// DEV: simulate incoming call
|
||||
const setSimCallState = useSetAtom(sipCallStateAtom);
|
||||
const setSimCallerNumber = useSetAtom(sipCallerNumberAtom);
|
||||
const setSimCallUcid = useSetAtom(sipCallUcidAtom);
|
||||
const setSimDuration = useSetAtom(sipCallDurationAtom);
|
||||
const simTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const startSimCall = useCallback(() => {
|
||||
setSimCallerNumber('+919959966676');
|
||||
setSimCallUcid(`SIM-${Date.now()}`);
|
||||
setSimDuration(0);
|
||||
setSimCallState('active');
|
||||
simTimerRef.current = setInterval(() => setSimDuration((d) => d + 1), 1000);
|
||||
}, [setSimCallState, setSimCallerNumber, setSimCallUcid, setSimDuration]);
|
||||
|
||||
const endSimCall = useCallback(() => {
|
||||
if (simTimerRef.current) { clearInterval(simTimerRef.current); simTimerRef.current = null; }
|
||||
setSimCallState('idle');
|
||||
setSimCallerNumber(null);
|
||||
setSimCallUcid(null);
|
||||
setSimDuration(0);
|
||||
}, [setSimCallState, setSimCallerNumber, setSimCallUcid, setSimDuration]);
|
||||
|
||||
const handleDial = async () => {
|
||||
const num = dialNumber.replace(/[^0-9]/g, '');
|
||||
if (num.length < 10) { notify.error('Enter a valid phone number'); return; }
|
||||
@@ -112,6 +136,23 @@ export const CallDeskPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{import.meta.env.DEV && (!isInCall ? (
|
||||
<button
|
||||
onClick={startSimCall}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-warning-secondary text-warning-primary hover:bg-warning-primary hover:text-white transition duration-100 ease-linear"
|
||||
>
|
||||
<FontAwesomeIcon icon={faFlask} className="size-3" />
|
||||
Sim Call
|
||||
</button>
|
||||
) : callUcid?.startsWith('SIM-') && (
|
||||
<button
|
||||
onClick={endSimCall}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-error-solid text-white hover:bg-error-solid_hover transition duration-100 ease-linear"
|
||||
>
|
||||
<FontAwesomeIcon icon={faFlask} className="size-3" />
|
||||
End Sim
|
||||
</button>
|
||||
))}
|
||||
{!isInCall && (
|
||||
<div className="relative">
|
||||
<button
|
||||
@@ -173,9 +214,6 @@ export const CallDeskPage = () => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{totalPending > 0 && (
|
||||
<Badge size="sm" color="brand" type="pill-color">{totalPending} pending</Badge>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setContextOpen(!contextOpen)}
|
||||
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||
@@ -192,7 +230,7 @@ export const CallDeskPage = () => {
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||
{/* Active call */}
|
||||
{isInCall && (
|
||||
<div className="p-5">
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden p-5">
|
||||
<ActiveCallCard lead={activeLeadFull} callerPhone={callerNumber ?? ''} missedCallId={activeMissedCallId} onCallComplete={() => { setActiveMissedCallId(null); setCallDismissed(true); }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Badge } from '@/components/base/badges/badges';
|
||||
// Button removed — actions are icon-only now
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Table, TableCard } from '@/components/application/table/table';
|
||||
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
|
||||
import { PatientProfilePanel } from '@/components/shared/patient-profile-panel';
|
||||
@@ -59,13 +60,13 @@ export const PatientsPage = () => {
|
||||
const { patients, loading } = useData();
|
||||
const navigate = useNavigate();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
||||
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
const filteredPatients = useMemo(() => {
|
||||
return patients.filter((patient) => {
|
||||
// Search filter
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
const name = getPatientDisplayName(patient).toLowerCase();
|
||||
@@ -75,13 +76,13 @@ export const PatientsPage = () => {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Status filter — treat all patients as active for now since we don't have a status field
|
||||
if (statusFilter === 'inactive') return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [patients, searchQuery, statusFilter]);
|
||||
}, [patients, searchQuery]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredPatients.length / PAGE_SIZE));
|
||||
const pagedPatients = filteredPatients.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
|
||||
const handleSearch = (val: string) => { setSearchQuery(val); setCurrentPage(1); };
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
@@ -103,22 +104,6 @@ export const PatientsPage = () => {
|
||||
>
|
||||
<FontAwesomeIcon icon={panelOpen ? faSidebarFlip : faSidebar} className="size-4" />
|
||||
</button>
|
||||
{/* Status filter buttons */}
|
||||
<div className="flex rounded-lg border border-secondary overflow-hidden">
|
||||
{(['all', 'active', 'inactive'] as const).map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => setStatusFilter(status)}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition duration-100 ease-linear capitalize ${
|
||||
statusFilter === status
|
||||
? 'bg-active text-brand-secondary'
|
||||
: 'bg-primary text-tertiary hover:bg-primary_hover'
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-56">
|
||||
<Input
|
||||
@@ -126,7 +111,7 @@ export const PatientsPage = () => {
|
||||
icon={SearchLg}
|
||||
size="sm"
|
||||
value={searchQuery}
|
||||
onChange={(value) => setSearchQuery(value)}
|
||||
onChange={handleSearch}
|
||||
aria-label="Search patients"
|
||||
/>
|
||||
</div>
|
||||
@@ -154,10 +139,9 @@ export const PatientsPage = () => {
|
||||
<Table.Head label="TYPE" />
|
||||
<Table.Head label="GENDER" />
|
||||
<Table.Head label="AGE" />
|
||||
<Table.Head label="STATUS" />
|
||||
<Table.Head label="ACTIONS" />
|
||||
</Table.Header>
|
||||
<Table.Body items={filteredPatients}>
|
||||
<Table.Body items={pagedPatients}>
|
||||
{(patient) => {
|
||||
const displayName = getPatientDisplayName(patient);
|
||||
const age = computeAge(patient.dateOfBirth);
|
||||
@@ -239,13 +223,6 @@ export const PatientsPage = () => {
|
||||
</span>
|
||||
</Table.Cell>
|
||||
|
||||
{/* Status */}
|
||||
<Table.Cell>
|
||||
<Badge size="sm" color="success" type="pill-color">
|
||||
Active
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
|
||||
{/* Actions */}
|
||||
<Table.Cell>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -288,6 +265,12 @@ export const PatientsPage = () => {
|
||||
</Table>
|
||||
)}
|
||||
</TableCard.Root>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="shrink-0 border-t border-secondary px-6 py-3">
|
||||
<PaginationPageDefault page={currentPage} total={totalPages} onPageChange={setCurrentPage} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Patient Profile Panel - collapsible with smooth transition */}
|
||||
|
||||
@@ -158,6 +158,7 @@ export const TeamDashboardPage = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user