Merge branch 'dev' into dev-kartik

This commit is contained in:
Kartik Datrika
2026-03-24 15:41:25 +05:30
55 changed files with 3413 additions and 168 deletions

5
.env.production Normal file
View File

@@ -0,0 +1,5 @@
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

View File

@@ -0,0 +1,643 @@
# Multi-Agent SIP + Duplicate Login Lockout — Implementation Plan
> **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:** Per-agent Ozonetel/SIP credentials resolved from platform Agent entity on login, with Redis-backed duplicate login lockout.
**Architecture:** Sidecar queries Agent entity on CC login, checks Redis for active sessions, returns per-agent SIP config. Frontend SIP provider uses dynamic credentials from login response. Heartbeat keeps session alive.
**Tech Stack:** NestJS sidecar + ioredis + FortyTwo platform GraphQL + React frontend
**Spec:** `docs/superpowers/specs/2026-03-23-multi-agent-sip-lockout.md`
---
## File Map
### Sidecar (`helix-engage-server/src/`)
| File | Action | Responsibility |
|------|--------|----------------|
| `auth/session.service.ts` | Create | Redis session lock/unlock/refresh |
| `auth/agent-config.service.ts` | Create | Query Agent entity, cache agent configs |
| `auth/auth.controller.ts` | Modify | Use agent config + session locking on login, add logout + heartbeat |
| `auth/auth.module.ts` | Modify | Register new services, import Redis |
| `config/configuration.ts` | Modify | Add `REDIS_URL` + SIP domain config |
### Frontend (`helix-engage/src/`)
| File | Action | Responsibility |
|------|--------|----------------|
| `pages/login.tsx` | Modify | Store agentConfig, handle 403/409 errors |
| `providers/sip-provider.tsx` | Modify | Read SIP config from agentConfig instead of env vars |
| `components/layout/app-shell.tsx` | Modify | Add heartbeat interval for CC agents |
| `lib/api-client.ts` | Modify | Add logout API call |
| `providers/auth-provider.tsx` | Modify | Call sidecar logout on sign-out |
### Docker
| File | Action | Responsibility |
|------|--------|----------------|
| VPS `docker-compose.yml` | Modify | Add `REDIS_URL` to sidecar env |
---
## Task 1: Install ioredis + Redis Session Service
**Files:**
- Modify: `helix-engage-server/package.json`
- Create: `helix-engage-server/src/auth/session.service.ts`
- Modify: `helix-engage-server/src/config/configuration.ts`
- [ ] **Step 1: Install ioredis**
```bash
cd helix-engage-server && npm install ioredis
```
- [ ] **Step 2: Add Redis URL to config**
In `config/configuration.ts`, add to the returned object:
```typescript
redis: {
url: process.env.REDIS_URL ?? 'redis://localhost:6379',
},
sip: {
domain: process.env.SIP_DOMAIN ?? 'blr-pub-rtc4.ozonetel.com',
wsPort: process.env.SIP_WS_PORT ?? '444',
},
```
- [ ] **Step 3: Create session service**
```typescript
// src/auth/session.service.ts
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
const SESSION_TTL = 3600; // 1 hour
@Injectable()
export class SessionService implements OnModuleInit {
private readonly logger = new Logger(SessionService.name);
private redis: Redis;
constructor(private config: ConfigService) {}
onModuleInit() {
const url = this.config.get<string>('redis.url', 'redis://localhost:6379');
this.redis = new Redis(url);
this.redis.on('connect', () => this.logger.log('Redis connected'));
this.redis.on('error', (err) => this.logger.error(`Redis error: ${err.message}`));
}
private key(agentId: string): string {
return `agent:session:${agentId}`;
}
async lockSession(agentId: string, memberId: string): Promise<void> {
await this.redis.set(this.key(agentId), memberId, 'EX', SESSION_TTL);
}
async isSessionLocked(agentId: string): Promise<string | null> {
return this.redis.get(this.key(agentId));
}
async refreshSession(agentId: string): Promise<void> {
await this.redis.expire(this.key(agentId), SESSION_TTL);
}
async unlockSession(agentId: string): Promise<void> {
await this.redis.del(this.key(agentId));
}
}
```
- [ ] **Step 4: Verify sidecar compiles**
```bash
cd helix-engage-server && npm run build
```
- [ ] **Step 5: Commit**
```bash
git add package.json package-lock.json src/auth/session.service.ts src/config/configuration.ts
git commit -m "feat: Redis session service for agent login lockout"
```
---
## Task 2: Agent Config Service
**Files:**
- Create: `helix-engage-server/src/auth/agent-config.service.ts`
- [ ] **Step 1: Create agent config service**
```typescript
// src/auth/agent-config.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
export type AgentConfig = {
id: string;
ozonetelAgentId: string;
sipExtension: string;
sipPassword: string;
campaignName: string;
sipUri: string;
sipWsServer: string;
};
@Injectable()
export class AgentConfigService {
private readonly logger = new Logger(AgentConfigService.name);
private readonly cache = new Map<string, AgentConfig>();
private readonly sipDomain: string;
private readonly sipWsPort: string;
constructor(
private platform: PlatformGraphqlService,
private config: ConfigService,
) {
this.sipDomain = config.get<string>('sip.domain', 'blr-pub-rtc4.ozonetel.com');
this.sipWsPort = config.get<string>('sip.wsPort', '444');
}
async getByMemberId(memberId: string): Promise<AgentConfig | null> {
// Check cache first
const cached = this.cache.get(memberId);
if (cached) return cached;
try {
const data = await this.platform.query<any>(
`{ agents(first: 1, filter: { wsmemberId: { eq: "${memberId}" } }) { edges { node {
id ozonetelagentid sipextension sippassword campaignname
} } } }`,
);
const node = data?.agents?.edges?.[0]?.node;
if (!node || !node.ozonetelagentid || !node.sipextension) return null;
const agentConfig: AgentConfig = {
id: node.id,
ozonetelAgentId: node.ozonetelagentid,
sipExtension: node.sipextension,
sipPassword: node.sippassword ?? node.sipextension,
campaignName: node.campaignname ?? process.env.OZONETEL_CAMPAIGN_NAME ?? 'Inbound_918041763265',
sipUri: `sip:${node.sipextension}@${this.sipDomain}`,
sipWsServer: `wss://${this.sipDomain}:${this.sipWsPort}`,
};
this.cache.set(memberId, agentConfig);
this.logger.log(`Loaded agent config for member ${memberId}: ${agentConfig.ozonetelAgentId} / ${agentConfig.sipExtension}`);
return agentConfig;
} catch (err) {
this.logger.warn(`Failed to fetch agent config: ${err}`);
return null;
}
}
getFromCache(memberId: string): AgentConfig | null {
return this.cache.get(memberId) ?? null;
}
clearCache(memberId: string): void {
this.cache.delete(memberId);
}
}
```
- [ ] **Step 2: Verify sidecar compiles**
```bash
cd helix-engage-server && npm run build
```
- [ ] **Step 3: Commit**
```bash
git add src/auth/agent-config.service.ts
git commit -m "feat: agent config service with platform query + in-memory cache"
```
---
## Task 3: Update Auth Module + Controller
**Files:**
- Modify: `helix-engage-server/src/auth/auth.module.ts`
- Modify: `helix-engage-server/src/auth/auth.controller.ts`
- [ ] **Step 1: Update auth module to register new services**
Read `src/auth/auth.module.ts` and add imports:
```typescript
import { SessionService } from './session.service';
import { AgentConfigService } from './agent-config.service';
import { PlatformModule } from '../platform/platform.module';
@Module({
imports: [PlatformModule],
controllers: [AuthController],
providers: [SessionService, AgentConfigService],
exports: [SessionService, AgentConfigService],
})
```
- [ ] **Step 2: Rewrite auth controller login for multi-agent**
Inject new services into `AuthController`:
```typescript
constructor(
private config: ConfigService,
private ozonetelAgent: OzonetelAgentService,
private sessionService: SessionService,
private agentConfigService: AgentConfigService,
) { ... }
```
Modify the CC agent section of `login()` (currently lines 115-128). Replace the hardcoded Ozonetel login with:
```typescript
if (appRole === 'cc-agent') {
const memberId = workspaceMember?.id;
if (!memberId) throw new HttpException('Workspace member not found', 400);
// Look up agent config from platform
const agentConfig = await this.agentConfigService.getByMemberId(memberId);
if (!agentConfig) {
throw new HttpException('Agent account not configured. Contact administrator.', 403);
}
// Check for duplicate login
const existingSession = await this.sessionService.isSessionLocked(agentConfig.ozonetelAgentId);
if (existingSession && existingSession !== memberId) {
throw new HttpException('You are already logged in on another device. Please log out there first.', 409);
}
// Lock session
await this.sessionService.lockSession(agentConfig.ozonetelAgentId, memberId);
// Login to Ozonetel with agent-specific credentials
const ozAgentPassword = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';
this.ozonetelAgent.loginAgent({
agentId: agentConfig.ozonetelAgentId,
password: ozAgentPassword,
phoneNumber: agentConfig.sipExtension,
mode: 'blended',
}).catch(err => {
this.logger.warn(`Ozonetel agent login failed (non-blocking): ${err.message}`);
});
// Return agent config to frontend
return {
accessToken,
refreshToken: tokens.refreshToken.token,
user: { ... }, // same as today
agentConfig: {
ozonetelAgentId: agentConfig.ozonetelAgentId,
sipExtension: agentConfig.sipExtension,
sipPassword: agentConfig.sipPassword,
sipUri: agentConfig.sipUri,
sipWsServer: agentConfig.sipWsServer,
campaignName: agentConfig.campaignName,
},
};
}
```
Note: `workspaceMember.id` is already available from the profile query on line 87-88 of the existing code.
- [ ] **Step 3: Add logout endpoint**
Add after the `refresh` endpoint:
```typescript
@Post('logout')
async logout(@Headers('authorization') auth: string) {
if (!auth) throw new HttpException('Authorization required', 401);
try {
// Resolve workspace member from JWT
const profileRes = await axios.post(this.graphqlUrl, {
query: '{ currentUser { workspaceMember { id } } }',
}, { headers: { 'Content-Type': 'application/json', Authorization: auth } });
const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id;
if (!memberId) return { status: 'ok' };
const agentConfig = this.agentConfigService.getFromCache(memberId);
if (agentConfig) {
// Unlock Redis session
await this.sessionService.unlockSession(agentConfig.ozonetelAgentId);
// Logout from Ozonetel
this.ozonetelAgent.logoutAgent({
agentId: agentConfig.ozonetelAgentId,
password: process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$',
}).catch(err => this.logger.warn(`Ozonetel logout failed: ${err.message}`));
// Clear cache
this.agentConfigService.clearCache(memberId);
}
return { status: 'ok' };
} catch (err) {
this.logger.warn(`Logout cleanup failed: ${err}`);
return { status: 'ok' };
}
}
```
- [ ] **Step 4: Add heartbeat endpoint**
```typescript
@Post('heartbeat')
async heartbeat(@Headers('authorization') auth: string) {
if (!auth) throw new HttpException('Authorization required', 401);
try {
const profileRes = await axios.post(this.graphqlUrl, {
query: '{ currentUser { workspaceMember { id } } }',
}, { headers: { 'Content-Type': 'application/json', Authorization: auth } });
const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id;
const agentConfig = memberId ? this.agentConfigService.getFromCache(memberId) : null;
if (agentConfig) {
await this.sessionService.refreshSession(agentConfig.ozonetelAgentId);
}
return { status: 'ok' };
} catch {
return { status: 'ok' };
}
}
```
- [ ] **Step 5: Verify sidecar compiles**
```bash
cd helix-engage-server && npm run build
```
- [ ] **Step 6: Commit**
```bash
git add src/auth/auth.module.ts src/auth/auth.controller.ts
git commit -m "feat: multi-agent login with Redis lockout, logout, heartbeat"
```
---
## Task 4: Update Ozonetel Controller for Per-Agent Calls
**Files:**
- Modify: `helix-engage-server/src/ozonetel/ozonetel-agent.controller.ts`
- [ ] **Step 1: Add AgentConfigService to Ozonetel controller**
Import and inject `AgentConfigService`. Add a helper to resolve the agent config from the auth header:
```typescript
import { AgentConfigService } from '../auth/agent-config.service';
// In constructor:
private readonly agentConfig: AgentConfigService,
// Helper method:
private async resolveAgentId(authHeader: string): Promise<string> {
try {
const data = await this.platform.queryWithAuth<any>(
'{ currentUser { workspaceMember { id } } }',
undefined, authHeader,
);
const memberId = data.currentUser?.workspaceMember?.id;
const config = memberId ? this.agentConfig.getFromCache(memberId) : null;
return config?.ozonetelAgentId ?? this.defaultAgentId;
} catch {
return this.defaultAgentId;
}
}
```
- [ ] **Step 2: Update dispose, agent-state, dial, and other endpoints**
Replace `this.defaultAgentId` with `await this.resolveAgentId(authHeader)` in the endpoints that pass the auth header. The key endpoints to update:
- `dispose()` — add `@Headers('authorization') auth: string` param, resolve agent ID
- `agentState()` — same
- `dial()` — same
- `agentReady()` — same
For endpoints that don't currently take the auth header, add it as a parameter.
- [ ] **Step 3: Update auth module to handle circular dependency**
The `OzonetelAgentModule` now needs `AgentConfigService` from `AuthModule`. Use `forwardRef` if needed, or export `AgentConfigService` from a shared module.
Simplest approach: move `AgentConfigService` export from `AuthModule` and import it in `OzonetelAgentModule`.
- [ ] **Step 4: Verify sidecar compiles**
```bash
cd helix-engage-server && npm run build
```
- [ ] **Step 5: Commit**
```bash
git add src/ozonetel/ozonetel-agent.controller.ts src/ozonetel/ozonetel-agent.module.ts src/auth/auth.module.ts
git commit -m "feat: per-agent Ozonetel credentials in all controller endpoints"
```
---
## Task 5: Frontend — Store Agent Config + Dynamic SIP
**Files:**
- Modify: `helix-engage/src/pages/login.tsx`
- Modify: `helix-engage/src/providers/sip-provider.tsx`
- Modify: `helix-engage/src/providers/auth-provider.tsx`
- [ ] **Step 1: Store agentConfig on login**
In `login.tsx`, after successful login, store the agent config:
```typescript
if (response.agentConfig) {
localStorage.setItem('helix_agent_config', JSON.stringify(response.agentConfig));
}
```
Handle new error codes:
```typescript
} catch (err: any) {
if (err.message?.includes('not configured')) {
setError('Agent account not configured. Contact your administrator.');
} else if (err.message?.includes('already logged in')) {
setError('You are already logged in on another device. Please log out there first.');
} else {
setError(err.message);
}
setIsLoading(false);
}
```
- [ ] **Step 2: Update SIP provider to use stored agent config**
In `sip-provider.tsx`, replace the hardcoded `DEFAULT_CONFIG`:
```typescript
const getAgentSipConfig = (): SIPConfig => {
try {
const stored = localStorage.getItem('helix_agent_config');
if (stored) {
const config = JSON.parse(stored);
return {
displayName: 'Helix Agent',
uri: config.sipUri,
password: config.sipPassword,
wsServer: config.sipWsServer,
stunServers: 'stun:stun.l.google.com:19302',
};
}
} catch {}
// Fallback to env vars
return {
displayName: import.meta.env.VITE_SIP_DISPLAY_NAME ?? 'Helix Agent',
uri: import.meta.env.VITE_SIP_URI ?? '',
password: import.meta.env.VITE_SIP_PASSWORD ?? '',
wsServer: import.meta.env.VITE_SIP_WS_SERVER ?? '',
stunServers: 'stun:stun.l.google.com:19302',
};
};
```
Use `getAgentSipConfig()` where `DEFAULT_CONFIG` was used.
- [ ] **Step 3: Update auth provider logout to call sidecar**
In `auth-provider.tsx`, modify `logout()` to call the sidecar first:
```typescript
const logout = async () => {
try {
const token = localStorage.getItem('helix_access_token');
if (token) {
await fetch(`${API_URL}/auth/logout`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
}).catch(() => {});
}
} finally {
localStorage.removeItem('helix_access_token');
localStorage.removeItem('helix_refresh_token');
localStorage.removeItem('helix_user');
localStorage.removeItem('helix_agent_config');
setUser(null);
}
};
```
Note: `API_URL` needs to be available here. Import from `api-client.ts` or read from env.
- [ ] **Step 4: Verify frontend compiles**
```bash
cd helix-engage && npm run build
```
- [ ] **Step 5: Commit**
```bash
git add src/pages/login.tsx src/providers/sip-provider.tsx src/providers/auth-provider.tsx
git commit -m "feat: dynamic SIP config from login response, logout cleanup"
```
---
## Task 6: Frontend — Heartbeat
**Files:**
- Modify: `helix-engage/src/components/layout/app-shell.tsx`
- [ ] **Step 1: Add heartbeat interval for CC agents**
In `AppShell`, add a heartbeat effect:
```typescript
const { isCCAgent } = useAuth();
useEffect(() => {
if (!isCCAgent) return;
const interval = setInterval(() => {
const token = localStorage.getItem('helix_access_token');
if (token) {
fetch(`${import.meta.env.VITE_API_URL ?? 'http://localhost:4100'}/auth/heartbeat`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
}).catch(() => {});
}
}, 5 * 60 * 1000); // Every 5 minutes
return () => clearInterval(interval);
}, [isCCAgent]);
```
- [ ] **Step 2: Verify frontend compiles**
```bash
cd helix-engage && npm run build
```
- [ ] **Step 3: Commit**
```bash
git add src/components/layout/app-shell.tsx
git commit -m "feat: heartbeat every 5 min to keep agent session alive"
```
---
## Task 7: Docker + Deploy
**Files:**
- Modify: VPS `docker-compose.yml`
- [ ] **Step 1: Add REDIS_URL to sidecar in docker-compose**
SSH to VPS and add `REDIS_URL: redis://redis:6379` to the sidecar environment section. Also add `redis` to the sidecar's `depends_on`.
- [ ] **Step 2: Deploy using deploy script**
```bash
./deploy.sh all
```
- [ ] **Step 3: Verify sidecar connects to Redis**
```bash
ssh -i ~/Downloads/fortytwoai_hostinger root@148.230.67.184 "docker logs fortytwo-staging-sidecar-1 --tail 10 2>&1 | grep -i redis"
```
Expected: `Redis connected`
- [ ] **Step 4: Test login flow**
Login as rekha.cc → should get `agentConfig` in response. SIP should connect with her specific extension. Try logging in from another browser → should get "already logged in" error.
- [ ] **Step 5: Commit docker-compose change and push all to Azure**
```bash
cd helix-engage && git add . && git push origin dev
cd helix-engage-server && git add . && git push origin dev
```

View File

