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:
284
docs/superpowers/plans/2026-03-21-phase1-unblock.md
Normal file
284
docs/superpowers/plans/2026-03-21-phase1-unblock.md
Normal 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.
|
||||||
@@ -3,7 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|||||||
import {
|
import {
|
||||||
faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash,
|
faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash,
|
||||||
faPause, faPlay, faCalendarPlus, faCheckCircle,
|
faPause, faPlay, faCalendarPlus, faCheckCircle,
|
||||||
faPhoneArrowRight, faRecordVinyl,
|
faPhoneArrowRight, faRecordVinyl, faClipboardQuestion,
|
||||||
} from '@fortawesome/pro-duotone-svg-icons';
|
} from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from '@/components/base/buttons/button';
|
||||||
import { Badge } from '@/components/base/badges/badges';
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
@@ -14,8 +14,10 @@ import { useSip } from '@/providers/sip-provider';
|
|||||||
import { DispositionForm } from './disposition-form';
|
import { DispositionForm } from './disposition-form';
|
||||||
import { AppointmentForm } from './appointment-form';
|
import { AppointmentForm } from './appointment-form';
|
||||||
import { TransferDialog } from './transfer-dialog';
|
import { TransferDialog } from './transfer-dialog';
|
||||||
|
import { EnquiryForm } from './enquiry-form';
|
||||||
import { formatPhone } from '@/lib/format';
|
import { formatPhone } from '@/lib/format';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
import { notify } from '@/lib/toast';
|
import { notify } from '@/lib/toast';
|
||||||
import type { Lead, CallDisposition } from '@/types/entities';
|
import type { Lead, CallDisposition } from '@/types/entities';
|
||||||
|
|
||||||
@@ -43,6 +45,7 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
|
|||||||
const [appointmentBookedDuringCall, setAppointmentBookedDuringCall] = useState(false);
|
const [appointmentBookedDuringCall, setAppointmentBookedDuringCall] = useState(false);
|
||||||
const [transferOpen, setTransferOpen] = useState(false);
|
const [transferOpen, setTransferOpen] = useState(false);
|
||||||
const [recordingPaused, setRecordingPaused] = useState(false);
|
const [recordingPaused, setRecordingPaused] = useState(false);
|
||||||
|
const [enquiryOpen, setEnquiryOpen] = useState(false);
|
||||||
// Capture direction at mount — survives through disposition stage
|
// Capture direction at mount — survives through disposition stage
|
||||||
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
|
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
|
||||||
|
|
||||||
@@ -242,30 +245,57 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
|
|||||||
</div>
|
</div>
|
||||||
<Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge>
|
<Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex items-center gap-1.5">
|
||||||
<Button size="sm" color={isMuted ? 'primary-destructive' : 'secondary'}
|
{/* Icon-only toggles */}
|
||||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={isMuted ? faMicrophoneSlash : faMicrophone} className={className} />}
|
<button
|
||||||
onClick={toggleMute}>{isMuted ? 'Unmute' : 'Mute'}</Button>
|
onClick={toggleMute}
|
||||||
<Button size="sm" color={isOnHold ? 'primary-destructive' : 'secondary'}
|
title={isMuted ? 'Unmute' : 'Mute'}
|
||||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={isOnHold ? faPlay : faPause} className={className} />}
|
className={cx(
|
||||||
onClick={toggleHold}>{isOnHold ? 'Resume' : 'Hold'}</Button>
|
'flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear',
|
||||||
<Button size="sm" color="secondary"
|
isMuted ? 'bg-error-solid text-white' : 'bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary',
|
||||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faCalendarPlus} className={className} />}
|
)}
|
||||||
onClick={() => setAppointmentOpen(true)}>Book Appt</Button>
|
>
|
||||||
<Button size="sm" color="secondary"
|
<FontAwesomeIcon icon={isMuted ? faMicrophoneSlash : faMicrophone} className="size-3.5" />
|
||||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} />}
|
</button>
|
||||||
onClick={() => setTransferOpen(!transferOpen)}>Transfer</Button>
|
<button
|
||||||
<Button size="sm" color={recordingPaused ? 'primary-destructive' : 'secondary'}
|
onClick={toggleHold}
|
||||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faRecordVinyl} className={className} />}
|
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={() => {
|
onClick={() => {
|
||||||
const action = recordingPaused ? 'unPause' : 'pause';
|
const action = recordingPaused ? 'unPause' : 'pause';
|
||||||
if (callUcid) {
|
if (callUcid) apiClient.post('/api/ozonetel/recording', { ucid: callUcid, action }).catch(() => {});
|
||||||
apiClient.post('/api/ozonetel/recording', { ucid: callUcid, action }).catch(() => {});
|
|
||||||
}
|
|
||||||
setRecordingPaused(!recordingPaused);
|
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"
|
<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>
|
onClick={() => { hangup(); setPostCallStage('disposition'); }}>End</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -291,6 +321,17 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
|
|||||||
leadId={lead?.id ?? null}
|
leadId={lead?.id ?? null}
|
||||||
onSaved={handleAppointmentSaved}
|
onSaved={handleAppointmentSaved}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Enquiry form */}
|
||||||
|
<EnquiryForm
|
||||||
|
isOpen={enquiryOpen}
|
||||||
|
onOpenChange={setEnquiryOpen}
|
||||||
|
callerPhone={callerPhone}
|
||||||
|
onSaved={() => {
|
||||||
|
setEnquiryOpen(false);
|
||||||
|
notify.success('Enquiry Logged');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
102
src/components/call-desk/agent-status-toggle.tsx
Normal file
102
src/components/call-desk/agent-status-toggle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
189
src/components/call-desk/enquiry-form.tsx
Normal file
189
src/components/call-desk/enquiry-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { faMagnifyingGlass, faUser, faCalendar } from '@fortawesome/pro-duotone-svg-icons';
|
import { faMagnifyingGlass, faUser, faCalendar } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { faIcon } from '@/lib/icon-wrapper';
|
import { faIcon } from '@/lib/icon-wrapper';
|
||||||
import { Input } from '@/components/base/input/input';
|
import { Input } from '@/components/base/input/input';
|
||||||
import { Badge } from '@/components/base/badges/badges';
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
import { useData } from '@/providers/data-provider';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { formatPhone } from '@/lib/format';
|
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
const SearchIcon = faIcon(faMagnifyingGlass);
|
const SearchIcon = faIcon(faMagnifyingGlass);
|
||||||
@@ -53,52 +52,11 @@ export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const navigate = useNavigate();
|
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(() => {
|
useEffect(() => {
|
||||||
if (debounceRef.current) {
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
clearTimeout(debounceRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.trim().length < 3) {
|
if (query.trim().length < 2) {
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
@@ -106,20 +64,60 @@ export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
debounceRef.current = setTimeout(() => {
|
debounceRef.current = setTimeout(async () => {
|
||||||
const searchResults = searchLeads(query);
|
try {
|
||||||
setResults(searchResults);
|
const data = await apiClient.get<{
|
||||||
setIsOpen(true);
|
leads: Array<any>;
|
||||||
setIsSearching(false);
|
patients: Array<any>;
|
||||||
setHighlightedIndex(-1);
|
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);
|
}, 300);
|
||||||
|
|
||||||
return () => {
|
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
||||||
if (debounceRef.current) {
|
}, [query]);
|
||||||
clearTimeout(debounceRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [query, searchLeads]);
|
|
||||||
|
|
||||||
// Close on outside click
|
// Close on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -174,7 +172,7 @@ export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
|
|||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative w-64" onKeyDown={handleKeyDown}>
|
<div ref={containerRef} className="relative w-64" onKeyDown={handleKeyDown}>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search leads..."
|
placeholder="Search patients, leads, appointments..."
|
||||||
icon={SearchIcon}
|
icon={SearchIcon}
|
||||||
aria-label="Global search"
|
aria-label="Global search"
|
||||||
value={query}
|
value={query}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import type { WorklistLead } from '@/components/call-desk/worklist-panel';
|
|||||||
import { ContextPanel } from '@/components/call-desk/context-panel';
|
import { ContextPanel } from '@/components/call-desk/context-panel';
|
||||||
import { ActiveCallCard } from '@/components/call-desk/active-call-card';
|
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';
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
export const CallDeskPage = () => {
|
export const CallDeskPage = () => {
|
||||||
@@ -40,13 +41,7 @@ export const CallDeskPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BadgeWithDot
|
<AgentStatusToggle isRegistered={isRegistered} connectionStatus={connectionStatus} />
|
||||||
color={isRegistered ? 'success' : connectionStatus === 'connecting' ? 'warning' : 'gray'}
|
|
||||||
size="sm"
|
|
||||||
type="pill-color"
|
|
||||||
>
|
|
||||||
{isRegistered ? 'Ready' : connectionStatus}
|
|
||||||
</BadgeWithDot>
|
|
||||||
{totalPending > 0 && (
|
{totalPending > 0 && (
|
||||||
<Badge size="sm" color="brand" type="pill-color">{totalPending} pending</Badge>
|
<Badge size="sm" color="brand" type="pill-color">{totalPending} pending</Badge>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user