feat: dashboard restructure, integrations, settings, UI fixes

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) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 15:58:31 +05:30
parent 94f4a18035
commit d9d98bce9c
15 changed files with 805 additions and 521 deletions

View File

@@ -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<WorklistLead | null>(null);
const [contextOpen, setContextOpen] = useState(true);
const [mainTab, setMainTab] = useState<MainTab>('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 (
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Call Desk" subtitle={`${user.name} \u00B7 Global Hospital`} />
{/* Compact header: title + name on left, status + toggle on right */}
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3">
<div className="flex items-center gap-3">
<h1 className="text-lg font-bold text-primary">Call Desk</h1>
<span className="text-sm text-tertiary">{user.name}</span>
</div>
{/* Status bar */}
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-2">
<div className="flex items-center gap-2">
<BadgeWithDot
color={isRegistered ? 'success' : connectionStatus === 'connecting' ? 'warning' : 'gray'}
@@ -62,37 +50,9 @@ export const CallDeskPage = () => {
{totalPending > 0 && (
<Badge size="sm" color="brand" type="pill-color">{totalPending} pending</Badge>
)}
</div>
<div className="flex items-center gap-3">
{/* Main tab toggle */}
{!isInCall && (
<div className="flex rounded-lg border border-secondary">
<button
onClick={() => setMainTab('worklist')}
className={cx(
"px-3 py-1 text-xs font-semibold transition duration-100 ease-linear rounded-l-lg",
mainTab === 'worklist' ? "bg-active text-brand-secondary" : "text-tertiary hover:text-secondary",
)}
>
Worklist
</button>
<button
onClick={() => setMainTab('calls')}
className={cx(
"px-3 py-1 text-xs font-semibold transition duration-100 ease-linear rounded-r-lg",
mainTab === 'calls' ? "bg-active text-brand-secondary" : "text-tertiary hover:text-secondary",
)}
>
Today&apos;s Calls ({todaysCalls.length})
</button>
</div>
)}
{/* Context panel toggle */}
<button
onClick={() => setContextOpen(!contextOpen)}
className="flex size-7 items-center justify-center rounded-md text-fg-quaternary hover:text-fg-secondary hover:bg-secondary transition duration-100 ease-linear"
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
title={contextOpen ? 'Hide AI panel' : 'Show AI panel'}
>
<FontAwesomeIcon icon={contextOpen ? faSidebarFlip : faSidebar} className="size-4" />
@@ -102,46 +62,42 @@ export const CallDeskPage = () => {
{/* Main content */}
<div className="flex flex-1 overflow-hidden">
{/* Main panel — expands when context is closed */}
{/* Main panel */}
<div className="flex flex-1 flex-col overflow-y-auto">
<div className="flex-1 p-5">
{/* Active call */}
{isInCall && (
<div className="space-y-4">
<ActiveCallCard lead={activeLeadFull} callerPhone={callerNumber ?? ''} />
<CallPrepCard lead={activeLeadFull} callerPhone={callerNumber ?? ''} activities={leadActivities} />
</div>
)}
{/* Active call */}
{isInCall && (
<div className="space-y-4 p-5">
<ActiveCallCard lead={activeLeadFull} callerPhone={callerNumber ?? ''} />
<CallPrepCard lead={activeLeadFull} callerPhone={callerNumber ?? ''} activities={leadActivities} />
</div>
)}
{/* Worklist tab */}
{!isInCall && mainTab === 'worklist' && (
<WorklistPanel
missedCalls={missedCalls}
followUps={followUps}
leads={marketingLeads}
loading={loading}
onSelectLead={(lead) => setSelectedLead(lead)}
selectedLeadId={selectedLead?.id ?? null}
/>
)}
{/* Today's Calls tab */}
{!isInCall && mainTab === 'calls' && (
<CallLog calls={todaysCalls} />
)}
</div>
{/* Worklist — no wrapper, tabs + table fill the space */}
{!isInCall && (
<WorklistPanel
missedCalls={missedCalls}
followUps={followUps}
leads={marketingLeads}
loading={loading}
onSelectLead={(lead) => setSelectedLead(lead)}
selectedLeadId={selectedLead?.id ?? null}
/>
)}
</div>
{/* Context panel — collapsible */}
{contextOpen && (
<div className="w-[400px] shrink-0 border-l border-secondary bg-primary flex flex-col">
{/* Context panel — collapsible with smooth transition */}
<div className={cx(
"shrink-0 border-l border-secondary bg-primary flex flex-col overflow-hidden transition-all duration-200 ease-linear",
contextOpen ? "w-[400px]" : "w-0 border-l-0",
)}>
{contextOpen && (
<ContextPanel
selectedLead={activeLeadFull}
activities={leadActivities}
callerPhone={callerNumber ?? undefined}
/>
</div>
)}
)}
</div>
</div>
</div>
);

176
src/pages/integrations.tsx Normal file
View File

@@ -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<IntegrationStatus, { color: 'success' | 'error' | 'warning'; label: string }> = {
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 (
<div className="rounded-xl border border-secondary bg-primary p-5 shadow-xs">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-lg bg-secondary">
<FontAwesomeIcon icon={icon} className={`size-5 ${iconColor}`} />
</div>
<div>
<h3 className="text-sm font-semibold text-primary">{name}</h3>
<p className="text-xs text-tertiary">{description}</p>
</div>
</div>
<Badge size="sm" color={statusCfg.color} type="pill-color">
<FontAwesomeIcon icon={status === 'connected' ? faCircleCheck : faCircleXmark} className="mr-1 size-3" />
{statusCfg.label}
</Badge>
</div>
{details.length > 0 && (
<div className="mt-4 space-y-2">
{details.map((d) => (
<div key={d.label} className="flex items-center justify-between text-xs">
<span className="text-tertiary">{d.label}</span>
<span className="font-medium text-secondary">{d.value}</span>
</div>
))}
</div>
)}
{webhookUrl && (
<div className="mt-4 flex items-center gap-2 rounded-lg bg-secondary p-3">
<code className="flex-1 truncate text-xs text-secondary">{webhookUrl}</code>
<Button size="sm" color="secondary" iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faCopy} className={className} />
)} onClick={copyWebhook}>
Copy
</Button>
</div>
)}
</div>
);
};
export const IntegrationsPage = () => {
const webhookBase = 'https://engage-api.srv1477139.hstgr.cloud';
return (
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Integrations" subtitle="Manage external service connections" />
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{/* Telephony */}
<IntegrationCard
name="Ozonetel CloudAgent"
description="Cloud telephony — inbound/outbound calls, SIP softphone, IVR"
icon={faPhone}
iconColor="text-fg-brand-primary"
status="connected"
details={[
{ label: 'Account', value: 'global_healthx' },
{ label: 'Agent ID', value: 'global' },
{ label: 'SIP Extension', value: '523590' },
{ label: 'Inbound Campaign', value: 'Inbound_918041763265' },
{ label: 'DID Number', value: '+91 804 176 3265' },
]}
webhookUrl={`${webhookBase}/webhooks/ozonetel/missed-call`}
/>
{/* WhatsApp */}
<IntegrationCard
name="WhatsApp Business"
description="Send templates, receive messages, automate outreach"
icon={faWhatsapp}
iconColor="text-green-600"
status="disconnected"
details={[]}
/>
{/* Facebook Lead Ads */}
<IntegrationCard
name="Facebook Lead Ads"
description="Auto-import leads from Facebook ad campaigns"
icon={faFacebook}
iconColor="text-blue-600"
status="disconnected"
details={[]}
/>
{/* Google Ads */}
<IntegrationCard
name="Google Ads"
description="Sync leads from Google Search and Display campaigns"
icon={faGoogle}
iconColor="text-red-500"
status="disconnected"
details={[]}
/>
{/* Instagram */}
<IntegrationCard
name="Instagram Lead Ads"
description="Capture leads from Instagram ad forms"
icon={faInstagram}
iconColor="text-pink-600"
status="disconnected"
details={[]}
/>
{/* Website */}
<IntegrationCard
name="Website Lead Forms"
description="Capture leads from website contact forms via webhook"
icon={faGlobe}
iconColor="text-fg-brand-primary"
status="configured"
details={[
{ label: 'Method', value: 'POST webhook' },
]}
webhookUrl={`${webhookBase}/webhooks/website/lead`}
/>
{/* Email */}
<IntegrationCard
name="Email (SMTP)"
description="Outbound email for campaigns and notifications"
icon={faEnvelope}
iconColor="text-fg-quaternary"
status="disconnected"
details={[]}
/>
</div>
</div>
);
};

