4 Commits

Author SHA1 Message Date
727a0728ee feat: QA fixes — Patient 360 rewrite, token refresh, call flow, UI polish
- Patient 360 page queries Patient entity with appointments, calls, leads
- Patients added to CC agent sidebar navigation
- Auto token refresh on 401 (deduplicated concurrent refreshes)
- Call desk: callDismissed flag prevents SIP race on worklist return
- Missed calls skip disposition when never answered
- Callbacks tab renamed to Leads tab
- Branch column header on missed calls tab
- F0rty2.ai link on login footer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 11:52:33 +05:30
88fc743928 docs: add team onboarding README with architecture and troubleshooting guide
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 09:47:42 +05:30
744a91a1ff feat: Phase 2 — missed call queue, login redesign, button fix
- Missed call queue with FIFO auto-assignment, dedup, SLA tracking
- Status sub-tabs (Pending/Attempted/Completed/Invalid) in worklist
- missedCallId passed through disposition flow for callback tracking
- Login page redesigned: centered white card on blue background
- Disposition button changed to content-width
- NavAccountCard popover close fix on menu item click

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 09:16:53 +05:30
c3604377b9 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>
2026-03-21 14:21:40 +05:30
17 changed files with 2612 additions and 396 deletions

217
README.md
View File

@@ -1,56 +1,191 @@
# Untitled UI starter kit for Vite # Helix Engage — Frontend
This is an official Untitled UI starter kit for Vite. Kickstart your Untitled UI project with Vite in seconds. Call center CRM frontend for healthcare lead management. Built on the FortyTwo platform.
## Untitled UI React **Owner: Mouli**
[Untitled UI React](https://www.untitledui.com/react) is the worlds largest collection of open-source React UI components. Everything you need to design and develop modern, beautiful interfaces—fast. ## Architecture
Built with React 19.1, Tailwind CSS v4.1, TypeScript 5.8, and React Aria, Untitled UI React components deliver modern performance, type safety, and maintainability. ```
┌─────────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐
[Learn more](https://www.untitledui.com/react) • [Documentation](https://www.untitledui.com/react/docs/introduction) • [Figma](https://www.untitledui.com/figma) • [FAQs](https://www.untitledui.com/faqs) │ helix-engage │ │ helix-engage-server │ │ FortyTwo Platform │
│ (this repo) │────▶│ (sidecar) │────▶│ (backend) │
## Getting started │ React frontend │ │ NestJS REST API │ │ GraphQL API │
│ Port 5173 (dev) │ │ Port 4100 │ │ Port 4000 │
First, run the development server: └─────────────────────┘ └──────────────────────┘ └─────────────────────┘
│ │
```bash │ SIP/WebRTC │ Ozonetel CloudAgent APIs
npm run dev ▼ ▼
# or ┌───────────┐ ┌──────────────┐
yarn dev │ Ozonetel │ │ Ozonetel │
# or │ SIP (444) │ │ REST APIs │
pnpm dev └───────────┘ └──────────────┘
# or
bun dev
``` ```
Open [http://localhost:5173](http://localhost:5173) with your browser to see the result. **Three repos:**
| Repo | Purpose | Owner |
|------|---------|-------|
| `helix-engage` (this) | React frontend | Mouli |
| `helix-engage-server` | NestJS sidecar — Ozonetel + Platform bridge | Karthik |
| `helix-engage-app` | FortyTwo SDK app — entity schemas (Call, Lead, etc.) | Shared |
You can start editing the app by modifying the components in `src/` folder. The page auto-updates as you edit the file. ## Getting Started
## Resources ```bash
npm install
npm run dev # http://localhost:5173
npm run build # TypeScript check + production build
```
Untitled UI React is built on top of [Untitled UI Figma](https://www.untitledui.com/figma), the world's largest and most popular Figma UI kit and design system. Explore more: ### Environment Variables (set at build time or in `.env`)
**[Untitled UI Figma:](https://www.untitledui.com/react/resources/figma-files)** The world's largest Figma UI kit and design system. | Variable | Purpose | Dev Default | Production |
<br/> |----------|---------|-------------|------------|
**[Untitled UI Icons:](https://www.untitledui.com/react/resources/icons)** A clean, consistent, and neutral icon library crafted specifically for modern UI design. | `VITE_API_URL` | Platform GraphQL | `http://localhost:4000` | `https://engage-api.srv1477139.hstgr.cloud` |
<br/> | `VITE_SIDECAR_URL` | Sidecar REST API | `http://localhost:4100` | `https://engage-api.srv1477139.hstgr.cloud` |
**[Untitled UI file icons:](https://www.untitledui.com/react/resources/file-icons)** Free file format icons, designed specifically for modern web and UI design. | `VITE_SIP_URI` | Ozonetel SIP URI | — | `sip:523590@blr-pub-rtc4.ozonetel.com` |
<br/> | `VITE_SIP_PASSWORD` | SIP password | — | `523590` |
**[Untitled UI flag icons:](https://www.untitledui.com/react/resources/flag-icons)** Free country flag icons, designed specifically for modern web and UI design. | `VITE_SIP_WS_SERVER` | SIP WebSocket | — | `wss://blr-pub-rtc4.ozonetel.com:444` |
<br/>
**[Untitled UI avatars:](https://www.untitledui.com/react/resources/avatars)** Free placeholder user avatars and profile pictures to use in your projects.
<br/>
**[Untitled UI logos:](https://www.untitledui.com/react/resources/logos)** Free fictional company logos to use in your projects.
## License **Production build command:**
```bash
VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud \
VITE_SIDECAR_URL=https://engage-api.srv1477139.hstgr.cloud \
VITE_SIP_URI=sip:523590@blr-pub-rtc4.ozonetel.com \
VITE_SIP_PASSWORD=523590 \
VITE_SIP_WS_SERVER=wss://blr-pub-rtc4.ozonetel.com:444 \
npm run build
```
Untitled UI React open-source components are licensed under the MIT license, which means you can use them for free in unlimited commercial projects. ## Tech Stack
> [!NOTE] - **React 19** + TypeScript + Vite
> This license applies only to the starter kit and to the components included in this open-source repository. [Untitled UI React PRO](https://www.untitledui.com/react) includes hundreds more advanced UI components and page examples and is subject to a separate [license agreement](https://www.untitledui.com/license). - **Tailwind CSS 4** with semantic color tokens (`text-primary`, `bg-brand-section` — never raw colors like `text-gray-900`)
- **React Aria Components** for accessibility (imports always prefixed `Aria*`)
- **Jotai** for SIP/call state
- **React Context** for auth, data, theme
- **FontAwesome Pro Duotone** icons
- **Untitled UI** component library (`src/components/base/`, `src/components/application/`)
[Untitled UI license agreement →](https://www.untitledui.com/license) ## Project Structure
[Frequently asked questions →](https://www.untitledui.com/faqs) ```
src/
├── pages/ # Route-level pages
│ ├── call-desk.tsx # Main CC agent workspace — THE CORE PAGE
│ ├── login.tsx # Auth page (centered card on blue bg)
│ ├── call-history.tsx # CDR log viewer
│ ├── my-performance.tsx # Agent KPI dashboard
│ ├── team-dashboard.tsx # Supervisor overview
│ ├── all-leads.tsx # Lead master table
│ └── campaigns.tsx # Campaign listing
├── components/
│ ├── call-desk/ # ⚡ Call center components — WHERE MOST WORK HAPPENS
│ │ ├── active-call-card.tsx # In-call UI + post-call disposition flow
│ │ ├── worklist-panel.tsx # Agent task queue with tabs + sub-tabs
│ │ ├── context-panel.tsx # AI assistant + Lead 360 sidebar
│ │ ├── disposition-form.tsx # Post-call outcome selector
│ │ ├── appointment-form.tsx # Book appointment during/after call
│ │ ├── agent-status-toggle.tsx # Ready/Break/Training/Offline toggle
│ │ ├── transfer-dialog.tsx # Call transfer
│ │ ├── enquiry-form.tsx # General enquiry capture
│ │ ├── live-transcript.tsx # Real-time transcription (Deepgram)
│ │ └── phone-action-cell.tsx # Click-to-call in table rows
│ ├── base/ # Untitled UI primitives (Button, Input, Select, Badge)
│ ├── application/ # Complex UI (Table, Modal, Tabs, DatePicker, Nav)
│ ├── layout/ # Sidebar — role-based navigation
│ └── dashboard/ # KPI cards, charts, missed queue widget
├── providers/
│ ├── sip-provider.tsx # SIP WebRTC — call lifecycle management
│ ├── auth-provider.tsx # User session, roles (executive/admin/cc-agent)
│ ├── data-provider.tsx # Bulk entity loader (leads, campaigns, calls)
│ └── theme-provider.tsx # Light/dark mode
├── hooks/
│ ├── use-worklist.ts # Polls sidecar /api/worklist every 30s
│ ├── use-call-assist.ts # Live transcript via Socket.IO
│ └── use-sip-phone.ts # Low-level SIP.js wrapper
├── lib/
│ ├── api-client.ts # REST + GraphQL client (auth, queries, sidecar calls)
│ ├── queries.ts # Platform GraphQL query strings
│ └── format.ts # Phone/date formatting
├── state/
│ └── sip-state.ts # Jotai atoms (callState, callerNumber, isMuted, etc.)
└── types/
└── entities.ts # Lead, Patient, Call, Appointment, etc.
```
## Troubleshooting Guide — Where to Look
### "The call desk isn't working"
**File:** `src/pages/call-desk.tsx`
This is the orchestrator. It uses `useSip()` for call state, `useWorklist()` for the task queue, and renders either `ActiveCallCard` (in-call) or `WorklistPanel` (idle). Start here, then drill into whichever child component is misbehaving.
### "Calls aren't connecting / SIP errors"
**File:** `src/providers/sip-provider.tsx` + `src/state/sip-state.ts`
Check `VITE_SIP_*` env vars. Ozonetel SIP WebSocket runs on **port 444** — VPNs block it. If WebSocket hangs at "connecting", turn off VPN. Also check browser console for SIP.js registration errors.
### "Worklist not loading / empty"
**File:** `src/hooks/use-worklist.ts`
This polls `GET /api/worklist` on the sidecar every 30s. Open browser Network tab → filter for `/api/worklist`. Common causes: sidecar is down, auth token expired, or agent name doesn't match any assigned leads.
### "Missed calls not appearing / sub-tabs empty"
**File:** `src/components/call-desk/worklist-panel.tsx`
Missed calls come from the sidecar worklist response. The sub-tabs filter by `callbackstatus` field. If all sub-tabs are empty, the sidecar ingestion may not be running (check sidecar logs for `MissedQueueService`).
### "Disposition / appointment not saving"
**File:** `src/components/call-desk/active-call-card.tsx``handleDisposition()`
Posts to sidecar `POST /api/ozonetel/dispose`. Errors are caught silently (non-blocking). Check browser Network tab for the dispose request/response, then check sidecar logs.
### "Login broken / Failed to fetch"
**File:** `src/pages/login.tsx` + `src/lib/api-client.ts`
Login calls `apiClient.login()` → sidecar `/auth/login` → platform GraphQL. Most common cause: wrong `VITE_API_URL` (built with localhost instead of production URL). **Always set env vars at build time.**
### "UI component looks wrong"
**Files:** `src/components/base/` (primitives), `src/components/application/` (complex)
These come from the Untitled UI library. Design tokens are in `src/styles/theme.css`. Brand colors were rebuilt from logo blue `rgb(32, 96, 160)`.
### "Navigation / role-based access"
**File:** `src/components/layout/sidebar.tsx`
Navigation groups are defined per role (admin, cc-agent, executive). Routes are registered in `src/main.tsx`.
## Data Flow
```
User action
Component (e.g. ActiveCallCard)
├──▶ Sidecar REST API (via apiClient.post/get)
│ e.g. /api/ozonetel/dispose, /api/worklist
├──▶ Platform GraphQL (via apiClient.graphql)
│ e.g. leads, appointments, patients queries
└──▶ SIP.js (via useSip() hook)
Call control: answer, hangup, mute, hold
```
**Key pattern:** The frontend talks to TWO backends:
1. **Sidecar** (REST) — for Ozonetel telephony operations and worklist
2. **Platform** (GraphQL) — for entity CRUD (leads, appointments, patients)
## Conventions
- **File naming**: kebab-case (`worklist-panel.tsx`)
- **Colors**: Semantic tokens only (`text-primary`, `bg-brand-section`)
- **Icons**: `@fortawesome/pro-duotone-svg-icons` + `faIcon()` wrapper in `src/lib/icon-wrapper.ts`
- **React Aria**: Always prefix imports (`Button as AriaButton`)
- **Transitions**: `transition duration-100 ease-linear`
## Git Workflow
- `dev` — active development
- `master` — stable baseline
- Always build with production env vars before deploying

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.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,242 @@
# Phase 2: Missed Call Queue + Login Redesign + Button Fix
**Date**: 2026-03-22
**PRD Reference**: US 7 (Missed Call Queue), Login Page Redesign, Button Width Fix
**Branch**: `dev`
---
## 1. Missed Call Queue (US 7)
### 1.1 Data Model
The existing `Call` entity on the Fortytwo platform is extended with 4 custom fields (already added via admin portal):
| GraphQL Field Name | DB Column | Type | Purpose |
|---|---|---|---|
| `callbackstatus` | `callbackstatus` | SELECT | Lifecycle: `PENDING_CALLBACK`, `CALLBACK_ATTEMPTED`, `CALLBACK_COMPLETED`, `INVALID`, `WRONG_NUMBER` |
| `callsourcenumber` | `callsourcenumber` | TEXT | Which DID/branch the patient called |
| `missedcallcount` | `missedcallcount` | NUMBER | Dedup counter — same number calling multiple times before callback |
| `callbackattemptedat` | `callbackattemptedat` | DATE_TIME | Timestamp of first callback attempt |
**Important**: Custom fields use **all-lowercase** GraphQL names (not camelCase). Verified via introspection and mutation test on staging.
Existing fields used:
- `callStatus: MISSED` — identifies missed calls
- `agentName` — tracks which agent is assigned
- `disposition` — records callback outcome
- `callerNumber` — caller's phone (PHONES type, accessed as `callerNumber { primaryPhoneNumber }`)
- `startedAt` — when the call was missed
- `leadId` — linked lead (if matched)
### 1.2 Sidecar: Missed Queue Service
Extend the existing `src/worklist/` module (already handles missed call data and is registered in `app.module.ts`).
**New files**:
- `src/worklist/missed-queue.service.ts` — Queue logic (ingestion, dedup, assignment)
**Modified files**:
- `src/worklist/worklist.controller.ts` — Add missed queue endpoints
- `src/worklist/worklist.module.ts` — Register MissedQueueService
**Auth model**:
- `GET /api/missed-queue` and `PATCH /api/missed-queue/:id/status` — use agent's forwarded auth token (same as existing worklist endpoints)
- Ingestion timer and auto-assignment — use server API key (`PLATFORM_API_KEY`) since these run without a user request
#### Endpoints
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/missed-queue` | Returns missed calls for current agent, grouped by `callbackstatus` |
| `POST` | `/api/missed-queue/ingest` | Polls Ozonetel `abandonCalls`, deduplicates, writes to platform |
| `PATCH` | `/api/missed-queue/:id/status` | Updates `callbackstatus` on a Call record |
| `POST` | `/api/missed-queue/assign` | Assigns oldest unassigned PENDING_CALLBACK call to an agent |
#### Ingestion Flow (runs every 30s via `setInterval` on service init)
1. Call `OzonetelAgentService.getAbandonCalls()` with `fromTime`/`toTime` limited to the **last 5 minutes** (the method already supports these parameters). This prevents re-processing the entire day's abandon calls on service restart.
2. Normalize caller phone numbers to `+91XXXXXXXXXX` format before any query or write (Ozonetel may return numbers in varying formats like `009919876543210` or `9876543210`).
3. For each abandoned call:
- Extract `callerID` (phone number, normalized) and `did` (source number)
- Query platform: `calls(filter: { callerNumber: { primaryPhoneNumber: { eq: "<normalized_number>" } }, callbackstatus: { eq: PENDING_CALLBACK } })`
- **Match found** → `updateCall`: increment `missedcallcount`, update `startedAt` to latest timestamp
- **No match** → `createCall`:
```graphql
mutation { createCall(data: {
callStatus: MISSED,
direction: INBOUND,
callerNumber: { primaryPhoneNumber: "<normalized_number>", primaryPhoneCallingCode: "+91" },
callsourcenumber: "<DID>",
callbackstatus: PENDING_CALLBACK,
missedcallcount: 1,
startedAt: "<timestamp>"
}) { id } }
```
4. Track ingested Ozonetel `monitorUCID` values in a Set to avoid re-processing within the same poll cycle
#### Auto-Assignment (triggered on two events)
Assignment fires when an agent becomes available via either path:
1. **Disposition submission** (`POST /api/ozonetel/dispose`): After an agent completes a call and submits disposition, they become Ready. This is the primary trigger — most "agent available" transitions happen here.
2. **Manual state change** (`POST /api/ozonetel/agent-state`): When an agent manually toggles to Ready via AgentStatusToggle.
In both cases, call `MissedQueueService.assignNext(agentName)`:
1. Query platform: oldest Call with `callbackstatus: PENDING_CALLBACK` and `agentName` is null/empty, ordered by `startedAt: AscNullsLast`
2. If found → `updateCall` setting `agentName` to the available agent
3. Use optimistic concurrency: if the update fails (another agent claimed it first), retry with the next oldest call
4. Return assigned call to frontend (so it can surface at top of worklist)
**Note on race conditions**: Since this is a single-instance sidecar, a simple in-memory mutex around the assignment query+update is sufficient to prevent two simultaneous Ready events from claiming the same call.
#### Status Transitions
| Trigger | From Status | To Status | Additional Updates |
|---------|------------|-----------|-------------------|
| Agent clicks call-back | `PENDING_CALLBACK` | `CALLBACK_ATTEMPTED` | Set `callbackattemptedat` |
| Disposition: APPOINTMENT_BOOKED, INFO_PROVIDED, FOLLOW_UP_SCHEDULED, CALLBACK_REQUESTED | `CALLBACK_ATTEMPTED` | `CALLBACK_COMPLETED` | — |
| Disposition: NO_ANSWER (after max retries) | `CALLBACK_ATTEMPTED` | `CALLBACK_ATTEMPTED` | Stays attempted, agent can retry |
| Disposition: WRONG_NUMBER | `CALLBACK_ATTEMPTED` | `WRONG_NUMBER` | — |
| Agent marks invalid | Any | `INVALID` | — |
### 1.3 Sidecar: Worklist Update
Update `WorklistService.getMissedCalls()` to include the new fields in the query:
```graphql
calls(first: 20, filter: {
agentName: { eq: "<agent>" },
callStatus: { eq: MISSED },
callbackstatus: { in: [PENDING_CALLBACK, CALLBACK_ATTEMPTED] }
}, orderBy: [{ startedAt: AscNullsLast }]) {
edges { node {
id name createdAt
direction callStatus agentName
callerNumber { primaryPhoneNumber }
startedAt endedAt durationSec
disposition leadId
callbackstatus callsourcenumber missedcallcount callbackattemptedat
} }
}
```
### 1.4 Frontend: Worklist Panel Changes
**`src/hooks/use-worklist.ts`**:
- Add `callbackstatus`, `callsourcenumber`, `missedcallcount`, `callbackattemptedat` to `MissedCall` type
- Transform data from sidecar response (fields are already lowercase, minimal mapping needed)
**`src/components/call-desk/worklist-panel.tsx`**:
Replace the flat "Missed" tab with status sub-tabs:
```
[All] [Missed] [Callbacks] [Follow-ups] [Leads]
└── [Pending | Attempted | Completed | Invalid]
```
**Pending sub-tab** (default view):
- FIFO ordered (oldest first, matching `AscNullsLast` sort)
- Row content: caller phone, time since missed, missed call count badge (shown if >1), call source number, SLA color indicator
- SLA thresholds: green (<15 min), orange (1530 min), red (>30 min) — existing logic
- Click-to-call → triggers callback, sidecar auto-transitions to `CALLBACK_ATTEMPTED`
**Attempted sub-tab**:
- Calls where agent tried calling back but no final resolution yet
- Row content: caller phone, time since first attempt (`callbackattemptedat`), last disposition
- Click-to-call for retry
**Completed / Invalid sub-tabs**:
- Read-only history of resolved missed calls
- Shows: caller phone, final disposition, resolution timestamp
**Assignment notification**: When auto-assigned, the missed call appears at **top of the worklist** with a highlighted "Missed Call" badge. A toast notification alerts the agent.
### 1.5 Frontend: Post-Callback Status Update
When an agent clicks call-back on a missed call:
1. Frontend calls `PATCH /api/missed-queue/:id/status` with `{ status: 'CALLBACK_ATTEMPTED' }`
2. Normal outbound call flow begins via SIP
3. After call ends → disposition form → disposition submitted → sidecar maps disposition to final `callbackstatus` and updates platform
This integrates with the existing `ActiveCallCard` disposition flow. The frontend must pass the missed Call record ID as `missedCallId` in the disposition request body so the sidecar can look up and update the `callbackstatus`. The dispose endpoint currently receives `{ ucid, disposition, callerPhone, direction, durationSec, leadId, notes }` — add `missedCallId?: string` as an optional field. When present, the sidecar updates the corresponding Call record's `callbackstatus` based on disposition mapping:
- APPOINTMENT_BOOKED, INFO_PROVIDED, FOLLOW_UP_SCHEDULED, CALLBACK_REQUESTED → `CALLBACK_COMPLETED`
- WRONG_NUMBER → `WRONG_NUMBER`
- NO_ANSWER → stays `CALLBACK_ATTEMPTED` (agent can retry)
---
## 2. Login Page Redesign
### Current State
Split-panel layout: 60% blue left panel with marketing feature cards (Unified Lead Inbox, Campaign Intelligence, Speed to Contact) + 40% white right panel with login form.
### Target State
- **Full blue background** using `bg-brand-section` (existing brand blue token)
- **Centered white card** (~420px max-width, `rounded-xl`, `shadow-xl`)
- **Inside the card**:
- Helix Engage logo (prominent, centered)
- "Global Hospital" subtitle
- Google sign-in button with "OR CONTINUE WITH" divider
- Email input
- Password input with eye toggle
- Remember me checkbox + Forgot password link (same row)
- Sign in button (full-width within card — standard for login forms)
- **Footer**: subtle "Powered by FortyTwo" text below the card
- **No left panel, no marketing copy, no feature cards**
- **Mobile**: card fills screen width with padding
### File Changes
- `src/pages/login.tsx` — restructure layout, remove left panel, center card
---
## 3. Button Width Fix
### Problem
Buttons in call desk inline forms (disposition, appointment, enquiry, transfer) use `w-full`, spanning the entire container width. This looks awkward in wide panels.
### Fix
Change buttons in these forms from `w-full` to `w-auto` with right-aligned layout (`flex justify-end gap-3`).
### Scope
Login page buttons stay `w-full` (narrow container, standard practice).
### Affected Files
- `src/components/call-desk/disposition-form.tsx` — Save Disposition button (confirmed `w-full`)
- Other call desk form buttons (appointment, enquiry, transfer) — verify at implementation time, may already be content-width
---
## Technical Notes
### GraphQL Field Naming
Custom fields added via admin portal use **all-lowercase** GraphQL names:
- `callbackstatus` (not `callbackStatus`)
- `callsourcenumber` (not `callSourceNumber`)
- `missedcallcount` (not `missedCallCount`)
- `callbackattemptedat` (not `callbackAttemptedAt`)
Managed (app-defined) fields retain camelCase (`callStatus`, `agentName`, etc.).
### Verified on Staging
- Queries: `calls(first: 2) { edges { node { callbackstatus callsourcenumber missedcallcount callbackattemptedat } } }` ✅
- Mutations: `updateCall(id: "...", data: { callbackstatus: PENDING_CALLBACK, missedcallcount: 1 })` ✅
- Staging DB: `fortytwo_staging`, workspace schema: `workspace_3x7sonctrktrxft4b0bwuc26x`, table: `_call`
### Dedup Strategy
Deduplication is by caller phone number against `PENDING_CALLBACK` records. Once a missed call transitions to any other status, a new missed call from the same number creates a fresh record. This prevents stale dedup.
### Ozonetel Ingestion Idempotency
Each poll queries only the last 5 minutes via `fromTime`/`toTime` parameters, preventing full-day reprocessing on restart. Within a poll cycle, processed `monitorUCID` values are tracked in a `Set<string>` to avoid duplicates. The platform dedup query (phone number + `PENDING_CALLBACK`) provides a second safety net.
### Phone Number Normalization
All phone numbers are normalized to `+91XXXXXXXXXX` format before writes and queries. Ozonetel may return numbers as `009919876543210`, `919876543210`, or `9876543210` — strip leading `0091`/`91`/`0` prefixes, then prepend `+91`.
### Edge Cases
- **Multiple DIDs**: If a caller dials branch A, then branch B before callback, the records merge (count incremented). The `callsourcenumber` updates to the latest branch. This is intentional — the callback is to the patient, not the branch.
- **Agent goes offline after assignment**: Assigned missed calls stay with the agent. No automatic requeue. Supervisors can manually reassign in Phase 3.
- **Ingestion poll interval**: 30s, configurable via `MISSED_QUEUE_POLL_INTERVAL_MS` env var.

View File

@@ -71,17 +71,21 @@ export const NavAccountMenu = ({
ref={dialogRef} ref={dialogRef}
className={cx("w-66 rounded-xl bg-secondary_alt shadow-lg ring ring-secondary_alt outline-hidden", className)} className={cx("w-66 rounded-xl bg-secondary_alt shadow-lg ring ring-secondary_alt outline-hidden", className)}
> >
{({ close }) => (
<>
<div className="rounded-xl bg-primary ring-1 ring-secondary"> <div className="rounded-xl bg-primary ring-1 ring-secondary">
<div className="flex flex-col gap-0.5 py-1.5"> <div className="flex flex-col gap-0.5 py-1.5">
<NavAccountCardMenuItem label="View profile" icon={IconUser} shortcut="⌘K->P" /> <NavAccountCardMenuItem label="View profile" icon={IconUser} shortcut="⌘K->P" />
<NavAccountCardMenuItem label="Account settings" icon={IconSettings} shortcut="⌘S" /> <NavAccountCardMenuItem label="Account settings" icon={IconSettings} shortcut="⌘S" />
<NavAccountCardMenuItem label="Force Ready" icon={IconForceReady} onClick={onForceReady} /> <NavAccountCardMenuItem label="Force Ready" icon={IconForceReady} onClick={() => { close(); onForceReady?.(); }} />
</div> </div>
</div> </div>
<div className="pt-1 pb-1.5"> <div className="pt-1 pb-1.5">
<NavAccountCardMenuItem label="Sign out" icon={IconLogout} shortcut="⌥⇧Q" onClick={onSignOut} /> <NavAccountCardMenuItem label="Sign out" icon={IconLogout} shortcut="⌥⇧Q" onClick={() => { close(); onSignOut?.(); }} />
</div> </div>
</>
)}
</AriaDialog> </AriaDialog>
); );
}; };

View File

@@ -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';
@@ -24,6 +26,8 @@ type PostCallStage = 'disposition' | 'appointment' | 'follow-up' | 'done';
interface ActiveCallCardProps { interface ActiveCallCardProps {
lead: Lead | null; lead: Lead | null;
callerPhone: string; callerPhone: string;
missedCallId?: string | null;
onCallComplete?: () => void;
} }
const formatDuration = (seconds: number): string => { const formatDuration = (seconds: number): string => {
@@ -32,7 +36,7 @@ const formatDuration = (seconds: number): string => {
return `${m}:${s.toString().padStart(2, '0')}`; return `${m}:${s.toString().padStart(2, '0')}`;
}; };
export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => { export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete }: ActiveCallCardProps) => {
const { callState, callDuration, callUcid, isMuted, isOnHold, answer, reject, hangup, toggleMute, toggleHold } = useSip(); const { callState, callDuration, callUcid, isMuted, isOnHold, answer, reject, hangup, toggleMute, toggleHold } = useSip();
const setCallState = useSetAtom(sipCallStateAtom); const setCallState = useSetAtom(sipCallStateAtom);
const setCallerNumber = useSetAtom(sipCallerNumberAtom); const setCallerNumber = useSetAtom(sipCallerNumberAtom);
@@ -43,8 +47,11 @@ 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');
// Track if the call was ever answered (reached 'active' state)
const wasAnsweredRef = useRef(callState === 'active');
const firstName = lead?.contactName?.firstName ?? ''; const firstName = lead?.contactName?.firstName ?? '';
const lastName = lead?.contactName?.lastName ?? ''; const lastName = lead?.contactName?.lastName ?? '';
@@ -65,6 +72,7 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
durationSec: callDuration, durationSec: callDuration,
leadId: lead?.id ?? null, leadId: lead?.id ?? null,
notes, notes,
missedCallId: missedCallId ?? undefined,
}).catch((err) => console.warn('Disposition failed:', err)); }).catch((err) => console.warn('Disposition failed:', err));
} }
@@ -114,6 +122,7 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
setCallerNumber(null); setCallerNumber(null);
setCallUcid(null); setCallUcid(null);
setOutboundPending(false); setOutboundPending(false);
onCallComplete?.();
}; };
// Outbound ringing — agent initiated the call // Outbound ringing — agent initiated the call
@@ -167,6 +176,20 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
); );
} }
// Skip disposition for unanswered calls (ringing-in → ended without ever reaching active)
if (!wasAnsweredRef.current && postCallStage === null && (callState === 'ended' || callState === 'failed')) {
return (
<div className="rounded-xl border border-secondary bg-primary p-4 text-center">
<FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" />
<p className="text-sm font-semibold text-primary">Missed Call</p>
<p className="text-xs text-tertiary mt-1">{phoneDisplay} not answered</p>
<Button size="sm" color="secondary" className="mt-3" onClick={handleReset}>
Back to Worklist
</Button>
</div>
);
}
// Post-call flow takes priority over active state (handles race between hangup + SIP ended event) // Post-call flow takes priority over active state (handles race between hangup + SIP ended event)
if (postCallStage !== null || callState === 'ended' || callState === 'failed') { if (postCallStage !== null || callState === 'ended' || callState === 'failed') {
// Done state // Done state
@@ -228,6 +251,7 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
// Active call // Active call
if (callState === 'active') { if (callState === 'active') {
wasAnsweredRef.current = true;
return ( return (
<div className="rounded-xl border border-brand bg-primary p-4"> <div className="rounded-xl border border-brand bg-primary p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -242,30 +266,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 +342,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>
); );
} }

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

@@ -94,12 +94,13 @@ export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFor
rows={3} rows={3}
/> />
<div className="flex justify-end">
<button <button
type="button" type="button"
onClick={handleSubmit} onClick={handleSubmit}
disabled={selected === null} disabled={selected === null}
className={cx( className={cx(
'w-full rounded-xl py-3 text-sm font-semibold transition duration-100 ease-linear', 'rounded-xl px-6 py-2.5 text-sm font-semibold transition duration-100 ease-linear',
selected !== null selected !== null
? 'cursor-pointer bg-brand-solid text-white hover:bg-brand-solid_hover' ? 'cursor-pointer bg-brand-solid text-white hover:bg-brand-solid_hover'
: 'cursor-not-allowed bg-disabled text-disabled', : 'cursor-not-allowed bg-disabled text-disabled',
@@ -108,5 +109,6 @@ export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFor
Save & Close Call Save & Close Call
</button> </button>
</div> </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

@@ -45,8 +45,14 @@ type MissedCall = {
startedAt: string | null; startedAt: string | null;
leadId: string | null; leadId: string | null;
disposition: string | null; disposition: string | null;
callbackstatus: string | null;
callsourcenumber: string | null;
missedcallcount: number | null;
callbackattemptedat: string | null;
}; };
type MissedSubTab = 'pending' | 'attempted' | 'completed' | 'invalid';
interface WorklistPanelProps { interface WorklistPanelProps {
missedCalls: MissedCall[]; missedCalls: MissedCall[];
followUps: WorklistFollowUp[]; followUps: WorklistFollowUp[];
@@ -56,7 +62,7 @@ interface WorklistPanelProps {
selectedLeadId: string | null; selectedLeadId: string | null;
} }
type TabKey = 'all' | 'missed' | 'callbacks' | 'follow-ups'; type TabKey = 'all' | 'missed' | 'leads' | 'follow-ups';
type WorklistRow = { type WorklistRow = {
id: string; id: string;
@@ -136,25 +142,27 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
for (const call of missedCalls) { for (const call of missedCalls) {
const phone = call.callerNumber?.[0]; const phone = call.callerNumber?.[0];
const countBadge = call.missedcallcount && call.missedcallcount > 1 ? ` (${call.missedcallcount}x)` : '';
const sourceSuffix = call.callsourcenumber ? `${call.callsourcenumber}` : '';
rows.push({ rows.push({
id: `mc-${call.id}`, id: `mc-${call.id}`,
type: 'missed', type: 'missed',
priority: 'HIGH', priority: 'HIGH',
name: phone ? formatPhone(phone) : 'Unknown', name: (phone ? formatPhone(phone) : 'Unknown') + countBadge,
phone: phone ? formatPhone(phone) : '', phone: phone ? formatPhone(phone) : '',
phoneRaw: phone?.number ?? '', phoneRaw: phone?.number ?? '',
direction: call.callDirection === 'OUTBOUND' ? 'outbound' : 'inbound', direction: call.callDirection === 'OUTBOUND' ? 'outbound' : 'inbound',
typeLabel: 'Missed Call', typeLabel: 'Missed Call',
reason: call.startedAt reason: call.startedAt
? `Missed at ${new Date(call.startedAt).toLocaleTimeString('en-IN', { hour: 'numeric', minute: '2-digit', hour12: true })}` ? `Missed at ${new Date(call.startedAt).toLocaleTimeString('en-IN', { hour: 'numeric', minute: '2-digit', hour12: true })}${sourceSuffix}`
: 'Missed call', : 'Missed call',
createdAt: call.createdAt, createdAt: call.createdAt,
taskState: 'PENDING', taskState: call.callbackstatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING',
leadId: call.leadId, leadId: call.leadId,
originalLead: null, originalLead: null,
lastContactedAt: call.startedAt ?? call.createdAt, lastContactedAt: call.callbackattemptedat ?? call.startedAt ?? call.createdAt,
contactAttempts: 0, contactAttempts: 0,
source: null, source: call.callsourcenumber ?? null,
lastDisposition: call.disposition ?? null, lastDisposition: call.disposition ?? null,
}); });
} }
@@ -227,16 +235,30 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectLead, selectedLeadId }: WorklistPanelProps) => { export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectLead, selectedLeadId }: WorklistPanelProps) => {
const [tab, setTab] = useState<TabKey>('all'); const [tab, setTab] = useState<TabKey>('all');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [missedSubTab, setMissedSubTab] = useState<MissedSubTab>('pending');
const missedByStatus = useMemo(() => ({
pending: missedCalls.filter(c => c.callbackstatus === 'PENDING_CALLBACK' || !c.callbackstatus),
attempted: missedCalls.filter(c => c.callbackstatus === 'CALLBACK_ATTEMPTED'),
completed: missedCalls.filter(c => c.callbackstatus === 'CALLBACK_COMPLETED' || c.callbackstatus === 'WRONG_NUMBER'),
invalid: missedCalls.filter(c => c.callbackstatus === 'INVALID'),
}), [missedCalls]);
const allRows = useMemo( const allRows = useMemo(
() => buildRows(missedCalls, followUps, leads), () => buildRows(missedCalls, followUps, leads),
[missedCalls, followUps, leads], [missedCalls, followUps, leads],
); );
// Build rows from sub-tab filtered missed calls when on missed tab
const missedSubTabRows = useMemo(
() => buildRows(missedByStatus[missedSubTab], [], []),
[missedByStatus, missedSubTab],
);
const filteredRows = useMemo(() => { const filteredRows = useMemo(() => {
let rows = allRows; let rows = allRows;
if (tab === 'missed') rows = rows.filter((r) => r.type === 'missed'); if (tab === 'missed') rows = missedSubTabRows;
else if (tab === 'callbacks') rows = rows.filter((r) => r.type === 'callback'); else if (tab === 'leads') rows = rows.filter((r) => r.type === 'lead');
else if (tab === 'follow-ups') rows = rows.filter((r) => r.type === 'follow-up'); else if (tab === 'follow-ups') rows = rows.filter((r) => r.type === 'follow-up');
if (search.trim()) { if (search.trim()) {
@@ -250,7 +272,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
}, [allRows, tab, search]); }, [allRows, tab, search]);
const missedCount = allRows.filter((r) => r.type === 'missed').length; const missedCount = allRows.filter((r) => r.type === 'missed').length;
const callbackCount = allRows.filter((r) => r.type === 'callback').length; const leadCount = allRows.filter((r) => r.type === 'lead').length;
const followUpCount = allRows.filter((r) => r.type === 'follow-up').length; const followUpCount = allRows.filter((r) => r.type === 'follow-up').length;
// Notification for new missed calls // Notification for new missed calls
@@ -274,7 +296,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
const tabItems = [ const tabItems = [
{ id: 'all' as const, label: 'All Tasks', badge: allRows.length > 0 ? String(allRows.length) : undefined }, { id: 'all' as const, label: 'All Tasks', badge: allRows.length > 0 ? String(allRows.length) : undefined },
{ id: 'missed' as const, label: 'Missed Calls', badge: missedCount > 0 ? String(missedCount) : undefined }, { id: 'missed' as const, label: 'Missed Calls', badge: missedCount > 0 ? String(missedCount) : undefined },
{ id: 'callbacks' as const, label: 'Callbacks', badge: callbackCount > 0 ? String(callbackCount) : undefined }, { id: 'leads' as const, label: 'Leads', badge: leadCount > 0 ? String(leadCount) : undefined },
{ id: 'follow-ups' as const, label: 'Follow-ups', badge: followUpCount > 0 ? String(followUpCount) : undefined }, { id: 'follow-ups' as const, label: 'Follow-ups', badge: followUpCount > 0 ? String(followUpCount) : undefined },
]; ];
@@ -318,6 +340,31 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
</div> </div>
</div> </div>
{/* Missed call status sub-tabs */}
{tab === 'missed' && (
<div className="flex gap-1 px-5 py-2 border-b border-secondary">
{(['pending', 'attempted', 'completed', 'invalid'] as MissedSubTab[]).map(sub => (
<button
key={sub}
onClick={() => { setMissedSubTab(sub); setPage(1); }}
className={cx(
'px-3 py-1 text-xs font-medium rounded-md capitalize transition duration-100 ease-linear',
missedSubTab === sub
? 'bg-brand-50 text-brand-700 border border-brand-200'
: 'text-tertiary hover:text-secondary hover:bg-secondary',
)}
>
{sub}
{sub === 'pending' && missedByStatus.pending.length > 0 && (
<span className="ml-1.5 bg-error-50 text-error-700 text-xs px-1.5 py-0.5 rounded-full">
{missedByStatus.pending.length}
</span>
)}
</button>
))}
</div>
)}
{filteredRows.length === 0 ? ( {filteredRows.length === 0 ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<p className="text-sm text-quaternary"> <p className="text-sm text-quaternary">
@@ -331,7 +378,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
<Table.Head label="PRIORITY" className="w-20" isRowHeader /> <Table.Head label="PRIORITY" className="w-20" isRowHeader />
<Table.Head label="PATIENT" /> <Table.Head label="PATIENT" />
<Table.Head label="PHONE" /> <Table.Head label="PHONE" />
<Table.Head label="SOURCE" className="w-28" /> <Table.Head label={tab === 'missed' ? 'BRANCH' : 'SOURCE'} className="w-28" />
<Table.Head label="SLA" className="w-24" /> <Table.Head label="SLA" className="w-24" />
</Table.Header> </Table.Header>
<Table.Body items={pagedRows}> <Table.Body items={pagedRows}>

View File

@@ -70,6 +70,7 @@ const getNavSections = (role: string): NavSection[] => {
{ label: 'Call Center', items: [ { label: 'Call Center', items: [
{ label: 'Call Desk', href: '/', icon: IconPhone }, { label: 'Call Desk', href: '/', icon: IconPhone },
{ label: 'Call History', href: '/call-history', icon: IconClockRewind }, { label: 'Call History', href: '/call-history', icon: IconClockRewind },
{ label: 'Patients', href: '/patients', icon: IconHospitalUser },
{ label: 'My Performance', href: '/my-performance', icon: IconChartMixed }, { label: 'My Performance', href: '/my-performance', icon: IconChartMixed },
]}, ]},
]; ];

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 { 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 {
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); setResults(searchResults);
setIsOpen(true); setIsOpen(true);
} catch {
setResults([]);
} finally {
setIsSearching(false); setIsSearching(false);
setHighlightedIndex(-1); 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}

View File

@@ -15,6 +15,10 @@ type MissedCall = {
disposition: string | null; disposition: string | null;
callNotes: string | null; callNotes: string | null;
leadId: string | null; leadId: string | null;
callbackstatus: string | null;
callsourcenumber: string | null;
missedcallcount: number | null;
callbackattemptedat: string | null;
}; };
type WorklistFollowUp = { type WorklistFollowUp = {

View File

@@ -31,8 +31,55 @@ const authHeaders = (): Record<string, string> => {
}; };
}; };
// Shared response handler — extracts error message, handles 401, toasts on failure // Token refresh — attempts to get a new access token using the refresh token
const handleResponse = async <T>(response: Response, silent = false): Promise<T> => { let refreshPromise: Promise<boolean> | null = null;
const tryRefreshToken = async (): Promise<boolean> => {
// Deduplicate concurrent refresh attempts
if (refreshPromise) return refreshPromise;
refreshPromise = (async () => {
const refreshToken = getRefreshToken();
if (!refreshToken) return false;
try {
const response = await fetch(`${API_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) return false;
const data = await response.json();
if (data.accessToken && data.refreshToken) {
storeTokens(data.accessToken, data.refreshToken);
return true;
}
return false;
} catch {
return false;
} finally {
refreshPromise = null;
}
})();
return refreshPromise;
};
// Shared response handler — extracts error message, handles 401 with auto-refresh, toasts on failure
const handleResponse = async <T>(response: Response, silent = false, retryFn?: () => Promise<Response>): Promise<T> => {
if (response.status === 401 && retryFn) {
const refreshed = await tryRefreshToken();
if (refreshed) {
const retryResponse = await retryFn();
return handleResponse<T>(retryResponse, silent);
}
clearTokens();
if (!silent) notify.error('Session expired. Please log in again.');
throw new AuthError();
}
if (response.status === 401) { if (response.status === 401) {
clearTokens(); clearTokens();
if (!silent) notify.error('Session expired. Please log in again.'); if (!silent) notify.error('Session expired. Please log in again.');
@@ -86,17 +133,24 @@ export const apiClient = {
const token = getStoredToken(); const token = getStoredToken();
if (!token) throw new AuthError(); if (!token) throw new AuthError();
const response = await fetch(`${API_URL}/graphql`, { const doFetch = () => fetch(`${API_URL}/graphql`, {
method: 'POST', method: 'POST',
headers: authHeaders(), headers: authHeaders(),
body: JSON.stringify({ query, variables }), body: JSON.stringify({ query, variables }),
}); });
let response = await doFetch();
if (response.status === 401) { if (response.status === 401) {
const refreshed = await tryRefreshToken();
if (refreshed) {
response = await doFetch();
} else {
clearTokens(); clearTokens();
if (!options?.silent) notify.error('Session expired', 'Please log in again.'); if (!options?.silent) notify.error('Session expired', 'Please log in again.');
throw new AuthError(); throw new AuthError();
} }
}
const json = await response.json(); const json = await response.json();
if (json.errors) { if (json.errors) {
@@ -110,20 +164,22 @@ export const apiClient = {
// REST — all sidecar API calls go through these // REST — all sidecar API calls go through these
async post<T>(path: string, body?: Record<string, unknown>, options?: { silent?: boolean }): Promise<T> { async post<T>(path: string, body?: Record<string, unknown>, options?: { silent?: boolean }): Promise<T> {
const response = await fetch(`${API_URL}${path}`, { const doFetch = () => fetch(`${API_URL}${path}`, {
method: 'POST', method: 'POST',
headers: authHeaders(), headers: authHeaders(),
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
}); });
return handleResponse<T>(response, options?.silent); const response = await doFetch();
return handleResponse<T>(response, options?.silent, doFetch);
}, },
async get<T>(path: string, options?: { silent?: boolean }): Promise<T> { async get<T>(path: string, options?: { silent?: boolean }): Promise<T> {
const response = await fetch(`${API_URL}${path}`, { const doFetch = () => fetch(`${API_URL}${path}`, {
method: 'GET', method: 'GET',
headers: authHeaders(), headers: authHeaders(),
}); });
return handleResponse<T>(response, options?.silent); const response = await doFetch();
return handleResponse<T>(response, options?.silent, doFetch);
}, },
// Health check — silent, no toasts // Health check — silent, no toasts

View File

@@ -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 = () => {
@@ -20,8 +21,15 @@ export const CallDeskPage = () => {
const { missedCalls, followUps, marketingLeads, totalPending, loading } = useWorklist(); const { missedCalls, followUps, marketingLeads, totalPending, loading } = useWorklist();
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null); const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
const [contextOpen, setContextOpen] = useState(true); const [contextOpen, setContextOpen] = useState(true);
const [activeMissedCallId, setActiveMissedCallId] = useState<string | null>(null);
const [callDismissed, setCallDismissed] = useState(false);
const isInCall = callState === 'ringing-in' || callState === 'ringing-out' || callState === 'active' || callState === 'ended' || callState === 'failed'; // Reset callDismissed when a new call starts (ringing in or out)
if (callDismissed && (callState === 'ringing-in' || callState === 'ringing-out')) {
setCallDismissed(false);
}
const isInCall = !callDismissed && (callState === 'ringing-in' || callState === 'ringing-out' || callState === 'active' || callState === 'ended' || callState === 'failed');
const callerLead = callerNumber const callerLead = callerNumber
? marketingLeads.find((l) => l.contactPhone?.[0]?.number?.endsWith(callerNumber) || callerNumber.endsWith(l.contactPhone?.[0]?.number ?? '---')) ? marketingLeads.find((l) => l.contactPhone?.[0]?.number?.endsWith(callerNumber) || callerNumber.endsWith(l.contactPhone?.[0]?.number ?? '---'))
@@ -40,13 +48,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>
)} )}
@@ -67,7 +69,7 @@ export const CallDeskPage = () => {
{/* Active call */} {/* Active call */}
{isInCall && ( {isInCall && (
<div className="p-5"> <div className="p-5">
<ActiveCallCard lead={activeLeadFull} callerPhone={callerNumber ?? ''} /> <ActiveCallCard lead={activeLeadFull} callerPhone={callerNumber ?? ''} missedCallId={activeMissedCallId} onCallComplete={() => { setActiveMissedCallId(null); setCallDismissed(true); }} />
</div> </div>
)} )}

View File

@@ -8,22 +8,6 @@ import { SocialButton } from '@/components/base/buttons/social-button';
import { Checkbox } from '@/components/base/checkbox/checkbox'; import { Checkbox } from '@/components/base/checkbox/checkbox';
import { Input } from '@/components/base/input/input'; import { Input } from '@/components/base/input/input';
const features = [
{
title: 'Unified Lead Inbox',
description: 'All channels in one workspace',
},
{
title: 'Campaign Intelligence',
description: 'Real-time performance tracking',
},
{
title: 'Speed to Contact',
description: 'Automated assignment and outreach',
},
];
export const LoginPage = () => { export const LoginPage = () => {
const { loginWithUser } = useAuth(); const { loginWithUser } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -87,81 +71,17 @@ export const LoginPage = () => {
}; };
return ( return (
<div className="flex h-screen w-full overflow-hidden"> <div className="min-h-screen bg-brand-section flex flex-col items-center justify-center p-4">
{/* Left panel — 60% — hidden on mobile */} {/* Login Card */}
<div <div className="w-full max-w-[420px] bg-primary rounded-xl shadow-xl p-8">
className="relative hidden lg:flex flex-col justify-center items-center bg-brand-section overflow-hidden" {/* Logo */}
style={{ flex: '0 0 60%' }} <div className="flex flex-col items-center mb-8">
> <img src="/helix-logo.png" alt="Helix Engage" className="size-12 rounded-xl mb-3" />
{/* Abstract corner gradients */} <h1 className="text-display-xs font-bold text-primary font-display">Sign in to Helix Engage</h1>
<div <p className="text-sm text-tertiary mt-1">Global Hospital</p>
className="pointer-events-none absolute -top-24 -left-24 size-[400px] rounded-full"
style={{
background:
'radial-gradient(circle, rgba(var(--color-brand-600-rgb, 99,102,241), 0.2) 0%, transparent 70%)',
filter: 'blur(200px)',
}}
aria-hidden="true"
/>
<div
className="pointer-events-none absolute -bottom-24 -right-24 size-[400px] rounded-full"
style={{
background:
'radial-gradient(circle, rgba(var(--color-blue-light-600-rgb, 56,189,248), 0.2) 0%, transparent 70%)',
filter: 'blur(200px)',
}}
aria-hidden="true"
/>
{/* Content */}
<div className="relative z-10 flex flex-col gap-10 w-full max-w-[560px] px-12">
{/* Logo lockup */}
<div className="flex items-center gap-3">
<img src="/helix-logo.png" alt="Helix Engage" className="size-10 rounded-xl shrink-0" />
<span className="text-white font-bold text-xl font-display tracking-tight">Helix Engage</span>
</div> </div>
{/* Headline */}
<div className="flex flex-col gap-4">
<h1 className="text-display-md font-bold text-white tracking-tight font-display leading-tight">
Smarter lead management for healthcare teams.
</h1>
<p className="text-lg text-white/70">
Unified visibility into leads, campaigns, and team performance. Built for Global Hospital.
</p>
</div>
{/* Feature cards */}
<div className="flex flex-col gap-3">
{features.map((feature) => (
<div
key={feature.title}
className="flex flex-col gap-1 rounded-2xl p-4 backdrop-blur-sm"
style={{ background: 'rgba(255,255,255,0.05)' }}
>
<span className="text-sm font-semibold text-white">{feature.title}</span>
<span className="text-sm" style={{ color: 'rgba(255,255,255,0.6)' }}>{feature.description}</span>
</div>
))}
</div>
</div>
</div>
{/* Right panel — 40% on desktop, full width on mobile */}
<div className="flex flex-1 flex-col justify-center items-center bg-primary px-6 py-12">
<form
onSubmit={handleSubmit}
className="flex flex-col w-full max-w-[448px]"
noValidate
>
{/* Heading */}
<h2 className="text-display-xs font-bold text-primary font-display">Sign in to Helix Engage</h2>
<p className="mt-1 text-sm text-tertiary">Global Hospital</p>
{/* Role is determined by platform — no selector needed */}
{/* Google sign-in */} {/* Google sign-in */}
<div className="mt-6">
<SocialButton <SocialButton
social="google" social="google"
size="lg" size="lg"
@@ -172,29 +92,32 @@ export const LoginPage = () => {
> >
Sign in with Google Sign in with Google
</SocialButton> </SocialButton>
</div>
{/* Divider */} {/* Divider */}
<div className="mt-6 flex items-center gap-3"> <div className="mt-5 mb-5 flex items-center gap-3">
<div className="flex-1 h-px bg-secondary" /> <div className="flex-1 h-px bg-secondary" />
<span className="text-xs font-semibold text-quaternary tracking-wider uppercase">or continue with</span> <span className="text-xs font-semibold text-quaternary tracking-wider uppercase">or continue with</span>
<div className="flex-1 h-px bg-secondary" /> <div className="flex-1 h-px bg-secondary" />
</div> </div>
{/* Email input */} {/* Form */}
<div className="mt-6"> <form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
{error && (
<div className="rounded-lg bg-error-secondary p-3 text-sm text-error-primary">
{error}
</div>
)}
<Input <Input
label="Email" label="Email"
type="email" type="email"
placeholder="sanjay@globalhospital.com" placeholder="you@globalhospital.com"
value={email} value={email}
onChange={(value) => setEmail(value)} onChange={(value) => setEmail(value)}
size="md" size="md"
/> />
</div>
{/* Password input with eye toggle */} <div className="relative">
<div className="mt-4 relative">
<Input <Input
label="Password" label="Password"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
@@ -213,8 +136,7 @@ export const LoginPage = () => {
</button> </button>
</div> </div>
{/* Remember me + Forgot password */} <div className="flex items-center justify-between">
<div className="mt-3 flex items-center justify-between">
<Checkbox <Checkbox
label="Remember me" label="Remember me"
size="sm" size="sm"
@@ -230,15 +152,6 @@ export const LoginPage = () => {
</button> </button>
</div> </div>
{/* Error message */}
{error && (
<div className="mt-4 rounded-lg bg-error-primary p-3 text-sm text-error-primary">
{error}
</div>
)}
{/* Sign in button */}
<div className="mt-6">
<Button <Button
type="submit" type="submit"
size="lg" size="lg"
@@ -248,9 +161,11 @@ export const LoginPage = () => {
> >
Sign in Sign in
</Button> </Button>
</div>
</form> </form>
</div> </div>
{/* Footer */}
<a href="https://f0rty2.ai" target="_blank" rel="noopener noreferrer" className="mt-6 text-xs text-primary_on-brand opacity-60 hover:opacity-90 transition duration-100 ease-linear">Powered by F0rty2.ai</a>
</div> </div>
); );
}; };

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { faPhone, faEnvelope, faCalendar, faCommentDots, faPlus } from '@fortawesome/pro-duotone-svg-icons'; import { faPhone, faEnvelope, faCalendar, faCommentDots, faPlus, faCalendarCheck } from '@fortawesome/pro-duotone-svg-icons';
import { faIcon } from '@/lib/icon-wrapper'; import { faIcon } from '@/lib/icon-wrapper';
const Phone01 = faIcon(faPhone); const Phone01 = faIcon(faPhone);
@@ -8,16 +8,15 @@ const Mail01 = faIcon(faEnvelope);
const Calendar = faIcon(faCalendar); const Calendar = faIcon(faCalendar);
const MessageTextSquare01 = faIcon(faCommentDots); const MessageTextSquare01 = faIcon(faCommentDots);
const Plus = faIcon(faPlus); const Plus = faIcon(faPlus);
const CalendarCheck = faIcon(faCalendarCheck);
import { Tabs, TabList, Tab, TabPanel } from '@/components/application/tabs/tabs'; import { Tabs, TabList, Tab, TabPanel } from '@/components/application/tabs/tabs';
import { TopBar } from '@/components/layout/top-bar'; import { TopBar } from '@/components/layout/top-bar';
import { Avatar } from '@/components/base/avatar/avatar'; import { Avatar } from '@/components/base/avatar/avatar';
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { Button } from '@/components/base/buttons/button'; import { Button } from '@/components/base/buttons/button';
import { LeadStatusBadge } from '@/components/shared/status-badge';
import { SourceTag } from '@/components/shared/source-tag';
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button'; import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
import { useData } from '@/providers/data-provider'; import { apiClient } from '@/lib/api-client';
import { formatPhone, formatShortDate, getInitials } from '@/lib/format'; import { formatShortDate, getInitials } from '@/lib/format';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
import type { LeadActivity, LeadActivityType, Call, CallDisposition } from '@/types/entities'; import type { LeadActivity, LeadActivityType, Call, CallDisposition } from '@/types/entities';
@@ -58,11 +57,84 @@ const DISPOSITION_COLORS: Record<CallDisposition, 'success' | 'brand' | 'blue' |
}; };
const TABS = [ const TABS = [
{ id: 'timeline', label: 'Timeline' }, { id: 'appointments', label: 'Appointments' },
{ id: 'calls', label: 'Calls' }, { id: 'calls', label: 'Calls' },
{ id: 'timeline', label: 'Timeline' },
{ id: 'notes', label: 'Notes' }, { id: 'notes', label: 'Notes' },
]; ];
const PATIENT_QUERY = `query GetPatient360($id: UUID!) {
patients(filter: { id: { eq: $id } }) { edges { node {
id fullName { firstName lastName } dateOfBirth gender
phones { primaryPhoneNumber } emails { primaryEmail }
patientType
appointments(orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
id scheduledAt durationMin appointmentType status
doctorName department reasonForVisit
} } }
calls(first: 20, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id callStatus disposition direction agentName
startedAt durationSec callerNumber { primaryPhoneNumber }
} } }
leads { edges { node {
id source status interestedService aiSummary
} } }
} } }
}`;
type PatientData = {
id: string;
fullName: { firstName: string; lastName: string } | null;
dateOfBirth: string | null;
gender: string | null;
phones: { primaryPhoneNumber: string } | null;
emails: { primaryEmail: string } | null;
patientType: string | null;
appointments: { edges: Array<{ node: any }> };
calls: { edges: Array<{ node: any }> };
leads: { edges: Array<{ node: any }> };
};
// Appointment row component
const AppointmentRow = ({ appt }: { appt: any }) => {
const scheduledAt = appt.scheduledAt ? formatShortDate(appt.scheduledAt) : '--';
const statusColors: Record<string, 'success' | 'brand' | 'warning' | 'error' | 'gray'> = {
COMPLETED: 'success', SCHEDULED: 'brand', CONFIRMED: 'brand',
CANCELLED: 'error', NO_SHOW: 'warning', RESCHEDULED: 'warning',
};
return (
<div className="flex items-center gap-4 border-b border-secondary px-4 py-3 last:border-b-0">
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-secondary">
<CalendarCheck className="size-4 text-fg-white" />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-primary">{scheduledAt}</span>
{appt.appointmentType && (
<Badge size="sm" type="pill-color" color="brand">
{appt.appointmentType.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, (c: string) => c.toUpperCase())}
</Badge>
)}
</div>
<p className="text-xs text-tertiary">
{appt.doctorName ?? 'Unknown Doctor'}
{appt.department ? ` · ${appt.department}` : ''}
{appt.durationMin ? ` · ${appt.durationMin}min` : ''}
</p>
{appt.reasonForVisit && (
<p className="text-xs text-quaternary">{appt.reasonForVisit}</p>
)}
</div>
{appt.status && (
<Badge size="sm" type="pill-color" color={statusColors[appt.status] ?? 'gray'}>
{appt.status.toLowerCase().replace(/\b\w/g, (c: string) => c.toUpperCase())}
</Badge>
)}
</div>
);
};
const formatDuration = (seconds: number | null): string => { const formatDuration = (seconds: number | null): string => {
if (seconds === null || seconds === 0) return '--'; if (seconds === null || seconds === 0) return '--';
const minutes = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60);
@@ -192,62 +264,97 @@ const EmptyState = ({ icon, title, subtitle }: { icon: string; title: string; su
export const Patient360Page = () => { export const Patient360Page = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const { leads, leadActivities, calls } = useData(); const [activeTab, setActiveTab] = useState<string>('appointments');
const [activeTab, setActiveTab] = useState<string>('timeline');
const [noteText, setNoteText] = useState(''); const [noteText, setNoteText] = useState('');
const [patient, setPatient] = useState<PatientData | null>(null);
const [loading, setLoading] = useState(true);
const [activities, setActivities] = useState<LeadActivity[]>([]);
const lead = leads.find((l) => l.id === id); // Fetch patient with related data from platform
useEffect(() => {
if (!id) return;
setLoading(true);
// Filter activities for this lead apiClient.graphql<{ patients: { edges: Array<{ node: PatientData }> } }>(
const activities = useMemo( PATIENT_QUERY,
() => { id },
leadActivities { silent: true },
.filter((a) => a.leadId === id) ).then(data => {
.sort((a, b) => { const p = data.patients.edges[0]?.node ?? null;
if (!a.occurredAt) return 1; setPatient(p);
if (!b.occurredAt) return -1;
return new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(); // Fetch activities from linked leads
}), const leadIds = p?.leads?.edges?.map((e: any) => e.node.id) ?? [];
[leadActivities, id], if (leadIds.length > 0) {
const leadFilter = leadIds.map((lid: string) => `"${lid}"`).join(', ');
apiClient.graphql<{ leadActivities: { edges: Array<{ node: LeadActivity }> } }>(
`{ leadActivities(first: 50, filter: { leadId: { in: [${leadFilter}] } }, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node {
id activityType summary occurredAt performedBy previousValue newValue leadId
} } } }`,
undefined,
{ silent: true },
).then(actData => {
setActivities(actData.leadActivities.edges.map(e => e.node));
}).catch(() => {});
}
}).catch(() => setPatient(null))
.finally(() => setLoading(false));
}, [id]);
const patientCalls = useMemo(
() => (patient?.calls?.edges?.map(e => e.node) ?? []).map((c: any) => ({
...c,
callDirection: c.direction,
durationSeconds: c.durationSec,
})),
[patient],
); );
// Filter calls for this lead const appointments = useMemo(
const leadCalls = useMemo( () => patient?.appointments?.edges?.map(e => e.node) ?? [],
() => [patient],
calls
.filter((c) => c.leadId === id)
.sort((a, b) => {
if (!a.startedAt) return 1;
if (!b.startedAt) return -1;
return new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime();
}),
[calls, id],
); );
// Notes are activities of type NOTE_ADDED
const notes = useMemo( const notes = useMemo(
() => activities.filter((a) => a.activityType === 'NOTE_ADDED'), () => activities.filter((a) => a.activityType === 'NOTE_ADDED'),
[activities], [activities],
); );
if (!lead) { const leadInfo = patient?.leads?.edges?.[0]?.node;
if (loading) {
return ( return (
<> <>
<TopBar title="Patient 360" /> <TopBar title="Patient 360" />
<div className="flex flex-1 items-center justify-center p-8"> <div className="flex flex-1 items-center justify-center p-8">
<p className="text-tertiary">Lead not found.</p> <p className="text-sm text-tertiary">Loading patient profile...</p>
</div> </div>
</> </>
); );
} }
const firstName = lead.contactName?.firstName ?? ''; if (!patient) {
const lastName = lead.contactName?.lastName ?? ''; return (
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown Lead'; <>
<TopBar title="Patient 360" />
<div className="flex flex-1 items-center justify-center p-8">
<p className="text-tertiary">Patient not found.</p>
</div>
</>
);
}
const firstName = patient.fullName?.firstName ?? '';
const lastName = patient.fullName?.lastName ?? '';
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown Patient';
const initials = getInitials(firstName || '?', lastName || '?'); const initials = getInitials(firstName || '?', lastName || '?');
const phone = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : null; const phoneRaw = patient.phones?.primaryPhoneNumber ?? '';
const phoneRaw = lead.contactPhone?.[0]?.number ?? ''; const email = patient.emails?.primaryEmail ?? null;
const email = lead.contactEmail?.[0]?.address ?? null;
const age = patient.dateOfBirth
? Math.floor((Date.now() - new Date(patient.dateOfBirth).getTime()) / (365.25 * 24 * 60 * 60 * 1000))
: null;
const genderLabel = patient.gender === 'MALE' ? 'Male' : patient.gender === 'FEMALE' ? 'Female' : patient.gender;
return ( return (
<> <>
@@ -263,8 +370,19 @@ export const Patient360Page = () => {
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h2 className="text-display-xs font-bold text-primary">{fullName}</h2> <h2 className="text-display-xs font-bold text-primary">{fullName}</h2>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{lead.leadStatus && <LeadStatusBadge status={lead.leadStatus} />} {patient.patientType && (
{lead.leadSource && <SourceTag source={lead.leadSource} />} <Badge size="sm" type="pill-color" color={patient.patientType === 'RETURNING' ? 'brand' : 'gray'}>
{patient.patientType === 'RETURNING' ? 'Returning' : 'New'}
</Badge>
)}
{age !== null && genderLabel && (
<span className="text-xs text-tertiary">{age}y · {genderLabel}</span>
)}
{leadInfo?.source && (
<Badge size="sm" type="pill-color" color="gray">
{leadInfo.source.replace(/_/g, ' ')}
</Badge>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -272,10 +390,10 @@ export const Patient360Page = () => {
{/* Contact details */} {/* Contact details */}
<div className="flex flex-1 flex-col gap-2 lg:ml-auto lg:items-end"> <div className="flex flex-1 flex-col gap-2 lg:ml-auto lg:items-end">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
{phone && ( {phoneRaw && (
<span className="flex items-center gap-1.5 text-sm text-secondary"> <span className="flex items-center gap-1.5 text-sm text-secondary">
<Phone01 className="size-4 text-fg-quaternary" /> <Phone01 className="size-4 text-fg-quaternary" />
{phone} {phoneRaw}
</span> </span>
)} )}
{email && ( {email && (
@@ -285,25 +403,18 @@ export const Patient360Page = () => {
</span> </span>
)} )}
</div> </div>
{lead.interestedService && ( {leadInfo?.interestedService && (
<span className="text-xs text-tertiary"> <span className="text-xs text-tertiary">
Interested in: {lead.interestedService} Interested in: {leadInfo.interestedService}
</span> </span>
)} )}
</div> </div>
</div> </div>
{/* AI summary */} {/* AI summary from linked lead */}
{(lead.aiSummary || lead.aiSuggestedAction) && ( {leadInfo?.aiSummary && (
<div className="mt-4 rounded-lg border border-secondary bg-secondary_alt p-3"> <div className="mt-4 rounded-lg border border-secondary bg-secondary_alt p-3">
{lead.aiSummary && ( <p className="text-sm text-secondary">{leadInfo.aiSummary}</p>
<p className="text-sm text-secondary">{lead.aiSummary}</p>
)}
{lead.aiSuggestedAction && (
<Badge size="sm" type="pill-color" color="brand" className="mt-2">
{lead.aiSuggestedAction}
</Badge>
)}
</div> </div>
)} )}
@@ -335,10 +446,12 @@ export const Patient360Page = () => {
id={item.id} id={item.id}
label={item.label} label={item.label}
badge={ badge={
item.id === 'timeline' item.id === 'appointments'
? activities.length ? appointments.length
: item.id === 'calls' : item.id === 'calls'
? leadCalls.length ? patientCalls.length
: item.id === 'timeline'
? activities.length
: item.id === 'notes' : item.id === 'notes'
? notes.length ? notes.length
: undefined : undefined
@@ -347,6 +460,44 @@ export const Patient360Page = () => {
)} )}
</TabList> </TabList>
{/* Appointments tab */}
<TabPanel id="appointments">
<div className="mt-5 pb-7">
{appointments.length === 0 ? (
<EmptyState
icon="📅"
title="No appointments"
subtitle="Appointment history will appear here."
/>
) : (
<div className="rounded-xl border border-secondary bg-primary">
{appointments.map((appt: any) => (
<AppointmentRow key={appt.id} appt={appt} />
))}
</div>
)}
</div>
</TabPanel>
{/* Calls tab */}
<TabPanel id="calls">
<div className="mt-5 pb-7">
{patientCalls.length === 0 ? (
<EmptyState
icon="📞"
title="No calls yet"
subtitle="Call history with this patient will appear here."
/>
) : (
<div className="rounded-xl border border-secondary bg-primary">
{patientCalls.map((call: any) => (
<CallRow key={call.id} call={call} />
))}
</div>
)}
</div>
</TabPanel>
{/* Timeline tab */} {/* Timeline tab */}
<TabPanel id="timeline"> <TabPanel id="timeline">
<div className="mt-5 pb-7"> <div className="mt-5 pb-7">
@@ -370,25 +521,6 @@ export const Patient360Page = () => {
</div> </div>
</TabPanel> </TabPanel>
{/* Calls tab */}
<TabPanel id="calls">
<div className="mt-5 pb-7">
{leadCalls.length === 0 ? (
<EmptyState
icon="📞"
title="No calls yet"
subtitle="Call history with this lead will appear here."
/>
) : (
<div className="rounded-xl border border-secondary bg-primary">
{leadCalls.map((call) => (
<CallRow key={call.id} call={call} />
))}
</div>
)}
</div>
</TabPanel>
{/* Notes tab */} {/* Notes tab */}
<TabPanel id="notes"> <TabPanel id="notes">
<div className="mt-5 pb-7"> <div className="mt-5 pb-7">