mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
feat: add appointment booking form slide-out during calls, wired to platform createAppointment mutation
This commit is contained in:
270
src/components/shared/global-search.tsx
Normal file
270
src/components/shared/global-search.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { SearchLg, User01, Phone01, Calendar } from '@untitledui/icons';
|
||||
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 { cx } from '@/utils/cx';
|
||||
|
||||
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, typeof User01> = {
|
||||
lead: User01,
|
||||
patient: User01,
|
||||
appointment: Calendar,
|
||||
};
|
||||
|
||||
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();
|
||||
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 (query.trim().length < 3) {
|
||||
setResults([]);
|
||||
setIsOpen(false);
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
const searchResults = searchLeads(query);
|
||||
setResults(searchResults);
|
||||
setIsOpen(true);
|
||||
setIsSearching(false);
|
||||
setHighlightedIndex(-1);
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [query, searchLeads]);
|
||||
|
||||
// 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 leads..."
|
||||
icon={SearchLg}
|
||||
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">
|
||||
<SearchLg 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user