feat: Contacts page + P360 for all tabs + dynamic column toggle + slot flicker fix

Contacts page:
 - New /contacts route — shows leads with source=PHONE/WALK_IN/REFERRAL
 - Leads page now excludes those sources (campaign-sourced only)
 - Sidebar: Contacts nav item added for all roles; Leads added for cc-agent
 - Same LeadTable + pagination + CSV export pattern as All Leads

P360 context panel for all worklist tabs (#6-10):
 - WorklistPanel: onSelectLead → onSelectItem (generic WorklistSelection)
 - call-desk: handleSelectItem builds ContextPanelSubject for any row type
 - ContextPanelSubject type replaces (lead as any).patientId casts
 - Highlight tracks row.id (mc-*/fu-*/lead-*) not lead.id

Dynamic column toggle (blank-screen fix):
 - missed-calls + call-recordings refactored to React Aria dynamic
   collections API (Table.Header columns={} + Table.Row columns={})
 - Fixes "Cell count must match column count" crash on column hide
 - Row-header column metadata in columnDefs instead of hardcoded JSX

Slot flickering fix (#2):
 - Removed clinic + timeSlot from slot-fetch useEffect deps (circular
   loop: effect sets clinic → clinic in deps → re-fires)
 - Memoized timeSlotSelectItems

Other:
 - GlobalSearch hidden (stale appointment state on navigation)
 - Branch column: shows campaign name from relation, falls back to DID
 - formatSource maps PHONE → "Phone"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 16:54:30 +05:30
parent c22d82f8c5
commit ca482e731e
12 changed files with 524 additions and 237 deletions

View File

@@ -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<string[]>([]);
@@ -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) {

View File

@@ -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],
);

View File

@@ -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<TabKey>('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,
});
}}
>
<Table.Cell>

View File

@@ -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) && (
<div className="flex shrink-0 items-center gap-2 border-b border-secondary px-4 py-2">
<GlobalSearch />
{/* 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) */}
{/* <GlobalSearch /> */}
<div className="ml-auto flex items-center gap-2">
{isAdmin && <NotificationBell />}
{hasAgentConfig && (

View File

@@ -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 },