feat: agent detail page, campaign edit slideout, integration config, auth persistence

Agent Detail (/agent/:id):
- Individual agent performance page with KPI cards + call log table
- Clickable agent names in dashboard table link to detail view
- Back button to Team Dashboard

Campaign Edit Slideout:
- Edit button on each campaign card
- Slideout with name, status, budget, dates
- Saves via updateCampaign GraphQL mutation

Integration Config Slideout:
- Configure button on each integration card
- Per-integration form fields (Ozonetel, WhatsApp, Facebook, etc.)
- Copy webhook URL, OAuth placeholder buttons

Auth Persistence:
- User data persisted to localStorage on login
- Session restored on page refresh — no more logout on F5
- Stale tokens cleaned up automatically

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 16:32:58 +05:30
parent bb004744f4
commit 567f9f2d72
8 changed files with 833 additions and 24 deletions

271
src/pages/agent-detail.tsx Normal file
View File

@@ -0,0 +1,271 @@
import { useMemo } from 'react';
import { Link, useParams } from 'react-router';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faArrowLeft,
faPhone,
faPhoneArrowDownLeft,
faPhoneArrowUpRight,
faPhoneMissed,
faClock,
faPercent,
faPhoneArrowDown,
faPhoneArrowUp,
faPhoneXmark,
faUserHeadset,
} 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 { Button } from '@/components/base/buttons/button';
import { Table, TableCard } from '@/components/application/table/table';
import { TopBar } from '@/components/layout/top-bar';
import { formatShortDate, formatPhone, getInitials } from '@/lib/format';
import { useData } from '@/providers/data-provider';
import type { Call, CallDirection, CallDisposition } from '@/types/entities';
type KpiCardProps = {
label: string;
value: number | string;
icon: IconDefinition;
iconColor: string;
iconBg: string;
};
const KpiCard = ({ label, value, icon, iconColor, iconBg }: 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>
</div>
</div>
);
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)}%`;
};
const formatPhoneDisplay = (call: Call): string => {
if (call.callerNumber && call.callerNumber.length > 0) {
return formatPhone(call.callerNumber[0]);
}
return '\u2014';
};
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
APPOINTMENT_BOOKED: { label: 'Appt Booked', color: 'success' },
FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' },
INFO_PROVIDED: { label: 'Info Provided', color: 'blue-light' },
NO_ANSWER: { label: 'No Answer', color: 'warning' },
WRONG_NUMBER: { label: 'Wrong Number', color: 'gray' },
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
};
const DirectionIcon = ({ direction, status }: { direction: CallDirection | null; status: Call['callStatus'] }) => {
if (status === 'MISSED') {
return <FontAwesomeIcon icon={faPhoneXmark} className="size-4 text-fg-error-secondary" />;
}
if (direction === 'OUTBOUND') {
return <FontAwesomeIcon icon={faPhoneArrowUp} className="size-4 text-fg-brand-secondary" />;
}
return <FontAwesomeIcon icon={faPhoneArrowDown} className="size-4 text-fg-success-secondary" />;
};
export const AgentDetailPage = () => {
const { id } = useParams<{ id: string }>();
const { calls, leads, loading } = useData();
const agentName = id ? decodeURIComponent(id) : '';
const agentCalls = useMemo(
() =>
calls
.filter((c) => c.agentName === agentName)
.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;
}),
[calls, agentName],
);
// Build lead name map for enrichment
const leadNameMap = useMemo(() => {
const map = new Map<string, string>();
for (const lead of leads) {
if (lead.id && lead.contactName) {
const name = `${lead.contactName.firstName ?? ''} ${lead.contactName.lastName ?? ''}`.trim();
if (name) map.set(lead.id, name);
}
}
return map;
}, [leads]);
// KPI calculations
const totalCalls = agentCalls.length;
const inboundCalls = agentCalls.filter((c) => c.callDirection === 'INBOUND').length;
const outboundCalls = agentCalls.filter((c) => c.callDirection === 'OUTBOUND').length;
const missedCalls = agentCalls.filter((c) => c.callStatus === 'MISSED').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 = totalCalls > 0 ? (booked / totalCalls) * 100 : 0;
const nameParts = agentName.split(' ');
const initials = getInitials(nameParts[0] ?? '', nameParts[1] ?? '');
if (loading) {
return (
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Agent Detail" />
<div className="flex flex-1 items-center justify-center">
<p className="text-sm text-tertiary">Loading...</p>
</div>
</div>
);
}
if (totalCalls === 0 && !loading) {
return (
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Agent Detail" />
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-8">
<FontAwesomeIcon icon={faUserHeadset} className="size-10 text-fg-quaternary" />
<p className="text-md font-semibold text-primary">No data found for "{agentName}"</p>
<p className="text-sm text-tertiary">This agent has no call records.</p>
<Link to="/team-dashboard">
<Button size="sm" color="secondary" iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faArrowLeft} className={className} />
)}>
Back to Team Dashboard
</Button>
</Link>
</div>
</div>
);
}
return (
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Agent Detail" subtitle={`${totalCalls} total calls`} />
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Agent header + back button */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link to="/team-dashboard">
<Button size="sm" color="tertiary" iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faArrowLeft} className={className} />
)}>
Back
</Button>
</Link>
<div className="h-8 w-px bg-secondary" />
<Avatar size="lg" initials={initials} />
<div className="flex flex-col">
<h2 className="text-lg font-bold text-primary">{agentName}</h2>
<p className="text-sm text-tertiary">Agent</p>
</div>
</div>
</div>
{/* KPI row */}
<div className="grid grid-cols-2 gap-3 xl:grid-cols-6">
<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" />
<KpiCard label="Avg Handle" value={formatDuration(avgHandle)} icon={faClock} iconColor="text-fg-warning-primary" iconBg="bg-warning-secondary" />
<KpiCard label="Conversion" value={formatPercent(conversion)} icon={faPercent} iconColor="text-fg-success-primary" iconBg="bg-success-secondary" />
</div>
{/* Call log table */}
<TableCard.Root size="sm">
<TableCard.Header
title="Call Log"
badge={agentCalls.length}
description={`Calls handled by ${agentName}`}
/>
{agentCalls.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16">
<p className="text-sm text-tertiary">No calls found.</p>
</div>
) : (
<Table>
<Table.Header>
<Table.Head label="TYPE" className="w-14" />
<Table.Head label="PATIENT" />
<Table.Head label="PHONE" />
<Table.Head label="DURATION" className="w-24" />
<Table.Head label="OUTCOME" />
<Table.Head label="TIME" />
</Table.Header>
<Table.Body items={agentCalls}>
{(call) => {
const patientName = call.leadName ?? leadNameMap.get(call.leadId ?? '') ?? 'Unknown';
const phoneDisplay = formatPhoneDisplay(call);
const durationStr = call.durationSeconds !== null && call.durationSeconds > 0
? formatDuration(call.durationSeconds)
: '\u2014';
const dispositionCfg = call.disposition !== null ? dispositionConfig[call.disposition] : null;
return (
<Table.Row id={call.id}>
<Table.Cell>
<DirectionIcon direction={call.callDirection} status={call.callStatus} />
</Table.Cell>
<Table.Cell>
<span className="text-sm font-medium text-primary truncate max-w-[160px] block">
{patientName}
</span>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-tertiary whitespace-nowrap">
{phoneDisplay}
</span>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-secondary whitespace-nowrap">
{durationStr}
</span>
</Table.Cell>
<Table.Cell>
{dispositionCfg ? (
<Badge size="sm" color={dispositionCfg.color} type="pill-color">
{dispositionCfg.label}
</Badge>
) : (
<span className="text-sm text-quaternary">{'\u2014'}</span>
)}
</Table.Cell>
<Table.Cell>
<span className="text-sm text-tertiary whitespace-nowrap">
{call.startedAt ? formatShortDate(call.startedAt) : '\u2014'}
</span>
</Table.Cell>
</Table.Row>
);
}}
</Table.Body>
</Table>
)}
</TableCard.Root>
</div>
</div>
);
};

View File

@@ -1,13 +1,18 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPenToSquare } from '@fortawesome/pro-duotone-svg-icons';
import { TopBar } from '@/components/layout/top-bar';
import { Tabs, TabList, Tab, TabPanel } from '@/components/application/tabs/tabs';
import { CampaignCard } from '@/components/campaigns/campaign-card';
import { CampaignEditSlideout } from '@/components/campaigns/campaign-edit-slideout';
import { Button } from '@/components/base/buttons/button';
import { useCampaigns } from '@/hooks/use-campaigns';
import { useLeads } from '@/hooks/use-leads';
import { useData } from '@/providers/data-provider';
import { formatCurrency } from '@/lib/format';
import type { CampaignStatus } from '@/types/entities';
import type { Campaign, CampaignStatus } from '@/types/entities';
type TabConfig = {
id: string;
@@ -25,7 +30,9 @@ const tabs: TabConfig[] = [
export const CampaignsPage = () => {
const [activeTab, setActiveTab] = useState<string>('all');
const [editCampaign, setEditCampaign] = useState<Campaign | null>(null);
const { refresh } = useData();
const selectedTab = tabs.find((t) => t.id === activeTab) ?? tabs[0];
const { campaigns, ads } = useCampaigns({ status: selectedTab.status });
const { campaigns: allCampaigns } = useCampaigns();
@@ -100,13 +107,30 @@ export const CampaignsPage = () => {
<TabPanel key={tab.id} id={tab.id}>
<div className="mt-5 grid grid-cols-1 gap-4 xl:grid-cols-2">
{campaigns.map((campaign) => (
<Link key={campaign.id} to={`/campaigns/${campaign.id}`} className="no-underline">
<CampaignCard
campaign={campaign}
ads={adsByCampaign.get(campaign.id) ?? []}
leads={leadsByCampaign.get(campaign.id) ?? []}
/>
</Link>
<div key={campaign.id} className="relative">
<Link to={`/campaigns/${campaign.id}`} className="no-underline">
<CampaignCard
campaign={campaign}
ads={adsByCampaign.get(campaign.id) ?? []}
leads={leadsByCampaign.get(campaign.id) ?? []}
/>
</Link>
<div className="absolute top-4 right-14">
<Button
size="sm"
color="secondary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faPenToSquare} className={className} />
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setEditCampaign(campaign);
}}
aria-label={`Edit ${campaign.campaignName ?? 'campaign'}`}
/>
</div>
</div>
))}
{campaigns.length === 0 && (
<p className="col-span-full py-12 text-center text-sm text-tertiary">
@@ -118,6 +142,15 @@ export const CampaignsPage = () => {
))}
</Tabs>
</div>
{editCampaign && (
<CampaignEditSlideout
isOpen={!!editCampaign}
onOpenChange={(open) => { if (!open) setEditCampaign(null); }}
campaign={editCampaign}
onSaved={refresh}
/>
)}
</div>
);
};

View File

@@ -1,3 +1,4 @@
import { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faPhone,
@@ -10,12 +11,23 @@ import {
faCopy,
faCircleCheck,
faCircleXmark,
faGear,
} 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 { IntegrationEditSlideout } from '@/components/integrations/integration-edit-slideout';
import { notify } from '@/lib/toast';
type IntegrationType = 'ozonetel' | 'whatsapp' | 'facebook' | 'google' | 'instagram' | 'website' | 'email';
type IntegrationConfig = {
type: IntegrationType;
name: string;
details: { label: string; value: string }[];
webhookUrl?: string;
};
type IntegrationStatus = 'connected' | 'disconnected' | 'configured';
type IntegrationCardProps = {
@@ -26,6 +38,7 @@ type IntegrationCardProps = {
status: IntegrationStatus;
details: { label: string; value: string }[];
webhookUrl?: string;
onConfigure?: () => void;
};
const statusConfig: Record<IntegrationStatus, { color: 'success' | 'error' | 'warning'; label: string }> = {
@@ -34,7 +47,7 @@ const statusConfig: Record<IntegrationStatus, { color: 'success' | 'error' | 'wa
configured: { color: 'warning', label: 'Configured' },
};
const IntegrationCard = ({ name, description, icon, iconColor, status, details, webhookUrl }: IntegrationCardProps) => {
const IntegrationCard = ({ name, description, icon, iconColor, status, details, webhookUrl, onConfigure }: IntegrationCardProps) => {
const statusCfg = statusConfig[status];
const copyWebhook = () => {
@@ -56,10 +69,24 @@ const IntegrationCard = ({ name, description, icon, iconColor, status, details,
<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 className="flex items-center gap-2">
{onConfigure && (
<Button
size="sm"
color="secondary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faGear} className={className} />
)}
onClick={onConfigure}
>
Configure
</Button>
)}
<Badge size="sm" color={statusCfg.color} type="pill-color">
<FontAwesomeIcon icon={status === 'connected' ? faCircleCheck : faCircleXmark} className="mr-1 size-3" />
{statusCfg.label}
</Badge>
</div>
</div>
{details.length > 0 && (
@@ -89,6 +116,9 @@ const IntegrationCard = ({ name, description, icon, iconColor, status, details,
export const IntegrationsPage = () => {
const webhookBase = 'https://engage-api.srv1477139.hstgr.cloud';
const [editIntegration, setEditIntegration] = useState<IntegrationConfig | null>(null);
const openEdit = (config: IntegrationConfig) => setEditIntegration(config);
return (
<div className="flex flex-1 flex-col overflow-hidden">
@@ -110,6 +140,16 @@ export const IntegrationsPage = () => {
{ label: 'DID Number', value: '+91 804 176 3265' },
]}
webhookUrl={`${webhookBase}/webhooks/ozonetel/missed-call`}
onConfigure={() => openEdit({
type: 'ozonetel',
name: 'Ozonetel CloudAgent',
details: [
{ label: 'Account', value: 'global_healthx' },
{ label: 'Agent ID', value: 'global' },
{ label: 'SIP Extension', value: '523590' },
{ label: 'Inbound Campaign', value: 'Inbound_918041763265' },
],
})}
/>
{/* WhatsApp */}
@@ -120,6 +160,7 @@ export const IntegrationsPage = () => {
iconColor="text-green-600"
status="disconnected"
details={[]}
onConfigure={() => openEdit({ type: 'whatsapp', name: 'WhatsApp Business', details: [] })}
/>
{/* Facebook Lead Ads */}
@@ -130,6 +171,7 @@ export const IntegrationsPage = () => {
iconColor="text-blue-600"
status="disconnected"
details={[]}
onConfigure={() => openEdit({ type: 'facebook', name: 'Facebook Lead Ads', details: [] })}
/>
{/* Google Ads */}
@@ -140,6 +182,7 @@ export const IntegrationsPage = () => {
iconColor="text-red-500"
status="disconnected"
details={[]}
onConfigure={() => openEdit({ type: 'google', name: 'Google Ads', details: [] })}
/>
{/* Instagram */}
@@ -150,6 +193,7 @@ export const IntegrationsPage = () => {
iconColor="text-pink-600"
status="disconnected"
details={[]}
onConfigure={() => openEdit({ type: 'instagram', name: 'Instagram Lead Ads', details: [] })}
/>
{/* Website */}
@@ -163,6 +207,12 @@ export const IntegrationsPage = () => {
{ label: 'Method', value: 'POST webhook' },
]}
webhookUrl={`${webhookBase}/webhooks/website/lead`}
onConfigure={() => openEdit({
type: 'website',
name: 'Website Lead Forms',
details: [{ label: 'Method', value: 'POST webhook' }],
webhookUrl: `${webhookBase}/webhooks/website/lead`,
})}
/>
{/* Email */}
@@ -173,8 +223,17 @@ export const IntegrationsPage = () => {
iconColor="text-fg-quaternary"
status="disconnected"
details={[]}
onConfigure={() => openEdit({ type: 'email', name: 'Email (SMTP)', details: [] })}
/>
</div>
{editIntegration && (
<IntegrationEditSlideout
isOpen={!!editIntegration}
onOpenChange={(open) => { if (!open) setEditIntegration(null); }}
integration={editIntegration}
/>
)}
</div>
);
};