import React, { useEffect, useRef, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { PlusIcon, ChevronRightIcon, ChevronLeftIcon, CalendarDaysIcon, AdjustmentsHorizontalIcon, } 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 Pagination from '../components/ui/Pagination'; import AppointmentFiltersModal, { applyAppointmentFilters, EMPTY_FILTERS } from '../components/AppointmentFiltersModal'; import type { AppointmentFilters } from '../components/AppointmentFiltersModal'; import PersianCalendar from '../components/ui/PersianCalendar'; import SearchableSelect from '../components/ui/SearchableSelect'; // اجزای طرح نوبت‌های tauri import TurnsStatInfo from '../components/appointments/TurnsStatInfo'; import TurnsViewToggle from '../components/appointments/TurnsViewToggle'; import type { TurnsViewMode } from '../components/appointments/TurnsViewToggle'; import DoctorTabs from '../components/appointments/DoctorTabs'; import TurnsTimeline from '../components/appointments/TurnsTimeline'; import TurnsTable from '../components/appointments/TurnsTable'; import { CANCELLED_STATUSES } from '../components/appointments/turnStatus'; import type { TimelineSlot } from '../components/appointments/types'; const EMPTY_ARR: Appointment[] = []; // ───────────────────────────────────────────────────────────────────────────── // Date Navigator (طرح tauri — ناوبری روزانه) // ───────────────────────────────────────────────────────────────────────────── const WEEK_DAYS_FA = ['یک‌شنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنج‌شنبه', 'جمعه', 'شنبه']; function getPersianWeekDay(gregorianDate: string): string { return WEEK_DAYS_FA[new Date(gregorianDate + 'T12:00:00').getDay()]; } 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)', }; 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)} /> )}
); } // ───────────────────────────────────────────────────────────────────────────── // Quick booking 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 — «نوبت‌ ها» (طرح tauri) // ───────────────────────────────────────────────────────────────────────────── interface TodayStats { total: number; completed: number; waiting: number; cancelled: number; } export default function AppointmentsPage() { const navigate = useNavigate(); 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 isRepresentation = primaryRole === 'representation'; const today = new Date().toISOString().slice(0, 10); const [selectedDate, setSelectedDate] = useState(today); const [viewMode, setViewMode] = useState('timeline'); const [selectedDoctorUuid, setSelectedDoctorUuid] = useState(isDoctor && dbUuid ? dbUuid : ''); const [bookingSlot, setBookingSlot] = useState(null); const [filtersOpen, setFiltersOpen] = useState(false); const [filters, setFilters] = useState(EMPTY_FILTERS); const qc = useQueryClient(); // ── Today stats const statsQuery = useQuery>({ queryKey: ['appt-today-stats', selectedDate, isAdmin], queryFn: () => api.get( isAdmin ? `/api/v1/admin/appointments/today-stats?date=${selectedDate}` : `/api/v1/my/appointments/today-stats?date=${selectedDate}` ), }); const stats = statsQuery.data?.data ?? { total: 0, completed: 0, waiting: 0, cancelled: 0 }; // ── Appointments query const apptEndpoint = isAdmin ? '/api/v1/admin/appointments' : isRepresentation ? '/api/v1/representation/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; const filteredAppointments = applyAppointmentFilters(appointments, filters); const filtersActive = filters !== EMPTY_FILTERS && JSON.stringify(filters) !== JSON.stringify(EMPTY_FILTERS); // Table pagination — client-side (full day stays loaded for timeline + doctor tabs). const TABLE_PAGE_SIZE = 20; const [tablePage, setTablePage] = useState(1); useEffect(() => { setTablePage(1); }, [selectedDate, selectedDoctorUuid, filters]); const pagedAppointments = filteredAppointments.slice((tablePage - 1) * TABLE_PAGE_SIZE, tablePage * TABLE_PAGE_SIZE); // ── Clinic doctors (authoritative list for tabs) 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 ?? []; const doctors = React.useMemo(() => { const map = new Map(); clinicDoctorsList.forEach(d => map.set(d.uuid, d.name)); 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 || isAdmin) && doctors.length >= 2; const showDoctorCol = isAdmin || (isClinic && !selectedDoctorUuid); // ── Slots query (timeline) 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 === 'timeline' && !!selectedDoctorUuid, }); // ── Merge sessions + appointments → flat timeline slots const timelineSlots: TimelineSlot[] = React.useMemo(() => { if (viewMode !== 'timeline') return []; const rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? []; 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); } }); const out: TimelineSlot[] = []; rawSessions.forEach((session: any) => { (session.slots as any[]).forEach((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); out.push({ 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, }); }); }); return out; }, [viewMode, slotsQuery.data, appointments]); // ── Slot click → quick booking modal function handleSlotClick(slot: TimelineSlot) { if (isRepresentation) return; 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, }); } function openDetail(a: Appointment) { navigate(`/admin/appointments/${a.uuid}`); } return (
{/* عنوان */}

نوبت‌ ها

{/* نوار آمار */} {/* تب دکترها */} {showDoctorTabs && ( )} {/* کارت اصلی */}
{/* نوار ابزار */}
{/* افزودن نوبت → صفحهٔ کامل */} {!isRepresentation && ( )} setFilters(f => ({ ...f, staffUuid: v }))} /> {/* انتخاب پزشک (admin / clinic) */} {!isDoctor && (
({ value: d.uuid, label: d.name }))} value={selectedDoctorUuid || null} onChange={(v) => setSelectedDoctorUuid(v ? String(v) : '')} placeholder="انتخاب پزشک..." isClearable height={36} />
)}
{/* محتوا */}
{viewMode === 'table' ? ( <> {filteredAppointments.length > TABLE_PAGE_SIZE && (
)} ) : ( <> {!selectedDoctorUuid ? (
برای نمایش زمانبندی، ابتدا یک پزشک انتخاب کنید
) : ( )} )}
{/* مودال ثبت سریع نوبت */} {bookingSlot && ( setBookingSlot(null)} onSuccess={() => { qc.invalidateQueries({ queryKey: apptQueryKey }); qc.invalidateQueries({ queryKey: slotsQueryKey }); qc.invalidateQueries({ queryKey: ['appt-today-stats', selectedDate] }); }} /> )} {/* مودال فیلترها */} {filtersOpen && ( setFiltersOpen(false)} /> )}
); } /** فیلتر پرسنل نوار ابزار — ردیف‌های بارگذاری‌شدهٔ روز را بر اساس پرسنل فیلتر می‌کند. */ function StaffFilterSelect({ value, onChange }: { value: string; onChange: (v: string) => void }) { const staffQ = useQuery>({ queryKey: ['staff-list'], queryFn: () => api.get('/api/v1/staff'), }); return ( ); }