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:
2026-03-21 14:21:40 +05:30
parent 721c2879ec
commit c3604377b9
6 changed files with 697 additions and 88 deletions

View File

@@ -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}