mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
feat: add appointment booking form slide-out during calls, wired to platform createAppointment mutation
This commit is contained in:
429
src/pages/patient-360.tsx
Normal file
429
src/pages/patient-360.tsx
Normal file
@@ -0,0 +1,429 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { Phone01, Mail01, Calendar, FileText06, Clock, MessageTextSquare01, Plus } from '@untitledui/icons';
|
||||
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 { LeadStatusBadge } from '@/components/shared/status-badge';
|
||||
import { SourceTag } from '@/components/shared/source-tag';
|
||||
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { formatPhone, formatShortDate, getInitials } from '@/lib/format';
|
||||
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',
|
||||
FOLLOW_UP_SCHEDULED: 'brand',
|
||||
INFO_PROVIDED: 'blue',
|
||||
WRONG_NUMBER: 'error',
|
||||
NO_ANSWER: 'warning',
|
||||
CALLBACK_REQUESTED: 'gray',
|
||||
};
|
||||
|
||||
const TABS = [
|
||||
{ id: 'timeline', label: 'Timeline' },
|
||||
{ id: 'calls', label: 'Calls' },
|
||||
{ id: 'notes', label: 'Notes' },
|
||||
];
|
||||
|
||||
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 { leads, leadActivities, calls } = useData();
|
||||
const [activeTab, setActiveTab] = useState<string>('timeline');
|
||||
const [noteText, setNoteText] = useState('');
|
||||
|
||||
const lead = leads.find((l) => l.id === id);
|
||||
|
||||
// Filter activities for this lead
|
||||
const activities = useMemo(
|
||||
() =>
|
||||
leadActivities
|
||||
.filter((a) => a.leadId === id)
|
||||
.sort((a, b) => {
|
||||
if (!a.occurredAt) return 1;
|
||||
if (!b.occurredAt) return -1;
|
||||
return new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime();
|
||||
}),
|
||||
[leadActivities, id],
|
||||
);
|
||||
|
||||
// Filter calls for this lead
|
||||
const leadCalls = useMemo(
|
||||
() =>
|
||||
calls
|
||||
.filter((c) => c.leadId === id)
|
||||
.sort((a, b) => {
|
||||
if (!a.startedAt) return 1;
|
||||
if (!b.startedAt) return -1;
|
||||
return new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime();
|
||||
}),
|
||||
[calls, id],
|
||||
);
|
||||
|
||||
// Notes are activities of type NOTE_ADDED
|
||||
const notes = useMemo(
|
||||
() => activities.filter((a) => a.activityType === 'NOTE_ADDED'),
|
||||
[activities],
|
||||
);
|
||||
|
||||
if (!lead) {
|
||||
return (
|
||||
<>
|
||||
<TopBar title="Patient 360" />
|
||||
<div className="flex flex-1 items-center justify-center p-8">
|
||||
<p className="text-tertiary">Lead not found.</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const firstName = lead.contactName?.firstName ?? '';
|
||||
const lastName = lead.contactName?.lastName ?? '';
|
||||
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown Lead';
|
||||
const initials = getInitials(firstName || '?', lastName || '?');
|
||||
const phone = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : null;
|
||||
const phoneRaw = lead.contactPhone?.[0]?.number ?? '';
|
||||
const email = lead.contactEmail?.[0]?.address ?? null;
|
||||
|
||||
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">
|
||||
{lead.leadStatus && <LeadStatusBadge status={lead.leadStatus} />}
|
||||
{lead.leadSource && <SourceTag source={lead.leadSource} />}
|
||||
</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">
|
||||
{phone && (
|
||||
<span className="flex items-center gap-1.5 text-sm text-secondary">
|
||||
<Phone01 className="size-4 text-fg-quaternary" />
|
||||
{phone}
|
||||
</span>
|
||||
)}
|
||||
{email && (
|
||||
<span className="flex items-center gap-1.5 text-sm text-secondary">
|
||||
<Mail01 className="size-4 text-fg-quaternary" />
|
||||
{email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{lead.interestedService && (
|
||||
<span className="text-xs text-tertiary">
|
||||
Interested in: {lead.interestedService}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI summary */}
|
||||
{(lead.aiSummary || lead.aiSuggestedAction) && (
|
||||
<div className="mt-4 rounded-lg border border-secondary bg-secondary_alt p-3">
|
||||
{lead.aiSummary && (
|
||||
<p className="text-sm text-secondary">{lead.aiSummary}</p>
|
||||
)}
|
||||
{lead.aiSuggestedAction && (
|
||||
<Badge size="sm" type="pill-color" color="brand" className="mt-2">
|
||||
{lead.aiSuggestedAction}
|
||||
</Badge>
|
||||
)}
|
||||
</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}>
|
||||
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 === 'timeline'
|
||||
? activities.length
|
||||
: item.id === 'calls'
|
||||
? leadCalls.length
|
||||
: item.id === 'notes'
|
||||
? notes.length
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TabList>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Calls tab */}
|
||||
<TabPanel id="calls">
|
||||
<div className="mt-5 pb-7">
|
||||
{leadCalls.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="📞"
|
||||
title="No calls yet"
|
||||
subtitle="Call history with this lead will appear here."
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-xl border border-secondary bg-primary">
|
||||
{leadCalls.map((call) => (
|
||||
<CallRow key={call.id} call={call} />
|
||||
))}
|
||||
</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() === ''}
|
||||
>
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user