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:
335
src/components/call-desk/appointment-form.tsx
Normal file
335
src/components/call-desk/appointment-form.tsx
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { CalendarPlus02 } from '@untitledui/icons';
|
||||||
|
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
|
||||||
|
import { Input } from '@/components/base/input/input';
|
||||||
|
import { Select } from '@/components/base/select/select';
|
||||||
|
import { TextArea } from '@/components/base/textarea/textarea';
|
||||||
|
import { Checkbox } from '@/components/base/checkbox/checkbox';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
|
||||||
|
type AppointmentFormProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
callerNumber?: string | null;
|
||||||
|
leadName?: string | null;
|
||||||
|
leadId?: string | null;
|
||||||
|
onSaved?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clinicItems = [
|
||||||
|
{ id: 'koramangala', label: 'Global Hospital - Koramangala' },
|
||||||
|
{ id: 'whitefield', label: 'Global Hospital - Whitefield' },
|
||||||
|
{ id: 'indiranagar', label: 'Global Hospital - Indiranagar' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const departmentItems = [
|
||||||
|
{ id: 'cardiology', label: 'Cardiology' },
|
||||||
|
{ id: 'gynecology', label: 'Gynecology' },
|
||||||
|
{ id: 'orthopedics', label: 'Orthopedics' },
|
||||||
|
{ id: 'general-medicine', label: 'General Medicine' },
|
||||||
|
{ id: 'ent', label: 'ENT' },
|
||||||
|
{ id: 'dermatology', label: 'Dermatology' },
|
||||||
|
{ id: 'pediatrics', label: 'Pediatrics' },
|
||||||
|
{ id: 'oncology', label: 'Oncology' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const doctorItems = [
|
||||||
|
{ id: 'dr-sharma', label: 'Dr. Sharma' },
|
||||||
|
{ id: 'dr-patel', label: 'Dr. Patel' },
|
||||||
|
{ id: 'dr-kumar', label: 'Dr. Kumar' },
|
||||||
|
{ id: 'dr-reddy', label: 'Dr. Reddy' },
|
||||||
|
{ id: 'dr-singh', label: 'Dr. Singh' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const genderItems = [
|
||||||
|
{ id: 'male', label: 'Male' },
|
||||||
|
{ id: 'female', label: 'Female' },
|
||||||
|
{ id: 'other', label: 'Other' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const timeSlotItems = [
|
||||||
|
{ id: '09:00', label: '9:00 AM' },
|
||||||
|
{ id: '09:30', label: '9:30 AM' },
|
||||||
|
{ id: '10:00', label: '10:00 AM' },
|
||||||
|
{ id: '10:30', label: '10:30 AM' },
|
||||||
|
{ id: '11:00', label: '11:00 AM' },
|
||||||
|
{ id: '11:30', label: '11:30 AM' },
|
||||||
|
{ id: '14:00', label: '2:00 PM' },
|
||||||
|
{ id: '14:30', label: '2:30 PM' },
|
||||||
|
{ id: '15:00', label: '3:00 PM' },
|
||||||
|
{ id: '15:30', label: '3:30 PM' },
|
||||||
|
{ id: '16:00', label: '4:00 PM' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const AppointmentForm = ({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
callerNumber,
|
||||||
|
leadName,
|
||||||
|
leadId,
|
||||||
|
onSaved,
|
||||||
|
}: AppointmentFormProps) => {
|
||||||
|
const [patientName, setPatientName] = useState(leadName ?? '');
|
||||||
|
const [patientPhone, setPatientPhone] = useState(callerNumber ?? '');
|
||||||
|
const [age, setAge] = useState('');
|
||||||
|
const [gender, setGender] = useState<string | null>(null);
|
||||||
|
const [clinic, setClinic] = useState<string | null>(null);
|
||||||
|
const [department, setDepartment] = useState<string | null>(null);
|
||||||
|
const [doctor, setDoctor] = useState<string | null>(null);
|
||||||
|
const [date, setDate] = useState('');
|
||||||
|
const [timeSlot, setTimeSlot] = useState<string | null>(null);
|
||||||
|
const [chiefComplaint, setChiefComplaint] = useState('');
|
||||||
|
const [isReturning, setIsReturning] = useState(false);
|
||||||
|
const [source, setSource] = useState('Inbound Call');
|
||||||
|
const [agentNotes, setAgentNotes] = useState('');
|
||||||
|
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!date || !timeSlot || !doctor || !department) {
|
||||||
|
setError('Please fill in the required fields: date, time, doctor, and department.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { apiClient } = await import('@/lib/api-client');
|
||||||
|
|
||||||
|
// Combine date + time slot into ISO datetime
|
||||||
|
const scheduledAt = new Date(`${date}T${timeSlot}:00`).toISOString();
|
||||||
|
|
||||||
|
const doctorLabel = doctorItems.find((d) => d.id === doctor)?.label ?? doctor;
|
||||||
|
const departmentLabel = departmentItems.find((d) => d.id === department)?.label ?? department;
|
||||||
|
|
||||||
|
// Create appointment on platform
|
||||||
|
await apiClient.graphql(
|
||||||
|
`mutation CreateAppointment($data: AppointmentCreateInput!) {
|
||||||
|
createAppointment(data: $data) { id }
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
scheduledAt,
|
||||||
|
durationMinutes: 30,
|
||||||
|
appointmentType: 'CONSULTATION',
|
||||||
|
appointmentStatus: 'SCHEDULED',
|
||||||
|
doctorName: doctorLabel,
|
||||||
|
department: departmentLabel,
|
||||||
|
reasonForVisit: chiefComplaint || null,
|
||||||
|
...(leadId ? { patientId: leadId } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update lead status if we have a matched lead
|
||||||
|
if (leadId) {
|
||||||
|
await apiClient
|
||||||
|
.graphql(
|
||||||
|
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
|
||||||
|
updateLead(id: $id, data: $data) { id }
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
id: leadId,
|
||||||
|
data: {
|
||||||
|
leadStatus: 'APPOINTMENT_SET',
|
||||||
|
lastContactedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.catch((err: unknown) => console.warn('Failed to update lead:', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
onSaved?.();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create appointment:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to create appointment. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SlideoutMenu isOpen={isOpen} onOpenChange={onOpenChange} className="max-w-120">
|
||||||
|
{({ close }) => (
|
||||||
|
<>
|
||||||
|
<SlideoutMenu.Header onClose={close}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-lg bg-brand-secondary">
|
||||||
|
<CalendarPlus02 className="size-5 text-fg-brand-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-primary">Book Appointment</h2>
|
||||||
|
<p className="text-sm text-tertiary">Schedule a new patient visit</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SlideoutMenu.Header>
|
||||||
|
|
||||||
|
<SlideoutMenu.Content>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Patient Info */}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-tertiary">
|
||||||
|
Patient Information
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Patient Name"
|
||||||
|
placeholder="Full name"
|
||||||
|
value={patientName}
|
||||||
|
onChange={setPatientName}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="Phone"
|
||||||
|
placeholder="Phone number"
|
||||||
|
value={patientPhone}
|
||||||
|
onChange={setPatientPhone}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Age"
|
||||||
|
placeholder="Age"
|
||||||
|
type="number"
|
||||||
|
value={age}
|
||||||
|
onChange={setAge}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Gender"
|
||||||
|
placeholder="Select gender"
|
||||||
|
items={genderItems}
|
||||||
|
selectedKey={gender}
|
||||||
|
onSelectionChange={(key) => setGender(key as string)}
|
||||||
|
>
|
||||||
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="border-t border-secondary" />
|
||||||
|
|
||||||
|
{/* Appointment Details */}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-tertiary">
|
||||||
|
Appointment Details
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Clinic / Branch"
|
||||||
|
placeholder="Select clinic"
|
||||||
|
items={clinicItems}
|
||||||
|
selectedKey={clinic}
|
||||||
|
onSelectionChange={(key) => setClinic(key as string)}
|
||||||
|
>
|
||||||
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Department / Specialty"
|
||||||
|
placeholder="Select department"
|
||||||
|
items={departmentItems}
|
||||||
|
selectedKey={department}
|
||||||
|
onSelectionChange={(key) => setDepartment(key as string)}
|
||||||
|
isRequired
|
||||||
|
>
|
||||||
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Doctor"
|
||||||
|
placeholder="Select doctor"
|
||||||
|
items={doctorItems}
|
||||||
|
selectedKey={doctor}
|
||||||
|
onSelectionChange={(key) => setDoctor(key as string)}
|
||||||
|
isRequired
|
||||||
|
>
|
||||||
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="Date"
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={setDate}
|
||||||
|
isRequired
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Time Slot"
|
||||||
|
placeholder="Select time"
|
||||||
|
items={timeSlotItems}
|
||||||
|
selectedKey={timeSlot}
|
||||||
|
onSelectionChange={(key) => setTimeSlot(key as string)}
|
||||||
|
isRequired
|
||||||
|
>
|
||||||
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextArea
|
||||||
|
label="Chief Complaint"
|
||||||
|
placeholder="Describe the reason for visit..."
|
||||||
|
value={chiefComplaint}
|
||||||
|
onChange={setChiefComplaint}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="border-t border-secondary" />
|
||||||
|
|
||||||
|
{/* Additional Info */}
|
||||||
|
<Checkbox
|
||||||
|
isSelected={isReturning}
|
||||||
|
onChange={setIsReturning}
|
||||||
|
label="Returning Patient"
|
||||||
|
hint="Check if the patient has visited before"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Source / Referral"
|
||||||
|
placeholder="How did the patient reach us?"
|
||||||
|
value={source}
|
||||||
|
onChange={setSource}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextArea
|
||||||
|
label="Agent Notes"
|
||||||
|
placeholder="Any additional notes..."
|
||||||
|
value={agentNotes}
|
||||||
|
onChange={setAgentNotes}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg bg-error-primary p-3 text-sm text-error-primary">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SlideoutMenu.Content>
|
||||||
|
|
||||||
|
<SlideoutMenu.Footer>
|
||||||
|
<div className="flex items-center justify-end gap-3">
|
||||||
|
<Button size="md" color="secondary" onClick={close}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
color="primary"
|
||||||
|
isLoading={isSaving}
|
||||||
|
showTextWhileLoading
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
{isSaving ? 'Booking...' : 'Book Appointment'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SlideoutMenu.Footer>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SlideoutMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,9 +10,11 @@ import {
|
|||||||
PauseCircle,
|
PauseCircle,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Save01,
|
Save01,
|
||||||
|
CalendarPlus02,
|
||||||
} from '@untitledui/icons';
|
} from '@untitledui/icons';
|
||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from '@/components/base/buttons/button';
|
||||||
import { TextArea } from '@/components/base/textarea/textarea';
|
import { TextArea } from '@/components/base/textarea/textarea';
|
||||||
|
import { AppointmentForm } from '@/components/call-desk/appointment-form';
|
||||||
import { useSip } from '@/providers/sip-provider';
|
import { useSip } from '@/providers/sip-provider';
|
||||||
import { useAuth } from '@/providers/auth-provider';
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from '@/utils/cx';
|
||||||
@@ -106,6 +108,7 @@ export const CallWidget = () => {
|
|||||||
const [matchedLead, setMatchedLead] = useState<any>(null);
|
const [matchedLead, setMatchedLead] = useState<any>(null);
|
||||||
const [leadActivities, setLeadActivities] = useState<any[]>([]);
|
const [leadActivities, setLeadActivities] = useState<any[]>([]);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isAppointmentOpen, setIsAppointmentOpen] = useState(false);
|
||||||
const callStartTimeRef = useRef<string | null>(null);
|
const callStartTimeRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Capture duration right before call ends
|
// Capture duration right before call ends
|
||||||
@@ -411,6 +414,29 @@ export const CallWidget = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Book Appointment */}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="primary"
|
||||||
|
iconLeading={CalendarPlus02}
|
||||||
|
onClick={() => setIsAppointmentOpen(true)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Book Appointment
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<AppointmentForm
|
||||||
|
isOpen={isAppointmentOpen}
|
||||||
|
onOpenChange={setIsAppointmentOpen}
|
||||||
|
callerNumber={callerNumber}
|
||||||
|
leadName={matchedLead ? `${matchedLead.contactName?.firstName ?? ''} ${matchedLead.contactName?.lastName ?? ''}`.trim() : null}
|
||||||
|
leadId={matchedLead?.id}
|
||||||
|
onSaved={() => {
|
||||||
|
setIsAppointmentOpen(false);
|
||||||
|
setDisposition('APPOINTMENT_BOOKED');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="border-t border-secondary" />
|
<div className="border-t border-secondary" />
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { SearchLg } from "@untitledui/icons";
|
|
||||||
import { Avatar } from "@/components/base/avatar/avatar";
|
import { Avatar } from "@/components/base/avatar/avatar";
|
||||||
import { Input } from "@/components/base/input/input";
|
import { GlobalSearch } from "@/components/shared/global-search";
|
||||||
import { useAuth } from "@/providers/auth-provider";
|
import { useAuth } from "@/providers/auth-provider";
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
@@ -19,13 +18,7 @@ export const TopBar = ({ title, subtitle }: TopBarProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-64">
|
<GlobalSearch />
|
||||||
<Input
|
|
||||||
placeholder="Search..."
|
|
||||||
icon={SearchLg}
|
|
||||||
aria-label="Search"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Avatar initials={user.initials} size="sm" />
|
<Avatar initials={user.initials} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
270
src/components/shared/global-search.tsx
Normal file
270
src/components/shared/global-search.tsx
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { SearchLg, User01, Phone01, Calendar } from '@untitledui/icons';
|
||||||
|
import { Input } from '@/components/base/input/input';
|
||||||
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
|
import { useData } from '@/providers/data-provider';
|
||||||
|
import { formatPhone } from '@/lib/format';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
|
type SearchResultType = 'lead' | 'patient' | 'appointment';
|
||||||
|
|
||||||
|
type SearchResult = {
|
||||||
|
id: string;
|
||||||
|
type: SearchResultType;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
phone?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GlobalSearchProps = {
|
||||||
|
onSelectResult?: (result: SearchResult) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_ICONS: Record<SearchResultType, typeof User01> = {
|
||||||
|
lead: User01,
|
||||||
|
patient: User01,
|
||||||
|
appointment: Calendar,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_BADGE_COLORS: Record<SearchResultType, 'brand' | 'success' | 'blue'> = {
|
||||||
|
lead: 'brand',
|
||||||
|
patient: 'success',
|
||||||
|
appointment: 'blue',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<SearchResultType, string> = {
|
||||||
|
lead: 'Lead',
|
||||||
|
patient: 'Patient',
|
||||||
|
appointment: 'Appointment',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { leads } = useData();
|
||||||
|
|
||||||
|
const searchLeads = useCallback(
|
||||||
|
(searchQuery: string): SearchResult[] => {
|
||||||
|
const normalizedQuery = searchQuery.trim().toLowerCase();
|
||||||
|
if (normalizedQuery.length < 3) return [];
|
||||||
|
|
||||||
|
const matched = leads.filter((lead) => {
|
||||||
|
const firstName = lead.contactName?.firstName?.toLowerCase() ?? '';
|
||||||
|
const lastName = lead.contactName?.lastName?.toLowerCase() ?? '';
|
||||||
|
const fullName = `${firstName} ${lastName}`.trim();
|
||||||
|
const phones = (lead.contactPhone ?? []).map((p) => `${p.callingCode}${p.number}`.toLowerCase());
|
||||||
|
|
||||||
|
const matchesName =
|
||||||
|
firstName.includes(normalizedQuery) ||
|
||||||
|
lastName.includes(normalizedQuery) ||
|
||||||
|
fullName.includes(normalizedQuery);
|
||||||
|
const matchesPhone = phones.some((phone) => phone.includes(normalizedQuery));
|
||||||
|
|
||||||
|
return matchesName || matchesPhone;
|
||||||
|
});
|
||||||
|
|
||||||
|
return matched.slice(0, 5).map((lead) => {
|
||||||
|
const firstName = lead.contactName?.firstName ?? '';
|
||||||
|
const lastName = lead.contactName?.lastName ?? '';
|
||||||
|
const phone = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : undefined;
|
||||||
|
const email = lead.contactEmail?.[0]?.address ?? undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: lead.id,
|
||||||
|
type: 'lead' as const,
|
||||||
|
title: `${firstName} ${lastName}`.trim() || 'Unknown Lead',
|
||||||
|
subtitle: [phone, email, lead.interestedService].filter(Boolean).join(' · '),
|
||||||
|
phone,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[leads],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceRef.current) {
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.trim().length < 3) {
|
||||||
|
setResults([]);
|
||||||
|
setIsOpen(false);
|
||||||
|
setIsSearching(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSearching(true);
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
const searchResults = searchLeads(query);
|
||||||
|
setResults(searchResults);
|
||||||
|
setIsOpen(true);
|
||||||
|
setIsSearching(false);
|
||||||
|
setHighlightedIndex(-1);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) {
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [query, searchLeads]);
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelect = (result: SearchResult) => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setQuery('');
|
||||||
|
if (onSelectResult) {
|
||||||
|
onSelectResult(result);
|
||||||
|
} else {
|
||||||
|
navigate(`/patient/${result.id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||||
|
if (!isOpen || results.length === 0) return;
|
||||||
|
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
setHighlightedIndex((prev) => (prev < results.length - 1 ? prev + 1 : 0));
|
||||||
|
} else if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : results.length - 1));
|
||||||
|
} else if (event.key === 'Enter' && highlightedIndex >= 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
handleSelect(results[highlightedIndex]);
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Group results by type
|
||||||
|
const groupedResults: Record<string, SearchResult[]> = {};
|
||||||
|
for (const result of results) {
|
||||||
|
const groupLabel = `${TYPE_LABELS[result.type]}s`;
|
||||||
|
if (!groupedResults[groupLabel]) {
|
||||||
|
groupedResults[groupLabel] = [];
|
||||||
|
}
|
||||||
|
groupedResults[groupLabel].push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
let flatIndex = -1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative w-64" onKeyDown={handleKeyDown}>
|
||||||
|
<Input
|
||||||
|
placeholder="Search leads..."
|
||||||
|
icon={SearchLg}
|
||||||
|
aria-label="Global search"
|
||||||
|
value={query}
|
||||||
|
onChange={(value) => setQuery(value)}
|
||||||
|
onFocus={() => {
|
||||||
|
if (results.length > 0) setIsOpen(true);
|
||||||
|
}}
|
||||||
|
shortcut="/"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute top-full right-0 left-0 z-50 mt-1 overflow-hidden rounded-xl border border-secondary bg-primary shadow-lg">
|
||||||
|
{isSearching && (
|
||||||
|
<div className="flex items-center justify-center px-4 py-6">
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
className="size-5 animate-spin text-fg-brand-primary"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="stroke-current opacity-30"
|
||||||
|
cx="10"
|
||||||
|
cy="10"
|
||||||
|
r="8"
|
||||||
|
fill="none"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
className="origin-center stroke-current"
|
||||||
|
cx="10"
|
||||||
|
cy="10"
|
||||||
|
r="8"
|
||||||
|
fill="none"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeDasharray="12.5 50"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isSearching && results.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center gap-1 px-4 py-6 text-center">
|
||||||
|
<SearchLg className="size-5 text-fg-quaternary" />
|
||||||
|
<p className="text-sm font-medium text-secondary">No results</p>
|
||||||
|
<p className="text-xs text-tertiary">Try a different search term</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isSearching &&
|
||||||
|
results.length > 0 &&
|
||||||
|
Object.entries(groupedResults).map(([groupLabel, groupResults]) => (
|
||||||
|
<div key={groupLabel}>
|
||||||
|
<div className="px-4 py-2 text-xs font-bold uppercase text-quaternary">
|
||||||
|
{groupLabel}
|
||||||
|
</div>
|
||||||
|
{groupResults.map((result) => {
|
||||||
|
flatIndex++;
|
||||||
|
const currentIndex = flatIndex;
|
||||||
|
const Icon = TYPE_ICONS[result.type];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={result.id}
|
||||||
|
type="button"
|
||||||
|
className={cx(
|
||||||
|
'flex w-full cursor-pointer items-center gap-3 px-4 py-2.5 text-left transition duration-100 ease-linear',
|
||||||
|
currentIndex === highlightedIndex
|
||||||
|
? 'bg-active'
|
||||||
|
: 'hover:bg-primary_hover',
|
||||||
|
)}
|
||||||
|
onClick={() => handleSelect(result)}
|
||||||
|
onMouseEnter={() => setHighlightedIndex(currentIndex)}
|
||||||
|
>
|
||||||
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary">
|
||||||
|
<Icon className="size-4 text-fg-quaternary" />
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="truncate text-sm font-semibold text-primary">
|
||||||
|
{result.title}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs text-tertiary">
|
||||||
|
{result.subtitle}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Badge size="sm" type="pill-color" color={TYPE_BADGE_COLORS[result.type]}>
|
||||||
|
{TYPE_LABELS[result.type]}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -13,6 +13,7 @@ import { CampaignsPage } from "@/pages/campaigns";
|
|||||||
import { FollowUpsPage } from "@/pages/follow-ups-page";
|
import { FollowUpsPage } from "@/pages/follow-ups-page";
|
||||||
import { LoginPage } from "@/pages/login";
|
import { LoginPage } from "@/pages/login";
|
||||||
import { OutreachPage } from "@/pages/outreach";
|
import { OutreachPage } from "@/pages/outreach";
|
||||||
|
import { Patient360Page } from "@/pages/patient-360";
|
||||||
import { TeamDashboardPage } from "@/pages/team-dashboard";
|
import { TeamDashboardPage } from "@/pages/team-dashboard";
|
||||||
import { AuthProvider } from "@/providers/auth-provider";
|
import { AuthProvider } from "@/providers/auth-provider";
|
||||||
import { DataProvider } from "@/providers/data-provider";
|
import { DataProvider } from "@/providers/data-provider";
|
||||||
@@ -46,6 +47,7 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
<Route path="/call-history" element={<CallHistoryPage />} />
|
<Route path="/call-history" element={<CallHistoryPage />} />
|
||||||
<Route path="/call-desk" element={<CallDeskPage />} />
|
<Route path="/call-desk" element={<CallDeskPage />} />
|
||||||
<Route path="/team-dashboard" element={<TeamDashboardPage />} />
|
<Route path="/team-dashboard" element={<TeamDashboardPage />} />
|
||||||
|
<Route path="/patient/:id" element={<Patient360Page />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
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