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

@@ -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 {

View File

@@ -99,12 +99,6 @@ export const AiChatPanel = ({ callerContext }: AiChatPanelProps) => {
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="flex items-center gap-2 pb-3">
<FontAwesomeIcon icon={faSparkles} className="size-4 text-fg-brand-primary" />
<h3 className="text-sm font-bold text-primary">AI Assistant</h3>
</div>
{/* Caller context banner */}
{callerContext?.leadName && (
<div className="mb-3 rounded-lg bg-brand-primary px-3 py-2">

View File

@@ -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 (
<TableCard.Root size="sm">
<TableCard.Header
title="Worklist"
badge={String(allRows.length)}
contentTrailing={
<div className="flex items-center gap-2">
<div className="w-48">
<Input
placeholder="Search..."
icon={SearchLg}
size="sm"
value={search}
onChange={(value) => setSearch(value)}
aria-label="Search worklist"
/>
</div>
</div>
}
/>
{/* Filter tabs */}
<div className="border-b border-secondary px-4">
<Tabs selectedKey={tab} onSelectionChange={(key) => setTab(key as TabKey)}>
<div className="flex flex-1 flex-col">
{/* Filter tabs + search — single row */}
<div className="flex items-end justify-between border-b border-secondary px-5 pt-3 pb-0.5">
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTabChange(key as TabKey)}>
<TabList items={tabItems} type="underline" size="sm">
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
</TabList>
</Tabs>
<div className="w-44 shrink-0">
<Input
placeholder="Search..."
icon={SearchLg}
size="sm"
value={search}
onChange={handleSearch}
aria-label="Search worklist"
/>
</div>
</div>
{filteredRows.length === 0 ? (
<div className="flex items-center justify-center py-8">
<div className="flex items-center justify-center py-12">
<p className="text-sm text-quaternary">
{search ? 'No matching items' : 'No items in this category'}
</p>
</div>
) : (
<Table>
<div className="px-2 pt-3">
<Table size="sm">
<Table.Header>
<Table.Head label="PRIORITY" className="w-20" />
<Table.Head label="PATIENT" />
@@ -296,7 +298,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
<Table.Head label="SLA" className="w-20" />
<Table.Head label="ACTIONS" className="w-24" />
</Table.Header>
<Table.Body items={filteredRows}>
<Table.Body items={pagedRows}>
{(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
}}
</Table.Body>
</Table>
{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-secondary px-5 py-3">
<span className="text-xs text-tertiary">
Showing {(page - 1) * PAGE_SIZE + 1}{Math.min(page * PAGE_SIZE, filteredRows.length)} of {filteredRows.length}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page === 1}
className="px-2 py-1 text-xs font-medium text-secondary rounded-md hover:bg-primary_hover disabled:text-disabled disabled:cursor-not-allowed transition duration-100 ease-linear"
>
Previous
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<button
key={p}
onClick={() => setPage(p)}
className={cx(
"size-8 text-xs font-medium rounded-lg transition duration-100 ease-linear",
p === page ? "bg-active text-brand-secondary" : "text-tertiary hover:bg-primary_hover",
)}
>
{p}
</button>
))}
<button
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page === totalPages}
className="px-2 py-1 text-xs font-medium text-secondary rounded-md hover:bg-primary_hover disabled:text-disabled disabled:cursor-not-allowed transition duration-100 ease-linear"
>
Next
</button>
</div>
</div>
)}
</div>
)}
</TableCard.Root>
</div>
);
};

View File

@@ -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<string, Call[]>();
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 (
<TableCard.Root size="sm">
<TableCard.Header title="Agent Performance" description="Call metrics by agent" />
<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>
</TableCard.Root>
);
}
return (
<TableCard.Root size="sm">
<TableCard.Header title="Agent Performance" badge={agents.length} description="Call metrics by agent" />
<Table>
<Table.Header>
<Table.Head label="AGENT" />
<Table.Head label="IN" />
<Table.Head label="OUT" />
<Table.Head label="MISSED" />
<Table.Head label="AVG HANDLE" />
<Table.Head label="CONVERSION" />
</Table.Header>
<Table.Body items={agents}>
{(agent) => (
<Table.Row id={agent.id}>
<Table.Cell>
<div className="flex items-center gap-2">
<Avatar size="xs" initials={agent.initials} />
<span className="text-sm font-medium text-primary">{agent.name}</span>
</div>
</Table.Cell>
<Table.Cell><span className="text-sm text-success-primary">{agent.inbound}</span></Table.Cell>
<Table.Cell><span className="text-sm text-brand-secondary">{agent.outbound}</span></Table.Cell>
<Table.Cell>
{agent.missed > 0 ? <Badge size="sm" color="error">{agent.missed}</Badge> : <span className="text-sm text-tertiary">0</span>}
</Table.Cell>
<Table.Cell><span className="text-sm text-secondary">{formatDuration(agent.avgHandle)}</span></Table.Cell>
<Table.Cell>
<Badge size="sm" color={agent.conversion >= 30 ? 'success' : agent.conversion >= 15 ? 'warning' : 'gray'}>
{formatPercent(agent.conversion)}
</Badge>
</Table.Cell>
</Table.Row>
)}
</Table.Body>
</Table>
</TableCard.Root>
);
};

