feat: enhance appointment scheduling with session management and availability checks
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user