From d9d98bce9c74c1adc71e28f29f8d5562a027329f Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Thu, 19 Mar 2026 15:58:31 +0530 Subject: [PATCH] feat: dashboard restructure, integrations, settings, UI fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard: - Split into components (kpi-cards, agent-table, missed-queue) - Add collapsible AI panel on right (same pattern as Call Desk) - Add tabs: Agent Performance | Missed Queue | Campaigns - Date range filter in header Integrations page: - Ozonetel (connected), WhatsApp, Facebook, Google, Instagram, Website, Email - Status badges, config details, webhook URL with copy button Settings page: - Employee table from workspaceMembers GraphQL query - Name, email, roles, status, reset password action Fixes: - Fix CALLS_QUERY: callerNumber needs { primaryPhoneNumber }, recordingUrl → recording { primaryLinkUrl } - Remove duplicate AI Assistant header - Remove Follow-ups from CC agent sidebar (already in worklist tabs) - Remove global search from TopBar (decorative, unused) - Slim down TopBar height - Fix search/table gap in worklist - Add brand border to active nav item Co-Authored-By: Claude Opus 4.6 (1M context) --- .../base-components/nav-item.tsx | 2 +- src/components/call-desk/ai-chat-panel.tsx | 6 - src/components/call-desk/worklist-panel.tsx | 94 +++- src/components/dashboard/agent-table.tsx | 106 ++++ src/components/dashboard/kpi-cards.tsx | 100 ++++ src/components/dashboard/missed-queue.tsx | 71 +++ src/components/layout/sidebar.tsx | 5 +- src/components/layout/top-bar.tsx | 12 +- src/lib/queries.ts | 4 +- src/lib/transforms.ts | 6 +- src/main.tsx | 4 + src/pages/call-desk.tsx | 114 ++-- src/pages/integrations.tsx | 176 +++++++ src/pages/settings.tsx | 130 +++++ src/pages/team-dashboard.tsx | 496 ++++-------------- 15 files changed, 805 insertions(+), 521 deletions(-) create mode 100644 src/components/dashboard/agent-table.tsx create mode 100644 src/components/dashboard/kpi-cards.tsx create mode 100644 src/components/dashboard/missed-queue.tsx create mode 100644 src/pages/integrations.tsx create mode 100644 src/pages/settings.tsx diff --git a/src/components/application/app-navigation/base-components/nav-item.tsx b/src/components/application/app-navigation/base-components/nav-item.tsx index 72a85f9..2647779 100644 --- a/src/components/application/app-navigation/base-components/nav-item.tsx +++ b/src/components/application/app-navigation/base-components/nav-item.tsx @@ -6,7 +6,7 @@ import { cx, sortCx } from "@/utils/cx"; const styles = sortCx({ root: "group relative flex w-full cursor-pointer items-center rounded-md bg-primary outline-focus-ring transition duration-100 ease-linear select-none hover:bg-primary_hover focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2", - rootSelected: "bg-active hover:bg-secondary_hover", + rootSelected: "bg-active hover:bg-secondary_hover border-l-2 border-l-brand-600 text-brand-secondary", }); interface NavItemBaseProps { diff --git a/src/components/call-desk/ai-chat-panel.tsx b/src/components/call-desk/ai-chat-panel.tsx index a919a14..39a9b55 100644 --- a/src/components/call-desk/ai-chat-panel.tsx +++ b/src/components/call-desk/ai-chat-panel.tsx @@ -99,12 +99,6 @@ export const AiChatPanel = ({ callerContext }: AiChatPanelProps) => { return (
- {/* Header */} -
- -

AI Assistant

-
- {/* Caller context banner */} {callerContext?.leadName && (
diff --git a/src/components/call-desk/worklist-panel.tsx b/src/components/call-desk/worklist-panel.tsx index 33be43a..c7ed230 100644 --- a/src/components/call-desk/worklist-panel.tsx +++ b/src/components/call-desk/worklist-panel.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import type { FC, HTMLAttributes } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { @@ -224,6 +224,16 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect const callbackCount = allRows.filter((r) => r.type === 'callback').length; const followUpCount = allRows.filter((r) => r.type === 'follow-up').length; + const PAGE_SIZE = 15; + const [page, setPage] = useState(1); + + // Reset page when filters change + const handleTabChange = useCallback((key: TabKey) => { setTab(key); setPage(1); }, []); + const handleSearch = useCallback((value: string) => { setSearch(value); setPage(1); }, []); + + const totalPages = Math.max(1, Math.ceil(filteredRows.length / PAGE_SIZE)); + const pagedRows = filteredRows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + const tabItems = [ { id: 'all' as const, label: 'All Tasks', badge: allRows.length > 0 ? String(allRows.length) : undefined }, { id: 'missed' as const, label: 'Missed Calls', badge: missedCount > 0 ? String(missedCount) : undefined }, @@ -251,43 +261,35 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect } return ( - - -
- setSearch(value)} - aria-label="Search worklist" - /> -
-
- } - /> - - {/* Filter tabs */} -
- setTab(key as TabKey)}> +
+ {/* Filter tabs + search — single row */} +
+ handleTabChange(key as TabKey)}> {(item) => } +
+ +
{filteredRows.length === 0 ? ( -
+

{search ? 'No matching items' : 'No items in this category'}

) : ( - +
+
@@ -296,7 +298,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect - + {(row) => { const priority = priorityConfig[row.priority] ?? priorityConfig.NORMAL; const sla = computeSla(row.createdAt); @@ -367,8 +369,44 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect }}
+ {totalPages > 1 && ( +
+ + Showing {(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, filteredRows.length)} of {filteredRows.length} + +
+ + {Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => ( + + ))} + +
+
+ )} +
)} - +
); }; diff --git a/src/components/dashboard/agent-table.tsx b/src/components/dashboard/agent-table.tsx new file mode 100644 index 0000000..220f59f --- /dev/null +++ b/src/components/dashboard/agent-table.tsx @@ -0,0 +1,106 @@ +import { useMemo } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faUserHeadset } from '@fortawesome/pro-duotone-svg-icons'; +import { Avatar } from '@/components/base/avatar/avatar'; +import { Badge } from '@/components/base/badges/badges'; +import { Table, TableCard } from '@/components/application/table/table'; +import { getInitials } from '@/lib/format'; +import type { Call } from '@/types/entities'; + +const formatDuration = (seconds: number): string => { + if (seconds < 60) return `${seconds}s`; + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`; +}; + +const formatPercent = (value: number): string => { + if (isNaN(value) || !isFinite(value)) return '0%'; + return `${Math.round(value)}%`; +}; + +interface AgentTableProps { + calls: Call[]; +} + +export const AgentTable = ({ calls }: AgentTableProps) => { + const agents = useMemo(() => { + const agentMap = new Map(); + for (const call of calls) { + const agent = call.agentName ?? 'Unknown'; + if (!agentMap.has(agent)) agentMap.set(agent, []); + agentMap.get(agent)!.push(call); + } + + return Array.from(agentMap.entries()).map(([name, agentCalls]) => { + const inbound = agentCalls.filter((c) => c.callDirection === 'INBOUND').length; + const outbound = agentCalls.filter((c) => c.callDirection === 'OUTBOUND').length; + const missed = agentCalls.filter((c) => c.callStatus === 'MISSED').length; + const total = agentCalls.length; + const completedCalls = agentCalls.filter((c) => (c.durationSeconds ?? 0) > 0); + const totalDuration = completedCalls.reduce((sum, c) => sum + (c.durationSeconds ?? 0), 0); + const avgHandle = completedCalls.length > 0 ? Math.round(totalDuration / completedCalls.length) : 0; + const booked = agentCalls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length; + const conversion = total > 0 ? (booked / total) * 100 : 0; + const nameParts = name.split(' '); + + return { + id: name, + name, + initials: getInitials(nameParts[0] ?? '', nameParts[1] ?? ''), + inbound, outbound, missed, total, avgHandle, conversion, + }; + }).sort((a, b) => b.total - a.total); + }, [calls]); + + if (agents.length === 0) { + return ( + + +
+ +

No agent data available

+
+
+ ); + } + + return ( + + + + + + + + + + + + + {(agent) => ( + + +
+ + {agent.name} +
+
+ {agent.inbound} + {agent.outbound} + + {agent.missed > 0 ? {agent.missed} : 0} + + {formatDuration(agent.avgHandle)} + + = 30 ? 'success' : agent.conversion >= 15 ? 'warning' : 'gray'}> + {formatPercent(agent.conversion)} + + +
+ )} +
+
+
+ ); +}; diff --git a/src/components/dashboard/kpi-cards.tsx b/src/components/dashboard/kpi-cards.tsx new file mode 100644 index 0000000..2643fb3 --- /dev/null +++ b/src/components/dashboard/kpi-cards.tsx @@ -0,0 +1,100 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faPhone, + faPhoneArrowDownLeft, + faPhoneArrowUpRight, + faPhoneMissed, +} from '@fortawesome/pro-duotone-svg-icons'; +import type { IconDefinition } from '@fortawesome/fontawesome-svg-core'; +import type { Call, Lead } from '@/types/entities'; + +type KpiCardProps = { + label: string; + value: number | string; + icon: IconDefinition; + iconColor: string; + iconBg: string; + subtitle?: string; +}; + +const KpiCard = ({ label, value, icon, iconColor, iconBg, subtitle }: KpiCardProps) => ( +
+
+ +
+
+ {label} + {value} + {subtitle && {subtitle}} +
+
+); + +type MetricCardProps = { + label: string; + value: string; + description: string; +}; + +const MetricCard = ({ label, value, description }: MetricCardProps) => ( +
+ {label} + {value} + {description} +
+); + +const formatPercent = (value: number): string => { + if (isNaN(value) || !isFinite(value)) return '0%'; + return `${Math.round(value)}%`; +}; + +interface DashboardKpiProps { + calls: Call[]; + leads: Lead[]; +} + +export const DashboardKpi = ({ calls, leads }: DashboardKpiProps) => { + const totalCalls = calls.length; + const inboundCalls = calls.filter((c) => c.callDirection === 'INBOUND').length; + const outboundCalls = calls.filter((c) => c.callDirection === 'OUTBOUND').length; + const missedCalls = calls.filter((c) => c.callStatus === 'MISSED').length; + + const leadsWithResponse = leads.filter((l) => l.createdAt && l.firstContactedAt); + const avgResponseTime = leadsWithResponse.length > 0 + ? Math.round(leadsWithResponse.reduce((sum, l) => { + return sum + (new Date(l.firstContactedAt!).getTime() - new Date(l.createdAt!).getTime()) / 60000; + }, 0) / leadsWithResponse.length) + : null; + + const missedCallsList = calls.filter((c) => c.callStatus === 'MISSED' && c.startedAt); + const missedCallbackTime = missedCallsList.length > 0 + ? Math.round(missedCallsList.reduce((sum, c) => sum + (Date.now() - new Date(c.startedAt!).getTime()) / 60000, 0) / missedCallsList.length) + : null; + + const callToAppt = totalCalls > 0 + ? (calls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length / totalCalls) * 100 + : 0; + + const leadToAppt = leads.length > 0 + ? (leads.filter((l) => l.leadStatus === 'APPOINTMENT_SET' || l.leadStatus === 'CONVERTED').length / leads.length) * 100 + : 0; + + return ( +
+
+ + + + 0 ? `${formatPercent((missedCalls / totalCalls) * 100)} of total` : undefined} /> +
+
+ + + + +
+
+ ); +}; diff --git a/src/components/dashboard/missed-queue.tsx b/src/components/dashboard/missed-queue.tsx new file mode 100644 index 0000000..4825edb --- /dev/null +++ b/src/components/dashboard/missed-queue.tsx @@ -0,0 +1,71 @@ +import { useMemo } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faPhoneMissed } from '@fortawesome/pro-duotone-svg-icons'; +import { Badge } from '@/components/base/badges/badges'; +import { ClickToCallButton } from '@/components/call-desk/click-to-call-button'; +import type { Call } from '@/types/entities'; + +const getTimeSince = (dateStr: string | null): string => { + if (!dateStr) return '—'; + const mins = Math.floor((Date.now() - new Date(dateStr).getTime()) / 60000); + if (mins < 1) return 'Just now'; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +}; + +interface MissedQueueProps { + calls: Call[]; +} + +export const MissedQueue = ({ calls }: MissedQueueProps) => { + const missedCalls = useMemo(() => { + return calls + .filter((c) => c.callStatus === 'MISSED') + .sort((a, b) => { + const dateA = a.startedAt ? new Date(a.startedAt).getTime() : 0; + const dateB = b.startedAt ? new Date(b.startedAt).getTime() : 0; + return dateB - dateA; + }) + .slice(0, 15); + }, [calls]); + + return ( +
+
+
+ +

Missed Call Queue

+
+ {missedCalls.length > 0 && ( + {missedCalls.length} + )} +
+
+ {missedCalls.length === 0 ? ( +
+ +

No missed calls

+
+ ) : ( +
    + {missedCalls.map((call) => { + const phone = call.callerNumber?.[0]?.number ?? ''; + const display = phone ? `+91 ${phone}` : 'Unknown'; + return ( +
  • +
    + {display} + {getTimeSince(call.startedAt)} +
    + {phone && } +
  • + ); + })} +
+ )} +
+
+ ); +}; diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index bdc64a6..027c636 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -88,7 +88,6 @@ const getNavSections = (role: string): NavSection[] => { { label: 'Call Center', items: [ { label: 'Call Desk', href: '/', icon: IconPhone }, { label: 'Patients', href: '/patients', icon: IconHospitalUser }, - { label: 'Follow-ups', href: '/follow-ups', icon: IconBell }, { label: 'Call History', href: '/call-history', icon: IconClockRewind }, ]}, ]; @@ -144,9 +143,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => { {/* Logo + collapse toggle */}
{collapsed ? ( -
- H -
+ Helix Engage ) : (
Helix Engage diff --git a/src/components/layout/top-bar.tsx b/src/components/layout/top-bar.tsx index 05b8642..ea4bb7c 100644 --- a/src/components/layout/top-bar.tsx +++ b/src/components/layout/top-bar.tsx @@ -1,5 +1,3 @@ -import { GlobalSearch } from "@/components/shared/global-search"; - interface TopBarProps { title: string; subtitle?: string; @@ -7,14 +5,10 @@ interface TopBarProps { export const TopBar = ({ title, subtitle }: TopBarProps) => { return ( -
+
-

{title}

- {subtitle &&

{subtitle}

} -
- -
- +

{title}

+ {subtitle &&

{subtitle}

}
); diff --git a/src/lib/queries.ts b/src/lib/queries.ts index fae6543..eb17225 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -50,9 +50,9 @@ export const LEAD_ACTIVITIES_QUERY = `{ leadActivities(first: 200, orderBy: [{ o export const CALLS_QUERY = `{ calls(first: 100, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { id name createdAt - direction callStatus callerNumber agentName + direction callStatus callerNumber { primaryPhoneNumber } agentName startedAt endedAt durationSec - recordingUrl disposition + recording { primaryLinkUrl } disposition patientId appointmentId leadId } } } }`; diff --git a/src/lib/transforms.ts b/src/lib/transforms.ts index 1263c45..ac87854 100644 --- a/src/lib/transforms.ts +++ b/src/lib/transforms.ts @@ -137,12 +137,14 @@ export function transformCalls(data: any): Call[] { createdAt: n.createdAt, callDirection: n.direction, callStatus: n.callStatus, - callerNumber: n.callerNumber ? [{ number: n.callerNumber, callingCode: '+91' }] : [], + callerNumber: n.callerNumber?.primaryPhoneNumber + ? [{ number: n.callerNumber.primaryPhoneNumber, callingCode: '+91' }] + : [], agentName: n.agentName, startedAt: n.startedAt, endedAt: n.endedAt, durationSeconds: n.durationSec ?? 0, - recordingUrl: n.recordingUrl, + recordingUrl: n.recording?.primaryLinkUrl || null, disposition: n.disposition, callNotes: null, patientId: n.patientId, diff --git a/src/main.tsx b/src/main.tsx index 3843ee9..6be276c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -17,6 +17,8 @@ import { Patient360Page } from "@/pages/patient-360"; import { ReportsPage } from "@/pages/reports"; import { PatientsPage } from "@/pages/patients"; import { TeamDashboardPage } from "@/pages/team-dashboard"; +import { IntegrationsPage } from "@/pages/integrations"; +import { SettingsPage } from "@/pages/settings"; import { AuthProvider } from "@/providers/auth-provider"; import { DataProvider } from "@/providers/data-provider"; import { RouteProvider } from "@/providers/router-provider"; @@ -52,6 +54,8 @@ createRoot(document.getElementById("root")!).render( } /> } /> } /> + } /> + } /> } /> } /> diff --git a/src/pages/call-desk.tsx b/src/pages/call-desk.tsx index 035d67f..7bcca6c 100644 --- a/src/pages/call-desk.tsx +++ b/src/pages/call-desk.tsx @@ -5,36 +5,21 @@ import { useAuth } from '@/providers/auth-provider'; import { useData } from '@/providers/data-provider'; import { useWorklist } from '@/hooks/use-worklist'; import { useSip } from '@/providers/sip-provider'; -import { TopBar } from '@/components/layout/top-bar'; import { WorklistPanel } from '@/components/call-desk/worklist-panel'; import type { WorklistLead } from '@/components/call-desk/worklist-panel'; import { ContextPanel } from '@/components/call-desk/context-panel'; import { ActiveCallCard } from '@/components/call-desk/active-call-card'; import { CallPrepCard } from '@/components/call-desk/call-prep-card'; -import { CallLog } from '@/components/call-desk/call-log'; import { BadgeWithDot, Badge } from '@/components/base/badges/badges'; import { cx } from '@/utils/cx'; -type MainTab = 'worklist' | 'calls'; - -const isToday = (dateStr: string): boolean => { - const d = new Date(dateStr); - const now = new Date(); - return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate(); -}; - export const CallDeskPage = () => { const { user } = useAuth(); - const { calls, leadActivities } = useData(); + const { leadActivities } = useData(); const { connectionStatus, isRegistered, callState, callerNumber } = useSip(); const { missedCalls, followUps, marketingLeads, totalPending, loading } = useWorklist(); const [selectedLead, setSelectedLead] = useState(null); const [contextOpen, setContextOpen] = useState(true); - const [mainTab, setMainTab] = useState('worklist'); - - const todaysCalls = calls.filter( - (c) => c.agentName === user.name && c.startedAt !== null && isToday(c.startedAt), - ); const isInCall = callState === 'ringing-in' || callState === 'ringing-out' || callState === 'active'; @@ -47,10 +32,13 @@ export const CallDeskPage = () => { return (
- + {/* Compact header: title + name on left, status + toggle on right */} +
+
+

Call Desk

+ {user.name} +
- {/* Status bar */} -
{ {totalPending > 0 && ( {totalPending} pending )} -
- -
- {/* Main tab toggle */} - {!isInCall && ( -
- - -
- )} - - {/* Context panel toggle */}
); diff --git a/src/pages/integrations.tsx b/src/pages/integrations.tsx new file mode 100644 index 0000000..19e4239 --- /dev/null +++ b/src/pages/integrations.tsx @@ -0,0 +1,176 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faPhone, + faWhatsapp, + faFacebook, + faGoogle, + faInstagram, +} from '@fortawesome/free-brands-svg-icons'; +import { faGlobe, faEnvelope, faCopy, faCircleCheck, faCircleXmark } from '@fortawesome/pro-duotone-svg-icons'; +import { Badge } from '@/components/base/badges/badges'; +import { Button } from '@/components/base/buttons/button'; +import { TopBar } from '@/components/layout/top-bar'; +import { notify } from '@/lib/toast'; + +type IntegrationStatus = 'connected' | 'disconnected' | 'configured'; + +type IntegrationCardProps = { + name: string; + description: string; + icon: any; + iconColor: string; + status: IntegrationStatus; + details: { label: string; value: string }[]; + webhookUrl?: string; +}; + +const statusConfig: Record = { + connected: { color: 'success', label: 'Connected' }, + disconnected: { color: 'error', label: 'Not Connected' }, + configured: { color: 'warning', label: 'Configured' }, +}; + +const IntegrationCard = ({ name, description, icon, iconColor, status, details, webhookUrl }: IntegrationCardProps) => { + const statusCfg = statusConfig[status]; + + const copyWebhook = () => { + if (webhookUrl) { + navigator.clipboard.writeText(webhookUrl); + notify.success('Copied', 'Webhook URL copied to clipboard'); + } + }; + + return ( +
+
+
+
+ +
+
+

{name}

+

{description}

+
+
+ + + {statusCfg.label} + +
+ + {details.length > 0 && ( +
+ {details.map((d) => ( +
+ {d.label} + {d.value} +
+ ))} +
+ )} + + {webhookUrl && ( +
+ {webhookUrl} + +
+ )} +
+ ); +}; + +export const IntegrationsPage = () => { + const webhookBase = 'https://engage-api.srv1477139.hstgr.cloud'; + + return ( +
+ + +
+ {/* Telephony */} + + + {/* WhatsApp */} + + + {/* Facebook Lead Ads */} + + + {/* Google Ads */} + + + {/* Instagram */} + + + {/* Website */} + + + {/* Email */} + +
+
+ ); +}; diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx new file mode 100644 index 0000000..b8f536c --- /dev/null +++ b/src/pages/settings.tsx @@ -0,0 +1,130 @@ +import { useEffect, useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faKey, faToggleOn, faToggleOff } from '@fortawesome/pro-duotone-svg-icons'; +import { Avatar } from '@/components/base/avatar/avatar'; +import { Badge } from '@/components/base/badges/badges'; +import { Button } from '@/components/base/buttons/button'; +import { Table, TableCard } from '@/components/application/table/table'; +import { TopBar } from '@/components/layout/top-bar'; +import { apiClient } from '@/lib/api-client'; +import { notify } from '@/lib/toast'; +import { getInitials } from '@/lib/format'; + +type WorkspaceMember = { + id: string; + name: { firstName: string; lastName: string } | null; + userEmail: string; + avatarUrl: string | null; + roles: { id: string; label: string }[]; +}; + +export const SettingsPage = () => { + const [members, setMembers] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchMembers = async () => { + try { + const data = await apiClient.graphql( + `{ workspaceMembers(first: 50) { edges { node { id name { firstName lastName } userEmail avatarUrl roles { id label } } } } }`, + undefined, + { silent: true }, + ); + setMembers(data?.workspaceMembers?.edges?.map((e: any) => e.node) ?? []); + } catch { + // silently fail + } finally { + setLoading(false); + } + }; + fetchMembers(); + }, []); + + const handleResetPassword = (member: WorkspaceMember) => { + notify.info('Password Reset', `Password reset link would be sent to ${member.userEmail}`); + }; + + return ( +
+ + +
+ {/* Employees section */} + + + {loading ? ( +
+

Loading employees...

+
+ ) : ( + + + + + + + + + + {(member) => { + const firstName = member.name?.firstName ?? ''; + const lastName = member.name?.lastName ?? ''; + const fullName = `${firstName} ${lastName}`.trim() || 'Unnamed'; + const initials = getInitials(firstName || '?', lastName || '?'); + const roles = member.roles?.map((r) => r.label) ?? []; + + return ( + + +
+ + {fullName} +
+
+ + {member.userEmail} + + +
+ {roles.length > 0 ? roles.map((role) => ( + + {role} + + )) : ( + No roles + )} +
+
+ + + + Active + + + + + +
+ ); + }} +
+
+ )} +
+
+
+ ); +}; diff --git a/src/pages/team-dashboard.tsx b/src/pages/team-dashboard.tsx index dfa8557..c9ffd74 100644 --- a/src/pages/team-dashboard.tsx +++ b/src/pages/team-dashboard.tsx @@ -1,114 +1,31 @@ import { useMemo, useState } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { - faPhone, - faPhoneArrowDownLeft, - faPhoneArrowUpRight, - faPhoneMissed, - faUserHeadset, - faChartMixed, -} from '@fortawesome/pro-duotone-svg-icons'; -import type { IconDefinition } from '@fortawesome/fontawesome-svg-core'; -import { Avatar } from '@/components/base/avatar/avatar'; -import { Badge } from '@/components/base/badges/badges'; -import { Table, TableCard } from '@/components/application/table/table'; -import { TopBar } from '@/components/layout/top-bar'; +import { faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons'; import { AiChatPanel } from '@/components/call-desk/ai-chat-panel'; +import { DashboardKpi } from '@/components/dashboard/kpi-cards'; +import { AgentTable } from '@/components/dashboard/agent-table'; +import { MissedQueue } from '@/components/dashboard/missed-queue'; import { useData } from '@/providers/data-provider'; -import { getInitials } from '@/lib/format'; -import type { Call } from '@/types/entities'; - -// KPI Card component -type KpiCardProps = { - label: string; - value: number | string; - icon: IconDefinition; - iconColor: string; - iconBg: string; - subtitle?: string; -}; - -const KpiCard = ({ label, value, icon, iconColor, iconBg, subtitle }: KpiCardProps) => ( -
-
- -
-
- {label} - {value} - {subtitle && {subtitle}} -
-
-); - -// Metric card for performance row -type MetricCardProps = { - label: string; - value: string; - description: string; -}; - -const MetricCard = ({ label, value, description }: MetricCardProps) => ( -
- {label} - {value} - {description} -
-); +import { cx } from '@/utils/cx'; type DateRange = 'today' | 'week' | 'month'; +type DashboardTab = 'agents' | 'missed' | 'campaigns'; const getDateRangeStart = (range: DateRange): Date => { const now = new Date(); switch (range) { - case 'today': { - const start = new Date(now); - start.setHours(0, 0, 0, 0); - return start; - } - case 'week': { - const start = new Date(now); - start.setDate(start.getDate() - 7); - return start; - } - case 'month': { - const start = new Date(now); - start.setDate(start.getDate() - 30); - return start; - } + case 'today': { const s = new Date(now); s.setHours(0, 0, 0, 0); return s; } + case 'week': { const s = new Date(now); s.setDate(s.getDate() - 7); return s; } + case 'month': { const s = new Date(now); s.setDate(s.getDate() - 30); return s; } } }; -const formatDuration = (seconds: number): string => { - if (seconds < 60) return `${seconds}s`; - const mins = Math.floor(seconds / 60); - const secs = seconds % 60; - return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`; -}; - -const formatPercent = (value: number): string => { - if (isNaN(value) || !isFinite(value)) return '0%'; - return `${Math.round(value)}%`; -}; - -type AgentPerformance = { - id: string; - name: string; - initials: string; - inboundCalls: number; - outboundCalls: number; - missedCalls: number; - totalCalls: number; - avgHandleTime: number; - appointmentsBooked: number; - conversionRate: number; -}; - export const TeamDashboardPage = () => { - const { calls, leads, loading } = useData(); + const { calls, leads, campaigns, loading } = useData(); const [dateRange, setDateRange] = useState('week'); + const [tab, setTab] = useState('agents'); + const [aiOpen, setAiOpen] = useState(true); - // Filter calls by date range const filteredCalls = useMemo(() => { const rangeStart = getDateRangeStart(dateRange); return calls.filter((call) => { @@ -117,329 +34,128 @@ export const TeamDashboardPage = () => { }); }, [calls, dateRange]); - // KPI computations - const totalCalls = filteredCalls.length; - const inboundCalls = filteredCalls.filter((c) => c.callDirection === 'INBOUND').length; - const outboundCalls = filteredCalls.filter((c) => c.callDirection === 'OUTBOUND').length; - const missedCalls = filteredCalls.filter((c) => c.callStatus === 'MISSED').length; - - // Performance metrics - const avgResponseTime = useMemo(() => { - const leadsWithResponse = leads.filter((l) => l.createdAt && l.firstContactedAt); - if (leadsWithResponse.length === 0) return null; - const totalMinutes = leadsWithResponse.reduce((sum, l) => { - const created = new Date(l.createdAt!).getTime(); - const contacted = new Date(l.firstContactedAt!).getTime(); - return sum + (contacted - created) / 60000; - }, 0); - return Math.round(totalMinutes / leadsWithResponse.length); - }, [leads]); - - const missedCallbackTime = useMemo(() => { - const missedCallsList = filteredCalls.filter((c) => c.callStatus === 'MISSED' && c.startedAt); - if (missedCallsList.length === 0) return null; - const now = Date.now(); - const totalMinutes = missedCallsList.reduce((sum, c) => { - return sum + (now - new Date(c.startedAt!).getTime()) / 60000; - }, 0); - return Math.round(totalMinutes / missedCallsList.length); - }, [filteredCalls]); - - const callToAppointmentRate = useMemo(() => { - if (totalCalls === 0) return 0; - const booked = filteredCalls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length; - return (booked / totalCalls) * 100; - }, [filteredCalls, totalCalls]); - - const leadToAppointmentRate = useMemo(() => { - if (leads.length === 0) return 0; - const converted = leads.filter( - (l) => l.leadStatus === 'APPOINTMENT_SET' || l.leadStatus === 'CONVERTED', - ).length; - return (converted / leads.length) * 100; - }, [leads]); - - // Agent performance table data - const agentPerformance = useMemo((): AgentPerformance[] => { - const agentMap = new Map(); - for (const call of filteredCalls) { - const agent = call.agentName ?? 'Unknown'; - if (!agentMap.has(agent)) agentMap.set(agent, []); - agentMap.get(agent)!.push(call); - } - - return Array.from(agentMap.entries()).map(([name, agentCalls]) => { - const inbound = agentCalls.filter((c) => c.callDirection === 'INBOUND').length; - const outbound = agentCalls.filter((c) => c.callDirection === 'OUTBOUND').length; - const missed = agentCalls.filter((c) => c.callStatus === 'MISSED').length; - const total = agentCalls.length; - const totalDuration = agentCalls.reduce((sum, c) => sum + (c.durationSeconds ?? 0), 0); - const completedCalls = agentCalls.filter((c) => (c.durationSeconds ?? 0) > 0); - const avgHandle = completedCalls.length > 0 ? Math.round(totalDuration / completedCalls.length) : 0; - const booked = agentCalls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length; - const conversion = total > 0 ? (booked / total) * 100 : 0; - - const nameParts = name.split(' '); - const initials = getInitials(nameParts[0] ?? '', nameParts[1] ?? ''); - - return { - id: name, - name, - initials, - inboundCalls: inbound, - outboundCalls: outbound, - missedCalls: missed, - totalCalls: total, - avgHandleTime: avgHandle, - appointmentsBooked: booked, - conversionRate: conversion, - }; - }).sort((a, b) => b.totalCalls - a.totalCalls); - }, [filteredCalls]); - - // Missed call queue (recent missed calls) - const missedCallQueue = useMemo(() => { - return filteredCalls - .filter((c) => c.callStatus === 'MISSED') - .sort((a, b) => { - const dateA = a.startedAt ? new Date(a.startedAt).getTime() : 0; - const dateB = b.startedAt ? new Date(b.startedAt).getTime() : 0; - return dateB - dateA; - }) - .slice(0, 10); - }, [filteredCalls]); - - const formatCallerPhone = (call: Call): string => { - if (!call.callerNumber || call.callerNumber.length === 0) return 'Unknown'; - const first = call.callerNumber[0]; - return `${first.callingCode} ${first.number}`; - }; - - const getTimeSince = (dateStr: string | null): string => { - if (!dateStr) return '—'; - const diffMs = Date.now() - new Date(dateStr).getTime(); - const mins = Math.floor(diffMs / 60000); - if (mins < 1) return 'Just now'; - if (mins < 60) return `${mins}m ago`; - const hours = Math.floor(mins / 60); - if (hours < 24) return `${hours}h ago`; - return `${Math.floor(hours / 24)}d ago`; - }; - const dateRangeLabel = dateRange === 'today' ? 'Today' : dateRange === 'week' ? 'This Week' : 'This Month'; + const tabs = [ + { id: 'agents' as const, label: 'Agent Performance' }, + { id: 'missed' as const, label: `Missed Queue (${filteredCalls.filter(c => c.callStatus === 'MISSED').length})` }, + { id: 'campaigns' as const, label: `Campaigns (${campaigns.length})` }, + ]; + return (
- - -
- {/* Date range filter */} -
-

Overview

+ {/* Header */} +
+
+

Team Dashboard

+ {dateRangeLabel} +
+
{(['today', 'week', 'month'] as const).map((range) => ( ))}
+
+
- {/* KPI Cards Row */} -
- - - - 0 ? `${formatPercent((missedCalls / totalCalls) * 100)} of total` : undefined} - /> -
- - {/* Performance Metrics Row */} -
- - - - -
- - {/* Agent Performance Table + Missed Call Queue */} -
- {/* Agent Performance Table */} -
- - - {loading ? ( -
-

Loading...

-
- ) : agentPerformance.length === 0 ? ( -
- -

No agent data available

-
- ) : ( - - - - - - - - - - - {(agent) => ( - - -
- -
- - {agent.name} - - - {agent.totalCalls} total calls - -
-
-
- - {agent.inboundCalls} - - - {agent.outboundCalls} - - - {agent.missedCalls > 0 ? ( - {agent.missedCalls} - ) : ( - 0 - )} - - - - {formatDuration(agent.avgHandleTime)} - - - - = 30 ? 'success' : agent.conversionRate >= 15 ? 'warning' : 'gray'} - > - {formatPercent(agent.conversionRate)} - - -
- )} -
-
- )} -
+
+ {/* Main content */} +
+ {/* KPI cards — always visible */} +
+
- {/* Missed Call Queue */} -
-
-
-
- -

Missed Call Queue

-
- {missedCalls > 0 && ( - {missedCalls} + {/* Tabs */} +
+ {tabs.map((t) => ( + + ))} +
+ + {/* Tab content */} +
+ {loading && ( +
+

Loading...

-
- {missedCallQueue.length === 0 ? ( -
- -

No missed calls

-
+ )} + + {!loading && tab === 'agents' && ( + + )} + + {!loading && tab === 'missed' && ( + + )} + + {!loading && tab === 'campaigns' && ( +
+ {campaigns.length === 0 ? ( +

No campaigns

) : ( -
    - {missedCallQueue.map((call) => ( -
  • -
    - - {formatCallerPhone(call)} - - {call.leadName && ( - {call.leadName} - )} + campaigns.map((c) => ( +
    +
    + {c.campaignName} +
    + {c.campaignStatus} + {c.platform} + {c.leadCount} leads + {c.convertedCount} converted
    - - {getTimeSince(call.startedAt)} +
    + {c.budget && ( + + ₹{Math.round(c.budget.amountMicros / 1_000_000).toLocaleString()} -
  • - ))} -
+ )} +
+ )) )}
-
+ )}
- {/* AI Assistant Section */} -
-
- -

Supervisor AI Assistant

-
-
- -
+ {/* AI panel — collapsible */} +
+ {aiOpen && ( +
+ +
+ )}