feat: call desk redesign — 2-panel layout, collapsible sidebar, inline AI, ringtone

- Collapsible sidebar with Jotai atom (icon-only mode, persisted to localStorage)
- 2-panel call desk: worklist (60%) + context panel (40%) with AI + Lead 360 tabs
- Inline AI call prep card — known lead summary or unknown caller script
- Active call card with compact Answer/Decline buttons
- Worklist panel with human-readable labels, priority badges, click-to-select
- Context panel auto-switches to Lead 360 when lead selected or call incoming
- Browser ringtone via Web Audio API on incoming calls
- Sonner + Untitled UI IconNotification for toast system
- apiClient pattern: centralized post/get/graphql with auto-toast on errors
- Remove duplicate avatar from top bar, hide floating widget on call desk
- Fix Link routing in collapsed sidebar (was using <a> causing full page reload)
- Fix GraphQL field names: adStatus→status, platformUrl needs subfield selection
- Silent mode for DataProvider queries to prevent toast spam

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 18:33:36 +05:30
parent 61901eb8fb
commit 526ad18159
25 changed files with 1664 additions and 540 deletions

View File

@@ -1,34 +1,16 @@
import { useState } from 'react';
import type { FC, HTMLAttributes } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPhoneXmark, faBell, faUsers, faPhoneArrowUp, faSparkles, faChartSimple } from '@fortawesome/pro-duotone-svg-icons';
import { useAuth } from '@/providers/auth-provider';
import { useData } from '@/providers/data-provider';
import { useLeads } from '@/hooks/use-leads';
import { useWorklist } from '@/hooks/use-worklist';
import { useSip } from '@/providers/sip-provider';
import { TopBar } from '@/components/layout/top-bar';
import { IncomingCallCard } from '@/components/call-desk/incoming-call-card';
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
import { WorklistPanel } from '@/components/call-desk/worklist-panel';
import type { WorklistLead } from '@/components/call-desk/worklist-panel';
import { ContextPanel } from '@/components/call-desk/context-panel';
import { ActiveCallCard } from '@/components/call-desk/active-call-card';
import { CallPrepCard } from '@/components/call-desk/call-prep-card';
import { CallLog } from '@/components/call-desk/call-log';
import { DailyStats } from '@/components/call-desk/daily-stats';
import { AiChatPanel } from '@/components/call-desk/ai-chat-panel';
import { Badge, BadgeWithDot } from '@/components/base/badges/badges';
import { formatPhone } from '@/lib/format';
// FA icon wrappers compatible with Untitled UI component props
const IconPhoneMissed: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
<FontAwesomeIcon icon={faPhoneXmark} className={className} />
);
const IconBell: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
<FontAwesomeIcon icon={faBell} className={className} />
);
const IconUsers: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
<FontAwesomeIcon icon={faUsers} className={className} />
);
const IconPhoneOutgoing: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
<FontAwesomeIcon icon={faPhoneArrowUp} className={className} />
);
import { BadgeWithDot, Badge } from '@/components/base/badges/badges';
const isToday = (dateStr: string): boolean => {
const d = new Date(dateStr);
@@ -36,331 +18,100 @@ const isToday = (dateStr: string): boolean => {
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
};
// Calculate minutes since a given ISO timestamp
const minutesSince = (dateStr: string): number => {
return Math.max(0, Math.round((Date.now() - new Date(dateStr).getTime()) / 60000));
};
// SLA color based on minutes elapsed
const getSlaColor = (minutes: number): 'success' | 'warning' | 'error' => {
if (minutes < 15) return 'success';
if (minutes <= 30) return 'warning';
return 'error';
};
// Format minutes into a readable age string
const formatAge = (minutes: number): string => {
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ${minutes % 60}m ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
};
// Section header with count badge
const SectionHeader = ({
icon: Icon,
title,
count,
badgeColor,
}: {
icon: FC<HTMLAttributes<HTMLOrSVGElement>>;
title: string;
count: number;
badgeColor: 'error' | 'blue' | 'brand';
}) => (
<div className="flex items-center gap-2 border-b border-secondary px-5 py-3">
<Icon className="size-4 text-fg-quaternary" />
<h3 className="text-sm font-bold text-primary">{title}</h3>
{count > 0 && (
<Badge size="sm" color={badgeColor} type="pill-color">
{count}
</Badge>
)}
</div>
);
type SidebarTab = 'stats' | 'ai';
export const CallDeskPage = () => {
const { user } = useAuth();
const { calls, leadActivities, campaigns } = useData();
const { leads: fallbackLeads } = useLeads();
const { connectionStatus, isRegistered } = useSip();
const { missedCalls, followUps, marketingLeads, totalPending, loading, error } = useWorklist();
const [sidebarTab, setSidebarTab] = useState<SidebarTab>('stats');
const { calls, leadActivities } = useData();
const { connectionStatus, isRegistered, callState, callerNumber } = useSip();
const { missedCalls, followUps, marketingLeads, totalPending, loading } = useWorklist();
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
const todaysCalls = calls.filter(
(c) => c.agentName === user.name && c.startedAt !== null && isToday(c.startedAt),
);
// When sidecar is unavailable, show fallback leads from DataProvider
const hasSidecarData = error === null && !loading;
const showFallbackLeads = !hasSidecarData && fallbackLeads.length > 0;
const isInCall = callState === 'ringing-in' || callState === 'ringing-out' || callState === 'active';
// Find lead matching caller number during active call
const callerLead = callerNumber
? marketingLeads.find((l) => l.contactPhone?.[0]?.number?.endsWith(callerNumber) || callerNumber.endsWith(l.contactPhone?.[0]?.number ?? '---'))
: null;
const activeLead = isInCall ? (callerLead ?? selectedLead) : selectedLead;
// Convert worklist lead to full Lead type for components that need it
const activeLeadFull = activeLead ? {
...activeLead,
updatedAt: activeLead.createdAt,
contactPhone: activeLead.contactPhone ?? [],
contactEmail: (activeLead as any).contactEmail ?? [],
priority: 'NORMAL' as const,
utmSource: null, utmMedium: null, utmCampaign: null, utmContent: null, utmTerm: null,
landingPageUrl: null, referrerUrl: null,
spamScore: 0, isSpam: false, isDuplicate: false, duplicateOfLeadId: null,
firstContactedAt: null, lastContactedAt: null, contactAttempts: 0,
convertedAt: null, patientId: null, campaignId: null, adId: null,
assignedAgent: null, leadScore: null,
} : null;
return (
<div className="flex flex-1 flex-col">
<div className="flex flex-1 flex-col overflow-hidden">
{/* Sticky header */}
<TopBar title="Call Desk" subtitle={`${user.name} \u00B7 Global Hospital`} />
{/* Status bar — sticky below header */}
<div className="flex shrink-0 items-center gap-2 border-b border-secondary px-6 py-2">
<BadgeWithDot
color={isRegistered ? 'success' : connectionStatus === 'connecting' ? 'warning' : 'gray'}
size="sm"
type="pill-color"
>
{isRegistered ? 'Ready' : connectionStatus}
</BadgeWithDot>
{totalPending > 0 && (
<Badge size="sm" color="brand" type="pill-color">{totalPending} pending</Badge>
)}
</div>
{/* 2-panel layout — only this area scrolls */}
<div className="flex flex-1 overflow-hidden">
<div className="flex-1 space-y-5 overflow-y-auto p-7">
{/* Status bar */}
<div className="flex items-center gap-2">
<BadgeWithDot
color={isRegistered ? 'success' : connectionStatus === 'connecting' ? 'warning' : 'gray'}
size="md"
type="pill-color"
>
{isRegistered ? 'Phone Ready' : `Phone: ${connectionStatus}`}
</BadgeWithDot>
{hasSidecarData && totalPending > 0 && (
<Badge size="sm" color="brand" type="pill-color">
{totalPending} pending
</Badge>
{/* Main panel (60%) */}
<div className="flex flex-[3] flex-col overflow-y-auto">
<div className="flex-1 space-y-4 p-5">
{/* Active call card (replaces worklist when in call) */}
{isInCall && (
<>
<ActiveCallCard lead={activeLeadFull} callerPhone={callerNumber ?? ''} />
<CallPrepCard lead={activeLeadFull} callerPhone={callerNumber ?? ''} activities={leadActivities} />
</>
)}
{error !== null && (
<Badge size="sm" color="warning" type="pill-color">
Offline mode
</Badge>
{/* Worklist (visible when idle) */}
{!isInCall && (
<div className="rounded-xl border border-secondary bg-primary">
<WorklistPanel
missedCalls={missedCalls}
followUps={followUps}
leads={marketingLeads}
loading={loading}
onSelectLead={(lead) => setSelectedLead(lead)}
selectedLeadId={selectedLead?.id ?? null}
/>
</div>
)}
{/* Today's calls — always visible */}
<CallLog calls={todaysCalls} />
</div>
{/* Section 1: Missed Calls (highest priority) */}
{hasSidecarData && missedCalls.length > 0 && (
<div className="rounded-2xl border border-error bg-primary">
<SectionHeader icon={IconPhoneMissed} title="Missed Calls" count={missedCalls.length} badgeColor="error" />
<div className="divide-y divide-secondary">
{missedCalls.map((call) => {
const callerPhone = call.callerNumber?.[0];
const phoneDisplay = callerPhone ? formatPhone(callerPhone) : 'Unknown';
const phoneNumber = callerPhone?.number ?? '';
const minutesAgo = call.createdAt ? minutesSince(call.createdAt) : 0;
const slaColor = getSlaColor(minutesAgo);
return (
<div key={call.id} className="flex items-center justify-between gap-3 px-5 py-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-primary">{phoneDisplay}</span>
<BadgeWithDot color={slaColor} size="sm" type="pill-color">
{formatAge(minutesAgo)}
</BadgeWithDot>
</div>
{call.startedAt !== null && (
<p className="text-xs text-tertiary">
{new Date(call.startedAt).toLocaleTimeString('en-IN', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
})}
</p>
)}
</div>
</div>
<ClickToCallButton phoneNumber={phoneNumber} label="Call Back" />
</div>
);
})}
</div>
</div>
)}
{/* Section 2: Follow-ups Due */}
{hasSidecarData && followUps.length > 0 && (
<div className="rounded-2xl border border-secondary bg-primary">
<SectionHeader icon={IconBell} title="Follow-ups" count={followUps.length} badgeColor="blue" />
<div className="divide-y divide-secondary">
{followUps.map((followUp) => {
const isOverdue =
followUp.followUpStatus === 'OVERDUE' ||
(followUp.scheduledAt !== null && new Date(followUp.scheduledAt) < new Date());
const scheduledDisplay = followUp.scheduledAt
? new Date(followUp.scheduledAt).toLocaleString('en-IN', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
: 'Not scheduled';
return (
<div
key={followUp.id}
className={`flex items-center justify-between gap-3 px-5 py-3 ${isOverdue ? 'bg-error-primary/5' : ''}`}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-primary">
{followUp.followUpType ?? 'Follow-up'}
</span>
{isOverdue && (
<Badge size="sm" color="error" type="pill-color">
Overdue
</Badge>
)}
{followUp.priority === 'HIGH' || followUp.priority === 'URGENT' ? (
<Badge size="sm" color="warning" type="pill-color">
{followUp.priority}
</Badge>
) : null}
</div>
<p className="text-xs text-tertiary">{scheduledDisplay}</p>
</div>
<ClickToCallButton phoneNumber="" label="Call" />
</div>
);
})}
</div>
</div>
)}
{/* Section 3: Marketing Leads (from sidecar or fallback) */}
{hasSidecarData && marketingLeads.length > 0 && (
<div className="rounded-2xl border border-secondary bg-primary">
<SectionHeader icon={IconUsers} title="Assigned Leads" count={marketingLeads.length} badgeColor="brand" />
<div className="divide-y divide-secondary">
{marketingLeads.map((lead) => {
const firstName = lead.contactName?.firstName ?? '';
const lastName = lead.contactName?.lastName ?? '';
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown';
const phone = lead.contactPhone?.[0];
const phoneDisplay = phone ? formatPhone(phone) : 'No phone';
const phoneNumber = phone?.number ?? '';
const daysSinceCreated = lead.createdAt
? Math.floor((Date.now() - new Date(lead.createdAt).getTime()) / (1000 * 60 * 60 * 24))
: 0;
return (
<div key={lead.id} className="flex items-center justify-between gap-3 px-5 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-primary">{fullName}</span>
<span className="text-sm text-tertiary">{phoneDisplay}</span>
</div>
<div className="flex items-center gap-2 text-xs text-quaternary">
{lead.leadSource !== null && <span>{lead.leadSource}</span>}
{lead.interestedService !== null && (
<>
<span>&middot;</span>
<span>{lead.interestedService}</span>
</>
)}
{daysSinceCreated > 0 && (
<>
<span>&middot;</span>
<span>{daysSinceCreated}d old</span>
</>
)}
</div>
</div>
<ClickToCallButton phoneNumber={phoneNumber} />
</div>
);
})}
</div>
</div>
)}
{/* Fallback: show DataProvider leads when sidecar is unavailable */}
{showFallbackLeads && (
<div className="rounded-2xl border border-secondary bg-primary">
<div className="border-b border-secondary px-5 py-3">
<h3 className="text-sm font-bold text-primary">Worklist</h3>
<p className="text-xs text-tertiary">Click to start an outbound call</p>
</div>
<div className="divide-y divide-secondary">
{fallbackLeads.slice(0, 10).map((lead) => {
const firstName = lead.contactName?.firstName ?? '';
const lastName = lead.contactName?.lastName ?? '';
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown';
const phone = lead.contactPhone?.[0];
const phoneDisplay = phone ? formatPhone(phone) : 'No phone';
const phoneNumber = phone?.number ?? '';
return (
<div key={lead.id} className="flex items-center justify-between gap-3 px-5 py-3">
<div className="min-w-0 flex-1">
<span className="text-sm font-semibold text-primary">{fullName}</span>
<span className="ml-2 text-sm text-tertiary">{phoneDisplay}</span>
{lead.interestedService !== null && (
<span className="ml-2 text-xs text-quaternary">{lead.interestedService}</span>
)}
</div>
<ClickToCallButton phoneNumber={phoneNumber} />
</div>
);
})}
</div>
</div>
)}
{/* Loading state */}
{loading && (
<div className="rounded-2xl border border-secondary bg-primary px-5 py-8 text-center">
<p className="text-sm text-tertiary">Loading worklist...</p>
</div>
)}
{/* Empty state */}
{hasSidecarData && missedCalls.length === 0 && followUps.length === 0 && marketingLeads.length === 0 && !loading && (
<div className="rounded-2xl border border-secondary bg-primary px-5 py-8 text-center">
<IconPhoneOutgoing className="mx-auto mb-2 size-6 text-fg-quaternary" />
<p className="text-sm font-semibold text-primary">All clear</p>
<p className="text-xs text-tertiary">No pending items in your worklist</p>
</div>
)}
{/* Incoming call card */}
<IncomingCallCard
callState="idle"
lead={null}
activities={leadActivities}
campaigns={campaigns}
onDisposition={() => {}}
completedDisposition={null}
/>
<CallLog calls={todaysCalls} />
</div>
<aside className="hidden w-80 flex-col border-l border-secondary bg-primary xl:flex">
{/* Tab bar */}
<div className="flex border-b border-secondary">
<button
onClick={() => setSidebarTab('stats')}
className={`flex flex-1 items-center justify-center gap-1.5 px-3 py-2.5 text-xs font-semibold transition duration-100 ease-linear ${
sidebarTab === 'stats'
? 'border-b-2 border-brand text-brand-secondary'
: 'text-tertiary hover:text-secondary'
}`}
>
<FontAwesomeIcon icon={faChartSimple} className="size-3.5" />
Stats
</button>
<button
onClick={() => setSidebarTab('ai')}
className={`flex flex-1 items-center justify-center gap-1.5 px-3 py-2.5 text-xs font-semibold transition duration-100 ease-linear ${
sidebarTab === 'ai'
? 'border-b-2 border-brand text-brand-secondary'
: 'text-tertiary hover:text-secondary'
}`}
>
<FontAwesomeIcon icon={faSparkles} className="size-3.5" />
AI Assistant
</button>
</div>
{/* Tab content */}
<div className="flex-1 overflow-y-auto p-5">
{sidebarTab === 'stats' && <DailyStats calls={todaysCalls} />}
{sidebarTab === 'ai' && <AiChatPanel />}
</div>
</aside>
{/* Context panel (40%) — border-left, fixed height */}
<div className="hidden flex-[2] border-l border-secondary bg-primary xl:flex xl:flex-col">
<ContextPanel
selectedLead={activeLeadFull}
activities={leadActivities}
callerPhone={callerNumber ?? undefined}
/>
</div>
</div>
</div>
);