diff --git a/docs/superpowers/plans/2026-03-21-phase1-unblock.md b/docs/superpowers/plans/2026-03-21-phase1-unblock.md
new file mode 100644
index 0000000..e5b9a79
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-21-phase1-unblock.md
@@ -0,0 +1,284 @@
+# Phase 1: Agent Status + Global Search + Enquiry Form
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Unblock supervisor features by adding agent availability toggle, give agents fast record lookup via global search, and add a general enquiry capture form for non-lead interactions.
+
+**Architecture:** Agent status syncs with Ozonetel's changeAgentState API. Global search queries the platform GraphQL for leads, patients, and appointments in parallel. Enquiry form creates a Lead record with source "PHONE_INQUIRY" and captures the interaction details.
+
+**Tech Stack:** NestJS sidecar (Ozonetel APIs), React 19 + Jotai, Platform GraphQL
+
+---
+
+## Feature A: Agent Availability Status
+
+The agent needs an Active/Away/Offline toggle that syncs with Ozonetel CloudAgent state.
+
+### File Map
+| File | Action |
+|------|--------|
+| `helix-engage/src/components/call-desk/agent-status-toggle.tsx` | Create: dropdown toggle for Ready/Pause/Offline |
+| `helix-engage/src/pages/call-desk.tsx` | Modify: replace hardcoded "Ready" badge with AgentStatusToggle |
+| `helix-engage-server/src/ozonetel/ozonetel-agent.controller.ts` | Modify: add `POST /api/ozonetel/agent-state` accepting state + pauseReason |
+
+### Task A1: Sidecar endpoint for agent state
+
+**Files:**
+- Modify: `helix-engage-server/src/ozonetel/ozonetel-agent.controller.ts`
+
+- [ ] **Step 1: Add `POST /api/ozonetel/agent-state` endpoint**
+
+```typescript
+@Post('agent-state')
+async agentState(
+ @Body() body: { state: 'Ready' | 'Pause'; pauseReason?: string },
+) {
+ if (!body.state) {
+ throw new HttpException('state required', 400);
+ }
+
+ this.logger.log(`Agent state change: ${this.defaultAgentId} → ${body.state}`);
+
+ try {
+ const result = await this.ozonetelAgent.changeAgentState({
+ agentId: this.defaultAgentId,
+ state: body.state,
+ pauseReason: body.pauseReason,
+ });
+ return result;
+ } catch (error: any) {
+ const message = error.response?.data?.message ?? error.message ?? 'State change failed';
+ return { status: 'error', message };
+ }
+}
+```
+
+- [ ] **Step 2: Type check and commit**
+
+```
+feat: add agent state change endpoint
+```
+
+### Task A2: Agent status toggle component
+
+**Files:**
+- Create: `helix-engage/src/components/call-desk/agent-status-toggle.tsx`
+
+- [ ] **Step 1: Create the toggle component**
+
+A dropdown button showing current status (Ready/Break/Offline) with color-coded dot. Selecting a state calls the sidecar API.
+
+States:
+- **Ready** (green dot) → Ozonetel state: Ready
+- **Break** (orange dot) → Ozonetel state: Pause, pauseReason: "Break"
+- **Training** (blue dot) → Ozonetel state: Pause, pauseReason: "Training"
+- **Offline** (gray dot) → calls agent-logout
+
+The component uses React Aria's `Select` or a simple popover.
+
+- [ ] **Step 2: Commit**
+
+```
+feat: add agent status toggle component
+```
+
+### Task A3: Wire into call desk
+
+**Files:**
+- Modify: `helix-engage/src/pages/call-desk.tsx`
+
+- [ ] **Step 1: Replace the hardcoded "Ready" BadgeWithDot with AgentStatusToggle**
+
+The current badge at line 43-49 shows SIP registration status. Replace with the new toggle that shows actual CloudAgent state AND SIP status.
+
+- [ ] **Step 2: Commit**
+
+```
+feat: replace hardcoded Ready badge with agent status toggle
+```
+
+---
+
+## Feature B: Global Search
+
+A search bar in the header/top-bar that searches across leads, patients, and appointments.
+
+### File Map
+| File | Action |
+|------|--------|
+| `helix-engage/src/components/shared/global-search.tsx` | Modify: search leads + patients + appointments via sidecar |
+| `helix-engage-server/src/ozonetel/ozonetel-agent.controller.ts` | Modify: add `GET /api/search?q=` that queries platform |
+| `helix-engage/src/components/layout/top-bar.tsx` | Modify: add GlobalSearch to the top bar |
+
+### Task B1: Sidecar search endpoint
+
+**Files:**
+- Modify: `helix-engage-server/src/ozonetel/ozonetel-agent.controller.ts` (or create a new search controller)
+
+- [ ] **Step 1: Add `GET /api/search` endpoint**
+
+Queries leads, patients, and appointments in parallel via platform GraphQL. Returns grouped results.
+
+```typescript
+@Get('search')
+async search(@Query('q') query: string) {
+ if (!query || query.length < 2) return { leads: [], patients: [], appointments: [] };
+
+ const authHeader = `Bearer ${this.platformApiKey}`;
+
+ // Search leads by name or phone
+ const [leadsResult, patientsResult, appointmentsResult] = await Promise.all([
+ this.platform.queryWithAuth(`{ leads(first: 5, filter: {
+ or: [
+ { contactName: { firstName: { like: "%${query}%" } } },
+ { contactPhone: { primaryPhoneNumber: { like: "%${query}%" } } }
+ ]
+ }) { edges { node { id name contactName { firstName lastName } contactPhone { primaryPhoneNumber } source status } } } }`, undefined, authHeader),
+
+ this.platform.queryWithAuth(`{ patients(first: 5, filter: {
+ or: [
+ { fullName: { firstName: { like: "%${query}%" } } },
+ { phones: { primaryPhoneNumber: { like: "%${query}%" } } }
+ ]
+ }) { edges { node { id name fullName { firstName lastName } phones { primaryPhoneNumber } } } } }`, undefined, authHeader),
+
+ this.platform.queryWithAuth(`{ appointments(first: 5, filter: {
+ doctorName: { like: "%${query}%" }
+ }) { edges { node { id scheduledAt doctorName department appointmentStatus patientId } } } }`, undefined, authHeader),
+ ]).catch(() => [{ leads: { edges: [] } }, { patients: { edges: [] } }, { appointments: { edges: [] } }]);
+
+ return {
+ leads: leadsResult?.leads?.edges?.map((e: any) => e.node) ?? [],
+ patients: patientsResult?.patients?.edges?.map((e: any) => e.node) ?? [],
+ appointments: appointmentsResult?.appointments?.edges?.map((e: any) => e.node) ?? [],
+ };
+}
+```
+
+Note: GraphQL `like` filter syntax may differ on the platform. May need to use `contains` or fetch-and-filter client-side.
+
+- [ ] **Step 2: Commit**
+
+```
+feat: add cross-entity search endpoint
+```
+
+### Task B2: Update GlobalSearch component
+
+**Files:**
+- Modify: `helix-engage/src/components/shared/global-search.tsx`
+
+- [ ] **Step 1: Wire to sidecar search endpoint**
+
+Replace the local leads-only search with a call to `GET /api/search?q=`. Display results grouped by entity type with icons:
+- 👤 Leads — name, phone, source
+- 🏥 Patients — name, phone, MRN
+- 📅 Appointments — doctor, date, status
+
+Clicking a result navigates to the appropriate detail page.
+
+- [ ] **Step 2: Commit**
+
+```
+feat: wire global search to cross-entity sidecar endpoint
+```
+
+### Task B3: Add search to call desk header
+
+**Files:**
+- Modify: `helix-engage/src/pages/call-desk.tsx` or `src/components/layout/top-bar.tsx`
+
+- [ ] **Step 1: Add GlobalSearch to the call desk header**
+
+Place next to the existing search in the worklist area, or in the top bar so it's accessible from every page.
+
+- [ ] **Step 2: Commit**
+
+```
+feat: add global search to call desk header
+```
+
+---
+
+## Feature C: General Enquiry Form
+
+When a caller has a question (not a lead), the agent needs a structured form to capture the interaction.
+
+### File Map
+| File | Action |
+|------|--------|
+| `helix-engage/src/components/call-desk/enquiry-form.tsx` | Create: inline form for capturing general enquiries |
+| `helix-engage/src/components/call-desk/active-call-card.tsx` | Modify: add "Log Enquiry" button during active call |
+
+### Task C1: Create enquiry form
+
+**Files:**
+- Create: `helix-engage/src/components/call-desk/enquiry-form.tsx`
+
+- [ ] **Step 1: Create inline enquiry form**
+
+Fields (from spec US 5):
+- Patient Name*
+- Source/Referral*
+- Query Asked* (textarea)
+- Existing Patient? (Y/N)*
+ - If Y: Registered mobile number
+- Relevant Department (optional, select from doctors list)
+- Relevant Doctor (optional, filtered by department)
+- Follow-up needed? (Y/N)*
+ - If Y: Date and time
+- Disposition*
+
+On submit:
+1. Creates a Lead record with `source: 'PHONE_INQUIRY'`
+2. Creates a LeadActivity with `activityType: 'ENQUIRY'`
+3. If follow-up needed, creates a FollowUp record
+
+The form is inline (same pattern as appointment form) — shows below the call card when "Log Enquiry" is clicked.
+
+- [ ] **Step 2: Commit**
+
+```
+feat: add general enquiry capture form
+```
+
+### Task C2: Add "Log Enquiry" button to active call
+
+**Files:**
+- Modify: `helix-engage/src/components/call-desk/active-call-card.tsx`
+
+- [ ] **Step 1: Add button between "Book Appt" and "Transfer"**
+
+```typescript
+
+```
+
+Show the enquiry form inline below the call card when open (same pattern as appointment form).
+
+- [ ] **Step 2: Commit**
+
+```
+feat: add Log Enquiry button to active call card
+```
+
+---
+
+## Task D: Deploy and verify
+
+- [ ] **Step 1: Type check both projects**
+- [ ] **Step 2: Build and deploy sidecar**
+- [ ] **Step 3: Build and deploy frontend**
+- [ ] **Step 4: Test agent status toggle** — switch to Break, verify badge changes, switch back to Ready
+- [ ] **Step 5: Test global search** — search by name, phone number, verify results from leads + patients
+- [ ] **Step 6: Test enquiry form** — during a call, click Enquiry, fill form, submit, verify Lead + Activity created
+
+---
+
+## Notes
+
+- **Agent state and Ozonetel** — `changeAgentState` cannot transition from ACW. The toggle should disable during ACW and show a "Completing wrap-up..." state.
+- **Search filter syntax** — the platform's GraphQL `like` operator may not exist. Fallback: fetch first 50 records of each type and filter client-side by name/phone match.
+- **Enquiry vs Disposition** — the enquiry form is separate from the disposition form. An enquiry can be logged DURING a call (like booking an appointment), while disposition is logged AFTER the call ends.
+- **The 6-button problem** — active call now has: Mute, Hold, Book Appt, Enquiry, Transfer, Pause Rec, End = 7 buttons. Consider grouping Book Appt + Enquiry under a "More" dropdown, or using icon-only buttons for some.
diff --git a/src/components/call-desk/active-call-card.tsx b/src/components/call-desk/active-call-card.tsx
index 465de61..bbd005f 100644
--- a/src/components/call-desk/active-call-card.tsx
+++ b/src/components/call-desk/active-call-card.tsx
@@ -3,7 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash,
faPause, faPlay, faCalendarPlus, faCheckCircle,
- faPhoneArrowRight, faRecordVinyl,
+ faPhoneArrowRight, faRecordVinyl, faClipboardQuestion,
} from '@fortawesome/pro-duotone-svg-icons';
import { Button } from '@/components/base/buttons/button';
import { Badge } from '@/components/base/badges/badges';
@@ -14,8 +14,10 @@ import { useSip } from '@/providers/sip-provider';
import { DispositionForm } from './disposition-form';
import { AppointmentForm } from './appointment-form';
import { TransferDialog } from './transfer-dialog';
+import { EnquiryForm } from './enquiry-form';
import { formatPhone } from '@/lib/format';
import { apiClient } from '@/lib/api-client';
+import { cx } from '@/utils/cx';
import { notify } from '@/lib/toast';
import type { Lead, CallDisposition } from '@/types/entities';
@@ -43,6 +45,7 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
const [appointmentBookedDuringCall, setAppointmentBookedDuringCall] = useState(false);
const [transferOpen, setTransferOpen] = useState(false);
const [recordingPaused, setRecordingPaused] = useState(false);
+ const [enquiryOpen, setEnquiryOpen] = useState(false);
// Capture direction at mount — survives through disposition stage
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
@@ -242,30 +245,57 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
{formatDuration(callDuration)}
-
-
-
-
-
-
);
}
diff --git a/src/components/call-desk/agent-status-toggle.tsx b/src/components/call-desk/agent-status-toggle.tsx
new file mode 100644
index 0000000..f9ee252
--- /dev/null
+++ b/src/components/call-desk/agent-status-toggle.tsx
@@ -0,0 +1,102 @@
+import { useState } from 'react';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faCircle, faChevronDown } from '@fortawesome/pro-duotone-svg-icons';
+import { apiClient } from '@/lib/api-client';
+import { notify } from '@/lib/toast';
+import { cx } from '@/utils/cx';
+
+type AgentStatus = 'ready' | 'break' | 'training' | 'offline';
+
+const statusConfig: Record = {
+ ready: { label: 'Ready', color: 'text-success-primary', dotColor: 'text-fg-success-primary' },
+ break: { label: 'Break', color: 'text-warning-primary', dotColor: 'text-fg-warning-primary' },
+ training: { label: 'Training', color: 'text-brand-secondary', dotColor: 'text-fg-brand-primary' },
+ offline: { label: 'Offline', color: 'text-tertiary', dotColor: 'text-fg-quaternary' },
+};
+
+type AgentStatusToggleProps = {
+ isRegistered: boolean;
+ connectionStatus: string;
+};
+
+export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatusToggleProps) => {
+ const [status, setStatus] = useState(isRegistered ? 'ready' : 'offline');
+ const [menuOpen, setMenuOpen] = useState(false);
+ const [changing, setChanging] = useState(false);
+
+ const handleChange = async (newStatus: AgentStatus) => {
+ setMenuOpen(false);
+ if (newStatus === status) return;
+ setChanging(true);
+
+ try {
+ if (newStatus === 'ready') {
+ await apiClient.post('/api/ozonetel/agent-state', { state: 'Ready' });
+ } else if (newStatus === 'offline') {
+ await apiClient.post('/api/ozonetel/agent-logout', {
+ agentId: 'global',
+ password: 'Test123$',
+ });
+ } else {
+ const pauseReason = newStatus === 'break' ? 'Break' : 'Training';
+ await apiClient.post('/api/ozonetel/agent-state', { state: 'Pause', pauseReason });
+ }
+ setStatus(newStatus);
+ } catch {
+ notify.error('Status Change Failed', 'Could not update agent status');
+ } finally {
+ setChanging(false);
+ }
+ };
+
+ // If SIP isn't connected, show connection status
+ if (!isRegistered) {
+ return (
+
+
+ {connectionStatus}
+
+ );
+ }
+
+ const current = statusConfig[status];
+
+ return (
+
+
setMenuOpen(!menuOpen)}
+ disabled={changing}
+ className={cx(
+ 'flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1 transition duration-100 ease-linear',
+ 'hover:bg-secondary_hover cursor-pointer',
+ changing && 'opacity-50',
+ )}
+ >
+
+ {current.label}
+
+
+
+ {menuOpen && (
+ <>
+
setMenuOpen(false)} />
+
+ {(Object.entries(statusConfig) as [AgentStatus, typeof current][]).map(([key, cfg]) => (
+ handleChange(key)}
+ className={cx(
+ 'flex w-full items-center gap-2 px-3 py-2 text-xs font-medium transition duration-100 ease-linear',
+ key === status ? 'bg-active' : 'hover:bg-primary_hover',
+ )}
+ >
+
+ {cfg.label}
+
+ ))}
+
+ >
+ )}
+
+ );
+};
diff --git a/src/components/call-desk/enquiry-form.tsx b/src/components/call-desk/enquiry-form.tsx
new file mode 100644
index 0000000..0dc3842
--- /dev/null
+++ b/src/components/call-desk/enquiry-form.tsx
@@ -0,0 +1,189 @@
+import { useState, useEffect } from 'react';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faClipboardQuestion, faXmark } from '@fortawesome/pro-duotone-svg-icons';
+import { Input } from '@/components/base/input/input';
+import { Select } from '@/components/base/select/select';
+import { TextArea } from '@/components/base/textarea/textarea';
+import { Checkbox } from '@/components/base/checkbox/checkbox';
+import { Button } from '@/components/base/buttons/button';
+import { apiClient } from '@/lib/api-client';
+import { notify } from '@/lib/toast';
+
+type EnquiryFormProps = {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ callerPhone?: string | null;
+ onSaved?: () => void;
+};
+
+const dispositionItems = [
+ { id: 'CONVERTED', label: 'Converted' },
+ { id: 'FOLLOW_UP', label: 'Follow-up Needed' },
+ { id: 'GENERAL_QUERY', label: 'General Query' },
+ { id: 'NO_ANSWER', label: 'No Answer' },
+ { id: 'INVALID_NUMBER', label: 'Invalid Number' },
+ { id: 'CALL_DROPPED', label: 'Call Dropped' },
+];
+
+export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: EnquiryFormProps) => {
+ const [patientName, setPatientName] = useState('');
+ const [source, setSource] = useState('Phone Inquiry');
+ const [queryAsked, setQueryAsked] = useState('');
+ const [isExisting, setIsExisting] = useState(false);
+ const [registeredPhone, setRegisteredPhone] = useState(callerPhone ?? '');
+ const [department, setDepartment] = useState
(null);
+ const [doctor, setDoctor] = useState(null);
+ const [followUpNeeded, setFollowUpNeeded] = useState(false);
+ const [followUpDate, setFollowUpDate] = useState('');
+ const [disposition, setDisposition] = useState(null);
+ const [isSaving, setIsSaving] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Fetch doctors for department/doctor dropdowns
+ const [doctors, setDoctors] = useState>([]);
+
+ useEffect(() => {
+ if (!isOpen) return;
+ apiClient.graphql<{ doctors: { edges: Array<{ node: any }> } }>(
+ `{ doctors(first: 50) { edges { node {
+ id name fullName { firstName lastName } department
+ } } } }`,
+ ).then(data => {
+ setDoctors(data.doctors.edges.map(e => ({
+ id: e.node.id,
+ name: e.node.fullName ? `Dr. ${e.node.fullName.firstName} ${e.node.fullName.lastName}`.trim() : e.node.name,
+ department: e.node.department ?? '',
+ })));
+ }).catch(() => {});
+ }, [isOpen]);
+
+ const departmentItems = [...new Set(doctors.map(d => d.department).filter(Boolean))]
+ .map(dept => ({ id: dept, label: dept.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) }));
+
+ const filteredDoctors = department ? doctors.filter(d => d.department === department) : doctors;
+ const doctorItems = filteredDoctors.map(d => ({ id: d.id, label: d.name }));
+
+ const handleSave = async () => {
+ if (!patientName.trim() || !queryAsked.trim() || !disposition) {
+ setError('Please fill in required fields: patient name, query, and disposition.');
+ return;
+ }
+
+ setIsSaving(true);
+ setError(null);
+
+ try {
+ // Create a lead with source PHONE_INQUIRY
+ await apiClient.graphql(
+ `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
+ {
+ data: {
+ name: `Enquiry — ${patientName}`,
+ contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
+ contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined,
+ source: 'PHONE_INQUIRY',
+ status: disposition === 'CONVERTED' ? 'CONVERTED' : 'NEW',
+ interestedService: queryAsked.substring(0, 100),
+ },
+ },
+ );
+
+ // Create follow-up if needed
+ if (followUpNeeded && followUpDate) {
+ await apiClient.graphql(
+ `mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`,
+ {
+ data: {
+ name: `Follow-up — ${patientName}`,
+ typeCustom: 'CALLBACK',
+ status: 'PENDING',
+ priority: 'NORMAL',
+ scheduledAt: new Date(`${followUpDate}T09:00:00`).toISOString(),
+ },
+ },
+ { silent: true },
+ );
+ }
+
+ notify.success('Enquiry Logged', 'Contact details and query captured');
+ onSaved?.();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to save enquiry');
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+
+
+
+
+
+
+
Log Enquiry
+
Capture caller's question and details
+
+
+
onOpenChange(false)}
+ className="flex size-7 items-center justify-center rounded-md text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isExisting && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {followUpNeeded && (
+
+ )}
+
+
+
+ {error && (
+
{error}
+ )}
+
+
+
+ onOpenChange(false)}>Cancel
+
+ {isSaving ? 'Saving...' : 'Log Enquiry'}
+
+
+
+ );
+};
diff --git a/src/components/shared/global-search.tsx b/src/components/shared/global-search.tsx
index 92e88c8..89eac19 100644
--- a/src/components/shared/global-search.tsx
+++ b/src/components/shared/global-search.tsx
@@ -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(null);
const debounceRef = useRef | 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;
+ patients: Array;
+ appointments: Array;
+ }>(`/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 (
{
@@ -40,13 +41,7 @@ export const CallDeskPage = () => {
-
- {isRegistered ? 'Ready' : connectionStatus}
-
+
{totalPending > 0 && (
{totalPending} pending
)}