feat: enhance appointment scheduling with session management and availability checks

This commit is contained in:
hamed
2026-06-11 19:56:27 +03:30
parent 0b31eb7812
commit 5a7df22eda
5 changed files with 310 additions and 145 deletions
@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ChevronDownIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
@@ -28,16 +29,39 @@ interface Props {
export default function AppointmentStatusDropdown({ uuid, currentStatus, version, queryKey }: Props) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const qc = useQueryClient();
useEffect(() => {
if (!open) return;
function handler(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
const target = e.target as Node;
if (!btnRef.current?.contains(target) && !menuRef.current?.contains(target)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
}, [open]);
// Reposition on scroll/resize while open
useEffect(() => {
if (!open) return;
function reposition() {
if (btnRef.current) {
const rect = btnRef.current.getBoundingClientRect();
setMenuPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right });
}
}
window.addEventListener('scroll', reposition, true);
window.addEventListener('resize', reposition);
return () => {
window.removeEventListener('scroll', reposition, true);
window.removeEventListener('resize', reposition);
};
}, [open]);
const mutation = useMutation({
mutationFn: (newStatus: string) =>
@@ -52,10 +76,19 @@ export default function AppointmentStatusDropdown({ uuid, currentStatus, version
const meta = STATUS_META[currentStatus] ?? { label: currentStatus, color: '#9ca3af' };
const nextStatuses = TRANSITIONS[currentStatus] ?? [];
function handleToggle() {
if (!open && btnRef.current) {
const rect = btnRef.current.getBoundingClientRect();
setMenuPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right });
}
setOpen(o => !o);
}
return (
<div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
<div style={{ position: 'relative', display: 'inline-block' }}>
<button
onClick={() => setOpen(o => !o)}
ref={btnRef}
onClick={handleToggle}
disabled={nextStatuses.length === 0}
style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
@@ -74,16 +107,19 @@ export default function AppointmentStatusDropdown({ uuid, currentStatus, version
)}
</button>
{open && nextStatuses.length > 0 && (
<div style={{
position: 'absolute', top: '100%', right: 0, marginTop: 4, zIndex: 100,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 'var(--r)', boxShadow: 'var(--shadow-lg)',
minWidth: 160, overflow: 'hidden',
}}>
{open && menuPos && nextStatuses.length > 0 && ReactDOM.createPortal(
<div
ref={menuRef}
style={{
position: 'fixed', top: menuPos.top, right: menuPos.right,
zIndex: 9000,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 'var(--r)', boxShadow: 'var(--shadow-lg)',
minWidth: 160, overflow: 'hidden',
}}
>
{nextStatuses.map(s => {
const sm = STATUS_META[s] ?? { label: s, color: '#9ca3af' };
const isCurrent = s === currentStatus;
return (
<button
key={s}
@@ -93,7 +129,7 @@ export default function AppointmentStatusDropdown({ uuid, currentStatus, version
display: 'flex', alignItems: 'center', gap: 8,
width: '100%', padding: '8px 12px', fontSize: 13,
background: 'transparent', border: 'none', cursor: 'pointer',
color: sm.color, fontWeight: isCurrent ? 700 : 400,
color: sm.color, fontWeight: 400,
textAlign: 'right',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--surface-2)')}
@@ -101,14 +137,14 @@ export default function AppointmentStatusDropdown({ uuid, currentStatus, version
>
<span style={{
width: 10, height: 10, borderRadius: '50%', flexShrink: 0,
background: isCurrent ? sm.color : 'transparent',
border: `2px solid ${sm.color}`,
}} />
{sm.label}
</button>
);
})}
</div>
</div>,
document.body
)}
</div>
);
+132 -61
View File
@@ -259,11 +259,71 @@ interface SlotItem {
end_time: string;
is_available: boolean;
appointment: Appointment | null;
cancelled_appointment: Appointment | null;
}
interface SessionGroup {
start_time: string;
end_time: string;
slots: SlotItem[];
}
function CancelledBadge({ appt }: { appt: Appointment }) {
const sm = statusMeta(appt.status);
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px',
borderRadius: 'var(--r-sm)', background: '#f9fafb',
border: '1px dashed #d1d5db', fontSize: 12, color: '#6b7280',
marginBottom: 4,
}}>
<span style={{ fontSize: 14, lineHeight: 1 }}></span>
<span style={{
padding: '1px 6px', borderRadius: 99, fontSize: 11, fontWeight: 700,
background: `${sm.color}15`, color: sm.color, whiteSpace: 'nowrap',
}}>{sm.label}</span>
<span style={{ fontWeight: 600, color: '#374151' }}>{appt.patient_name || '—'}</span>
<span style={{ direction: 'ltr', color: '#9ca3af' }}>{appt.patient_mobile}</span>
</div>
);
}
function SlotCard({ slot, queryKey, onBook }: { slot: SlotItem; queryKey: unknown[]; onBook: (slot: SlotItem) => void }) {
if (slot.is_available) {
if (!slot.is_available && slot.appointment) {
const a = slot.appointment;
const sm = statusMeta(a.status);
return (
<div style={{
border: `1.5px solid ${sm.color}40`, borderRadius: 'var(--r)',
background: sm.bg, overflow: 'hidden',
}}>
<div style={{
padding: '8px 12px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
borderBottom: `1px solid ${sm.color}20`,
}}>
<AppointmentStatusDropdown uuid={a.uuid} currentStatus={a.status} version={a.version} queryKey={queryKey} />
<div style={{ fontSize: 12, color: 'var(--text-3)', fontWeight: 600 }}>
{slot.start_time} {slot.end_time}
</div>
</div>
<div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<UserCircleIcon style={{ width: 16, height: 16, color: 'var(--text-3)' }} />
<span style={{ fontWeight: 600 }}>{a.patient_name || '—'}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--text-2)' }}>
<PhoneIcon style={{ width: 14, height: 14 }} />
<span style={{ direction: 'ltr' }}>{a.patient_mobile}</span>
</div>
</div>
</div>
);
}
// Available slot — possibly with cancelled history
return (
<div style={{ display: 'flex', flexDirection: 'column' }}>
{slot.cancelled_appointment && <CancelledBadge appt={slot.cancelled_appointment} />}
<div
onClick={() => onBook(slot)}
style={{
@@ -277,46 +337,39 @@ function SlotCard({ slot, queryKey, onBook }: { slot: SlotItem; queryKey: unknow
>
<span style={{ fontSize: 18 }}></span> نوبت جدید
</div>
);
}
</div>
);
}
const a = slot.appointment!;
const sm = statusMeta(a.status);
function SessionDivider({ startTime, endTime, count }: { startTime: string; endTime: string; count: number }) {
const hour = parseInt(startTime.split(':')[0], 10);
const isAm = hour < 12;
const icon = isAm ? '🌅' : '🌆';
const label = isAm ? 'صبح' : 'عصر';
const color = isAm ? '#f59e0b' : '#6366f1';
return (
<div style={{
border: `1.5px solid ${sm.color}40`, borderRadius: 'var(--r)',
background: sm.bg, overflow: 'hidden',
}}>
<div style={{
padding: '8px 12px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
borderBottom: `1px solid ${sm.color}20`,
}}>
<AppointmentStatusDropdown uuid={a.uuid} currentStatus={a.status} version={a.version} queryKey={queryKey} />
<div style={{ fontSize: 12, color: 'var(--text-3)', fontWeight: 600 }}>
{slot.start_time} {slot.end_time}
</div>
</div>
<div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<UserCircleIcon style={{ width: 16, height: 16, color: 'var(--text-3)' }} />
<span style={{ fontWeight: 600 }}>{a.patient_name || '—'}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--text-2)' }}>
<PhoneIcon style={{ width: 14, height: 14 }} />
<span style={{ direction: 'ltr' }}>{a.patient_mobile}</span>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 2px' }}>
<span style={{ fontSize: 15 }}>{icon}</span>
<span style={{ fontWeight: 700, fontSize: 13, color }}>{label}</span>
<span style={{ fontSize: 12, color: 'var(--text-3)', fontWeight: 500 }}>
{startTime} {endTime}
</span>
<span style={{
background: `${color}18`, borderRadius: 99, padding: '1px 8px',
fontSize: 11, color, fontWeight: 600,
}}>{count} نوبت</span>
<div style={{ flex: 1, height: 1, background: 'var(--border)' }} />
</div>
);
}
function ScheduleView({
slots, loading, queryKey, onBook,
sessions, loading, queryKey, onBook,
}: {
slots: SlotItem[]; loading: boolean; queryKey: unknown[]; onBook: (s: SlotItem) => void;
sessions: SessionGroup[]; loading: boolean; queryKey: unknown[]; onBook: (s: SlotItem) => void;
}) {
if (loading) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>;
if (!slots.length) return (
if (!sessions.length) return (
<div style={{ padding: 40, textAlign: 'center' }}>
<div style={{ fontSize: 32, marginBottom: 8 }}>🏖</div>
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text)' }}>این روز تعطیل است</div>
@@ -326,16 +379,21 @@ function ScheduleView({
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '4px 0' }}>
{slots.map((slot, i) => (
<div key={i} style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'center' }}>
<SlotCard slot={slot} queryKey={queryKey} onBook={onBook} />
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2,
fontSize: 12, color: 'var(--text-3)', fontWeight: 600, minWidth: 48,
}}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: 'var(--text-3)', display: 'block' }} />
{slot.start_time}
</div>
{sessions.map((session, si) => (
<div key={si} style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: si > 0 ? 16 : 0 }}>
<SessionDivider startTime={session.start_time} endTime={session.end_time} count={session.slots.length} />
{session.slots.map((slot, i) => (
<div key={`${slot.start}-${i}`} style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'center' }}>
<SlotCard slot={slot} queryKey={queryKey} onBook={onBook} />
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2,
fontSize: 12, color: 'var(--text-3)', fontWeight: 600, minWidth: 48,
}}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: 'var(--text-3)', display: 'block' }} />
{slot.start_time}
</div>
</div>
))}
</div>
))}
</div>
@@ -452,7 +510,7 @@ export default function AppointmentsPage() {
const today = new Date().toISOString().slice(0, 10);
const [selectedDate, setSelectedDate] = useState(today);
const [viewMode, setViewMode] = useState<'table' | 'schedule'>('table');
const [viewMode, setViewMode] = useState<'table' | 'schedule'>('schedule');
const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>(isDoctor && dbUuid ? dbUuid : '');
const [bookingSlot, setBookingSlot] = useState<BookingSlot | null>(null);
const [bookingHint, setBookingHint] = useState(false);
@@ -490,29 +548,42 @@ export default function AppointmentsPage() {
enabled: viewMode === 'schedule' && !!selectedDoctorUuid,
});
// ── Merge slots + appointments for schedule view
const mergedSlots: SlotItem[] = React.useMemo(() => {
// ── Merge sessions + appointments for schedule view
const mergedSessions: SessionGroup[] = React.useMemo(() => {
if (viewMode !== 'schedule') return [];
const rawSlots: any[] = (slotsQuery.data?.data as any)?.slots ?? [];
const apptByStart = new Map<number, Appointment>();
const rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? [];
const CANCELLED_STATUSES = new Set(['cancelled_by_doctor', 'cancelled_by_user', 'cancelled_by_admin', 'auto_cancel_unpaid', 'no_show', 'expired']);
const activeByStart = new Map<number, Appointment>();
const cancelledByStart = new Map<number, Appointment>();
appointments.forEach(a => {
const key = typeof a.slot_start === 'number' ? a.slot_start : parseInt(String(a.slot_start), 10);
apptByStart.set(key, a);
if (CANCELLED_STATUSES.has(a.status)) {
const existing = cancelledByStart.get(key);
if (!existing || a.created_at > existing.created_at) cancelledByStart.set(key, a);
} else {
activeByStart.set(key, a);
}
});
return rawSlots.map((s: any) => {
const slotStart = typeof s.start === 'number' ? s.start : parseInt(String(s.start), 10);
const slotEnd = typeof s.end === 'number' ? s.end : parseInt(String(s.end), 10);
const appt = apptByStart.get(slotStart) ?? null;
return {
start: slotStart,
end: slotEnd,
start_time: s.start_time ?? new Date(slotStart * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
end_time: s.end_time ?? new Date(slotEnd * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
is_available: !appt,
appointment: appt,
};
});
return rawSessions.map((session: any) => ({
start_time: session.start_time as string,
end_time: session.end_time as string,
slots: (session.slots as any[]).map((s: any) => {
const slotStart = typeof s.start === 'number' ? s.start : parseInt(String(s.start), 10);
const slotEnd = typeof s.end === 'number' ? s.end : parseInt(String(s.end), 10);
return {
start: slotStart,
end: slotEnd,
start_time: s.start_time ?? new Date(slotStart * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
end_time: s.end_time ?? new Date(slotEnd * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
is_available: s.is_available as boolean,
appointment: activeByStart.get(slotStart) ?? null,
cancelled_appointment: cancelledByStart.get(slotStart) ?? null,
};
}),
}));
}, [viewMode, slotsQuery.data, appointments]);
// ── Handle slot click → open booking modal
@@ -662,7 +733,7 @@ export default function AppointmentsPage() {
</div>
)}
<ScheduleView
slots={mergedSlots}
sessions={mergedSessions}
loading={apptQuery.isLoading || slotsQuery.isLoading}
queryKey={apptQueryKey}
onBook={handleSlotClick}