mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28: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:
@@ -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