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

@@ -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
<Button size="sm" color="secondary"
iconLeading={...}
onClick={() => setEnquiryOpen(!enquiryOpen)}>Enquiry</Button>
```
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.

View File

@@ -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) => {
</div>
<Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge>
</div>
<div className="mt-3 flex gap-2">
<Button size="sm" color={isMuted ? 'primary-destructive' : 'secondary'}
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={isMuted ? faMicrophoneSlash : faMicrophone} className={className} />}
onClick={toggleMute}>{isMuted ? 'Unmute' : 'Mute'}</Button>
<Button size="sm" color={isOnHold ? 'primary-destructive' : 'secondary'}
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={isOnHold ? faPlay : faPause} className={className} />}
onClick={toggleHold}>{isOnHold ? 'Resume' : 'Hold'}</Button>
<Button size="sm" color="secondary"
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faCalendarPlus} className={className} />}
onClick={() => setAppointmentOpen(true)}>Book Appt</Button>
<Button size="sm" color="secondary"
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} />}
onClick={() => setTransferOpen(!transferOpen)}>Transfer</Button>
<Button size="sm" color={recordingPaused ? 'primary-destructive' : 'secondary'}
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faRecordVinyl} className={className} />}
<div className="mt-3 flex items-center gap-1.5">
{/* Icon-only toggles */}
<button
onClick={toggleMute}
title={isMuted ? 'Unmute' : 'Mute'}
className={cx(
'flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear',
isMuted ? 'bg-error-solid text-white' : 'bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary',
)}
>
<FontAwesomeIcon icon={isMuted ? faMicrophoneSlash : faMicrophone} className="size-3.5" />
</button>
<button
onClick={toggleHold}
title={isOnHold ? 'Resume' : 'Hold'}
className={cx(
'flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear',
isOnHold ? 'bg-warning-solid text-white' : 'bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary',
)}
>
<FontAwesomeIcon icon={isOnHold ? faPlay : faPause} className="size-3.5" />
</button>
<button
onClick={() => {
const action = recordingPaused ? 'unPause' : 'pause';
if (callUcid) {
apiClient.post('/api/ozonetel/recording', { ucid: callUcid, action }).catch(() => {});
}
if (callUcid) apiClient.post('/api/ozonetel/recording', { ucid: callUcid, action }).catch(() => {});
setRecordingPaused(!recordingPaused);
}}>{recordingPaused ? 'Resume Rec' : 'Pause Rec'}</Button>
}}
title={recordingPaused ? 'Resume Recording' : 'Pause Recording'}
className={cx(
'flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear',
recordingPaused ? 'bg-error-solid text-white' : 'bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary',
)}
>
<FontAwesomeIcon icon={faRecordVinyl} className="size-3.5" />
</button>
<div className="w-px h-6 bg-secondary mx-0.5" />
{/* Text+Icon primary actions */}
<Button size="sm" color="secondary"
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
onClick={() => setAppointmentOpen(true)}>Book Appt</Button>
<Button size="sm" color="secondary"
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
onClick={() => setEnquiryOpen(!enquiryOpen)}>Enquiry</Button>
<Button size="sm" color="secondary"
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
onClick={() => setTransferOpen(!transferOpen)}>Transfer</Button>
<Button size="sm" color="primary-destructive" className="ml-auto"
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faPhoneHangup} className={className} />}
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneHangup} className={className} {...rest} />}
onClick={() => { hangup(); setPostCallStage('disposition'); }}>End</Button>
</div>
@@ -291,6 +321,17 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
leadId={lead?.id ?? null}
onSaved={handleAppointmentSaved}
/>
{/* Enquiry form */}
<EnquiryForm
isOpen={enquiryOpen}
onOpenChange={setEnquiryOpen}
callerPhone={callerPhone}
onSaved={() => {
setEnquiryOpen(false);
notify.success('Enquiry Logged');
}}
/>
</div>
);
}

View File

@@ -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<AgentStatus, { label: string; color: string; dotColor: string }> = {
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<AgentStatus>(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 (
<div className="flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1">
<FontAwesomeIcon icon={faCircle} className="size-2 text-fg-warning-primary animate-pulse" />
<span className="text-xs font-medium text-tertiary">{connectionStatus}</span>
</div>
);
}
const current = statusConfig[status];
return (
<div className="relative">
<button
onClick={() => 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',
)}
>
<FontAwesomeIcon icon={faCircle} className={cx('size-2', current.dotColor)} />
<span className={cx('text-xs font-medium', current.color)}>{current.label}</span>
<FontAwesomeIcon icon={faChevronDown} className="size-2.5 text-fg-quaternary" />
</button>
{menuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div className="absolute right-0 top-full z-50 mt-1 w-36 rounded-lg bg-primary shadow-lg ring-1 ring-secondary py-1">
{(Object.entries(statusConfig) as [AgentStatus, typeof current][]).map(([key, cfg]) => (
<button
key={key}
onClick={() => 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',
)}
>
<FontAwesomeIcon icon={faCircle} className={cx('size-2', cfg.dotColor)} />
<span className={cfg.color}>{cfg.label}</span>
</button>
))}
</div>
</>
)}
</div>
);
};

View File

@@ -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<string | null>(null);
const [doctor, setDoctor] = useState<string | null>(null);
const [followUpNeeded, setFollowUpNeeded] = useState(false);
const [followUpDate, setFollowUpDate] = useState('');
const [disposition, setDisposition] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch doctors for department/doctor dropdowns
const [doctors, setDoctors] = useState<Array<{ id: string; name: string; department: string }>>([]);
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 (
<div className="rounded-xl border border-secondary bg-primary p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="flex size-8 items-center justify-center rounded-lg bg-warning-secondary">
<FontAwesomeIcon icon={faClipboardQuestion} className="size-4 text-fg-warning-primary" />
</div>
<div>
<h3 className="text-sm font-semibold text-primary">Log Enquiry</h3>
<p className="text-xs text-tertiary">Capture caller's question and details</p>
</div>
</div>
<button
onClick={() => 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"
>
<FontAwesomeIcon icon={faXmark} className="size-4" />
</button>
</div>
<div className="flex flex-col gap-3">
<Input label="Patient Name" placeholder="Full name" value={patientName} onChange={setPatientName} isRequired />
<Input label="Source / Referral" placeholder="How did they reach us?" value={source} onChange={setSource} isRequired />
<TextArea label="Query Asked" placeholder="What did the caller ask about?" value={queryAsked} onChange={setQueryAsked} rows={3} isRequired />
<Checkbox isSelected={isExisting} onChange={setIsExisting} label="Existing Patient" hint="Has visited the hospital before" />
{isExisting && (
<Input label="Registered Phone" placeholder="Phone number on file" value={registeredPhone} onChange={setRegisteredPhone} />
)}
<div className="border-t border-secondary" />
<div className="grid grid-cols-2 gap-3">
<Select label="Department" placeholder="Optional" items={departmentItems} selectedKey={department}
onSelectionChange={(key) => { setDepartment(key as string); setDoctor(null); }}>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
<Select label="Doctor" placeholder="Optional" items={doctorItems} selectedKey={doctor}
onSelectionChange={(key) => setDoctor(key as string)} isDisabled={!department}>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
</div>
<Checkbox isSelected={followUpNeeded} onChange={setFollowUpNeeded} label="Follow-up Needed" />
{followUpNeeded && (
<Input label="Follow-up Date" type="date" value={followUpDate} onChange={setFollowUpDate} isRequired />
)}
<Select label="Disposition" placeholder="Select outcome" items={dispositionItems} selectedKey={disposition}
onSelectionChange={(key) => setDisposition(key as string)} isRequired>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
{error && (
<div className="rounded-lg bg-error-primary p-3 text-sm text-error-primary">{error}</div>
)}
</div>
<div className="flex items-center justify-end gap-3 mt-4 pt-4 border-t border-secondary">
<Button size="sm" color="secondary" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}>
{isSaving ? 'Saving...' : 'Log Enquiry'}
</Button>
</div>
</div>
);
};

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

View File

@@ -10,7 +10,8 @@ import type { WorklistLead } from '@/components/call-desk/worklist-panel';
import { ContextPanel } from '@/components/call-desk/context-panel';
import { ActiveCallCard } from '@/components/call-desk/active-call-card';
import { BadgeWithDot, Badge } from '@/components/base/badges/badges';
import { Badge } from '@/components/base/badges/badges';
import { AgentStatusToggle } from '@/components/call-desk/agent-status-toggle';
import { cx } from '@/utils/cx';
export const CallDeskPage = () => {
@@ -40,13 +41,7 @@ export const CallDeskPage = () => {
</div>
<div className="flex items-center gap-2">
<BadgeWithDot
color={isRegistered ? 'success' : connectionStatus === 'connecting' ? 'warning' : 'gray'}
size="sm"
type="pill-color"
>
{isRegistered ? 'Ready' : connectionStatus}
</BadgeWithDot>
<AgentStatusToggle isRegistered={isRegistered} connectionStatus={connectionStatus} />
{totalPending > 0 && (
<Badge size="sm" color="brand" type="pill-color">{totalPending} pending</Badge>
)}