import React, { useRef, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { PlusIcon, ChevronRightIcon, ChevronLeftIcon, CalendarDaysIcon, TableCellsIcon, ClockIcon, UserCircleIcon, PhoneIcon, } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../lib/api'; import type { PaginatedResponse, ApiResponse } from '../lib/api'; import type { Appointment } from '../types'; import { formatDate, toGregorianDate } from '../lib/utils'; import { useAuthStore } from '../stores/authStore'; import AppointmentStatusDropdown from '../components/ui/AppointmentStatusDropdown'; import PersianCalendar from '../components/ui/PersianCalendar'; import SearchableSelect from '../components/ui/SearchableSelect'; const EMPTY_ARR: Appointment[] = []; // ───────────────────────────────────────────────────────────────────────────── // Status config // ───────────────────────────────────────────────────────────────────────────── const STATUS_META: Record = { pending: { label: 'رزرو شده', color: '#3b82f6', bg: '#eff6ff' }, confirmed: { label: 'تأیید شده', color: '#22c55e', bg: '#f0fdf4' }, completed: { label: 'تکمیل شده', color: '#16a34a', bg: '#dcfce7' }, cancelled_by_doctor: { label: 'لغو پزشک', color: '#ef4444', bg: '#fef2f2' }, cancelled_by_user: { label: 'لغو بیمار', color: '#ef4444', bg: '#fef2f2' }, no_show: { label: 'غیبت', color: '#9ca3af', bg: '#f9fafb' }, expired: { label: 'منقضی', color: '#9ca3af', bg: '#f9fafb' }, }; function statusMeta(s: string) { return STATUS_META[s] ?? { label: s, color: '#9ca3af', bg: '#f9fafb' }; } // ───────────────────────────────────────────────────────────────────────────── // Stats icons (inline SVG) // ───────────────────────────────────────────────────────────────────────────── function IconTotal() { const dots: [number, number][] = []; for (const x of [10, 16, 22, 28, 34]) for (const y of [10, 16, 22, 28, 34]) dots.push([x, y]); return ( {dots.map(([x, y]) => )} ); } function IconCompleted() { return ( ); } function IconWaiting() { return ( ); } function IconCancelled() { return ( ); } // ───────────────────────────────────────────────────────────────────────────── // Stats Bar // ───────────────────────────────────────────────────────────────────────────── interface TodayStats { total: number; completed: number; waiting: number; cancelled: number; } function StatsBar({ date, isAdmin }: { date: string; isAdmin: boolean }) { const { data } = useQuery>({ queryKey: ['appt-today-stats', date, isAdmin], queryFn: () => api.get( isAdmin ? `/api/v1/admin/appointments/today-stats?date=${date}` : `/api/v1/my/appointments/today-stats?date=${date}` ), }); const s = data?.data ?? { total: 0, completed: 0, waiting: 0, cancelled: 0 }; const stats = [ { label: 'کل نوبت‌های امروز', value: s.total, icon: }, { label: 'نوبت‌های انجام شده', value: s.completed, icon: }, { label: 'مراجعین در انتظار', value: s.waiting, icon: }, { label: 'نوبت‌های لغو شده', value: s.cancelled, icon: }, ]; return (
{stats.map((s, i) => (
{s.icon}
{s.label}
{s.value.toLocaleString('fa-IR')}
))}
); } // ───────────────────────────────────────────────────────────────────────────── // Date Navigator // ───────────────────────────────────────────────────────────────────────────── const WEEK_DAYS_FA = ['یک‌شنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنج‌شنبه', 'جمعه', 'شنبه']; function getPersianWeekDay(gregorianDate: string): string { return WEEK_DAYS_FA[new Date(gregorianDate + 'T12:00:00').getDay()]; } function DateNavigator({ date, onChange }: { date: string; onChange: (d: string) => void }) { const [showCal, setShowCal] = useState(false); const ref = useRef(null); const weekDay = getPersianWeekDay(date); const isFriday = new Date(date + 'T12:00:00').getDay() === 5; function addDays(n: number) { const d = new Date(date + 'T12:00:00'); d.setDate(d.getDate() + n); onChange(toGregorianDate(d)); } return (
{weekDay} {formatDate(date)}
{showCal && ( { onChange(v); setShowCal(false); }} onClose={() => setShowCal(false)} /> )}
); } const navBtnSx: React.CSSProperties = { background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', height: 36, width: 36, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: 'var(--text-2)', }; // ───────────────────────────────────────────────────────────────────────────── // Table View // ───────────────────────────────────────────────────────────────────────────── function TableView({ items, loading, queryKey, showDoctor, }: { items: Appointment[]; loading: boolean; queryKey: unknown[]; showDoctor: boolean; }) { if (loading) return
در حال بارگذاری...
; if (!items.length) return
نوبتی برای این روز ثبت نشده است
; return (
{showDoctor && } {items.map((a, i) => ( {showDoctor && } ))}
ردیف نام بیمار شماره تماسپزشکشروع پایان وضعیت
{(i + 1).toLocaleString('fa-IR')}
{a.patient_name || '—'}
{a.patient_mobile}
{a.doctor_name}{a.appointment_time} {a.end_time}
); } const th: React.CSSProperties = { padding: '10px 14px', textAlign: 'right', fontWeight: 600, color: 'var(--text-2)', whiteSpace: 'nowrap' }; const td: React.CSSProperties = { padding: '10px 14px', textAlign: 'right', color: 'var(--text)', verticalAlign: 'middle' }; // ───────────────────────────────────────────────────────────────────────────── // Schedule View — Slot Card // ───────────────────────────────────────────────────────────────────────────── interface SlotItem { start: number; end: number; start_time: string; 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 (
{sm.label} {appt.appointment_time} — {appt.end_time} {appt.patient_name || '—'} {appt.patient_mobile}
); } function SlotCard({ slot, queryKey, onBook }: { slot: SlotItem; queryKey: unknown[]; onBook: (slot: SlotItem) => void }) { const isPast = slot.start < Math.floor(Date.now() / 1000); if (!slot.is_available && slot.appointment) { const a = slot.appointment; const sm = statusMeta(a.status); return (
{slot.start_time} — {slot.end_time}
{a.patient_name || '—'}
{a.patient_mobile}
); } // Available slot — possibly with cancelled history if (isPast) { return (
{slot.cancelled_appointment && }
گذشته
); } return (
{slot.cancelled_appointment && }
onBook(slot)} style={{ border: '1.5px dashed #93c5fd', borderRadius: 'var(--r)', padding: '10px 16px', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: '#3b82f6', fontSize: 13, fontWeight: 600, background: '#eff6ff', transition: 'all 0.15s', gap: 6, }} onMouseEnter={e => (e.currentTarget.style.background = '#dbeafe')} onMouseLeave={e => (e.currentTarget.style.background = '#eff6ff')} > نوبت جدید
); } 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 (
{icon} {label} {startTime} — {endTime} {count} نوبت
); } function ScheduleView({ sessions, loading, queryKey, onBook, }: { sessions: SessionGroup[]; loading: boolean; queryKey: unknown[]; onBook: (s: SlotItem) => void; }) { if (loading) return
در حال بارگذاری...
; if (!sessions.length) return (
🏖️
این روز تعطیل است
هیچ برنامه زمانبندی برای این روز تنظیم نشده است
); return (
{sessions.map((session, si) => (
0 ? 16 : 0 }}> {session.slots.map((slot, i) => (
{slot.start_time}
))}
))}
); } // ───────────────────────────────────────────────────────────────────────────── // New Appointment Modal // ───────────────────────────────────────────────────────────────────────────── interface BookingSlot { start: number; end: number; start_time: string; end_time: string; doctor_uuid: string; doctor_name: string; } function NewAppointmentModal({ slot, onClose, onSuccess, }: { slot: BookingSlot; onClose: () => void; onSuccess: () => void }) { const [mobile, setMobile] = useState(''); const [patientName, setPatientName] = useState(''); const role = useAuthStore(s => s.primaryRole); const createEndpoint = role === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/my/appointment'; const isValid = mobile.length >= 10 && patientName.trim().length >= 2; const mutation = useMutation({ mutationFn: () => api.post(createEndpoint, { doctor_uuid: slot.doctor_uuid, slot_start: slot.start, slot_end: slot.end, patient_mobile: mobile, patient_name: patientName.trim(), }), onSuccess: () => { toast.success('نوبت با موفقیت ثبت شد'); onSuccess(); onClose(); }, onError: (e: any) => { const msg = e?.response?.data?.errors?.[0]?.message ?? e?.message ?? 'خطا در ثبت نوبت'; toast.error(msg); }, }); const inputSx: React.CSSProperties = { width: '100%', height: 38, padding: '0 10px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 13, boxSizing: 'border-box', }; const labelSx: React.CSSProperties = { display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-2)', marginBottom: 6, }; return (
e.stopPropagation()}>
ثبت نوبت
{slot.start_time} تا {slot.end_time} — {slot.doctor_name}
setPatientName(e.target.value)} placeholder="مثال: علی محمدی" style={inputSx} autoFocus />
setMobile(e.target.value)} placeholder="مثال: 09123456789" style={{ ...inputSx, direction: 'ltr' }} />
); } // ───────────────────────────────────────────────────────────────────────────── // Main Page // ───────────────────────────────────────────────────────────────────────────── export default function AppointmentsPage() { const primaryRole = useAuthStore(s => s.primaryRole); const dbUuid = useAuthStore(s => s.dbUuid); const isAdmin = primaryRole === 'admin'; const isClinic = primaryRole === 'clinic'; const isDoctor = primaryRole === 'doctor'; const today = new Date().toISOString().slice(0, 10); const [selectedDate, setSelectedDate] = useState(today); const [viewMode, setViewMode] = useState<'table' | 'schedule'>('schedule'); const [selectedDoctorUuid, setSelectedDoctorUuid] = useState(isDoctor && dbUuid ? dbUuid : ''); const [bookingSlot, setBookingSlot] = useState(null); const [bookingHint, setBookingHint] = useState(false); const qc = useQueryClient(); // ── Appointments query const apptEndpoint = isAdmin ? '/api/v1/admin/appointments' : '/api/v1/my/appointments'; const apptQueryKey = ['appointments', apptEndpoint, selectedDate, selectedDoctorUuid]; const apptParams = new URLSearchParams({ date: selectedDate, limit: '500' }); if (selectedDoctorUuid) apptParams.set('doctor_uuid', selectedDoctorUuid); const apptQuery = useQuery>({ queryKey: apptQueryKey, queryFn: () => api.get(`${apptEndpoint}?${apptParams}`), }); const appointments: Appointment[] = apptQuery.data?.data ?? EMPTY_ARR; // ── Clinic: load doctors from clinic profile (not derived from appointments) const clinicDoctorsQuery = useQuery>({ queryKey: ['clinic-doctors', dbUuid], queryFn: () => api.get(`/api/v1/clinic/doctor-list/${dbUuid}`), enabled: isClinic && !!dbUuid, }); const clinicDoctorsList = clinicDoctorsQuery.data?.data?.data ?? []; // ── Unique doctors from results (for clinic tabs) + merge with clinic list const doctors = React.useMemo(() => { const map = new Map(); // first from clinic API (authoritative list) clinicDoctorsList.forEach(d => map.set(d.uuid, d.name)); // then supplement with appointment data (for admin view) appointments.forEach(a => { if (a.doctor_uuid && a.doctor_name && !map.has(a.doctor_uuid)) { map.set(a.doctor_uuid, a.doctor_name); } }); return Array.from(map.entries()).map(([uuid, name]) => ({ uuid, name })); }, [appointments, clinicDoctorsList]); const showDoctorTabs = isClinic && doctors.length >= 2; const showDoctorCol = isAdmin || (isClinic && !selectedDoctorUuid); // ── Slots query (schedule view) const slotsQueryKey = ['appt-slots', selectedDoctorUuid, selectedDate]; const slotsQuery = useQuery>({ queryKey: slotsQueryKey, queryFn: () => api.get(`/api/v1/appointment-slots?doctor_uuid=${selectedDoctorUuid}&date=${selectedDate}`), enabled: viewMode === 'schedule' && !!selectedDoctorUuid, }); // ── Merge sessions + appointments for schedule view const mergedSessions: SessionGroup[] = React.useMemo(() => { if (viewMode !== 'schedule') return []; 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(); const cancelledByStart = new Map(); appointments.forEach(a => { const key = typeof a.slot_start === 'number' ? a.slot_start : parseInt(String(a.slot_start), 10); 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 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 function handleSlotClick(slot: SlotItem) { const doctorName = doctors.find(d => d.uuid === selectedDoctorUuid)?.name ?? ''; setBookingHint(false); setBookingSlot({ start: slot.start, end: slot.end, start_time: slot.start_time, end_time: slot.end_time, doctor_uuid: selectedDoctorUuid, doctor_name: doctorName, }); } return (
{/* Stats Bar */} {/* Doctor tabs (clinic with ≥2 doctors) */} {showDoctorTabs && (
{doctors.map(d => ( ))}
)} {/* Main card */}
{/* Toolbar */}
{/* New appointment button */} {/* View toggle */}
{(['table', 'schedule'] as const).map(mode => ( ))}
{/* Doctor selector (admin / clinic) */} {!isDoctor && (
({ value: d.uuid, label: d.name }))} value={selectedDoctorUuid || null} onChange={(v) => setSelectedDoctorUuid(v ? String(v) : '')} placeholder="انتخاب پزشک..." isClearable height={36} />
)}
{/* Date navigator */}
{/* Content */}
{viewMode === 'table' ? ( ) : ( <> {bookingHint && (
👆 روی یک زمان خالی کلیک کنید تا نوبت جدید ثبت شود
)} )}
{/* New appointment modal */} {bookingSlot && ( setBookingSlot(null)} onSuccess={() => { qc.invalidateQueries({ queryKey: apptQueryKey }); qc.invalidateQueries({ queryKey: slotsQueryKey }); qc.invalidateQueries({ queryKey: ['appt-today-stats', selectedDate] }); }} /> )}
); } function tabStyle(active: boolean): React.CSSProperties { return { padding: '6px 14px', borderRadius: 'var(--r-sm)', fontSize: 13, fontWeight: 600, border: active ? '1px solid var(--primary)' : '1px solid var(--border)', background: active ? '#eff6ff' : 'var(--surface)', color: active ? 'var(--primary)' : 'var(--text-2)', cursor: 'pointer', }; }