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.