mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
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>
629 lines
30 KiB
TypeScript
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(() => {});
|
|
}
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
};
|