feat: add appointment booking form slide-out during calls, wired to platform createAppointment mutation

This commit is contained in:
2026-03-18 11:07:15 +05:30
parent 66ad398b81
commit 9690ac416e
6 changed files with 1064 additions and 9 deletions

View 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>
);
};

View File

@@ -10,9 +10,11 @@ import {
PauseCircle,
CheckCircle,
Save01,
CalendarPlus02,
} from '@untitledui/icons';
import { Button } from '@/components/base/buttons/button';
import { TextArea } from '@/components/base/textarea/textarea';
import { AppointmentForm } from '@/components/call-desk/appointment-form';
import { useSip } from '@/providers/sip-provider';
import { useAuth } from '@/providers/auth-provider';
import { cx } from '@/utils/cx';
@@ -106,6 +108,7 @@ export const CallWidget = () => {
const [matchedLead, setMatchedLead] = useState<any>(null);
const [leadActivities, setLeadActivities] = useState<any[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [isAppointmentOpen, setIsAppointmentOpen] = useState(false);
const callStartTimeRef = useRef<string | null>(null);
// Capture duration right before call ends
@@ -411,6 +414,29 @@ export const CallWidget = () => {
</Button>
</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 */}
<div className="border-t border-secondary" />

View File

@@ -1,6 +1,5 @@
import { SearchLg } from "@untitledui/icons";
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";
interface TopBarProps {
@@ -19,13 +18,7 @@ export const TopBar = ({ title, subtitle }: TopBarProps) => {
</div>
<div className="flex items-center gap-3">
<div className="w-64">
<Input
placeholder="Search..."
icon={SearchLg}
aria-label="Search"
/>
</div>
<GlobalSearch />
<Avatar initials={user.initials} size="sm" />
</div>
</header>

View 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>
);
};