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:
2026-03-20 20:24:58 +05:30
parent f13decefc4
commit 99bca1e008
7 changed files with 1328 additions and 284 deletions

View File

@@ -1,7 +1,7 @@
import type { FC, HTMLAttributes } from "react";
import { useCallback, useEffect, useRef } from "react";
import type { Placement } from "@react-types/overlays";
import { ChevronSelectorVertical, LogOut01, Settings01, User01 } from "@untitledui/icons";
import { ChevronSelectorVertical, LogOut01, PhoneCall01, Settings01, User01 } from "@untitledui/icons";
import { useFocusManager } from "react-aria";
import type { DialogProps as AriaDialogProps } from "react-aria-components";
import { Button as AriaButton, Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Popover as AriaPopover } from "react-aria-components";
@@ -26,8 +26,9 @@ type NavAccountType = {
export const NavAccountMenu = ({
className,
onSignOut,
onForceReady,
...dialogProps
}: AriaDialogProps & { className?: string; accounts?: NavAccountType[]; selectedAccountId?: string; onSignOut?: () => void }) => {
}: AriaDialogProps & { className?: string; accounts?: NavAccountType[]; selectedAccountId?: string; onSignOut?: () => void; onForceReady?: () => void }) => {
const focusManager = useFocusManager();
const dialogRef = useRef<HTMLDivElement>(null);
@@ -68,6 +69,7 @@ export const NavAccountMenu = ({
<div className="flex flex-col gap-0.5 py-1.5">
<NavAccountCardMenuItem label="View profile" icon={User01} shortcut="⌘K->P" />
<NavAccountCardMenuItem label="Account settings" icon={Settings01} shortcut="⌘S" />
<NavAccountCardMenuItem label="Force Ready" icon={PhoneCall01} onClick={onForceReady} />
</div>
</div>
@@ -114,11 +116,13 @@ export const NavAccountCard = ({
selectedAccountId,
items = [],
onSignOut,
onForceReady,
}: {
popoverPlacement?: Placement;
selectedAccountId?: string;
items?: NavAccountType[];
onSignOut?: () => void;
onForceReady?: () => void;
}) => {
const triggerRef = useRef<HTMLDivElement>(null);
const isDesktop = useBreakpoint("lg");
@@ -159,7 +163,7 @@ export const NavAccountCard = ({
)
}
>
<NavAccountMenu selectedAccountId={selectedAccountId} accounts={items} onSignOut={onSignOut} />
<NavAccountMenu selectedAccountId={selectedAccountId} accounts={items} onSignOut={onSignOut} onForceReady={onForceReady} />
</AriaPopover>
</AriaDialogTrigger>
</div>

View File

@@ -38,6 +38,7 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
const [postCallStage, setPostCallStage] = useState<PostCallStage | null>(null);
const [savedDisposition, setSavedDisposition] = useState<CallDisposition | null>(null);
const [appointmentOpen, setAppointmentOpen] = useState(false);
const [appointmentBookedDuringCall, setAppointmentBookedDuringCall] = useState(false);
// Capture direction at mount — survives through disposition stage
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
@@ -94,7 +95,12 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
const handleAppointmentSaved = () => {
setAppointmentOpen(false);
notify.success('Appointment Booked', 'Payment link will be sent to the patient');
setPostCallStage('done');
// If booked during active call, don't skip to 'done' — wait for disposition after call ends
if (callState === 'active') {
setAppointmentBookedDuringCall(true);
} else {
setPostCallStage('done');
}
};
const handleReset = () => {
@@ -157,52 +163,8 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
);
}
// Active call
if (callState === 'active') {
return (
<div className="rounded-xl border border-brand bg-primary p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-full bg-success-solid">
<FontAwesomeIcon icon={faPhone} className="size-4 text-white" />
</div>
<div>
<p className="text-sm font-bold text-primary">{fullName || phoneDisplay}</p>
{fullName && <p className="text-xs text-tertiary">{phoneDisplay}</p>}
</div>
</div>
<Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge>
</div>
<div className="mt-3 flex gap-2">
<Button size="sm" color={isMuted ? 'primary-destructive' : 'secondary'}
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={isMuted ? faMicrophoneSlash : faMicrophone} className={className} />}
onClick={toggleMute}>{isMuted ? 'Unmute' : 'Mute'}</Button>
<Button size="sm" color={isOnHold ? 'primary-destructive' : 'secondary'}
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={isOnHold ? faPlay : faPause} className={className} />}
onClick={toggleHold}>{isOnHold ? 'Resume' : 'Hold'}</Button>
<Button size="sm" color="secondary"
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faCalendarPlus} className={className} />}
onClick={() => setAppointmentOpen(true)}>Book Appt</Button>
<Button size="sm" color="primary-destructive" className="ml-auto"
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faPhoneHangup} className={className} />}
onClick={() => { hangup(); setPostCallStage('disposition'); }}>End</Button>
</div>
{/* Appointment form accessible during call */}
<AppointmentForm
isOpen={appointmentOpen}
onOpenChange={setAppointmentOpen}
callerNumber={callerPhone}
leadName={fullName || null}
leadId={lead?.id ?? null}
onSaved={handleAppointmentSaved}
/>
</div>
);
}
// Call ended — show disposition
if (callState === 'ended' || callState === 'failed' || postCallStage !== null) {
// Post-call flow takes priority over active state (handles race between hangup + SIP ended event)
if (postCallStage !== null || callState === 'ended' || callState === 'failed') {
// Done state
if (postCallStage === 'done') {
return (
@@ -255,7 +217,51 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
<p className="text-xs text-tertiary">{formatDuration(callDuration)} · Log this call</p>
</div>
</div>
<DispositionForm onSubmit={handleDisposition} />
<DispositionForm onSubmit={handleDisposition} defaultDisposition={appointmentBookedDuringCall ? 'APPOINTMENT_BOOKED' : null} />
</div>
);
}
// Active call
if (callState === 'active') {
return (
<div className="rounded-xl border border-brand bg-primary p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-full bg-success-solid">
<FontAwesomeIcon icon={faPhone} className="size-4 text-white" />
</div>
<div>
<p className="text-sm font-bold text-primary">{fullName || phoneDisplay}</p>
{fullName && <p className="text-xs text-tertiary">{phoneDisplay}</p>}
</div>
</div>
<Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge>
</div>
<div className="mt-3 flex gap-2">
<Button size="sm" color={isMuted ? 'primary-destructive' : 'secondary'}
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={isMuted ? faMicrophoneSlash : faMicrophone} className={className} />}
onClick={toggleMute}>{isMuted ? 'Unmute' : 'Mute'}</Button>
<Button size="sm" color={isOnHold ? 'primary-destructive' : 'secondary'}
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={isOnHold ? faPlay : faPause} className={className} />}
onClick={toggleHold}>{isOnHold ? 'Resume' : 'Hold'}</Button>
<Button size="sm" color="secondary"
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faCalendarPlus} className={className} />}
onClick={() => setAppointmentOpen(true)}>Book Appt</Button>
<Button size="sm" color="primary-destructive" className="ml-auto"
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faPhoneHangup} className={className} />}
onClick={() => { hangup(); setPostCallStage('disposition'); }}>End</Button>
</div>
{/* Appointment form accessible during call */}
<AppointmentForm
isOpen={appointmentOpen}
onOpenChange={setAppointmentOpen}
callerNumber={callerPhone}
leadName={fullName || null}
leadId={lead?.id ?? null}
onSaved={handleAppointmentSaved}
/>
</div>
);
}

