mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 10:23:27 +00:00
- Fix outbound disposition: store UCID from dial API response (root cause of silent disposition failure) - SSE agent state: real-time Ozonetel state drives status toggle (ready/break/calling/in-call/acw) - Maint module with OTP-protected endpoints (force-ready, unlock-agent, backfill, fix-timestamps) - Maint OTP modal with PinInput component, keyboard shortcuts (Ctrl+Shift+R/U/B/T) - Force-logout via SSE: admin unlock pushes force-logout to connected browsers - Silence JsSIP debug flood, add structured lifecycle logging ([SIP], [DIAL], [DISPOSE], [AGENT-STATE]) - Centralize date formatting with IST-aware formatters across 11 files - Fix call history: non-overlapping aggregates (completed/missed), correct timestamp display - Auto-dismiss CallWidget ended/failed state after 3 seconds - Remove floating "Helix Phone" idle badge from all pages - Fix dead code in agent-state endpoint (auto-assign was unreachable after return) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
275 lines
11 KiB
TypeScript
275 lines
11 KiB
TypeScript
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 { apiClient } from '@/lib/api-client';
|
|
import { formatShortDate } from '@/lib/format';
|
|
import { cx } from '@/utils/cx';
|
|
|
|
const SearchIcon = faIcon(faMagnifyingGlass);
|
|
const UserIcon = faIcon(faUser);
|
|
const CalendarIcon = faIcon(faCalendar);
|
|
|
|
type SearchResultType = 'lead' | 'patient' | 'appointment';
|
|
|
|
type SearchResult = {
|
|
id: string;
|
|
type: SearchResultType;
|
|
title: string;
|
|
subtitle: string;
|
|
phone?: string;
|
|
};
|
|
|
|
type GlobalSearchProps = {
|
|
onSelectResult?: (result: SearchResult) => void;
|
|
};
|
|
|
|
const TYPE_ICONS: Record<SearchResultType, ReturnType<typeof faIcon>> = {
|
|
lead: UserIcon,
|
|
patient: UserIcon,
|
|
appointment: CalendarIcon,
|
|
};
|
|
|
|
const TYPE_BADGE_COLORS: Record<SearchResultType, 'brand' | 'success' | 'blue'> = {
|
|
lead: 'brand',
|
|
patient: 'success',
|
|
appointment: 'blue',
|
|
};
|
|
|
|
const TYPE_LABELS: Record<SearchResultType, string> = {
|
|
lead: 'Lead',
|
|
patient: 'Patient',
|
|
appointment: 'Appointment',
|
|
};
|
|
|
|
export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
|
|
const [query, setQuery] = useState('');
|
|
const [results, setResults] = useState<SearchResult[]>([]);
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [isSearching, setIsSearching] = useState(false);
|
|
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const navigate = useNavigate();
|
|
|
|
useEffect(() => {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
|
|
if (query.trim().length < 2) {
|
|
setResults([]);
|
|
setIsOpen(false);
|
|
setIsSearching(false);
|
|
return;
|
|
}
|
|
|
|
setIsSearching(true);
|
|
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 ? formatShortDate(a.scheduledAt) : '';
|
|
searchResults.push({
|
|
id: a.id,
|
|
type: 'appointment',
|
|
title: a.doctorName ?? 'Appointment',
|
|
subtitle: [a.department, date, a.status].filter(Boolean).join(' · '),
|
|
});
|
|
}
|
|
|
|
setResults(searchResults);
|
|
setIsOpen(true);
|
|
} catch {
|
|
setResults([]);
|
|
} finally {
|
|
setIsSearching(false);
|
|
setHighlightedIndex(-1);
|
|
}
|
|
}, 300);
|
|
|
|
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
|
}, [query]);
|
|
|
|
// Close on outside click
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
const handleSelect = (result: SearchResult) => {
|
|
setIsOpen(false);
|
|
setQuery('');
|
|
if (onSelectResult) {
|
|
onSelectResult(result);
|
|
} else {
|
|
navigate(`/patient/${result.id}`);
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
|
if (!isOpen || results.length === 0) return;
|
|
|
|
if (event.key === 'ArrowDown') {
|
|
event.preventDefault();
|
|
setHighlightedIndex((prev) => (prev < results.length - 1 ? prev + 1 : 0));
|
|
} else if (event.key === 'ArrowUp') {
|
|
event.preventDefault();
|
|
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : results.length - 1));
|
|
} else if (event.key === 'Enter' && highlightedIndex >= 0) {
|
|
event.preventDefault();
|
|
handleSelect(results[highlightedIndex]);
|
|
} else if (event.key === 'Escape') {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
// Group results by type
|
|
const groupedResults: Record<string, SearchResult[]> = {};
|
|
for (const result of results) {
|
|
const groupLabel = `${TYPE_LABELS[result.type]}s`;
|
|
if (!groupedResults[groupLabel]) {
|
|
groupedResults[groupLabel] = [];
|
|
}
|
|
groupedResults[groupLabel].push(result);
|
|
}
|
|
|
|
let flatIndex = -1;
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative w-64" onKeyDown={handleKeyDown}>
|
|
<Input
|
|
placeholder="Search patients, leads, appointments..."
|
|
icon={SearchIcon}
|
|
aria-label="Global search"
|
|
value={query}
|
|
onChange={(value) => setQuery(value)}
|
|
onFocus={() => {
|
|
if (results.length > 0) setIsOpen(true);
|
|
}}
|
|
shortcut="/"
|
|
/>
|
|
|
|
{isOpen && (
|
|
<div className="absolute top-full right-0 left-0 z-50 mt-1 overflow-hidden rounded-xl border border-secondary bg-primary shadow-lg">
|
|
{isSearching && (
|
|
<div className="flex items-center justify-center px-4 py-6">
|
|
<svg
|
|
fill="none"
|
|
viewBox="0 0 20 20"
|
|
className="size-5 animate-spin text-fg-brand-primary"
|
|
>
|
|
<circle
|
|
className="stroke-current opacity-30"
|
|
cx="10"
|
|
cy="10"
|
|
r="8"
|
|
fill="none"
|
|
strokeWidth="2"
|
|
/>
|
|
<circle
|
|
className="origin-center stroke-current"
|
|
cx="10"
|
|
cy="10"
|
|
r="8"
|
|
fill="none"
|
|
strokeWidth="2"
|
|
strokeDasharray="12.5 50"
|
|
strokeLinecap="round"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
)}
|
|
|
|
{!isSearching && results.length === 0 && (
|
|
<div className="flex flex-col items-center gap-1 px-4 py-6 text-center">
|
|
<SearchIcon className="size-5 text-fg-quaternary" />
|
|
<p className="text-sm font-medium text-secondary">No results</p>
|
|
<p className="text-xs text-tertiary">Try a different search term</p>
|
|
</div>
|
|
)}
|
|
|
|
{!isSearching &&
|
|
results.length > 0 &&
|
|
Object.entries(groupedResults).map(([groupLabel, groupResults]) => (
|
|
<div key={groupLabel}>
|
|
<div className="px-4 py-2 text-xs font-bold uppercase text-quaternary">
|
|
{groupLabel}
|
|
</div>
|
|
{groupResults.map((result) => {
|
|
flatIndex++;
|
|
const currentIndex = flatIndex;
|
|
const Icon = TYPE_ICONS[result.type];
|
|
|
|
return (
|
|
<button
|
|
key={result.id}
|
|
type="button"
|
|
className={cx(
|
|
'flex w-full cursor-pointer items-center gap-3 px-4 py-2.5 text-left transition duration-100 ease-linear',
|
|
currentIndex === highlightedIndex
|
|
? 'bg-active'
|
|
: 'hover:bg-primary_hover',
|
|
)}
|
|
onClick={() => handleSelect(result)}
|
|
onMouseEnter={() => setHighlightedIndex(currentIndex)}
|
|
>
|
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary">
|
|
<Icon className="size-4 text-fg-quaternary" />
|
|
</div>
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<span className="truncate text-sm font-semibold text-primary">
|
|
{result.title}
|
|
</span>
|
|
<span className="truncate text-xs text-tertiary">
|
|
{result.subtitle}
|
|
</span>
|
|
</div>
|
|
<Badge size="sm" type="pill-color" color={TYPE_BADGE_COLORS[result.type]}>
|
|
{TYPE_LABELS[result.type]}
|
|
</Badge>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|