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'; // ───────────────────────────────────────────────────────────────────────────── // 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() { return ( {[8,14,20,26].map(x => [8,14,20,26].map(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 }: { date: string }) { const { data } = useQuery>({ queryKey: ['appt-today-stats', date], queryFn: () => api.get(`/api/v1/admin/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 // ───────────────────────────────────────────────────────────────────────────── function DateNavigator({ date, onChange }: { date: string; onChange: (d: string) => void }) { const [showCal, setShowCal] = useState(false); const ref = useRef(null); function addDays(n: number) { const d = new Date(date + 'T12:00:00'); d.setDate(d.getDate() + n); onChange(toGregorianDate(d)); } return (
{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; } function SlotCard({ slot, queryKey, onBook }: { slot: SlotItem; queryKey: unknown[]; onBook: (slot: SlotItem) => void }) { if (slot.is_available) { return (
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')} > نوبت جدید
); } const a = slot.appointment!; const sm = statusMeta(a.status); return (
{slot.start_time} — {slot.end_time}
{a.patient_name || '—'}
{a.patient_mobile}
); } function ScheduleView({ slots, loading, queryKey, onBook, }: { slots: SlotItem[]; loading: boolean; queryKey: unknown[]; onBook: (s: SlotItem) => void; }) { if (loading) return
در حال بارگذاری...
; if (!slots.length) return
هیچ slot زمانی برای این روز تنظیم نشده است
; return (
{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 qc = useQueryClient(); const mutation = useMutation({ mutationFn: () => api.post('/api/v1/admin/appointment', { doctor_uuid: slot.doctor_uuid, slot_start: slot.start, slot_end: slot.end, patient_mobile: mobile, }), onSuccess: () => { toast.success('نوبت با موفقیت ثبت شد'); onSuccess(); onClose(); }, onError: (e: any) => { const msg = e?.response?.data?.errors?.[0]?.message ?? 'خطا در ثبت نوبت'; toast.error(msg); }, }); return (
e.stopPropagation()}>
ثبت نوبت
{slot.start_time} تا {slot.end_time} — {slot.doctor_name}
setMobile(e.target.value)} placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹" style={{ width: '100%', height: 38, padding: '0 10px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 13, direction: 'ltr', boxSizing: 'border-box', }} autoFocus />
); } // ───────────────────────────────────────────────────────────────────────────── // Main Page // ───────────────────────────────────────────────────────────────────────────── export default function AppointmentsPage() { const { primaryRole, dbUuid } = useAuthStore(s => ({ primaryRole: s.primaryRole, dbUuid: 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'>('table'); const [selectedDoctorUuid, setSelectedDoctorUuid] = useState(isDoctor && dbUuid ? dbUuid : ''); const [bookingSlot, setBookingSlot] = useState(null); const qc = useQueryClient(); // ── Appointments query const apptQueryKey = ['admin-appointments', 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(`/api/v1/admin/appointments?${apptParams}`), }); const appointments: Appointment[] = apptQuery.data?.data ?? []; // ── Unique doctors from results (for clinic tabs) const doctors = React.useMemo(() => { const map = new Map(); appointments.forEach(a => { if (a.doctor_uuid && a.doctor_name) map.set(a.doctor_uuid, a.doctor_name); }); return Array.from(map.entries()).map(([uuid, name]) => ({ uuid, name })); }, [appointments]); 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 slots + appointments for schedule view const mergedSlots: SlotItem[] = React.useMemo(() => { if (viewMode !== 'schedule') return []; const rawSlots: any[] = slotsQuery.data?.data ?? []; const apptByStart = new Map(); appointments.forEach(a => apptByStart.set(a.slot_start, a)); return rawSlots.map((s: any) => { const appt = apptByStart.get(s.start) ?? null; return { start: s.start, end: s.end, start_time: s.start_time ?? new Date(s.start * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }), end_time: s.end_time ?? new Date(s.end * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }), is_available: !appt, appointment: appt, }; }); }, [viewMode, slotsQuery.data, appointments]); // ── Handle slot click → open booking modal function handleSlotClick(slot: SlotItem) { const doctorName = doctors.find(d => d.uuid === selectedDoctorUuid)?.name ?? ''; 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 */} {isAdmin && } {/* 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 && ( )}
{/* Date navigator */}
{/* Content */}
{viewMode === 'table' ? ( ) : ( )}
{/* 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', }; }