mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
feat: telephony overhaul + appointment availability + Force Ready
Telephony: - Track UCID from SIP headers and ManualDial response - Submit disposition to Ozonetel via Set Disposition API (ends ACW) - Fix outboundPending flag lifecycle to prevent inbound poisoning - Fix render order: post-call UI takes priority over active state - Pre-select disposition when appointment booked during call Appointment form: - Convert from slideout to inline collapsible below call card - Fetch real doctors from platform, filter by department - Show time slot availability grid (booked slots greyed + strikethrough) - Double-check availability before booking - Support edit and cancel existing appointments UI: - Add Force Ready button to profile menu (logout+login to clear ACW) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,28 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCalendarPlus } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import type { FC, HTMLAttributes } from 'react';
|
||||
|
||||
const CalendarPlus02: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => <FontAwesomeIcon icon={faCalendarPlus} className={className} />;
|
||||
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
|
||||
import { XClose } from '@untitledui/icons';
|
||||
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';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { notify } from '@/lib/toast';
|
||||
|
||||
type ExistingAppointment = {
|
||||
id: string;
|
||||
scheduledAt: string;
|
||||
doctorName: string;
|
||||
doctorId?: string;
|
||||
department: string;
|
||||
reasonForVisit?: string;
|
||||
appointmentStatus: string;
|
||||
};
|
||||
|
||||
type AppointmentFormProps = {
|
||||
isOpen: boolean;
|
||||
@@ -18,33 +31,17 @@ type AppointmentFormProps = {
|
||||
leadName?: string | null;
|
||||
leadId?: string | null;
|
||||
onSaved?: () => void;
|
||||
existingAppointment?: ExistingAppointment | null;
|
||||
};
|
||||
|
||||
type DoctorRecord = { id: string; name: string; department: string; clinic: string };
|
||||
|
||||
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' },
|
||||
@@ -65,6 +62,9 @@ const timeSlotItems = [
|
||||
{ id: '16:00', label: '4:00 PM' },
|
||||
];
|
||||
|
||||
const formatDeptLabel = (dept: string) =>
|
||||
dept.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
|
||||
export const AppointmentForm = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
@@ -72,24 +72,125 @@ export const AppointmentForm = ({
|
||||
leadName,
|
||||
leadId,
|
||||
onSaved,
|
||||
existingAppointment,
|
||||
}: AppointmentFormProps) => {
|
||||
const isEditMode = !!existingAppointment;
|
||||
|
||||
// Doctor data from platform
|
||||
const [doctors, setDoctors] = useState<DoctorRecord[]>([]);
|
||||
|
||||
// Form state — initialized from existing appointment in edit mode
|
||||
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 [department, setDepartment] = useState<string | null>(existingAppointment?.department ?? null);
|
||||
const [doctor, setDoctor] = useState<string | null>(existingAppointment?.doctorId ?? null);
|
||||
const [date, setDate] = useState(() => {
|
||||
if (existingAppointment?.scheduledAt) return existingAppointment.scheduledAt.split('T')[0];
|
||||
return '';
|
||||
});
|
||||
const [timeSlot, setTimeSlot] = useState<string | null>(() => {
|
||||
if (existingAppointment?.scheduledAt) {
|
||||
const dt = new Date(existingAppointment.scheduledAt);
|
||||
return `${dt.getHours().toString().padStart(2, '0')}:${dt.getMinutes().toString().padStart(2, '0')}`;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const [chiefComplaint, setChiefComplaint] = useState(existingAppointment?.reasonForVisit ?? '');
|
||||
const [isReturning, setIsReturning] = useState(false);
|
||||
const [source, setSource] = useState('Inbound Call');
|
||||
const [agentNotes, setAgentNotes] = useState('');
|
||||
|
||||
// Availability state
|
||||
const [bookedSlots, setBookedSlots] = useState<string[]>([]);
|
||||
const [loadingSlots, setLoadingSlots] = useState(false);
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch doctors on mount
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
apiClient.graphql<{ doctors: { edges: Array<{ node: any }> } }>(
|
||||
`{ doctors(first: 50) { edges { node {
|
||||
id name fullName { firstName lastName } department clinic { id name clinicName }
|
||||
} } } }`,
|
||||
).then(data => {
|
||||
const docs = data.doctors.edges.map(e => ({
|
||||
id: e.node.id,
|
||||
name: e.node.fullName
|
||||
? `Dr. ${e.node.fullName.firstName} ${e.node.fullName.lastName}`.trim()
|
||||
: e.node.name,
|
||||
department: e.node.department ?? '',
|
||||
clinic: e.node.clinic?.clinicName ?? e.node.clinic?.name ?? '',
|
||||
}));
|
||||
setDoctors(docs);
|
||||
}).catch(() => {});
|
||||
}, [isOpen]);
|
||||
|
||||
// Fetch booked slots when doctor + date selected
|
||||
useEffect(() => {
|
||||
if (!doctor || !date) {
|
||||
setBookedSlots([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingSlots(true);
|
||||
apiClient.graphql<{ appointments: { edges: Array<{ node: any }> } }>(
|
||||
`{ appointments(filter: {
|
||||
doctorId: { eq: "${doctor}" },
|
||||
scheduledAt: { gte: "${date}T00:00:00", lte: "${date}T23:59:59" }
|
||||
}) { edges { node { id scheduledAt durationMin appointmentStatus } } } }`,
|
||||
).then(data => {
|
||||
// Filter out cancelled/completed appointments client-side
|
||||
const activeAppointments = data.appointments.edges.filter(e => {
|
||||
const status = e.node.appointmentStatus;
|
||||
return status !== 'CANCELLED' && status !== 'COMPLETED' && status !== 'NO_SHOW';
|
||||
});
|
||||
const slots = activeAppointments.map(e => {
|
||||
const dt = new Date(e.node.scheduledAt);
|
||||
return `${dt.getHours().toString().padStart(2, '0')}:${dt.getMinutes().toString().padStart(2, '0')}`;
|
||||
});
|
||||
// In edit mode, don't block the current appointment's slot
|
||||
if (isEditMode && existingAppointment) {
|
||||
const currentDt = new Date(existingAppointment.scheduledAt);
|
||||
const currentSlot = `${currentDt.getHours().toString().padStart(2, '0')}:${currentDt.getMinutes().toString().padStart(2, '0')}`;
|
||||
setBookedSlots(slots.filter(s => s !== currentSlot));
|
||||
} else {
|
||||
setBookedSlots(slots);
|
||||
}
|
||||
}).catch(() => setBookedSlots([]))
|
||||
.finally(() => setLoadingSlots(false));
|
||||
}, [doctor, date, isEditMode, existingAppointment]);
|
||||
|
||||
// Reset doctor when department changes
|
||||
useEffect(() => {
|
||||
setDoctor(null);
|
||||
setTimeSlot(null);
|
||||
}, [department]);
|
||||
|
||||
// Reset time slot when doctor or date changes
|
||||
useEffect(() => {
|
||||
setTimeSlot(null);
|
||||
}, [doctor, date]);
|
||||
|
||||
// Derive department and doctor lists from fetched data
|
||||
const departmentItems = [...new Set(doctors.map(d => d.department).filter(Boolean))]
|
||||
.map(dept => ({ id: dept, label: formatDeptLabel(dept) }));
|
||||
|
||||
const filteredDoctors = department
|
||||
? doctors.filter(d => d.department === department)
|
||||
: doctors;
|
||||
const doctorSelectItems = filteredDoctors.map(d => ({ id: d.id, label: d.name }));
|
||||
|
||||
const timeSlotSelectItems = timeSlotItems.map(slot => ({
|
||||
...slot,
|
||||
isDisabled: bookedSlots.includes(slot.id),
|
||||
label: bookedSlots.includes(slot.id) ? `${slot.label} (Booked)` : slot.label,
|
||||
}));
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!date || !timeSlot || !doctor || !department) {
|
||||
setError('Please fill in the required fields: date, time, doctor, and department.');
|
||||
@@ -100,40 +201,70 @@ export const AppointmentForm = ({
|
||||
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 selectedDoctor = doctors.find(d => d.id === doctor);
|
||||
|
||||
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 }
|
||||
if (isEditMode && existingAppointment) {
|
||||
// Update existing appointment
|
||||
await apiClient.graphql(
|
||||
`mutation UpdateAppointment($id: ID!, $data: AppointmentUpdateInput!) {
|
||||
updateAppointment(id: $id, data: $data) { id }
|
||||
}`,
|
||||
{
|
||||
id: existingAppointment.id,
|
||||
data: {
|
||||
scheduledAt,
|
||||
doctorName: selectedDoctor?.name ?? '',
|
||||
department: selectedDoctor?.department ?? '',
|
||||
doctorId: doctor,
|
||||
reasonForVisit: chiefComplaint || null,
|
||||
},
|
||||
},
|
||||
);
|
||||
notify.success('Appointment Updated');
|
||||
} else {
|
||||
// Double-check slot availability before booking
|
||||
const checkResult = await apiClient.graphql<{ appointments: { edges: Array<{ node: { appointmentStatus: string } }> } }>(
|
||||
`{ appointments(filter: {
|
||||
doctorId: { eq: "${doctor}" },
|
||||
scheduledAt: { gte: "${date}T${timeSlot}:00", lte: "${date}T${timeSlot}:00" }
|
||||
}) { edges { node { appointmentStatus } } } }`,
|
||||
);
|
||||
const activeBookings = checkResult.appointments.edges.filter(e =>
|
||||
e.node.appointmentStatus !== 'CANCELLED' && e.node.appointmentStatus !== 'NO_SHOW',
|
||||
);
|
||||
if (activeBookings.length > 0) {
|
||||
setError('This slot was just booked by someone else. Please select a different time.');
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create appointment
|
||||
await apiClient.graphql(
|
||||
`mutation CreateAppointment($data: AppointmentCreateInput!) {
|
||||
createAppointment(data: $data) { id }
|
||||
}`,
|
||||
{
|
||||
data: {
|
||||
scheduledAt,
|
||||
durationMin: 30,
|
||||
appointmentType: 'CONSULTATION',
|
||||
appointmentStatus: 'SCHEDULED',
|
||||
doctorName: selectedDoctor?.name ?? '',
|
||||
department: selectedDoctor?.department ?? '',
|
||||
doctorId: doctor,
|
||||
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: {
|
||||
@@ -141,199 +272,266 @@ export const AppointmentForm = ({
|
||||
lastContactedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
)
|
||||
.catch((err: unknown) => console.warn('Failed to update lead:', err));
|
||||
).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.');
|
||||
console.error('Failed to save appointment:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to save appointment. Please try again.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!existingAppointment) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await apiClient.graphql(
|
||||
`mutation CancelAppointment($id: ID!, $data: AppointmentUpdateInput!) {
|
||||
updateAppointment(id: $id, data: $data) { id }
|
||||
}`,
|
||||
{
|
||||
id: existingAppointment.id,
|
||||
data: { appointmentStatus: 'CANCELLED' },
|
||||
},
|
||||
);
|
||||
notify.success('Appointment Cancelled');
|
||||
onSaved?.();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to cancel appointment');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
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 className="rounded-xl border border-secondary bg-primary p-4">
|
||||
{/* Header with close button */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-brand-secondary">
|
||||
<CalendarPlus02 className="size-4 text-fg-brand-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-primary">
|
||||
{isEditMode ? 'Edit Appointment' : 'Book Appointment'}
|
||||
</h3>
|
||||
<p className="text-xs text-tertiary">
|
||||
{isEditMode ? 'Modify or cancel this appointment' : 'Schedule a new patient visit'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex size-7 items-center justify-center rounded-md text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||
>
|
||||
<XClose className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form fields */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Patient Info — only for new appointments */}
|
||||
{!isEditMode && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-tertiary">
|
||||
Patient Information
|
||||
</span>
|
||||
</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="Patient Name"
|
||||
placeholder="Full name"
|
||||
value={patientName}
|
||||
onChange={setPatientName}
|
||||
label="Phone"
|
||||
placeholder="Phone number"
|
||||
value={patientPhone}
|
||||
onChange={setPatientPhone}
|
||||
/>
|
||||
|
||||
<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}
|
||||
label="Age"
|
||||
placeholder="Age"
|
||||
type="number"
|
||||
value={age}
|
||||
onChange={setAge}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
{!isEditMode && (
|
||||
<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={doctors.length === 0 ? 'Loading...' : 'Select department'}
|
||||
items={departmentItems}
|
||||
selectedKey={department}
|
||||
onSelectionChange={(key) => setDepartment(key as string)}
|
||||
isRequired
|
||||
isDisabled={doctors.length === 0}
|
||||
>
|
||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
label="Doctor"
|
||||
placeholder={!department ? 'Select department first' : 'Select doctor'}
|
||||
items={doctorSelectItems}
|
||||
selectedKey={doctor}
|
||||
onSelectionChange={(key) => setDoctor(key as string)}
|
||||
isRequired
|
||||
isDisabled={!department}
|
||||
>
|
||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
label="Date"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={setDate}
|
||||
isRequired
|
||||
/>
|
||||
|
||||
{/* Time slot grid */}
|
||||
{doctor && date && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-semibold text-secondary">
|
||||
{loadingSlots ? 'Checking availability...' : 'Available Slots'}
|
||||
</span>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{timeSlotSelectItems.map(slot => {
|
||||
const isBooked = slot.isDisabled;
|
||||
const isSelected = timeSlot === slot.id;
|
||||
return (
|
||||
<button
|
||||
key={slot.id}
|
||||
type="button"
|
||||
disabled={isBooked}
|
||||
onClick={() => setTimeSlot(slot.id)}
|
||||
className={cx(
|
||||
'rounded-lg py-2 px-1 text-xs font-medium transition duration-100 ease-linear',
|
||||
isBooked
|
||||
? 'cursor-not-allowed bg-disabled text-disabled line-through'
|
||||
: isSelected
|
||||
? 'bg-brand-solid text-white ring-2 ring-brand'
|
||||
: 'cursor-pointer bg-secondary text-secondary hover:bg-secondary_hover hover:text-secondary_hover',
|
||||
)}
|
||||
>
|
||||
{timeSlotItems.find(t => t.id === slot.id)?.label ?? slot.id}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SlideoutMenu.Footer>
|
||||
</>
|
||||
)}
|
||||
</SlideoutMenu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!doctor || !date ? (
|
||||
<p className="text-xs text-tertiary">Select a doctor and date to see available time slots</p>
|
||||
) : null}
|
||||
|
||||
<TextArea
|
||||
label="Chief Complaint"
|
||||
placeholder="Describe the reason for visit..."
|
||||
value={chiefComplaint}
|
||||
onChange={setChiefComplaint}
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
{/* Additional Info — only for new appointments */}
|
||||
{!isEditMode && (
|
||||
<>
|
||||
<div className="border-t border-secondary" />
|
||||
|
||||
<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>
|
||||
|
||||
{/* Footer buttons */}
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-secondary">
|
||||
<div>
|
||||
{isEditMode && (
|
||||
<Button size="sm" color="primary-destructive" isLoading={isSaving} onClick={handleCancel}>
|
||||
Cancel Appointment
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button size="sm" color="secondary" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}>
|
||||
{isSaving ? 'Saving...' : isEditMode ? 'Update Appointment' : 'Book Appointment'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user