Files
helix-engage/src/components/shared/global-search.tsx
saridsa2 488f524f84 feat: SSE agent state, UCID fix, maint module, QA bug fixes
- 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>
2026-03-30 14:44:48 +05:30

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