130
src/pages/settings.tsx Normal file
View File

@@ -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<WorkspaceMember[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchMembers = async () => {
try {
const data = await apiClient.graphql<any>(
`{ 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 (
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Settings" subtitle="Team management and configuration" />
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Employees section */}
<TableCard.Root size="sm">
<TableCard.Header
title="Employees"
badge={members.length}
description="Manage team members and their roles"
/>
{loading ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-tertiary">Loading employees...</p>
</div>
) : (
<Table>
<Table.Header>
<Table.Head label="EMPLOYEE" />
<Table.Head label="EMAIL" />
<Table.Head label="ROLES" />
<Table.Head label="STATUS" />
<Table.Head label="ACTIONS" />
</Table.Header>
<Table.Body items={members}>
{(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 (
<Table.Row id={member.id}>
<Table.Cell>
<div className="flex items-center gap-3">
<Avatar size="sm" initials={initials} src={member.avatarUrl ?? undefined} />
<span className="text-sm font-medium text-primary">{fullName}</span>
</div>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-tertiary">{member.userEmail}</span>
</Table.Cell>
<Table.Cell>
<div className="flex flex-wrap gap-1">
{roles.length > 0 ? roles.map((role) => (
<Badge key={role} size="sm" color={role.includes('Manager') ? 'brand' : 'gray'}>
{role}
</Badge>
)) : (
<span className="text-xs text-quaternary">No roles</span>
)}
</div>
</Table.Cell>
<Table.Cell>
<Badge size="sm" color="success" type="pill-color">
<FontAwesomeIcon icon={faToggleOn} className="mr-1 size-3" />
Active
</Badge>
</Table.Cell>
<Table.Cell>
<Button
size="sm"
color="secondary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faKey} className={className} />
)}
onClick={() => handleResetPassword(member)}
>
Reset Password
</Button>
</Table.Cell>
</Table.Row>
);
}}
</Table.Body>
</Table>
)}
</TableCard.Root>
</div>
</div>
);
};

View File

@@ -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) => (
<div className="flex flex-1 items-center gap-4 rounded-xl border border-secondary bg-primary p-5 shadow-xs">
<div className={`flex size-12 shrink-0 items-center justify-center rounded-full ${iconBg}`}>
<FontAwesomeIcon icon={icon} className={`size-5 ${iconColor}`} />
</div>
<div className="flex flex-col">
<span className="text-xs font-medium text-tertiary">{label}</span>
<span className="text-display-xs font-bold text-primary">{value}</span>
{subtitle && <span className="text-xs text-tertiary">{subtitle}</span>}
</div>
</div>
);
// Metric card for performance row
type MetricCardProps = {
label: string;
value: string;
description: string;
};
const MetricCard = ({ label, value, description }: MetricCardProps) => (
<div className="flex flex-1 flex-col gap-1 rounded-xl border border-secondary bg-primary p-5 shadow-xs">
<span className="text-xs font-medium text-tertiary">{label}</span>
<span className="text-lg font-bold text-primary">{value}</span>
<span className="text-xs text-tertiary">{description}</span>
</div>
);
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<DateRange>('week');
const [tab, setTab] = useState<DashboardTab>('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<string, Call[]>();
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 (
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Team Dashboard" subtitle={`Global Hospital \u00b7 ${dateRangeLabel}`} />
<div className="flex-1 overflow-y-auto p-7 space-y-6">
{/* Date range filter */}
<div className="flex items-center justify-between">
<h2 className="text-md font-semibold text-primary">Overview</h2>
{/* Header */}
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3">
<div className="flex items-center gap-3">
<h1 className="text-lg font-bold text-primary">Team Dashboard</h1>
<span className="text-sm text-tertiary">{dateRangeLabel}</span>
</div>
<div className="flex items-center gap-2">
<div className="flex rounded-lg border border-secondary overflow-hidden">
{(['today', 'week', 'month'] as const).map((range) => (
<button
key={range}
onClick={() => setDateRange(range)}
className={`px-3 py-1.5 text-xs font-medium transition duration-100 ease-linear capitalize ${
dateRange === range
? 'bg-active text-brand-secondary'
: 'bg-primary text-tertiary hover:bg-primary_hover'
}`}
className={cx(
"px-3 py-1 text-xs font-medium transition duration-100 ease-linear",
dateRange === range ? 'bg-active text-brand-secondary' : 'text-tertiary hover:bg-primary_hover',
)}
>
{range === 'today' ? 'Today' : range === 'week' ? 'Week' : 'Month'}
</button>
))}
</div>
<button
onClick={() => setAiOpen(!aiOpen)}
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
title={aiOpen ? 'Hide AI panel' : 'Show AI panel'}
>
<FontAwesomeIcon icon={aiOpen ? faSidebarFlip : faSidebar} className="size-4" />
</button>
</div>
</div>
{/* KPI Cards Row */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<KpiCard
label="Total Calls"
value={totalCalls}
icon={faPhone}
iconColor="text-fg-brand-primary"
iconBg="bg-brand-secondary"
/>
<KpiCard
label="Inbound"
value={inboundCalls}
icon={faPhoneArrowDownLeft}
iconColor="text-fg-success-primary"
iconBg="bg-success-secondary"
/>
<KpiCard
label="Outbound"
value={outboundCalls}
icon={faPhoneArrowUpRight}
iconColor="text-fg-brand-primary"
iconBg="bg-brand-secondary"
/>
<KpiCard
label="Missed"
value={missedCalls}
icon={faPhoneMissed}
iconColor="text-fg-error-primary"
iconBg="bg-error-secondary"
subtitle={totalCalls > 0 ? `${formatPercent((missedCalls / totalCalls) * 100)} of total` : undefined}
/>
</div>
{/* Performance Metrics Row */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<MetricCard
label="Avg Lead Response Time"
value={avgResponseTime !== null ? `${avgResponseTime} min` : '—'}
description="Time from lead creation to first contact"
/>
<MetricCard
label="Avg Missed Callback Time"
value={missedCallbackTime !== null ? `${missedCallbackTime} min` : '—'}
description="Avg wait time for missed call callbacks"
/>
<MetricCard
label="Call to Appointment %"
value={formatPercent(callToAppointmentRate)}
description="Calls resulting in appointments"
/>
<MetricCard
label="Lead to Appointment %"
value={formatPercent(leadToAppointmentRate)}
description="Leads converted to appointments"
/>
</div>
{/* Agent Performance Table + Missed Call Queue */}
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
{/* Agent Performance Table */}
<div className="xl:col-span-2">
<TableCard.Root size="sm">
<TableCard.Header
title="Agent Performance"
badge={agentPerformance.length}
description="Call metrics by agent"
/>
{loading ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-tertiary">Loading...</p>
</div>
) : agentPerformance.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 gap-2">
<FontAwesomeIcon icon={faUserHeadset} className="size-8 text-fg-quaternary" />
<p className="text-sm text-tertiary">No agent data available</p>
</div>
) : (
<Table>
<Table.Header>
<Table.Head label="AGENT" />
<Table.Head label="INBOUND" />
<Table.Head label="OUTBOUND" />
<Table.Head label="MISSED" />
<Table.Head label="AVG HANDLE TIME" />
<Table.Head label="CONVERSION %" />
</Table.Header>
<Table.Body items={agentPerformance}>
{(agent) => (
<Table.Row id={agent.id}>
<Table.Cell>
<div className="flex items-center gap-3">
<Avatar size="sm" initials={agent.initials} />
<div className="flex flex-col">
<span className="text-sm font-medium text-primary">
{agent.name}
</span>
<span className="text-xs text-tertiary">
{agent.totalCalls} total calls
</span>
</div>
</div>
</Table.Cell>
<Table.Cell>
<span className="text-sm font-medium text-success-primary">{agent.inboundCalls}</span>
</Table.Cell>
<Table.Cell>
<span className="text-sm font-medium text-brand-secondary">{agent.outboundCalls}</span>
</Table.Cell>
<Table.Cell>
{agent.missedCalls > 0 ? (
<Badge size="sm" color="error">{agent.missedCalls}</Badge>
) : (
<span className="text-sm text-tertiary">0</span>
)}
</Table.Cell>
<Table.Cell>
<span className="text-sm text-secondary">
{formatDuration(agent.avgHandleTime)}
</span>
</Table.Cell>
<Table.Cell>
<Badge
size="sm"
color={agent.conversionRate >= 30 ? 'success' : agent.conversionRate >= 15 ? 'warning' : 'gray'}
>
{formatPercent(agent.conversionRate)}
</Badge>
</Table.Cell>
</Table.Row>
)}
</Table.Body>
</Table>
)}
</TableCard.Root>
<div className="flex flex-1 overflow-hidden">
{/* Main content */}
<div className="flex flex-1 flex-col overflow-y-auto">
{/* KPI cards — always visible */}
<div className="px-6 pt-5 pb-3">
<DashboardKpi calls={filteredCalls} leads={leads} />
</div>
{/* Missed Call Queue */}
<div className="xl:col-span-1">
<div className="rounded-xl border border-secondary bg-primary shadow-xs">
<div className="flex items-center justify-between border-b border-secondary px-5 py-4">
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faPhoneMissed} className="size-4 text-fg-error-primary" />
<h3 className="text-md font-semibold text-primary">Missed Call Queue</h3>
</div>
{missedCalls > 0 && (
<Badge size="sm" color="error">{missedCalls}</Badge>
{/* Tabs */}
<div className="flex items-center gap-1 border-b border-secondary px-6">
{tabs.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={cx(
"px-3 py-2.5 text-xs font-semibold transition duration-100 ease-linear border-b-2",
tab === t.id
? "border-brand text-brand-secondary"
: "border-transparent text-tertiary hover:text-secondary",
)}
>
{t.label}
</button>
))}
</div>
{/* Tab content */}
<div className="flex-1 p-6">
{loading && (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-tertiary">Loading...</p>
</div>
<div className="max-h-[400px] overflow-y-auto">
{missedCallQueue.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 gap-2">
<FontAwesomeIcon icon={faPhoneMissed} className="size-6 text-fg-quaternary" />
<p className="text-sm text-tertiary">No missed calls</p>
</div>
)}
{!loading && tab === 'agents' && (
<AgentTable calls={filteredCalls} />
)}
{!loading && tab === 'missed' && (
<MissedQueue calls={filteredCalls} />
)}
{!loading && tab === 'campaigns' && (
<div className="space-y-3">
{campaigns.length === 0 ? (
<p className="text-sm text-tertiary py-12 text-center">No campaigns</p>
) : (
<ul className="divide-y divide-secondary">
{missedCallQueue.map((call) => (
<li key={call.id} className="flex items-center justify-between px-5 py-3 hover:bg-primary_hover transition duration-100 ease-linear">
<div className="flex flex-col">
<span className="text-sm font-medium text-primary">
{formatCallerPhone(call)}
</span>
{call.leadName && (
<span className="text-xs text-tertiary">{call.leadName}</span>
)}
campaigns.map((c) => (
<div key={c.id} className="flex items-center justify-between rounded-xl border border-secondary bg-primary p-4 shadow-xs">
<div>
<span className="text-sm font-semibold text-primary">{c.campaignName}</span>
<div className="flex items-center gap-3 mt-1 text-xs text-tertiary">
<span>{c.campaignStatus}</span>
<span>{c.platform}</span>
<span>{c.leadCount} leads</span>
<span>{c.convertedCount} converted</span>
</div>
<span className="text-xs text-tertiary whitespace-nowrap">
{getTimeSince(call.startedAt)}
</div>
{c.budget && (
<span className="text-sm font-medium text-secondary">
{Math.round(c.budget.amountMicros / 1_000_000).toLocaleString()}
</span>
</li>
))}
</ul>
)}
</div>
))
)}
</div>
</div>
)}
</div>
</div>
{/* AI Assistant Section */}
<div className="rounded-xl border border-secondary bg-primary p-5 shadow-xs">
<div className="flex items-center gap-2 mb-4">
<FontAwesomeIcon icon={faChartMixed} className="size-4 text-fg-brand-primary" />
<h3 className="text-md font-semibold text-primary">Supervisor AI Assistant</h3>
</div>
<div className="h-[350px]">
<AiChatPanel />
</div>
{/* AI panel — collapsible */}
<div className={cx(
"shrink-0 border-l border-secondary bg-primary flex flex-col overflow-hidden transition-all duration-200 ease-linear",
aiOpen ? "w-[380px]" : "w-0 border-l-0",
)}>
{aiOpen && (
<div className="flex h-full flex-col p-4">
<AiChatPanel />
</div>
)}
</div>
</div>
</div>