@@ -0,0 +1,531 @@
# Supervisor Module Implementation Plan
> **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:** Build the supervisor module with team performance dashboard (PP-5), live call monitor (PP-6), master data pages, and admin sidebar restructure.
**Architecture:** Frontend pages query platform GraphQL directly for entity data (calls, appointments, leads, agents). Sidecar provides Ozonetel-specific data (agent time breakdown, active calls via event subscription). No hardcoded/mock data anywhere.
**Tech Stack:** React + Tailwind + ECharts (frontend), NestJS sidecar (Ozonetel integration), Fortytwo platform GraphQL
**Spec:** `docs/superpowers/specs/2026-03-24-supervisor-module.md`
---
## File Map
### Frontend (`helix-engage/src/`)
| File | Action | Responsibility |
|------|--------|----------------|
| `pages/team-performance.tsx` | Create | PP-5 full dashboard |
| `pages/live-monitor.tsx` | Create | PP-6 active call table |
| `pages/call-recordings.tsx` | Create | Calls with recordings master |
| `pages/missed-calls.tsx` | Create | Missed calls master (supervisor view) |
| `components/layout/sidebar.tsx` | Modify | Admin nav restructure |
| `main.tsx` | Modify | Add new routes |
### Sidecar (`helix-engage-server/src/`)
| File | Action | Responsibility |
|------|--------|----------------|
| `supervisor/supervisor.service.ts` | Create | Team perf aggregation + active call tracking |
| `supervisor/supervisor.controller.ts` | Create | REST endpoints |
| `supervisor/supervisor.module.ts` | Create | Module registration |
| `app.module.ts` | Modify | Import SupervisorModule |
---
## Task 1: Admin Sidebar Nav + Routes
**Files:**
- Modify: `helix-engage/src/components/layout/sidebar.tsx`
- Modify: `helix-engage/src/main.tsx`
- [ ] **Step 1: Add new icon imports to sidebar**
In `sidebar.tsx`, add to the FontAwesome imports:
```typescript
import {
// existing imports...
faRadio,
faFileAudio,
faPhoneMissed,
faChartLine,
} from '@fortawesome/pro-duotone-svg-icons';
```
Add icon wrappers:
```typescript
const IconRadio = faIcon(faRadio);
const IconFileAudio = faIcon(faFileAudio);
const IconPhoneMissed = faIcon(faPhoneMissed);
const IconChartLine = faIcon(faChartLine);
```
- [ ] **Step 2: Restructure admin nav**
Replace the admin nav section (currently has Overview + Management + Admin groups) with:
```typescript
if (role === 'admin') {
return [
{ label: 'Supervisor', items: [
{ label: 'Dashboard', href: '/', icon: IconGrid2 },
{ label: 'Team Performance', href: '/team-performance', icon: IconChartLine },
{ label: 'Live Call Monitor', href: '/live-monitor', icon: IconRadio },
]},
{ label: 'Data & Reports', items: [
{ label: 'Lead Master', href: '/leads', icon: IconUsers },
{ label: 'Patient Master', href: '/patients', icon: IconHospitalUser },
{ label: 'Appointment Master', href: '/appointments', icon: IconCalendarCheck },
{ label: 'Call Log Master', href: '/call-history', icon: IconClockRewind },
{ label: 'Call Recordings', href: '/call-recordings', icon: IconFileAudio },
{ label: 'Missed Calls', href: '/missed-calls', icon: IconPhoneMissed },
]},
{ label: 'Admin', items: [
{ label: 'Settings', href: '/settings', icon: IconGear },
]},
];
}
```
- [ ] **Step 3: Add routes in main.tsx**
Import new page components (they'll be created in later tasks — use placeholder components for now):
```typescript
import { TeamPerformancePage } from "@/pages/team-performance";
import { LiveMonitorPage } from "@/pages/live-monitor";
import { CallRecordingsPage } from "@/pages/call-recordings";
import { MissedCallsPage } from "@/pages/missed-calls";
```
Add routes:
```typescript
<Route path="/team-performance" element={<TeamPerformancePage />} />
<Route path="/live-monitor" element={<LiveMonitorPage />} />
<Route path="/call-recordings" element={<CallRecordingsPage />} />
<Route path="/missed-calls" element={<MissedCallsPage />} />
```
- [ ] **Step 4: Create placeholder pages**
Create minimal placeholder files for each new page so the build doesn't fail:
```typescript
// src/pages/team-performance.tsx
export const TeamPerformancePage = () => <div>Team Performance coming soon</div>;
// src/pages/live-monitor.tsx
export const LiveMonitorPage = () => <div>Live Call Monitor coming soon</div>;
// src/pages/call-recordings.tsx
export const CallRecordingsPage = () => <div>Call Recordings coming soon</div>;
// src/pages/missed-calls.tsx
export const MissedCallsPage = () => <div>Missed Calls coming soon</div>;
```
- [ ] **Step 5: Verify build**
```bash
cd helix-engage && npm run build
```
- [ ] **Step 6: Commit**
```bash
git add src/components/layout/sidebar.tsx src/main.tsx src/pages/team-performance.tsx src/pages/live-monitor.tsx src/pages/call-recordings.tsx src/pages/missed-calls.tsx
git commit -m "feat: admin sidebar restructure + placeholder pages for supervisor module"
```
---
## Task 2: Call Recordings Page
**Files:**
- Modify: `helix-engage/src/pages/call-recordings.tsx`
- [ ] **Step 1: Implement call recordings page**
Query platform for calls with recordings. Reuse patterns from `call-history.tsx`.
```typescript
// Query: calls where recording primaryLinkUrl is not empty
const QUERY = `{ calls(first: 100, filter: {
recording: { primaryLinkUrl: { neq: "" } }
}, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id direction callStatus callerNumber { primaryPhoneNumber }
agentName startedAt durationSec disposition
recording { primaryLinkUrl primaryLinkLabel }
} } } }`;
```
Table columns: Agent, Caller (PhoneActionCell), Type (In/Out badge), Date, Duration, Disposition, Recording (play button).
Search by agent name or phone number. Date filter optional.
- [ ] **Step 2: Verify build**
```bash
cd helix-engage && npm run build
```
- [ ] **Step 3: Commit**
```bash
git add src/pages/call-recordings.tsx
git commit -m "feat: call recordings master page"
```
---
## Task 3: Missed Calls Page (Supervisor View)
**Files:**
- Modify: `helix-engage/src/pages/missed-calls.tsx`
- [ ] **Step 1: Implement missed calls page**
Query platform for all missed calls — no agent filter (supervisor sees all).
```typescript
const QUERY = `{ calls(first: 100, filter: {
callStatus: { eq: MISSED }
}, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id callerNumber { primaryPhoneNumber } agentName
startedAt callsourcenumber callbackstatus missedcallcount callbackattemptedat
} } } }`;
```
Table columns: Caller (PhoneActionCell), Date/Time, Branch (`callsourcenumber`), Agent, Callback Status (badge), SLA (computed from `startedAt`).
Tabs: All | Pending (`PENDING_CALLBACK`) | Attempted (`CALLBACK_ATTEMPTED`) | Completed (`CALLBACK_COMPLETED` + `WRONG_NUMBER`).
Search by phone or agent.
- [ ] **Step 2: Verify build**
```bash
cd helix-engage && npm run build
```
- [ ] **Step 3: Commit**
```bash
git add src/pages/missed-calls.tsx
git commit -m "feat: missed calls master page for supervisors"
```
---
## Task 4: Sidecar — Supervisor Module
**Files:**
- Create: `helix-engage-server/src/supervisor/supervisor.service.ts`
- Create: `helix-engage-server/src/supervisor/supervisor.controller.ts`
- Create: `helix-engage-server/src/supervisor/supervisor.module.ts`
- Modify: `helix-engage-server/src/app.module.ts`
- [ ] **Step 1: Create supervisor service**
```typescript
// supervisor.service.ts
// Two responsibilities:
// 1. Aggregate Ozonetel agent summary across all agents
// 2. Track active calls from Ozonetel real-time events
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
type ActiveCall = {
ucid: string;
agentId: string;
callerNumber: string;
callType: string;
startTime: string;
status: 'active' | 'on-hold';
};
@Injectable()
export class SupervisorService implements OnModuleInit {
private readonly logger = new Logger(SupervisorService.name);
private readonly activeCalls = new Map<string, ActiveCall>();
constructor(
private platform: PlatformGraphqlService,
private ozonetel: OzonetelAgentService,
private config: ConfigService,
) {}
async onModuleInit() {
// Subscribe to Ozonetel events (fire and forget)
// Will be implemented when webhook URL is configured
this.logger.log('Supervisor service initialized');
}
// Called by webhook when Ozonetel pushes call events
handleCallEvent(event: any) {
const { action, ucid, agent_id, caller_id, call_type, event_time } = event;
if (action === 'Answered' || action === 'Calling') {
this.activeCalls.set(ucid, {
ucid, agentId: agent_id, callerNumber: caller_id,
callType: call_type, startTime: event_time, status: 'active',
});
} else if (action === 'Disconnect') {
this.activeCalls.delete(ucid);
}
}
// Called by webhook when Ozonetel pushes agent events
handleAgentEvent(event: any) {
this.logger.log(`Agent event: ${event.agentId}${event.action}`);
}
getActiveCalls(): ActiveCall[] {
return Array.from(this.activeCalls.values());
}
// Aggregate time breakdown across all agents
async getTeamPerformance(date: string): Promise<any> {
// Get all agent IDs from platform
const agentData = await this.platform.query<any>(
`{ agents(first: 20) { edges { node {
id name ozonetelagentid npsscore maxidleminutes minnpsthreshold minconversionpercent
} } } }`,
);
const agents = agentData?.agents?.edges?.map((e: any) => e.node) ?? [];
// Fetch Ozonetel summary per agent
const summaries = await Promise.all(
agents.map(async (agent: any) => {
try {
const summary = await this.ozonetel.getAgentSummary(agent.ozonetelagentid, date);
return { ...agent, timeBreakdown: summary };
} catch {
return { ...agent, timeBreakdown: null };
}
}),
);
return { date, agents: summaries };
}
}
```
- [ ] **Step 2: Create supervisor controller**
```typescript
// supervisor.controller.ts
import { Controller, Get, Post, Body, Query, Logger } from '@nestjs/common';
import { SupervisorService } from './supervisor.service';
@Controller('api/supervisor')
export class SupervisorController {
private readonly logger = new Logger(SupervisorController.name);
constructor(private readonly supervisor: SupervisorService) {}
@Get('active-calls')
getActiveCalls() {
return this.supervisor.getActiveCalls();
}
@Get('team-performance')
async getTeamPerformance(@Query('date') date?: string) {
const targetDate = date ?? new Date().toISOString().split('T')[0];
return this.supervisor.getTeamPerformance(targetDate);
}
@Post('call-event')
handleCallEvent(@Body() body: any) {
// Ozonetel pushes events here
const event = body.data ?? body;
this.logger.log(`Call event: ${event.action} ucid=${event.ucid} agent=${event.agent_id}`);
this.supervisor.handleCallEvent(event);
return { received: true };
}
@Post('agent-event')
handleAgentEvent(@Body() body: any) {
const event = body.data ?? body;
this.logger.log(`Agent event: ${event.action} agent=${event.agentId}`);
this.supervisor.handleAgentEvent(event);
return { received: true };
}
}
```
- [ ] **Step 3: Create supervisor module and register**
```typescript
// supervisor.module.ts
import { Module } from '@nestjs/common';
import { PlatformModule } from '../platform/platform.module';
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
import { SupervisorController } from './supervisor.controller';
import { SupervisorService } from './supervisor.service';
@Module({
imports: [PlatformModule, OzonetelAgentModule],
controllers: [SupervisorController],
providers: [SupervisorService],
})
export class SupervisorModule {}
```
Add to `app.module.ts`:
```typescript
import { SupervisorModule } from './supervisor/supervisor.module';
// Add to imports array
```
- [ ] **Step 4: Verify sidecar build**
```bash
cd helix-engage-server && npm run build
```
- [ ] **Step 5: Commit**
```bash
git add src/supervisor/ src/app.module.ts
git commit -m "feat: supervisor module with team performance + active calls endpoints"
```
---
## Task 5: Team Performance Dashboard (PP-5)
**Files:**
- Modify: `helix-engage/src/pages/team-performance.tsx`
This is the largest task. The page queries platform directly for calls/appointments/leads and the sidecar for time breakdown.
- [ ] **Step 1: Build the full page**
The page has 6 sections. Use `apiClient.graphql()` for platform data and `apiClient.get()` for sidecar data.
**Queries needed:**
- Calls by date range: `calls(first: 500, filter: { startedAt: { gte: "...", lte: "..." } })`
- Appointments by date range: `appointments(first: 200, filter: { scheduledAt: { gte: "...", lte: "..." } })`
- Leads: `leads(first: 200)`
- Follow-ups: `followUps(first: 200)`
- Agents with thresholds: `agents(first: 20) { ... npsscore maxidleminutes minnpsthreshold minconversionpercent }`
- Sidecar: `GET /api/supervisor/team-performance?date=YYYY-MM-DD`
**Date range logic:**
- Today: today start → now
- Week: Monday of current week → now
- Month: 1st of current month → now
- Year: Jan 1 → now
- Custom: user-selected range
**Sections to implement:**
1. Key Metrics bar (6 cards in a row)
2. Call Breakdown Trends (2 ECharts line charts side by side)
3. Agent Performance table (sortable)
4. Time Breakdown (team average + per-agent stacked bars)
5. NPS + Conversion Metrics (donut + cards)
6. Performance Alerts (threshold comparison)
Check if ECharts is already installed:
```bash
grep echarts helix-engage/package.json
```
If not, install: `npm install echarts echarts-for-react`
Follow the existing My Performance page (`my-performance.tsx`) for ECharts patterns.
- [ ] **Step 2: Verify build**
```bash
cd helix-engage && npm run build
```
- [ ] **Step 3: Test locally**
```bash
cd helix-engage && npm run dev
```
Navigate to `/team-performance` as admin user. Verify all 6 sections render with real data.
- [ ] **Step 4: Commit**
```bash
git add src/pages/team-performance.tsx package.json package-lock.json
git commit -m "feat: team performance dashboard (PP-5) with 6 data sections"
```
---
## Task 6: Live Call Monitor (PP-6)
**Files:**
- Modify: `helix-engage/src/pages/live-monitor.tsx`
- [ ] **Step 1: Build the live monitor page**
Page polls `GET /api/supervisor/active-calls` every 5 seconds.
**Structure:**
1. TopBar: "Live Call Monitor" with subtitle "Listen, whisper, or barge into active calls"
2. Three KPI cards: Active Calls, On Hold, Avg Duration
3. Active Calls table: Agent, Caller, Type, Department, Duration (live counter), Status, Actions
4. Actions: Listen / Whisper / Barge buttons — all disabled with tooltip "Coming soon — pending Ozonetel API"
5. Empty state: headphones icon + "No active calls"
Duration should be a live counter — calculated client-side from `startTime` in the active call data. Use `setInterval` to update every second.
Caller name: attempt to match `callerNumber` against leads from `useData()`. If matched, show lead name + phone. If not, show phone only.
- [ ] **Step 2: Verify build**
```bash
cd helix-engage && npm run build
```
- [ ] **Step 3: Test locally**
Navigate to `/live-monitor`. Verify empty state renders. If Ozonetel events are flowing, verify active calls appear.
- [ ] **Step 4: Commit**
```bash
git add src/pages/live-monitor.tsx
git commit -m "feat: live call monitor page (PP-6) with polling + KPI cards"
```
---
## Task 7: Local Testing + Final Verification
- [ ] **Step 1: Run both locally**
Terminal 1: `cd helix-engage-server && npm run start:dev`
Terminal 2: `cd helix-engage && npm run dev`
- [ ] **Step 2: Test admin login**
Login as admin (sanjay.marketing@globalhospital.com). Verify:
- Sidebar shows new nav structure (Supervisor + Data & Reports sections)
- Dashboard loads
- Team Performance shows data from platform
- Live Monitor shows empty state or active calls
- All master data pages load (Lead, Patient, Appointment, Call Log, Call Recordings, Missed Calls)
- [ ] **Step 3: Commit any fixes**
- [ ] **Step 4: Push to Azure**
```bash
cd helix-engage && git push origin dev
cd helix-engage-server && git push origin dev
```

View File

