mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
feat: CC agent features, live call assist, worklist redesign, brand tokens
CC Agent: - Call transfer (CONFERENCE + KICK_CALL) with inline transfer dialog - Recording pause/resume during active calls - Missed calls API (Ozonetel abandonCalls) - Call history API (Ozonetel fetchCDRDetails) Live Call Assist: - Deepgram Nova STT via raw WebSocket - OpenAI suggestions every 10s with lead context - LiveTranscript component in sidebar during calls - Browser audio capture from remote WebRTC stream Worklist: - Redesigned table: clickable phones, context menu (Call/SMS/WhatsApp) - Last interaction sub-line, source column, improved SLA - Filtered out rows without phone numbers - New missed call notifications Brand: - Logo on login page - Blue scale rebuilt from logo blue rgb(32, 96, 160) - FontAwesome duotone CSS variables set globally - Profile menu icons switched to duotone Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,14 @@
|
||||
import type { FC, HTMLAttributes } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import type { Placement } from "@react-types/overlays";
|
||||
import { ChevronSelectorVertical, LogOut01, PhoneCall01, Settings01, User01 } from "@untitledui/icons";
|
||||
import { ChevronSelectorVertical } from "@untitledui/icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faUser, faGear, faArrowRightFromBracket, faPhoneVolume } from "@fortawesome/pro-duotone-svg-icons";
|
||||
|
||||
const IconUser: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faUser} className={className} />;
|
||||
const IconSettings: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faGear} className={className} />;
|
||||
const IconLogout: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRightFromBracket} className={className} />;
|
||||
const IconForceReady: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faPhoneVolume} className={className} />;
|
||||
import { useFocusManager } from "react-aria";
|
||||
import type { DialogProps as AriaDialogProps } from "react-aria-components";
|
||||
import { Button as AriaButton, Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Popover as AriaPopover } from "react-aria-components";
|
||||
@@ -67,14 +74,14 @@ export const NavAccountMenu = ({
|
||||
>
|
||||
<div className="rounded-xl bg-primary ring-1 ring-secondary">
|
||||
<div className="flex flex-col gap-0.5 py-1.5">
|
||||
<NavAccountCardMenuItem label="View profile" icon={User01} shortcut="⌘K->P" />
|
||||
<NavAccountCardMenuItem label="Account settings" icon={Settings01} shortcut="⌘S" />
|
||||
<NavAccountCardMenuItem label="Force Ready" icon={PhoneCall01} onClick={onForceReady} />
|
||||
<NavAccountCardMenuItem label="View profile" icon={IconUser} shortcut="⌘K->P" />
|
||||
<NavAccountCardMenuItem label="Account settings" icon={IconSettings} shortcut="⌘S" />
|
||||
<NavAccountCardMenuItem label="Force Ready" icon={IconForceReady} onClick={onForceReady} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-1 pb-1.5">
|
||||
<NavAccountCardMenuItem label="Sign out" icon={LogOut01} shortcut="⌥⇧Q" onClick={onSignOut} />
|
||||
<NavAccountCardMenuItem label="Sign out" icon={IconLogout} shortcut="⌥⇧Q" onClick={onSignOut} />
|
||||
</div>
|
||||
</AriaDialog>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash,
|
||||
faPause, faPlay, faCalendarPlus, faCheckCircle,
|
||||
faPhoneArrowRight, faRecordVinyl,
|
||||
} from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
@@ -12,6 +13,7 @@ import { setOutboundPending } from '@/state/sip-manager';
|
||||
import { useSip } from '@/providers/sip-provider';
|
||||
import { DispositionForm } from './disposition-form';
|
||||
import { AppointmentForm } from './appointment-form';
|
||||
import { TransferDialog } from './transfer-dialog';
|
||||
import { formatPhone } from '@/lib/format';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
@@ -39,6 +41,8 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
|
||||
const [savedDisposition, setSavedDisposition] = useState<CallDisposition | null>(null);
|
||||
const [appointmentOpen, setAppointmentOpen] = useState(false);
|
||||
const [appointmentBookedDuringCall, setAppointmentBookedDuringCall] = useState(false);
|
||||
const [transferOpen, setTransferOpen] = useState(false);
|
||||
const [recordingPaused, setRecordingPaused] = useState(false);
|
||||
// Capture direction at mount — survives through disposition stage
|
||||
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
|
||||
|
||||
@@ -248,11 +252,36 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
|
||||
<Button size="sm" color="secondary"
|
||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faCalendarPlus} className={className} />}
|
||||
onClick={() => setAppointmentOpen(true)}>Book Appt</Button>
|
||||
<Button size="sm" color="secondary"
|
||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} />}
|
||||
onClick={() => setTransferOpen(!transferOpen)}>Transfer</Button>
|
||||
<Button size="sm" color={recordingPaused ? 'primary-destructive' : 'secondary'}
|
||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faRecordVinyl} className={className} />}
|
||||
onClick={() => {
|
||||
const action = recordingPaused ? 'unPause' : 'pause';
|
||||
if (callUcid) {
|
||||
apiClient.post('/api/ozonetel/recording', { ucid: callUcid, action }).catch(() => {});
|
||||
}
|
||||
setRecordingPaused(!recordingPaused);
|
||||
}}>{recordingPaused ? 'Resume Rec' : 'Pause Rec'}</Button>
|
||||
<Button size="sm" color="primary-destructive" className="ml-auto"
|
||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faPhoneHangup} className={className} />}
|
||||
onClick={() => { hangup(); setPostCallStage('disposition'); }}>End</Button>
|
||||
</div>
|
||||
|
||||
{/* Transfer dialog */}
|
||||
{transferOpen && callUcid && (
|
||||
<TransferDialog
|
||||
ucid={callUcid}
|
||||
onClose={() => setTransferOpen(false)}
|
||||
onTransferred={() => {
|
||||
setTransferOpen(false);
|
||||
hangup();
|
||||
setPostCallStage('disposition');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Appointment form accessible during call */}
|
||||
<AppointmentForm
|
||||
isOpen={appointmentOpen}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useEffect, useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSparkles, faUser } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { AiChatPanel } from './ai-chat-panel';
|
||||
import { LiveTranscript } from './live-transcript';
|
||||
import { useCallAssist } from '@/hooks/use-call-assist';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { formatPhone, formatShortDate } from '@/lib/format';
|
||||
import { cx } from '@/utils/cx';
|
||||
@@ -13,9 +15,11 @@ interface ContextPanelProps {
|
||||
selectedLead: Lead | null;
|
||||
activities: LeadActivity[];
|
||||
callerPhone?: string;
|
||||
isInCall?: boolean;
|
||||
callUcid?: string | null;
|
||||
}
|
||||
|
||||
export const ContextPanel = ({ selectedLead, activities, callerPhone }: ContextPanelProps) => {
|
||||
export const ContextPanel = ({ selectedLead, activities, callerPhone, isInCall, callUcid }: ContextPanelProps) => {
|
||||
const [activeTab, setActiveTab] = useState<ContextTab>('ai');
|
||||
|
||||
// Auto-switch to lead 360 when a lead is selected
|
||||
@@ -25,6 +29,13 @@ export const ContextPanel = ({ selectedLead, activities, callerPhone }: ContextP
|
||||
}
|
||||
}, [selectedLead?.id]);
|
||||
|
||||
const { transcript, suggestions, connected: assistConnected } = useCallAssist(
|
||||
isInCall ?? false,
|
||||
callUcid ?? null,
|
||||
selectedLead?.id ?? null,
|
||||
callerPhone ?? null,
|
||||
);
|
||||
|
||||
const callerContext = selectedLead ? {
|
||||
callerPhone: selectedLead.contactPhone?.[0]?.number ?? callerPhone,
|
||||
leadId: selectedLead.id,
|
||||
@@ -64,9 +75,13 @@ export const ContextPanel = ({ selectedLead, activities, callerPhone }: ContextP
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{activeTab === 'ai' && (
|
||||
<div className="flex h-full flex-col p-4">
|
||||
<AiChatPanel callerContext={callerContext} />
|
||||
</div>
|
||||
isInCall ? (
|
||||
<LiveTranscript transcript={transcript} suggestions={suggestions} connected={assistConnected} />
|
||||
) : (
|
||||
<div className="flex h-full flex-col p-4">
|
||||
<AiChatPanel callerContext={callerContext} />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{activeTab === 'lead360' && (
|
||||
<Lead360Tab lead={selectedLead} activities={activities} />
|
||||
|
||||
90
src/components/call-desk/live-transcript.tsx
Normal file
90
src/components/call-desk/live-transcript.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSparkles, faMicrophone } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
type TranscriptLine = {
|
||||
id: string;
|
||||
text: string;
|
||||
isFinal: boolean;
|
||||
timestamp: Date;
|
||||
};
|
||||
|
||||
type Suggestion = {
|
||||
id: string;
|
||||
text: string;
|
||||
timestamp: Date;
|
||||
};
|
||||
|
||||
type LiveTranscriptProps = {
|
||||
transcript: TranscriptLine[];
|
||||
suggestions: Suggestion[];
|
||||
connected: boolean;
|
||||
};
|
||||
|
||||
export const LiveTranscript = ({ transcript, suggestions, connected }: LiveTranscriptProps) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [transcript.length, suggestions.length]);
|
||||
|
||||
// Merge transcript and suggestions by timestamp
|
||||
const items = [
|
||||
...transcript.map(t => ({ ...t, kind: 'transcript' as const })),
|
||||
...suggestions.map(s => ({ ...s, kind: 'suggestion' as const, isFinal: true })),
|
||||
].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-secondary">
|
||||
<FontAwesomeIcon icon={faSparkles} className="size-3.5 text-fg-brand-primary" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">Live Assist</span>
|
||||
<div className={cx(
|
||||
"ml-auto size-2 rounded-full",
|
||||
connected ? "bg-success-solid" : "bg-disabled",
|
||||
)} />
|
||||
</div>
|
||||
|
||||
{/* Transcript body */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-3 space-y-2">
|
||||
{items.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<FontAwesomeIcon icon={faMicrophone} className="size-6 text-fg-quaternary mb-2" />
|
||||
<p className="text-xs text-quaternary">Listening to customer...</p>
|
||||
<p className="text-xs text-quaternary">Transcript will appear here</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.map(item => {
|
||||
if (item.kind === 'suggestion') {
|
||||
return (
|
||||
<div key={item.id} className="rounded-lg bg-brand-primary p-3 border border-brand">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<FontAwesomeIcon icon={faSparkles} className="size-3 text-fg-brand-primary" />
|
||||
<span className="text-xs font-semibold text-brand-secondary">AI Suggestion</span>
|
||||
</div>
|
||||
<p className="text-sm text-primary">{item.text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={item.id} className={cx(
|
||||
"text-sm",
|
||||
item.isFinal ? "text-primary" : "text-tertiary italic",
|
||||
)}>
|
||||
<span className="text-xs text-quaternary mr-2">
|
||||
{item.timestamp.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||
</span>
|
||||
{item.text}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
150
src/components/call-desk/phone-action-cell.tsx
Normal file
150
src/components/call-desk/phone-action-cell.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPhone, faCommentDots, faEllipsisVertical, faMessageDots } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useSip } from '@/providers/sip-provider';
|
||||
import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom } from '@/state/sip-state';
|
||||
import { setOutboundPending } from '@/state/sip-manager';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
type PhoneActionCellProps = {
|
||||
phoneNumber: string;
|
||||
displayNumber: string;
|
||||
leadId?: string;
|
||||
};
|
||||
|
||||
export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId }: PhoneActionCellProps) => {
|
||||
const { isRegistered, isInCall } = useSip();
|
||||
const setCallState = useSetAtom(sipCallStateAtom);
|
||||
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
||||
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [dialing, setDialing] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const touchTimer = useRef<number | null>(null);
|
||||
|
||||
// Close menu on click outside
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [menuOpen]);
|
||||
|
||||
const handleCall = async () => {
|
||||
if (!isRegistered || isInCall || dialing) return;
|
||||
setMenuOpen(false);
|
||||
setDialing(true);
|
||||
setCallState('ringing-out');
|
||||
setCallerNumber(phoneNumber);
|
||||
setOutboundPending(true);
|
||||
const safetyTimer = setTimeout(() => setOutboundPending(false), 30000);
|
||||
|
||||
try {
|
||||
const result = await apiClient.post<{ ucid?: string }>('/api/ozonetel/dial', { phoneNumber });
|
||||
if (result?.ucid) setCallUcid(result.ucid);
|
||||
} catch {
|
||||
clearTimeout(safetyTimer);
|
||||
setCallState('idle');
|
||||
setCallerNumber(null);
|
||||
setOutboundPending(false);
|
||||
setCallUcid(null);
|
||||
notify.error('Dial Failed', 'Could not place the call');
|
||||
} finally {
|
||||
setDialing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSms = () => {
|
||||
setMenuOpen(false);
|
||||
window.open(`sms:+91${phoneNumber}`, '_self');
|
||||
};
|
||||
|
||||
const handleWhatsApp = () => {
|
||||
setMenuOpen(false);
|
||||
window.open(`https://wa.me/91${phoneNumber}`, '_blank');
|
||||
};
|
||||
|
||||
// Long-press for mobile
|
||||
const onTouchStart = () => {
|
||||
touchTimer.current = window.setTimeout(() => setMenuOpen(true), 500);
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
if (touchTimer.current) {
|
||||
clearTimeout(touchTimer.current);
|
||||
touchTimer.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const canCall = isRegistered && !isInCall && !dialing;
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-1" ref={menuRef}>
|
||||
{/* Clickable phone number — calls directly */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCall}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onContextMenu={(e) => { e.preventDefault(); setMenuOpen(true); }}
|
||||
disabled={!canCall}
|
||||
className={cx(
|
||||
'flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm transition duration-100 ease-linear',
|
||||
canCall
|
||||
? 'cursor-pointer text-brand-secondary hover:bg-brand-primary hover:text-brand-secondary'
|
||||
: 'cursor-default text-tertiary',
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPhone} className="size-3" />
|
||||
<span className="whitespace-nowrap">{displayNumber}</span>
|
||||
</button>
|
||||
|
||||
{/* Kebab menu trigger — desktop */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setMenuOpen(!menuOpen); }}
|
||||
className="flex size-6 items-center justify-center rounded-md text-fg-quaternary opacity-0 group-hover/row:opacity-100 hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisVertical} className="size-3" />
|
||||
</button>
|
||||
|
||||
{/* Context menu */}
|
||||
{menuOpen && (
|
||||
<div className="absolute left-0 top-full z-50 mt-1 w-40 rounded-lg bg-primary shadow-lg ring-1 ring-secondary py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCall}
|
||||
disabled={!canCall}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-secondary hover:bg-primary_hover disabled:text-disabled"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPhone} className="size-3.5 text-fg-success-secondary" />
|
||||
Call
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSms}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-secondary hover:bg-primary_hover"
|
||||
>
|
||||
<FontAwesomeIcon icon={faCommentDots} className="size-3.5 text-fg-brand-secondary" />
|
||||
SMS
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleWhatsApp}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-secondary hover:bg-primary_hover"
|
||||
>
|
||||
<FontAwesomeIcon icon={faMessageDots} className="size-3.5 text-[#25D366]" />
|
||||
WhatsApp
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
91
src/components/call-desk/transfer-dialog.tsx
Normal file
91
src/components/call-desk/transfer-dialog.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faXmark } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
|
||||
type TransferDialogProps = {
|
||||
ucid: string;
|
||||
onClose: () => void;
|
||||
onTransferred: () => void;
|
||||
};
|
||||
|
||||
export const TransferDialog = ({ ucid, onClose, onTransferred }: TransferDialogProps) => {
|
||||
const [number, setNumber] = useState('');
|
||||
const [transferring, setTransferring] = useState(false);
|
||||
const [stage, setStage] = useState<'input' | 'connected'>('input');
|
||||
|
||||
const handleConference = async () => {
|
||||
if (!number.trim()) return;
|
||||
setTransferring(true);
|
||||
try {
|
||||
await apiClient.post('/api/ozonetel/call-control', {
|
||||
action: 'CONFERENCE',
|
||||
ucid,
|
||||
conferenceNumber: `0${number.replace(/\D/g, '')}`,
|
||||
});
|
||||
notify.success('Connected', 'Third party connected. Click Complete to transfer.');
|
||||
setStage('connected');
|
||||
} catch {
|
||||
notify.error('Transfer Failed', 'Could not connect to the target number');
|
||||
} finally {
|
||||
setTransferring(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = async () => {
|
||||
setTransferring(true);
|
||||
try {
|
||||
await apiClient.post('/api/ozonetel/call-control', {
|
||||
action: 'KICK_CALL',
|
||||
ucid,
|
||||
conferenceNumber: `0${number.replace(/\D/g, '')}`,
|
||||
});
|
||||
notify.success('Transferred', 'Call transferred successfully');
|
||||
onTransferred();
|
||||
} catch {
|
||||
notify.error('Transfer Failed', 'Could not complete transfer');
|
||||
} finally {
|
||||
setTransferring(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-lg border border-secondary bg-secondary p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-secondary">Transfer Call</span>
|
||||
<button onClick={onClose} className="text-fg-quaternary hover:text-fg-secondary transition duration-100 ease-linear">
|
||||
<FontAwesomeIcon icon={faXmark} className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
{stage === 'input' ? (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
size="sm"
|
||||
placeholder="Enter phone number"
|
||||
value={number}
|
||||
onChange={setNumber}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
isLoading={transferring}
|
||||
onClick={handleConference}
|
||||
isDisabled={!number.trim()}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-tertiary">Connected to {number}</span>
|
||||
<Button size="sm" color="primary" isLoading={transferring} onClick={handleComplete}>
|
||||
Complete Transfer
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { FC, HTMLAttributes } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
@@ -10,8 +10,9 @@ import { Table } from '@/components/application/table/table';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
||||
import { ClickToCallButton } from './click-to-call-button';
|
||||
import { PhoneActionCell } from './phone-action-cell';
|
||||
import { formatPhone } from '@/lib/format';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
type WorklistLead = {
|
||||
@@ -24,6 +25,10 @@ type WorklistLead = {
|
||||
interestedService: string | null;
|
||||
aiSummary: string | null;
|
||||
aiSuggestedAction: string | null;
|
||||
lastContacted: string | null;
|
||||
contactAttempts: number | null;
|
||||
utmCampaign: string | null;
|
||||
campaignId: string | null;
|
||||
};
|
||||
|
||||
type WorklistFollowUp = {
|
||||
@@ -42,6 +47,7 @@ type MissedCall = {
|
||||
callerNumber: { number: string; callingCode: string }[] | null;
|
||||
startedAt: string | null;
|
||||
leadId: string | null;
|
||||
disposition: string | null;
|
||||
};
|
||||
|
||||
interface WorklistPanelProps {
|
||||
@@ -55,7 +61,6 @@ interface WorklistPanelProps {
|
||||
|
||||
type TabKey = 'all' | 'missed' | 'callbacks' | 'follow-ups';
|
||||
|
||||
// Unified row type for the table
|
||||
type WorklistRow = {
|
||||
id: string;
|
||||
type: 'missed' | 'callback' | 'follow-up' | 'lead';
|
||||
@@ -70,6 +75,10 @@ type WorklistRow = {
|
||||
taskState: 'PENDING' | 'ATTEMPTED' | 'SCHEDULED';
|
||||
leadId: string | null;
|
||||
originalLead: WorklistLead | null;
|
||||
lastContactedAt: string | null;
|
||||
contactAttempts: number;
|
||||
source: string | null;
|
||||
lastDisposition: string | null;
|
||||
};
|
||||
|
||||
const priorityConfig: Record<string, { color: 'error' | 'warning' | 'brand' | 'gray'; label: string; sort: number }> = {
|
||||
@@ -87,9 +96,8 @@ const followUpLabel: Record<string, string> = {
|
||||
REVIEW_REQUEST: 'Review',
|
||||
};
|
||||
|
||||
// Compute SLA: minutes since created, color-coded
|
||||
const computeSla = (createdAt: string): { label: string; color: 'success' | 'warning' | 'error' } => {
|
||||
const minutes = Math.max(0, Math.round((Date.now() - new Date(createdAt).getTime()) / 60000));
|
||||
const computeSla = (dateStr: string): { label: string; color: 'success' | 'warning' | 'error' } => {
|
||||
const minutes = Math.max(0, Math.round((Date.now() - new Date(dateStr).getTime()) / 60000));
|
||||
if (minutes < 1) return { label: '<1m', color: 'success' };
|
||||
if (minutes < 15) return { label: `${minutes}m`, color: 'success' };
|
||||
if (minutes < 30) return { label: `${minutes}m`, color: 'warning' };
|
||||
@@ -99,6 +107,30 @@ const computeSla = (createdAt: string): { label: string; color: 'success' | 'war
|
||||
return { label: `${Math.floor(hours / 24)}d`, color: 'error' };
|
||||
};
|
||||
|
||||
const formatTimeAgo = (dateStr: string): string => {
|
||||
const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000);
|
||||
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 ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
};
|
||||
|
||||
const formatDisposition = (disposition: string): string =>
|
||||
disposition.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
|
||||
const formatSource = (source: string): string => {
|
||||
const map: Record<string, string> = {
|
||||
FACEBOOK_AD: 'Facebook',
|
||||
GOOGLE_AD: 'Google',
|
||||
WALK_IN: 'Walk-in',
|
||||
REFERRAL: 'Referral',
|
||||
WEBSITE: 'Website',
|
||||
PHONE_INQUIRY: 'Phone',
|
||||
};
|
||||
return map[source] ?? source.replace(/_/g, ' ');
|
||||
};
|
||||
|
||||
const IconInbound: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
|
||||
<FontAwesomeIcon icon={faPhoneArrowDown} className={className} />
|
||||
);
|
||||
@@ -127,6 +159,10 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
||||
taskState: 'PENDING',
|
||||
leadId: call.leadId,
|
||||
originalLead: null,
|
||||
lastContactedAt: call.startedAt ?? call.createdAt,
|
||||
contactAttempts: 0,
|
||||
source: null,
|
||||
lastDisposition: call.disposition ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -149,6 +185,10 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
||||
taskState: isOverdue ? 'PENDING' : (fu.followUpStatus === 'COMPLETED' ? 'ATTEMPTED' : 'SCHEDULED'),
|
||||
leadId: null,
|
||||
originalLead: null,
|
||||
lastContactedAt: fu.scheduledAt ?? fu.createdAt ?? null,
|
||||
contactAttempts: 0,
|
||||
source: null,
|
||||
lastDisposition: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -171,25 +211,24 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
||||
taskState: 'PENDING',
|
||||
leadId: lead.id,
|
||||
originalLead: lead,
|
||||
lastContactedAt: lead.lastContacted ?? null,
|
||||
contactAttempts: lead.contactAttempts ?? 0,
|
||||
source: lead.leadSource ?? lead.utmCampaign ?? null,
|
||||
lastDisposition: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by priority (urgent first), then by creation time (oldest first)
|
||||
rows.sort((a, b) => {
|
||||
// Remove rows without a phone number — agent can't act on them
|
||||
const actionableRows = rows.filter(r => r.phoneRaw);
|
||||
|
||||
actionableRows.sort((a, b) => {
|
||||
const pa = priorityConfig[a.priority]?.sort ?? 2;
|
||||
const pb = priorityConfig[b.priority]?.sort ?? 2;
|
||||
if (pa !== pb) return pa - pb;
|
||||
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
});
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
const typeConfig: Record<WorklistRow['type'], { color: 'error' | 'brand' | 'blue-light' | 'gray' }> = {
|
||||
missed: { color: 'error' },
|
||||
callback: { color: 'brand' },
|
||||
'follow-up': { color: 'blue-light' },
|
||||
lead: { color: 'gray' },
|
||||
return actionableRows;
|
||||
};
|
||||
|
||||
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectLead, selectedLeadId }: WorklistPanelProps) => {
|
||||
@@ -203,13 +242,10 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
let rows = allRows;
|
||||
|
||||
// Tab filter
|
||||
if (tab === 'missed') rows = rows.filter((r) => r.type === 'missed');
|
||||
else if (tab === 'callbacks') rows = rows.filter((r) => r.type === 'callback');
|
||||
else if (tab === 'follow-ups') rows = rows.filter((r) => r.type === 'follow-up');
|
||||
|
||||
// Search filter
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
rows = rows.filter(
|
||||
@@ -224,10 +260,18 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
const callbackCount = allRows.filter((r) => r.type === 'callback').length;
|
||||
const followUpCount = allRows.filter((r) => r.type === 'follow-up').length;
|
||||
|
||||
// Notification for new missed calls
|
||||
const prevMissedCount = useRef(missedCount);
|
||||
useEffect(() => {
|
||||
if (missedCount > prevMissedCount.current && prevMissedCount.current > 0) {
|
||||
notify.info('New Missed Call', `${missedCount - prevMissedCount.current} new missed call(s)`);
|
||||
}
|
||||
prevMissedCount.current = missedCount;
|
||||
}, [missedCount]);
|
||||
|
||||
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); }, []);
|
||||
|
||||
@@ -262,7 +306,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
{/* Filter tabs + search — single row */}
|
||||
{/* Filter tabs + search */}
|
||||
<div className="flex items-end justify-between border-b border-secondary px-5 pt-3 pb-0.5">
|
||||
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTabChange(key as TabKey)}>
|
||||
<TabList items={tabItems} type="underline" size="sm">
|
||||
@@ -294,28 +338,29 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
<Table.Head label="PRIORITY" className="w-20" isRowHeader />
|
||||
<Table.Head label="PATIENT" />
|
||||
<Table.Head label="PHONE" />
|
||||
<Table.Head label="TYPE" />
|
||||
<Table.Head label="SLA" className="w-20" />
|
||||
<Table.Head label="ACTIONS" className="w-24" />
|
||||
<Table.Head label="SOURCE" className="w-28" />
|
||||
<Table.Head label="SLA" className="w-24" />
|
||||
</Table.Header>
|
||||
<Table.Body items={pagedRows}>
|
||||
{(row) => {
|
||||
const priority = priorityConfig[row.priority] ?? priorityConfig.NORMAL;
|
||||
const sla = computeSla(row.createdAt);
|
||||
const typeCfg = typeConfig[row.type];
|
||||
const sla = computeSla(row.lastContactedAt ?? row.createdAt);
|
||||
const isSelected = row.originalLead !== null && row.originalLead.id === selectedLeadId;
|
||||
|
||||
// Sub-line: last interaction context
|
||||
const subLine = row.lastContactedAt
|
||||
? `${formatTimeAgo(row.lastContactedAt)}${row.lastDisposition ? ` — ${formatDisposition(row.lastDisposition)}` : ''}`
|
||||
: row.reason || row.typeLabel;
|
||||
|
||||
return (
|
||||
<Table.Row
|
||||
id={row.id}
|
||||
className={cx(
|
||||
'cursor-pointer',
|
||||
'cursor-pointer group/row',
|
||||
isSelected && 'bg-brand-primary',
|
||||
)}
|
||||
onAction={() => {
|
||||
if (row.originalLead) {
|
||||
onSelectLead(row.originalLead);
|
||||
}
|
||||
if (row.originalLead) onSelectLead(row.originalLead);
|
||||
}}
|
||||
>
|
||||
<Table.Cell>
|
||||
@@ -326,44 +371,46 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
<Table.Cell>
|
||||
<div className="flex items-center gap-2">
|
||||
{row.direction === 'inbound' && (
|
||||
<IconInbound className="size-3.5 text-fg-success-secondary" />
|
||||
<IconInbound className="size-3.5 text-fg-success-secondary shrink-0" />
|
||||
)}
|
||||
{row.direction === 'outbound' && (
|
||||
<IconOutbound className="size-3.5 text-fg-brand-secondary" />
|
||||
<IconOutbound className="size-3.5 text-fg-brand-secondary shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium text-primary truncate max-w-[140px]">
|
||||
{row.name}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-primary truncate block max-w-[180px]">
|
||||
{row.name}
|
||||
</span>
|
||||
<span className="text-xs text-tertiary truncate block max-w-[200px]">
|
||||
{subLine}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-tertiary whitespace-nowrap">
|
||||
{row.phone || '\u2014'}
|
||||
</span>
|
||||
{row.phoneRaw ? (
|
||||
<PhoneActionCell
|
||||
phoneNumber={row.phoneRaw}
|
||||
displayNumber={row.phone}
|
||||
leadId={row.leadId ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-quaternary italic">No phone</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge size="sm" color={typeCfg.color} type="pill-color">
|
||||
{row.typeLabel}
|
||||
</Badge>
|
||||
{row.source ? (
|
||||
<span className="text-xs text-tertiary truncate block max-w-[100px]">
|
||||
{formatSource(row.source)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-quaternary">—</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge size="sm" color={sla.color} type="pill-color">
|
||||
{sla.label}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div className="flex items-center gap-1">
|
||||
{row.phoneRaw ? (
|
||||
<ClickToCallButton
|
||||
phoneNumber={row.phoneRaw}
|
||||
leadId={row.leadId ?? undefined}
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-quaternary">No phone</span>
|
||||
)}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
}}
|
||||
|
||||
90
src/hooks/use-call-assist.ts
Normal file
90
src/hooks/use-call-assist.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { startAudioCapture, stopAudioCapture } from '@/lib/audio-capture';
|
||||
import { getSipClient } from '@/state/sip-manager';
|
||||
|
||||
const SIDECAR_URL = import.meta.env.VITE_SIDECAR_URL ?? 'http://localhost:4100';
|
||||
|
||||
type TranscriptLine = {
|
||||
id: string;
|
||||
text: string;
|
||||
isFinal: boolean;
|
||||
timestamp: Date;
|
||||
};
|
||||
|
||||
type Suggestion = {
|
||||
id: string;
|
||||
text: string;
|
||||
timestamp: Date;
|
||||
};
|
||||
|
||||
export const useCallAssist = (
|
||||
active: boolean,
|
||||
ucid: string | null,
|
||||
leadId: string | null,
|
||||
callerPhone: string | null,
|
||||
) => {
|
||||
const [transcript, setTranscript] = useState<TranscriptLine[]>([]);
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const idCounter = useRef(0);
|
||||
|
||||
const nextId = useCallback(() => `ca-${++idCounter.current}`, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !ucid) return;
|
||||
|
||||
const socket = io(`${SIDECAR_URL}/call-assist`);
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on('connect', () => {
|
||||
setConnected(true);
|
||||
socket.emit('call-assist:start', { ucid, leadId, callerPhone });
|
||||
|
||||
// Start capturing remote audio from the SIP session
|
||||
const sipClient = getSipClient();
|
||||
const audioElement = sipClient?.getAudioElement();
|
||||
if (audioElement?.srcObject) {
|
||||
startAudioCapture(audioElement.srcObject as MediaStream, (chunk) => {
|
||||
socket.emit('call-assist:audio', chunk);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('call-assist:transcript', (data: { text: string; isFinal: boolean }) => {
|
||||
if (!data.text.trim()) return;
|
||||
setTranscript(prev => {
|
||||
if (!data.isFinal) {
|
||||
const finals = prev.filter(l => l.isFinal);
|
||||
return [...finals, { id: nextId(), text: data.text, isFinal: false, timestamp: new Date() }];
|
||||
}
|
||||
const finals = prev.filter(l => l.isFinal);
|
||||
return [...finals, { id: nextId(), text: data.text, isFinal: true, timestamp: new Date() }];
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('call-assist:suggestion', (data: { text: string }) => {
|
||||
setSuggestions(prev => [...prev, { id: nextId(), text: data.text, timestamp: new Date() }]);
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => setConnected(false));
|
||||
|
||||
return () => {
|
||||
stopAudioCapture();
|
||||
socket.emit('call-assist:stop');
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
setConnected(false);
|
||||
};
|
||||
}, [active, ucid, leadId, callerPhone, nextId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
setTranscript([]);
|
||||
setSuggestions([]);
|
||||
}
|
||||
}, [active]);
|
||||
|
||||
return { transcript, suggestions, connected };
|
||||
};
|
||||
@@ -47,6 +47,8 @@ type WorklistLead = {
|
||||
isSpam: boolean | null;
|
||||
aiSummary: string | null;
|
||||
aiSuggestedAction: string | null;
|
||||
lastContacted: string | null;
|
||||
utmCampaign: string | null;
|
||||
};
|
||||
|
||||
type WorklistData = {
|
||||
|
||||
45
src/lib/audio-capture.ts
Normal file
45
src/lib/audio-capture.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
type AudioChunkCallback = (chunk: ArrayBuffer) => void;
|
||||
|
||||
let audioContext: AudioContext | null = null;
|
||||
let mediaStreamSource: MediaStreamAudioSourceNode | null = null;
|
||||
let scriptProcessor: ScriptProcessorNode | null = null;
|
||||
|
||||
export function startAudioCapture(remoteStream: MediaStream, onChunk: AudioChunkCallback): void {
|
||||
stopAudioCapture();
|
||||
|
||||
audioContext = new AudioContext({ sampleRate: 16000 });
|
||||
mediaStreamSource = audioContext.createMediaStreamSource(remoteStream);
|
||||
|
||||
scriptProcessor = audioContext.createScriptProcessor(4096, 1, 1);
|
||||
|
||||
scriptProcessor.onaudioprocess = (event) => {
|
||||
const inputData = event.inputBuffer.getChannelData(0);
|
||||
|
||||
// Convert Float32 to Int16 PCM
|
||||
const pcm = new Int16Array(inputData.length);
|
||||
for (let i = 0; i < inputData.length; i++) {
|
||||
const s = Math.max(-1, Math.min(1, inputData[i]));
|
||||
pcm[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
|
||||
}
|
||||
|
||||
onChunk(pcm.buffer);
|
||||
};
|
||||
|
||||
mediaStreamSource.connect(scriptProcessor);
|
||||
scriptProcessor.connect(audioContext.destination);
|
||||
}
|
||||
|
||||
export function stopAudioCapture(): void {
|
||||
if (scriptProcessor) {
|
||||
scriptProcessor.disconnect();
|
||||
scriptProcessor = null;
|
||||
}
|
||||
if (mediaStreamSource) {
|
||||
mediaStreamSource.disconnect();
|
||||
mediaStreamSource = null;
|
||||
}
|
||||
if (audioContext) {
|
||||
audioContext.close().catch(() => {});
|
||||
audioContext = null;
|
||||
}
|
||||
}
|
||||
@@ -211,6 +211,10 @@ export class SIPClient {
|
||||
return this.ua?.isRegistered() ?? false;
|
||||
}
|
||||
|
||||
getAudioElement(): HTMLAudioElement | null {
|
||||
return this.audioElement;
|
||||
}
|
||||
|
||||
private resetSession(): void {
|
||||
this.currentSession = null;
|
||||
this.cleanupAudio();
|
||||
|
||||
@@ -9,14 +9,14 @@ 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 { BadgeWithDot, Badge } from '@/components/base/badges/badges';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
export const CallDeskPage = () => {
|
||||
const { user } = useAuth();
|
||||
const { leadActivities } = useData();
|
||||
const { connectionStatus, isRegistered, callState, callerNumber } = useSip();
|
||||
const { connectionStatus, isRegistered, callState, callerNumber, callUcid } = useSip();
|
||||
const { missedCalls, followUps, marketingLeads, totalPending, loading } = useWorklist();
|
||||
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
|
||||
const [contextOpen, setContextOpen] = useState(true);
|
||||
@@ -66,9 +66,8 @@ export const CallDeskPage = () => {
|
||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||
{/* Active call */}
|
||||
{isInCall && (
|
||||
<div className="space-y-4 p-5">
|
||||
<div className="p-5">
|
||||
<ActiveCallCard lead={activeLeadFull} callerPhone={callerNumber ?? ''} />
|
||||
<CallPrepCard lead={activeLeadFull} callerPhone={callerNumber ?? ''} activities={leadActivities} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -95,6 +94,8 @@ export const CallDeskPage = () => {
|
||||
selectedLead={activeLeadFull}
|
||||
activities={leadActivities}
|
||||
callerPhone={callerNumber ?? undefined}
|
||||
isInCall={isInCall}
|
||||
callUcid={callUcid}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -117,9 +117,7 @@ export const LoginPage = () => {
|
||||
<div className="relative z-10 flex flex-col gap-10 w-full max-w-[560px] px-12">
|
||||
{/* Logo lockup */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center bg-brand-solid rounded-xl p-2 size-10 shrink-0">
|
||||
<span className="text-white font-bold text-lg leading-none font-display">H</span>
|
||||
</div>
|
||||
<img src="/helix-logo.png" alt="Helix Engage" className="size-10 rounded-xl shrink-0" />
|
||||
<span className="text-white font-bold text-xl font-display tracking-tight">Helix Engage</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -29,6 +29,14 @@
|
||||
transition-timing-function: inherit;
|
||||
}
|
||||
|
||||
/* FontAwesome duotone icon colors — uses brand tokens */
|
||||
:root {
|
||||
--fa-primary-color: var(--color-fg-brand-primary);
|
||||
--fa-secondary-color: var(--color-fg-brand-secondary);
|
||||
--fa-primary-opacity: 1;
|
||||
--fa-secondary-opacity: 0.4;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
|
||||
@@ -351,18 +351,18 @@
|
||||
--color-blue-light-900: rgb(11 74 111);
|
||||
--color-blue-light-950: rgb(6 44 65);
|
||||
|
||||
--color-blue-25: rgb(245 250 255);
|
||||
--color-blue-50: rgb(239 248 255);
|
||||
--color-blue-100: rgb(209 233 255);
|
||||
--color-blue-200: rgb(178 221 255);
|
||||
--color-blue-300: rgb(132 202 255);
|
||||
--color-blue-400: rgb(83 177 253);
|
||||
--color-blue-500: rgb(46 144 250);
|
||||
--color-blue-600: rgb(21 112 239);
|
||||
--color-blue-700: rgb(23 92 211);
|
||||
--color-blue-800: rgb(24 73 169);
|
||||
--color-blue-900: rgb(25 65 133);
|
||||
--color-blue-950: rgb(16 42 86);
|
||||
--color-blue-25: rgb(246 249 253);
|
||||
--color-blue-50: rgb(235 243 250);
|
||||
--color-blue-100: rgb(214 230 245);
|
||||
--color-blue-200: rgb(178 207 235);
|
||||
--color-blue-300: rgb(138 180 220);
|
||||
--color-blue-400: rgb(96 150 200);
|
||||
--color-blue-500: rgb(56 120 180);
|
||||
--color-blue-600: rgb(32 96 160);
|
||||
--color-blue-700: rgb(24 76 132);
|
||||
--color-blue-800: rgb(18 60 108);
|
||||
--color-blue-900: rgb(14 46 84);
|
||||
--color-blue-950: rgb(8 28 56);
|
||||
|
||||
--color-blue-dark-25: rgb(245 248 255);
|
||||
--color-blue-dark-50: rgb(239 244 255);
|
||||
@@ -758,8 +758,8 @@
|
||||
--color-bg-brand-secondary: var(--color-brand-100);
|
||||
--color-bg-brand-solid: var(--color-brand-600);
|
||||
--color-bg-brand-solid_hover: var(--color-brand-700);
|
||||
--color-bg-brand-section: var(--color-brand-800);
|
||||
--color-bg-brand-section_subtle: var(--color-brand-700);
|
||||
--color-bg-brand-section: var(--color-brand-600);
|
||||
--color-bg-brand-section_subtle: var(--color-brand-500);
|
||||
|
||||
/* COMPONENT COLORS */
|
||||
--color-app-store-badge-border: rgb(166 166 166);
|
||||
|
||||
Reference in New Issue
Block a user