mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user