From 721c2879ecf1b27d5552b60fbb30b1b086e317f2 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Sat, 21 Mar 2026 13:40:37 +0530 Subject: [PATCH] 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) --- .../plans/2026-03-21-my-performance.md | 304 +++++++++++++++++ src/components/layout/sidebar.tsx | 41 +++ src/main.tsx | 2 + src/pages/my-performance.tsx | 322 ++++++++++++++++++ 4 files changed, 669 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-21-my-performance.md create mode 100644 src/pages/my-performance.tsx diff --git a/docs/superpowers/plans/2026-03-21-my-performance.md b/docs/superpowers/plans/2026-03-21-my-performance.md new file mode 100644 index 0000000..aa800d7 --- /dev/null +++ b/docs/superpowers/plans/2026-03-21-my-performance.md @@ -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 { + 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 = {}; + 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: +} /> +``` + +- [ ] **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. diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 2eff330..10feaf4 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -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 */} + + + +
+
+
+ +
+
+

Sign out?

+

+ You will be logged out of Helix Engage and your Ozonetel agent session will end. Any active calls will be disconnected. +

+
+
+ + +
+
+
+
+
+
); }; diff --git a/src/main.tsx b/src/main.tsx index 0d202cb..a5853ff 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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( } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/pages/my-performance.tsx b/src/pages/my-performance.tsx new file mode 100644 index 0000000..f007103 --- /dev/null +++ b/src/pages/my-performance.tsx @@ -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; +}; + +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) => ( +
+
+ {label} + +
+

{value}

+ {subtitle &&

{subtitle}

} +
+); + +export const MyPerformancePage = () => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [selectedDate, setSelectedDate] = useState(() => new Date().toISOString().split('T')[0]); + + useEffect(() => { + setLoading(true); + apiClient.get(`/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 ( +
+ + +
+ {/* Date selector */} +
+ + + +
+ + {loading ? ( +
+

Loading performance data...

+
+ ) : !data ? ( +
+

No performance data available for this date.

+
+ ) : ( + <> + {/* KPI Cards */} +
+ + + + + + +
+ + {/* Charts row */} +
+ {/* Call breakdown bar chart */} +
+

Call Breakdown

+ +
+ + {/* Disposition donut */} +
+

Disposition Breakdown

+ {Object.keys(data.dispositions).length === 0 ? ( +
+

No dispositions recorded

+
+ ) : ( + ({ + name, + value, + itemStyle: { + color: [BRAND.blue600, BRAND.success, BRAND.warning, BRAND.purple, BRAND.gray400, BRAND.error][i % 6], + }, + })), + }], + }} + style={{ height: 240 }} + /> + )} +
+
+ + {/* Time utilization */} + {data.timeUtilization && ( +
+

Time Utilization

+ +
+ )} + + )} +
+
+ ); +}; + +const TimeBar = ({ utilization }: { utilization: NonNullable }) => { + 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

No time data available

; + } + + 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 ( +
+ {/* Stacked bar */} +
+ {segments.map(s => ( +
+ ))} +
+ + {/* Legend */} +
+ {segments.map(s => ( +
+
+ {s.label} + {formatDuration(s.value)} + ({Math.round((s.value / total) * 100)}%) +
+ ))} +
+
+ ); +};