mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
feat: My Performance page + logout modal + sidebar cleanup
- My Performance page with KPI cards, ECharts, DatePicker, time utilization - Sidecar: agent summary + AHT + performance aggregation endpoint - Logout confirmation modal - Removed Patients from CC agent nav Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
304
docs/superpowers/plans/2026-03-21-my-performance.md
Normal file
304
docs/superpowers/plans/2026-03-21-my-performance.md
Normal file
@@ -0,0 +1,304 @@
|
||||
# My Performance — 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 a personal performance dashboard for the CC agent showing call stats, conversion metrics, time utilization, and trends — aggregated from both Ozonetel CDR and platform data.
|
||||
|
||||
**Architecture:** A new sidecar endpoint `/api/performance` aggregates data from three sources: Ozonetel CDR (call stats), Ozonetel Agent State Summary (time utilization), and platform GraphQL (appointment conversions). The frontend renders KPI cards, an ECharts call volume chart, and a disposition breakdown donut.
|
||||
|
||||
**Tech Stack:** NestJS sidecar, Ozonetel APIs (CDR + Agent Summary + AHT), Platform GraphQL, React 19 + ECharts
|
||||
|
||||
---
|
||||
|
||||
## Data Sources
|
||||
|
||||
| Metric | Source | API |
|
||||
|--------|--------|-----|
|
||||
| Total calls today | Ozonetel CDR | `GET /ca_reports/fetchCDRDetails` |
|
||||
| Inbound / Outbound split | Ozonetel CDR | Same (field: `Type`) |
|
||||
| Answered / Missed split | Ozonetel CDR | Same (field: `Status`) |
|
||||
| Avg call duration | Ozonetel CDR | Same (field: `TalkTime`) |
|
||||
| Avg handling time | Ozonetel AHT | `GET /ca_apis/aht` |
|
||||
| Login duration | Ozonetel Summary | `POST /ca_reports/summaryReport` |
|
||||
| Idle / Busy / Pause time | Ozonetel Summary | Same |
|
||||
| Appointments booked | Platform | Query calls with disposition `APPOINTMENT_BOOKED` |
|
||||
| Conversion rate | Platform | Appointments booked / total calls |
|
||||
| Call volume trend (7 days) | Ozonetel CDR | Fetch per day for last 7 days |
|
||||
| Disposition breakdown | Ozonetel CDR | Group by `Disposition` field |
|
||||
|
||||
## File Map
|
||||
|
||||
### Sidecar
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `src/ozonetel/ozonetel-agent.service.ts` | Modify: add `getAgentSummary()`, `getAHT()` |
|
||||
| `src/ozonetel/ozonetel-agent.controller.ts` | Modify: add `GET /api/ozonetel/performance` |
|
||||
|
||||
### Frontend
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `src/pages/my-performance.tsx` | Create: KPI cards + charts page |
|
||||
| `src/components/layout/sidebar.tsx` | Modify: add My Performance nav item for cc-agent |
|
||||
| `src/main.tsx` | Modify: add route |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add Agent Summary and AHT service methods
|
||||
|
||||
**Files:**
|
||||
- Modify: `helix-engage-server/src/ozonetel/ozonetel-agent.service.ts`
|
||||
|
||||
- [ ] **Step 1: Add `getAgentSummary()` method**
|
||||
|
||||
```typescript
|
||||
async getAgentSummary(agentId: string, date: string): Promise<{
|
||||
totalLoginDuration: string;
|
||||
totalBusyTime: string;
|
||||
totalIdleTime: string;
|
||||
totalPauseTime: string;
|
||||
totalWrapupTime: string;
|
||||
totalDialTime: string;
|
||||
} | null> {
|
||||
const url = `https://${this.apiDomain}/ca_reports/summaryReport`;
|
||||
|
||||
try {
|
||||
const token = await this.getToken();
|
||||
const response = await axios({
|
||||
method: 'GET',
|
||||
url,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: JSON.stringify({
|
||||
userName: this.accountId,
|
||||
agentId,
|
||||
fromDate: `${date} 00:00:00`,
|
||||
toDate: `${date} 23:59:59`,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
if (data.status === 'success' && data.message) {
|
||||
const record = Array.isArray(data.message) ? data.message[0] : data.message;
|
||||
return {
|
||||
totalLoginDuration: record.TotalLoginDuration ?? '00:00:00',
|
||||
totalBusyTime: record.TotalBusyTime ?? '00:00:00',
|
||||
totalIdleTime: record.TotalIdleTime ?? '00:00:00',
|
||||
totalPauseTime: record.TotalPauseTime ?? '00:00:00',
|
||||
totalWrapupTime: record.TotalWrapupTime ?? '00:00:00',
|
||||
totalDialTime: record.TotalDialTime ?? '00:00:00',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Agent summary failed: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `getAHT()` method**
|
||||
|
||||
```typescript
|
||||
async getAHT(agentId: string): Promise<string> {
|
||||
const url = `https://${this.apiDomain}/ca_apis/aht`;
|
||||
|
||||
try {
|
||||
const token = await this.getToken();
|
||||
const response = await axios({
|
||||
method: 'GET',
|
||||
url,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: JSON.stringify({
|
||||
userName: this.accountId,
|
||||
agentId,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
if (data.status === 'success') {
|
||||
return data.AHT ?? '00:00:00';
|
||||
}
|
||||
return '00:00:00';
|
||||
} catch (error: any) {
|
||||
this.logger.error(`AHT failed: ${error.message}`);
|
||||
return '00:00:00';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Type check and commit**
|
||||
|
||||
```
|
||||
feat: add agent summary and AHT service methods
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add performance aggregation endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `helix-engage-server/src/ozonetel/ozonetel-agent.controller.ts`
|
||||
|
||||
- [ ] **Step 1: Add `GET /api/ozonetel/performance` endpoint**
|
||||
|
||||
This endpoint fetches today's CDR, agent summary, and AHT in parallel, then aggregates:
|
||||
|
||||
```typescript
|
||||
@Get('performance')
|
||||
async performance(@Query('date') date?: string) {
|
||||
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
||||
this.logger.log(`Performance: date=${targetDate} agent=${this.defaultAgentId}`);
|
||||
|
||||
// Fetch all data in parallel
|
||||
const [cdr, summary, aht] = await Promise.all([
|
||||
this.ozonetelAgent.fetchCDR({ date: targetDate }),
|
||||
this.ozonetelAgent.getAgentSummary(this.defaultAgentId, targetDate),
|
||||
this.ozonetelAgent.getAHT(this.defaultAgentId),
|
||||
]);
|
||||
|
||||
// Aggregate CDR stats
|
||||
const totalCalls = cdr.length;
|
||||
const inbound = cdr.filter(c => c.Type === 'InBound').length;
|
||||
const outbound = cdr.filter(c => c.Type === 'Manual' || c.Type === 'Progressive').length;
|
||||
const answered = cdr.filter(c => c.Status === 'Answered').length;
|
||||
const missed = cdr.filter(c => c.Status === 'Unanswered' || c.Status === 'NotAnswered').length;
|
||||
|
||||
// Average talk time in seconds
|
||||
const talkTimes = cdr
|
||||
.filter(c => c.TalkTime && c.TalkTime !== '00:00:00')
|
||||
.map(c => {
|
||||
const parts = c.TalkTime.split(':').map(Number);
|
||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
});
|
||||
const avgTalkTimeSec = talkTimes.length > 0
|
||||
? Math.round(talkTimes.reduce((a, b) => a + b, 0) / talkTimes.length)
|
||||
: 0;
|
||||
|
||||
// Disposition breakdown
|
||||
const dispositions: Record<string, number> = {};
|
||||
for (const c of cdr) {
|
||||
const d = c.Disposition || 'No Disposition';
|
||||
dispositions[d] = (dispositions[d] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Appointments booked (disposition contains "Appointment" or "General Enquiry" with appointment)
|
||||
const appointmentsBooked = cdr.filter(c =>
|
||||
c.Disposition?.includes('Appointment') || c.Disposition?.includes('appointment')
|
||||
).length;
|
||||
|
||||
return {
|
||||
date: targetDate,
|
||||
calls: { total: totalCalls, inbound, outbound, answered, missed },
|
||||
avgTalkTimeSec,
|
||||
avgHandlingTime: aht,
|
||||
conversionRate: totalCalls > 0 ? Math.round((appointmentsBooked / totalCalls) * 100) : 0,
|
||||
appointmentsBooked,
|
||||
timeUtilization: summary,
|
||||
dispositions,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Type check and commit**
|
||||
|
||||
```
|
||||
feat: add performance aggregation endpoint
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Build My Performance page
|
||||
|
||||
**Files:**
|
||||
- Create: `helix-engage/src/pages/my-performance.tsx`
|
||||
|
||||
- [ ] **Step 1: Create the page**
|
||||
|
||||
The page has:
|
||||
1. **Header row**: "My Performance" + date picker (today/yesterday/this week)
|
||||
2. **KPI cards row**: Total Calls, Answered, Appointments Booked, Avg Handle Time, Conversion Rate, Login Time
|
||||
3. **Charts row**: Call Volume bar chart (inbound vs outbound) + Disposition donut chart
|
||||
4. **Time utilization bar**: horizontal stacked bar showing Busy/Idle/Pause/Wrapup proportions
|
||||
|
||||
The page fetches data from `GET /api/ozonetel/performance?date=YYYY-MM-DD` on mount and when the date changes.
|
||||
|
||||
KPI cards follow the same pattern as the existing reports page — icon, value, label, with trend badge.
|
||||
|
||||
Charts use ECharts (already installed as `echarts-for-react`).
|
||||
|
||||
For the 7-day call volume chart, the page fetches performance for each of the last 7 days (7 API calls). To avoid hitting the CDR rate limit (2 req/min), it fetches sequentially with a small delay, or better — the sidecar caches/batches this.
|
||||
|
||||
**Simpler approach for v1**: Only show today's data. The 7-day trend chart can be added later when we have the real-time event subscription storing historical data.
|
||||
|
||||
- [ ] **Step 2: Type check and commit**
|
||||
|
||||
```
|
||||
feat: add My Performance page with KPI cards and charts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Wire into navigation and routing
|
||||
|
||||
**Files:**
|
||||
- Modify: `helix-engage/src/components/layout/sidebar.tsx`
|
||||
- Modify: `helix-engage/src/main.tsx`
|
||||
|
||||
- [ ] **Step 1: Add nav item for cc-agent**
|
||||
|
||||
In sidebar.tsx, add to the cc-agent section:
|
||||
```typescript
|
||||
{ label: 'Call Center', items: [
|
||||
{ label: 'Call Desk', href: '/', icon: IconPhone },
|
||||
{ label: 'Call History', href: '/call-history', icon: IconClockRewind },
|
||||
{ label: 'My Performance', href: '/my-performance', icon: IconChartMixed },
|
||||
]},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add route in main.tsx**
|
||||
|
||||
```typescript
|
||||
import { MyPerformancePage } from '@/pages/my-performance';
|
||||
// In routes:
|
||||
<Route path="/my-performance" element={<MyPerformancePage />} />
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Type check and commit**
|
||||
|
||||
```
|
||||
feat: add My Performance to CC agent navigation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Deploy and verify
|
||||
|
||||
- [ ] **Step 1: Build and deploy sidecar**
|
||||
- [ ] **Step 2: Build and deploy frontend**
|
||||
- [ ] **Step 3: Test performance endpoint**
|
||||
|
||||
```bash
|
||||
curl -s "https://engage-api.srv1477139.hstgr.cloud/api/ozonetel/performance?date=2026-03-20" | python3 -m json.tool
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Test the page**
|
||||
|
||||
1. Login as CC agent
|
||||
2. Navigate to My Performance
|
||||
3. Verify KPI cards show real data
|
||||
4. Verify charts render
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **CDR rate limit is 2 req/min** — the performance endpoint makes 1 CDR call per request. For 7-day trend, we'd need 7 calls which would take 3.5 minutes. Defer 7-day trend to later.
|
||||
- **Agent Summary rate limit is 5 req/min** — fine for single-day queries.
|
||||
- **AHT rate limit is 50 req/min** — no concern.
|
||||
- **Time format from Ozonetel** is `HH:MM:SS` — need to parse to seconds for display.
|
||||
- **ECharts colors** should use the new brand blue scale. The existing reports page has hardcoded colors — update to match.
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faBullhorn,
|
||||
@@ -12,10 +13,13 @@ import {
|
||||
faPhone,
|
||||
faPlug,
|
||||
faUsers,
|
||||
faArrowRightFromBracket,
|
||||
} from "@fortawesome/pro-duotone-svg-icons";
|
||||
import { faIcon } from "@/lib/icon-wrapper";
|
||||
import { useAtom } from "jotai";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { ModalOverlay, Modal, Dialog } from "@/components/application/modals/modal";
|
||||
import { Button } from "@/components/base/buttons/button";
|
||||
import { MobileNavigationHeader } from "@/components/application/app-navigation/base-components/mobile-header";
|
||||
import { NavAccountCard } from "@/components/application/app-navigation/base-components/nav-account-card";
|
||||
import { NavItemBase } from "@/components/application/app-navigation/base-components/nav-item";
|
||||
@@ -66,6 +70,7 @@ const getNavSections = (role: string): NavSection[] => {
|
||||
{ label: 'Call Center', items: [
|
||||
{ label: 'Call Desk', href: '/', icon: IconPhone },
|
||||
{ label: 'Call History', href: '/call-history', icon: IconClockRewind },
|
||||
{ label: 'My Performance', href: '/my-performance', icon: IconChartMixed },
|
||||
]},
|
||||
];
|
||||
}
|
||||
@@ -103,7 +108,14 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
||||
|
||||
const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH;
|
||||
|
||||
const [logoutOpen, setLogoutOpen] = useState(false);
|
||||
|
||||
const handleSignOut = () => {
|
||||
setLogoutOpen(true);
|
||||
};
|
||||
|
||||
const confirmSignOut = () => {
|
||||
setLogoutOpen(false);
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
@@ -224,6 +236,35 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
||||
style={{ paddingLeft: width + 4 }}
|
||||
className="invisible hidden lg:sticky lg:top-0 lg:bottom-0 lg:left-0 lg:block transition-all duration-200 ease-linear"
|
||||
/>
|
||||
|
||||
{/* Logout confirmation modal */}
|
||||
<ModalOverlay isOpen={logoutOpen} onOpenChange={setLogoutOpen} isDismissable>
|
||||
<Modal className="max-w-md">
|
||||
<Dialog>
|
||||
<div className="rounded-xl bg-primary p-6 shadow-xl">
|
||||
<div className="flex flex-col items-center text-center gap-4">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-warning-secondary">
|
||||
<FontAwesomeIcon icon={faArrowRightFromBracket} className="size-5 text-fg-warning-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-primary">Sign out?</h3>
|
||||
<p className="mt-1 text-sm text-tertiary">
|
||||
You will be logged out of Helix Engage and your Ozonetel agent session will end. Any active calls will be disconnected.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full gap-3">
|
||||
<Button size="md" color="secondary" className="flex-1" onClick={() => setLogoutOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="md" color="primary-destructive" className="flex-1" onClick={confirmSignOut}>
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ import { TeamDashboardPage } from "@/pages/team-dashboard";
|
||||
import { IntegrationsPage } from "@/pages/integrations";
|
||||
import { AgentDetailPage } from "@/pages/agent-detail";
|
||||
import { SettingsPage } from "@/pages/settings";
|
||||
import { MyPerformancePage } from "@/pages/my-performance";
|
||||
import { AuthProvider } from "@/providers/auth-provider";
|
||||
import { DataProvider } from "@/providers/data-provider";
|
||||
import { RouteProvider } from "@/providers/router-provider";
|
||||
@@ -51,6 +52,7 @@ createRoot(document.getElementById("root")!).render(
|
||||
<Route path="/outreach" element={<OutreachPage />} />
|
||||
<Route path="/follow-ups" element={<FollowUpsPage />} />
|
||||
<Route path="/call-history" element={<CallHistoryPage />} />
|
||||
<Route path="/my-performance" element={<MyPerformancePage />} />
|
||||
<Route path="/call-desk" element={<CallDeskPage />} />
|
||||
<Route path="/patients" element={<PatientsPage />} />
|
||||
<Route path="/team-dashboard" element={<TeamDashboardPage />} />
|
||||
|
||||
322
src/pages/my-performance.tsx
Normal file
322
src/pages/my-performance.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faPhoneVolume, faPhoneArrowDown, faCalendarCheck, faClock,
|
||||
faPercent, faRightToBracket,
|
||||
} from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { getLocalTimeZone, parseDate, today as todayDate } from '@internationalized/date';
|
||||
import type { DateValue } from 'react-aria-components';
|
||||
import { DatePicker } from '@/components/application/date-picker/date-picker';
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
type PerformanceData = {
|
||||
date: string;
|
||||
calls: { total: number; inbound: number; outbound: number; answered: number; missed: number };
|
||||
avgTalkTimeSec: number;
|
||||
avgHandlingTime: string;
|
||||
conversionRate: number;
|
||||
appointmentsBooked: number;
|
||||
timeUtilization: {
|
||||
totalLoginDuration: string;
|
||||
totalBusyTime: string;
|
||||
totalIdleTime: string;
|
||||
totalPauseTime: string;
|
||||
totalWrapupTime: string;
|
||||
totalDialTime: string;
|
||||
} | null;
|
||||
dispositions: Record<string, number>;
|
||||
};
|
||||
|
||||
const parseTime = (timeStr: string): number => {
|
||||
const parts = timeStr.split(':').map(Number);
|
||||
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
return 0;
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
if (mins < 60) return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
return `${hrs}h ${mins % 60}m`;
|
||||
};
|
||||
|
||||
const BRAND = {
|
||||
blue600: 'rgb(32, 96, 160)',
|
||||
blue500: 'rgb(56, 120, 180)',
|
||||
blue400: 'rgb(96, 150, 200)',
|
||||
blue300: 'rgb(138, 180, 220)',
|
||||
success: 'rgb(23, 178, 106)',
|
||||
warning: 'rgb(247, 144, 9)',
|
||||
error: 'rgb(240, 68, 56)',
|
||||
gray400: 'rgb(164, 167, 174)',
|
||||
purple: 'rgb(158, 119, 237)',
|
||||
};
|
||||
|
||||
type KpiCardProps = {
|
||||
icon: any;
|
||||
iconColor: string;
|
||||
label: string;
|
||||
value: string | number;
|
||||
subtitle?: string;
|
||||
};
|
||||
|
||||
const KpiCard = ({ icon, iconColor, label, value, subtitle }: KpiCardProps) => (
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-secondary bg-primary p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-tertiary uppercase tracking-wider">{label}</span>
|
||||
<FontAwesomeIcon icon={icon} className={cx('size-4', iconColor)} />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-primary">{value}</p>
|
||||
{subtitle && <p className="text-xs text-tertiary">{subtitle}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const MyPerformancePage = () => {
|
||||
const [data, setData] = useState<PerformanceData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedDate, setSelectedDate] = useState(() => new Date().toISOString().split('T')[0]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
apiClient.get<PerformanceData>(`/api/ozonetel/performance?date=${selectedDate}`, { silent: true })
|
||||
.then(setData)
|
||||
.catch(() => setData(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [selectedDate]);
|
||||
|
||||
const now = todayDate(getLocalTimeZone());
|
||||
const dateValue = selectedDate ? parseDate(selectedDate) : now;
|
||||
|
||||
const handleDateChange = (value: DateValue | null) => {
|
||||
if (value) {
|
||||
setSelectedDate(value.toString());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<TopBar title="My Performance" subtitle="Personal call center metrics" />
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-y-auto p-6 gap-6">
|
||||
{/* Date selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedDate(now.toString())}
|
||||
className={cx(
|
||||
'px-3 py-1.5 text-xs font-medium rounded-lg transition duration-100 ease-linear',
|
||||
selectedDate === now.toString()
|
||||
? 'bg-brand-solid text-white'
|
||||
: 'bg-secondary text-secondary hover:bg-secondary_hover',
|
||||
)}
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedDate(now.subtract({ days: 1 }).toString())}
|
||||
className={cx(
|
||||
'px-3 py-1.5 text-xs font-medium rounded-lg transition duration-100 ease-linear',
|
||||
selectedDate === now.subtract({ days: 1 }).toString()
|
||||
? 'bg-brand-solid text-white'
|
||||
: 'bg-secondary text-secondary hover:bg-secondary_hover',
|
||||
)}
|
||||
>
|
||||
Yesterday
|
||||
</button>
|
||||
<DatePicker
|
||||
value={dateValue}
|
||||
onChange={handleDateChange}
|
||||
maxValue={now}
|
||||
aria-label="Select date"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<p className="text-sm text-tertiary">Loading performance data...</p>
|
||||
</div>
|
||||
) : !data ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<p className="text-sm text-tertiary">No performance data available for this date.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
|
||||
<KpiCard
|
||||
icon={faPhoneVolume}
|
||||
iconColor="text-fg-brand-primary"
|
||||
label="Total Calls"
|
||||
value={data.calls.total}
|
||||
subtitle={`${data.calls.inbound} in · ${data.calls.outbound} out`}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={faPhoneArrowDown}
|
||||
iconColor="text-fg-success-primary"
|
||||
label="Answered"
|
||||
value={data.calls.answered}
|
||||
subtitle={`${data.calls.missed} missed`}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={faCalendarCheck}
|
||||
iconColor="text-fg-brand-primary"
|
||||
label="Appts Booked"
|
||||
value={data.appointmentsBooked}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={faClock}
|
||||
iconColor="text-fg-warning-primary"
|
||||
label="Avg Talk Time"
|
||||
value={formatDuration(data.avgTalkTimeSec)}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={faPercent}
|
||||
iconColor="text-fg-success-primary"
|
||||
label="Conversion"
|
||||
value={`${data.conversionRate}%`}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={faRightToBracket}
|
||||
iconColor="text-fg-brand-primary"
|
||||
label="Login Time"
|
||||
value={data.timeUtilization ? formatDuration(parseTime(data.timeUtilization.totalLoginDuration)) : '—'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charts row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Call breakdown bar chart */}
|
||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||
<h3 className="text-sm font-semibold text-primary mb-4">Call Breakdown</h3>
|
||||
<ReactECharts
|
||||
option={{
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 40, right: 20, top: 20, bottom: 30 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['Inbound', 'Outbound', 'Answered', 'Missed'],
|
||||
axisLabel: { fontSize: 11, color: '#667085' },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: { fontSize: 11, color: '#667085' },
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: [
|
||||
{ value: data.calls.inbound, itemStyle: { color: BRAND.blue600 } },
|
||||
{ value: data.calls.outbound, itemStyle: { color: BRAND.blue400 } },
|
||||
{ value: data.calls.answered, itemStyle: { color: BRAND.success } },
|
||||
{ value: data.calls.missed, itemStyle: { color: BRAND.error } },
|
||||
],
|
||||
barWidth: '50%',
|
||||
itemStyle: { borderRadius: [4, 4, 0, 0] },
|
||||
}],
|
||||
}}
|
||||
style={{ height: 240 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Disposition donut */}
|
||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||
<h3 className="text-sm font-semibold text-primary mb-4">Disposition Breakdown</h3>
|
||||
{Object.keys(data.dispositions).length === 0 ? (
|
||||
<div className="flex items-center justify-center h-[240px]">
|
||||
<p className="text-xs text-quaternary">No dispositions recorded</p>
|
||||
</div>
|
||||
) : (
|
||||
<ReactECharts
|
||||
option={{
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
right: 10,
|
||||
top: 'center',
|
||||
textStyle: { fontSize: 11, color: '#667085' },
|
||||
},
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['45%', '70%'],
|
||||
center: ['35%', '50%'],
|
||||
avoidLabelOverlap: false,
|
||||
label: { show: false },
|
||||
data: Object.entries(data.dispositions).map(([name, value], i) => ({
|
||||
name,
|
||||
value,
|
||||
itemStyle: {
|
||||
color: [BRAND.blue600, BRAND.success, BRAND.warning, BRAND.purple, BRAND.gray400, BRAND.error][i % 6],
|
||||
},
|
||||
})),
|
||||
}],
|
||||
}}
|
||||
style={{ height: 240 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time utilization */}
|
||||
{data.timeUtilization && (
|
||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||
<h3 className="text-sm font-semibold text-primary mb-4">Time Utilization</h3>
|
||||
<TimeBar utilization={data.timeUtilization} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TimeBar = ({ utilization }: { utilization: NonNullable<PerformanceData['timeUtilization']> }) => {
|
||||
const busy = parseTime(utilization.totalBusyTime);
|
||||
const idle = parseTime(utilization.totalIdleTime);
|
||||
const pause = parseTime(utilization.totalPauseTime);
|
||||
const wrapup = parseTime(utilization.totalWrapupTime);
|
||||
const dial = parseTime(utilization.totalDialTime);
|
||||
const total = busy + idle + pause + wrapup + dial;
|
||||
|
||||
if (total === 0) {
|
||||
return <p className="text-xs text-quaternary">No time data available</p>;
|
||||
}
|
||||
|
||||
const segments = [
|
||||
{ label: 'Busy', value: busy, color: BRAND.blue600 },
|
||||
{ label: 'Dialing', value: dial, color: BRAND.blue400 },
|
||||
{ label: 'Idle', value: idle, color: BRAND.gray400 },
|
||||
{ label: 'Wrap-up', value: wrapup, color: BRAND.warning },
|
||||
{ label: 'Pause', value: pause, color: BRAND.error },
|
||||
].filter(s => s.value > 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Stacked bar */}
|
||||
<div className="flex h-6 rounded-lg overflow-hidden">
|
||||
{segments.map(s => (
|
||||
<div
|
||||
key={s.label}
|
||||
style={{ width: `${(s.value / total) * 100}%`, backgroundColor: s.color }}
|
||||
title={`${s.label}: ${formatDuration(s.value)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{segments.map(s => (
|
||||
<div key={s.label} className="flex items-center gap-2">
|
||||
<div className="size-2.5 rounded-full" style={{ backgroundColor: s.color }} />
|
||||
<span className="text-xs text-secondary">{s.label}</span>
|
||||
<span className="text-xs font-semibold text-primary">{formatDuration(s.value)}</span>
|
||||
<span className="text-xs text-quaternary">({Math.round((s.value / total) * 100)}%)</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user