View File

@@ -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) => (
<div className="flex flex-1 items-center gap-4 rounded-xl border border-secondary bg-primary p-4 shadow-xs">
<div className={`flex size-10 shrink-0 items-center justify-center rounded-full ${iconBg}`}>
<FontAwesomeIcon icon={icon} className={`size-4 ${iconColor}`} />
</div>
<div className="flex flex-col">
<span className="text-xs font-medium text-tertiary">{label}</span>
<span className="text-lg font-bold text-primary">{value}</span>
{subtitle && <span className="text-[10px] text-tertiary">{subtitle}</span>}
</div>
</div>
);
type MetricCardProps = {
label: string;
value: string;
description: string;
};
const MetricCard = ({ label, value, description }: MetricCardProps) => (
<div className="flex flex-1 flex-col gap-0.5 rounded-xl border border-secondary bg-primary p-4 shadow-xs">
<span className="text-xs font-medium text-tertiary">{label}</span>
<span className="text-md font-bold text-primary">{value}</span>
<span className="text-[10px] text-tertiary">{description}</span>
</div>
);
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 (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 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>
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
<MetricCard label="Avg Response" value={avgResponseTime !== null ? `${avgResponseTime}m` : '—'} description="Lead creation to first contact" />
<MetricCard label="Missed Callback" value={missedCallbackTime !== null ? `${missedCallbackTime}m` : '—'} description="Avg wait for missed callbacks" />
<MetricCard label="Call → Appt" value={formatPercent(callToAppt)} description="Calls resulting in bookings" />
<MetricCard label="Lead → Appt" value={formatPercent(leadToAppt)} description="Leads converted to appointments" />
</div>
</div>
);
};

View File

@@ -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 (
<div className="rounded-xl border border-secondary bg-primary shadow-xs">
<div className="flex items-center justify-between border-b border-secondary px-4 py-3">
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faPhoneMissed} className="size-3.5 text-fg-error-primary" />
<h3 className="text-sm font-semibold text-primary">Missed Call Queue</h3>
</div>
{missedCalls.length > 0 && (
<Badge size="sm" color="error">{missedCalls.length}</Badge>
)}
</div>
<div className="max-h-[500px] overflow-y-auto">
{missedCalls.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>
) : (
<ul className="divide-y divide-secondary">
{missedCalls.map((call) => {
const phone = call.callerNumber?.[0]?.number ?? '';
const display = phone ? `+91 ${phone}` : 'Unknown';
return (
<li key={call.id} className="flex items-center justify-between px-4 py-2.5 hover:bg-primary_hover transition duration-100 ease-linear">
<div className="flex flex-col">
<span className="text-sm font-medium text-primary">{display}</span>
<span className="text-xs text-tertiary">{getTimeSince(call.startedAt)}</span>
</div>
{phone && <ClickToCallButton phoneNumber={phone} size="sm" />}
</li>
);
})}
</ul>
)}
</div>
</div>
);
};

View File

@@ -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 */}
<div className={cx("flex items-center gap-2", collapsed ? "justify-center px-2" : "justify-between px-4 lg:px-5")}>
{collapsed ? (
<div className="flex items-center justify-center bg-brand-solid rounded-lg p-1.5 size-8 shrink-0">
<span className="text-white font-bold text-sm leading-none">H</span>
</div>
<img src="/favicon-32.png" alt="Helix Engage" className="size-8 rounded-lg shrink-0" />
) : (
<div className="flex flex-col gap-1">
<span className="text-md font-bold text-primary">Helix Engage</span>

View File

@@ -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 (
<header className="flex h-16 items-center justify-between border-b border-secondary bg-primary px-6">
<header className="flex h-14 items-center border-b border-secondary bg-primary px-6">
<div className="flex flex-col justify-center">
<h1 className="text-display-xs font-bold text-primary">{title}</h1>
{subtitle && <p className="text-sm text-tertiary">{subtitle}</p>}
</div>
<div className="flex items-center gap-3">
<GlobalSearch />
<h1 className="text-lg font-bold text-primary">{title}</h1>
{subtitle && <p className="text-xs text-tertiary">{subtitle}</p>}
</div>
</header>
);