From c3604377b9f00687cdd8b64940eeb3b602e2add6 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Sat, 21 Mar 2026 14:21:40 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=201=20=E2=80=94=20agent=20status?= =?UTF-8?q?=20toggle,=20global=20search,=20enquiry=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../plans/2026-03-21-phase1-unblock.md | 284 ++++++++++++++++++ src/components/call-desk/active-call-card.tsx | 83 +++-- .../call-desk/agent-status-toggle.tsx | 102 +++++++ src/components/call-desk/enquiry-form.tsx | 189 ++++++++++++ src/components/shared/global-search.tsx | 116 ++++--- src/pages/call-desk.tsx | 11 +- 6 files changed, 697 insertions(+), 88 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-21-phase1-unblock.md create mode 100644 src/components/call-desk/agent-status-toggle.tsx create mode 100644 src/components/call-desk/enquiry-form.tsx 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)} -
- - - - - + + + }} + 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', + )} + > + + + +
+ + {/* Text+Icon primary actions */} + + +
@@ -291,6 +321,17 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => { leadId={lead?.id ?? null} onSaved={handleAppointmentSaved} /> + + {/* Enquiry form */} + { + setEnquiryOpen(false); + notify.success('Enquiry Logged'); + }} + />
); } 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 ( +
+ + + {menuOpen && ( + <> +
setMenuOpen(false)} /> +
+ {(Object.entries(statusConfig) as [AgentStatus, typeof current][]).map(([key, cfg]) => ( + + ))} +
+ + )} +
+ ); +}; 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

+
+
+ +
+ +
+ + + + +