mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
Compare commits
4 Commits
demo-v1
...
727a0728ee
| Author | SHA1 | Date | |
|---|---|---|---|
| 727a0728ee | |||
| 88fc743928 | |||
| 744a91a1ff | |||
| c3604377b9 |
217
README.md
217
README.md
@@ -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 world’s 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
|
||||||
|
|||||||
284
docs/superpowers/plans/2026-03-21-phase1-unblock.md
Normal file
284
docs/superpowers/plans/2026-03-21-phase1-unblock.md
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
# Phase 1: Agent Status + Global Search + Enquiry Form
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Unblock supervisor features by adding agent availability toggle, give agents fast record lookup via global search, and add a general enquiry capture form for non-lead interactions.
|
||||||
|
|
||||||
|
**Architecture:** Agent status syncs with Ozonetel's changeAgentState API. Global search queries the platform GraphQL for leads, patients, and appointments in parallel. Enquiry form creates a Lead record with source "PHONE_INQUIRY" and captures the interaction details.
|
||||||
|
|
||||||
|
**Tech Stack:** NestJS sidecar (Ozonetel APIs), React 19 + Jotai, Platform GraphQL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature A: Agent Availability Status
|
||||||
|
|
||||||
|
The agent needs an Active/Away/Offline toggle that syncs with Ozonetel CloudAgent state.
|
||||||
|
|
||||||
|
### File Map
|
||||||
|
| File | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `helix-engage/src/components/call-desk/agent-status-toggle.tsx` | Create: dropdown toggle for Ready/Pause/Offline |
|
||||||
|
| `helix-engage/src/pages/call-desk.tsx` | Modify: replace hardcoded "Ready" badge with AgentStatusToggle |
|
||||||
|
| `helix-engage-server/src/ozonetel/ozonetel-agent.controller.ts` | Modify: add `POST /api/ozonetel/agent-state` accepting state + pauseReason |
|
||||||
|
|
||||||
|
### Task A1: Sidecar endpoint for agent state
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `helix-engage-server/src/ozonetel/ozonetel-agent.controller.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `POST /api/ozonetel/agent-state` endpoint**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Post('agent-state')
|
||||||
|
async agentState(
|
||||||
|
@Body() body: { state: 'Ready' | 'Pause'; pauseReason?: string },
|
||||||
|
) {
|
||||||
|
if (!body.state) {
|
||||||
|
throw new HttpException('state required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Agent state change: ${this.defaultAgentId} → ${body.state}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.ozonetelAgent.changeAgentState({
|
||||||
|
agentId: this.defaultAgentId,
|
||||||
|
state: body.state,
|
||||||
|
pauseReason: body.pauseReason,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error.response?.data?.message ?? error.message ?? 'State change failed';
|
||||||
|
return { status: 'error', message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Type check and commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: add agent state change endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task A2: Agent status toggle component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `helix-engage/src/components/call-desk/agent-status-toggle.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the toggle component**
|
||||||
|
|
||||||
|
A dropdown button showing current status (Ready/Break/Offline) with color-coded dot. Selecting a state calls the sidecar API.
|
||||||
|
|
||||||
|
States:
|
||||||
|
- **Ready** (green dot) → Ozonetel state: Ready
|
||||||
|
- **Break** (orange dot) → Ozonetel state: Pause, pauseReason: "Break"
|
||||||
|
- **Training** (blue dot) → Ozonetel state: Pause, pauseReason: "Training"
|
||||||
|
- **Offline** (gray dot) → calls agent-logout
|
||||||
|
|
||||||
|
The component uses React Aria's `Select` or a simple popover.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: add agent status toggle component
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task A3: Wire into call desk
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `helix-engage/src/pages/call-desk.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the hardcoded "Ready" BadgeWithDot with AgentStatusToggle**
|
||||||
|
|
||||||
|
The current badge at line 43-49 shows SIP registration status. Replace with the new toggle that shows actual CloudAgent state AND SIP status.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: replace hardcoded Ready badge with agent status toggle
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature B: Global Search
|
||||||
|
|
||||||
|
A search bar in the header/top-bar that searches across leads, patients, and appointments.
|
||||||
|
|
||||||
|
### File Map
|
||||||
|
| File | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `helix-engage/src/components/shared/global-search.tsx` | Modify: search leads + patients + appointments via sidecar |
|
||||||
|
| `helix-engage-server/src/ozonetel/ozonetel-agent.controller.ts` | Modify: add `GET /api/search?q=` that queries platform |
|
||||||
|
| `helix-engage/src/components/layout/top-bar.tsx` | Modify: add GlobalSearch to the top bar |
|
||||||
|
|
||||||
|
### Task B1: Sidecar search endpoint
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `helix-engage-server/src/ozonetel/ozonetel-agent.controller.ts` (or create a new search controller)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `GET /api/search` endpoint**
|
||||||
|
|
||||||
|
Queries leads, patients, and appointments in parallel via platform GraphQL. Returns grouped results.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Get('search')
|
||||||
|
async search(@Query('q') query: string) {
|
||||||
|
if (!query || query.length < 2) return { leads: [], patients: [], appointments: [] };
|
||||||
|
|
||||||
|
const authHeader = `Bearer ${this.platformApiKey}`;
|
||||||
|
|
||||||
|
// Search leads by name or phone
|
||||||
|
const [leadsResult, patientsResult, appointmentsResult] = await Promise.all([
|
||||||
|
this.platform.queryWithAuth(`{ leads(first: 5, filter: {
|
||||||
|
or: [
|
||||||
|
{ contactName: { firstName: { like: "%${query}%" } } },
|
||||||
|
{ contactPhone: { primaryPhoneNumber: { like: "%${query}%" } } }
|
||||||
|
]
|
||||||
|
}) { edges { node { id name contactName { firstName lastName } contactPhone { primaryPhoneNumber } source status } } } }`, undefined, authHeader),
|
||||||
|
|
||||||
|
this.platform.queryWithAuth(`{ patients(first: 5, filter: {
|
||||||
|
or: [
|
||||||
|
{ fullName: { firstName: { like: "%${query}%" } } },
|
||||||
|
{ phones: { primaryPhoneNumber: { like: "%${query}%" } } }
|
||||||
|
]
|
||||||
|
}) { edges { node { id name fullName { firstName lastName } phones { primaryPhoneNumber } } } } }`, undefined, authHeader),
|
||||||
|
|
||||||
|
this.platform.queryWithAuth(`{ appointments(first: 5, filter: {
|
||||||
|
doctorName: { like: "%${query}%" }
|
||||||
|
}) { edges { node { id scheduledAt doctorName department appointmentStatus patientId } } } }`, undefined, authHeader),
|
||||||
|
]).catch(() => [{ leads: { edges: [] } }, { patients: { edges: [] } }, { appointments: { edges: [] } }]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
leads: leadsResult?.leads?.edges?.map((e: any) => e.node) ?? [],
|
||||||
|
patients: patientsResult?.patients?.edges?.map((e: any) => e.node) ?? [],
|
||||||
|
appointments: appointmentsResult?.appointments?.edges?.map((e: any) => e.node) ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: GraphQL `like` filter syntax may differ on the platform. May need to use `contains` or fetch-and-filter client-side.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: add cross-entity search endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task B2: Update GlobalSearch component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `helix-engage/src/components/shared/global-search.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Wire to sidecar search endpoint**
|
||||||
|
|
||||||
|
Replace the local leads-only search with a call to `GET /api/search?q=`. Display results grouped by entity type with icons:
|
||||||
|
- 👤 Leads — name, phone, source
|
||||||
|
- 🏥 Patients — name, phone, MRN
|
||||||
|
- 📅 Appointments — doctor, date, status
|
||||||
|
|
||||||
|
Clicking a result navigates to the appropriate detail page.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: wire global search to cross-entity sidecar endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task B3: Add search to call desk header
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `helix-engage/src/pages/call-desk.tsx` or `src/components/layout/top-bar.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add GlobalSearch to the call desk header**
|
||||||
|
|
||||||
|
Place next to the existing search in the worklist area, or in the top bar so it's accessible from every page.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: add global search to call desk header
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature C: General Enquiry Form
|
||||||
|
|
||||||
|
When a caller has a question (not a lead), the agent needs a structured form to capture the interaction.
|
||||||
|
|
||||||
|
### File Map
|
||||||
|
| File | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `helix-engage/src/components/call-desk/enquiry-form.tsx` | Create: inline form for capturing general enquiries |
|
||||||
|
| `helix-engage/src/components/call-desk/active-call-card.tsx` | Modify: add "Log Enquiry" button during active call |
|
||||||
|
|
||||||
|
### Task C1: Create enquiry form
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `helix-engage/src/components/call-desk/enquiry-form.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create inline enquiry form**
|
||||||
|
|
||||||
|
Fields (from spec US 5):
|
||||||
|
- Patient Name*
|
||||||
|
- Source/Referral*
|
||||||
|
- Query Asked* (textarea)
|
||||||
|
- Existing Patient? (Y/N)*
|
||||||
|
- If Y: Registered mobile number
|
||||||
|
- Relevant Department (optional, select from doctors list)
|
||||||
|
- Relevant Doctor (optional, filtered by department)
|
||||||
|
- Follow-up needed? (Y/N)*
|
||||||
|
- If Y: Date and time
|
||||||
|
- Disposition*
|
||||||
|
|
||||||
|
On submit:
|
||||||
|
1. Creates a Lead record with `source: 'PHONE_INQUIRY'`
|
||||||
|
2. Creates a LeadActivity with `activityType: 'ENQUIRY'`
|
||||||
|
3. If follow-up needed, creates a FollowUp record
|
||||||
|
|
||||||
|
The form is inline (same pattern as appointment form) — shows below the call card when "Log Enquiry" is clicked.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: add general enquiry capture form
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task C2: Add "Log Enquiry" button to active call
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `helix-engage/src/components/call-desk/active-call-card.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add button between "Book Appt" and "Transfer"**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<Button size="sm" color="secondary"
|
||||||
|
iconLeading={...}
|
||||||
|
onClick={() => setEnquiryOpen(!enquiryOpen)}>Enquiry</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Show the enquiry form inline below the call card when open (same pattern as appointment form).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: add Log Enquiry button to active call card
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task D: Deploy and verify
|
||||||
|
|
||||||
|
- [ ] **Step 1: Type check both projects**
|
||||||
|
- [ ] **Step 2: Build and deploy sidecar**
|
||||||
|
- [ ] **Step 3: Build and deploy frontend**
|
||||||
|
- [ ] **Step 4: Test agent status toggle** — switch to Break, verify badge changes, switch back to Ready
|
||||||
|
- [ ] **Step 5: Test global search** — search by name, phone number, verify results from leads + patients
|
||||||
|
- [ ] **Step 6: Test enquiry form** — during a call, click Enquiry, fill form, submit, verify Lead + Activity created
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Agent state and Ozonetel** — `changeAgentState` cannot transition from ACW. The toggle should disable during ACW and show a "Completing wrap-up..." state.
|
||||||
|
- **Search filter syntax** — the platform's GraphQL `like` operator may not exist. Fallback: fetch first 50 records of each type and filter client-side by name/phone match.
|
||||||
|
- **Enquiry vs Disposition** — the enquiry form is separate from the disposition form. An enquiry can be logged DURING a call (like booking an appointment), while disposition is logged AFTER the call ends.
|
||||||
|
- **The 6-button problem** — active call now has: Mute, Hold, Book Appt, Enquiry, Transfer, Pause Rec, End = 7 buttons. Consider grouping Book Appt + Enquiry under a "More" dropdown, or using icon-only buttons for some.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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 (15–30 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.
|
||||||
@@ -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)}
|
||||||
>
|
>
|
||||||
<div className="rounded-xl bg-primary ring-1 ring-secondary">
|
{({ close }) => (
|
||||||
<div className="flex flex-col gap-0.5 py-1.5">
|
<>
|
||||||
<NavAccountCardMenuItem label="View profile" icon={IconUser} shortcut="⌘K->P" />
|
<div className="rounded-xl bg-primary ring-1 ring-secondary">
|
||||||
<NavAccountCardMenuItem label="Account settings" icon={IconSettings} shortcut="⌘S" />
|
<div className="flex flex-col gap-0.5 py-1.5">
|
||||||
<NavAccountCardMenuItem label="Force Ready" icon={IconForceReady} onClick={onForceReady} />
|
<NavAccountCardMenuItem label="View profile" icon={IconUser} shortcut="⌘K->P" />
|
||||||
</div>
|
<NavAccountCardMenuItem label="Account settings" icon={IconSettings} shortcut="⌘S" />
|
||||||
</div>
|
<NavAccountCardMenuItem label="Force Ready" icon={IconForceReady} onClick={() => { close(); onForceReady?.(); }} />
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
102
src/components/call-desk/agent-status-toggle.tsx
Normal file
102
src/components/call-desk/agent-status-toggle.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faCircle, faChevronDown } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { notify } from '@/lib/toast';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
|
type AgentStatus = 'ready' | 'break' | 'training' | 'offline';
|
||||||
|
|
||||||
|
const statusConfig: Record<AgentStatus, { label: string; color: string; dotColor: string }> = {
|
||||||
|
ready: { label: 'Ready', color: 'text-success-primary', dotColor: 'text-fg-success-primary' },
|
||||||
|
break: { label: 'Break', color: 'text-warning-primary', dotColor: 'text-fg-warning-primary' },
|
||||||
|
training: { label: 'Training', color: 'text-brand-secondary', dotColor: 'text-fg-brand-primary' },
|
||||||
|
offline: { label: 'Offline', color: 'text-tertiary', dotColor: 'text-fg-quaternary' },
|
||||||
|
};
|
||||||
|
|
||||||
|
type AgentStatusToggleProps = {
|
||||||
|
isRegistered: boolean;
|
||||||
|
connectionStatus: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatusToggleProps) => {
|
||||||
|
const [status, setStatus] = useState<AgentStatus>(isRegistered ? 'ready' : 'offline');
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [changing, setChanging] = useState(false);
|
||||||
|
|
||||||
|
const handleChange = async (newStatus: AgentStatus) => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
if (newStatus === status) return;
|
||||||
|
setChanging(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (newStatus === 'ready') {
|
||||||
|
await apiClient.post('/api/ozonetel/agent-state', { state: 'Ready' });
|
||||||
|
} else if (newStatus === 'offline') {
|
||||||
|
await apiClient.post('/api/ozonetel/agent-logout', {
|
||||||
|
agentId: 'global',
|
||||||
|
password: 'Test123$',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const pauseReason = newStatus === 'break' ? 'Break' : 'Training';
|
||||||
|
await apiClient.post('/api/ozonetel/agent-state', { state: 'Pause', pauseReason });
|
||||||
|
}
|
||||||
|
setStatus(newStatus);
|
||||||
|
} catch {
|
||||||
|
notify.error('Status Change Failed', 'Could not update agent status');
|
||||||
|
} finally {
|
||||||
|
setChanging(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// If SIP isn't connected, show connection status
|
||||||
|
if (!isRegistered) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1">
|
||||||
|
<FontAwesomeIcon icon={faCircle} className="size-2 text-fg-warning-primary animate-pulse" />
|
||||||
|
<span className="text-xs font-medium text-tertiary">{connectionStatus}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = statusConfig[status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setMenuOpen(!menuOpen)}
|
||||||
|
disabled={changing}
|
||||||
|
className={cx(
|
||||||
|
'flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1 transition duration-100 ease-linear',
|
||||||
|
'hover:bg-secondary_hover cursor-pointer',
|
||||||
|
changing && 'opacity-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faCircle} className={cx('size-2', current.dotColor)} />
|
||||||
|
<span className={cx('text-xs font-medium', current.color)}>{current.label}</span>
|
||||||
|
<FontAwesomeIcon icon={faChevronDown} className="size-2.5 text-fg-quaternary" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{menuOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
|
||||||
|
<div className="absolute right-0 top-full z-50 mt-1 w-36 rounded-lg bg-primary shadow-lg ring-1 ring-secondary py-1">
|
||||||
|
{(Object.entries(statusConfig) as [AgentStatus, typeof current][]).map(([key, cfg]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => handleChange(key)}
|
||||||
|
className={cx(
|
||||||
|
'flex w-full items-center gap-2 px-3 py-2 text-xs font-medium transition duration-100 ease-linear',
|
||||||
|
key === status ? 'bg-active' : 'hover:bg-primary_hover',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faCircle} className={cx('size-2', cfg.dotColor)} />
|
||||||
|
<span className={cfg.color}>{cfg.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -94,19 +94,21 @@ export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFor
|
|||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<div className="flex justify-end">
|
||||||
type="button"
|
<button
|
||||||
onClick={handleSubmit}
|
type="button"
|
||||||
disabled={selected === null}
|
onClick={handleSubmit}
|
||||||
className={cx(
|
disabled={selected === null}
|
||||||
'w-full rounded-xl py-3 text-sm font-semibold transition duration-100 ease-linear',
|
className={cx(
|
||||||
selected !== null
|
'rounded-xl px-6 py-2.5 text-sm font-semibold transition duration-100 ease-linear',
|
||||||
? 'cursor-pointer bg-brand-solid text-white hover:bg-brand-solid_hover'
|
selected !== null
|
||||||
: 'cursor-not-allowed bg-disabled text-disabled',
|
? 'cursor-pointer bg-brand-solid text-white hover:bg-brand-solid_hover'
|
||||||
)}
|
: 'cursor-not-allowed bg-disabled text-disabled',
|
||||||
>
|
)}
|
||||||
Save & Close Call
|
>
|
||||||
</button>
|
Save & Close Call
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
189
src/components/call-desk/enquiry-form.tsx
Normal file
189
src/components/call-desk/enquiry-form.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faClipboardQuestion, faXmark } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { Input } from '@/components/base/input/input';
|
||||||
|
import { Select } from '@/components/base/select/select';
|
||||||
|
import { TextArea } from '@/components/base/textarea/textarea';
|
||||||
|
import { Checkbox } from '@/components/base/checkbox/checkbox';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { notify } from '@/lib/toast';
|
||||||
|
|
||||||
|
type EnquiryFormProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
callerPhone?: string | null;
|
||||||
|
onSaved?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dispositionItems = [
|
||||||
|
{ id: 'CONVERTED', label: 'Converted' },
|
||||||
|
{ id: 'FOLLOW_UP', label: 'Follow-up Needed' },
|
||||||
|
{ id: 'GENERAL_QUERY', label: 'General Query' },
|
||||||
|
{ id: 'NO_ANSWER', label: 'No Answer' },
|
||||||
|
{ id: 'INVALID_NUMBER', label: 'Invalid Number' },
|
||||||
|
{ id: 'CALL_DROPPED', label: 'Call Dropped' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: EnquiryFormProps) => {
|
||||||
|
const [patientName, setPatientName] = useState('');
|
||||||
|
const [source, setSource] = useState('Phone Inquiry');
|
||||||
|
const [queryAsked, setQueryAsked] = useState('');
|
||||||
|
const [isExisting, setIsExisting] = useState(false);
|
||||||
|
const [registeredPhone, setRegisteredPhone] = useState(callerPhone ?? '');
|
||||||
|
const [department, setDepartment] = useState<string | null>(null);
|
||||||
|
const [doctor, setDoctor] = useState<string | null>(null);
|
||||||
|
const [followUpNeeded, setFollowUpNeeded] = useState(false);
|
||||||
|
const [followUpDate, setFollowUpDate] = useState('');
|
||||||
|
const [disposition, setDisposition] = useState<string | null>(null);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Fetch doctors for department/doctor dropdowns
|
||||||
|
const [doctors, setDoctors] = useState<Array<{ id: string; name: string; department: string }>>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
apiClient.graphql<{ doctors: { edges: Array<{ node: any }> } }>(
|
||||||
|
`{ doctors(first: 50) { edges { node {
|
||||||
|
id name fullName { firstName lastName } department
|
||||||
|
} } } }`,
|
||||||
|
).then(data => {
|
||||||
|
setDoctors(data.doctors.edges.map(e => ({
|
||||||
|
id: e.node.id,
|
||||||
|
name: e.node.fullName ? `Dr. ${e.node.fullName.firstName} ${e.node.fullName.lastName}`.trim() : e.node.name,
|
||||||
|
department: e.node.department ?? '',
|
||||||
|
})));
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const departmentItems = [...new Set(doctors.map(d => d.department).filter(Boolean))]
|
||||||
|
.map(dept => ({ id: dept, label: dept.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) }));
|
||||||
|
|
||||||
|
const filteredDoctors = department ? doctors.filter(d => d.department === department) : doctors;
|
||||||
|
const doctorItems = filteredDoctors.map(d => ({ id: d.id, label: d.name }));
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!patientName.trim() || !queryAsked.trim() || !disposition) {
|
||||||
|
setError('Please fill in required fields: patient name, query, and disposition.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a lead with source PHONE_INQUIRY
|
||||||
|
await apiClient.graphql(
|
||||||
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
name: `Enquiry — ${patientName}`,
|
||||||
|
contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
|
||||||
|
contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined,
|
||||||
|
source: 'PHONE_INQUIRY',
|
||||||
|
status: disposition === 'CONVERTED' ? 'CONVERTED' : 'NEW',
|
||||||
|
interestedService: queryAsked.substring(0, 100),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create follow-up if needed
|
||||||
|
if (followUpNeeded && followUpDate) {
|
||||||
|
await apiClient.graphql(
|
||||||
|
`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
name: `Follow-up — ${patientName}`,
|
||||||
|
typeCustom: 'CALLBACK',
|
||||||
|
status: 'PENDING',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
scheduledAt: new Date(`${followUpDate}T09:00:00`).toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ silent: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
notify.success('Enquiry Logged', 'Contact details and query captured');
|
||||||
|
onSaved?.();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to save enquiry');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-8 items-center justify-center rounded-lg bg-warning-secondary">
|
||||||
|
<FontAwesomeIcon icon={faClipboardQuestion} className="size-4 text-fg-warning-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-primary">Log Enquiry</h3>
|
||||||
|
<p className="text-xs text-tertiary">Capture caller's question and details</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="flex size-7 items-center justify-center rounded-md text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faXmark} className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Input label="Patient Name" placeholder="Full name" value={patientName} onChange={setPatientName} isRequired />
|
||||||
|
|
||||||
|
<Input label="Source / Referral" placeholder="How did they reach us?" value={source} onChange={setSource} isRequired />
|
||||||
|
|
||||||
|
<TextArea label="Query Asked" placeholder="What did the caller ask about?" value={queryAsked} onChange={setQueryAsked} rows={3} isRequired />
|
||||||
|
|
||||||
|
<Checkbox isSelected={isExisting} onChange={setIsExisting} label="Existing Patient" hint="Has visited the hospital before" />
|
||||||
|
|
||||||
|
{isExisting && (
|
||||||
|
<Input label="Registered Phone" placeholder="Phone number on file" value={registeredPhone} onChange={setRegisteredPhone} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="border-t border-secondary" />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Select label="Department" placeholder="Optional" items={departmentItems} selectedKey={department}
|
||||||
|
onSelectionChange={(key) => { setDepartment(key as string); setDoctor(null); }}>
|
||||||
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
|
</Select>
|
||||||
|
<Select label="Doctor" placeholder="Optional" items={doctorItems} selectedKey={doctor}
|
||||||
|
onSelectionChange={(key) => setDoctor(key as string)} isDisabled={!department}>
|
||||||
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Checkbox isSelected={followUpNeeded} onChange={setFollowUpNeeded} label="Follow-up Needed" />
|
||||||
|
|
||||||
|
{followUpNeeded && (
|
||||||
|
<Input label="Follow-up Date" type="date" value={followUpDate} onChange={setFollowUpDate} isRequired />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Select label="Disposition" placeholder="Select outcome" items={dispositionItems} selectedKey={disposition}
|
||||||
|
onSelectionChange={(key) => setDisposition(key as string)} isRequired>
|
||||||
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg bg-error-primary p-3 text-sm text-error-primary">{error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 mt-4 pt-4 border-t border-secondary">
|
||||||
|
<Button size="sm" color="secondary" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||||
|
<Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}>
|
||||||
|
{isSaving ? 'Saving...' : 'Log Enquiry'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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 },
|
||||||
]},
|
]},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { faMagnifyingGlass, faUser, faCalendar } from '@fortawesome/pro-duotone-svg-icons';
|
import { faMagnifyingGlass, faUser, faCalendar } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { faIcon } from '@/lib/icon-wrapper';
|
import { faIcon } from '@/lib/icon-wrapper';
|
||||||
import { Input } from '@/components/base/input/input';
|
import { Input } from '@/components/base/input/input';
|
||||||
import { Badge } from '@/components/base/badges/badges';
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
import { useData } from '@/providers/data-provider';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { formatPhone } from '@/lib/format';
|
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
const SearchIcon = faIcon(faMagnifyingGlass);
|
const SearchIcon = faIcon(faMagnifyingGlass);
|
||||||
@@ -53,52 +52,11 @@ export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { leads } = useData();
|
|
||||||
|
|
||||||
const searchLeads = useCallback(
|
|
||||||
(searchQuery: string): SearchResult[] => {
|
|
||||||
const normalizedQuery = searchQuery.trim().toLowerCase();
|
|
||||||
if (normalizedQuery.length < 3) return [];
|
|
||||||
|
|
||||||
const matched = leads.filter((lead) => {
|
|
||||||
const firstName = lead.contactName?.firstName?.toLowerCase() ?? '';
|
|
||||||
const lastName = lead.contactName?.lastName?.toLowerCase() ?? '';
|
|
||||||
const fullName = `${firstName} ${lastName}`.trim();
|
|
||||||
const phones = (lead.contactPhone ?? []).map((p) => `${p.callingCode}${p.number}`.toLowerCase());
|
|
||||||
|
|
||||||
const matchesName =
|
|
||||||
firstName.includes(normalizedQuery) ||
|
|
||||||
lastName.includes(normalizedQuery) ||
|
|
||||||
fullName.includes(normalizedQuery);
|
|
||||||
const matchesPhone = phones.some((phone) => phone.includes(normalizedQuery));
|
|
||||||
|
|
||||||
return matchesName || matchesPhone;
|
|
||||||
});
|
|
||||||
|
|
||||||
return matched.slice(0, 5).map((lead) => {
|
|
||||||
const firstName = lead.contactName?.firstName ?? '';
|
|
||||||
const lastName = lead.contactName?.lastName ?? '';
|
|
||||||
const phone = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : undefined;
|
|
||||||
const email = lead.contactEmail?.[0]?.address ?? undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: lead.id,
|
|
||||||
type: 'lead' as const,
|
|
||||||
title: `${firstName} ${lastName}`.trim() || 'Unknown Lead',
|
|
||||||
subtitle: [phone, email, lead.interestedService].filter(Boolean).join(' · '),
|
|
||||||
phone,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[leads],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (debounceRef.current) {
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
clearTimeout(debounceRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.trim().length < 3) {
|
if (query.trim().length < 2) {
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
@@ -106,20 +64,60 @@ export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
debounceRef.current = setTimeout(() => {
|
debounceRef.current = setTimeout(async () => {
|
||||||
const searchResults = searchLeads(query);
|
try {
|
||||||
setResults(searchResults);
|
const data = await apiClient.get<{
|
||||||
setIsOpen(true);
|
leads: Array<any>;
|
||||||
setIsSearching(false);
|
patients: Array<any>;
|
||||||
setHighlightedIndex(-1);
|
appointments: Array<any>;
|
||||||
|
}>(`/api/search?q=${encodeURIComponent(query)}`, { silent: true });
|
||||||
|
|
||||||
|
const searchResults: SearchResult[] = [];
|
||||||
|
|
||||||
|
for (const l of data.leads ?? []) {
|
||||||
|
const name = l.contactName ? `${l.contactName.firstName} ${l.contactName.lastName}`.trim() : l.name;
|
||||||
|
searchResults.push({
|
||||||
|
id: l.id,
|
||||||
|
type: 'lead',
|
||||||
|
title: name || 'Unknown',
|
||||||
|
subtitle: [l.contactPhone?.primaryPhoneNumber, l.source, l.interestedService].filter(Boolean).join(' · '),
|
||||||
|
phone: l.contactPhone?.primaryPhoneNumber,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of data.patients ?? []) {
|
||||||
|
const name = p.fullName ? `${p.fullName.firstName} ${p.fullName.lastName}`.trim() : p.name;
|
||||||
|
searchResults.push({
|
||||||
|
id: p.id,
|
||||||
|
type: 'patient',
|
||||||
|
title: name || 'Unknown',
|
||||||
|
subtitle: p.phones?.primaryPhoneNumber ?? '',
|
||||||
|
phone: p.phones?.primaryPhoneNumber,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const a of data.appointments ?? []) {
|
||||||
|
const date = a.scheduledAt ? new Date(a.scheduledAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : '';
|
||||||
|
searchResults.push({
|
||||||
|
id: a.id,
|
||||||
|
type: 'appointment',
|
||||||
|
title: a.doctorName ?? 'Appointment',
|
||||||
|
subtitle: [a.department, date, a.appointmentStatus].filter(Boolean).join(' · '),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setResults(searchResults);
|
||||||
|
setIsOpen(true);
|
||||||
|
} catch {
|
||||||
|
setResults([]);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
setHighlightedIndex(-1);
|
||||||
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
return () => {
|
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
||||||
if (debounceRef.current) {
|
}, [query]);
|
||||||
clearTimeout(debounceRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [query, searchLeads]);
|
|
||||||
|
|
||||||
// Close on outside click
|
// Close on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -174,7 +172,7 @@ export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
|
|||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative w-64" onKeyDown={handleKeyDown}>
|
<div ref={containerRef} className="relative w-64" onKeyDown={handleKeyDown}>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search leads..."
|
placeholder="Search patients, leads, appointments..."
|
||||||
icon={SearchIcon}
|
icon={SearchIcon}
|
||||||
aria-label="Global search"
|
aria-label="Global search"
|
||||||
value={query}
|
value={query}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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,16 +133,23 @@ 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) {
|
||||||
clearTokens();
|
const refreshed = await tryRefreshToken();
|
||||||
if (!options?.silent) notify.error('Session expired', 'Please log in again.');
|
if (refreshed) {
|
||||||
throw new AuthError();
|
response = await doFetch();
|
||||||
|
} else {
|
||||||
|
clearTokens();
|
||||||
|
if (!options?.silent) notify.error('Session expired', 'Please log in again.');
|
||||||
|
throw new AuthError();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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,114 +71,53 @@ 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>
|
|
||||||
|
|
||||||
{/* 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>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right panel — 40% on desktop, full width on mobile */}
|
{/* Google sign-in */}
|
||||||
<div className="flex flex-1 flex-col justify-center items-center bg-primary px-6 py-12">
|
<SocialButton
|
||||||
<form
|
social="google"
|
||||||
onSubmit={handleSubmit}
|
size="lg"
|
||||||
className="flex flex-col w-full max-w-[448px]"
|
theme="gray"
|
||||||
noValidate
|
type="button"
|
||||||
|
onClick={handleGoogleSignIn}
|
||||||
|
className="w-full rounded-xl py-3 border-2 border-secondary font-semibold hover:bg-secondary transition duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
||||||
>
|
>
|
||||||
{/* Heading */}
|
Sign in with Google
|
||||||
<h2 className="text-display-xs font-bold text-primary font-display">Sign in to Helix Engage</h2>
|
</SocialButton>
|
||||||
<p className="mt-1 text-sm text-tertiary">Global Hospital</p>
|
|
||||||
|
|
||||||
{/* Role is determined by platform — no selector needed */}
|
{/* Divider */}
|
||||||
|
<div className="mt-5 mb-5 flex items-center gap-3">
|
||||||
|
<div className="flex-1 h-px bg-secondary" />
|
||||||
|
<span className="text-xs font-semibold text-quaternary tracking-wider uppercase">or continue with</span>
|
||||||
|
<div className="flex-1 h-px bg-secondary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Google sign-in */}
|
{/* Form */}
|
||||||
<div className="mt-6">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
|
||||||
<SocialButton
|
{error && (
|
||||||
social="google"
|
<div className="rounded-lg bg-error-secondary p-3 text-sm text-error-primary">
|
||||||
size="lg"
|
{error}
|
||||||
theme="gray"
|
</div>
|
||||||
type="button"
|
)}
|
||||||
onClick={handleGoogleSignIn}
|
|
||||||
className="w-full rounded-xl py-3 border-2 border-secondary font-semibold hover:bg-secondary transition duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
|
||||||
>
|
|
||||||
Sign in with Google
|
|
||||||
</SocialButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Divider */}
|
<Input
|
||||||
<div className="mt-6 flex items-center gap-3">
|
label="Email"
|
||||||
<div className="flex-1 h-px bg-secondary" />
|
type="email"
|
||||||
<span className="text-xs font-semibold text-quaternary tracking-wider uppercase">or continue with</span>
|
placeholder="you@globalhospital.com"
|
||||||
<div className="flex-1 h-px bg-secondary" />
|
value={email}
|
||||||
</div>
|
onChange={(value) => setEmail(value)}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Email input */}
|
<div className="relative">
|
||||||
<div className="mt-6">
|
|
||||||
<Input
|
|
||||||
label="Email"
|
|
||||||
type="email"
|
|
||||||
placeholder="sanjay@globalhospital.com"
|
|
||||||
value={email}
|
|
||||||
onChange={(value) => setEmail(value)}
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Password input with eye toggle */}
|
|
||||||
<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,27 +152,20 @@ export const LoginPage = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error message */}
|
<Button
|
||||||
{error && (
|
type="submit"
|
||||||
<div className="mt-4 rounded-lg bg-error-primary p-3 text-sm text-error-primary">
|
size="lg"
|
||||||
{error}
|
color="primary"
|
||||||
</div>
|
isLoading={isLoading}
|
||||||
)}
|
className="w-full rounded-xl py-3 font-semibold active:scale-[0.98] transition duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
||||||
|
>
|
||||||
{/* Sign in button */}
|
Sign in
|
||||||
<div className="mt-6">
|
</Button>
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
size="lg"
|
|
||||||
color="primary"
|
|
||||||
isLoading={isLoading}
|
|
||||||
className="w-full rounded-xl py-3 font-semibold active:scale-[0.98] transition duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,18 +446,58 @@ 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 === 'notes'
|
: item.id === 'timeline'
|
||||||
? notes.length
|
? activities.length
|
||||||
: undefined
|
: item.id === 'notes'
|
||||||
|
? notes.length
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</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">
|
||||||
|
|||||||
Reference in New Issue
Block a user