mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-12 02:38:15 +00:00
feat: Phase 1 — agent status toggle, global search, enquiry form
- Agent status toggle: Ready/Break/Training/Offline with Ozonetel sync - Global search: cross-entity search (leads + patients + appointments) via sidecar - General enquiry form: capture caller questions during calls - Button standard: icon-only for toggles, text+icon for primary actions - Sidecar: agent-state endpoint, search module with platform queries Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash,
|
||||
faPause, faPlay, faCalendarPlus, faCheckCircle,
|
||||
faPhoneArrowRight, faRecordVinyl,
|
||||
faPhoneArrowRight, faRecordVinyl, faClipboardQuestion,
|
||||
} from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
@@ -14,8 +14,10 @@ import { useSip } from '@/providers/sip-provider';
|
||||
import { DispositionForm } from './disposition-form';
|
||||
import { AppointmentForm } from './appointment-form';
|
||||
import { TransferDialog } from './transfer-dialog';
|
||||
import { EnquiryForm } from './enquiry-form';
|
||||
import { formatPhone } from '@/lib/format';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { notify } from '@/lib/toast';
|
||||
import type { Lead, CallDisposition } from '@/types/entities';
|
||||
|
||||
@@ -43,6 +45,7 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
|
||||
const [appointmentBookedDuringCall, setAppointmentBookedDuringCall] = useState(false);
|
||||
const [transferOpen, setTransferOpen] = useState(false);
|
||||
const [recordingPaused, setRecordingPaused] = useState(false);
|
||||
const [enquiryOpen, setEnquiryOpen] = useState(false);
|
||||
// Capture direction at mount — survives through disposition stage
|
||||
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
|
||||
|
||||
@@ -242,30 +245,57 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
|
||||
</div>
|
||||
<Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button size="sm" color={isMuted ? 'primary-destructive' : 'secondary'}
|
||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={isMuted ? faMicrophoneSlash : faMicrophone} className={className} />}
|
||||
onClick={toggleMute}>{isMuted ? 'Unmute' : 'Mute'}</Button>
|
||||
<Button size="sm" color={isOnHold ? 'primary-destructive' : 'secondary'}
|
||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={isOnHold ? faPlay : faPause} className={className} />}
|
||||
onClick={toggleHold}>{isOnHold ? 'Resume' : 'Hold'}</Button>
|
||||
<Button size="sm" color="secondary"
|
||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faCalendarPlus} className={className} />}
|
||||
onClick={() => setAppointmentOpen(true)}>Book Appt</Button>
|
||||
<Button size="sm" color="secondary"
|
||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} />}
|
||||
onClick={() => setTransferOpen(!transferOpen)}>Transfer</Button>
|
||||
<Button size="sm" color={recordingPaused ? 'primary-destructive' : 'secondary'}
|
||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faRecordVinyl} className={className} />}
|
||||
<div className="mt-3 flex items-center gap-1.5">
|
||||
{/* Icon-only toggles */}
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
title={isMuted ? 'Unmute' : 'Mute'}
|
||||
className={cx(
|
||||
'flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear',
|
||||
isMuted ? 'bg-error-solid text-white' : 'bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary',
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={isMuted ? faMicrophoneSlash : faMicrophone} className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleHold}
|
||||
title={isOnHold ? 'Resume' : 'Hold'}
|
||||
className={cx(
|
||||
'flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear',
|
||||
isOnHold ? 'bg-warning-solid text-white' : 'bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary',
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={isOnHold ? faPlay : faPause} className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const action = recordingPaused ? 'unPause' : 'pause';
|
||||
if (callUcid) {
|
||||
apiClient.post('/api/ozonetel/recording', { ucid: callUcid, action }).catch(() => {});
|
||||
}
|
||||
if (callUcid) apiClient.post('/api/ozonetel/recording', { ucid: callUcid, action }).catch(() => {});
|
||||
setRecordingPaused(!recordingPaused);
|
||||
}}>{recordingPaused ? 'Resume Rec' : 'Pause Rec'}</Button>
|
||||
}}
|
||||
title={recordingPaused ? 'Resume Recording' : 'Pause Recording'}
|
||||
className={cx(
|
||||
'flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear',
|
||||
recordingPaused ? 'bg-error-solid text-white' : 'bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary',
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faRecordVinyl} className="size-3.5" />
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-secondary mx-0.5" />
|
||||
|
||||
{/* Text+Icon primary actions */}
|
||||
<Button size="sm" color="secondary"
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
|
||||
onClick={() => setAppointmentOpen(true)}>Book Appt</Button>
|
||||
<Button size="sm" color="secondary"
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
|
||||
onClick={() => setEnquiryOpen(!enquiryOpen)}>Enquiry</Button>
|
||||
<Button size="sm" color="secondary"
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
|
||||
onClick={() => setTransferOpen(!transferOpen)}>Transfer</Button>
|
||||
<Button size="sm" color="primary-destructive" className="ml-auto"
|
||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faPhoneHangup} className={className} />}
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneHangup} className={className} {...rest} />}
|
||||
onClick={() => { hangup(); setPostCallStage('disposition'); }}>End</Button>
|
||||
</div>
|
||||
|
||||
@@ -291,6 +321,17 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
|
||||
leadId={lead?.id ?? null}
|
||||
onSaved={handleAppointmentSaved}
|
||||
/>
|
||||
|
||||
{/* Enquiry form */}
|
||||
<EnquiryForm
|
||||
isOpen={enquiryOpen}
|
||||
onOpenChange={setEnquiryOpen}
|
||||
callerPhone={callerPhone}
|
||||
onSaved={() => {
|
||||
setEnquiryOpen(false);
|
||||
notify.success('Enquiry Logged');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
102
src/components/call-desk/agent-status-toggle.tsx
Normal file
102
src/components/call-desk/agent-status-toggle.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCircle, faChevronDown } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
type AgentStatus = 'ready' | 'break' | 'training' | 'offline';
|
||||
|
||||
const statusConfig: Record<AgentStatus, { label: string; color: string; dotColor: string }> = {
|
||||
ready: { label: 'Ready', color: 'text-success-primary', dotColor: 'text-fg-success-primary' },
|
||||
break: { label: 'Break', color: 'text-warning-primary', dotColor: 'text-fg-warning-primary' },
|
||||
training: { label: 'Training', color: 'text-brand-secondary', dotColor: 'text-fg-brand-primary' },
|
||||
offline: { label: 'Offline', color: 'text-tertiary', dotColor: 'text-fg-quaternary' },
|
||||
};
|
||||
|
||||
type AgentStatusToggleProps = {
|
||||
isRegistered: boolean;
|
||||
connectionStatus: string;
|
||||
};
|
||||
|
||||
export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatusToggleProps) => {
|
||||
const [status, setStatus] = useState<AgentStatus>(isRegistered ? 'ready' : 'offline');
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [changing, setChanging] = useState(false);
|
||||
|
||||
const handleChange = async (newStatus: AgentStatus) => {
|
||||
setMenuOpen(false);
|
||||
if (newStatus === status) return;
|
||||
setChanging(true);
|
||||
|
||||
try {
|
||||
if (newStatus === 'ready') {
|
||||
await apiClient.post('/api/ozonetel/agent-state', { state: 'Ready' });
|
||||
} else if (newStatus === 'offline') {
|
||||
await apiClient.post('/api/ozonetel/agent-logout', {
|
||||
agentId: 'global',
|
||||
password: 'Test123$',
|
||||
});
|
||||
} else {
|
||||
const pauseReason = newStatus === 'break' ? 'Break' : 'Training';
|
||||
await apiClient.post('/api/ozonetel/agent-state', { state: 'Pause', pauseReason });
|
||||
}
|
||||
setStatus(newStatus);
|
||||
} catch {
|
||||
notify.error('Status Change Failed', 'Could not update agent status');
|
||||
} finally {
|
||||
setChanging(false);
|
||||
}
|
||||
};
|
||||
|
||||
// If SIP isn't connected, show connection status
|
||||
if (!isRegistered) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1">
|
||||
<FontAwesomeIcon icon={faCircle} className="size-2 text-fg-warning-primary animate-pulse" />
|
||||
<span className="text-xs font-medium text-tertiary">{connectionStatus}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const current = statusConfig[status];
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
disabled={changing}
|
||||
className={cx(
|
||||
'flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1 transition duration-100 ease-linear',
|
||||
'hover:bg-secondary_hover cursor-pointer',
|
||||
changing && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCircle} className={cx('size-2', current.dotColor)} />
|
||||
<span className={cx('text-xs font-medium', current.color)}>{current.label}</span>
|
||||
<FontAwesomeIcon icon={faChevronDown} className="size-2.5 text-fg-quaternary" />
|
||||
</button>
|
||||
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
|
||||
<div className="absolute right-0 top-full z-50 mt-1 w-36 rounded-lg bg-primary shadow-lg ring-1 ring-secondary py-1">
|
||||
{(Object.entries(statusConfig) as [AgentStatus, typeof current][]).map(([key, cfg]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => handleChange(key)}
|
||||
className={cx(
|
||||
'flex w-full items-center gap-2 px-3 py-2 text-xs font-medium transition duration-100 ease-linear',
|
||||
key === status ? 'bg-active' : 'hover:bg-primary_hover',
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCircle} className={cx('size-2', cfg.dotColor)} />
|
||||
<span className={cfg.color}>{cfg.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
189
src/components/call-desk/enquiry-form.tsx
Normal file
189
src/components/call-desk/enquiry-form.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
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 { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
|
||||
type EnquiryFormProps = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
callerPhone?: 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) => {
|
||||
const [patientName, setPatientName] = useState('');
|
||||
const [source, setSource] = useState('Phone Inquiry');
|
||||
const [queryAsked, setQueryAsked] = useState('');
|
||||
const [isExisting, setIsExisting] = useState(false);
|
||||
const [registeredPhone, setRegisteredPhone] = useState(callerPhone ?? '');
|
||||
const [department, setDepartment] = useState<string | null>(null);
|
||||
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);
|
||||
|
||||
// Fetch doctors for department/doctor dropdowns
|
||||
const [doctors, setDoctors] = useState<Array<{ id: string; name: string; department: string }>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
apiClient.graphql<{ doctors: { edges: Array<{ node: any }> } }>(
|
||||
`{ doctors(first: 50) { edges { node {
|
||||
id name fullName { firstName lastName } department
|
||||
} } } }`,
|
||||
).then(data => {
|
||||
setDoctors(data.doctors.edges.map(e => ({
|
||||
id: e.node.id,
|
||||
name: e.node.fullName ? `Dr. ${e.node.fullName.firstName} ${e.node.fullName.lastName}`.trim() : e.node.name,
|
||||
department: e.node.department ?? '',
|
||||
})));
|
||||
}).catch(() => {});
|
||||
}, [isOpen]);
|
||||
|
||||
const departmentItems = [...new Set(doctors.map(d => d.department).filter(Boolean))]
|
||||
.map(dept => ({ id: dept, label: dept.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) }));
|
||||
|
||||
const filteredDoctors = department ? doctors.filter(d => d.department === department) : doctors;
|
||||
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.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Create a lead with source PHONE_INQUIRY
|
||||
await apiClient.graphql(
|
||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||
{
|
||||
data: {
|
||||
name: `Enquiry — ${patientName}`,
|
||||
contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
|
||||
contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined,
|
||||
source: 'PHONE_INQUIRY',
|
||||
status: disposition === 'CONVERTED' ? 'CONVERTED' : 'NEW',
|
||||
interestedService: queryAsked.substring(0, 100),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Create follow-up if needed
|
||||
if (followUpNeeded && followUpDate) {
|
||||
await apiClient.graphql(
|
||||
`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`,
|
||||
{
|
||||
data: {
|
||||
name: `Follow-up — ${patientName}`,
|
||||
typeCustom: 'CALLBACK',
|
||||
status: 'PENDING',
|
||||
priority: 'NORMAL',
|
||||
scheduledAt: new Date(`${followUpDate}T09:00:00`).toISOString(),
|
||||
},
|
||||
},
|
||||
{ silent: true },
|
||||
);
|
||||
}
|
||||
|
||||
notify.success('Enquiry Logged', 'Contact details and query captured');
|
||||
onSaved?.();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save enquiry');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-secondary bg-primary 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>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<Input label="Patient Name" placeholder="Full name" value={patientName} onChange={setPatientName} isRequired />
|
||||
|
||||
<Input label="Source / Referral" placeholder="How did they reach us?" value={source} onChange={setSource} isRequired />
|
||||
|
||||
<TextArea label="Query Asked" placeholder="What did the caller ask about?" value={queryAsked} onChange={setQueryAsked} rows={3} isRequired />
|
||||
|
||||
<Checkbox isSelected={isExisting} onChange={setIsExisting} label="Existing Patient" hint="Has visited the hospital before" />
|
||||
|
||||
{isExisting && (
|
||||
<Input label="Registered Phone" placeholder="Phone number on file" value={registeredPhone} onChange={setRegisteredPhone} />
|
||||
)}
|
||||
|
||||
<div className="border-t border-secondary" />
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Select label="Department" placeholder="Optional" items={departmentItems} selectedKey={department}
|
||||
onSelectionChange={(key) => { setDepartment(key as string); setDoctor(null); }}>
|
||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||
</Select>
|
||||
<Select label="Doctor" placeholder="Optional" items={doctorItems} selectedKey={doctor}
|
||||
onSelectionChange={(key) => setDoctor(key as string)} isDisabled={!department}>
|
||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Checkbox isSelected={followUpNeeded} onChange={setFollowUpNeeded} label="Follow-up Needed" />
|
||||
|
||||
{followUpNeeded && (
|
||||
<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 className="flex items-center justify-end gap-3 mt-4 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>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { faMagnifyingGlass, faUser, faCalendar } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { faIcon } from '@/lib/icon-wrapper';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { formatPhone } from '@/lib/format';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
const SearchIcon = faIcon(faMagnifyingGlass);
|
||||
@@ -53,52 +52,11 @@ export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const { leads } = useData();
|
||||
|
||||
const searchLeads = useCallback(
|
||||
(searchQuery: string): SearchResult[] => {
|
||||
const normalizedQuery = searchQuery.trim().toLowerCase();
|
||||
if (normalizedQuery.length < 3) return [];
|
||||
|
||||
const matched = leads.filter((lead) => {
|
||||
const firstName = lead.contactName?.firstName?.toLowerCase() ?? '';
|
||||
const lastName = lead.contactName?.lastName?.toLowerCase() ?? '';
|
||||
const fullName = `${firstName} ${lastName}`.trim();
|
||||
const phones = (lead.contactPhone ?? []).map((p) => `${p.callingCode}${p.number}`.toLowerCase());
|
||||
|
||||
const matchesName =
|
||||
firstName.includes(normalizedQuery) ||
|
||||
lastName.includes(normalizedQuery) ||
|
||||
fullName.includes(normalizedQuery);
|
||||
const matchesPhone = phones.some((phone) => phone.includes(normalizedQuery));
|
||||
|
||||
return matchesName || matchesPhone;
|
||||
});
|
||||
|
||||
return matched.slice(0, 5).map((lead) => {
|
||||
const firstName = lead.contactName?.firstName ?? '';
|
||||
const lastName = lead.contactName?.lastName ?? '';
|
||||
const phone = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : undefined;
|
||||
const email = lead.contactEmail?.[0]?.address ?? undefined;
|
||||
|
||||
return {
|
||||
id: lead.id,
|
||||
type: 'lead' as const,
|
||||
title: `${firstName} ${lastName}`.trim() || 'Unknown Lead',
|
||||
subtitle: [phone, email, lead.interestedService].filter(Boolean).join(' · '),
|
||||
phone,
|
||||
};
|
||||
});
|
||||
},
|
||||
[leads],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
if (query.trim().length < 3) {
|
||||
if (query.trim().length < 2) {
|
||||
setResults([]);
|
||||
setIsOpen(false);
|
||||
setIsSearching(false);
|
||||
@@ -106,20 +64,60 @@ export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
const searchResults = searchLeads(query);
|
||||
setResults(searchResults);
|
||||
setIsOpen(true);
|
||||
setIsSearching(false);
|
||||
setHighlightedIndex(-1);
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const data = await apiClient.get<{
|
||||
leads: Array<any>;
|
||||
patients: Array<any>;
|
||||
appointments: Array<any>;
|
||||
}>(`/api/search?q=${encodeURIComponent(query)}`, { silent: true });
|
||||
|
||||
const searchResults: SearchResult[] = [];
|
||||
|
||||
for (const l of data.leads ?? []) {
|
||||
const name = l.contactName ? `${l.contactName.firstName} ${l.contactName.lastName}`.trim() : l.name;
|
||||
searchResults.push({
|
||||
id: l.id,
|
||||
type: 'lead',
|
||||
title: name || 'Unknown',
|
||||
subtitle: [l.contactPhone?.primaryPhoneNumber, l.source, l.interestedService].filter(Boolean).join(' · '),
|
||||
phone: l.contactPhone?.primaryPhoneNumber,
|
||||
});
|
||||
}
|
||||
|
||||
for (const p of data.patients ?? []) {
|
||||
const name = p.fullName ? `${p.fullName.firstName} ${p.fullName.lastName}`.trim() : p.name;
|
||||
searchResults.push({
|
||||
id: p.id,
|
||||
type: 'patient',
|
||||
title: name || 'Unknown',
|
||||
subtitle: p.phones?.primaryPhoneNumber ?? '',
|
||||
phone: p.phones?.primaryPhoneNumber,
|
||||
});
|
||||
}
|
||||
|
||||
for (const a of data.appointments ?? []) {
|
||||
const date = a.scheduledAt ? new Date(a.scheduledAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : '';
|
||||
searchResults.push({
|
||||
id: a.id,
|
||||
type: 'appointment',
|
||||
title: a.doctorName ?? 'Appointment',
|
||||
subtitle: [a.department, date, a.appointmentStatus].filter(Boolean).join(' · '),
|
||||
});
|
||||
}
|
||||
|
||||
setResults(searchResults);
|
||||
setIsOpen(true);
|
||||
} catch {
|
||||
setResults([]);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
setHighlightedIndex(-1);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [query, searchLeads]);
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
||||
}, [query]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
@@ -174,7 +172,7 @@ export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-64" onKeyDown={handleKeyDown}>
|
||||
<Input
|
||||
placeholder="Search leads..."
|
||||
placeholder="Search patients, leads, appointments..."
|
||||
icon={SearchIcon}
|
||||
aria-label="Global search"
|
||||
value={query}
|
||||
|
||||
Reference in New Issue
Block a user