diff --git a/src/components/call-desk/appointment-form.tsx b/src/components/call-desk/appointment-form.tsx index 172cb92..d719a9e 100644 --- a/src/components/call-desk/appointment-form.tsx +++ b/src/components/call-desk/appointment-form.tsx @@ -146,7 +146,12 @@ export const AppointmentForm = ({ setTimeSlotItems(autoItems); } }).catch(() => setTimeSlotItems([])); - }, [doctor, date, clinic, timeSlot]); + // eslint-disable-next-line react-hooks/exhaustive-deps — clinic and timeSlot + // deliberately excluded. Including clinic causes a loop: the effect calls + // setClinic() for auto-selection → clinic changes → effect re-fires → loop. + // timeSlot is only needed for the synthetic "current" option injection which + // is a read, not a trigger. Re-fetch should only happen on doctor/date change. + }, [doctor, date]); // Availability state const [bookedSlots, setBookedSlots] = useState([]); @@ -256,11 +261,11 @@ export const AppointmentForm = ({ return items; }, [filteredDoctors, doctors, doctor]); - const timeSlotSelectItems = timeSlotItems.map(slot => ({ + const timeSlotSelectItems = useMemo(() => timeSlotItems.map(slot => ({ ...slot, isDisabled: bookedSlots.includes(slot.id), label: bookedSlots.includes(slot.id) ? `${slot.label} (Booked)` : slot.label, - })); + })), [timeSlotItems, bookedSlots]); const handleSave = async () => { if (!date || !timeSlot || !doctor || !department) { diff --git a/src/components/call-desk/context-panel.tsx b/src/components/call-desk/context-panel.tsx index 097b053..9741113 100644 --- a/src/components/call-desk/context-panel.tsx +++ b/src/components/call-desk/context-panel.tsx @@ -10,11 +10,29 @@ import { AiChatPanel } from './ai-chat-panel'; import { Badge } from '@/components/base/badges/badges'; import { formatPhone, formatShortDate } from '@/lib/format'; import { cx } from '@/utils/cx'; -import type { Lead, LeadActivity, Call, FollowUp, Patient, Appointment } from '@/types/entities'; +import type { LeadActivity, Call, FollowUp, Patient, Appointment } from '@/types/entities'; import { AppointmentForm } from './appointment-form'; +// The context panel can render for any worklist item — not just leads. +// Missed calls and follow-ups provide a subset of the fields (phone + +// patientId + name) without a full Lead entity. ContextPanelSubject +// captures the minimum the panel needs to render P360. +export type ContextPanelSubject = { + id: string; + contactName?: { firstName: string; lastName: string } | null; + contactPhone?: Array<{ number: string; callingCode: string }> | null; + patientId?: string | null; + // Lead-specific fields — present when the subject IS a lead + leadSource?: string | null; + leadStatus?: string | null; + aiSummary?: string | null; + aiSuggestedAction?: string | null; + utmCampaign?: string | null; + campaignId?: string | null; +}; + interface ContextPanelProps { - selectedLead: Lead | null; + selectedLead: ContextPanelSubject | null; activities: LeadActivity[]; calls: Call[]; followUps: FollowUp[]; @@ -87,14 +105,14 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi ); const leadFollowUps = useMemo(() => - followUps.filter(f => f.patientId === (lead as any)?.patientId && f.followUpStatus !== 'COMPLETED' && f.followUpStatus !== 'CANCELLED') + followUps.filter(f => f.patientId === lead?.patientId && f.followUpStatus !== 'COMPLETED' && f.followUpStatus !== 'CANCELLED') .sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime()) .slice(0, 3), [followUps, lead], ); const leadAppointments = useMemo(() => { - const patientId = (lead as any)?.patientId; + const patientId = lead?.patientId; if (!patientId) return []; return appointments .filter(a => a.patientId === patientId && a.appointmentStatus !== 'CANCELLED' && a.appointmentStatus !== 'NO_SHOW') @@ -111,7 +129,7 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi // Linked patient const linkedPatient = useMemo(() => - patients.find(p => p.id === (lead as any)?.patientId), + patients.find(p => p.id === lead?.patientId), [patients, lead], ); diff --git a/src/components/call-desk/worklist-panel.tsx b/src/components/call-desk/worklist-panel.tsx index 0df08ca..c53ac2a 100644 --- a/src/components/call-desk/worklist-panel.tsx +++ b/src/components/call-desk/worklist-panel.tsx @@ -36,6 +36,7 @@ type WorklistFollowUp = { followUpStatus: string | null; scheduledAt: string | null; priority: string | null; + patientId?: string | null; patientName?: string; patientPhone?: string; }; @@ -53,17 +54,30 @@ type MissedCall = { callSourceNumber: string | null; missedCallCount: number | null; callbackAttemptedAt: string | null; + campaign?: { id: string; campaignName: string } | null; }; type MissedSubTab = 'pending' | 'attempted' | 'completed' | 'invalid'; +// Generic selection from any worklist row — the call-desk resolves +// lead/patient context from whatever is available on the row. +export type WorklistSelection = { + rowId: string; + type: 'missed' | 'callback' | 'follow-up' | 'lead'; + lead: WorklistLead | null; + phoneRaw: string | null; + patientId: string | null; + leadId: string | null; + name: string; +}; + interface WorklistPanelProps { missedCalls: MissedCall[]; followUps: WorklistFollowUp[]; leads: WorklistLead[]; loading: boolean; - onSelectLead: (lead: WorklistLead) => void; - selectedLeadId: string | null; + onSelectItem: (selection: WorklistSelection) => void; + selectedItemId: string | null; onDialMissedCall?: (missedCallId: string) => void; } @@ -82,6 +96,7 @@ type WorklistRow = { createdAt: string; taskState: 'PENDING' | 'ATTEMPTED' | 'SCHEDULED'; leadId: string | null; + patientId: string | null; originalLead: WorklistLead | null; lastContactedAt: string | null; contactAttempts: number; @@ -171,10 +186,27 @@ const formatSource = (source: string): string => { REFERRAL: 'Referral', WEBSITE: 'Website', PHONE_INQUIRY: 'Phone', + PHONE: 'Phone', + OTHER: 'Other', }; return map[source] ?? source.replace(/_/g, ' '); }; +// Resolve a DID (e.g. "918041763265") to a friendly branch/campaign name. +// The DID is the phone number the caller dialed — it identifies which +// hospital branch or campaign the call came through. Falls back to the +// last 10 digits if no mapping is found. +const formatDid = (did: string): string => { + if (!did) return '—'; + // Known DIDs — loaded from the sidecar theme tokens at boot (via + // the ThemeTokenProvider). For now, hardcode nothing — strip country + // code and show the DID as a short number so it's at least readable. + const digits = did.replace(/\D/g, ''); + // Strip leading 91 (India) for display + const short = digits.length > 10 && digits.startsWith('91') ? digits.slice(2) : digits; + return short; +}; + const IconInbound = faIcon(faPhoneArrowDown); const IconOutbound = faIcon(faPhoneArrowUp); @@ -200,10 +232,14 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea createdAt: call.createdAt, taskState: call.callbackStatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING', leadId: call.leadId, + patientId: (call as any).patientId ?? null, originalLead: null, lastContactedAt: call.callbackAttemptedAt ?? call.startedAt ?? call.createdAt, contactAttempts: 0, - source: call.callSourceNumber ?? null, + // Branch column: prefer the campaign name (e.g. "Cervical Cancer + // Screening Drive") over the raw DID. Falls back to formatted DID + // for organic calls with no campaign. + source: call.campaign?.campaignName ?? (call.callSourceNumber ? formatDid(call.callSourceNumber) : null), lastDisposition: call.disposition ?? null, missedCallId: call.id, }); @@ -234,6 +270,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea createdAt: fu.createdAt ?? fu.scheduledAt ?? new Date().toISOString(), taskState: isOverdue ? 'PENDING' : (fu.followUpStatus === 'COMPLETED' ? 'ATTEMPTED' : 'SCHEDULED'), leadId: null, + patientId: fu.patientId ?? null, originalLead: null, lastContactedAt: fu.scheduledAt ?? fu.createdAt ?? null, contactAttempts: 0, @@ -261,6 +298,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea createdAt: lead.createdAt, taskState: 'PENDING', leadId: lead.id, + patientId: (lead as any).patientId ?? null, originalLead: lead, lastContactedAt: lead.lastContacted ?? null, contactAttempts: lead.contactAttempts ?? 0, @@ -286,7 +324,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea return actionableRows; }; -export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectLead, selectedLeadId, onDialMissedCall }: WorklistPanelProps) => { +export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectItem, selectedItemId, onDialMissedCall }: WorklistPanelProps) => { const [tab, setTab] = useState('all'); const [search, setSearch] = useState(''); // Missed tab always shows PENDING callbacks. Attempted/Completed/Invalid @@ -466,7 +504,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect {(row) => { const priority = priorityConfig[row.priority] ?? priorityConfig.NORMAL; const sla = computeSla(row); - const isSelected = row.originalLead !== null && row.originalLead.id === selectedLeadId; + const isSelected = row.id === selectedItemId; // Sub-line: last interaction context const subLine = row.lastContactedAt @@ -481,7 +519,15 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect isSelected && 'bg-brand-primary', )} onAction={() => { - if (row.originalLead) onSelectLead(row.originalLead); + onSelectItem({ + rowId: row.id, + type: row.type, + lead: row.originalLead, + phoneRaw: row.phoneRaw || null, + patientId: row.patientId, + leadId: row.leadId, + name: row.name, + }); }} > diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index 04305d5..85ce909 100644 --- a/src/components/layout/app-shell.tsx +++ b/src/components/layout/app-shell.tsx @@ -15,7 +15,7 @@ import { useAuth } from '@/providers/auth-provider'; import { useData } from '@/providers/data-provider'; import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts'; import { useNetworkStatus } from '@/hooks/use-network-status'; -import { GlobalSearch } from '@/components/shared/global-search'; +// import { GlobalSearch } from '@/components/shared/global-search'; import { apiClient } from '@/lib/api-client'; import { cx } from '@/utils/cx'; @@ -121,7 +121,11 @@ export const AppShell = ({ children }: AppShellProps) => { {/* Persistent top bar — visible on all pages */} {(hasAgentConfig || isAdmin) && (
- + {/* GlobalSearch hidden — navigation on result click + routes to Patient 360 with stale appointment state + from the call desk. Revisit when the Patient 360 + route properly resets context on mount. (#4) */} + {/* */}
{isAdmin && } {hasAgentConfig && ( diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 92aabd8..1eafd6a 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -12,6 +12,7 @@ import { faHospitalUser, faCalendarCheck, faPhone, + faAddressBook, faUsers, faArrowRightFromBracket, faTowerBroadcast, @@ -44,6 +45,7 @@ const IconCommentDots = faIcon(faCommentDots); const IconChartMixed = faIcon(faChartMixed); const IconGear = faIcon(faGear); const IconPhone = faIcon(faPhone); +const IconAddressBook = faIcon(faAddressBook); const IconClockRewind = faIcon(faClockRotateLeft); const IconUsers = faIcon(faUsers); const IconHospitalUser = faIcon(faHospitalUser); @@ -70,6 +72,7 @@ const getNavSections = (role: string): NavSection[] => { ]}, { label: 'Data & Reports', items: [ { label: 'Leads', href: '/leads', icon: IconUsers }, + { label: 'Contacts', href: '/contacts', icon: IconAddressBook }, { label: 'Patients', href: '/patients', icon: IconHospitalUser }, { label: 'Appointments', href: '/appointments', icon: IconCalendarCheck }, { label: 'Call Log', href: '/call-history', icon: IconClockRewind }, @@ -93,6 +96,8 @@ const getNavSections = (role: string): NavSection[] => { { label: 'Call Center', items: [ { label: 'Call Desk', href: '/', icon: IconPhone }, { label: 'Call History', href: '/call-history', icon: IconClockRewind }, + { label: 'Leads', href: '/leads', icon: IconUsers }, + { label: 'Contacts', href: '/contacts', icon: IconAddressBook }, { label: 'Patients', href: '/patients', icon: IconHospitalUser }, { label: 'Appointments', href: '/appointments', icon: IconCalendarCheck }, { label: 'My Performance', href: '/my-performance', icon: IconChartMixed }, @@ -104,6 +109,7 @@ const getNavSections = (role: string): NavSection[] => { { label: 'Main', items: [ { label: 'Lead Workspace', href: '/', icon: IconGrid2 }, { label: 'All Leads', href: '/leads', icon: IconUsers }, + { label: 'Contacts', href: '/contacts', icon: IconAddressBook }, { label: 'Patients', href: '/patients', icon: IconHospitalUser }, { label: 'Appointments', href: '/appointments', icon: IconCalendarCheck }, { label: 'Campaigns', href: '/campaigns', icon: IconBullhorn }, diff --git a/src/hooks/use-leads.ts b/src/hooks/use-leads.ts index 8a1c441..877c799 100644 --- a/src/hooks/use-leads.ts +++ b/src/hooks/use-leads.ts @@ -5,6 +5,7 @@ import { useData } from '@/providers/data-provider'; type UseLeadsFilters = { source?: LeadSource; + excludeSources?: Set; status?: LeadStatus; search?: string; }; @@ -17,7 +18,7 @@ type UseLeadsResult = { export const useLeads = (filters: UseLeadsFilters = {}): UseLeadsResult => { const { leads, updateLead } = useData(); - const { source, status, search } = filters; + const { source, excludeSources, status, search } = filters; const filteredLeads = useMemo(() => { return leads.filter((lead) => { @@ -25,6 +26,10 @@ export const useLeads = (filters: UseLeadsFilters = {}): UseLeadsResult => { return false; } + if (excludeSources && lead.leadSource && excludeSources.has(lead.leadSource)) { + return false; + } + if (status !== undefined && lead.leadStatus !== status) { return false; } @@ -46,7 +51,7 @@ export const useLeads = (filters: UseLeadsFilters = {}): UseLeadsResult => { return true; }); - }, [leads, source, status, search]); + }, [leads, source, excludeSources, status, search]); return { leads: filteredLeads, diff --git a/src/main.tsx b/src/main.tsx index fb68952..51bf649 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -43,6 +43,7 @@ import { OutreachPage } from "@/pages/outreach"; import { Patient360Page } from "@/pages/patient-360"; import { ReportsPage } from "@/pages/reports"; import { PatientsPage } from "@/pages/patients"; +import { ContactsPage } from "@/pages/contacts"; import { TeamDashboardPage } from "@/pages/team-dashboard"; import { IntegrationsPage } from "@/pages/integrations"; import { AgentDetailPage } from "@/pages/agent-detail"; @@ -103,6 +104,7 @@ createRoot(document.getElementById("root")!).render( } /> } /> } /> + } /> } /> } /> {/* Admin-only routes */} diff --git a/src/pages/all-leads.tsx b/src/pages/all-leads.tsx index 328da63..3281019 100644 --- a/src/pages/all-leads.tsx +++ b/src/pages/all-leads.tsx @@ -52,8 +52,13 @@ export const AllLeadsPage = () => { const statusFilter: LeadStatus | undefined = tab === 'new' ? 'NEW' : undefined; const myLeadsOnly = tab === 'my-leads'; + // Exclude organic contact sources — those live on the Contacts page. + // Leads page shows campaign-sourced / marketing-qualified leads only. + const CONTACT_SOURCES = useMemo(() => new Set(['PHONE', 'WALK_IN', 'REFERRAL'] as const), []); + const { leads: filteredLeads, total, updateLead } = useLeads({ source: sourceFilter ?? undefined, + excludeSources: CONTACT_SOURCES, status: statusFilter, search: searchQuery || undefined, }); diff --git a/src/pages/call-desk.tsx b/src/pages/call-desk.tsx index 2157c2e..64e85b5 100644 --- a/src/pages/call-desk.tsx +++ b/src/pages/call-desk.tsx @@ -8,8 +8,9 @@ import { useData } from '@/providers/data-provider'; import { useWorklist } from '@/hooks/use-worklist'; import { useSip } from '@/providers/sip-provider'; import { WorklistPanel } from '@/components/call-desk/worklist-panel'; -import type { WorklistLead } from '@/components/call-desk/worklist-panel'; +import type { WorklistSelection } from '@/components/call-desk/worklist-panel'; import { ContextPanel } from '@/components/call-desk/context-panel'; +import type { ContextPanelSubject } from '@/components/call-desk/context-panel'; import { ActiveCallCard } from '@/components/call-desk/active-call-card'; import { apiClient } from '@/lib/api-client'; @@ -21,7 +22,8 @@ export const CallDeskPage = () => { const { leadActivities, calls, followUps: dataFollowUps, patients, appointments } = useData(); const { callState, callerNumber, callUcid, dialOutbound, isRegistered } = useSip(); const { missedCalls, followUps, marketingLeads, loading } = useWorklist(); - const [selectedLead, setSelectedLead] = useState(null); + const [selectedLead, setSelectedLead] = useState(null); + const [selectedItemId, setSelectedItemId] = useState(null); const [contextOpen, setContextOpen] = useState(true); const [activeMissedCallId, setActiveMissedCallId] = useState(null); const [callDismissed, setCallDismissed] = useState(false); @@ -134,6 +136,34 @@ export const CallDeskPage = () => { : selectedLead; const activeLeadFull = activeLead as any; + // Handle selection from any worklist row type. Leads use the lead + // object directly; missed calls and follow-ups build a synthetic + // lead-like object from their phone/patientId so the P360 context + // panel can render for any row type. + const handleSelectItem = useCallback((selection: WorklistSelection) => { + setSelectedItemId(selection.rowId); + + if (selection.lead) { + // Lead row — use the full lead object as before + setSelectedLead(selection.lead); + return; + } + + // Non-lead row (missed call, follow-up, callback) — build a + // ContextPanelSubject from the row's available data. The panel + // uses contactPhone for call-history matching and patientId for + // appointment/follow-up lookups. No type cast needed — the + // ContextPanelSubject type accepts these optional fields. + const phone = selection.phoneRaw ? selection.phoneRaw.replace(/\D/g, '').slice(-10) : ''; + const subject: ContextPanelSubject = { + id: selection.leadId ?? selection.rowId, + contactName: { firstName: selection.name.split(' ')[0] || '', lastName: selection.name.split(' ').slice(1).join(' ') || '' }, + contactPhone: phone ? [{ number: phone, callingCode: '+91' }] : [], + patientId: selection.patientId, + }; + setSelectedLead(subject); + }, []); + return (
{/* Compact header: title + name on left, status + toggle on right */} @@ -250,8 +280,8 @@ export const CallDeskPage = () => { followUps={followUps} leads={marketingLeads} loading={loading} - onSelectLead={(lead) => setSelectedLead(lead)} - selectedLeadId={selectedLead?.id ?? null} + onSelectItem={handleSelectItem} + selectedItemId={selectedItemId} onDialMissedCall={(id) => setActiveMissedCallId(id)} /> )} diff --git a/src/pages/call-recordings.tsx b/src/pages/call-recordings.tsx index 7b388b1..9d29d64 100644 --- a/src/pages/call-recordings.tsx +++ b/src/pages/call-recordings.tsx @@ -76,13 +76,13 @@ const RecordingPlayer = ({ url }: { url: string }) => { }; const columnDefs = [ - { id: 'agent', label: 'Agent', defaultVisible: true }, + { id: 'agent', label: 'Agent', defaultVisible: true, allowsSorting: true, isRowHeader: true }, { id: 'caller', label: 'Caller', defaultVisible: true }, { id: 'ai', label: 'AI', defaultVisible: true }, { id: 'type', label: 'Type', defaultVisible: true }, - { id: 'sla', label: 'SLA', defaultVisible: true }, - { id: 'dateTime', label: 'Date & Time', defaultVisible: true }, - { id: 'duration', label: 'Duration', defaultVisible: true }, + { id: 'sla', label: 'SLA', defaultVisible: true, allowsSorting: true }, + { id: 'dateTime', label: 'Date & Time', defaultVisible: true, allowsSorting: true }, + { id: 'duration', label: 'Duration', defaultVisible: true, allowsSorting: true }, { id: 'disposition', label: 'Disposition', defaultVisible: true }, { id: 'recording', label: 'Recording', defaultVisible: true }, ]; @@ -96,6 +96,85 @@ export const CallRecordingsPage = () => { const [sortDescriptor, setSortDescriptor] = useState({ column: 'dateTime', direction: 'descending' }); const { visibleColumns, toggle: toggleColumn } = useColumnVisibility(columnDefs); + // Dynamic columns for React Aria — filter by visibility, pass as prop + const activeColumns = useMemo( + () => columnDefs.filter(c => visibleColumns.has(c.id)), + [visibleColumns], + ); + + // Cell renderer — lives inside the component so it can access setSlideoutCallId + const renderRecordingCell = useCallback((call: RecordingRecord, colId: string) => { + const phone = call.callerNumber?.primaryPhoneNumber ?? ''; + const dirLabel = call.direction === 'INBOUND' ? 'In' : 'Out'; + const dirColor: 'blue' | 'brand' = call.direction === 'INBOUND' ? 'blue' : 'brand'; + switch (colId) { + case 'agent': + return {call.agentName || '—'}; + case 'caller': + return phone + ? + : ; + case 'ai': + return ( + { + e.stopPropagation(); + let longPressed = false; + const timer = setTimeout(() => { + longPressed = true; + window.dispatchEvent(new CustomEvent('maint:trigger', { detail: 'clearAnalysisCache' })); + }, 1000); + const up = () => { clearTimeout(timer); if (!longPressed) setSlideoutCallId(call.id); }; + document.addEventListener('pointerup', up, { once: true }); + }} + className="inline-flex items-center gap-1 rounded-full bg-brand-primary px-2.5 py-1 text-xs font-semibold text-brand-secondary hover:bg-brand-secondary hover:text-white cursor-pointer transition duration-100 ease-linear" + title="AI Analysis (long-press to regenerate)" + > + + AI + + ); + case 'type': + return {dirLabel}; + case 'sla': { + const sla = getCallSla(call); + if (!sla) return ; + return ( + + + {sla.percent}% + + ); + } + case 'dateTime': + return call.startedAt ? ( +
+ {formatDateOrdinal(call.startedAt)} + {formatTimeOnly(call.startedAt)} +
+ ) : ; + case 'duration': + return {formatDuration(call.durationSec)}; + case 'disposition': + return call.disposition + ? {call.disposition.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())} + : ; + case 'recording': + return call.recording?.primaryLinkUrl + ? + : null; + default: + return null; + } + }, []); + const fetchRecordings = useCallback(() => { apiClient.graphql<{ calls: { edges: Array<{ node: RecordingRecord }> } }>(QUERY, undefined, { silent: true }) .then(data => { @@ -186,119 +265,27 @@ export const CallRecordingsPage = () => { ) : (
- - {visibleColumns.has('agent') && } - {visibleColumns.has('caller') && } - {visibleColumns.has('ai') && } - {visibleColumns.has('type') && } - {visibleColumns.has('sla') && } - {visibleColumns.has('dateTime') && } - {visibleColumns.has('duration') && } - {visibleColumns.has('disposition') && } - {visibleColumns.has('recording') && } + + {(col) => ( + + )} - {(call) => { - const phone = call.callerNumber?.primaryPhoneNumber ?? ''; - const dirLabel = call.direction === 'INBOUND' ? 'In' : 'Out'; - const dirColor = call.direction === 'INBOUND' ? 'blue' : 'brand'; - - return ( - - {visibleColumns.has('agent') && ( - - {call.agentName || '—'} - - )} - {visibleColumns.has('caller') && ( - - {phone ? ( - - ) : } - - )} - {visibleColumns.has('ai') && ( - - { - e.stopPropagation(); - let longPressed = false; - const timer = setTimeout(() => { - longPressed = true; - window.dispatchEvent(new CustomEvent('maint:trigger', { detail: 'clearAnalysisCache' })); - }, 1000); - const up = () => { clearTimeout(timer); if (!longPressed) setSlideoutCallId(call.id); }; - document.addEventListener('pointerup', up, { once: true }); - }} - className="inline-flex items-center gap-1 rounded-full bg-brand-primary px-2.5 py-1 text-xs font-semibold text-brand-secondary hover:bg-brand-secondary hover:text-white cursor-pointer transition duration-100 ease-linear" - title="AI Analysis (long-press to regenerate)" - > - - AI - - - )} - {visibleColumns.has('type') && ( - - {dirLabel} - - )} - {visibleColumns.has('sla') && ( - - {(() => { - const sla = getCallSla(call); - if (!sla) return ; - return ( - - - {sla.percent}% - - ); - })()} - - )} - {visibleColumns.has('dateTime') && ( - - {call.startedAt ? ( -
- {formatDateOrdinal(call.startedAt)} - {formatTimeOnly(call.startedAt)} -
- ) : } -
- )} - {visibleColumns.has('duration') && ( - - {formatDuration(call.durationSec)} - - )} - {visibleColumns.has('disposition') && ( - - {call.disposition ? ( - - {call.disposition.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())} - - ) : } - - )} - {visibleColumns.has('recording') && ( - - {call.recording?.primaryLinkUrl && ( - - )} - - )} -
- ); - }} + {(call) => ( + + {(col) => ( + + {renderRecordingCell(call, col.id)} + + )} + + )}
diff --git a/src/pages/contacts.tsx b/src/pages/contacts.tsx new file mode 100644 index 0000000..2bce226 --- /dev/null +++ b/src/pages/contacts.tsx @@ -0,0 +1,176 @@ +// Contacts page — organic inbound callers (source = PHONE, WALK_IN, +// REFERRAL). Same Lead entity, filtered view. Campaign-sourced leads +// live on the Leads page; contacts are people who reached out directly +// without a marketing touchpoint. +// +// Uses the same LeadTable + column toggle + pagination pattern as +// All Leads. No separate backend endpoint — filters client-side on +// the DataProvider's leads array. + +import type { FC } from 'react'; +import { useMemo, useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faArrowDownToLine, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons'; + +const Download01: FC<{ className?: string }> = ({ className }) => ; +const SearchLg: FC<{ className?: string }> = ({ className }) => ; +import { Button } from '@/components/base/buttons/button'; +import { Input } from '@/components/base/input/input'; +import { PaginationPageDefault } from '@/components/application/pagination/pagination'; +import { TopBar } from '@/components/layout/top-bar'; +import { LeadTable } from '@/components/leads/lead-table'; +import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle'; +import { LeadActivitySlideout } from '@/components/leads/lead-activity-slideout'; +import { useData } from '@/providers/data-provider'; +import { rowsToCsv, downloadCsv } from '@/lib/csv-utils'; +import { notify } from '@/lib/toast'; +import type { Lead } from '@/types/entities'; + +// Sources that qualify as "contacts" — direct/organic, not campaign-sourced +const CONTACT_SOURCES = new Set(['PHONE', 'WALK_IN', 'REFERRAL']); + +const PAGE_SIZE = 15; + +export const ContactsPage = () => { + const { leads, leadActivities } = useData(); + const [searchQuery, setSearchQuery] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const [selectedIds, setSelectedIds] = useState([]); + const [sortField, setSortField] = useState('createdAt'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); + const [activityLead, setActivityLead] = useState(null); + + const columnDefs = [ + { id: 'phone', label: 'Phone', defaultVisible: true }, + { id: 'name', label: 'Name', defaultVisible: true }, + { id: 'email', label: 'Email', defaultVisible: false }, + { id: 'source', label: 'Source', defaultVisible: true }, + { id: 'firstContactedAt', label: 'First Contact', defaultVisible: false }, + { id: 'lastContactedAt', label: 'Last Contact', defaultVisible: true }, + { id: 'status', label: 'Status', defaultVisible: true }, + { id: 'createdAt', label: 'Age', defaultVisible: true }, + ]; + const { visibleColumns, toggle: toggleColumn } = useColumnVisibility(columnDefs); + + // Filter to contact sources only + const contacts = useMemo(() => { + let filtered = leads.filter((l) => CONTACT_SOURCES.has(l.leadSource ?? '')); + + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase(); + filtered = filtered.filter((l) => { + const name = `${l.contactName?.firstName ?? ''} ${l.contactName?.lastName ?? ''}`.toLowerCase(); + const phone = l.contactPhone?.[0]?.number ?? ''; + return name.includes(q) || phone.includes(q); + }); + } + + return filtered; + }, [leads, searchQuery]); + + // Sort + const sorted = useMemo(() => { + const copy = [...contacts]; + const dir = sortDirection === 'asc' ? 1 : -1; + copy.sort((a, b) => { + const av = (a as any)[sortField] ?? ''; + const bv = (b as any)[sortField] ?? ''; + if (av === bv) return 0; + return av > bv ? dir : -dir; + }); + return copy; + }, [contacts, sortField, sortDirection]); + + const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE)); + const paged = sorted.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE); + + const handleSort = (field: string) => { + if (field === sortField) { + setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc')); + } else { + setSortField(field); + setSortDirection('desc'); + } + setCurrentPage(1); + }; + + const handleExportCsv = () => { + if (sorted.length === 0) { notify.error('Export CSV', 'No contacts to export'); return; } + const headers = ['Phone', 'First Name', 'Last Name', 'Email', 'Source', 'Status', 'Created', 'Last Contact']; + const rows = sorted.map((l) => ({ + 'Phone': l.contactPhone?.[0]?.number ?? '', + 'First Name': l.contactName?.firstName ?? '', + 'Last Name': l.contactName?.lastName ?? '', + 'Email': l.contactEmail?.[0]?.address ?? '', + 'Source': l.leadSource ?? '', + 'Status': l.leadStatus ?? '', + 'Created': l.createdAt ?? '', + 'Last Contact': l.lastContactedAt ?? '', + })); + const csv = rowsToCsv(headers, rows); + downloadCsv(`contacts-${new Date().toISOString().slice(0, 10)}.csv`, csv); + notify.success('Export CSV', `${rows.length} contact${rows.length === 1 ? '' : 's'} exported`); + }; + + return ( +
+ + +
+
+

+ People who reached out directly — phone, walk-in, referral. Not sourced from campaigns. +

+
+
+ { setSearchQuery(value); setCurrentPage(1); }} + aria-label="Search contacts" + /> +
+ + +
+
+ +
+ setActivityLead(lead)} + visibleColumns={visibleColumns} + /> +
+ + {totalPages > 1 && ( +
+ { setCurrentPage(page); setSelectedIds([]); }} + /> +
+ )} +
+ + {activityLead && ( + !open && setActivityLead(null)} + lead={activityLead} + activities={leadActivities} + /> + )} +
+ ); +}; diff --git a/src/pages/missed-calls.tsx b/src/pages/missed-calls.tsx index 8d83fbf..d656966 100644 --- a/src/pages/missed-calls.tsx +++ b/src/pages/missed-calls.tsx @@ -57,16 +57,108 @@ const STATUS_COLORS: Record}` crashes the +// table (#8127). Using the dynamic collections API (columns prop + +// render function) lets React Aria rebuild its collection cleanly when +// the visible set changes. +type ColDef = { id: string; label: string; allowsSorting?: boolean; isRowHeader?: boolean }; + +const renderCell = (call: MissedCallRecord, colId: string) => { + const phone = call.callerNumber?.primaryPhoneNumber ?? ''; + const status = call.callbackStatus ?? 'PENDING_CALLBACK'; + switch (colId) { + case 'caller': + return phone + ? + : Unknown; + case 'dateTime': + return call.startedAt ? ( +
+ {formatDateOrdinal(call.startedAt)} + {formatTimeOnly(call.startedAt)} +
+ ) : ; + case 'branch': + return {call.callSourceNumber || '—'}; + case 'agent': + return {call.agentName || '—'}; + case 'count': + return call.missedCallCount && call.missedCallCount > 1 + ? {call.missedCallCount}x + : 1; + case 'status': + return {STATUS_LABELS[status] ?? status}; + case 'sla': + if (call.sla == null) return ; + const slaStatus = computeSlaStatus(call.sla); + return ( + + + {call.sla}% + + ); + case 'callback': + return call.callbackAttemptedAt ? ( +
+ {formatDateOrdinal(call.callbackAttemptedAt)} + {formatTimeOnly(call.callbackAttemptedAt)} +
+ ) : ; + default: + return null; + } +}; + +const DynamicMissedCallTable = ({ calls, columns, sortDescriptor, onSortChange }: { + calls: MissedCallRecord[]; + columns: ColDef[]; + sortDescriptor: SortDescriptor; + onSortChange: (desc: SortDescriptor) => void; +}) => ( +
+ + + {(col) => ( + + )} + + + {(call) => ( + + {(col) => ( + + {renderCell(call, col.id)} + + )} + + )} + +
+
+); + export const MissedCallsPage = () => { const [calls, setCalls] = useState([]); const [loading, setLoading] = useState(true); @@ -175,101 +267,12 @@ export const MissedCallsPage = () => {

{search ? 'No matching calls' : 'No missed calls'}

) : ( -
- - - {visibleColumns.has('caller') && } - {visibleColumns.has('dateTime') && } - {visibleColumns.has('branch') && } - {visibleColumns.has('agent') && } - {visibleColumns.has('count') && } - {visibleColumns.has('status') && } - {visibleColumns.has('sla') && } - {visibleColumns.has('callback') && } - - - {(call) => { - const phone = call.callerNumber?.primaryPhoneNumber ?? ''; - const status = call.callbackStatus ?? 'PENDING_CALLBACK'; - - return ( - - {visibleColumns.has('caller') && ( - - {phone ? ( - - ) : Unknown} - - )} - {visibleColumns.has('dateTime') && ( - - {call.startedAt ? ( -
- {formatDateOrdinal(call.startedAt)} - {formatTimeOnly(call.startedAt)} -
- ) : } -
- )} - {visibleColumns.has('branch') && ( - - {call.callSourceNumber || '—'} - - )} - {visibleColumns.has('agent') && ( - - {call.agentName || '—'} - - )} - {visibleColumns.has('count') && ( - - {call.missedCallCount && call.missedCallCount > 1 ? ( - {call.missedCallCount}x - ) : 1} - - )} - {visibleColumns.has('status') && ( - - - {STATUS_LABELS[status] ?? status} - - - )} - {visibleColumns.has('sla') && ( - - {call.sla != null ? (() => { - const status = computeSlaStatus(call.sla); - return ( - - - {call.sla}% - - ); - })() : } - - )} - {visibleColumns.has('callback') && ( - - {call.callbackAttemptedAt ? ( -
- {formatDateOrdinal(call.callbackAttemptedAt)} - {formatTimeOnly(call.callbackAttemptedAt)} -
- ) : } -
- )} -
- ); - }} -
-
-
+ visibleColumns.has(c.id))} + sortDescriptor={sortDescriptor} + onSortChange={setSortDescriptor} + /> )}