View File

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

View File

@@ -5,6 +5,7 @@ import { cx } from '@/utils/cx';
interface DispositionFormProps {
onSubmit: (disposition: CallDisposition, notes: string) => void;
defaultDisposition?: CallDisposition | null;
}
const dispositionOptions: Array<{
@@ -51,8 +52,8 @@ const dispositionOptions: Array<{
},
];
export const DispositionForm = ({ onSubmit }: DispositionFormProps) => {
const [selected, setSelected] = useState<CallDisposition | null>(null);
export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFormProps) => {
const [selected, setSelected] = useState<CallDisposition | null>(defaultDisposition ?? null);
const [notes, setNotes] = useState('');
const handleSubmit = () => {

View File

@@ -21,6 +21,8 @@ import { NavAccountCard } from "@/components/application/app-navigation/base-com
import { NavItemBase } from "@/components/application/app-navigation/base-components/nav-item";
import type { NavItemType } from "@/components/application/app-navigation/config";
import { Avatar } from "@/components/base/avatar/avatar";
import { apiClient } from "@/lib/api-client";
import { notify } from "@/lib/toast";
import { useAuth } from "@/providers/auth-provider";
import { sidebarCollapsedAtom } from "@/state/sidebar-state";
import { cx } from "@/utils/cx";
@@ -127,6 +129,15 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
navigate('/login');
};
const handleForceReady = async () => {
try {
await apiClient.post('/api/ozonetel/agent-ready', {});
notify.success('Agent Ready', 'Agent state has been reset to Ready');
} catch {
notify.error('Force Ready Failed', 'Could not reset agent state');
}
};
const navSections = getNavSections(user.role);
const content = (
@@ -219,6 +230,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
}]}
selectedAccountId="current"
onSignOut={handleSignOut}
onForceReady={handleForceReady}
/>
)}
</div>