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:
@@ -6,7 +6,7 @@ import { cx, sortCx } from "@/utils/cx";
|
|||||||
|
|
||||||
const styles = sortCx({
|
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",
|
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 {
|
interface NavItemBaseProps {
|
||||||
|
|||||||
@@ -99,12 +99,6 @@ export const AiChatPanel = ({ callerContext }: AiChatPanelProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<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 */}
|
{/* Caller context banner */}
|
||||||
{callerContext?.leadName && (
|
{callerContext?.leadName && (
|
||||||
<div className="mb-3 rounded-lg bg-brand-primary px-3 py-2">
|
<div className="mb-3 rounded-lg bg-brand-primary px-3 py-2">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import type { FC, HTMLAttributes } from 'react';
|
import type { FC, HTMLAttributes } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import {
|
import {
|
||||||
@@ -224,6 +224,16 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
const callbackCount = allRows.filter((r) => r.type === 'callback').length;
|
const callbackCount = allRows.filter((r) => r.type === 'callback').length;
|
||||||
const followUpCount = allRows.filter((r) => r.type === 'follow-up').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 = [
|
const tabItems = [
|
||||||
{ id: 'all' as const, label: 'All Tasks', badge: allRows.length > 0 ? String(allRows.length) : undefined },
|
{ 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 },
|
{ 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 (
|
return (
|
||||||
<TableCard.Root size="sm">
|
<div className="flex flex-1 flex-col">
|
||||||
<TableCard.Header
|
{/* Filter tabs + search — single row */}
|
||||||
title="Worklist"
|
<div className="flex items-end justify-between border-b border-secondary px-5 pt-3 pb-0.5">
|
||||||
badge={String(allRows.length)}
|
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTabChange(key as TabKey)}>
|
||||||
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)}>
|
|
||||||
<TabList items={tabItems} type="underline" size="sm">
|
<TabList items={tabItems} type="underline" size="sm">
|
||||||
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
|
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
|
||||||
</TabList>
|
</TabList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
<div className="w-44 shrink-0">
|
||||||
|
<Input
|
||||||
|
placeholder="Search..."
|
||||||
|
icon={SearchLg}
|
||||||
|
size="sm"
|
||||||
|
value={search}
|
||||||
|
onChange={handleSearch}
|
||||||
|
aria-label="Search worklist"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredRows.length === 0 ? (
|
{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">
|
<p className="text-sm text-quaternary">
|
||||||
{search ? 'No matching items' : 'No items in this category'}
|
{search ? 'No matching items' : 'No items in this category'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<div className="px-2 pt-3">
|
||||||
|
<Table size="sm">
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Head label="PRIORITY" className="w-20" />
|
<Table.Head label="PRIORITY" className="w-20" />
|
||||||
<Table.Head label="PATIENT" />
|
<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="SLA" className="w-20" />
|
||||||
<Table.Head label="ACTIONS" className="w-24" />
|
<Table.Head label="ACTIONS" className="w-24" />
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body items={filteredRows}>
|
<Table.Body items={pagedRows}>
|
||||||
{(row) => {
|
{(row) => {
|
||||||
const priority = priorityConfig[row.priority] ?? priorityConfig.NORMAL;
|
const priority = priorityConfig[row.priority] ?? priorityConfig.NORMAL;
|
||||||
const sla = computeSla(row.createdAt);
|
const sla = computeSla(row.createdAt);
|
||||||
@@ -367,8 +369,44 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
}}
|
}}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table>
|
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
106
src/components/dashboard/agent-table.tsx
Normal file
106
src/components/dashboard/agent-table.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
100
src/components/dashboard/kpi-cards.tsx
Normal file
100
src/components/dashboard/kpi-cards.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
71
src/components/dashboard/missed-queue.tsx
Normal file
71
src/components/dashboard/missed-queue.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -88,7 +88,6 @@ const getNavSections = (role: string): NavSection[] => {
|
|||||||
{ label: 'Call Center', items: [
|
{ label: 'Call Center', items: [
|
||||||
{ label: 'Call Desk', href: '/', icon: IconPhone },
|
{ label: 'Call Desk', href: '/', icon: IconPhone },
|
||||||
{ label: 'Patients', href: '/patients', icon: IconHospitalUser },
|
{ label: 'Patients', href: '/patients', icon: IconHospitalUser },
|
||||||
{ label: 'Follow-ups', href: '/follow-ups', icon: IconBell },
|
|
||||||
{ label: 'Call History', href: '/call-history', icon: IconClockRewind },
|
{ label: 'Call History', href: '/call-history', icon: IconClockRewind },
|
||||||
]},
|
]},
|
||||||
];
|
];
|
||||||
@@ -144,9 +143,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
|||||||
{/* Logo + collapse toggle */}
|
{/* Logo + collapse toggle */}
|
||||||
<div className={cx("flex items-center gap-2", collapsed ? "justify-center px-2" : "justify-between px-4 lg:px-5")}>
|
<div className={cx("flex items-center gap-2", collapsed ? "justify-center px-2" : "justify-between px-4 lg:px-5")}>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
<div className="flex items-center justify-center bg-brand-solid rounded-lg p-1.5 size-8 shrink-0">
|
<img src="/favicon-32.png" alt="Helix Engage" className="size-8 rounded-lg shrink-0" />
|
||||||
<span className="text-white font-bold text-sm leading-none">H</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-md font-bold text-primary">Helix Engage</span>
|
<span className="text-md font-bold text-primary">Helix Engage</span>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { GlobalSearch } from "@/components/shared/global-search";
|
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
@@ -7,14 +5,10 @@ interface TopBarProps {
|
|||||||
|
|
||||||
export const TopBar = ({ title, subtitle }: TopBarProps) => {
|
export const TopBar = ({ title, subtitle }: TopBarProps) => {
|
||||||
return (
|
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">
|
<div className="flex flex-col justify-center">
|
||||||
<h1 className="text-display-xs font-bold text-primary">{title}</h1>
|
<h1 className="text-lg font-bold text-primary">{title}</h1>
|
||||||
{subtitle && <p className="text-sm text-tertiary">{subtitle}</p>}
|
{subtitle && <p className="text-xs text-tertiary">{subtitle}</p>}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<GlobalSearch />
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 {
|
export const CALLS_QUERY = `{ calls(first: 100, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
||||||
id name createdAt
|
id name createdAt
|
||||||
direction callStatus callerNumber agentName
|
direction callStatus callerNumber { primaryPhoneNumber } agentName
|
||||||
startedAt endedAt durationSec
|
startedAt endedAt durationSec
|
||||||
recordingUrl disposition
|
recording { primaryLinkUrl } disposition
|
||||||
patientId appointmentId leadId
|
patientId appointmentId leadId
|
||||||
} } } }`;
|
} } } }`;
|
||||||
|
|
||||||
|
|||||||
@@ -137,12 +137,14 @@ export function transformCalls(data: any): Call[] {
|
|||||||
createdAt: n.createdAt,
|
createdAt: n.createdAt,
|
||||||
callDirection: n.direction,
|
callDirection: n.direction,
|
||||||
callStatus: n.callStatus,
|
callStatus: n.callStatus,
|
||||||
callerNumber: n.callerNumber ? [{ number: n.callerNumber, callingCode: '+91' }] : [],
|
callerNumber: n.callerNumber?.primaryPhoneNumber
|
||||||
|
? [{ number: n.callerNumber.primaryPhoneNumber, callingCode: '+91' }]
|
||||||
|
: [],
|
||||||
agentName: n.agentName,
|
agentName: n.agentName,
|
||||||
startedAt: n.startedAt,
|
startedAt: n.startedAt,
|
||||||
endedAt: n.endedAt,
|
endedAt: n.endedAt,
|
||||||
durationSeconds: n.durationSec ?? 0,
|
durationSeconds: n.durationSec ?? 0,
|
||||||
recordingUrl: n.recordingUrl,
|
recordingUrl: n.recording?.primaryLinkUrl || null,
|
||||||
disposition: n.disposition,
|
disposition: n.disposition,
|
||||||
callNotes: null,
|
callNotes: null,
|
||||||
patientId: n.patientId,
|
patientId: n.patientId,
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { Patient360Page } from "@/pages/patient-360";
|
|||||||
import { ReportsPage } from "@/pages/reports";
|
import { ReportsPage } from "@/pages/reports";
|
||||||
import { PatientsPage } from "@/pages/patients";
|
import { PatientsPage } from "@/pages/patients";
|
||||||
import { TeamDashboardPage } from "@/pages/team-dashboard";
|
import { TeamDashboardPage } from "@/pages/team-dashboard";
|
||||||
|
import { IntegrationsPage } from "@/pages/integrations";
|
||||||
|
import { SettingsPage } from "@/pages/settings";
|
||||||
import { AuthProvider } from "@/providers/auth-provider";
|
import { AuthProvider } from "@/providers/auth-provider";
|
||||||
import { DataProvider } from "@/providers/data-provider";
|
import { DataProvider } from "@/providers/data-provider";
|
||||||
import { RouteProvider } from "@/providers/router-provider";
|
import { RouteProvider } from "@/providers/router-provider";
|
||||||
@@ -52,6 +54,8 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
<Route path="/patients" element={<PatientsPage />} />
|
<Route path="/patients" element={<PatientsPage />} />
|
||||||
<Route path="/team-dashboard" element={<TeamDashboardPage />} />
|
<Route path="/team-dashboard" element={<TeamDashboardPage />} />
|
||||||
<Route path="/reports" element={<ReportsPage />} />
|
<Route path="/reports" element={<ReportsPage />} />
|
||||||
|
<Route path="/integrations" element={<IntegrationsPage />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/patient/:id" element={<Patient360Page />} />
|
<Route path="/patient/:id" element={<Patient360Page />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -5,36 +5,21 @@ import { useAuth } from '@/providers/auth-provider';
|
|||||||
import { useData } from '@/providers/data-provider';
|
import { useData } from '@/providers/data-provider';
|
||||||
import { useWorklist } from '@/hooks/use-worklist';
|
import { useWorklist } from '@/hooks/use-worklist';
|
||||||
import { useSip } from '@/providers/sip-provider';
|
import { useSip } from '@/providers/sip-provider';
|
||||||
import { TopBar } from '@/components/layout/top-bar';
|
|
||||||
import { WorklistPanel } from '@/components/call-desk/worklist-panel';
|
import { WorklistPanel } from '@/components/call-desk/worklist-panel';
|
||||||
import type { WorklistLead } from '@/components/call-desk/worklist-panel';
|
import type { WorklistLead } from '@/components/call-desk/worklist-panel';
|
||||||
import { ContextPanel } from '@/components/call-desk/context-panel';
|
import { ContextPanel } from '@/components/call-desk/context-panel';
|
||||||
import { ActiveCallCard } from '@/components/call-desk/active-call-card';
|
import { ActiveCallCard } from '@/components/call-desk/active-call-card';
|
||||||
import { CallPrepCard } from '@/components/call-desk/call-prep-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 { BadgeWithDot, Badge } from '@/components/base/badges/badges';
|
||||||
import { cx } from '@/utils/cx';
|
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 = () => {
|
export const CallDeskPage = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { calls, leadActivities } = useData();
|
const { leadActivities } = useData();
|
||||||
const { connectionStatus, isRegistered, callState, callerNumber } = useSip();
|
const { connectionStatus, isRegistered, callState, callerNumber } = useSip();
|
||||||
const { missedCalls, followUps, marketingLeads, totalPending, loading } = useWorklist();
|
const { missedCalls, followUps, marketingLeads, totalPending, loading } = useWorklist();
|
||||||
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
|
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
|
||||||
const [contextOpen, setContextOpen] = useState(true);
|
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';
|
const isInCall = callState === 'ringing-in' || callState === 'ringing-out' || callState === 'active';
|
||||||
|
|
||||||
@@ -47,10 +32,13 @@ export const CallDeskPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<BadgeWithDot
|
<BadgeWithDot
|
||||||
color={isRegistered ? 'success' : connectionStatus === 'connecting' ? 'warning' : 'gray'}
|
color={isRegistered ? 'success' : connectionStatus === 'connecting' ? 'warning' : 'gray'}
|
||||||
@@ -62,37 +50,9 @@ export const CallDeskPage = () => {
|
|||||||
{totalPending > 0 && (
|
{totalPending > 0 && (
|
||||||
<Badge size="sm" color="brand" type="pill-color">{totalPending} pending</Badge>
|
<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's Calls ({todaysCalls.length})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Context panel toggle */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setContextOpen(!contextOpen)}
|
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'}
|
title={contextOpen ? 'Hide AI panel' : 'Show AI panel'}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={contextOpen ? faSidebarFlip : faSidebar} className="size-4" />
|
<FontAwesomeIcon icon={contextOpen ? faSidebarFlip : faSidebar} className="size-4" />
|
||||||
@@ -102,46 +62,42 @@ export const CallDeskPage = () => {
|
|||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<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 flex-1 flex-col overflow-y-auto">
|
||||||
<div className="flex-1 p-5">
|
{/* Active call */}
|
||||||
{/* Active call */}
|
{isInCall && (
|
||||||
{isInCall && (
|
<div className="space-y-4 p-5">
|
||||||
<div className="space-y-4">
|
<ActiveCallCard lead={activeLeadFull} callerPhone={callerNumber ?? ''} />
|
||||||
<ActiveCallCard lead={activeLeadFull} callerPhone={callerNumber ?? ''} />
|
<CallPrepCard lead={activeLeadFull} callerPhone={callerNumber ?? ''} activities={leadActivities} />
|
||||||
<CallPrepCard lead={activeLeadFull} callerPhone={callerNumber ?? ''} activities={leadActivities} />
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Worklist tab */}
|
{/* Worklist — no wrapper, tabs + table fill the space */}
|
||||||
{!isInCall && mainTab === 'worklist' && (
|
{!isInCall && (
|
||||||
<WorklistPanel
|
<WorklistPanel
|
||||||
missedCalls={missedCalls}
|
missedCalls={missedCalls}
|
||||||
followUps={followUps}
|
followUps={followUps}
|
||||||
leads={marketingLeads}
|
leads={marketingLeads}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onSelectLead={(lead) => setSelectedLead(lead)}
|
onSelectLead={(lead) => setSelectedLead(lead)}
|
||||||
selectedLeadId={selectedLead?.id ?? null}
|
selectedLeadId={selectedLead?.id ?? null}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Today's Calls tab */}
|
|
||||||
{!isInCall && mainTab === 'calls' && (
|
|
||||||
<CallLog calls={todaysCalls} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Context panel — collapsible */}
|
{/* Context panel — collapsible with smooth transition */}
|
||||||
{contextOpen && (
|
<div className={cx(
|
||||||
<div className="w-[400px] shrink-0 border-l border-secondary bg-primary flex flex-col">
|
"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
|
<ContextPanel
|
||||||
selectedLead={activeLeadFull}
|
selectedLead={activeLeadFull}
|
||||||
activities={leadActivities}
|
activities={leadActivities}
|
||||||
callerPhone={callerNumber ?? undefined}
|
callerPhone={callerNumber ?? undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
176
src/pages/integrations.tsx
Normal file
176
src/pages/integrations.tsx
Normal 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
130
src/pages/settings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,114 +1,31 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import {
|
import { faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
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 { AiChatPanel } from '@/components/call-desk/ai-chat-panel';
|
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 { useData } from '@/providers/data-provider';
|
||||||
import { getInitials } from '@/lib/format';
|
import { cx } from '@/utils/cx';
|
||||||
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>
|
|
||||||
);
|
|
||||||
|
|
||||||
type DateRange = 'today' | 'week' | 'month';
|
type DateRange = 'today' | 'week' | 'month';
|
||||||
|
type DashboardTab = 'agents' | 'missed' | 'campaigns';
|
||||||
|
|
||||||
const getDateRangeStart = (range: DateRange): Date => {
|
const getDateRangeStart = (range: DateRange): Date => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
switch (range) {
|
switch (range) {
|
||||||
case 'today': {
|
case 'today': { const s = new Date(now); s.setHours(0, 0, 0, 0); return s; }
|
||||||
const start = new Date(now);
|
case 'week': { const s = new Date(now); s.setDate(s.getDate() - 7); return s; }
|
||||||
start.setHours(0, 0, 0, 0);
|
case 'month': { const s = new Date(now); s.setDate(s.getDate() - 30); return s; }
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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 = () => {
|
export const TeamDashboardPage = () => {
|
||||||
const { calls, leads, loading } = useData();
|
const { calls, leads, campaigns, loading } = useData();
|
||||||
const [dateRange, setDateRange] = useState<DateRange>('week');
|
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 filteredCalls = useMemo(() => {
|
||||||
const rangeStart = getDateRangeStart(dateRange);
|
const rangeStart = getDateRangeStart(dateRange);
|
||||||
return calls.filter((call) => {
|
return calls.filter((call) => {
|
||||||
@@ -117,329 +34,128 @@ export const TeamDashboardPage = () => {
|
|||||||
});
|
});
|
||||||
}, [calls, dateRange]);
|
}, [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 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 (
|
return (
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<TopBar title="Team Dashboard" subtitle={`Global Hospital \u00b7 ${dateRangeLabel}`} />
|
{/* Header */}
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3">
|
||||||
<div className="flex-1 overflow-y-auto p-7 space-y-6">
|
<div className="flex items-center gap-3">
|
||||||
{/* Date range filter */}
|
<h1 className="text-lg font-bold text-primary">Team Dashboard</h1>
|
||||||
<div className="flex items-center justify-between">
|
<span className="text-sm text-tertiary">{dateRangeLabel}</span>
|
||||||
<h2 className="text-md font-semibold text-primary">Overview</h2>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex rounded-lg border border-secondary overflow-hidden">
|
<div className="flex rounded-lg border border-secondary overflow-hidden">
|
||||||
{(['today', 'week', 'month'] as const).map((range) => (
|
{(['today', 'week', 'month'] as const).map((range) => (
|
||||||
<button
|
<button
|
||||||
key={range}
|
key={range}
|
||||||
onClick={() => setDateRange(range)}
|
onClick={() => setDateRange(range)}
|
||||||
className={`px-3 py-1.5 text-xs font-medium transition duration-100 ease-linear capitalize ${
|
className={cx(
|
||||||
dateRange === range
|
"px-3 py-1 text-xs font-medium transition duration-100 ease-linear",
|
||||||
? 'bg-active text-brand-secondary'
|
dateRange === range ? 'bg-active text-brand-secondary' : 'text-tertiary hover:bg-primary_hover',
|
||||||
: 'bg-primary text-tertiary hover:bg-primary_hover'
|
)}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{range === 'today' ? 'Today' : range === 'week' ? 'Week' : 'Month'}
|
{range === 'today' ? 'Today' : range === 'week' ? 'Week' : 'Month'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* KPI Cards Row */}
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
{/* Main content */}
|
||||||
<KpiCard
|
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||||
label="Total Calls"
|
{/* KPI cards — always visible */}
|
||||||
value={totalCalls}
|
<div className="px-6 pt-5 pb-3">
|
||||||
icon={faPhone}
|
<DashboardKpi calls={filteredCalls} leads={leads} />
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
{/* Missed Call Queue */}
|
{/* Tabs */}
|
||||||
<div className="xl:col-span-1">
|
<div className="flex items-center gap-1 border-b border-secondary px-6">
|
||||||
<div className="rounded-xl border border-secondary bg-primary shadow-xs">
|
{tabs.map((t) => (
|
||||||
<div className="flex items-center justify-between border-b border-secondary px-5 py-4">
|
<button
|
||||||
<div className="flex items-center gap-2">
|
key={t.id}
|
||||||
<FontAwesomeIcon icon={faPhoneMissed} className="size-4 text-fg-error-primary" />
|
onClick={() => setTab(t.id)}
|
||||||
<h3 className="text-md font-semibold text-primary">Missed Call Queue</h3>
|
className={cx(
|
||||||
</div>
|
"px-3 py-2.5 text-xs font-semibold transition duration-100 ease-linear border-b-2",
|
||||||
{missedCalls > 0 && (
|
tab === t.id
|
||||||
<Badge size="sm" color="error">{missedCalls}</Badge>
|
? "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>
|
||||||
<div className="max-h-[400px] overflow-y-auto">
|
)}
|
||||||
{missedCallQueue.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-10 gap-2">
|
{!loading && tab === 'agents' && (
|
||||||
<FontAwesomeIcon icon={faPhoneMissed} className="size-6 text-fg-quaternary" />
|
<AgentTable calls={filteredCalls} />
|
||||||
<p className="text-sm text-tertiary">No missed calls</p>
|
)}
|
||||||
</div>
|
|
||||||
|
{!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">
|
campaigns.map((c) => (
|
||||||
{missedCallQueue.map((call) => (
|
<div key={c.id} className="flex items-center justify-between rounded-xl border border-secondary bg-primary p-4 shadow-xs">
|
||||||
<li key={call.id} className="flex items-center justify-between px-5 py-3 hover:bg-primary_hover transition duration-100 ease-linear">
|
<div>
|
||||||
<div className="flex flex-col">
|
<span className="text-sm font-semibold text-primary">{c.campaignName}</span>
|
||||||
<span className="text-sm font-medium text-primary">
|
<div className="flex items-center gap-3 mt-1 text-xs text-tertiary">
|
||||||
{formatCallerPhone(call)}
|
<span>{c.campaignStatus}</span>
|
||||||
</span>
|
<span>{c.platform}</span>
|
||||||
{call.leadName && (
|
<span>{c.leadCount} leads</span>
|
||||||
<span className="text-xs text-tertiary">{call.leadName}</span>
|
<span>{c.convertedCount} converted</span>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-tertiary whitespace-nowrap">
|
</div>
|
||||||
{getTimeSince(call.startedAt)}
|
{c.budget && (
|
||||||
|
<span className="text-sm font-medium text-secondary">
|
||||||
|
₹{Math.round(c.budget.amountMicros / 1_000_000).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
)}
|
||||||
))}
|
</div>
|
||||||
</ul>
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI Assistant Section */}
|
{/* AI panel — collapsible */}
|
||||||
<div className="rounded-xl border border-secondary bg-primary p-5 shadow-xs">
|
<div className={cx(
|
||||||
<div className="flex items-center gap-2 mb-4">
|
"shrink-0 border-l border-secondary bg-primary flex flex-col overflow-hidden transition-all duration-200 ease-linear",
|
||||||
<FontAwesomeIcon icon={faChartMixed} className="size-4 text-fg-brand-primary" />
|
aiOpen ? "w-[380px]" : "w-0 border-l-0",
|
||||||
<h3 className="text-md font-semibold text-primary">Supervisor AI Assistant</h3>
|
)}>
|
||||||
</div>
|
{aiOpen && (
|
||||||
<div className="h-[350px]">
|
<div className="flex h-full flex-col p-4">
|
||||||
<AiChatPanel />
|
<AiChatPanel />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user