Files
helix-engage/src/pages/patient-360.tsx
saridsa2 d3cbf4d2bb fix: P2 defect batch + context-panel edit takeover
Layout (P1-adjacent):
- context-panel switches to an edit-only layout when editingAppointment
  is set. Previously AppointmentForm rendered inline BELOW the AI panel,
  crushing the AI area into a ~2-line strip that made the returning-
  patient summary + quick actions unusable. Edit view gets full height
  with a "Back to context" button.

P2s:
- Remove Attempted sub-tab from Missed Calls worklist (Pending only).
- Add CALL_DROPPED disposition option + propagate through every
  per-disposition Record<CallDisposition,...> map (incoming-call-card,
  call-log, call-history, agent-detail, patient-360).
- Block SLA-gaming on unanswered calls: Book Appt / Enquiry / Transfer
  buttons on active-call-card are disabled until the call reaches the
  answered state (wasAnsweredRef). The disposition filter was already
  in place; this closes the upstream entry.
- Data labels on performance charts: my-performance bar chart shows
  value on top of each bar; donut shows {d}% slice labels; team-
  performance day trend line shows per-point values.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:48:39 +05:30

629 lines
30 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router';
import { faPhone, faEnvelope, faCalendar, faCommentDots, faPlus, faCalendarCheck } from '@fortawesome/pro-duotone-svg-icons';
import { faIcon } from '@/lib/icon-wrapper';
const Phone01 = faIcon(faPhone);
const Mail01 = faIcon(faEnvelope);
const Calendar = faIcon(faCalendar);
const MessageTextSquare01 = faIcon(faCommentDots);
const Plus = faIcon(faPlus);
const CalendarCheck = faIcon(faCalendarCheck);
import { Tabs, TabList, Tab, TabPanel } from '@/components/application/tabs/tabs';
import { TopBar } from '@/components/layout/top-bar';
import { Avatar } from '@/components/base/avatar/avatar';
import { Badge } from '@/components/base/badges/badges';
import { Button } from '@/components/base/buttons/button';
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
import { AppointmentForm } from '@/components/call-desk/appointment-form';
import { apiClient } from '@/lib/api-client';
import { formatShortDate, getInitials } from '@/lib/format';
import { notify } from '@/lib/toast';
import { cx } from '@/utils/cx';
import type { LeadActivity, LeadActivityType, Call, CallDisposition } from '@/types/entities';
// Activity config for timeline (reused from lead-activity-slideout pattern)
type ActivityConfig = {
icon: string;
dotClass: string;
label: string;
};
const ACTIVITY_CONFIG: Record<LeadActivityType, ActivityConfig> = {
STATUS_CHANGE: { icon: '🔄', dotClass: 'bg-brand-secondary', label: 'Status Changed' },
CALL_MADE: { icon: '📞', dotClass: 'bg-brand-secondary', label: 'Call Made' },
CALL_RECEIVED: { icon: '📲', dotClass: 'bg-brand-secondary', label: 'Call Received' },
WHATSAPP_SENT: { icon: '💬', dotClass: 'bg-success-solid', label: 'WhatsApp Sent' },
WHATSAPP_RECEIVED: { icon: '💬', dotClass: 'bg-success-solid', label: 'WhatsApp Received' },
SMS_SENT: { icon: '✉️', dotClass: 'bg-brand-secondary', label: 'SMS Sent' },
EMAIL_SENT: { icon: '📧', dotClass: 'bg-brand-secondary', label: 'Email Sent' },
EMAIL_RECEIVED: { icon: '📧', dotClass: 'bg-brand-secondary', label: 'Email Received' },
NOTE_ADDED: { icon: '📝', dotClass: 'bg-warning-solid', label: 'Note Added' },
ASSIGNED: { icon: '📤', dotClass: 'bg-brand-secondary', label: 'Assigned' },
APPOINTMENT_BOOKED: { icon: '📅', dotClass: 'bg-brand-secondary', label: 'Appointment Booked' },
FOLLOW_UP_CREATED: { icon: '🔁', dotClass: 'bg-brand-secondary', label: 'Follow-up Created' },
CONVERTED: { icon: '✅', dotClass: 'bg-success-solid', label: 'Converted' },
MARKED_SPAM: { icon: '🚫', dotClass: 'bg-error-solid', label: 'Marked as Spam' },
DUPLICATE_DETECTED: { icon: '🔍', dotClass: 'bg-warning-solid', label: 'Duplicate Detected' },
};
const DEFAULT_CONFIG: ActivityConfig = { icon: '📌', dotClass: 'bg-tertiary', label: 'Activity' };
const DISPOSITION_COLORS: Record<CallDisposition, 'success' | 'brand' | 'blue' | 'error' | 'warning' | 'gray'> = {
APPOINTMENT_BOOKED: 'success',
APPOINTMENT_RESCHEDULED: 'warning',
APPOINTMENT_CANCELLED: 'error',
FOLLOW_UP_SCHEDULED: 'brand',
INFO_PROVIDED: 'blue',
WRONG_NUMBER: 'error',
NO_ANSWER: 'warning',
NOT_INTERESTED: 'error',
CALLBACK_REQUESTED: 'gray',
CALL_DROPPED: 'gray',
};
const TABS = [
{ id: 'appointments', label: 'Appointments' },
{ id: 'calls', label: 'Calls' },
{ id: 'timeline', label: 'Timeline' },
{ id: 'notes', label: 'Notes' },
];
const PATIENT_QUERY = `query GetPatient360($id: UUID!) {
patients(filter: { id: { eq: $id } }) { edges { node {
id fullName { firstName lastName } dateOfBirth gender
phones { primaryPhoneNumber } emails { primaryEmail }
patientType
appointments(orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
id scheduledAt durationMin appointmentType status
doctorName department reasonForVisit
} } }
calls(first: 20, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id callStatus disposition direction agentName
startedAt durationSec callerNumber { primaryPhoneNumber }
} } }
leads { edges { node {
id source status interestedService aiSummary
} } }
} } }
}`;
type PatientData = {
id: string;
fullName: { firstName: string; lastName: string } | null;
dateOfBirth: string | null;
gender: string | null;
phones: { primaryPhoneNumber: string } | null;
emails: { primaryEmail: string } | null;
patientType: string | null;
appointments: { edges: Array<{ node: any }> };
calls: { edges: Array<{ node: any }> };
leads: { edges: Array<{ node: any }> };
};
// Appointment row component
const AppointmentRow = ({ appt, onEdit }: { appt: any; onEdit?: (appt: any) => void }) => {
const scheduledAt = appt.scheduledAt ? formatShortDate(appt.scheduledAt) : '--';
const statusColors: Record<string, 'success' | 'brand' | 'warning' | 'error' | 'gray'> = {
COMPLETED: 'success', SCHEDULED: 'brand', CONFIRMED: 'brand',
CANCELLED: 'error', NO_SHOW: 'warning', RESCHEDULED: 'warning',
};
const canEdit = appt.status !== 'COMPLETED' && appt.status !== 'CANCELLED' && appt.status !== 'NO_SHOW';
return (
<div className={cx('flex items-center gap-4 border-b border-secondary px-4 py-3 last:border-b-0', canEdit && onEdit && 'cursor-pointer hover:bg-primary_hover transition duration-100 ease-linear')} onClick={() => canEdit && onEdit?.(appt)}>
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-secondary">
<CalendarCheck className="size-4 text-fg-white" />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-primary">{scheduledAt}</span>
{appt.appointmentType && (
<Badge size="sm" type="pill-color" color="brand">
{appt.appointmentType.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, (c: string) => c.toUpperCase())}
</Badge>
)}
</div>
<p className="text-xs text-tertiary">
{appt.doctorName ?? 'Unknown Doctor'}
{appt.department ? ` · ${appt.department}` : ''}
{appt.durationMin ? ` · ${appt.durationMin}min` : ''}
</p>
{appt.reasonForVisit && (
<p className="text-xs text-quaternary">{appt.reasonForVisit}</p>
)}
</div>
{appt.status && (
<Badge size="sm" type="pill-color" color={statusColors[appt.status] ?? 'gray'}>
{appt.status.toLowerCase().replace(/\b\w/g, (c: string) => c.toUpperCase())}
</Badge>
)}
</div>
);
};
const formatDuration = (seconds: number | null): string => {
if (seconds === null || seconds === 0) return '--';
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes === 0) return `${remainingSeconds}s`;
return `${minutes}m ${remainingSeconds}s`;
};
const formatDisposition = (disposition: string): string =>
disposition
.toLowerCase()
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
// Timeline item component
const TimelineItem = ({ activity, isLast }: { activity: LeadActivity; isLast: boolean }) => {
const type = activity.activityType;
const config = type ? (ACTIVITY_CONFIG[type] ?? DEFAULT_CONFIG) : DEFAULT_CONFIG;
const occurredAt = activity.occurredAt ? formatShortDate(activity.occurredAt) : '';
return (
<div className="relative flex gap-3 pb-4">
{!isLast && <div className="absolute left-[15px] top-[36px] bottom-0 w-0.5 bg-tertiary" />}
<div
className={cx(
'relative z-10 flex size-8 shrink-0 items-center justify-center rounded-full text-sm',
config.dotClass,
)}
aria-hidden="true"
>
{config.icon}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5 pt-1">
<span className="text-sm font-semibold text-primary">{activity.summary ?? config.label}</span>
{type === 'STATUS_CHANGE' && (activity.previousValue || activity.newValue) && (
<span className="text-sm text-secondary">
{activity.previousValue && (
<span className="mr-1 text-sm text-quaternary line-through">{activity.previousValue}</span>
)}
{activity.previousValue && activity.newValue && '→ '}
{activity.newValue && <span className="font-medium text-brand-secondary">{activity.newValue}</span>}
</span>
)}
{type !== 'STATUS_CHANGE' && activity.newValue && (
<p className="text-xs text-tertiary">{activity.newValue}</p>
)}
<p className="text-xs text-quaternary">
{occurredAt}
{activity.performedBy ? ` · by ${activity.performedBy}` : ''}
</p>
</div>
</div>
);
};
// Call row component
const CallRow = ({ call }: { call: Call }) => {
const startedAt = call.startedAt ? formatShortDate(call.startedAt) : '--';
const directionLabel = call.callDirection === 'INBOUND' ? 'Inbound' : 'Outbound';
const directionColor = call.callDirection === 'INBOUND' ? 'blue' : 'brand';
return (
<div className="flex items-center gap-4 border-b border-secondary px-4 py-3 last:border-b-0">
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-secondary">
<Phone01 className="size-4 text-fg-quaternary" />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-primary">{startedAt}</span>
<Badge size="sm" type="pill-color" color={directionColor}>{directionLabel}</Badge>
</div>
<p className="text-xs text-tertiary">
{call.agentName ?? 'Unknown Agent'}
{' · '}
{formatDuration(call.durationSeconds)}
</p>
</div>
{call.disposition && (
<Badge size="sm" type="pill-color" color={DISPOSITION_COLORS[call.disposition] ?? 'gray'}>
{formatDisposition(call.disposition)}
</Badge>
)}
</div>
);
};
// Note item component
const NoteItem = ({ activity }: { activity: LeadActivity }) => {
const occurredAt = activity.occurredAt ? formatShortDate(activity.occurredAt) : '';
return (
<div className="border-b border-secondary px-4 py-3 last:border-b-0">
<div className="flex items-start gap-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-warning-secondary text-sm">
📝
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-sm font-semibold text-primary">
{activity.summary ?? 'Note'}
</span>
{activity.newValue && (
<p className="text-sm text-secondary">{activity.newValue}</p>
)}
<p className="text-xs text-quaternary">
{occurredAt}
{activity.performedBy ? ` · by ${activity.performedBy}` : ''}
</p>
</div>
</div>
</div>
);
};
// Empty state component
const EmptyState = ({ icon, title, subtitle }: { icon: string; title: string; subtitle: string }) => (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center">
<span className="text-3xl">{icon}</span>
<p className="text-sm font-medium text-secondary">{title}</p>
<p className="text-xs text-tertiary">{subtitle}</p>
</div>
);
export const Patient360Page = () => {
const { id } = useParams<{ id: string }>();
const [activeTab, setActiveTab] = useState<string>('appointments');
const [noteText, setNoteText] = useState('');
const [noteSaving, setNoteSaving] = useState(false);
const [apptFormOpen, setApptFormOpen] = useState(false);
const [editingAppt, setEditingAppt] = useState<any>(null);
const [patient, setPatient] = useState<PatientData | null>(null);
const [loading, setLoading] = useState(true);
const [activities, setActivities] = useState<LeadActivity[]>([]);
// Fetch patient with related data from platform
useEffect(() => {
if (!id) return;
setLoading(true);
apiClient.graphql<{ patients: { edges: Array<{ node: PatientData }> } }>(
PATIENT_QUERY,
{ id },
{ silent: true },
).then(data => {
const p = data.patients.edges[0]?.node ?? null;
setPatient(p);
// Fetch activities from linked leads
const leadIds = p?.leads?.edges?.map((e: any) => e.node.id) ?? [];
if (leadIds.length > 0) {
const leadFilter = leadIds.map((lid: string) => `"${lid}"`).join(', ');
apiClient.graphql<{ leadActivities: { edges: Array<{ node: LeadActivity }> } }>(
`{ leadActivities(first: 50, filter: { leadId: { in: [${leadFilter}] } }, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node {
id activityType summary occurredAt performedBy previousValue newValue leadId
} } } }`,
undefined,
{ silent: true },
).then(actData => {
setActivities(actData.leadActivities.edges.map(e => e.node));
}).catch(() => {});
}
}).catch(() => setPatient(null))
.finally(() => setLoading(false));
}, [id]);
const patientCalls = useMemo(
() => (patient?.calls?.edges?.map(e => e.node) ?? []).map((c: any) => ({
...c,
callDirection: c.direction,
durationSeconds: c.durationSec,
})),
[patient],
);
const appointments = useMemo(
() => patient?.appointments?.edges?.map(e => e.node) ?? [],
[patient],
);
const notes = useMemo(
() => activities.filter((a) => a.activityType === 'NOTE_ADDED'),
[activities],
);
const leadInfo = patient?.leads?.edges?.[0]?.node;
if (loading) {
return (
<>
<TopBar title="Patient 360" />
<div className="flex flex-1 items-center justify-center p-8">
<p className="text-sm text-tertiary">Loading patient profile...</p>
</div>
</>
);
}
if (!patient) {
return (
<>
<TopBar title="Patient 360" />
<div className="flex flex-1 items-center justify-center p-8">
<p className="text-tertiary">Patient not found.</p>
</div>
</>
);
}
const firstName = patient.fullName?.firstName ?? '';
const lastName = patient.fullName?.lastName ?? '';
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown Patient';
const initials = getInitials(firstName || '?', lastName || '?');
const phoneRaw = patient.phones?.primaryPhoneNumber ?? '';
const email = patient.emails?.primaryEmail ?? null;
const age = patient.dateOfBirth
? Math.floor((Date.now() - new Date(patient.dateOfBirth).getTime()) / (365.25 * 24 * 60 * 60 * 1000))
: null;
const genderLabel = patient.gender === 'MALE' ? 'Male' : patient.gender === 'FEMALE' ? 'Female' : patient.gender;
return (
<>
<TopBar title={`Patient 360 — ${fullName}`} />
<div className="flex flex-1 flex-col overflow-y-auto">
{/* Header card */}
<div className="border-b border-secondary bg-primary px-6 py-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:gap-6">
{/* Avatar + name */}
<div className="flex items-center gap-4">
<Avatar initials={initials} size="xl" />
<div className="flex flex-col gap-1">
<h2 className="text-display-xs font-bold text-primary">{fullName}</h2>
<div className="flex flex-wrap items-center gap-2">
{patient.patientType && (
<Badge size="sm" type="pill-color" color={patient.patientType === 'RETURNING' ? 'brand' : 'gray'}>
{patient.patientType === 'RETURNING' ? 'Returning' : 'New'}
</Badge>
)}
{age !== null && genderLabel && (
<span className="text-xs text-tertiary">{age}y · {genderLabel}</span>
)}
{leadInfo?.source && (
<Badge size="sm" type="pill-color" color="gray">
{leadInfo.source.replace(/_/g, ' ')}
</Badge>
)}
{leadInfo?.status && (
<Badge size="sm" type="pill-color" color={leadInfo.status === 'CONVERTED' ? 'success' : leadInfo.status === 'NEW' ? 'brand' : 'gray'}>
{leadInfo.status.replace(/_/g, ' ')}
</Badge>
)}
</div>
</div>
</div>
{/* Contact details */}
<div className="flex flex-1 flex-col gap-2 lg:ml-auto lg:items-end">
<div className="flex flex-wrap items-center gap-3">
{phoneRaw && (
<span className="flex items-center gap-1.5 text-sm text-secondary">
<Phone01 className="size-4 text-fg-quaternary" />
{phoneRaw}
</span>
)}
{email && (
<span className="flex items-center gap-1.5 text-sm text-secondary">
<Mail01 className="size-4 text-fg-quaternary" />
{email}
</span>
)}
</div>
{leadInfo?.interestedService && (
<span className="text-xs text-tertiary">
Interested in: {leadInfo.interestedService}
</span>
)}
</div>
</div>
{/* AI summary from linked lead */}
{leadInfo?.aiSummary && (
<div className="mt-4 rounded-lg border border-secondary bg-secondary_alt p-3">
<p className="text-sm text-secondary">{leadInfo.aiSummary}</p>
</div>
)}
{/* Quick actions */}
<div className="mt-4 flex flex-wrap gap-2">
{phoneRaw && (
<ClickToCallButton phoneNumber={phoneRaw} label="Call" size="sm" />
)}
<Button size="sm" color="secondary" iconLeading={Calendar} onClick={() => { setEditingAppt(null); setApptFormOpen(true); }}>
Book Appointment
</Button>
<Button size="sm" color="secondary" iconLeading={MessageTextSquare01}>
Send WhatsApp
</Button>
</div>
</div>
{/* Tabs */}
<div className="px-6 pt-5">
<Tabs selectedKey={activeTab} onSelectionChange={(key) => setActiveTab(String(key))}>
<TabList
type="underline"
size="sm"
items={TABS}
>
{(item) => (
<Tab
key={item.id}
id={item.id}
label={item.label}
badge={
item.id === 'appointments'
? appointments.length
: item.id === 'calls'
? patientCalls.length
: item.id === 'timeline'
? activities.length
: item.id === 'notes'
? notes.length
: undefined
}
/>
)}
</TabList>
{/* Appointments tab */}
<TabPanel id="appointments">
<div className="mt-5 pb-7">
{appointments.length === 0 ? (
<EmptyState
icon="📅"
title="No appointments"
subtitle="Appointment history will appear here."
/>
) : (
<div className="rounded-xl border border-secondary bg-primary">
{appointments.map((appt: any) => (
<AppointmentRow key={appt.id} appt={appt} onEdit={(a) => { setEditingAppt(a); setApptFormOpen(true); }} />
))}
</div>
)}
</div>
</TabPanel>
{/* Calls tab */}
<TabPanel id="calls">
<div className="mt-5 pb-7">
{patientCalls.length === 0 ? (
<EmptyState
icon="📞"
title="No calls yet"
subtitle="Call history with this patient will appear here."
/>
) : (
<div className="rounded-xl border border-secondary bg-primary">
{patientCalls.map((call: any) => (
<CallRow key={call.id} call={call} />
))}
</div>
)}
</div>
</TabPanel>
{/* Timeline tab */}
<TabPanel id="timeline">
<div className="mt-5 pb-7">
{activities.length === 0 ? (
<EmptyState
icon="📭"
title="No activity yet"
subtitle="Activity will appear here as interactions occur."
/>
) : (
<div className="flex flex-col">
{activities.map((activity, idx) => (
<TimelineItem
key={activity.id}
activity={activity}
isLast={idx === activities.length - 1}
/>
))}
</div>
)}
</div>
</TabPanel>
{/* Notes tab */}
<TabPanel id="notes">
<div className="mt-5 pb-7">
{/* Add note form */}
<div className="mb-4 rounded-xl border border-secondary bg-primary p-4">
<textarea
className="w-full resize-none rounded-lg border border-secondary bg-primary px-3 py-2 text-sm text-primary placeholder:text-placeholder focus:border-brand focus:ring-1 focus:ring-brand focus:outline-hidden"
rows={3}
placeholder="Write a note..."
value={noteText}
onChange={(event) => setNoteText(event.target.value)}
/>
<div className="mt-2 flex justify-end">
<Button
size="sm"
color="primary"
iconLeading={Plus}
isDisabled={noteText.trim() === '' || noteSaving}
isLoading={noteSaving}
onClick={async () => {
if (!noteText.trim() || !leadInfo?.id) return;
setNoteSaving(true);
try {
await apiClient.graphql(
`mutation($data: LeadActivityCreateInput!) { createLeadActivity(data: $data) { id } }`,
{ data: { name: `Note — ${fullName}`, activityType: 'NOTE_ADDED', summary: noteText.trim(), occurredAt: new Date().toISOString(), leadId: leadInfo.id } },
);
setActivities(prev => [{ id: crypto.randomUUID(), createdAt: new Date().toISOString(), activityType: 'NOTE_ADDED' as LeadActivityType, summary: noteText.trim(), occurredAt: new Date().toISOString(), performedBy: null, previousValue: null, newValue: noteText.trim(), channel: null, durationSeconds: null, outcome: null, leadId: leadInfo.id }, ...prev]);
setNoteText('');
notify.success('Note Added');
} catch {
notify.error('Failed', 'Could not save note');
} finally {
setNoteSaving(false);
}
}}
>
Add Note
</Button>
</div>
</div>
{notes.length === 0 ? (
<EmptyState
icon="📝"
title="No notes yet"
subtitle="Notes will appear here when added."
/>
) : (
<div className="rounded-xl border border-secondary bg-primary">
{notes.map((note) => (
<NoteItem key={note.id} activity={note} />
))}
</div>
)}
</div>
</TabPanel>
</Tabs>
</div>
</div>
<AppointmentForm
isOpen={apptFormOpen}
onOpenChange={setApptFormOpen}
callerNumber={phoneRaw || null}
leadName={fullName !== 'Unknown Patient' ? fullName : null}
leadId={leadInfo?.id ?? null}
patientId={id ?? null}
existingAppointment={editingAppt ? {
id: editingAppt.id,
scheduledAt: editingAppt.scheduledAt,
doctorName: editingAppt.doctorName ?? '',
department: editingAppt.department ?? '',
reasonForVisit: editingAppt.reasonForVisit,
status: editingAppt.status,
} : null}
onSaved={() => {
setApptFormOpen(false);
setEditingAppt(null);
// Refresh patient data
if (id) {
apiClient.graphql<{ patients: { edges: Array<{ node: PatientData }> } }>(
PATIENT_QUERY, { id }, { silent: true },
).then(data => setPatient(data.patients.edges[0]?.node ?? null)).catch(() => {});
}
}}
/>
</>
);
};