mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
Merge branch 'dev' into dev-kartik
This commit is contained in:
5
.env.production
Normal file
5
.env.production
Normal 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
|
||||||
643
docs/superpowers/plans/2026-03-23-multi-agent-sip-lockout.md
Normal file
643
docs/superpowers/plans/2026-03-23-multi-agent-sip-lockout.md
Normal 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
|
||||||
|
```
|
||||||
531
docs/superpowers/plans/2026-03-24-supervisor-module.md
Normal file
531
docs/superpowers/plans/2026-03-24-supervisor-module.md
Normal 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
|
||||||
|
```
|
||||||
176
docs/superpowers/specs/2026-03-23-multi-agent-sip-lockout.md
Normal file
176
docs/superpowers/specs/2026-03-23-multi-agent-sip-lockout.md
Normal 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.
|
||||||
191
docs/superpowers/specs/2026-03-24-supervisor-module.md
Normal file
191
docs/superpowers/specs/2026-03-24-supervisor-module.md
Normal 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 |
|
||||||
@@ -20,6 +20,7 @@ interface NavItemBaseProps {
|
|||||||
/** Type of the nav item. */
|
/** Type of the nav item. */
|
||||||
type: "link" | "collapsible" | "collapsible-child";
|
type: "link" | "collapsible" | "collapsible-child";
|
||||||
/** Icon component to display. */
|
/** Icon component to display. */
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
icon?: FC<Record<string, any>>;
|
icon?: FC<Record<string, any>>;
|
||||||
/** Badge to display. */
|
/** Badge to display. */
|
||||||
badge?: ReactNode;
|
badge?: ReactNode;
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ export type NavItemType = {
|
|||||||
/** URL to navigate to when the nav item is clicked. */
|
/** URL to navigate to when the nav item is clicked. */
|
||||||
href?: string;
|
href?: string;
|
||||||
/** Icon component to display. */
|
/** Icon component to display. */
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
icon?: FC<Record<string, any>>;
|
icon?: FC<Record<string, any>>;
|
||||||
/** Badge to display. */
|
/** Badge to display. */
|
||||||
badge?: ReactNode;
|
badge?: ReactNode;
|
||||||
/** List of sub-items to display. */
|
/** 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 }[];
|
items?: { label: string; href: string; icon?: FC<Record<string, any>>; badge?: ReactNode }[];
|
||||||
/** Whether this nav item is a divider. */
|
/** Whether this nav item is a divider. */
|
||||||
divider?: boolean;
|
divider?: boolean;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const Trash01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon
|
|||||||
* @param bytes - The size of the file in bytes.
|
* @param bytes - The size of the file in bytes.
|
||||||
* @returns A string representing the file size in a human-readable format.
|
* @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) => {
|
export const getReadableFileSize = (bytes: number) => {
|
||||||
if (bytes === 0) return "0 KB";
|
if (bytes === 0) return "0 KB";
|
||||||
|
|
||||||
@@ -388,6 +389,7 @@ const FileUploadList = (props: ComponentPropsWithRef<"ul">) => (
|
|||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const FileUpload = {
|
export const FileUpload = {
|
||||||
Root: FileUploadRoot,
|
Root: FileUploadRoot,
|
||||||
List: FileUploadList,
|
List: FileUploadList,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export interface PaginationRootProps {
|
|||||||
onPageChange?: (page: number) => void;
|
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 PaginationRoot = ({ total, siblingCount = 1, page, onPageChange, children, style, className }: PaginationRootProps) => {
|
||||||
const createPaginationItems = useCallback((): PaginationItemType[] => {
|
const createPaginationItems = useCallback((): PaginationItemType[] => {
|
||||||
const items: PaginationItemType[] = [];
|
const items: PaginationItemType[] = [];
|
||||||
@@ -202,6 +203,7 @@ interface TriggerProps {
|
|||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
const Trigger: FC<TriggerProps> = ({ children, style, className, asChild = false, direction, ariaLabel }) => {
|
const Trigger: FC<TriggerProps> = ({ children, style, className, asChild = false, direction, ariaLabel }) => {
|
||||||
const context = useContext(PaginationContext);
|
const context = useContext(PaginationContext);
|
||||||
if (!context) {
|
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" />;
|
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" />;
|
const PaginationNextTrigger: FC<Omit<TriggerProps, "direction">> = (props) => <Trigger {...props} direction="next" />;
|
||||||
|
|
||||||
interface PaginationItemRenderProps {
|
interface PaginationItemRenderProps {
|
||||||
@@ -276,6 +280,7 @@ export interface PaginationItemProps {
|
|||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
const PaginationItem = ({ value, isCurrent, children, style, className, ariaLabel, asChild = false }: PaginationItemProps) => {
|
const PaginationItem = ({ value, isCurrent, children, style, className, ariaLabel, asChild = false }: PaginationItemProps) => {
|
||||||
const context = useContext(PaginationContext);
|
const context = useContext(PaginationContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
@@ -338,6 +343,7 @@ interface PaginationEllipsisProps {
|
|||||||
className?: string | (() => string);
|
className?: string | (() => string);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
const PaginationEllipsis: FC<PaginationEllipsisProps> = ({ children, style, className }) => {
|
const PaginationEllipsis: FC<PaginationEllipsisProps> = ({ children, style, className }) => {
|
||||||
const computedClassName = typeof className === "function" ? className() : className;
|
const computedClassName = typeof className === "function" ? className() : className;
|
||||||
|
|
||||||
@@ -352,6 +358,7 @@ interface PaginationContextComponentProps {
|
|||||||
children: (pagination: PaginationContextType) => ReactNode;
|
children: (pagination: PaginationContextType) => ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
const PaginationContextComponent: FC<PaginationContextComponentProps> = ({ children }) => {
|
const PaginationContextComponent: FC<PaginationContextComponentProps> = ({ children }) => {
|
||||||
const context = useContext(PaginationContext);
|
const context = useContext(PaginationContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
export * from "./avatar-add-button";
|
export * from "./avatar-add-button";
|
||||||
export * from "./avatar-company-icon";
|
export * from "./avatar-company-icon";
|
||||||
export * from "./avatar-online-indicator";
|
export * from "./avatar-online-indicator";
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { badgeTypes } from "./badge-types";
|
|||||||
|
|
||||||
const CloseX: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faXmark} className={className} />;
|
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 }> = {
|
export const filledColors: Record<BadgeColors, { root: string; addon: string; addonButton: string }> = {
|
||||||
gray: {
|
gray: {
|
||||||
root: "bg-utility-gray-50 text-utility-gray-700 ring-utility-gray-200",
|
root: "bg-utility-gray-50 text-utility-gray-700 ring-utility-gray-200",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import { cx, sortCx } from "@/utils/cx";
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
import { isReactComponent } from "@/utils/is-react-component";
|
import { isReactComponent } from "@/utils/is-react-component";
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const styles = sortCx({
|
export const styles = sortCx({
|
||||||
common: {
|
common: {
|
||||||
root: [
|
root: [
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Tooltip } from "@/components/base/tooltip/tooltip";
|
|||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
import { isReactComponent } from "@/utils/is-react-component";
|
import { isReactComponent } from "@/utils/is-react-component";
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const styles = {
|
export const styles = {
|
||||||
secondary:
|
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",
|
"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",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Button as AriaButton, Link as AriaLink } from "react-aria-components";
|
|||||||
import { cx, sortCx } from "@/utils/cx";
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
import { isReactComponent } from "@/utils/is-react-component";
|
import { isReactComponent } from "@/utils/is-react-component";
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const styles = sortCx({
|
export const styles = sortCx({
|
||||||
common: {
|
common: {
|
||||||
root: [
|
root: [
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Button as AriaButton, Link as AriaLink } from "react-aria-components";
|
|||||||
import { cx, sortCx } from "@/utils/cx";
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
import { AppleLogo, DribbleLogo, FacebookLogo, FigmaLogo, FigmaLogoOutlined, GoogleLogo, TwitterLogo } from "./social-logos";
|
import { AppleLogo, DribbleLogo, FacebookLogo, FigmaLogo, FigmaLogoOutlined, GoogleLogo, TwitterLogo } from "./social-logos";
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const styles = sortCx({
|
export const styles = sortCx({
|
||||||
common: {
|
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",
|
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",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ interface DropdownItemProps extends AriaMenuItemProps {
|
|||||||
icon?: FC<{ className?: string }>;
|
icon?: FC<{ className?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
const DropdownItem = ({ label, children, addon, icon: Icon, unstyled, ...props }: DropdownItemProps) => {
|
const DropdownItem = ({ label, children, addon, icon: Icon, unstyled, ...props }: DropdownItemProps) => {
|
||||||
if (unstyled) {
|
if (unstyled) {
|
||||||
return <AriaMenuItem id={label} textValue={label} {...props} />;
|
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>;
|
type DropdownMenuProps<T extends object> = AriaMenuProps<T>;
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
const DropdownMenu = <T extends object>(props: DropdownMenuProps<T>) => {
|
const DropdownMenu = <T extends object>(props: DropdownMenuProps<T>) => {
|
||||||
return (
|
return (
|
||||||
<AriaMenu
|
<AriaMenu
|
||||||
@@ -106,6 +108,7 @@ const DropdownMenu = <T extends object>(props: DropdownMenuProps<T>) => {
|
|||||||
|
|
||||||
type DropdownPopoverProps = AriaPopoverProps;
|
type DropdownPopoverProps = AriaPopoverProps;
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
const DropdownPopover = (props: DropdownPopoverProps) => {
|
const DropdownPopover = (props: DropdownPopoverProps) => {
|
||||||
return (
|
return (
|
||||||
<AriaPopover
|
<AriaPopover
|
||||||
@@ -127,10 +130,12 @@ const DropdownPopover = (props: DropdownPopoverProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
const DropdownSeparator = (props: AriaSeparatorProps) => {
|
const DropdownSeparator = (props: AriaSeparatorProps) => {
|
||||||
return <AriaSeparator {...props} className={cx("my-1 h-px w-full bg-border-secondary", props.className)} />;
|
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>) => {
|
const DropdownDotsButton = (props: AriaButtonProps & RefAttributes<HTMLButtonElement>) => {
|
||||||
return (
|
return (
|
||||||
<AriaButton
|
<AriaButton
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ const detectCardType = (number: string) => {
|
|||||||
/**
|
/**
|
||||||
* Format the card number in groups of 4 digits (i.e. 1234 5678 9012 3456).
|
* 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) => {
|
export const formatCardNumber = (number: string) => {
|
||||||
// Remove non-numeric characters
|
// Remove non-numeric characters
|
||||||
const cleaned = number.replace(/\D/g, "");
|
const cleaned = number.replace(/\D/g, "");
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const PinInputContext = createContext<PinInputContextType>({
|
|||||||
disabled: false,
|
disabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const usePinInputContext = () => {
|
export const usePinInputContext = () => {
|
||||||
const context = useContext(PinInputContext);
|
const context = useContext(PinInputContext);
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ interface SelectValueProps {
|
|||||||
placeholderIcon?: FC | ReactNode;
|
placeholderIcon?: FC | ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const sizes = {
|
export const sizes = {
|
||||||
sm: { root: "py-2 px-3", shortcut: "pr-2.5" },
|
sm: { root: "py-2 px-3", shortcut: "pr-2.5" },
|
||||||
md: { root: "py-2.5 px-3.5", shortcut: "pr-3" },
|
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" });
|
export const SelectContext = createContext<{ size: "sm" | "md" }>({ size: "sm" });
|
||||||
|
|
||||||
const Select = ({ placeholder = "Select", placeholderIcon, size = "sm", children, items, label, hint, tooltip, className, ...rest }: SelectProps) => {
|
const Select = ({ placeholder = "Select", placeholderIcon, size = "sm", children, items, label, hint, tooltip, className, ...rest }: SelectProps) => {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
faCalendarPlus,
|
faCalendarPlus,
|
||||||
faCheckCircle,
|
|
||||||
faClipboardQuestion,
|
faClipboardQuestion,
|
||||||
faMicrophone,
|
faMicrophone,
|
||||||
faMicrophoneSlash,
|
faMicrophoneSlash,
|
||||||
@@ -50,7 +49,6 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
||||||
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
||||||
const [postCallStage, setPostCallStage] = useState<PostCallStage | null>(null);
|
const [postCallStage, setPostCallStage] = useState<PostCallStage | null>(null);
|
||||||
const [savedDisposition, setSavedDisposition] = useState<CallDisposition | null>(null);
|
|
||||||
const [appointmentOpen, setAppointmentOpen] = useState(false);
|
const [appointmentOpen, setAppointmentOpen] = useState(false);
|
||||||
const [appointmentBookedDuringCall, setAppointmentBookedDuringCall] = useState(false);
|
const [appointmentBookedDuringCall, setAppointmentBookedDuringCall] = useState(false);
|
||||||
const [transferOpen, setTransferOpen] = 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
|
// Capture direction at mount — survives through disposition stage
|
||||||
const callDirectionRef = useRef(callState === "ringing-out" ? "OUTBOUND" : "INBOUND");
|
const callDirectionRef = useRef(callState === "ringing-out" ? "OUTBOUND" : "INBOUND");
|
||||||
// Track if the call was ever answered (reached 'active' state)
|
// Track if the call was ever answered (reached 'active' state)
|
||||||
const [wasAnswered, setWasAnswered] = useState(callState === "active");
|
const wasAnsweredRef = useRef(callState === "active");
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (callState === "active") {
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setWasAnswered(true);
|
|
||||||
}
|
|
||||||
}, [callState]);
|
|
||||||
|
|
||||||
const firstName = lead?.contactName?.firstName ?? "";
|
const firstName = lead?.contactName?.firstName ?? "";
|
||||||
const lastName = lead?.contactName?.lastName ?? "";
|
const lastName = lead?.contactName?.lastName ?? "";
|
||||||
@@ -75,8 +66,6 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
const phoneDisplay = phone ? formatPhone(phone) : callerPhone || "Unknown";
|
const phoneDisplay = phone ? formatPhone(phone) : callerPhone || "Unknown";
|
||||||
|
|
||||||
const handleDisposition = async (disposition: CallDisposition, notes: string) => {
|
const handleDisposition = async (disposition: CallDisposition, notes: string) => {
|
||||||
setSavedDisposition(disposition);
|
|
||||||
|
|
||||||
// Submit disposition to sidecar — handles Ozonetel ACW release
|
// Submit disposition to sidecar — handles Ozonetel ACW release
|
||||||
if (callUcid) {
|
if (callUcid) {
|
||||||
apiClient
|
apiClient
|
||||||
@@ -93,12 +82,8 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
.catch((err) => console.warn("Disposition failed:", err));
|
.catch((err) => console.warn("Disposition failed:", err));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (disposition === "APPOINTMENT_BOOKED") {
|
// Side effects per disposition type
|
||||||
setPostCallStage("appointment");
|
if (disposition === "FOLLOW_UP_SCHEDULED") {
|
||||||
setAppointmentOpen(true);
|
|
||||||
} else if (disposition === "FOLLOW_UP_SCHEDULED") {
|
|
||||||
setPostCallStage("follow-up");
|
|
||||||
// Create follow-up
|
|
||||||
try {
|
try {
|
||||||
await apiClient.graphql(
|
await apiClient.graphql(
|
||||||
`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`,
|
`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`,
|
||||||
@@ -109,6 +94,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
status: "PENDING",
|
status: "PENDING",
|
||||||
assignedAgent: null,
|
assignedAgent: null,
|
||||||
priority: "NORMAL",
|
priority: "NORMAL",
|
||||||
|
// eslint-disable-next-line react-hooks/purity
|
||||||
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -118,27 +104,23 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
} catch {
|
} catch {
|
||||||
notify.info("Follow-up", "Could not auto-create follow-up");
|
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 = () => {
|
const handleAppointmentSaved = () => {
|
||||||
setAppointmentOpen(false);
|
setAppointmentOpen(false);
|
||||||
notify.success("Appointment Booked", "Payment link will be sent to the patient");
|
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") {
|
if (callState === "active") {
|
||||||
setAppointmentBookedDuringCall(true);
|
setAppointmentBookedDuringCall(true);
|
||||||
} else {
|
|
||||||
setPostCallStage("done");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setPostCallStage(null);
|
setPostCallStage(null);
|
||||||
setSavedDisposition(null);
|
|
||||||
setCallState("idle");
|
setCallState("idle");
|
||||||
setCallerNumber(null);
|
setCallerNumber(null);
|
||||||
setCallUcid(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)
|
// 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 (
|
return (
|
||||||
<div className="rounded-xl border border-secondary bg-primary p-4 text-center">
|
<div className="rounded-xl border border-secondary bg-primary p-4 text-center">
|
||||||
<FontAwesomeIcon icon={faPhoneHangup} className="mb-2 size-6 text-fg-quaternary" />
|
<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)
|
// Post-call flow takes priority over active state (handles race between hangup + SIP ended event)
|
||||||
if (postCallStage !== null || callState === "ended" || callState === "failed") {
|
if (postCallStage !== null || callState === "ended" || callState === "failed") {
|
||||||
// Done state
|
// Disposition form + enquiry access
|
||||||
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
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
<>
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||||
<div className="flex size-8 items-center justify-center rounded-full bg-secondary">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<FontAwesomeIcon icon={faPhoneHangup} className="size-3.5 text-fg-quaternary" />
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<div className="flex size-8 items-center justify-center rounded-full bg-secondary">
|
||||||
<div>
|
<FontAwesomeIcon icon={faPhoneHangup} className="size-3.5 text-fg-quaternary" />
|
||||||
<p className="text-sm font-semibold text-primary">Call Ended — {fullName || phoneDisplay}</p>
|
</div>
|
||||||
<p className="text-xs text-tertiary">{formatDuration(callDuration)} · Log this call</p>
|
<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>
|
</div>
|
||||||
|
<DispositionForm onSubmit={handleDisposition} defaultDisposition={appointmentBookedDuringCall ? "APPOINTMENT_BOOKED" : null} />
|
||||||
</div>
|
</div>
|
||||||
<DispositionForm onSubmit={handleDisposition} defaultDisposition={appointmentBookedDuringCall ? "APPOINTMENT_BOOKED" : null} />
|
<EnquiryForm
|
||||||
</div>
|
isOpen={enquiryOpen}
|
||||||
|
onOpenChange={setEnquiryOpen}
|
||||||
|
callerPhone={callerPhone}
|
||||||
|
onSaved={() => {
|
||||||
|
setEnquiryOpen(false);
|
||||||
|
notify.success("Enquiry Logged");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active call
|
// Active call
|
||||||
if (callState === "active") {
|
if (callState === "active") {
|
||||||
|
wasAnsweredRef.current = true;
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-brand bg-primary p-4">
|
<div className="rounded-xl border border-brand bg-primary p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -323,7 +290,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const action = recordingPaused ? "unPause" : "pause";
|
const action = recordingPaused ? "unPause" : "pause";
|
||||||
if (callUcid) apiClient.post("/api/ozonetel/recording", { ucid: callUcid, action }).catch(() => {});
|
if (callUcid) apiClient.post("/api/ozonetel/recording", { ucid: callUcid, action }).catch(() => {});
|
||||||
setRecordingPaused((prev) => !prev);
|
setRecordingPaused(!recordingPaused);
|
||||||
}}
|
}}
|
||||||
title={recordingPaused ? "Resume Recording" : "Pause Recording"}
|
title={recordingPaused ? "Resume Recording" : "Pause Recording"}
|
||||||
className={cx(
|
className={cx(
|
||||||
@@ -339,25 +306,34 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
{/* Text+Icon primary actions */}
|
{/* Text+Icon primary actions */}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
color="secondary"
|
color={appointmentOpen ? "primary" : "secondary"}
|
||||||
iconLeading={<FontAwesomeIcon icon={faCalendarPlus} data-icon className="size-3.5" />}
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
onClick={() => setAppointmentOpen(true)}
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
|
||||||
|
onClick={() => {
|
||||||
|
setAppointmentOpen(!appointmentOpen);
|
||||||
|
setEnquiryOpen(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Book Appt
|
Book Appt
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
color="secondary"
|
color={enquiryOpen ? "primary" : "secondary"}
|
||||||
iconLeading={<FontAwesomeIcon icon={faClipboardQuestion} data-icon className="size-3.5" />}
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
onClick={() => setEnquiryOpen((prev) => !prev)}
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
|
||||||
|
onClick={() => {
|
||||||
|
setEnquiryOpen(!enquiryOpen);
|
||||||
|
setAppointmentOpen(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Enquiry
|
Enquiry
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
iconLeading={<FontAwesomeIcon icon={faPhoneArrowRight} data-icon className="size-3.5" />}
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
onClick={() => setTransferOpen((prev) => !prev)}
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
|
||||||
|
onClick={() => setTransferOpen(!transferOpen)}
|
||||||
>
|
>
|
||||||
Transfer
|
Transfer
|
||||||
</Button>
|
</Button>
|
||||||
@@ -365,7 +341,8 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
size="sm"
|
size="sm"
|
||||||
color="primary-destructive"
|
color="primary-destructive"
|
||||||
className="ml-auto"
|
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={() => {
|
onClick={() => {
|
||||||
hangup();
|
hangup();
|
||||||
setPostCallStage("disposition");
|
setPostCallStage("disposition");
|
||||||
@@ -395,6 +372,8 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
callerNumber={callerPhone}
|
callerNumber={callerPhone}
|
||||||
leadName={fullName || null}
|
leadName={fullName || null}
|
||||||
leadId={lead?.id ?? null}
|
leadId={lead?.id ?? null}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
patientId={(lead as any)?.patientId ?? null}
|
||||||
onSaved={handleAppointmentSaved}
|
onSaved={handleAppointmentSaved}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,11 @@ export const AiChatPanel = ({ callerContext, role = "cc-agent" }: AiChatPanelPro
|
|||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const scrollToBottom = useCallback(() => {
|
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(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ type ExistingAppointment = {
|
|||||||
doctorId?: string;
|
doctorId?: string;
|
||||||
department: string;
|
department: string;
|
||||||
reasonForVisit?: string;
|
reasonForVisit?: string;
|
||||||
appointmentStatus: string;
|
status: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AppointmentFormProps = {
|
type AppointmentFormProps = {
|
||||||
@@ -29,6 +29,7 @@ type AppointmentFormProps = {
|
|||||||
callerNumber?: string | null;
|
callerNumber?: string | null;
|
||||||
leadName?: string | null;
|
leadName?: string | null;
|
||||||
leadId?: string | null;
|
leadId?: string | null;
|
||||||
|
patientId?: string | null;
|
||||||
onSaved?: () => void;
|
onSaved?: () => void;
|
||||||
existingAppointment?: ExistingAppointment | null;
|
existingAppointment?: ExistingAppointment | null;
|
||||||
};
|
};
|
||||||
@@ -63,7 +64,7 @@ const timeSlotItems = [
|
|||||||
|
|
||||||
const formatDeptLabel = (dept: string) => dept.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
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;
|
const isEditMode = !!existingAppointment;
|
||||||
|
|
||||||
// Doctor data from platform
|
// Doctor data from platform
|
||||||
@@ -103,6 +104,7 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
|
|||||||
// Fetch doctors on mount
|
// Fetch doctors on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
apiClient
|
apiClient
|
||||||
.graphql<{ doctors: { edges: Array<{ node: any }> } }>(
|
.graphql<{ doctors: { edges: Array<{ node: any }> } }>(
|
||||||
`{ doctors(first: 50) { edges { node {
|
`{ doctors(first: 50) { edges { node {
|
||||||
@@ -129,17 +131,18 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
|
|||||||
}
|
}
|
||||||
|
|
||||||
setLoadingSlots(true);
|
setLoadingSlots(true);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
apiClient
|
apiClient
|
||||||
.graphql<{ appointments: { edges: Array<{ node: any }> } }>(
|
.graphql<{ appointments: { edges: Array<{ node: any }> } }>(
|
||||||
`{ appointments(filter: {
|
`{ appointments(filter: {
|
||||||
doctorId: { eq: "${doctor}" },
|
doctorId: { eq: "${doctor}" },
|
||||||
scheduledAt: { gte: "${date}T00:00:00", lte: "${date}T23:59:59" }
|
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) => {
|
.then((data) => {
|
||||||
// Filter out cancelled/completed appointments client-side
|
// Filter out cancelled/completed appointments client-side
|
||||||
const activeAppointments = data.appointments.edges.filter((e) => {
|
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";
|
return status !== "CANCELLED" && status !== "COMPLETED" && status !== "NO_SHOW";
|
||||||
});
|
});
|
||||||
const slots = activeAppointments.map((e) => {
|
const slots = activeAppointments.map((e) => {
|
||||||
@@ -198,7 +201,7 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
|
|||||||
if (isEditMode && existingAppointment) {
|
if (isEditMode && existingAppointment) {
|
||||||
// Update existing appointment
|
// Update existing appointment
|
||||||
await apiClient.graphql(
|
await apiClient.graphql(
|
||||||
`mutation UpdateAppointment($id: ID!, $data: AppointmentUpdateInput!) {
|
`mutation UpdateAppointment($id: UUID!, $data: AppointmentUpdateInput!) {
|
||||||
updateAppointment(id: $id, data: $data) { id }
|
updateAppointment(id: $id, data: $data) { id }
|
||||||
}`,
|
}`,
|
||||||
{
|
{
|
||||||
@@ -214,22 +217,6 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
|
|||||||
);
|
);
|
||||||
notify.success("Appointment Updated");
|
notify.success("Appointment Updated");
|
||||||
} else {
|
} 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
|
// Create appointment
|
||||||
await apiClient.graphql(
|
await apiClient.graphql(
|
||||||
`mutation CreateAppointment($data: AppointmentCreateInput!) {
|
`mutation CreateAppointment($data: AppointmentCreateInput!) {
|
||||||
@@ -240,12 +227,12 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
|
|||||||
scheduledAt,
|
scheduledAt,
|
||||||
durationMin: 30,
|
durationMin: 30,
|
||||||
appointmentType: "CONSULTATION",
|
appointmentType: "CONSULTATION",
|
||||||
appointmentStatus: "SCHEDULED",
|
status: "SCHEDULED",
|
||||||
doctorName: selectedDoctor?.name ?? "",
|
doctorName: selectedDoctor?.name ?? "",
|
||||||
department: selectedDoctor?.department ?? "",
|
department: selectedDoctor?.department ?? "",
|
||||||
doctorId: doctor,
|
doctorId: doctor,
|
||||||
reasonForVisit: chiefComplaint || null,
|
reasonForVisit: chiefComplaint || null,
|
||||||
...(leadId ? { patientId: leadId } : {}),
|
...(patientId ? { patientId } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -254,7 +241,7 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
|
|||||||
if (leadId) {
|
if (leadId) {
|
||||||
await apiClient
|
await apiClient
|
||||||
.graphql(
|
.graphql(
|
||||||
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
|
`mutation UpdateLead($id: UUID!, $data: LeadUpdateInput!) {
|
||||||
updateLead(id: $id, data: $data) { id }
|
updateLead(id: $id, data: $data) { id }
|
||||||
}`,
|
}`,
|
||||||
{
|
{
|
||||||
@@ -283,12 +270,12 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
|
|||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
await apiClient.graphql(
|
await apiClient.graphql(
|
||||||
`mutation CancelAppointment($id: ID!, $data: AppointmentUpdateInput!) {
|
`mutation CancelAppointment($id: UUID!, $data: AppointmentUpdateInput!) {
|
||||||
updateAppointment(id: $id, data: $data) { id }
|
updateAppointment(id: $id, data: $data) { id }
|
||||||
}`,
|
}`,
|
||||||
{
|
{
|
||||||
id: existingAppointment.id,
|
id: existingAppointment.id,
|
||||||
data: { appointmentStatus: "CANCELLED" },
|
data: { status: "CANCELLED" },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
notify.success("Appointment Cancelled");
|
notify.success("Appointment Cancelled");
|
||||||
|
|||||||
@@ -106,7 +106,9 @@ export const CallWidget = () => {
|
|||||||
const [disposition, setDisposition] = useState<CallDisposition | null>(null);
|
const [disposition, setDisposition] = useState<CallDisposition | null>(null);
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [lastDuration, setLastDuration] = useState(0);
|
const [lastDuration, setLastDuration] = useState(0);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const [matchedLead, setMatchedLead] = useState<any>(null);
|
const [matchedLead, setMatchedLead] = useState<any>(null);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const [leadActivities, setLeadActivities] = useState<any[]>([]);
|
const [leadActivities, setLeadActivities] = useState<any[]>([]);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [isAppointmentOpen, setIsAppointmentOpen] = useState(false);
|
const [isAppointmentOpen, setIsAppointmentOpen] = useState(false);
|
||||||
@@ -214,7 +216,7 @@ export const CallWidget = () => {
|
|||||||
if (newStatus) {
|
if (newStatus) {
|
||||||
await apiClient
|
await apiClient
|
||||||
.graphql(
|
.graphql(
|
||||||
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
|
`mutation UpdateLead($id: UUID!, $data: LeadUpdateInput!) {
|
||||||
updateLead(id: $id, data: $data) { id }
|
updateLead(id: $id, data: $data) { id }
|
||||||
}`,
|
}`,
|
||||||
{
|
{
|
||||||
@@ -382,6 +384,7 @@ export const CallWidget = () => {
|
|||||||
{leadActivities.length > 0 && (
|
{leadActivities.length > 0 && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-xs font-semibold text-tertiary">Recent Activity</div>
|
<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) => (
|
{leadActivities.slice(0, 3).map((a: any, i: number) => (
|
||||||
<div key={i} className="text-xs text-quaternary">
|
<div key={i} className="text-xs text-quaternary">
|
||||||
{a.activityType?.replace(/_/g, " ")}: {a.summary}
|
{a.activityType?.replace(/_/g, " ")}: {a.summary}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useSip } from "@/providers/sip-provider";
|
|||||||
import { setOutboundPending } from "@/state/sip-manager";
|
import { setOutboundPending } from "@/state/sip-manager";
|
||||||
import { sipCallStateAtom, sipCallUcidAtom, sipCallerNumberAtom } from "@/state/sip-state";
|
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 }) => (
|
const Phone01: FC<{ className?: string } & Record<string, any>> = ({ className, ...rest }) => (
|
||||||
<FontAwesomeIcon icon={faPhone} className={className} {...rest} />
|
<FontAwesomeIcon icon={faPhone} className={className} {...rest} />
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { useEffect, useState } from "react";
|
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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { Badge } from "@/components/base/badges/badges";
|
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 { formatPhone, formatShortDate } from "@/lib/format";
|
||||||
|
import { faIcon } from "@/lib/icon-wrapper";
|
||||||
import type { Lead, LeadActivity } from "@/types/entities";
|
import type { Lead, LeadActivity } from "@/types/entities";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
import { AiChatPanel } from "./ai-chat-panel";
|
import { AiChatPanel } from "./ai-chat-panel";
|
||||||
import { LiveTranscript } from "./live-transcript";
|
|
||||||
|
const CalendarCheck = faIcon(faCalendarCheck);
|
||||||
|
|
||||||
type ContextTab = "ai" | "lead360";
|
type ContextTab = "ai" | "lead360";
|
||||||
|
|
||||||
@@ -19,22 +21,15 @@ interface ContextPanelProps {
|
|||||||
callUcid?: string | null;
|
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");
|
const [activeTab, setActiveTab] = useState<ContextTab>("ai");
|
||||||
|
|
||||||
// Auto-switch to lead 360 when a lead is selected
|
// Auto-switch to lead 360 when a lead is selected
|
||||||
useEffect(() => {
|
const [prevLeadId, setPrevLeadId] = useState(selectedLead?.id);
|
||||||
if (selectedLead) {
|
if (prevLeadId !== selectedLead?.id) {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
setPrevLeadId(selectedLead?.id);
|
||||||
setActiveTab("lead360");
|
if (selectedLead) setActiveTab("lead360");
|
||||||
}
|
}
|
||||||
}, [selectedLead?.id]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
transcript,
|
|
||||||
suggestions,
|
|
||||||
connected: assistConnected,
|
|
||||||
} = useCallAssist(isInCall ?? false, callUcid ?? null, selectedLead?.id ?? null, callerPhone ?? null);
|
|
||||||
|
|
||||||
const callerContext = selectedLead
|
const callerContext = selectedLead
|
||||||
? {
|
? {
|
||||||
@@ -68,27 +63,65 @@ export const ContextPanel = ({ selectedLead, activities, callerPhone, isInCall,
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faUser} className="size-3.5" />
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab content */}
|
{/* Tab content */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
{activeTab === "ai" && (
|
||||||
{activeTab === "ai" &&
|
<div className="flex flex-1 flex-col overflow-hidden p-4">
|
||||||
(isInCall ? (
|
<AiChatPanel callerContext={callerContext} />
|
||||||
<LiveTranscript transcript={transcript} suggestions={suggestions} connected={assistConnected} />
|
</div>
|
||||||
) : (
|
)}
|
||||||
<div className="flex h-full flex-col p-4">
|
{activeTab === "lead360" && (
|
||||||
<AiChatPanel callerContext={callerContext} />
|
<div className="flex-1 overflow-y-auto">
|
||||||
</div>
|
<Lead360Tab lead={selectedLead} activities={activities} />
|
||||||
))}
|
</div>
|
||||||
{activeTab === "lead360" && <Lead360Tab lead={selectedLead} activities={activities} />}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadActivity[] }) => {
|
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) {
|
if (!lead) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center px-4 py-16 text-center">
|
<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())
|
.sort((a, b) => new Date(b.occurredAt ?? b.createdAt ?? "").getTime() - new Date(a.occurredAt ?? a.createdAt ?? "").getTime())
|
||||||
.slice(0, 10);
|
.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 (
|
return (
|
||||||
<div className="space-y-4 p-4">
|
<div className="space-y-4 p-4">
|
||||||
{/* Profile */}
|
{/* Profile */}
|
||||||
@@ -117,6 +162,16 @@ const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadA
|
|||||||
{phone && <p className="text-sm text-secondary">{formatPhone(phone)}</p>}
|
{phone && <p className="text-sm text-secondary">{formatPhone(phone)}</p>}
|
||||||
{email && <p className="text-xs text-tertiary">{email}</p>}
|
{email && <p className="text-xs text-tertiary">{email}</p>}
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
<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 && (
|
{lead.leadStatus && (
|
||||||
<Badge size="sm" color="brand">
|
<Badge size="sm" color="brand">
|
||||||
{lead.leadStatus}
|
{lead.leadStatus}
|
||||||
@@ -134,9 +189,69 @@ const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadA
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{lead.interestedService && <p className="mt-2 text-sm text-secondary">Interested in: {lead.interestedService}</p>}
|
{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>
|
</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 */}
|
{/* AI Insight */}
|
||||||
{(lead.aiSummary || lead.aiSuggestedAction) && (
|
{(lead.aiSummary || lead.aiSuggestedAction) && (
|
||||||
<div className="rounded-lg bg-brand-primary p-3">
|
<div className="rounded-lg bg-brand-primary p-3">
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
apiClient
|
apiClient
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
.graphql<{ doctors: { edges: Array<{ node: any }> } }>(
|
.graphql<{ doctors: { edges: Array<{ node: any }> } }>(
|
||||||
`{ doctors(first: 50) { edges { node {
|
`{ doctors(first: 50) { edges { node {
|
||||||
id name fullName { firstName lastName } department
|
id name fullName { firstName lastName } department
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export const CampaignEditSlideout = ({ isOpen, onOpenChange, campaign, onSaved }
|
|||||||
const budgetMicros = budget ? Number(budget) * 1_000_000 : null;
|
const budgetMicros = budget ? Number(budget) * 1_000_000 : null;
|
||||||
|
|
||||||
await apiClient.graphql(
|
await apiClient.graphql(
|
||||||
`mutation UpdateCampaign($id: ID!, $data: CampaignUpdateInput!) {
|
`mutation UpdateCampaign($id: UUID!, $data: CampaignUpdateInput!) {
|
||||||
updateCampaign(id: $id, data: $data) { id }
|
updateCampaign(id: $id, data: $data) { id }
|
||||||
}`,
|
}`,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { HTMLAttributes, SVGProps } from "react";
|
|||||||
import { useId } from "react";
|
import { useId } from "react";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const getStarProgress = (starPosition: number, rating: number, maxRating: number = 5) => {
|
export const getStarProgress = (starPosition: number, rating: number, maxRating: number = 5) => {
|
||||||
// Ensure rating is between 0 and 5
|
// Ensure rating is between 0 and 5
|
||||||
const clampedRating = Math.min(Math.max(rating, 0), maxRating);
|
const clampedRating = Math.min(Math.max(rating, 0), maxRating);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ReactNode } from "react";
|
import { type ReactNode, useEffect } from "react";
|
||||||
import { useLocation } from "react-router";
|
import { useLocation } from "react-router";
|
||||||
import { CallWidget } from "@/components/call-desk/call-widget";
|
import { CallWidget } from "@/components/call-desk/call-widget";
|
||||||
import { useAuth } from "@/providers/auth-provider";
|
import { useAuth } from "@/providers/auth-provider";
|
||||||
@@ -13,11 +13,30 @@ export const AppShell = ({ children }: AppShellProps) => {
|
|||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const { isCCAgent } = useAuth();
|
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 (
|
return (
|
||||||
<SipProvider>
|
<SipProvider>
|
||||||
<div className="flex min-h-screen bg-primary">
|
<div className="flex h-screen bg-primary">
|
||||||
<Sidebar activeUrl={pathname} />
|
<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 />}
|
{isCCAgent && pathname !== "/" && pathname !== "/call-desk" && <CallWidget />}
|
||||||
</div>
|
</div>
|
||||||
</SipProvider>
|
</SipProvider>
|
||||||
|
|||||||
@@ -2,16 +2,20 @@ import { useState } from "react";
|
|||||||
import {
|
import {
|
||||||
faArrowRightFromBracket,
|
faArrowRightFromBracket,
|
||||||
faBullhorn,
|
faBullhorn,
|
||||||
|
faCalendarCheck,
|
||||||
|
faChartLine,
|
||||||
faChartMixed,
|
faChartMixed,
|
||||||
faChevronLeft,
|
faChevronLeft,
|
||||||
faChevronRight,
|
faChevronRight,
|
||||||
faClockRotateLeft,
|
faClockRotateLeft,
|
||||||
faCommentDots,
|
faCommentDots,
|
||||||
|
faFileAudio,
|
||||||
faGear,
|
faGear,
|
||||||
faGrid2,
|
faGrid2,
|
||||||
faHospitalUser,
|
faHospitalUser,
|
||||||
faPhone,
|
faPhone,
|
||||||
faPlug,
|
faPhoneMissed,
|
||||||
|
faTowerBroadcast,
|
||||||
faUsers,
|
faUsers,
|
||||||
} from "@fortawesome/pro-duotone-svg-icons";
|
} from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
@@ -38,12 +42,16 @@ const IconGrid2 = faIcon(faGrid2);
|
|||||||
const IconBullhorn = faIcon(faBullhorn);
|
const IconBullhorn = faIcon(faBullhorn);
|
||||||
const IconCommentDots = faIcon(faCommentDots);
|
const IconCommentDots = faIcon(faCommentDots);
|
||||||
const IconChartMixed = faIcon(faChartMixed);
|
const IconChartMixed = faIcon(faChartMixed);
|
||||||
const IconPlug = faIcon(faPlug);
|
|
||||||
const IconGear = faIcon(faGear);
|
const IconGear = faIcon(faGear);
|
||||||
const IconPhone = faIcon(faPhone);
|
const IconPhone = faIcon(faPhone);
|
||||||
const IconClockRewind = faIcon(faClockRotateLeft);
|
const IconClockRewind = faIcon(faClockRotateLeft);
|
||||||
const IconUsers = faIcon(faUsers);
|
const IconUsers = faIcon(faUsers);
|
||||||
const IconHospitalUser = faIcon(faHospitalUser);
|
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 = {
|
type NavSection = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -53,21 +61,26 @@ type NavSection = {
|
|||||||
const getNavSections = (role: string): NavSection[] => {
|
const getNavSections = (role: string): NavSection[] => {
|
||||||
if (role === "admin") {
|
if (role === "admin") {
|
||||||
return [
|
return [
|
||||||
{ label: "Overview", items: [{ label: "Team Dashboard", href: "/", icon: IconGrid2 }] },
|
|
||||||
{
|
{
|
||||||
label: "Management",
|
label: "Supervisor",
|
||||||
items: [
|
items: [
|
||||||
{ label: "Campaigns", href: "/campaigns", icon: IconBullhorn },
|
{ label: "Dashboard", href: "/", icon: IconGrid2 },
|
||||||
{ label: "Analytics", href: "/reports", icon: IconChartMixed },
|
{ label: "Team Performance", href: "/team-performance", icon: IconChartLine },
|
||||||
|
{ label: "Live Call Monitor", href: "/live-monitor", icon: IconTowerBroadcast },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Admin",
|
label: "Data & Reports",
|
||||||
items: [
|
items: [
|
||||||
{ label: "Integrations", href: "/integrations", icon: IconPlug },
|
{ label: "Lead Master", href: "/leads", icon: IconUsers },
|
||||||
{ label: "Settings", href: "/settings", icon: IconGear },
|
{ 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 Desk", href: "/", icon: IconPhone },
|
||||||
{ label: "Call History", href: "/call-history", icon: IconClockRewind },
|
{ label: "Call History", href: "/call-history", icon: IconClockRewind },
|
||||||
{ label: "Patients", href: "/patients", icon: IconHospitalUser },
|
{ label: "Patients", href: "/patients", icon: IconHospitalUser },
|
||||||
|
{ label: "Appointments", href: "/appointments", icon: IconCalendarCheck },
|
||||||
{ label: "My Performance", href: "/my-performance", icon: IconChartMixed },
|
{ label: "My Performance", href: "/my-performance", icon: IconChartMixed },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -92,6 +106,7 @@ const getNavSections = (role: string): NavSection[] => {
|
|||||||
{ label: "Lead Workspace", href: "/", icon: IconGrid2 },
|
{ label: "Lead Workspace", href: "/", icon: IconGrid2 },
|
||||||
{ label: "All Leads", href: "/leads", icon: IconUsers },
|
{ label: "All Leads", href: "/leads", icon: IconUsers },
|
||||||
{ label: "Patients", href: "/patients", icon: IconHospitalUser },
|
{ label: "Patients", href: "/patients", icon: IconHospitalUser },
|
||||||
|
{ label: "Appointments", href: "/appointments", icon: IconCalendarCheck },
|
||||||
{ label: "Campaigns", href: "/campaigns", icon: IconBullhorn },
|
{ label: "Campaigns", href: "/campaigns", icon: IconBullhorn },
|
||||||
{ label: "Outreach", href: "/outreach", icon: IconCommentDots },
|
{ label: "Outreach", href: "/outreach", icon: IconCommentDots },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const BoxIllustration = ({ size = "lg", ...otherProps }: IllustrationProp
|
|||||||
return <Pattern {...otherProps} />;
|
return <Pattern {...otherProps} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const sm = ({
|
export const sm = ({
|
||||||
className,
|
className,
|
||||||
svgClassName,
|
svgClassName,
|
||||||
@@ -98,6 +99,7 @@ export const sm = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const md = ({
|
export const md = ({
|
||||||
className,
|
className,
|
||||||
svgClassName,
|
svgClassName,
|
||||||
@@ -179,6 +181,7 @@ export const md = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const lg = ({
|
export const lg = ({
|
||||||
className,
|
className,
|
||||||
svgClassName,
|
svgClassName,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const CloudIllustration = ({ size = "lg", ...otherProps }: IllustrationPr
|
|||||||
return <Pattern {...otherProps} />;
|
return <Pattern {...otherProps} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const sm = ({
|
export const sm = ({
|
||||||
className,
|
className,
|
||||||
svgClassName,
|
svgClassName,
|
||||||
@@ -100,6 +101,7 @@ export const sm = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const md = ({
|
export const md = ({
|
||||||
className,
|
className,
|
||||||
svgClassName,
|
svgClassName,
|
||||||
@@ -184,6 +186,7 @@ export const md = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const lg = ({
|
export const lg = ({
|
||||||
className,
|
className,
|
||||||
svgClassName,
|
svgClassName,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const CreditCardIllustration = ({ size = "lg", ...otherProps }: Illustrat
|
|||||||
return <Pattern {...otherProps} />;
|
return <Pattern {...otherProps} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const sm = ({
|
export const sm = ({
|
||||||
className,
|
className,
|
||||||
svgClassName,
|
svgClassName,
|
||||||
@@ -112,6 +113,7 @@ export const sm = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const md = ({
|
export const md = ({
|
||||||
className,
|
className,
|
||||||
svgClassName,
|
svgClassName,
|
||||||
@@ -205,6 +207,7 @@ export const md = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const lg = ({
|
export const lg = ({
|
||||||
className,
|
className,
|
||||||
svgClassName,
|
svgClassName,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const DocumentsIllustration = ({ size = "lg", ...otherProps }: Illustrati
|
|||||||
return <Pattern {...otherProps} />;
|
return <Pattern {...otherProps} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const sm = ({
|
export const sm = ({
|
||||||
className,
|
className,
|
||||||
svgClassName,
|
svgClassName,
|
||||||
@@ -189,6 +190,7 @@ export const sm = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const md = ({
|
export const md = ({
|
||||||
className,
|
className,
|
||||||
svgClassName,
|
svgClassName,
|
||||||
@@ -361,6 +363,7 @@ export const md = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const lg = ({
|
export const lg = ({
|
||||||
className,
|
className,
|
||||||
svgClassName,
|
svgClassName,
|
||||||
|
|||||||
@@ -67,8 +67,11 @@ export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
|
|||||||
debounceRef.current = setTimeout(async () => {
|
debounceRef.current = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await apiClient.get<{
|
const data = await apiClient.get<{
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
leads: Array<any>;
|
leads: Array<any>;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
patients: Array<any>;
|
patients: Array<any>;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
appointments: Array<any>;
|
appointments: Array<any>;
|
||||||
}>(`/api/search?q=${encodeURIComponent(query)}`, { silent: true });
|
}>(`/api/search?q=${encodeURIComponent(query)}`, { silent: true });
|
||||||
|
|
||||||
@@ -102,7 +105,7 @@ export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
|
|||||||
id: a.id,
|
id: a.id,
|
||||||
type: "appointment",
|
type: "appointment",
|
||||||
title: a.doctorName ?? "Appointment",
|
title: a.doctorName ?? "Appointment",
|
||||||
subtitle: [a.department, date, a.appointmentStatus].filter(Boolean).join(" · "),
|
subtitle: [a.department, date, a.status].filter(Boolean).join(" · "),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,11 +87,13 @@ export const useWorklist = (): UseWorklistResult => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const json = await apiClient.get<any>("/api/worklist", { silent: true });
|
const json = await apiClient.get<any>("/api/worklist", { silent: true });
|
||||||
|
|
||||||
// Transform platform field shapes to frontend types
|
// Transform platform field shapes to frontend types
|
||||||
const transformed: WorklistData = {
|
const transformed: WorklistData = {
|
||||||
...json,
|
...json,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
marketingLeads: (json.marketingLeads ?? []).map((lead: any) => ({
|
marketingLeads: (json.marketingLeads ?? []).map((lead: any) => ({
|
||||||
...lead,
|
...lead,
|
||||||
leadSource: lead.source ?? lead.leadSource,
|
leadSource: lead.source ?? lead.leadSource,
|
||||||
@@ -101,6 +103,7 @@ export const useWorklist = (): UseWorklistResult => {
|
|||||||
: lead.contactPhone,
|
: lead.contactPhone,
|
||||||
contactEmail: lead.contactEmail?.primaryEmail ? [{ address: lead.contactEmail.primaryEmail }] : lead.contactEmail,
|
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) => ({
|
missedCalls: (json.missedCalls ?? []).map((call: any) => ({
|
||||||
...call,
|
...call,
|
||||||
callDirection: call.direction ?? call.callDirection,
|
callDirection: call.direction ?? call.callDirection,
|
||||||
@@ -109,6 +112,7 @@ export const useWorklist = (): UseWorklistResult => {
|
|||||||
? [{ number: call.callerNumber.primaryPhoneNumber, callingCode: "+91" }]
|
? [{ number: call.callerNumber.primaryPhoneNumber, callingCode: "+91" }]
|
||||||
: call.callerNumber,
|
: call.callerNumber,
|
||||||
})),
|
})),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
followUps: (json.followUps ?? []).map((fu: any) => ({
|
followUps: (json.followUps ?? []).map((fu: any) => ({
|
||||||
...fu,
|
...fu,
|
||||||
followUpType: fu.typeCustom ?? fu.followUpType,
|
followUpType: fu.typeCustom ?? fu.followUpType,
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|||||||
// Creates a wrapper component that passes all props (including data-icon)
|
// Creates a wrapper component that passes all props (including data-icon)
|
||||||
// to FontAwesomeIcon. This is needed because the Button component uses
|
// to FontAwesomeIcon. This is needed because the Button component uses
|
||||||
// data-icon CSS selectors for sizing.
|
// data-icon CSS selectors for sizing.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export const faIcon = (icon: IconDefinition): FC<Record<string, 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 });
|
const IconComponent: FC<Record<string, any>> = (props) => createElement(FontAwesomeIcon, { icon, ...props });
|
||||||
IconComponent.displayName = `FAIcon(${icon.iconName})`;
|
IconComponent.displayName = `FAIcon(${icon.iconName})`;
|
||||||
return IconComponent;
|
return IconComponent;
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export class SIPClient {
|
|||||||
this.currentSession = session;
|
this.currentSession = session;
|
||||||
|
|
||||||
// Extract caller number and UCID — try event request first, then 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 sipRequest = (data as any).request ?? (session as any)._request ?? null;
|
||||||
const callerNumber = this.extractCallerNumber(session, sipRequest);
|
const callerNumber = this.extractCallerNumber(session, sipRequest);
|
||||||
const ucid = sipRequest?.getHeader ? (sipRequest.getHeader("X-UCID") ?? null) : null;
|
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 {
|
private extractCallerNumber(session: RTCSession, sipRequest?: any): string {
|
||||||
try {
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const request = sipRequest ?? (session.direction === "incoming" ? (session as any)._request : null);
|
const request = sipRequest ?? (session.direction === "incoming" ? (session as any)._request : null);
|
||||||
if (request) {
|
if (request) {
|
||||||
// Ozonetel sends the real caller number in X-CALLERNO header
|
// Ozonetel sends the real caller number in X-CALLERNO header
|
||||||
|
|||||||
@@ -2,12 +2,16 @@
|
|||||||
// Platform remaps field names during sync — this layer normalizes them
|
// Platform remaps field names during sync — this layer normalizes them
|
||||||
import type { Ad, Call, Campaign, FollowUp, Lead, LeadActivity, Patient } from "@/types/entities";
|
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>;
|
type PlatformNode = Record<string, any>;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
function extractEdges(data: any, entityName: string): PlatformNode[] {
|
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) ?? [];
|
return data?.[entityName]?.edges?.map((e: any) => e.node) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export function transformLeads(data: any): Lead[] {
|
export function transformLeads(data: any): Lead[] {
|
||||||
return extractEdges(data, "leads").map((n) => ({
|
return extractEdges(data, "leads").map((n) => ({
|
||||||
id: n.id,
|
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[] {
|
export function transformCampaigns(data: any): Campaign[] {
|
||||||
return extractEdges(data, "campaigns").map((n) => ({
|
return extractEdges(data, "campaigns").map((n) => ({
|
||||||
id: n.id,
|
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[] {
|
export function transformAds(data: any): Ad[] {
|
||||||
return extractEdges(data, "ads").map((n) => ({
|
return extractEdges(data, "ads").map((n) => ({
|
||||||
id: n.id,
|
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[] {
|
export function transformFollowUps(data: any): FollowUp[] {
|
||||||
return extractEdges(data, "followUps").map((n) => ({
|
return extractEdges(data, "followUps").map((n) => ({
|
||||||
id: n.id,
|
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[] {
|
export function transformLeadActivities(data: any): LeadActivity[] {
|
||||||
return extractEdges(data, "leadActivities").map((n) => ({
|
return extractEdges(data, "leadActivities").map((n) => ({
|
||||||
id: n.id,
|
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[] {
|
export function transformCalls(data: any): Call[] {
|
||||||
return extractEdges(data, "calls").map((n) => ({
|
return extractEdges(data, "calls").map((n) => ({
|
||||||
id: n.id,
|
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[] {
|
export function transformPatients(data: any): Patient[] {
|
||||||
return extractEdges(data, "patients").map((n) => ({
|
return extractEdges(data, "patients").map((n) => ({
|
||||||
id: n.id,
|
id: n.id,
|
||||||
|
|||||||
10
src/main.tsx
10
src/main.tsx
@@ -7,13 +7,17 @@ import { AuthGuard } from "@/components/layout/auth-guard";
|
|||||||
import { RoleRouter } from "@/components/layout/role-router";
|
import { RoleRouter } from "@/components/layout/role-router";
|
||||||
import { AgentDetailPage } from "@/pages/agent-detail";
|
import { AgentDetailPage } from "@/pages/agent-detail";
|
||||||
import { AllLeadsPage } from "@/pages/all-leads";
|
import { AllLeadsPage } from "@/pages/all-leads";
|
||||||
|
import { AppointmentsPage } from "@/pages/appointments";
|
||||||
import { CallDeskPage } from "@/pages/call-desk";
|
import { CallDeskPage } from "@/pages/call-desk";
|
||||||
import { CallHistoryPage } from "@/pages/call-history";
|
import { CallHistoryPage } from "@/pages/call-history";
|
||||||
|
import { CallRecordingsPage } from "@/pages/call-recordings";
|
||||||
import { CampaignDetailPage } from "@/pages/campaign-detail";
|
import { CampaignDetailPage } from "@/pages/campaign-detail";
|
||||||
import { CampaignsPage } from "@/pages/campaigns";
|
import { CampaignsPage } from "@/pages/campaigns";
|
||||||
import { FollowUpsPage } from "@/pages/follow-ups-page";
|
import { FollowUpsPage } from "@/pages/follow-ups-page";
|
||||||
import { IntegrationsPage } from "@/pages/integrations";
|
import { IntegrationsPage } from "@/pages/integrations";
|
||||||
|
import { LiveMonitorPage } from "@/pages/live-monitor";
|
||||||
import { LoginPage } from "@/pages/login";
|
import { LoginPage } from "@/pages/login";
|
||||||
|
import { MissedCallsPage } from "@/pages/missed-calls";
|
||||||
import { MyPerformancePage } from "@/pages/my-performance";
|
import { MyPerformancePage } from "@/pages/my-performance";
|
||||||
import { NotFound } from "@/pages/not-found";
|
import { NotFound } from "@/pages/not-found";
|
||||||
import { OutreachPage } from "@/pages/outreach";
|
import { OutreachPage } from "@/pages/outreach";
|
||||||
@@ -22,6 +26,7 @@ import { PatientsPage } from "@/pages/patients";
|
|||||||
import { ReportsPage } from "@/pages/reports";
|
import { ReportsPage } from "@/pages/reports";
|
||||||
import { SettingsPage } from "@/pages/settings";
|
import { SettingsPage } from "@/pages/settings";
|
||||||
import { TeamDashboardPage } from "@/pages/team-dashboard";
|
import { TeamDashboardPage } from "@/pages/team-dashboard";
|
||||||
|
import { TeamPerformancePage } from "@/pages/team-performance";
|
||||||
import { AuthProvider } from "@/providers/auth-provider";
|
import { AuthProvider } from "@/providers/auth-provider";
|
||||||
import { DataProvider } from "@/providers/data-provider";
|
import { DataProvider } from "@/providers/data-provider";
|
||||||
import { RouteProvider } from "@/providers/router-provider";
|
import { RouteProvider } from "@/providers/router-provider";
|
||||||
@@ -55,6 +60,11 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
<Route path="/my-performance" element={<MyPerformancePage />} />
|
<Route path="/my-performance" element={<MyPerformancePage />} />
|
||||||
<Route path="/call-desk" element={<CallDeskPage />} />
|
<Route path="/call-desk" element={<CallDeskPage />} />
|
||||||
<Route path="/patients" element={<PatientsPage />} />
|
<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="/team-dashboard" element={<TeamDashboardPage />} />
|
||||||
<Route path="/reports" element={<ReportsPage />} />
|
<Route path="/reports" element={<ReportsPage />} />
|
||||||
<Route path="/integrations" element={<IntegrationsPage />} />
|
<Route path="/integrations" element={<IntegrationsPage />} />
|
||||||
|
|||||||
228
src/pages/appointments.tsx
Normal file
228
src/pages/appointments.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { Badge } from "@/components/base/badges/badges";
|
import { Badge } from "@/components/base/badges/badges";
|
||||||
import { ActiveCallCard } from "@/components/call-desk/active-call-card";
|
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 { WorklistPanel } from "@/components/call-desk/worklist-panel";
|
||||||
import type { WorklistLead } from "@/components/call-desk/worklist-panel";
|
import type { WorklistLead } from "@/components/call-desk/worklist-panel";
|
||||||
import { useWorklist } from "@/hooks/use-worklist";
|
import { useWorklist } from "@/hooks/use-worklist";
|
||||||
|
import { apiClient } from "@/lib/api-client";
|
||||||
|
import { notify } from "@/lib/toast";
|
||||||
import { useAuth } from "@/providers/auth-provider";
|
import { useAuth } from "@/providers/auth-provider";
|
||||||
import { useData } from "@/providers/data-provider";
|
import { useData } from "@/providers/data-provider";
|
||||||
import { useSip } from "@/providers/sip-provider";
|
import { useSip } from "@/providers/sip-provider";
|
||||||
@@ -22,6 +24,27 @@ export const CallDeskPage = () => {
|
|||||||
const [contextOpen, setContextOpen] = useState(true);
|
const [contextOpen, setContextOpen] = useState(true);
|
||||||
const [activeMissedCallId, setActiveMissedCallId] = useState<string | null>(null);
|
const [activeMissedCallId, setActiveMissedCallId] = useState<string | null>(null);
|
||||||
const [callDismissed, setCallDismissed] = useState(false);
|
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)
|
// Reset callDismissed when a new call starts (ringing in or out)
|
||||||
if (callDismissed && (callState === "ringing-in" || callState === "ringing-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 ?? "---"))
|
? marketingLeads.find((l) => l.contactPhone?.[0]?.number?.endsWith(callerNumber) || callerNumber.endsWith(l.contactPhone?.[0]?.number ?? "---"))
|
||||||
: null;
|
: 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;
|
const activeLeadFull = activeLead as any;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -49,6 +75,62 @@ export const CallDeskPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<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} />
|
<AgentStatusToggle isRegistered={isRegistered} connectionStatus={connectionStatus} />
|
||||||
{totalPending > 0 && (
|
{totalPending > 0 && (
|
||||||
<Badge size="sm" color="brand" type="pill-color">
|
<Badge size="sm" color="brand" type="pill-color">
|
||||||
|
|||||||
174
src/pages/call-recordings.tsx
Normal file
174
src/pages/call-recordings.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -33,6 +33,7 @@ type IntegrationStatus = "connected" | "disconnected" | "configured";
|
|||||||
type IntegrationCardProps = {
|
type IntegrationCardProps = {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
icon: any;
|
icon: any;
|
||||||
iconColor: string;
|
iconColor: string;
|
||||||
status: IntegrationStatus;
|
status: IntegrationStatus;
|
||||||
|
|||||||
195
src/pages/live-monitor.tsx
Normal file
195
src/pages/live-monitor.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -7,9 +7,11 @@ import { SocialButton } from "@/components/base/buttons/social-button";
|
|||||||
import { Checkbox } from "@/components/base/checkbox/checkbox";
|
import { Checkbox } from "@/components/base/checkbox/checkbox";
|
||||||
import { Input } from "@/components/base/input/input";
|
import { Input } from "@/components/base/input/input";
|
||||||
import { useAuth } from "@/providers/auth-provider";
|
import { useAuth } from "@/providers/auth-provider";
|
||||||
|
import { useData } from "@/providers/data-provider";
|
||||||
|
|
||||||
export const LoginPage = () => {
|
export const LoginPage = () => {
|
||||||
const { loginWithUser } = useAuth();
|
const { loginWithUser } = useAuth();
|
||||||
|
const { refresh } = useData();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const saved = localStorage.getItem("helix_remember");
|
const saved = localStorage.getItem("helix_remember");
|
||||||
@@ -49,6 +51,15 @@ export const LoginPage = () => {
|
|||||||
localStorage.removeItem("helix_remember");
|
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({
|
loginWithUser({
|
||||||
id: u?.id,
|
id: u?.id,
|
||||||
name,
|
name,
|
||||||
@@ -59,7 +70,9 @@ export const LoginPage = () => {
|
|||||||
platformRoles: u?.platformRoles,
|
platformRoles: u?.platformRoles,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
refresh();
|
||||||
navigate("/");
|
navigate("/");
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|||||||
204
src/pages/missed-calls.tsx
Normal file
204
src/pages/missed-calls.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -55,6 +55,7 @@ const BRAND = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type KpiCardProps = {
|
type KpiCardProps = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
icon: any;
|
icon: any;
|
||||||
iconColor: string;
|
iconColor: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -90,12 +90,16 @@ type PatientData = {
|
|||||||
phones: { primaryPhoneNumber: string } | null;
|
phones: { primaryPhoneNumber: string } | null;
|
||||||
emails: { primaryEmail: string } | null;
|
emails: { primaryEmail: string } | null;
|
||||||
patientType: string | null;
|
patientType: string | null;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
appointments: { edges: Array<{ node: any }> };
|
appointments: { edges: Array<{ node: any }> };
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
calls: { edges: Array<{ node: any }> };
|
calls: { edges: Array<{ node: any }> };
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
leads: { edges: Array<{ node: any }> };
|
leads: { edges: Array<{ node: any }> };
|
||||||
};
|
};
|
||||||
|
|
||||||
// Appointment row component
|
// Appointment row component
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const AppointmentRow = ({ appt }: { appt: any }) => {
|
const AppointmentRow = ({ appt }: { appt: any }) => {
|
||||||
const scheduledAt = appt.scheduledAt ? formatShortDate(appt.scheduledAt) : "--";
|
const scheduledAt = appt.scheduledAt ? formatShortDate(appt.scheduledAt) : "--";
|
||||||
const statusColors: Record<string, "success" | "brand" | "warning" | "error" | "gray"> = {
|
const statusColors: Record<string, "success" | "brand" | "warning" | "error" | "gray"> = {
|
||||||
@@ -273,6 +277,7 @@ export const Patient360Page = () => {
|
|||||||
setPatient(p);
|
setPatient(p);
|
||||||
|
|
||||||
// Fetch activities from linked leads
|
// 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) ?? [];
|
const leadIds = p?.leads?.edges?.map((e: any) => e.node.id) ?? [];
|
||||||
if (leadIds.length > 0) {
|
if (leadIds.length > 0) {
|
||||||
const leadFilter = leadIds.map((lid: string) => `"${lid}"`).join(", ");
|
const leadFilter = leadIds.map((lid: string) => `"${lid}"`).join(", ");
|
||||||
@@ -296,6 +301,7 @@ export const Patient360Page = () => {
|
|||||||
|
|
||||||
const patientCalls = useMemo(
|
const patientCalls = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
(patient?.calls?.edges?.map((e) => e.node) ?? []).map((c: any) => ({
|
(patient?.calls?.edges?.map((e) => e.node) ?? []).map((c: any) => ({
|
||||||
...c,
|
...c,
|
||||||
callDirection: c.direction,
|
callDirection: c.direction,
|
||||||
@@ -447,6 +453,7 @@ export const Patient360Page = () => {
|
|||||||
<EmptyState icon="📅" title="No appointments" subtitle="Appointment history will appear here." />
|
<EmptyState icon="📅" title="No appointments" subtitle="Appointment history will appear here." />
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl border border-secondary bg-primary">
|
<div className="rounded-xl border border-secondary bg-primary">
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
{appointments.map((appt: any) => (
|
{appointments.map((appt: any) => (
|
||||||
<AppointmentRow key={appt.id} appt={appt} />
|
<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." />
|
<EmptyState icon="📞" title="No calls yet" subtitle="Call history with this patient will appear here." />
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl border border-secondary bg-primary">
|
<div className="rounded-xl border border-secondary bg-primary">
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
{patientCalls.map((call: any) => (
|
{patientCalls.map((call: any) => (
|
||||||
<CallRow key={call.id} call={call} />
|
<CallRow key={call.id} call={call} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -26,14 +26,17 @@ export const SettingsPage = () => {
|
|||||||
const fetchMembers = async () => {
|
const fetchMembers = async () => {
|
||||||
try {
|
try {
|
||||||
// Roles are only accessible via user JWT, not API key
|
// 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>(
|
const data = await apiClient.graphql<any>(
|
||||||
`{ workspaceMembers(first: 50) { edges { node { id name { firstName lastName } userEmail avatarUrl } } } }`,
|
`{ workspaceMembers(first: 50) { edges { node { id name { firstName lastName } userEmail avatarUrl } } } }`,
|
||||||
undefined,
|
undefined,
|
||||||
{ silent: true },
|
{ silent: true },
|
||||||
);
|
);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const rawMembers = data?.workspaceMembers?.edges?.map((e: any) => e.node) ?? [];
|
const rawMembers = data?.workspaceMembers?.edges?.map((e: any) => e.node) ?? [];
|
||||||
// Roles come from the platform's role assignment — map known emails to roles
|
// Roles come from the platform's role assignment — map known emails to roles
|
||||||
setMembers(
|
setMembers(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
rawMembers.map((m: any) => ({
|
rawMembers.map((m: any) => ({
|
||||||
...m,
|
...m,
|
||||||
roles: inferRoles(m.userEmail),
|
roles: inferRoles(m.userEmail),
|
||||||
|
|||||||
552
src/pages/team-performance.tsx
Normal file
552
src/pages/team-performance.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -51,6 +51,7 @@ const loadPersistedUser = (): User | null => {
|
|||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const useAuth = (): AuthContextType => {
|
export const useAuth = (): AuthContextType => {
|
||||||
const context = useContext(AuthContext);
|
const context = useContext(AuthContext);
|
||||||
if (context === undefined) {
|
if (context === undefined) {
|
||||||
@@ -96,10 +97,21 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
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);
|
setUser(DEFAULT_USER);
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
localStorage.removeItem("helix_access_token");
|
localStorage.removeItem("helix_access_token");
|
||||||
localStorage.removeItem("helix_refresh_token");
|
localStorage.removeItem("helix_refresh_token");
|
||||||
|
localStorage.removeItem("helix_agent_config");
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -118,4 +130,5 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export { getInitials };
|
export { getInitials };
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type DataContextType = {
|
|||||||
|
|
||||||
const DataContext = createContext<DataContextType | undefined>(undefined);
|
const DataContext = createContext<DataContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const useData = (): DataContextType => {
|
export const useData = (): DataContextType => {
|
||||||
const context = useContext(DataContext);
|
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 gql = <T,>(query: string) => apiClient.graphql<T>(query, undefined, { silent: true }).catch(() => null);
|
||||||
|
|
||||||
const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData, patientsData] = await Promise.all([
|
const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData, patientsData] = await Promise.all([
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
gql<any>(LEADS_QUERY),
|
gql<any>(LEADS_QUERY),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
gql<any>(CAMPAIGNS_QUERY),
|
gql<any>(CAMPAIGNS_QUERY),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
gql<any>(ADS_QUERY),
|
gql<any>(ADS_QUERY),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
gql<any>(FOLLOW_UPS_QUERY),
|
gql<any>(FOLLOW_UPS_QUERY),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
gql<any>(LEAD_ACTIVITIES_QUERY),
|
gql<any>(LEAD_ACTIVITIES_QUERY),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
gql<any>(CALLS_QUERY),
|
gql<any>(CALLS_QUERY),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
gql<any>(PATIENTS_QUERY),
|
gql<any>(PATIENTS_QUERY),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -92,6 +100,7 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
|||||||
if (activitiesData) setLeadActivities(transformLeadActivities(activitiesData));
|
if (activitiesData) setLeadActivities(transformLeadActivities(activitiesData));
|
||||||
if (callsData) setCalls(transformCalls(callsData));
|
if (callsData) setCalls(transformCalls(callsData));
|
||||||
if (patientsData) setPatients(transformPatients(patientsData));
|
if (patientsData) setPatients(transformPatients(patientsData));
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message ?? "Failed to load data");
|
setError(err.message ?? "Failed to load data");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -13,12 +13,29 @@ import {
|
|||||||
} from "@/state/sip-state";
|
} from "@/state/sip-state";
|
||||||
import type { SIPConfig } from "@/types/sip";
|
import type { SIPConfig } from "@/types/sip";
|
||||||
|
|
||||||
const DEFAULT_CONFIG: SIPConfig = {
|
const getSipConfig = (): SIPConfig => {
|
||||||
displayName: import.meta.env.VITE_SIP_DISPLAY_NAME ?? "Helix Agent",
|
try {
|
||||||
uri: import.meta.env.VITE_SIP_URI ?? "",
|
const stored = localStorage.getItem("helix_agent_config");
|
||||||
password: import.meta.env.VITE_SIP_PASSWORD ?? "",
|
if (stored) {
|
||||||
wsServer: import.meta.env.VITE_SIP_WS_SERVER ?? "",
|
const config = JSON.parse(stored);
|
||||||
stunServers: "stun:stun.l.google.com:19302",
|
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) => {
|
export const SipProvider = ({ children }: PropsWithChildren) => {
|
||||||
@@ -41,7 +58,7 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
|
|||||||
|
|
||||||
// Auto-connect SIP on mount
|
// Auto-connect SIP on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
connectSip(DEFAULT_CONFIG);
|
connectSip(getSipConfig());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Call duration timer
|
// Call duration timer
|
||||||
@@ -81,6 +98,7 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Hook for components to access SIP actions + state
|
// Hook for components to access SIP actions + state
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const useSip = () => {
|
export const useSip = () => {
|
||||||
const [connectionStatus] = useAtom(sipConnectionStatusAtom);
|
const [connectionStatus] = useAtom(sipConnectionStatusAtom);
|
||||||
const [callState] = useAtom(sipCallStateAtom);
|
const [callState] = useAtom(sipCallStateAtom);
|
||||||
@@ -132,7 +150,7 @@ export const useSip = () => {
|
|||||||
isInCall: ["ringing-in", "ringing-out", "active"].includes(callState),
|
isInCall: ["ringing-in", "ringing-out", "active"].includes(callState),
|
||||||
ozonetelStatus: "logged-in" as const,
|
ozonetelStatus: "logged-in" as const,
|
||||||
ozonetelError: null as string | null,
|
ozonetelError: null as string | null,
|
||||||
connect: () => connectSip(DEFAULT_CONFIG),
|
connect: () => connectSip(getSipConfig()),
|
||||||
disconnect: disconnectSip,
|
disconnect: disconnectSip,
|
||||||
makeCall,
|
makeCall,
|
||||||
answer,
|
answer,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface ThemeContextType {
|
|||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const useTheme = (): ThemeContextType => {
|
export const useTheme = (): ThemeContextType => {
|
||||||
const context = useContext(ThemeContext);
|
const context = useContext(ThemeContext);
|
||||||
|
|
||||||
@@ -76,6 +77,7 @@ export const ThemeProvider = ({ children, defaultTheme = "system", storageKey =
|
|||||||
|
|
||||||
mediaQuery.addEventListener("change", handleChange);
|
mediaQuery.addEventListener("change", handleChange);
|
||||||
return () => mediaQuery.removeEventListener("change", handleChange);
|
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>;
|
return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>;
|
||||||
|
|||||||
Reference in New Issue
Block a user