@@ -0,0 +1,176 @@
# Multi-Agent SIP Credentials + Duplicate Login Lockout
**Date**: 2026-03-23
**Status**: Approved design
---
## Problem
Single Ozonetel agent account (`global`) and SIP extension (`523590`) shared across all CC agents. When multiple agents log in, calls route to whichever browser registered last. No way to have multiple simultaneous CC agents.
## Solution
Per-agent Ozonetel credentials stored in the platform's Agent entity, resolved on login. Redis-backed session locking prevents duplicate logins. Frontend SIP provider uses dynamic credentials from login response.
---
## 1. Data Model
**Agent entity** (already created on platform via admin portal):
| Field (GraphQL) | Type | Purpose |
|---|---|---|
| `wsmemberId` | Relation | Links to workspace member |
| `ozonetelagentid` | Text | Ozonetel agent ID (e.g. "global", "agent2") |
| `sipextension` | Text | SIP extension number (e.g. "523590") |
| `sippassword` | Text | SIP auth password |
| `campaignname` | Text | Ozonetel campaign (e.g. "Inbound_918041763265") |
Custom fields use **all-lowercase** GraphQL names. One Agent record per CC user.
---
## 2. Sidecar Changes
### 2.1 Redis Integration
Add `ioredis` dependency to `helix-engage-server`. Connect to `REDIS_URL` (default `redis://redis:6379`).
New service: `src/auth/session.service.ts`
```
lockSession(agentId, memberId) → SET agent:session:{agentId} {memberId} EX 3600
isSessionLocked(agentId) → GET agent:session:{agentId} → returns memberId or null
refreshSession(agentId) → EXPIRE agent:session:{agentId} 3600
unlockSession(agentId) → DEL agent:session:{agentId}
```
### 2.2 Auth Controller — Login Flow
Modify `POST /auth/login`:
1. Authenticate with platform → get JWT + user profile + workspace member ID
2. Determine role (same as today)
3. **If CC agent:**
a. Query platform: `agents(filter: { wsmemberId: { eq: "<memberId>" } })` using server API key
b. No Agent record → `403: "Agent account not configured. Contact administrator."`
c. Check Redis: `isSessionLocked(agent.ozonetelagentid)`
d. Locked by different user → `409: "You are already logged in on another device. Please log out there first."`
e. Locked by same user → refresh TTL (re-login from same browser)
f. Not locked → `lockSession(agent.ozonetelagentid, memberId)`
g. Login to Ozonetel with agent's specific credentials
h. Return `agentConfig` in response
4. **If manager/executive:** No Agent query, no Redis, no SIP. Same as today.
**Login response** (CC agent):
```json
{
"accessToken": "...",
"refreshToken": "...",
"user": { "id": "...", "role": "cc-agent", ... },
"agentConfig": {
"ozonetelAgentId": "global",
"sipExtension": "523590",
"sipPassword": "523590",
"sipUri": "sip:523590@blr-pub-rtc4.ozonetel.com",
"sipWsServer": "wss://blr-pub-rtc4.ozonetel.com:444",
"campaignName": "Inbound_918041763265"
}
}
```
SIP domain (`blr-pub-rtc4.ozonetel.com`) and WS port (`444`) remain from env vars — these are shared infrastructure, not per-agent.
### 2.3 Auth Controller — Logout
Modify `POST /auth/logout` (or add if doesn't exist):
1. Resolve agent from JWT
2. `unlockSession(agent.ozonetelagentid)`
3. Ozonetel agent logout
### 2.4 Auth Controller — Heartbeat
New endpoint: `POST /auth/heartbeat`
1. Resolve agent from JWT
2. `refreshSession(agent.ozonetelagentid)` → extends TTL to 1 hour
3. Return `{ status: 'ok' }`
### 2.5 Agent Config Cache
On login, store agent config in an in-memory `Map<workspaceMemberId, AgentConfig>`.
All Ozonetel controller endpoints currently use `this.defaultAgentId`. Change to:
1. Resolve workspace member from JWT (already done in worklist controller's `resolveAgentName`)
2. Lookup agent config from the in-memory map
3. Use the agent's `ozonetelagentid` for Ozonetel API calls
This avoids querying Redis/platform on every API call.
Clear the cache entry on logout.
### 2.6 Config
New env var: `REDIS_URL` (default: `redis://redis:6379`)
Existing env vars (`OZONETEL_AGENT_ID`, `OZONETEL_SIP_ID`, etc.) become fallbacks only — used when no Agent record exists (backward compatibility for dev).
---
## 3. Frontend Changes
### 3.1 Store Agent Config
On login, store `agentConfig` from the response in localStorage (`helix_agent_config`).
On logout, clear it.
### 3.2 SIP Provider
`sip-provider.tsx`: Read SIP credentials from stored `agentConfig` instead of env vars.
```
const agentConfig = JSON.parse(localStorage.getItem('helix_agent_config'));
const sipUri = agentConfig?.sipUri ?? import.meta.env.VITE_SIP_URI;
const sipPassword = agentConfig?.sipPassword ?? import.meta.env.VITE_SIP_PASSWORD;
const sipWsServer = agentConfig?.sipWsServer ?? import.meta.env.VITE_SIP_WS_SERVER;
```
If no `agentConfig` and no env vars → don't connect SIP.
### 3.3 Heartbeat
Add a heartbeat interval in `AppShell` (only for CC agents):
- Every 5 minutes: `POST /auth/heartbeat`
- If heartbeat fails with 401 → session expired, redirect to login
### 3.4 Login Error Handling
Handle new error codes from login:
- `403` → "Agent account not configured. Contact administrator."
- `409` → "You are already logged in on another device. Please log out there first."
### 3.5 Logout
On logout, call `POST /auth/logout` before clearing tokens (so sidecar can clean up Redis + Ozonetel).
---
## 4. Docker Compose
Add `REDIS_URL` to sidecar environment in `docker-compose.yml`:
```yaml
sidecar:
environment:
REDIS_URL: redis://redis:6379
```
---
## 5. Edge Cases
- **Sidecar restart**: Redis retains session locks. Agent config cache is lost but rebuilt on next API call (query Agent entity lazily).
- **Redis restart**: All session locks cleared. Agents can re-login. Acceptable — same as TTL expiry.
- **Browser crash (no logout)**: Heartbeat stops → Redis key expires in ≤1 hour → lock clears.
- **Same user, same browser re-login**: Detected by comparing `memberId` in Redis → refreshes TTL instead of blocking.
- **Agent record deleted while logged in**: Next Ozonetel API call fails → sidecar clears cache → agent gets logged out.

View File

@@ -0,0 +1,191 @@
# Supervisor Module — Team Performance, Live Call Monitor, Master Data
**Date**: 2026-03-24
**Jira**: PP-5 (Team Performance), PP-6 (Live Call Monitor)
**Status**: Approved design
---
## Principle
No hardcoded/mock data. All data from Ozonetel APIs or platform GraphQL queries.
---
## 1. Admin Sidebar Nav Restructure
```
SUPERVISOR
Dashboard → / (existing team-dashboard.tsx — summary)
Team Performance → /team-performance (new — full PP-5)
Live Call Monitor → /live-monitor (new — PP-6)
DATA & REPORTS
Lead Master → /leads (existing all-leads.tsx)
Patient Master → /patients (existing patients.tsx)
Appointment Master → /appointments (existing appointments.tsx)
Call Log Master → /call-history (existing call-history.tsx)
Call Recordings → /call-recordings (new — filtered calls with recordings)
Missed Calls → /missed-calls (new — standalone missed call table)
```
**Files**: `sidebar.tsx` (admin nav config), `main.tsx` (routes)
---
## 2. Team Performance Dashboard (PP-5)
**Route**: `/team-performance`
**Page**: `src/pages/team-performance.tsx`
### Section 1: Key Metrics Bar
- Active Agents / On Call Now → sidecar (from active calls tracking)
- Total Calls → platform `calls` count by date range
- Appointments → platform `appointments` count
- Missed Calls → platform `calls` where `callStatus: MISSED`
- Conversion Rate → appointments / total calls
- Time filter: Today | Week | Month | Year | Custom
### Section 2: Call Breakdown Trends
- Left: Inbound vs Outbound line chart (ECharts) by day
- Right: Leads vs Missed vs Follow-ups by day
- Data: platform `calls` grouped by date + direction
### Section 3: Agent Performance Table
| Column | Source |
|--------|--------|
| Agent | Agent entity `name` |
| Calls | Platform `calls` filtered by `agentName` |
| Inbound | Platform `calls` where `direction: INBOUND` |
| Missed | Platform `calls` where `callStatus: MISSED` |
| Follow-ups | Platform `followUps` filtered by `assignedAgent` |
| Leads | Platform `leads` filtered by `assignedAgent` |
| Conv% | Derived: appointments / calls |
| NPS | Agent entity `npsscore` |
| Idle | Ozonetel `getAgentSummary` API |
Sortable columns. Own time filter (Today/Week/Month/Year/Custom).
### Section 4: Time Breakdown
- Team average: Active / Wrap / Idle / Break totals
- Per-agent horizontal stacked bars
- Data: Ozonetel `getAgentSummary` per agent
- Agents with idle > `maxidleminutes` threshold highlighted red
### Section 5: NPS + Conversion Metrics
- NPS donut chart (average of all agents' `npsscore`)
- Per-agent NPS horizontal bars
- Call→Appointment % card (big number)
- Lead→Contact % card (big number)
- Per-agent conversion breakdown below cards
### Section 6: Performance Alerts
- Compare actual metrics vs Agent entity thresholds:
- `maxidleminutes` → "Excessive Idle Time"
- `minnpsthreshold` → "Low NPS"
- `minconversionpercent` → "Low Lead-to-Contact"
- Red-highlighted alert cards with agent name, alert type, value
### Sidecar Endpoint
`GET /api/supervisor/team-performance?date=YYYY-MM-DD`
- Aggregates Ozonetel `getAgentSummary` across all agents
- Returns per-agent time breakdown (active/wrap/idle/break in minutes)
- Uses Agent entity to get list of all agent IDs
---
## 3. Live Call Monitor (PP-6)
**Route**: `/live-monitor`
**Page**: `src/pages/live-monitor.tsx`
### KPI Cards
- Active Calls count
- On Hold count
- Avg Duration
### Active Calls Table
| Column | Source |
|--------|--------|
| Agent | Ozonetel event `agent_id` → mapped to Agent entity name |
| Caller | Event `caller_id` → matched against platform leads/patients |
| Type | Event `call_type` (InBound/Manual) |
| Department | From matched lead's `interestedService` or "—" |
| Duration | Live counter from `event_time` |
| Status | active / on-hold |
| Actions | Listen / Whisper / Barge buttons (disabled until API confirmed) |
### Data Flow
1. Sidecar subscribes to Ozonetel real-time events on startup
- `POST https://subscription.ozonetel.com/events/subscribe`
- Body: `{ callEventsURL: "<sidecar-webhook-url>", agentEventsURL: "<sidecar-webhook-url>" }`
2. Sidecar receives events at `POST /webhooks/ozonetel/call-event`
3. In-memory map: `ucid → { agentId, callerNumber, callType, startTime, status }`
- `Calling` / `Answered` → add/update entry
- `Disconnect` → remove entry
4. `GET /api/supervisor/active-calls` → returns current map
5. Frontend polls every 5 seconds
### Sidecar Changes
- New module: `src/supervisor/`
- `supervisor.controller.ts` — team-performance + active-calls endpoints
- `supervisor.service.ts` — Ozonetel event subscription, active call tracking
- `supervisor.module.ts`
- New webhook: `POST /webhooks/ozonetel/call-event`
- Ozonetel event subscription on `onModuleInit`
---
## 4. Master Data Pages
### Call Recordings (`/call-recordings`)
**Page**: `src/pages/call-recordings.tsx`
- Query: platform `calls` where `recording` is not null
- Table: Agent, Caller, Type, Date, Duration, Recording Player
- Search by agent/phone + date filter
### Missed Calls (`/missed-calls`)
**Page**: `src/pages/missed-calls.tsx`
- Query: platform `calls` where `callStatus: MISSED`
- Table: Caller, Date/Time, Branch (`callsourcenumber`), Agent, Callback Status, SLA
- Tabs: All | Pending | Attempted | Completed (filter by `callbackstatus`)
- Not filtered by agent — supervisor sees all
---
## 5. Agent Entity Fields (Already Configured)
| GraphQL Field | Type | Purpose |
|---|---|---|
| `ozonetelagentid` | Text | Ozonetel agent ID |
| `sipextension` | Text | SIP extension |
| `sippassword` | Text | SIP password |
| `campaignname` | Text | Ozonetel campaign |
| `npsscore` | Number | Agent NPS score |
| `maxidleminutes` | Number | Idle time alert threshold |
| `minnpsthreshold` | Number | NPS alert threshold |
| `minconversionpercent` | Number | Conversion alert threshold |
All custom fields use **all-lowercase** GraphQL names.
---
## 6. File Map
### New Files
| File | Purpose |
|------|---------|
| `helix-engage/src/pages/team-performance.tsx` | PP-5 dashboard |
| `helix-engage/src/pages/live-monitor.tsx` | PP-6 active call monitor |
| `helix-engage/src/pages/call-recordings.tsx` | Call recordings master |
| `helix-engage/src/pages/missed-calls.tsx` | Missed calls master |
| `helix-engage-server/src/supervisor/supervisor.controller.ts` | Supervisor endpoints |
| `helix-engage-server/src/supervisor/supervisor.service.ts` | Event subscription + active calls |
| `helix-engage-server/src/supervisor/supervisor.module.ts` | Module registration |
### Modified Files
| File | Change |
|------|--------|
| `helix-engage/src/components/layout/sidebar.tsx` | Admin nav restructure |
| `helix-engage/src/main.tsx` | New routes |
| `helix-engage-server/src/app.module.ts` | Import SupervisorModule |

View File

@@ -20,6 +20,7 @@ interface NavItemBaseProps {
/** Type of the nav item. */
type: "link" | "collapsible" | "collapsible-child";
/** Icon component to display. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
icon?: FC<Record<string, any>>;
/** Badge to display. */
badge?: ReactNode;

View File

@@ -6,10 +6,12 @@ export type NavItemType = {
/** URL to navigate to when the nav item is clicked. */
href?: string;
/** Icon component to display. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
icon?: FC<Record<string, any>>;
/** Badge to display. */
badge?: ReactNode;
/** List of sub-items to display. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
items?: { label: string; href: string; icon?: FC<Record<string, any>>; badge?: ReactNode }[];
/** Whether this nav item is a divider. */
divider?: boolean;

View File

@@ -18,6 +18,7 @@ const Trash01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon
* @param bytes - The size of the file in bytes.
* @returns A string representing the file size in a human-readable format.
*/
// eslint-disable-next-line react-refresh/only-export-components
export const getReadableFileSize = (bytes: number) => {
if (bytes === 0) return "0 KB";
@@ -388,6 +389,7 @@ const FileUploadList = (props: ComponentPropsWithRef<"ul">) => (
</ul>
);
// eslint-disable-next-line react-refresh/only-export-components
export const FileUpload = {
Root: FileUploadRoot,
List: FileUploadList,

View File

@@ -46,6 +46,7 @@ export interface PaginationRootProps {
onPageChange?: (page: number) => void;
}
// eslint-disable-next-line react-refresh/only-export-components
const PaginationRoot = ({ total, siblingCount = 1, page, onPageChange, children, style, className }: PaginationRootProps) => {
const createPaginationItems = useCallback((): PaginationItemType[] => {
const items: PaginationItemType[] = [];
@@ -202,6 +203,7 @@ interface TriggerProps {
ariaLabel?: string;
}
// eslint-disable-next-line react-refresh/only-export-components
const Trigger: FC<TriggerProps> = ({ children, style, className, asChild = false, direction, ariaLabel }) => {
const context = useContext(PaginationContext);
if (!context) {
@@ -247,8 +249,10 @@ const Trigger: FC<TriggerProps> = ({ children, style, className, asChild = false
);
};
// eslint-disable-next-line react-refresh/only-export-components
const PaginationPrevTrigger: FC<Omit<TriggerProps, "direction">> = (props) => <Trigger {...props} direction="prev" />;
// eslint-disable-next-line react-refresh/only-export-components
const PaginationNextTrigger: FC<Omit<TriggerProps, "direction">> = (props) => <Trigger {...props} direction="next" />;
interface PaginationItemRenderProps {
@@ -276,6 +280,7 @@ export interface PaginationItemProps {
asChild?: boolean;
}
// eslint-disable-next-line react-refresh/only-export-components
const PaginationItem = ({ value, isCurrent, children, style, className, ariaLabel, asChild = false }: PaginationItemProps) => {
const context = useContext(PaginationContext);
if (!context) {
@@ -338,6 +343,7 @@ interface PaginationEllipsisProps {
className?: string | (() => string);
}
// eslint-disable-next-line react-refresh/only-export-components
const PaginationEllipsis: FC<PaginationEllipsisProps> = ({ children, style, className }) => {
const computedClassName = typeof className === "function" ? className() : className;
@@ -352,6 +358,7 @@ interface PaginationContextComponentProps {
children: (pagination: PaginationContextType) => ReactNode;
}
// eslint-disable-next-line react-refresh/only-export-components
const PaginationContextComponent: FC<PaginationContextComponentProps> = ({ children }) => {
const context = useContext(PaginationContext);
if (!context) {

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
export * from "./avatar-add-button";
export * from "./avatar-company-icon";
export * from "./avatar-online-indicator";

View File

@@ -8,6 +8,7 @@ import { badgeTypes } from "./badge-types";
const CloseX: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faXmark} className={className} />;
// eslint-disable-next-line react-refresh/only-export-components
export const filledColors: Record<BadgeColors, { root: string; addon: string; addonButton: string }> = {
gray: {
root: "bg-utility-gray-50 text-utility-gray-700 ring-utility-gray-200",

View File

@@ -8,6 +8,7 @@ import {
import { cx, sortCx } from "@/utils/cx";
import { isReactComponent } from "@/utils/is-react-component";
// eslint-disable-next-line react-refresh/only-export-components
export const styles = sortCx({
common: {
root: [

View File

@@ -7,6 +7,7 @@ import { Tooltip } from "@/components/base/tooltip/tooltip";
import { cx } from "@/utils/cx";
import { isReactComponent } from "@/utils/is-react-component";
// eslint-disable-next-line react-refresh/only-export-components
export const styles = {
secondary:
"bg-primary text-fg-quaternary shadow-xs-skeumorphic ring-1 ring-primary ring-inset hover:bg-primary_hover hover:text-fg-quaternary_hover disabled:shadow-xs disabled:ring-disabled_subtle",

View File

@@ -5,6 +5,7 @@ import { Button as AriaButton, Link as AriaLink } from "react-aria-components";
import { cx, sortCx } from "@/utils/cx";
import { isReactComponent } from "@/utils/is-react-component";
// eslint-disable-next-line react-refresh/only-export-components
export const styles = sortCx({
common: {
root: [

View File

@@ -4,6 +4,7 @@ import { Button as AriaButton, Link as AriaLink } from "react-aria-components";
import { cx, sortCx } from "@/utils/cx";
import { AppleLogo, DribbleLogo, FacebookLogo, FigmaLogo, FigmaLogoOutlined, GoogleLogo, TwitterLogo } from "./social-logos";
// eslint-disable-next-line react-refresh/only-export-components
export const styles = sortCx({
common: {
root: "group relative inline-flex h-max cursor-pointer items-center justify-center font-semibold whitespace-nowrap outline-focus-ring transition duration-100 ease-linear before:absolute focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:stroke-fg-disabled disabled:text-fg-disabled disabled:*:text-fg-disabled",

View File

@@ -31,6 +31,7 @@ interface DropdownItemProps extends AriaMenuItemProps {
icon?: FC<{ className?: string }>;
}
// eslint-disable-next-line react-refresh/only-export-components
const DropdownItem = ({ label, children, addon, icon: Icon, unstyled, ...props }: DropdownItemProps) => {
if (unstyled) {
return <AriaMenuItem id={label} textValue={label} {...props} />;
@@ -91,6 +92,7 @@ const DropdownItem = ({ label, children, addon, icon: Icon, unstyled, ...props }
type DropdownMenuProps<T extends object> = AriaMenuProps<T>;
// eslint-disable-next-line react-refresh/only-export-components
const DropdownMenu = <T extends object>(props: DropdownMenuProps<T>) => {
return (
<AriaMenu
@@ -106,6 +108,7 @@ const DropdownMenu = <T extends object>(props: DropdownMenuProps<T>) => {
type DropdownPopoverProps = AriaPopoverProps;
// eslint-disable-next-line react-refresh/only-export-components
const DropdownPopover = (props: DropdownPopoverProps) => {
return (
<AriaPopover
@@ -127,10 +130,12 @@ const DropdownPopover = (props: DropdownPopoverProps) => {
);
};
// eslint-disable-next-line react-refresh/only-export-components
const DropdownSeparator = (props: AriaSeparatorProps) => {
return <AriaSeparator {...props} className={cx("my-1 h-px w-full bg-border-secondary", props.className)} />;
};
// eslint-disable-next-line react-refresh/only-export-components
const DropdownDotsButton = (props: AriaButtonProps & RefAttributes<HTMLButtonElement>) => {
return (
<AriaButton

View File

@@ -62,6 +62,7 @@ const detectCardType = (number: string) => {
/**
* Format the card number in groups of 4 digits (i.e. 1234 5678 9012 3456).
*/
// eslint-disable-next-line react-refresh/only-export-components
export const formatCardNumber = (number: string) => {
// Remove non-numeric characters
const cleaned = number.replace(/\D/g, "");

View File

@@ -15,6 +15,7 @@ const PinInputContext = createContext<PinInputContextType>({
disabled: false,
});
// eslint-disable-next-line react-refresh/only-export-components
export const usePinInputContext = () => {
const context = useContext(PinInputContext);

View File

@@ -47,6 +47,7 @@ interface SelectValueProps {
placeholderIcon?: FC | ReactNode;
}
// eslint-disable-next-line react-refresh/only-export-components
export const sizes = {
sm: { root: "py-2 px-3", shortcut: "pr-2.5" },
md: { root: "py-2.5 px-3.5", shortcut: "pr-3" },
@@ -106,6 +107,7 @@ const SelectValue = ({ isOpen, isFocused, isDisabled, size, placeholder, placeho
);
};
// eslint-disable-next-line react-refresh/only-export-components
export const SelectContext = createContext<{ size: "sm" | "md" }>({ size: "sm" });
const Select = ({ placeholder = "Select", placeholderIcon, size = "sm", children, items, label, hint, tooltip, className, ...rest }: SelectProps) => {

View File

@@ -1,7 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useRef, useState } from "react";
import {
faCalendarPlus,
faCheckCircle,
faClipboardQuestion,
faMicrophone,
faMicrophoneSlash,
@@ -50,7 +49,6 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
const setCallUcid = useSetAtom(sipCallUcidAtom);
const [postCallStage, setPostCallStage] = useState<PostCallStage | null>(null);
const [savedDisposition, setSavedDisposition] = useState<CallDisposition | null>(null);
const [appointmentOpen, setAppointmentOpen] = useState(false);
const [appointmentBookedDuringCall, setAppointmentBookedDuringCall] = useState(false);
const [transferOpen, setTransferOpen] = useState(false);
@@ -59,14 +57,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
// Capture direction at mount — survives through disposition stage
const callDirectionRef = useRef(callState === "ringing-out" ? "OUTBOUND" : "INBOUND");
// Track if the call was ever answered (reached 'active' state)
const [wasAnswered, setWasAnswered] = useState(callState === "active");
useEffect(() => {
if (callState === "active") {
// eslint-disable-next-line react-hooks/set-state-in-effect
setWasAnswered(true);
}
}, [callState]);
const wasAnsweredRef = useRef(callState === "active");
const firstName = lead?.contactName?.firstName ?? "";
const lastName = lead?.contactName?.lastName ?? "";
@@ -75,8 +66,6 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
const phoneDisplay = phone ? formatPhone(phone) : callerPhone || "Unknown";
const handleDisposition = async (disposition: CallDisposition, notes: string) => {
setSavedDisposition(disposition);
// Submit disposition to sidecar — handles Ozonetel ACW release
if (callUcid) {
apiClient
@@ -93,12 +82,8 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
.catch((err) => console.warn("Disposition failed:", err));
}
if (disposition === "APPOINTMENT_BOOKED") {
setPostCallStage("appointment");
setAppointmentOpen(true);
} else if (disposition === "FOLLOW_UP_SCHEDULED") {
setPostCallStage("follow-up");
// Create follow-up
// Side effects per disposition type
if (disposition === "FOLLOW_UP_SCHEDULED") {
try {
await apiClient.graphql(
`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`,
@@ -109,6 +94,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
status: "PENDING",
assignedAgent: null,
priority: "NORMAL",
// eslint-disable-next-line react-hooks/purity
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
},
},
@@ -118,27 +104,23 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
} catch {
notify.info("Follow-up", "Could not auto-create follow-up");
}
setPostCallStage("done");
} else {
notify.success("Call Logged", `Disposition: ${disposition.replace(/_/g, " ").toLowerCase()}`);
setPostCallStage("done");
}
// Disposition is the last step — return to worklist immediately
notify.success("Call Logged", `Disposition: ${disposition.replace(/_/g, " ").toLowerCase()}`);
handleReset();
};
const handleAppointmentSaved = () => {
setAppointmentOpen(false);
notify.success("Appointment Booked", "Payment link will be sent to the patient");
// If booked during active call, don't skip to 'done' — wait for disposition after call ends
if (callState === "active") {
setAppointmentBookedDuringCall(true);
} else {
setPostCallStage("done");
}
};
const handleReset = () => {
setPostCallStage(null);
setSavedDisposition(null);
setCallState("idle");
setCallerNumber(null);
setCallUcid(null);
@@ -209,7 +191,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
}
// Skip disposition for unanswered calls (ringing-in → ended without ever reaching active)
if (!wasAnswered && postCallStage === null && (callState === "ended" || callState === "failed")) {
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="mb-2 size-6 text-fg-quaternary" />
@@ -224,63 +206,48 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
// Post-call flow takes priority over active state (handles race between hangup + SIP ended event)
if (postCallStage !== null || callState === "ended" || callState === "failed") {
// Done state
if (postCallStage === "done") {
return (
<div className="border-success rounded-xl border bg-success-primary p-4 text-center">
<FontAwesomeIcon icon={faCheckCircle} className="mb-2 size-8 text-fg-success-primary" />
<p className="text-sm font-semibold text-success-primary">Call Completed</p>
<p className="mt-1 text-xs text-tertiary">{savedDisposition ? savedDisposition.replace(/_/g, " ").toLowerCase() : "logged"}</p>
<Button size="sm" color="secondary" className="mt-3" onClick={handleReset}>
Back to Worklist
</Button>
</div>
);
}
// Appointment booking after disposition
if (postCallStage === "appointment") {
return (
<>
<div className="rounded-xl border border-brand bg-brand-primary p-4 text-center">
<FontAwesomeIcon icon={faCalendarPlus} className="mb-2 size-6 text-fg-brand-primary" />
<p className="text-sm font-semibold text-brand-secondary">Booking Appointment</p>
<p className="mt-1 text-xs text-tertiary">for {fullName || phoneDisplay}</p>
</div>
<AppointmentForm
isOpen={appointmentOpen}
onOpenChange={(open) => {
setAppointmentOpen(open);
if (!open) setPostCallStage("done");
}}
callerNumber={callerPhone}
leadName={fullName || null}
leadId={lead?.id ?? null}
onSaved={handleAppointmentSaved}
/>
</>
);
}
// Disposition form
// Disposition form + enquiry access
return (
<div className="rounded-xl border border-secondary bg-primary p-4">
<div className="mb-3 flex items-center gap-2">
<div className="flex size-8 items-center justify-center rounded-full bg-secondary">
<FontAwesomeIcon icon={faPhoneHangup} className="size-3.5 text-fg-quaternary" />
</div>
<div>
<p className="text-sm font-semibold text-primary">Call Ended {fullName || phoneDisplay}</p>
<p className="text-xs text-tertiary">{formatDuration(callDuration)} · Log this call</p>
<>
<div className="rounded-xl border border-secondary bg-primary p-4">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex size-8 items-center justify-center rounded-full bg-secondary">
<FontAwesomeIcon icon={faPhoneHangup} className="size-3.5 text-fg-quaternary" />
</div>
<div>
<p className="text-sm font-semibold text-primary">Call Ended {fullName || phoneDisplay}</p>
<p className="text-xs text-tertiary">{formatDuration(callDuration)} · Log this call</p>
</div>
</div>
<Button
size="sm"
color="secondary"
// eslint-disable-next-line @typescript-eslint/no-explicit-any
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
onClick={() => setEnquiryOpen(!enquiryOpen)}
>
Enquiry
</Button>
</div>
<DispositionForm onSubmit={handleDisposition} defaultDisposition={appointmentBookedDuringCall ? "APPOINTMENT_BOOKED" : null} />
</div>
<DispositionForm onSubmit={handleDisposition} defaultDisposition={appointmentBookedDuringCall ? "APPOINTMENT_BOOKED" : null} />
</div>
<EnquiryForm
isOpen={enquiryOpen}
onOpenChange={setEnquiryOpen}
callerPhone={callerPhone}
onSaved={() => {
setEnquiryOpen(false);
notify.success("Enquiry Logged");
}}
/>
</>
);
}
// Active call
if (callState === "active") {
wasAnsweredRef.current = true;
return (
<div className="rounded-xl border border-brand bg-primary p-4">
<div className="flex items-center justify-between">
@@ -323,7 +290,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
onClick={() => {
const action = recordingPaused ? "unPause" : "pause";
if (callUcid) apiClient.post("/api/ozonetel/recording", { ucid: callUcid, action }).catch(() => {});
setRecordingPaused((prev) => !prev);
setRecordingPaused(!recordingPaused);
}}
title={recordingPaused ? "Resume Recording" : "Pause Recording"}
className={cx(
@@ -339,25 +306,34 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
{/* Text+Icon primary actions */}
<Button
size="sm"
color="secondary"
iconLeading={<FontAwesomeIcon icon={faCalendarPlus} data-icon className="size-3.5" />}
onClick={() => setAppointmentOpen(true)}
color={appointmentOpen ? "primary" : "secondary"}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
onClick={() => {
setAppointmentOpen(!appointmentOpen);
setEnquiryOpen(false);
}}
>
Book Appt
</Button>
<Button
size="sm"
color="secondary"
iconLeading={<FontAwesomeIcon icon={faClipboardQuestion} data-icon className="size-3.5" />}
onClick={() => setEnquiryOpen((prev) => !prev)}
color={enquiryOpen ? "primary" : "secondary"}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
onClick={() => {
setEnquiryOpen(!enquiryOpen);
setAppointmentOpen(false);
}}
>
Enquiry
</Button>
<Button
size="sm"
color="secondary"
iconLeading={<FontAwesomeIcon icon={faPhoneArrowRight} data-icon className="size-3.5" />}
onClick={() => setTransferOpen((prev) => !prev)}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
onClick={() => setTransferOpen(!transferOpen)}
>
Transfer
</Button>
@@ -365,7 +341,8 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
size="sm"
color="primary-destructive"
className="ml-auto"
iconLeading={<FontAwesomeIcon icon={faPhoneHangup} data-icon className="size-3.5" />}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneHangup} className={className} {...rest} />}
onClick={() => {
hangup();
setPostCallStage("disposition");
@@ -395,6 +372,8 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
callerNumber={callerPhone}
leadName={fullName || null}
leadId={lead?.id ?? null}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
patientId={(lead as any)?.patientId ?? null}
onSaved={handleAppointmentSaved}
/>

View File

@@ -45,7 +45,11 @@ export const AiChatPanel = ({ callerContext, role = "cc-agent" }: AiChatPanelPro
const inputRef = useRef<HTMLInputElement>(null);
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
// Scroll within the messages container only — don't scroll the parent panel
const el = messagesEndRef.current;
if (el?.parentElement) {
el.parentElement.scrollTop = el.parentElement.scrollHeight;
}
}, []);
useEffect(() => {

View File

@@ -20,7 +20,7 @@ type ExistingAppointment = {
doctorId?: string;
department: string;
reasonForVisit?: string;
appointmentStatus: string;
status: string;
};
type AppointmentFormProps = {
@@ -29,6 +29,7 @@ type AppointmentFormProps = {
callerNumber?: string | null;
leadName?: string | null;
leadId?: string | null;
patientId?: string | null;
onSaved?: () => void;
existingAppointment?: ExistingAppointment | null;
};
@@ -63,7 +64,7 @@ const timeSlotItems = [
const formatDeptLabel = (dept: string) => dept.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName, leadId, onSaved, existingAppointment }: AppointmentFormProps) => {
export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName, leadId, patientId, onSaved, existingAppointment }: AppointmentFormProps) => {
const isEditMode = !!existingAppointment;
// Doctor data from platform
@@ -103,6 +104,7 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
// Fetch doctors on mount
useEffect(() => {
if (!isOpen) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
apiClient
.graphql<{ doctors: { edges: Array<{ node: any }> } }>(
`{ doctors(first: 50) { edges { node {
@@ -129,17 +131,18 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
}
setLoadingSlots(true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
apiClient
.graphql<{ appointments: { edges: Array<{ node: any }> } }>(
`{ appointments(filter: {
doctorId: { eq: "${doctor}" },
scheduledAt: { gte: "${date}T00:00:00", lte: "${date}T23:59:59" }
}) { edges { node { id scheduledAt durationMin appointmentStatus } } } }`,
}) { edges { node { id scheduledAt durationMin status } } } }`,
)
.then((data) => {
// Filter out cancelled/completed appointments client-side
const activeAppointments = data.appointments.edges.filter((e) => {
const status = e.node.appointmentStatus;
const status = e.node.status;
return status !== "CANCELLED" && status !== "COMPLETED" && status !== "NO_SHOW";
});
const slots = activeAppointments.map((e) => {
@@ -198,7 +201,7 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
if (isEditMode && existingAppointment) {
// Update existing appointment
await apiClient.graphql(
`mutation UpdateAppointment($id: ID!, $data: AppointmentUpdateInput!) {
`mutation UpdateAppointment($id: UUID!, $data: AppointmentUpdateInput!) {
updateAppointment(id: $id, data: $data) { id }
}`,
{
@@ -214,22 +217,6 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
);
notify.success("Appointment Updated");
} else {
// Double-check slot availability before booking
const checkResult = await apiClient.graphql<{ appointments: { edges: Array<{ node: { appointmentStatus: string } }> } }>(
`{ appointments(filter: {
doctorId: { eq: "${doctor}" },
scheduledAt: { gte: "${date}T${timeSlot}:00", lte: "${date}T${timeSlot}:00" }
}) { edges { node { appointmentStatus } } } }`,
);
const activeBookings = checkResult.appointments.edges.filter(
(e) => e.node.appointmentStatus !== "CANCELLED" && e.node.appointmentStatus !== "NO_SHOW",
);
if (activeBookings.length > 0) {
setError("This slot was just booked by someone else. Please select a different time.");
setIsSaving(false);
return;
}
// Create appointment
await apiClient.graphql(
`mutation CreateAppointment($data: AppointmentCreateInput!) {
@@ -240,12 +227,12 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
scheduledAt,
durationMin: 30,
appointmentType: "CONSULTATION",
appointmentStatus: "SCHEDULED",
status: "SCHEDULED",
doctorName: selectedDoctor?.name ?? "",
department: selectedDoctor?.department ?? "",
doctorId: doctor,
reasonForVisit: chiefComplaint || null,
...(leadId ? { patientId: leadId } : {}),
...(patientId ? { patientId } : {}),
},
},
);
@@ -254,7 +241,7 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
if (leadId) {
await apiClient
.graphql(
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
`mutation UpdateLead($id: UUID!, $data: LeadUpdateInput!) {
updateLead(id: $id, data: $data) { id }
}`,
{
@@ -283,12 +270,12 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
setIsSaving(true);
try {
await apiClient.graphql(
`mutation CancelAppointment($id: ID!, $data: AppointmentUpdateInput!) {
`mutation CancelAppointment($id: UUID!, $data: AppointmentUpdateInput!) {
updateAppointment(id: $id, data: $data) { id }
}`,
{
id: existingAppointment.id,
data: { appointmentStatus: "CANCELLED" },
data: { status: "CANCELLED" },
},
);
notify.success("Appointment Cancelled");

View File

@@ -106,7 +106,9 @@ export const CallWidget = () => {
const [disposition, setDisposition] = useState<CallDisposition | null>(null);
const [notes, setNotes] = useState("");
const [lastDuration, setLastDuration] = useState(0);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [matchedLead, setMatchedLead] = useState<any>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [leadActivities, setLeadActivities] = useState<any[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [isAppointmentOpen, setIsAppointmentOpen] = useState(false);
@@ -214,7 +216,7 @@ export const CallWidget = () => {
if (newStatus) {
await apiClient
.graphql(
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
`mutation UpdateLead($id: UUID!, $data: LeadUpdateInput!) {
updateLead(id: $id, data: $data) { id }
}`,
{
@@ -382,6 +384,7 @@ export const CallWidget = () => {
{leadActivities.length > 0 && (
<div className="space-y-1">
<div className="text-xs font-semibold text-tertiary">Recent Activity</div>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{leadActivities.slice(0, 3).map((a: any, i: number) => (
<div key={i} className="text-xs text-quaternary">
{a.activityType?.replace(/_/g, " ")}: {a.summary}

View File

@@ -10,6 +10,7 @@ import { useSip } from "@/providers/sip-provider";
import { setOutboundPending } from "@/state/sip-manager";
import { sipCallStateAtom, sipCallUcidAtom, sipCallerNumberAtom } from "@/state/sip-state";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const Phone01: FC<{ className?: string } & Record<string, any>> = ({ className, ...rest }) => (
<FontAwesomeIcon icon={faPhone} className={className} {...rest} />
);

View File

@@ -1,13 +1,15 @@
import { useEffect, useState } from "react";
import { faSparkles, faUser } from "@fortawesome/pro-duotone-svg-icons";
import { faCalendarCheck, faSparkles, faUser } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Badge } from "@/components/base/badges/badges";
import { useCallAssist } from "@/hooks/use-call-assist";
import { apiClient } from "@/lib/api-client";
import { formatPhone, formatShortDate } from "@/lib/format";
import { faIcon } from "@/lib/icon-wrapper";
import type { Lead, LeadActivity } from "@/types/entities";
import { cx } from "@/utils/cx";
import { AiChatPanel } from "./ai-chat-panel";
import { LiveTranscript } from "./live-transcript";
const CalendarCheck = faIcon(faCalendarCheck);
type ContextTab = "ai" | "lead360";
@@ -19,22 +21,15 @@ interface ContextPanelProps {
callUcid?: string | null;
}
export const ContextPanel = ({ selectedLead, activities, callerPhone, isInCall, callUcid }: ContextPanelProps) => {
export const ContextPanel = ({ selectedLead, activities, callerPhone }: ContextPanelProps) => {
const [activeTab, setActiveTab] = useState<ContextTab>("ai");
// Auto-switch to lead 360 when a lead is selected
useEffect(() => {
if (selectedLead) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setActiveTab("lead360");
}
}, [selectedLead?.id]);
const {
transcript,
suggestions,
connected: assistConnected,
} = useCallAssist(isInCall ?? false, callUcid ?? null, selectedLead?.id ?? null, callerPhone ?? null);
const [prevLeadId, setPrevLeadId] = useState(selectedLead?.id);
if (prevLeadId !== selectedLead?.id) {
setPrevLeadId(selectedLead?.id);
if (selectedLead) setActiveTab("lead360");
}
const callerContext = selectedLead
? {
@@ -68,27 +63,65 @@ export const ContextPanel = ({ selectedLead, activities, callerPhone, isInCall,
)}
>
<FontAwesomeIcon icon={faUser} className="size-3.5" />
Lead 360
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{(selectedLead as any)?.patientId ? "Patient 360" : "Lead 360"}
</button>
</div>
{/* Tab content */}
<div className="flex-1 overflow-y-auto">
{activeTab === "ai" &&
(isInCall ? (
<LiveTranscript transcript={transcript} suggestions={suggestions} connected={assistConnected} />
) : (
<div className="flex h-full flex-col p-4">
<AiChatPanel callerContext={callerContext} />
</div>
))}
{activeTab === "lead360" && <Lead360Tab lead={selectedLead} activities={activities} />}
</div>
{activeTab === "ai" && (
<div className="flex flex-1 flex-col overflow-hidden p-4">
<AiChatPanel callerContext={callerContext} />
</div>
)}
{activeTab === "lead360" && (
<div className="flex-1 overflow-y-auto">
<Lead360Tab lead={selectedLead} activities={activities} />
</div>
)}
</div>
);
};
const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadActivity[] }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [patientData, setPatientData] = useState<any>(null);
const [loadingPatient, setLoadingPatient] = useState(false);
// Fetch patient data when lead has a patientId (returning patient)
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const patientId = (lead as any)?.patientId;
if (!patientId) {
setPatientData(null);
return;
}
setLoadingPatient(true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
apiClient
.graphql<{ patients: { edges: Array<{ node: any }> } }>(
`query GetPatient($id: UUID!) { patients(filter: { id: { eq: $id } }) { edges { node {
id fullName { firstName lastName } dateOfBirth gender patientType
phones { primaryPhoneNumber } emails { primaryEmail }
appointments(first: 5, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
id scheduledAt status doctorName department reasonForVisit appointmentType
} } }
calls(first: 5, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id callStatus disposition direction startedAt durationSec agentName
} } }
} } } }`,
{ id: patientId },
{ silent: true },
)
.then((data) => {
setPatientData(data.patients.edges[0]?.node ?? null);
})
.catch(() => setPatientData(null))
.finally(() => setLoadingPatient(false));
// eslint-disable-next-line @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps
}, [(lead as any)?.patientId]);
if (!lead) {
return (
<div className="flex flex-col items-center justify-center px-4 py-16 text-center">
@@ -109,6 +142,18 @@ const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadA
.sort((a, b) => new Date(b.occurredAt ?? b.createdAt ?? "").getTime() - new Date(a.occurredAt ?? a.createdAt ?? "").getTime())
.slice(0, 10);
const isReturning = !!patientData;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const appointments = patientData?.appointments?.edges?.map((e: any) => e.node) ?? [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const patientCalls = patientData?.calls?.edges?.map((e: any) => e.node) ?? [];
const patientAge = patientData?.dateOfBirth
? // eslint-disable-next-line react-hooks/purity
Math.floor((Date.now() - new Date(patientData.dateOfBirth).getTime()) / (365.25 * 24 * 60 * 60 * 1000))
: null;
const patientGender = patientData?.gender === "MALE" ? "M" : patientData?.gender === "FEMALE" ? "F" : null;
return (
<div className="space-y-4 p-4">
{/* Profile */}
@@ -117,6 +162,16 @@ const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadA
{phone && <p className="text-sm text-secondary">{formatPhone(phone)}</p>}
{email && <p className="text-xs text-tertiary">{email}</p>}
<div className="mt-2 flex flex-wrap gap-1.5">
{isReturning && (
<Badge size="sm" color="brand" type="pill-color">
Returning Patient
</Badge>
)}
{patientAge !== null && patientGender && (
<Badge size="sm" color="gray" type="pill-color">
{patientAge}y · {patientGender}
</Badge>
)}
{lead.leadStatus && (
<Badge size="sm" color="brand">
{lead.leadStatus}
@@ -134,9 +189,69 @@ const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadA
)}
</div>
{lead.interestedService && <p className="mt-2 text-sm text-secondary">Interested in: {lead.interestedService}</p>}
{lead.leadScore !== null && lead.leadScore !== undefined && <p className="text-xs text-tertiary">Lead score: {lead.leadScore}</p>}
</div>
{/* Returning patient: Appointments */}
{loadingPatient && <p className="text-xs text-tertiary">Loading patient details...</p>}
{isReturning && appointments.length > 0 && (
<div>
<h4 className="mb-2 text-xs font-bold text-secondary uppercase">Appointments</h4>
<div className="space-y-2">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{appointments.map((appt: any) => {
const statusColors: Record<string, "success" | "brand" | "warning" | "error" | "gray"> = {
COMPLETED: "success",
SCHEDULED: "brand",
CONFIRMED: "brand",
CANCELLED: "error",
NO_SHOW: "warning",
};
return (
<div key={appt.id} className="flex items-start gap-2 rounded-lg bg-secondary p-2">
<CalendarCheck className="mt-0.5 size-3.5 shrink-0 text-fg-brand-primary" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-primary">
{appt.doctorName ?? "Doctor"} · {appt.department ?? ""}
</span>
{appt.status && (
<Badge size="sm" color={statusColors[appt.status] ?? "gray"}>
{appt.status.toLowerCase()}
</Badge>
)}
</div>
<p className="text-[10px] text-quaternary">
{appt.scheduledAt ? formatShortDate(appt.scheduledAt) : ""}
{appt.reasonForVisit ? `${appt.reasonForVisit}` : ""}
</p>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Returning patient: Recent calls */}
{isReturning && patientCalls.length > 0 && (
<div>
<h4 className="mb-2 text-xs font-bold text-secondary uppercase">Recent Calls</h4>
<div className="space-y-1">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{patientCalls.map((call: any) => (
<div key={call.id} className="flex items-center gap-2 text-xs">
<div className="mt-0.5 size-1.5 shrink-0 rounded-full bg-fg-quaternary" />
<span className="text-primary">
{call.direction === "INBOUND" ? "Inbound" : "Outbound"}
{call.disposition ? `${call.disposition.replace(/_/g, " ").toLowerCase()}` : ""}
</span>
<span className="ml-auto text-quaternary">{call.startedAt ? formatShortDate(call.startedAt) : ""}</span>
</div>
))}
</div>
</div>
)}
{/* AI Insight */}
{(lead.aiSummary || lead.aiSuggestedAction) && (
<div className="rounded-lg bg-brand-primary p-3">

View File

@@ -45,6 +45,7 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
useEffect(() => {
if (!isOpen) return;
apiClient
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.graphql<{ doctors: { edges: Array<{ node: any }> } }>(
`{ doctors(first: 50) { edges { node {
id name fullName { firstName lastName } department

View File

@@ -53,7 +53,7 @@ export const CampaignEditSlideout = ({ isOpen, onOpenChange, campaign, onSaved }
const budgetMicros = budget ? Number(budget) * 1_000_000 : null;
await apiClient.graphql(
`mutation UpdateCampaign($id: ID!, $data: CampaignUpdateInput!) {
`mutation UpdateCampaign($id: UUID!, $data: CampaignUpdateInput!) {
updateCampaign(id: $id, data: $data) { id }
}`,
{

View File

@@ -2,6 +2,7 @@ import type { HTMLAttributes, SVGProps } from "react";
import { useId } from "react";
import { cx } from "@/utils/cx";
// eslint-disable-next-line react-refresh/only-export-components
export const getStarProgress = (starPosition: number, rating: number, maxRating: number = 5) => {
// Ensure rating is between 0 and 5
const clampedRating = Math.min(Math.max(rating, 0), maxRating);

View File

@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import { type ReactNode, useEffect } from "react";
import { useLocation } from "react-router";
import { CallWidget } from "@/components/call-desk/call-widget";
import { useAuth } from "@/providers/auth-provider";
@@ -13,11 +13,30 @@ export const AppShell = ({ children }: AppShellProps) => {
const { pathname } = useLocation();
const { isCCAgent } = useAuth();
// Heartbeat: keep agent session alive in Redis (CC agents only)
useEffect(() => {
if (!isCCAgent) return;
const beat = () => {
const token = localStorage.getItem("helix_access_token");
if (token) {
const apiUrl = import.meta.env.VITE_API_URL ?? "http://localhost:4100";
fetch(`${apiUrl}/auth/heartbeat`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
}).catch(() => {});
}
};
const interval = setInterval(beat, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [isCCAgent]);
return (
<SipProvider>
<div className="flex min-h-screen bg-primary">
<div className="flex h-screen bg-primary">
<Sidebar activeUrl={pathname} />
<main className="flex flex-1 flex-col overflow-auto">{children}</main>
<main className="flex flex-1 flex-col overflow-hidden">{children}</main>
{isCCAgent && pathname !== "/" && pathname !== "/call-desk" && <CallWidget />}
</div>
</SipProvider>

View File

@@ -2,16 +2,20 @@ import { useState } from "react";
import {
faArrowRightFromBracket,
faBullhorn,
faCalendarCheck,
faChartLine,
faChartMixed,
faChevronLeft,
faChevronRight,
faClockRotateLeft,
faCommentDots,
faFileAudio,
faGear,
faGrid2,
faHospitalUser,
faPhone,
faPlug,
faPhoneMissed,
faTowerBroadcast,
faUsers,
} from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -38,12 +42,16 @@ const IconGrid2 = faIcon(faGrid2);
const IconBullhorn = faIcon(faBullhorn);
const IconCommentDots = faIcon(faCommentDots);
const IconChartMixed = faIcon(faChartMixed);
const IconPlug = faIcon(faPlug);
const IconGear = faIcon(faGear);
const IconPhone = faIcon(faPhone);
const IconClockRewind = faIcon(faClockRotateLeft);
const IconUsers = faIcon(faUsers);
const IconHospitalUser = faIcon(faHospitalUser);
const IconCalendarCheck = faIcon(faCalendarCheck);
const IconTowerBroadcast = faIcon(faTowerBroadcast);
const IconChartLine = faIcon(faChartLine);
const IconFileAudio = faIcon(faFileAudio);
const IconPhoneMissed = faIcon(faPhoneMissed);
type NavSection = {
label: string;
@@ -53,21 +61,26 @@ type NavSection = {
const getNavSections = (role: string): NavSection[] => {
if (role === "admin") {
return [
{ label: "Overview", items: [{ label: "Team Dashboard", href: "/", icon: IconGrid2 }] },
{
label: "Management",
label: "Supervisor",
items: [
{ label: "Campaigns", href: "/campaigns", icon: IconBullhorn },
{ label: "Analytics", href: "/reports", icon: IconChartMixed },
{ label: "Dashboard", href: "/", icon: IconGrid2 },
{ label: "Team Performance", href: "/team-performance", icon: IconChartLine },
{ label: "Live Call Monitor", href: "/live-monitor", icon: IconTowerBroadcast },
],
},
{
label: "Admin",
label: "Data & Reports",
items: [
{ label: "Integrations", href: "/integrations", icon: IconPlug },
{ label: "Settings", href: "/settings", icon: IconGear },
{ label: "Lead Master", href: "/leads", icon: IconUsers },
{ label: "Patient Master", href: "/patients", icon: IconHospitalUser },
{ label: "Appointment Master", href: "/appointments", icon: IconCalendarCheck },
{ label: "Call Log Master", href: "/call-history", icon: IconClockRewind },
{ label: "Call Recordings", href: "/call-recordings", icon: IconFileAudio },
{ label: "Missed Calls", href: "/missed-calls", icon: IconPhoneMissed },
],
},
{ label: "Admin", items: [{ label: "Settings", href: "/settings", icon: IconGear }] },
];
}
@@ -79,6 +92,7 @@ const getNavSections = (role: string): NavSection[] => {
{ label: "Call Desk", href: "/", icon: IconPhone },
{ label: "Call History", href: "/call-history", icon: IconClockRewind },
{ label: "Patients", href: "/patients", icon: IconHospitalUser },
{ label: "Appointments", href: "/appointments", icon: IconCalendarCheck },
{ label: "My Performance", href: "/my-performance", icon: IconChartMixed },
],
},
@@ -92,6 +106,7 @@ const getNavSections = (role: string): NavSection[] => {
{ label: "Lead Workspace", href: "/", icon: IconGrid2 },
{ label: "All Leads", href: "/leads", icon: IconUsers },
{ label: "Patients", href: "/patients", icon: IconHospitalUser },
{ label: "Appointments", href: "/appointments", icon: IconCalendarCheck },
{ label: "Campaigns", href: "/campaigns", icon: IconBullhorn },
{ label: "Outreach", href: "/outreach", icon: IconCommentDots },
],

View File

@@ -17,6 +17,7 @@ export const BoxIllustration = ({ size = "lg", ...otherProps }: IllustrationProp
return <Pattern {...otherProps} />;
};
// eslint-disable-next-line react-refresh/only-export-components
export const sm = ({
className,
svgClassName,
@@ -98,6 +99,7 @@ export const sm = ({
);
};
// eslint-disable-next-line react-refresh/only-export-components
export const md = ({
className,
svgClassName,
@@ -179,6 +181,7 @@ export const md = ({
);
};
// eslint-disable-next-line react-refresh/only-export-components
export const lg = ({
className,
svgClassName,

View File

@@ -17,6 +17,7 @@ export const CloudIllustration = ({ size = "lg", ...otherProps }: IllustrationPr
return <Pattern {...otherProps} />;
};
// eslint-disable-next-line react-refresh/only-export-components
export const sm = ({
className,
svgClassName,
@@ -100,6 +101,7 @@ export const sm = ({
);
};
// eslint-disable-next-line react-refresh/only-export-components
export const md = ({
className,
svgClassName,
@@ -184,6 +186,7 @@ export const md = ({
);
};
// eslint-disable-next-line react-refresh/only-export-components
export const lg = ({
className,
svgClassName,

View File

@@ -17,6 +17,7 @@ export const CreditCardIllustration = ({ size = "lg", ...otherProps }: Illustrat
return <Pattern {...otherProps} />;
};
// eslint-disable-next-line react-refresh/only-export-components
export const sm = ({
className,
svgClassName,
@@ -112,6 +113,7 @@ export const sm = ({
);
};
// eslint-disable-next-line react-refresh/only-export-components
export const md = ({
className,
svgClassName,
@@ -205,6 +207,7 @@ export const md = ({
);
};
// eslint-disable-next-line react-refresh/only-export-components
export const lg = ({
className,
svgClassName,

View File

@@ -17,6 +17,7 @@ export const DocumentsIllustration = ({ size = "lg", ...otherProps }: Illustrati
return <Pattern {...otherProps} />;
};
// eslint-disable-next-line react-refresh/only-export-components
export const sm = ({
className,
svgClassName,
@@ -189,6 +190,7 @@ export const sm = ({
);
};
// eslint-disable-next-line react-refresh/only-export-components
export const md = ({
className,
svgClassName,
@@ -361,6 +363,7 @@ export const md = ({
);
};
// eslint-disable-next-line react-refresh/only-export-components
export const lg = ({
className,
svgClassName,

View File

@@ -67,8 +67,11 @@ export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
debounceRef.current = setTimeout(async () => {
try {
const data = await apiClient.get<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
leads: Array<any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
patients: Array<any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
appointments: Array<any>;
}>(`/api/search?q=${encodeURIComponent(query)}`, { silent: true });
@@ -102,7 +105,7 @@ export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
id: a.id,
type: "appointment",
title: a.doctorName ?? "Appointment",
subtitle: [a.department, date, a.appointmentStatus].filter(Boolean).join(" · "),
subtitle: [a.department, date, a.status].filter(Boolean).join(" · "),
});
}

View File

@@ -87,11 +87,13 @@ export const useWorklist = (): UseWorklistResult => {
}
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const json = await apiClient.get<any>("/api/worklist", { silent: true });
// Transform platform field shapes to frontend types
const transformed: WorklistData = {
...json,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
marketingLeads: (json.marketingLeads ?? []).map((lead: any) => ({
...lead,
leadSource: lead.source ?? lead.leadSource,
@@ -101,6 +103,7 @@ export const useWorklist = (): UseWorklistResult => {
: lead.contactPhone,
contactEmail: lead.contactEmail?.primaryEmail ? [{ address: lead.contactEmail.primaryEmail }] : lead.contactEmail,
})),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
missedCalls: (json.missedCalls ?? []).map((call: any) => ({
...call,
callDirection: call.direction ?? call.callDirection,
@@ -109,6 +112,7 @@ export const useWorklist = (): UseWorklistResult => {
? [{ number: call.callerNumber.primaryPhoneNumber, callingCode: "+91" }]
: call.callerNumber,
})),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
followUps: (json.followUps ?? []).map((fu: any) => ({
...fu,
followUpType: fu.typeCustom ?? fu.followUpType,

View File

@@ -6,7 +6,9 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
// Creates a wrapper component that passes all props (including data-icon)
// to FontAwesomeIcon. This is needed because the Button component uses
// data-icon CSS selectors for sizing.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const faIcon = (icon: IconDefinition): FC<Record<string, any>> => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const IconComponent: FC<Record<string, any>> = (props) => createElement(FontAwesomeIcon, { icon, ...props });
IconComponent.displayName = `FAIcon(${icon.iconName})`;
return IconComponent;

View File

@@ -67,6 +67,7 @@ export class SIPClient {
this.currentSession = session;
// Extract caller number and UCID — try event request first, then session
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sipRequest = (data as any).request ?? (session as any)._request ?? null;
const callerNumber = this.extractCallerNumber(session, sipRequest);
const ucid = sipRequest?.getHeader ? (sipRequest.getHeader("X-UCID") ?? null) : null;
@@ -228,8 +229,10 @@ export class SIPClient {
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private extractCallerNumber(session: RTCSession, sipRequest?: any): string {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const request = sipRequest ?? (session.direction === "incoming" ? (session as any)._request : null);
if (request) {
// Ozonetel sends the real caller number in X-CALLERNO header

View File

@@ -2,12 +2,16 @@
// Platform remaps field names during sync — this layer normalizes them
import type { Ad, Call, Campaign, FollowUp, Lead, LeadActivity, Patient } from "@/types/entities";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type PlatformNode = Record<string, any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function extractEdges(data: any, entityName: string): PlatformNode[] {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return data?.[entityName]?.edges?.map((e: any) => e.node) ?? [];
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function transformLeads(data: any): Lead[] {
return extractEdges(data, "leads").map((n) => ({
id: n.id,
@@ -47,6 +51,7 @@ export function transformLeads(data: any): Lead[] {
}));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function transformCampaigns(data: any): Campaign[] {
return extractEdges(data, "campaigns").map((n) => ({
id: n.id,
@@ -71,6 +76,7 @@ export function transformCampaigns(data: any): Campaign[] {
}));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function transformAds(data: any): Ad[] {
return extractEdges(data, "ads").map((n) => ({
id: n.id,
@@ -92,6 +98,7 @@ export function transformAds(data: any): Ad[] {
}));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function transformFollowUps(data: any): FollowUp[] {
return extractEdges(data, "followUps").map((n) => ({
id: n.id,
@@ -110,6 +117,7 @@ export function transformFollowUps(data: any): FollowUp[] {
}));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function transformLeadActivities(data: any): LeadActivity[] {
return extractEdges(data, "leadActivities").map((n) => ({
id: n.id,
@@ -128,6 +136,7 @@ export function transformLeadActivities(data: any): LeadActivity[] {
}));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function transformCalls(data: any): Call[] {
return extractEdges(data, "calls").map((n) => ({
id: n.id,
@@ -148,6 +157,7 @@ export function transformCalls(data: any): Call[] {
}));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function transformPatients(data: any): Patient[] {
return extractEdges(data, "patients").map((n) => ({
id: n.id,

View File

@@ -7,13 +7,17 @@ import { AuthGuard } from "@/components/layout/auth-guard";
import { RoleRouter } from "@/components/layout/role-router";
import { AgentDetailPage } from "@/pages/agent-detail";
import { AllLeadsPage } from "@/pages/all-leads";
import { AppointmentsPage } from "@/pages/appointments";
import { CallDeskPage } from "@/pages/call-desk";
import { CallHistoryPage } from "@/pages/call-history";
import { CallRecordingsPage } from "@/pages/call-recordings";
import { CampaignDetailPage } from "@/pages/campaign-detail";
import { CampaignsPage } from "@/pages/campaigns";
import { FollowUpsPage } from "@/pages/follow-ups-page";
import { IntegrationsPage } from "@/pages/integrations";
import { LiveMonitorPage } from "@/pages/live-monitor";
import { LoginPage } from "@/pages/login";
import { MissedCallsPage } from "@/pages/missed-calls";
import { MyPerformancePage } from "@/pages/my-performance";
import { NotFound } from "@/pages/not-found";
import { OutreachPage } from "@/pages/outreach";
@@ -22,6 +26,7 @@ import { PatientsPage } from "@/pages/patients";
import { ReportsPage } from "@/pages/reports";
import { SettingsPage } from "@/pages/settings";
import { TeamDashboardPage } from "@/pages/team-dashboard";
import { TeamPerformancePage } from "@/pages/team-performance";
import { AuthProvider } from "@/providers/auth-provider";
import { DataProvider } from "@/providers/data-provider";
import { RouteProvider } from "@/providers/router-provider";
@@ -55,6 +60,11 @@ createRoot(document.getElementById("root")!).render(
<Route path="/my-performance" element={<MyPerformancePage />} />
<Route path="/call-desk" element={<CallDeskPage />} />
<Route path="/patients" element={<PatientsPage />} />
<Route path="/appointments" element={<AppointmentsPage />} />
<Route path="/team-performance" element={<TeamPerformancePage />} />
<Route path="/live-monitor" element={<LiveMonitorPage />} />
<Route path="/call-recordings" element={<CallRecordingsPage />} />
<Route path="/missed-calls" element={<MissedCallsPage />} />
<Route path="/team-dashboard" element={<TeamDashboardPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/integrations" element={<IntegrationsPage />} />

228
src/pages/appointments.tsx Normal file
View File

@@ -0,0 +1,228 @@
import { useEffect, useMemo, useState } from "react";
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
import { Table } from "@/components/application/table/table";
import { Tab, TabList, Tabs } from "@/components/application/tabs/tabs";
import { Badge } from "@/components/base/badges/badges";
import { Input } from "@/components/base/input/input";
import { PhoneActionCell } from "@/components/call-desk/phone-action-cell";
import { TopBar } from "@/components/layout/top-bar";
import { apiClient } from "@/lib/api-client";
import { formatPhone } from "@/lib/format";
import { faIcon } from "@/lib/icon-wrapper";
const SearchLg = faIcon(faMagnifyingGlass);
type AppointmentRecord = {
id: string;
scheduledAt: string | null;
durationMin: number | null;
appointmentType: string | null;
status: string | null;
doctorName: string | null;
department: string | null;
reasonForVisit: string | null;
patient: {
id: string;
fullName: { firstName: string; lastName: string } | null;
phones: { primaryPhoneNumber: string } | null;
} | null;
doctor: {
clinic: { clinicName: string } | null;
} | null;
};
type StatusTab = "all" | "SCHEDULED" | "COMPLETED" | "CANCELLED" | "RESCHEDULED";
const STATUS_COLORS: Record<string, "brand" | "success" | "error" | "warning" | "gray"> = {
SCHEDULED: "brand",
CONFIRMED: "brand",
COMPLETED: "success",
CANCELLED: "error",
NO_SHOW: "warning",
RESCHEDULED: "warning",
};
const STATUS_LABELS: Record<string, string> = {
SCHEDULED: "Booked",
CONFIRMED: "Confirmed",
COMPLETED: "Completed",
CANCELLED: "Cancelled",
NO_SHOW: "No Show",
RESCHEDULED: "Rescheduled",
FOLLOW_UP: "Follow-up",
CONSULTATION: "Consultation",
};
const QUERY = `{ appointments(first: 100, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
id scheduledAt durationMin appointmentType status
doctorName department reasonForVisit
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
doctor { clinic { clinicName } }
} } } }`;
const formatDate = (iso: string): string => {
const d = new Date(iso);
return d.toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" });
};
const formatTime = (iso: string): string => {
const d = new Date(iso);
return d.toLocaleTimeString("en-IN", { hour: "numeric", minute: "2-digit", hour12: true });
};
export const AppointmentsPage = () => {
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<StatusTab>("all");
const [search, setSearch] = useState("");
useEffect(() => {
apiClient
.graphql<{ appointments: { edges: Array<{ node: AppointmentRecord }> } }>(QUERY, undefined, { silent: true })
.then((data) => setAppointments(data.appointments.edges.map((e) => e.node)))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const statusCounts = useMemo(() => {
const counts: Record<string, number> = {};
for (const a of appointments) {
const s = a.status ?? "UNKNOWN";
counts[s] = (counts[s] ?? 0) + 1;
}
return counts;
}, [appointments]);
const filtered = useMemo(() => {
let rows = appointments;
if (tab !== "all") {
rows = rows.filter((a) => a.status === tab);
}
if (search.trim()) {
const q = search.toLowerCase();
rows = rows.filter((a) => {
const patientName = `${a.patient?.fullName?.firstName ?? ""} ${a.patient?.fullName?.lastName ?? ""}`.toLowerCase();
const phone = a.patient?.phones?.primaryPhoneNumber ?? "";
const doctor = (a.doctorName ?? "").toLowerCase();
const dept = (a.department ?? "").toLowerCase();
const branch = (a.doctor?.clinic?.clinicName ?? "").toLowerCase();
return patientName.includes(q) || phone.includes(q) || doctor.includes(q) || dept.includes(q) || branch.includes(q);
});
}
return rows;
}, [appointments, tab, search]);
const tabItems = [
{ id: "all" as const, label: "All", badge: appointments.length > 0 ? String(appointments.length) : undefined },
{ id: "SCHEDULED" as const, label: "Booked", badge: statusCounts.SCHEDULED ? String(statusCounts.SCHEDULED) : undefined },
{ id: "COMPLETED" as const, label: "Completed", badge: statusCounts.COMPLETED ? String(statusCounts.COMPLETED) : undefined },
{ id: "CANCELLED" as const, label: "Cancelled", badge: statusCounts.CANCELLED ? String(statusCounts.CANCELLED) : undefined },
{ id: "RESCHEDULED" as const, label: "Rescheduled", badge: statusCounts.RESCHEDULED ? String(statusCounts.RESCHEDULED) : undefined },
];
return (
<>
<TopBar title="Appointment Master" />
<div className="flex flex-1 flex-col overflow-hidden">
{/* Tabs + search */}
<div className="flex items-end justify-between border-b border-secondary px-6 pt-3 pb-0.5">
<Tabs selectedKey={tab} onSelectionChange={(key) => setTab(key as StatusTab)}>
<TabList items={tabItems} type="underline" size="sm">
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
</TabList>
</Tabs>
<div className="w-56 shrink-0 pb-1">
<Input
placeholder="Search patient, doctor, branch..."
icon={SearchLg}
size="sm"
value={search}
onChange={setSearch}
aria-label="Search appointments"
/>
</div>
</div>
{/* Table */}
<div className="flex-1 overflow-y-auto px-4 pt-3">
{loading ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-tertiary">Loading appointments...</p>
</div>
) : filtered.length === 0 ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-quaternary">{search ? "No matching appointments" : "No appointments found"}</p>
</div>
) : (
<Table size="sm">
<Table.Header>
<Table.Head label="Patient" isRowHeader />
<Table.Head label="Date" className="w-28" />
<Table.Head label="Time" className="w-24" />
<Table.Head label="Doctor" />
<Table.Head label="Department" className="w-28" />
<Table.Head label="Branch" className="w-36" />
<Table.Head label="Status" className="w-28" />
<Table.Head label="Chief Complaint" />
</Table.Header>
<Table.Body items={filtered}>
{(appt) => {
const patientName = appt.patient
? `${appt.patient.fullName?.firstName ?? ""} ${appt.patient.fullName?.lastName ?? ""}`.trim() || "Unknown"
: "Unknown";
const phone = appt.patient?.phones?.primaryPhoneNumber ?? "";
const branch = appt.doctor?.clinic?.clinicName ?? "—";
const statusLabel = STATUS_LABELS[appt.status ?? ""] ?? appt.status ?? "—";
const statusColor = STATUS_COLORS[appt.status ?? ""] ?? "gray";
return (
<Table.Row id={appt.id}>
<Table.Cell>
<div className="min-w-0">
<span className="block max-w-[180px] truncate text-sm font-medium text-primary">{patientName}</span>
{phone && (
<PhoneActionCell
phoneNumber={phone}
displayNumber={formatPhone({ number: phone, callingCode: "+91" })}
/>
)}
</div>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-primary">{appt.scheduledAt ? formatDate(appt.scheduledAt) : "—"}</span>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-primary">{appt.scheduledAt ? formatTime(appt.scheduledAt) : "—"}</span>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-primary">{appt.doctorName ?? "—"}</span>
</Table.Cell>
<Table.Cell>
<span className="text-xs text-tertiary">{appt.department ?? "—"}</span>
</Table.Cell>
<Table.Cell>
<span className="block max-w-[130px] truncate text-xs text-tertiary">{branch}</span>
</Table.Cell>
<Table.Cell>
<Badge size="sm" color={statusColor} type="pill-color">
{statusLabel}
</Badge>
</Table.Cell>
<Table.Cell>
<span className="block max-w-[200px] truncate text-xs text-tertiary">{appt.reasonForVisit ?? "—"}</span>
</Table.Cell>
</Table.Row>
);
}}
</Table.Body>
</Table>
)}
</div>
</div>
</>
);
};

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { faSidebar, faSidebarFlip } from "@fortawesome/pro-duotone-svg-icons";
import { faDeleteLeft, faPhone, faSidebar, faSidebarFlip, faXmark } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Badge } from "@/components/base/badges/badges";
import { ActiveCallCard } from "@/components/call-desk/active-call-card";
@@ -8,6 +8,8 @@ import { ContextPanel } from "@/components/call-desk/context-panel";
import { WorklistPanel } from "@/components/call-desk/worklist-panel";
import type { WorklistLead } from "@/components/call-desk/worklist-panel";
import { useWorklist } from "@/hooks/use-worklist";
import { apiClient } from "@/lib/api-client";
import { notify } from "@/lib/toast";
import { useAuth } from "@/providers/auth-provider";
import { useData } from "@/providers/data-provider";
import { useSip } from "@/providers/sip-provider";
@@ -22,6 +24,27 @@ export const CallDeskPage = () => {
const [contextOpen, setContextOpen] = useState(true);
const [activeMissedCallId, setActiveMissedCallId] = useState<string | null>(null);
const [callDismissed, setCallDismissed] = useState(false);
const [diallerOpen, setDiallerOpen] = useState(false);
const [dialNumber, setDialNumber] = useState("");
const [dialling, setDialling] = useState(false);
const handleDial = async () => {
const num = dialNumber.replace(/[^0-9]/g, "");
if (num.length < 10) {
notify.error("Enter a valid phone number");
return;
}
setDialling(true);
try {
await apiClient.post("/api/ozonetel/dial", { phoneNumber: num });
setDiallerOpen(false);
setDialNumber("");
} catch {
notify.error("Dial failed");
} finally {
setDialling(false);
}
};
// Reset callDismissed when a new call starts (ringing in or out)
if (callDismissed && (callState === "ringing-in" || callState === "ringing-out")) {
@@ -36,7 +59,10 @@ export const CallDeskPage = () => {
? marketingLeads.find((l) => l.contactPhone?.[0]?.number?.endsWith(callerNumber) || callerNumber.endsWith(l.contactPhone?.[0]?.number ?? "---"))
: null;
const activeLead = isInCall ? (callerLead ?? selectedLead) : selectedLead;
// For inbound calls, only use matched lead (don't fall back to previously selected worklist lead)
// For outbound (agent initiated from worklist), selectedLead is the intended target
const activeLead = isInCall ? (callerLead ?? (callState === "ringing-out" ? selectedLead : null)) : selectedLead;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeLeadFull = activeLead as any;
return (
@@ -49,6 +75,62 @@ export const CallDeskPage = () => {
</div>
<div className="flex items-center gap-2">
{!isInCall && (
<div className="relative">
<button
onClick={() => setDiallerOpen(!diallerOpen)}
className={cx(
"flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition duration-100 ease-linear",
diallerOpen ? "bg-brand-solid text-white" : "bg-secondary text-secondary hover:bg-secondary_hover",
)}
>
<FontAwesomeIcon icon={faPhone} className="size-3" />
Dial
</button>
{diallerOpen && (
<div className="absolute top-full right-0 z-50 mt-2 w-72 rounded-xl bg-primary p-4 shadow-xl ring-1 ring-secondary">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-semibold text-primary">Dial</span>
<button onClick={() => setDiallerOpen(false)} className="text-fg-quaternary hover:text-fg-secondary">
<FontAwesomeIcon icon={faXmark} className="size-4" />
</button>
</div>
<div className="mb-3 flex min-h-[40px] items-center gap-2 rounded-lg bg-secondary px-3 py-2.5">
<span className="flex-1 text-center text-lg font-semibold tracking-wider text-primary">
{dialNumber || <span className="text-sm font-normal text-placeholder">Enter number</span>}
</span>
{dialNumber && (
<button
onClick={() => setDialNumber(dialNumber.slice(0, -1))}
className="shrink-0 text-fg-quaternary hover:text-fg-secondary"
>
<FontAwesomeIcon icon={faDeleteLeft} className="size-4" />
</button>
)}
</div>
<div className="mb-3 grid grid-cols-3 gap-1.5">
{["1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "0", "#"].map((key) => (
<button
key={key}
onClick={() => setDialNumber((prev) => prev + key)}
className="flex h-11 items-center justify-center rounded-lg border border-secondary bg-primary text-sm font-semibold text-primary transition duration-100 ease-linear hover:bg-secondary active:scale-95"
>
{key}
</button>
))}
</div>
<button
onClick={handleDial}
disabled={dialling || dialNumber.replace(/[^0-9]/g, "").length < 10}
className="flex w-full items-center justify-center gap-2 rounded-lg bg-success-solid py-2.5 text-sm font-medium text-white transition duration-100 ease-linear hover:opacity-90 disabled:cursor-not-allowed disabled:bg-disabled"
>
<FontAwesomeIcon icon={faPhone} className="size-3.5" />
{dialling ? "Dialling..." : "Call"}
</button>
</div>
)}
</div>
)}
<AgentStatusToggle isRegistered={isRegistered} connectionStatus={connectionStatus} />
{totalPending > 0 && (
<Badge size="sm" color="brand" type="pill-color">

View File

@@ -0,0 +1,174 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { faMagnifyingGlass, faPause, faPlay } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Table } from "@/components/application/table/table";
import { Badge } from "@/components/base/badges/badges";
import { Input } from "@/components/base/input/input";
import { PhoneActionCell } from "@/components/call-desk/phone-action-cell";
import { TopBar } from "@/components/layout/top-bar";
import { apiClient } from "@/lib/api-client";
import { formatPhone } from "@/lib/format";
import { faIcon } from "@/lib/icon-wrapper";
const SearchLg = faIcon(faMagnifyingGlass);
type RecordingRecord = {
id: string;
direction: string | null;
callStatus: string | null;
callerNumber: { primaryPhoneNumber: string } | null;
agentName: string | null;
startedAt: string | null;
durationSec: number | null;
disposition: string | null;
recording: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
};
const QUERY = `{ calls(first: 200, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id direction callStatus callerNumber { primaryPhoneNumber }
agentName startedAt durationSec disposition
recording { primaryLinkUrl primaryLinkLabel }
} } } }`;
const formatDate = (iso: string): string => new Date(iso).toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" });
const formatDuration = (sec: number | null): string => {
if (!sec) return "—";
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
};
const RecordingPlayer = ({ url }: { url: string }) => {
const audioRef = useRef<HTMLAudioElement>(null);
const [playing, setPlaying] = useState(false);
const toggle = () => {
if (!audioRef.current) return;
if (playing) {
audioRef.current.pause();
} else {
audioRef.current.play();
}
setPlaying(!playing);
};
return (
<div className="flex items-center gap-2">
<button
onClick={toggle}
className="flex size-7 items-center justify-center rounded-full bg-brand-solid text-white transition duration-100 ease-linear hover:opacity-90"
>
<FontAwesomeIcon icon={playing ? faPause : faPlay} className="size-3" />
</button>
<audio ref={audioRef} src={url} onEnded={() => setPlaying(false)} />
</div>
);
};
export const CallRecordingsPage = () => {
const [calls, setCalls] = useState<RecordingRecord[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
useEffect(() => {
apiClient
.graphql<{ calls: { edges: Array<{ node: RecordingRecord }> } }>(QUERY, undefined, { silent: true })
.then((data) => {
const withRecordings = data.calls.edges.map((e) => e.node).filter((c) => c.recording?.primaryLinkUrl);
setCalls(withRecordings);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const filtered = useMemo(() => {
if (!search.trim()) return calls;
const q = search.toLowerCase();
return calls.filter((c) => (c.agentName ?? "").toLowerCase().includes(q) || (c.callerNumber?.primaryPhoneNumber ?? "").includes(q));
}, [calls, search]);
return (
<>
<TopBar title="Call Recordings" />
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-secondary px-6 py-3">
<span className="text-sm text-tertiary">{filtered.length} recordings</span>
<div className="w-56">
<Input placeholder="Search agent or phone..." icon={SearchLg} size="sm" value={search} onChange={setSearch} />
</div>
</div>
<div className="flex-1 overflow-y-auto px-4 pt-3">
{loading ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-tertiary">Loading recordings...</p>
</div>
) : filtered.length === 0 ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-quaternary">{search ? "No matching recordings" : "No call recordings found"}</p>
</div>
) : (
<Table size="sm">
<Table.Header>
<Table.Head label="Agent" isRowHeader />
<Table.Head label="Caller" />
<Table.Head label="Type" className="w-20" />
<Table.Head label="Date" className="w-28" />
<Table.Head label="Duration" className="w-20" />
<Table.Head label="Disposition" className="w-32" />
<Table.Head label="Recording" className="w-24" />
</Table.Header>
<Table.Body items={filtered}>
{(call) => {
const phone = call.callerNumber?.primaryPhoneNumber ?? "";
const dirLabel = call.direction === "INBOUND" ? "In" : "Out";
const dirColor = call.direction === "INBOUND" ? "blue" : "brand";
return (
<Table.Row id={call.id}>
<Table.Cell>
<span className="text-sm text-primary">{call.agentName || "—"}</span>
</Table.Cell>
<Table.Cell>
{phone ? (
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: "+91" })} />
) : (
<span className="text-xs text-quaternary"></span>
)}
</Table.Cell>
<Table.Cell>
<Badge size="sm" color={dirColor} type="pill-color">
{dirLabel}
</Badge>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-primary">{call.startedAt ? formatDate(call.startedAt) : "—"}</span>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-primary">{formatDuration(call.durationSec)}</span>
</Table.Cell>
<Table.Cell>
{call.disposition ? (
<Badge size="sm" color="gray" type="pill-color">
{call.disposition
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (c) => c.toUpperCase())}
</Badge>
) : (
<span className="text-xs text-quaternary"></span>
)}
</Table.Cell>
<Table.Cell>{call.recording?.primaryLinkUrl && <RecordingPlayer url={call.recording.primaryLinkUrl} />}</Table.Cell>
</Table.Row>
);
}}
</Table.Body>
</Table>
)}
</div>
</div>
</>
);
};

View File

@@ -33,6 +33,7 @@ type IntegrationStatus = "connected" | "disconnected" | "configured";
type IntegrationCardProps = {
name: string;
description: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
icon: any;
iconColor: string;
status: IntegrationStatus;

195
src/pages/live-monitor.tsx Normal file
View File

@@ -0,0 +1,195 @@
import { useEffect, useMemo, useState } from "react";
import { faClock, faHeadset, faPause, faPhoneVolume } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Table } from "@/components/application/table/table";
import { Badge } from "@/components/base/badges/badges";
import { Button } from "@/components/base/buttons/button";
import { TopBar } from "@/components/layout/top-bar";
import { apiClient } from "@/lib/api-client";
import { useData } from "@/providers/data-provider";
type ActiveCall = {
ucid: string;
agentId: string;
callerNumber: string;
callType: string;
startTime: string;
status: "active" | "on-hold";
};
const formatDuration = (startTime: string): string => {
const seconds = Math.max(0, Math.floor((Date.now() - new Date(startTime).getTime()) / 1000));
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const KpiCard = ({ value, label, icon }: { value: string | number; label: string; icon: any }) => (
<div className="flex flex-1 flex-col items-center justify-center rounded-xl border border-secondary bg-primary py-6">
<FontAwesomeIcon icon={icon} className="mb-2 size-5 text-fg-quaternary" />
<p className="text-3xl font-bold text-primary">{value}</p>
<p className="mt-1 text-xs text-tertiary">{label}</p>
</div>
);
export const LiveMonitorPage = () => {
const [activeCalls, setActiveCalls] = useState<ActiveCall[]>([]);
const [loading, setLoading] = useState(true);
const [tick, setTick] = useState(0);
const { leads } = useData();
// Poll active calls every 5 seconds
useEffect(() => {
const fetchCalls = () => {
apiClient
.get<ActiveCall[]>("/api/supervisor/active-calls", { silent: true })
.then(setActiveCalls)
.catch(() => {})
.finally(() => setLoading(false));
};
fetchCalls();
const interval = setInterval(fetchCalls, 5000);
return () => clearInterval(interval);
}, []);
// Tick every second to update duration counters
useEffect(() => {
const interval = setInterval(() => setTick((t) => t + 1), 1000);
return () => clearInterval(interval);
}, []);
const onHold = activeCalls.filter((c) => c.status === "on-hold").length;
const avgDuration = useMemo(() => {
if (activeCalls.length === 0) return "0:00";
const totalSec = activeCalls.reduce((sum, c) => {
return sum + Math.max(0, Math.floor((Date.now() - new Date(c.startTime).getTime()) / 1000));
}, 0);
const avg = Math.floor(totalSec / activeCalls.length);
return `${Math.floor(avg / 60)}:${(avg % 60).toString().padStart(2, "0")}`;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeCalls, tick]);
// Match caller to lead
const resolveCallerName = (phone: string): string | null => {
if (!phone) return null;
const clean = phone.replace(/\D/g, "");
const lead = leads.find((l) => {
const lp = (l.contactPhone?.[0]?.number ?? "").replace(/\D/g, "");
return lp && (lp.endsWith(clean) || clean.endsWith(lp));
});
if (lead) {
return `${lead.contactName?.firstName ?? ""} ${lead.contactName?.lastName ?? ""}`.trim() || null;
}
return null;
};
return (
<>
<TopBar title="Live Call Monitor" subtitle="Listen, whisper, or barge into active calls" />
<div className="flex flex-1 flex-col overflow-y-auto">
{/* KPI Cards */}
<div className="px-6 pt-5">
<div className="flex gap-4">
<KpiCard value={activeCalls.length} label="Active Calls" icon={faPhoneVolume} />
<KpiCard value={onHold} label="On Hold" icon={faPause} />
<KpiCard value={avgDuration} label="Avg Duration" icon={faClock} />
</div>
</div>
{/* Active Calls Table */}
<div className="px-6 pt-6">
<h3 className="mb-3 text-sm font-semibold text-secondary">Active Calls</h3>
{loading ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-tertiary">Loading...</p>
</div>
) : activeCalls.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-secondary bg-primary py-16 text-center">
<FontAwesomeIcon icon={faHeadset} className="mb-4 size-12 text-fg-quaternary" />
<p className="text-sm font-medium text-secondary">No active calls</p>
<p className="mt-1 text-xs text-tertiary">Active calls will appear here in real-time</p>
</div>
) : (
<Table size="sm">
<Table.Header>
<Table.Head label="Agent" isRowHeader />
<Table.Head label="Caller" />
<Table.Head label="Type" className="w-16" />
<Table.Head label="Duration" className="w-20" />
<Table.Head label="Status" className="w-24" />
<Table.Head label="Actions" className="w-48" />
</Table.Header>
<Table.Body items={activeCalls}>
{(call) => {
const callerName = resolveCallerName(call.callerNumber);
const typeLabel = call.callType === "InBound" ? "In" : "Out";
const typeColor = call.callType === "InBound" ? "blue" : "brand";
return (
<Table.Row id={call.ucid}>
<Table.Cell>
<span className="text-sm font-medium text-primary">{call.agentId}</span>
</Table.Cell>
<Table.Cell>
<div>
{callerName && <span className="block text-sm font-medium text-primary">{callerName}</span>}
<span className="text-xs text-tertiary">{call.callerNumber}</span>
</div>
</Table.Cell>
<Table.Cell>
<Badge size="sm" color={typeColor} type="pill-color">
{typeLabel}
</Badge>
</Table.Cell>
<Table.Cell>
<span className="font-mono text-sm text-primary">{formatDuration(call.startTime)}</span>
</Table.Cell>
<Table.Cell>
<Badge size="sm" color={call.status === "on-hold" ? "warning" : "success"} type="pill-color">
{call.status}
</Badge>
</Table.Cell>
<Table.Cell>
<div className="flex items-center gap-1.5">
<Button size="sm" color="secondary" isDisabled title="Coming soon — pending Ozonetel API">
Listen
</Button>
<Button size="sm" color="secondary" isDisabled title="Coming soon — pending Ozonetel API">
Whisper
</Button>
<Button
size="sm"
color="primary-destructive"
isDisabled
title="Coming soon — requires supervisor SIP extension"
>
Barge
</Button>
</div>
</Table.Cell>
</Table.Row>
);
}}
</Table.Body>
</Table>
)}
</div>
{/* Monitoring hint */}
{activeCalls.length > 0 && (
<div className="px-6 pt-6 pb-8">
<div className="flex flex-col items-center justify-center rounded-xl border border-secondary bg-secondary_alt py-8 text-center">
<FontAwesomeIcon icon={faHeadset} className="mb-3 size-8 text-fg-quaternary" />
<p className="text-sm text-secondary">Select "Listen" on any active call to start monitoring</p>
<p className="mt-1 text-xs text-tertiary">Agent will not be notified during listen mode</p>
</div>
</div>
)}
</div>
</>
);
};

View File

@@ -7,9 +7,11 @@ import { SocialButton } from "@/components/base/buttons/social-button";
import { Checkbox } from "@/components/base/checkbox/checkbox";
import { Input } from "@/components/base/input/input";
import { useAuth } from "@/providers/auth-provider";
import { useData } from "@/providers/data-provider";
export const LoginPage = () => {
const { loginWithUser } = useAuth();
const { refresh } = useData();
const navigate = useNavigate();
const saved = localStorage.getItem("helix_remember");
@@ -49,6 +51,15 @@ export const LoginPage = () => {
localStorage.removeItem("helix_remember");
}
// Store agent config for SIP provider (CC agents only)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((response as any).agentConfig) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
localStorage.setItem("helix_agent_config", JSON.stringify((response as any).agentConfig));
} else {
localStorage.removeItem("helix_agent_config");
}
loginWithUser({
id: u?.id,
name,
@@ -59,7 +70,9 @@ export const LoginPage = () => {
platformRoles: u?.platformRoles,
});
refresh();
navigate("/");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
setError(err.message);
setIsLoading(false);

204
src/pages/missed-calls.tsx Normal file
View File

@@ -0,0 +1,204 @@
import { useEffect, useMemo, useState } from "react";
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
import { Table } from "@/components/application/table/table";
import { Tab, TabList, Tabs } from "@/components/application/tabs/tabs";
import { Badge } from "@/components/base/badges/badges";
import { Input } from "@/components/base/input/input";
import { PhoneActionCell } from "@/components/call-desk/phone-action-cell";
import { TopBar } from "@/components/layout/top-bar";
import { apiClient } from "@/lib/api-client";
import { formatPhone } from "@/lib/format";
import { faIcon } from "@/lib/icon-wrapper";
const SearchLg = faIcon(faMagnifyingGlass);
type MissedCallRecord = {
id: string;
callerNumber: { primaryPhoneNumber: string } | null;
agentName: string | null;
startedAt: string | null;
callsourcenumber: string | null;
callbackstatus: string | null;
missedcallcount: number | null;
callbackattemptedat: string | null;
};
type StatusTab = "all" | "PENDING_CALLBACK" | "CALLBACK_ATTEMPTED" | "CALLBACK_COMPLETED";
const QUERY = `{ calls(first: 200, filter: {
callStatus: { eq: MISSED }
}, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id callerNumber { primaryPhoneNumber } agentName
startedAt callsourcenumber callbackstatus missedcallcount callbackattemptedat
} } } }`;
const formatDate = (iso: string): string =>
new Date(iso).toLocaleDateString("en-IN", { day: "numeric", month: "short", hour: "numeric", minute: "2-digit", hour12: true });
const computeSla = (dateStr: string): { label: string; color: "success" | "warning" | "error" } => {
const minutes = Math.max(0, Math.round((Date.now() - new Date(dateStr).getTime()) / 60000));
if (minutes < 15) return { label: `${minutes}m`, color: "success" };
if (minutes < 30) return { label: `${minutes}m`, color: "warning" };
if (minutes < 60) return { label: `${minutes}m`, color: "error" };
const hours = Math.floor(minutes / 60);
if (hours < 24) return { label: `${hours}h ${minutes % 60}m`, color: "error" };
return { label: `${Math.floor(hours / 24)}d`, color: "error" };
};
const STATUS_LABELS: Record<string, string> = {
PENDING_CALLBACK: "Pending",
CALLBACK_ATTEMPTED: "Attempted",
CALLBACK_COMPLETED: "Completed",
WRONG_NUMBER: "Wrong Number",
INVALID: "Invalid",
};
const STATUS_COLORS: Record<string, "warning" | "brand" | "success" | "error" | "gray"> = {
PENDING_CALLBACK: "warning",
CALLBACK_ATTEMPTED: "brand",
CALLBACK_COMPLETED: "success",
WRONG_NUMBER: "error",
INVALID: "gray",
};
export const MissedCallsPage = () => {
const [calls, setCalls] = useState<MissedCallRecord[]>([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<StatusTab>("all");
const [search, setSearch] = useState("");
useEffect(() => {
apiClient
.graphql<{ calls: { edges: Array<{ node: MissedCallRecord }> } }>(QUERY, undefined, { silent: true })
.then((data) => setCalls(data.calls.edges.map((e) => e.node)))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const statusCounts = useMemo(() => {
const counts: Record<string, number> = {};
for (const c of calls) {
const s = c.callbackstatus ?? "PENDING_CALLBACK";
counts[s] = (counts[s] ?? 0) + 1;
}
return counts;
}, [calls]);
const filtered = useMemo(() => {
let rows = calls;
if (tab === "PENDING_CALLBACK") rows = rows.filter((c) => c.callbackstatus === "PENDING_CALLBACK" || !c.callbackstatus);
else if (tab === "CALLBACK_ATTEMPTED") rows = rows.filter((c) => c.callbackstatus === "CALLBACK_ATTEMPTED");
else if (tab === "CALLBACK_COMPLETED") rows = rows.filter((c) => c.callbackstatus === "CALLBACK_COMPLETED" || c.callbackstatus === "WRONG_NUMBER");
if (search.trim()) {
const q = search.toLowerCase();
rows = rows.filter((c) => (c.callerNumber?.primaryPhoneNumber ?? "").includes(q) || (c.agentName ?? "").toLowerCase().includes(q));
}
return rows;
}, [calls, tab, search]);
const tabItems = [
{ id: "all" as const, label: "All", badge: calls.length > 0 ? String(calls.length) : undefined },
{ id: "PENDING_CALLBACK" as const, label: "Pending", badge: statusCounts.PENDING_CALLBACK ? String(statusCounts.PENDING_CALLBACK) : undefined },
{ id: "CALLBACK_ATTEMPTED" as const, label: "Attempted", badge: statusCounts.CALLBACK_ATTEMPTED ? String(statusCounts.CALLBACK_ATTEMPTED) : undefined },
{
id: "CALLBACK_COMPLETED" as const,
label: "Completed",
badge:
statusCounts.CALLBACK_COMPLETED || statusCounts.WRONG_NUMBER
? String((statusCounts.CALLBACK_COMPLETED ?? 0) + (statusCounts.WRONG_NUMBER ?? 0))
: undefined,
},
];
return (
<>
<TopBar title="Missed Calls" />
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex items-end justify-between border-b border-secondary px-6 pt-3 pb-0.5">
<Tabs selectedKey={tab} onSelectionChange={(key) => setTab(key as StatusTab)}>
<TabList items={tabItems} type="underline" size="sm">
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
</TabList>
</Tabs>
<div className="w-56 shrink-0 pb-1">
<Input placeholder="Search phone or agent..." icon={SearchLg} size="sm" value={search} onChange={setSearch} />
</div>
</div>
<div className="flex-1 overflow-y-auto px-4 pt-3">
{loading ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-tertiary">Loading missed calls...</p>
</div>
) : filtered.length === 0 ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-quaternary">{search ? "No matching calls" : "No missed calls"}</p>
</div>
) : (
<Table size="sm">
<Table.Header>
<Table.Head label="Caller" isRowHeader />
<Table.Head label="Date / Time" className="w-36" />
<Table.Head label="Branch" className="w-32" />
<Table.Head label="Agent" className="w-28" />
<Table.Head label="Count" className="w-16" />
<Table.Head label="Status" className="w-28" />
<Table.Head label="SLA" className="w-24" />
</Table.Header>
<Table.Body items={filtered}>
{(call) => {
const phone = call.callerNumber?.primaryPhoneNumber ?? "";
const status = call.callbackstatus ?? "PENDING_CALLBACK";
const sla = call.startedAt ? computeSla(call.startedAt) : null;
return (
<Table.Row id={call.id}>
<Table.Cell>
{phone ? (
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: "+91" })} />
) : (
<span className="text-xs text-quaternary">Unknown</span>
)}
</Table.Cell>
<Table.Cell>
<span className="text-sm text-primary">{call.startedAt ? formatDate(call.startedAt) : "—"}</span>
</Table.Cell>
<Table.Cell>
<span className="text-xs text-tertiary">{call.callsourcenumber || "—"}</span>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-primary">{call.agentName || "—"}</span>
</Table.Cell>
<Table.Cell>
{call.missedcallcount && call.missedcallcount > 1 ? (
<Badge size="sm" color="warning" type="pill-color">
{call.missedcallcount}x
</Badge>
) : (
<span className="text-xs text-quaternary">1</span>
)}
</Table.Cell>
<Table.Cell>
<Badge size="sm" color={STATUS_COLORS[status] ?? "gray"} type="pill-color">
{STATUS_LABELS[status] ?? status}
</Badge>
</Table.Cell>
<Table.Cell>
{sla && (
<Badge size="sm" color={sla.color} type="pill-color">
{sla.label}
</Badge>
)}
</Table.Cell>
</Table.Row>
);
}}
</Table.Body>
</Table>
)}
</div>
</div>
</>
);
};

View File

@@ -55,6 +55,7 @@ const BRAND = {
};
type KpiCardProps = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
icon: any;
iconColor: string;
label: string;

View File

@@ -90,12 +90,16 @@ type PatientData = {
phones: { primaryPhoneNumber: string } | null;
emails: { primaryEmail: string } | null;
patientType: string | null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
appointments: { edges: Array<{ node: any }> };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
calls: { edges: Array<{ node: any }> };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
leads: { edges: Array<{ node: any }> };
};
// Appointment row component
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const AppointmentRow = ({ appt }: { appt: any }) => {
const scheduledAt = appt.scheduledAt ? formatShortDate(appt.scheduledAt) : "--";
const statusColors: Record<string, "success" | "brand" | "warning" | "error" | "gray"> = {
@@ -273,6 +277,7 @@ export const Patient360Page = () => {
setPatient(p);
// Fetch activities from linked leads
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const leadIds = p?.leads?.edges?.map((e: any) => e.node.id) ?? [];
if (leadIds.length > 0) {
const leadFilter = leadIds.map((lid: string) => `"${lid}"`).join(", ");
@@ -296,6 +301,7 @@ export const Patient360Page = () => {
const patientCalls = useMemo(
() =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(patient?.calls?.edges?.map((e) => e.node) ?? []).map((c: any) => ({
...c,
callDirection: c.direction,
@@ -447,6 +453,7 @@ export const Patient360Page = () => {
<EmptyState icon="📅" title="No appointments" subtitle="Appointment history will appear here." />
) : (
<div className="rounded-xl border border-secondary bg-primary">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{appointments.map((appt: any) => (
<AppointmentRow key={appt.id} appt={appt} />
))}
@@ -462,6 +469,7 @@ export const Patient360Page = () => {
<EmptyState icon="📞" title="No calls yet" subtitle="Call history with this patient will appear here." />
) : (
<div className="rounded-xl border border-secondary bg-primary">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{patientCalls.map((call: any) => (
<CallRow key={call.id} call={call} />
))}

View File

@@ -26,14 +26,17 @@ export const SettingsPage = () => {
const fetchMembers = async () => {
try {
// Roles are only accessible via user JWT, not API key
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await apiClient.graphql<any>(
`{ workspaceMembers(first: 50) { edges { node { id name { firstName lastName } userEmail avatarUrl } } } }`,
undefined,
{ silent: true },
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rawMembers = data?.workspaceMembers?.edges?.map((e: any) => e.node) ?? [];
// Roles come from the platform's role assignment — map known emails to roles
setMembers(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
rawMembers.map((m: any) => ({
...m,
roles: inferRoles(m.userEmail),

View File

@@ -0,0 +1,552 @@
import { useEffect, useMemo, useState } from "react";
import { faCalendarCheck, faPercent, faPhoneMissed, faPhoneVolume, faTriangleExclamation, faUsers } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import ReactECharts from "echarts-for-react";
import { Table } from "@/components/application/table/table";
import { Badge } from "@/components/base/badges/badges";
import { TopBar } from "@/components/layout/top-bar";
import { apiClient } from "@/lib/api-client";
import { cx } from "@/utils/cx";
type DateRange = "today" | "week" | "month" | "year";
const getDateRange = (range: DateRange): { gte: string; lte: string } => {
const now = new Date();
const lte = now.toISOString();
const start = new Date(now);
if (range === "today") start.setHours(0, 0, 0, 0);
else if (range === "week") {
start.setDate(start.getDate() - start.getDay() + 1);
start.setHours(0, 0, 0, 0);
} else if (range === "month") {
start.setDate(1);
start.setHours(0, 0, 0, 0);
} else if (range === "year") {
start.setMonth(0, 1);
start.setHours(0, 0, 0, 0);
}
return { gte: start.toISOString(), lte };
};
const parseTime = (timeStr: string): number => {
if (!timeStr) return 0;
const parts = timeStr.split(":").map(Number);
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
return 0;
};
type AgentPerf = {
name: string;
ozonetelagentid: string;
npsscore: number | null;
maxidleminutes: number | null;
minnpsthreshold: number | null;
minconversionpercent: number | null;
calls: number;
inbound: number;
missed: number;
followUps: number;
leads: number;
appointments: number;
convPercent: number;
idleMinutes: number;
activeMinutes: number;
wrapMinutes: number;
breakMinutes: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
timeBreakdown: any;
};
const DateFilter = ({ value, onChange }: { value: DateRange; onChange: (v: DateRange) => void }) => (
<div className="flex overflow-hidden rounded-lg border border-secondary">
{(["today", "week", "month", "year"] as DateRange[]).map((r) => (
<button
key={r}
onClick={() => onChange(r)}
className={cx(
"px-3 py-1 text-xs font-medium capitalize transition duration-100 ease-linear",
value === r ? "bg-brand-solid text-white" : "bg-primary text-secondary hover:bg-secondary",
)}
>
{r}
</button>
))}
</div>
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const KpiCard = ({ icon, value, label, color }: { icon: any; value: string | number; label: string; color?: string }) => (
<div className="flex flex-1 items-center gap-3 rounded-xl border border-secondary bg-primary p-4">
<div className={cx("flex size-10 items-center justify-center rounded-lg", color ?? "bg-brand-secondary")}>
<FontAwesomeIcon icon={icon} className="size-4 text-fg-white" />
</div>
<div>
<p className="text-xl font-bold text-primary">{value}</p>
<p className="text-xs text-tertiary">{label}</p>
</div>
</div>
);
export const TeamPerformancePage = () => {
const [range, setRange] = useState<DateRange>("today");
const [agents, setAgents] = useState<AgentPerf[]>([]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [allCalls, setAllCalls] = useState<any[]>([]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [allAppointments, setAllAppointments] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const load = async () => {
setLoading(true);
const { gte, lte } = getDateRange(range);
const dateStr = new Date().toISOString().split("T")[0];
try {
const [callsData, apptsData, leadsData, followUpsData, teamData] = await Promise.all([
// eslint-disable-next-line @typescript-eslint/no-explicit-any
apiClient.graphql<any>(
`{ calls(first: 500, filter: { startedAt: { gte: "${gte}", lte: "${lte}" } }) { edges { node { id direction callStatus agentName startedAt } } } }`,
undefined,
{ silent: true },
),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
apiClient.graphql<any>(
`{ appointments(first: 200, filter: { scheduledAt: { gte: "${gte}", lte: "${lte}" } }) { edges { node { id status } } } }`,
undefined,
{ silent: true },
),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
apiClient.graphql<any>(`{ leads(first: 200) { edges { node { id assignedAgent status } } } }`, undefined, { silent: true }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
apiClient.graphql<any>(`{ followUps(first: 200) { edges { node { id assignedAgent } } } }`, undefined, { silent: true }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
apiClient.get<any>(`/api/supervisor/team-performance?date=${dateStr}`, { silent: true }).catch(() => ({ agents: [] })),
]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const calls = callsData?.calls?.edges?.map((e: any) => e.node) ?? [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const appts = apptsData?.appointments?.edges?.map((e: any) => e.node) ?? [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const leads = leadsData?.leads?.edges?.map((e: any) => e.node) ?? [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const followUps = followUpsData?.followUps?.edges?.map((e: any) => e.node) ?? [];
const teamAgents = teamData?.agents ?? [];
setAllCalls(calls);
setAllAppointments(appts);
// Build per-agent metrics
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const agentPerfs: AgentPerf[] = teamAgents.map((agent: any) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const agentCalls = calls.filter((c: any) => c.agentName === agent.name || c.agentName === agent.ozonetelagentid);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const agentLeads = leads.filter((l: any) => l.assignedAgent === agent.name);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === agent.name);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const agentAppts = agentCalls.filter((c: any) => c.callStatus === "COMPLETED").length; // approximate
const totalCalls = agentCalls.length;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const inbound = agentCalls.filter((c: any) => c.direction === "INBOUND").length;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const missed = agentCalls.filter((c: any) => c.callStatus === "MISSED").length;
const tb = agent.timeBreakdown;
const idleSec = tb ? parseTime(tb.totalIdleTime ?? "0:0:0") : 0;
const activeSec = tb ? parseTime(tb.totalBusyTime ?? "0:0:0") : 0;
const wrapSec = tb ? parseTime(tb.totalWrapupTime ?? "0:0:0") : 0;
const breakSec = tb ? parseTime(tb.totalPauseTime ?? "0:0:0") : 0;
return {
name: agent.name ?? agent.ozonetelagentid,
ozonetelagentid: agent.ozonetelagentid,
npsscore: agent.npsscore,
maxidleminutes: agent.maxidleminutes,
minnpsthreshold: agent.minnpsthreshold,
minconversionpercent: agent.minconversionpercent,
calls: totalCalls,
inbound,
missed,
followUps: agentFollowUps.length,
leads: agentLeads.length,
appointments: agentAppts,
convPercent: totalCalls > 0 ? Math.round((agentAppts / totalCalls) * 100) : 0,
idleMinutes: Math.round(idleSec / 60),
activeMinutes: Math.round(activeSec / 60),
wrapMinutes: Math.round(wrapSec / 60),
breakMinutes: Math.round(breakSec / 60),
timeBreakdown: tb,
};
});
setAgents(agentPerfs);
} catch (err) {
console.error("Failed to load team performance:", err);
} finally {
setLoading(false);
}
};
load();
}, [range]);
// Aggregate KPIs
const totalCalls = allCalls.length;
const totalMissed = allCalls.filter((c) => c.callStatus === "MISSED").length;
const totalAppts = allAppointments.length;
const convRate = totalCalls > 0 ? Math.round((totalAppts / totalCalls) * 100) : 0;
const activeAgents = agents.length;
// Call trend by day
const callTrendOption = useMemo(() => {
const dayMap: Record<string, { inbound: number; outbound: number }> = {};
for (const c of allCalls) {
if (!c.startedAt) continue;
const day = new Date(c.startedAt).toLocaleDateString("en-IN", { weekday: "short" });
if (!dayMap[day]) dayMap[day] = { inbound: 0, outbound: 0 };
if (c.direction === "INBOUND") dayMap[day].inbound++;
else dayMap[day].outbound++;
}
const days = Object.keys(dayMap);
return {
tooltip: { trigger: "axis" },
legend: { data: ["Inbound", "Outbound"], bottom: 0 },
grid: { top: 10, right: 10, bottom: 30, left: 40 },
xAxis: { type: "category", data: days },
yAxis: { type: "value" },
series: [
{ name: "Inbound", type: "line", data: days.map((d) => dayMap[d].inbound), smooth: true, color: "#2060A0" },
{ name: "Outbound", type: "line", data: days.map((d) => dayMap[d].outbound), smooth: true, color: "#E88C30" },
],
};
}, [allCalls]);
// NPS
const avgNps = useMemo(() => {
const withNps = agents.filter((a) => a.npsscore != null);
if (withNps.length === 0) return 0;
return Math.round(withNps.reduce((sum, a) => sum + (a.npsscore ?? 0), 0) / withNps.length);
}, [agents]);
const npsOption = useMemo(
() => ({
tooltip: { trigger: "item" },
series: [
{
type: "gauge",
startAngle: 180,
endAngle: 0,
min: 0,
max: 100,
pointer: { show: false },
progress: { show: true, width: 18, roundCap: true, itemStyle: { color: avgNps >= 70 ? "#22C55E" : avgNps >= 50 ? "#F59E0B" : "#EF4444" } },
axisLine: { lineStyle: { width: 18, color: [[1, "#E5E7EB"]] } },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
detail: { valueAnimation: true, fontSize: 28, fontWeight: "bold", offsetCenter: [0, "-10%"], formatter: "{value}" },
data: [{ value: avgNps }],
},
],
}),
[avgNps],
);
// Performance alerts
const alerts = useMemo(() => {
const list: { agent: string; type: string; value: string; severity: "error" | "warning" }[] = [];
for (const a of agents) {
if (a.maxidleminutes && a.idleMinutes > a.maxidleminutes) {
list.push({ agent: a.name, type: "Excessive Idle Time", value: `${a.idleMinutes}m`, severity: "error" });
}
if (a.minnpsthreshold && (a.npsscore ?? 100) < a.minnpsthreshold) {
list.push({ agent: a.name, type: "Low NPS", value: String(a.npsscore ?? 0), severity: "warning" });
}
if (a.minconversionpercent && a.convPercent < a.minconversionpercent) {
list.push({ agent: a.name, type: "Low Conversion", value: `${a.convPercent}%`, severity: "warning" });
}
}
return list;
}, [agents]);
// Team time averages
const teamAvg = useMemo(() => {
if (agents.length === 0) return { active: 0, wrap: 0, idle: 0, break_: 0 };
return {
active: Math.round(agents.reduce((s, a) => s + a.activeMinutes, 0) / agents.length),
wrap: Math.round(agents.reduce((s, a) => s + a.wrapMinutes, 0) / agents.length),
idle: Math.round(agents.reduce((s, a) => s + a.idleMinutes, 0) / agents.length),
break_: Math.round(agents.reduce((s, a) => s + a.breakMinutes, 0) / agents.length),
};
}, [agents]);
if (loading) {
return (
<>
<TopBar title="Team Performance" />
<div className="flex flex-1 items-center justify-center">
<p className="text-sm text-tertiary">Loading team performance...</p>
</div>
</>
);
}
return (
<>
<TopBar title="Team Performance" subtitle="Aggregated metrics across all agents" />
<div className="flex flex-1 flex-col overflow-y-auto">
{/* Section 1: Key Metrics */}
<div className="px-6 pt-5">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-semibold text-secondary">Key Metrics</h3>
<DateFilter value={range} onChange={setRange} />
</div>
<div className="flex gap-3">
<KpiCard icon={faUsers} value={activeAgents} label="Active Agents" color="bg-brand-secondary" />
<KpiCard icon={faPhoneVolume} value={totalCalls} label="Total Calls" color="bg-brand-solid" />
<KpiCard icon={faCalendarCheck} value={totalAppts} label="Appointments" color="bg-success-solid" />
<KpiCard icon={faPhoneMissed} value={totalMissed} label="Missed Calls" color="bg-error-solid" />
<KpiCard icon={faPercent} value={`${convRate}%`} label="Conversion Rate" color="bg-warning-solid" />
</div>
</div>
{/* Section 2: Call Breakdown Trends */}
<div className="px-6 pt-6">
<div className="rounded-xl border border-secondary bg-primary p-4">
<h3 className="mb-3 text-sm font-semibold text-secondary">Call Breakdown Trends</h3>
<div className="flex gap-4">
<div className="flex-1">
<p className="mb-2 text-xs text-tertiary">Inbound vs Outbound</p>
<ReactECharts option={callTrendOption} style={{ height: 200 }} />
</div>
</div>
</div>
</div>
{/* Section 3: Agent Performance Table */}
<div className="px-6 pt-6">
<div className="rounded-xl border border-secondary bg-primary p-4">
<h3 className="mb-3 text-sm font-semibold text-secondary">Agent Performance</h3>
<Table size="sm">
<Table.Header>
<Table.Head label="Agent" isRowHeader />
<Table.Head label="Calls" className="w-16" />
<Table.Head label="Inbound" className="w-20" />
<Table.Head label="Missed" className="w-16" />
<Table.Head label="Follow-ups" className="w-24" />
<Table.Head label="Leads" className="w-16" />
<Table.Head label="Conv%" className="w-16" />
<Table.Head label="NPS" className="w-16" />
<Table.Head label="Idle" className="w-16" />
</Table.Header>
<Table.Body items={agents}>
{(agent) => (
<Table.Row id={agent.ozonetelagentid || agent.name}>
<Table.Cell>
<span className="text-sm font-medium text-primary">{agent.name}</span>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-primary">{agent.calls}</span>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-primary">{agent.inbound}</span>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-primary">{agent.missed}</span>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-primary">{agent.followUps}</span>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-primary">{agent.leads}</span>
</Table.Cell>
<Table.Cell>
<span
className={cx("text-sm font-medium", agent.convPercent >= 25 ? "text-success-primary" : "text-error-primary")}
>
{agent.convPercent}%
</span>
</Table.Cell>
<Table.Cell>
<span
className={cx(
"text-sm font-bold",
(agent.npsscore ?? 0) >= 70
? "text-success-primary"
: (agent.npsscore ?? 0) >= 50
? "text-warning-primary"
: "text-error-primary",
)}
>
{agent.npsscore ?? "—"}
</span>
</Table.Cell>
<Table.Cell>
<span
className={cx(
"text-sm",
agent.maxidleminutes && agent.idleMinutes > agent.maxidleminutes
? "font-bold text-error-primary"
: "text-primary",
)}
>
{agent.idleMinutes}m
</span>
</Table.Cell>
</Table.Row>
)}
</Table.Body>
</Table>
</div>
</div>
{/* Section 4: Time Breakdown */}
<div className="px-6 pt-6">
<div className="rounded-xl border border-secondary bg-primary p-4">
<h3 className="mb-3 text-sm font-semibold text-secondary">Time Breakdown</h3>
<div className="mb-4 flex gap-6 px-2">
<div className="flex items-center gap-2">
<div className="size-3 rounded-sm bg-success-solid" />
<span className="text-xs text-secondary">{teamAvg.active}m Active</span>
</div>
<div className="flex items-center gap-2">
<div className="size-3 rounded-sm bg-brand-solid" />
<span className="text-xs text-secondary">{teamAvg.wrap}m Wrap</span>
</div>
<div className="flex items-center gap-2">
<div className="size-3 rounded-sm bg-warning-solid" />
<span className="text-xs text-secondary">{teamAvg.idle}m Idle</span>
</div>
<div className="flex items-center gap-2">
<div className="size-3 rounded-sm bg-tertiary" />
<span className="text-xs text-secondary">{teamAvg.break_}m Break</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3">
{agents.map((agent) => {
const total = agent.activeMinutes + agent.wrapMinutes + agent.idleMinutes + agent.breakMinutes || 1;
const isHighIdle = agent.maxidleminutes && agent.idleMinutes > agent.maxidleminutes;
return (
<div
key={agent.name}
className={cx("rounded-lg border p-3", isHighIdle ? "border-error bg-error-secondary" : "border-secondary")}
>
<p className="mb-2 text-xs font-semibold text-primary">{agent.name}</p>
<div className="flex h-3 overflow-hidden rounded-full">
<div className="bg-success-solid" style={{ width: `${(agent.activeMinutes / total) * 100}%` }} />
<div className="bg-brand-solid" style={{ width: `${(agent.wrapMinutes / total) * 100}%` }} />
<div className="bg-warning-solid" style={{ width: `${(agent.idleMinutes / total) * 100}%` }} />
<div className="bg-tertiary" style={{ width: `${(agent.breakMinutes / total) * 100}%` }} />
</div>
<div className="mt-1.5 flex gap-2 text-[10px] text-quaternary">
<span>Active {agent.activeMinutes}m</span>
<span>Wrap {agent.wrapMinutes}m</span>
<span>Idle {agent.idleMinutes}m</span>
<span>Break {agent.breakMinutes}m</span>
</div>
</div>
);
})}
</div>
</div>
</div>
{/* Section 5: NPS + Conversion */}
<div className="px-6 pt-6">
<div className="flex gap-4">
<div className="flex-1 rounded-xl border border-secondary bg-primary p-4">
<h3 className="mb-2 text-sm font-semibold text-secondary">Overall NPS</h3>
<ReactECharts option={npsOption} style={{ height: 150 }} />
<div className="mt-2 space-y-1">
{agents
.filter((a) => a.npsscore != null)
.map((a) => (
<div key={a.name} className="flex items-center gap-2">
<span className="w-28 truncate text-xs text-secondary">{a.name}</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-tertiary">
<div
className={cx(
"h-full rounded-full",
(a.npsscore ?? 0) >= 70
? "bg-success-solid"
: (a.npsscore ?? 0) >= 50
? "bg-warning-solid"
: "bg-error-solid",
)}
style={{ width: `${a.npsscore ?? 0}%` }}
/>
</div>
<span className="w-8 text-right text-xs font-bold text-primary">{a.npsscore}</span>
</div>
))}
</div>
</div>
<div className="flex-1 rounded-xl border border-secondary bg-primary p-4">
<h3 className="mb-3 text-sm font-semibold text-secondary">Conversion Metrics</h3>
<div className="mb-4 flex gap-3">
<div className="flex-1 rounded-lg bg-secondary p-4 text-center">
<p className="text-2xl font-bold text-brand-secondary">{convRate}%</p>
<p className="text-xs text-tertiary">Call Appointment</p>
</div>
<div className="flex-1 rounded-lg bg-secondary p-4 text-center">
<p className="text-2xl font-bold text-brand-secondary">
{agents.length > 0 ? Math.round((agents.reduce((s, a) => s + (a.leads > 0 ? 1 : 0), 0) / agents.length) * 100) : 0}%
</p>
<p className="text-xs text-tertiary">Lead Contact</p>
</div>
</div>
<div className="space-y-1">
{agents.map((a) => (
<div key={a.name} className="flex items-center gap-2 text-xs">
<span className="w-28 truncate text-secondary">{a.name}</span>
<Badge size="sm" color={a.convPercent >= 25 ? "success" : "error"}>
{a.convPercent}%
</Badge>
</div>
))}
</div>
</div>
</div>
</div>
{/* Section 6: Performance Alerts */}
{alerts.length > 0 && (
<div className="px-6 pt-6 pb-8">
<div className="rounded-xl border border-secondary bg-primary p-4">
<h3 className="mb-3 text-sm font-semibold text-error-primary">
<FontAwesomeIcon icon={faTriangleExclamation} className="mr-1.5 size-3.5" />
Performance Alerts ({alerts.length})
</h3>
<div className="space-y-2">
{alerts.map((alert, i) => (
<div
key={i}
className={cx(
"flex items-center justify-between rounded-lg px-4 py-3",
alert.severity === "error" ? "bg-error-secondary" : "bg-warning-secondary",
)}
>
<div className="flex items-center gap-2">
<FontAwesomeIcon
icon={faTriangleExclamation}
className={cx("size-3.5", alert.severity === "error" ? "text-fg-error-primary" : "text-fg-warning-primary")}
/>
<span className="text-sm font-medium text-primary">{alert.agent}</span>
<span className="text-sm text-secondary"> {alert.type}</span>
</div>
<Badge size="sm" color={alert.severity}>
{alert.value}
</Badge>
</div>
))}
</div>
</div>
</div>
)}
</div>
</>
);
};

View File

@@ -51,6 +51,7 @@ const loadPersistedUser = (): User | null => {
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// eslint-disable-next-line react-refresh/only-export-components
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (context === undefined) {
@@ -96,10 +97,21 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
}, []);
const logout = useCallback(() => {
// Notify sidecar to unlock Redis session + Ozonetel logout
const token = localStorage.getItem("helix_access_token");
if (token) {
const apiUrl = import.meta.env.VITE_API_URL ?? "http://localhost:4100";
fetch(`${apiUrl}/auth/logout`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
}).catch(() => {});
}
setUser(DEFAULT_USER);
setIsAuthenticated(false);
localStorage.removeItem("helix_access_token");
localStorage.removeItem("helix_refresh_token");
localStorage.removeItem("helix_agent_config");
localStorage.removeItem(STORAGE_KEY);
}, []);
@@ -118,4 +130,5 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
);
};
// eslint-disable-next-line react-refresh/only-export-components
export { getInitials };

View File

@@ -33,6 +33,7 @@ type DataContextType = {
const DataContext = createContext<DataContextType | undefined>(undefined);
// eslint-disable-next-line react-refresh/only-export-components
export const useData = (): DataContextType => {
const context = useContext(DataContext);
@@ -76,12 +77,19 @@ export const DataProvider = ({ children }: DataProviderProps) => {
const gql = <T,>(query: string) => apiClient.graphql<T>(query, undefined, { silent: true }).catch(() => null);
const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData, patientsData] = await Promise.all([
// eslint-disable-next-line @typescript-eslint/no-explicit-any
gql<any>(LEADS_QUERY),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
gql<any>(CAMPAIGNS_QUERY),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
gql<any>(ADS_QUERY),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
gql<any>(FOLLOW_UPS_QUERY),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
gql<any>(LEAD_ACTIVITIES_QUERY),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
gql<any>(CALLS_QUERY),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
gql<any>(PATIENTS_QUERY),
]);
@@ -92,6 +100,7 @@ export const DataProvider = ({ children }: DataProviderProps) => {
if (activitiesData) setLeadActivities(transformLeadActivities(activitiesData));
if (callsData) setCalls(transformCalls(callsData));
if (patientsData) setPatients(transformPatients(patientsData));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
setError(err.message ?? "Failed to load data");
} finally {

View File

@@ -13,12 +13,29 @@ import {
} from "@/state/sip-state";
import type { SIPConfig } from "@/types/sip";
const DEFAULT_CONFIG: SIPConfig = {
displayName: import.meta.env.VITE_SIP_DISPLAY_NAME ?? "Helix Agent",
uri: import.meta.env.VITE_SIP_URI ?? "",
password: import.meta.env.VITE_SIP_PASSWORD ?? "",
wsServer: import.meta.env.VITE_SIP_WS_SERVER ?? "",
stunServers: "stun:stun.l.google.com:19302",
const getSipConfig = (): SIPConfig => {
try {
const stored = localStorage.getItem("helix_agent_config");
if (stored) {
const config = JSON.parse(stored);
return {
displayName: "Helix Agent",
uri: config.sipUri,
password: config.sipPassword,
wsServer: config.sipWsServer,
stunServers: "stun:stun.l.google.com:19302",
};
}
} catch {
/* intentional */
}
return {
displayName: import.meta.env.VITE_SIP_DISPLAY_NAME ?? "Helix Agent",
uri: import.meta.env.VITE_SIP_URI ?? "",
password: import.meta.env.VITE_SIP_PASSWORD ?? "",
wsServer: import.meta.env.VITE_SIP_WS_SERVER ?? "",
stunServers: "stun:stun.l.google.com:19302",
};
};
export const SipProvider = ({ children }: PropsWithChildren) => {
@@ -41,7 +58,7 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
// Auto-connect SIP on mount
useEffect(() => {
connectSip(DEFAULT_CONFIG);
connectSip(getSipConfig());
}, []);
// Call duration timer
@@ -81,6 +98,7 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
};
// Hook for components to access SIP actions + state
// eslint-disable-next-line react-refresh/only-export-components
export const useSip = () => {
const [connectionStatus] = useAtom(sipConnectionStatusAtom);
const [callState] = useAtom(sipCallStateAtom);
@@ -132,7 +150,7 @@ export const useSip = () => {
isInCall: ["ringing-in", "ringing-out", "active"].includes(callState),
ozonetelStatus: "logged-in" as const,
ozonetelError: null as string | null,
connect: () => connectSip(DEFAULT_CONFIG),
connect: () => connectSip(getSipConfig()),
disconnect: disconnectSip,
makeCall,
answer,

View File

@@ -10,6 +10,7 @@ interface ThemeContextType {
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
// eslint-disable-next-line react-refresh/only-export-components
export const useTheme = (): ThemeContextType => {
const context = useContext(ThemeContext);
@@ -76,6 +77,7 @@ export const ThemeProvider = ({ children, defaultTheme = "system", storageKey =
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [theme]);
return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>;