import React, { useEffect, useRef, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { PlusIcon, ChevronRightIcon, ChevronLeftIcon, ChevronDownIcon, CalendarDaysIcon, AdjustmentsHorizontalIcon, } from '@heroicons/react/24/outline'; import { api } from '../lib/api'; import type { PaginatedResponse, ApiResponse } from '../lib/api'; import type { Appointment, ClinicResource } from '../types'; import { formatDate, toGregorianDate, todayIso, formatTime } from '../lib/utils'; import { useAuthStore } from '../stores/authStore'; import { useClinicContext } from '../hooks/useClinicContext'; 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 { useResources } from '../hooks/useResources'; import ResourceDayPanel from '../components/appointments/ResourceDayPanel'; import { useResourceBookingServices } from '../hooks/useResourceBookingServices'; import { useUrlState } from '../hooks/useUrlState'; import TurnsTimeline from '../components/appointments/TurnsTimeline'; import TurnsTable from '../components/appointments/TurnsTable'; import { usePermissions } from '../hooks/usePermissions'; import NewAppointmentModal from '../components/appointments/NewAppointmentModal'; import type { BookingSlot } from '../components/appointments/NewAppointmentModal'; import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices'; import { buildServiceTimeline } from '../components/appointments/serviceTimeline'; 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)} /> )}
); } // ───────────────────────────────────────────────────────────────────────────── // 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); // در محیط کلینیک، dbUuid شناسهٔ کلینیک است نه پزشک؛ uuid پزشک فقط از doctorUuid می‌آید. const doctorUuid = useAuthStore(s => s.doctorUuid); const clinicUuid = useClinicContext(); const scope = useAuthStore(s => s.context?.scope); const isAdmin = primaryRole === 'admin'; const isClinic = primaryRole === 'clinic'; const isDoctor = primaryRole === 'doctor'; const isRepresentation = primaryRole === 'representation'; // منشی در هر دو ساختار باید تایم‌لاین ببیند: کلینیک چندپزشکه (تب پزشکانِ // تخصیص‌یافته) و پزشک مستقل (همان یک پزشک). لیست در هر دو حالت از اندپوینتِ // احرازشدهٔ /my/clinic-doctors می‌آید که خودش هر دو سناریو را resolve می‌کند. const isSecretary = primaryRole === 'secretary'; const isClinicScopedSecretary = isSecretary && scope === 'clinic'; // مجوزهای منشی روی نوبت‌ها؛ برای owner/پزشک همیشه true. const { can } = usePermissions(); const canCreateAppt = can('appointments', 'create'); const canManageAppt = can('appointments', 'update_status'); const canCancelAppt = can('appointments', 'cancel'); const [params] = useSearchParams(); const today = todayIso(); // پس از ویرایش/ثبت، صفحه با ?date=... باز می‌شود تا همان روز نمایش داده شود. const [selectedDate, setSelectedDate] = useState(params.get('date') || today); const [viewMode, setViewMode] = useState('timeline'); const [selectedDoctorUuid, setSelectedDoctorUuid] = useState(isDoctor ? (doctorUuid ?? '') : ''); /** * تب منبع در URL می‌نشیند تا «بازگشت» و رفرش همان تب را برگردانند — همان قاعده‌ای که * `CLAUDE.md` برای وضعیت لیست‌ها می‌گذارد. (تب پزشک هنوز `useState` است؛ رفعش * refactor جداست و اینجا دست نمی‌خورد.) */ const [urlState, setUrlState] = useUrlState({ resource: '' }); const selectedResourceUuid = urlState.resource; const { resources: bookableResources } = useResources({ active: '1' }); /** * منابعی که پزشکِ انتخاب‌شده ناظرشان است. * * وقتی خودِ تب منبع فعال است `selectedDoctorUuid` خالی می‌شود، پس ناظرِ همان منبع * مبنا قرار می‌گیرد — وگرنه نوار منابع زیر پای کاربر خالی می‌شد. */ const supervisedResources = selectedDoctorUuid ? bookableResources.filter((r) => r.supervisor?.uuid === selectedDoctorUuid) : []; const [bookingResource, setBookingResource] = useState(null); const activeResource = bookableResources.find((r) => r.uuid === selectedResourceUuid) ?? null; // سرویس‌های همان منبع، نه کل کاتالوگ: منبعی که سرویسی را نمی‌دهد نباید در فهرست // بیاید — سرور هم همان را با ۴۲۲ رد می‌کند. const { services: resourceServices } = useResourceBookingServices(bookingResource?.uuid); /** * منبع زیرمجموعهٔ پزشک است، نه رقیبش: انتخاب یک منبع فقط نما را داخل همان پزشک * تنگ می‌کند. پاک کردن پزشک، نوبت‌دهی خودِ او را از دسترس خارج می‌کرد. */ const selectResource = (uuid: string) => setUrlState({ resource: uuid }); /** برگشت به خودِ پزشک: نمای منبع بسته می‌شود. */ const selectDoctor = (uuid: string) => { setSelectedDoctorUuid(uuid); if (selectedResourceUuid) setUrlState({ resource: '' }); }; 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, selectedResourceUuid]; const apptParams = new URLSearchParams({ date: selectedDate, limit: '500' }); // تب منبع جای تب پزشک را می‌گیرد، نه اینکه رویش سوار شود: «نوبت‌های لیزر CO2» یعنی // همهٔ نوبت‌های آن دستگاه، از هر پزشکی. if (selectedResourceUuid) apptParams.set('resource_uuid', selectedResourceUuid); else 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, isSecretary, isClinic], // منشی و کلینیک از اندپوینتِ احرازشده می‌گیرند: هم فقط پزشکانِ مجاز را می‌دهد و هم // `has_schedule` را، که مبنای ساختن تب است. ادمین از لیستِ کلینیکِ انتخاب‌شده // می‌خواند (آن اندپوینت نقش ادمین را پوشش نمی‌دهد) و آنجا فلگ نمی‌آید. queryFn: () => api.get( isSecretary || isClinic ? '/api/v1/my/clinic-doctors' : `/api/v1/clinic/doctor-list/${dbUuid}`, ), enabled: isSecretary || (isClinic && !!dbUuid), }); const clinicDoctorsList = clinicDoctorsQuery.data?.data?.data ?? []; const doctors = React.useMemo(() => { const map = new Map(); // پزشکِ بدون برنامهٔ کاری تب نمی‌گیرد — تبش جز یک تایم‌لاین همیشه‌خالی چیزی ندارد. // نبودِ فیلد (مسیر ادمین) یعنی «نمی‌دانیم»، پس پنهان نمی‌شود. clinicDoctorsList .filter(d => d.has_schedule !== false) .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 serviceOptions = React.useMemo(() => { const map = new Map(); appointments.forEach(a => { if (a.service_item?.uuid && a.service_item?.name) map.set(a.service_item.uuid, a.service_item.name); }); return Array.from(map.entries()).map(([uuid, name]) => ({ uuid, name })); }, [appointments]); // نوع پروفایل: کلینیک چندپزشکه (تب دکترها + مدیریت چند پزشک) در برابر پزشک مستقل. // نقش clinic/admin = چندپزشکه؛ نقش doctor (حتی مهمانِ کلینیک) = مستقل، فقط برنامهٔ خودش. const isMultiDoctorClinic = isClinic || isAdmin || isSecretary; const showDoctorTabs = isMultiDoctorClinic && doctors.length >= 1; const showDoctorCol = isAdmin && !selectedDoctorUuid; // کلینیک و منشی: اولین پزشکِ در دسترس پیش‌فرض انتخاب می‌شود تا تایم‌لاین خالی نماند. // برای منشیِ پزشک مستقل این تنها راه انتخاب است (تبِ پزشک هم یک گزینه بیشتر ندارد). useEffect(() => { if ((isClinic || isSecretary) && !selectedDoctorUuid && doctors.length > 0) { setSelectedDoctorUuid(doctors[0].uuid); } }, [isClinic, isSecretary, selectedDoctorUuid, doctors]); // ── محل نوبت‌دهی برای ادمین // ادمین context کلینیکی ندارد (useClinicContext → null)؛ بدون clinic_uuid فقط برنامهٔ // مطب شخصی خوانده می‌شود. محل از booking-locations همان پزشک انتخاب می‌شود. const adminLocationsQuery = useQuery>({ queryKey: ['booking-locations', selectedDoctorUuid], queryFn: () => api.get(`/api/v1/appointment-booking-locations/${selectedDoctorUuid}?management=1`), enabled: isAdmin && !!selectedDoctorUuid, }); const adminLocations: any[] = (adminLocationsQuery.data?.data as any)?.booking_locations ?? EMPTY_ARR; const [adminLocKey, setAdminLocKey] = useState(null); useEffect(() => { setAdminLocKey(null); }, [selectedDoctorUuid]); const locKey = (l: any) => l.clinic_uuid ?? 'personal'; // پیش‌فرض = اولین آیتم؛ backend بر اساس زودترین نوبت آزاد مرتب کرده است. const adminLocation = adminLocations.find(l => locKey(l) === adminLocKey) ?? adminLocations[0] ?? null; const effectiveClinicUuid: string | null = isAdmin ? (adminLocation?.clinic_uuid ?? null) : clinicUuid; // ── Slots query (timeline) // effectiveClinicUuid باید در کلید کش باشد، وگرنه برنامهٔ یک محیط برای محیط دیگر نشان داده می‌شود. const slotsQueryKey = ['appt-slots', selectedDoctorUuid, selectedDate, effectiveClinicUuid]; const slotsQuery = useQuery>({ queryKey: slotsQueryKey, queryFn: () => api.get( `/api/v1/appointment-slots?doctor_uuid=${selectedDoctorUuid}&date=${selectedDate}&management=1` + (effectiveClinicUuid ? `&clinic_uuid=${encodeURIComponent(effectiveClinicUuid)}` : '') ), enabled: viewMode === 'timeline' && !!selectedDoctorUuid, }); // ── روش نوبت‌دهی پزشکِ انتخاب‌شده (سرویسی/اسلاتی) const { bookingMode, services } = useDoctorBookingServices( selectedDoctorUuid, isAdmin ? (adminLocation?.clinic_uuid ?? null) : undefined, ); const serviceMode = bookingMode === 'service'; // بازهٔ کاری پزشک در این روز (برای هدرِ تایم‌لاینِ سرویسی). const workingRange = React.useMemo(() => { const rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? []; if (!rawSessions.length) return null; const starts = rawSessions.map(s => s.start_time).filter(Boolean).sort(); const ends = rawSessions.map(s => s.end_time).filter(Boolean).sort(); if (!starts.length || !ends.length) return null; return { start: starts[0], end: ends[ends.length - 1] }; }, [slotsQuery.data]); // ── Merge sessions + appointments → flat timeline slots const timelineSlots: TimelineSlot[] = React.useMemo(() => { if (viewMode !== 'timeline') return []; 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); } }); // حالت سرویسی: اسلات ثابت وجود ندارد — برای هر شیفتِ کاری، نوبت‌های رزروشده // نمایش داده می‌شوند و باقیِ زمان به‌صورت بازه‌(های) خالیِ قابل‌رزرو بین آن‌ها. // همان الگوریتمِ تایم‌لاینِ منبع؛ فقط بازهٔ کاری از برنامهٔ هفتگی می‌آید. if (serviceMode) { const parseHM = (t: string) => { const [h, m] = (t ?? '00:00').split(':').map(Number); return (h * 3600) + (m * 60); }; const rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? []; const windows = rawSessions.flatMap((session: any) => { const slots = (session.slots as any[]) ?? []; if (!slots.length) return []; const dayStart = Number(slots[0].start) - parseHM(session.start_time); return [{ start: dayStart + parseHM(session.start_time), end: dayStart + parseHM(session.end_time) }]; }); return buildServiceTimeline(windows, appointments); } const rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? []; 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 ?? formatTime(slotStart), end_time: s.end_time ?? formatTime(slotEnd), is_available: s.is_available as boolean, appointment: activeByStart.get(slotStart) ?? null, cancelled_appointment: cancelledByStart.get(slotStart) ?? null, }); }); }); return out; }, [viewMode, slotsQuery.data, appointments, serviceMode]); // ── Slot click → quick booking modal function handleSlotClick(slot: TimelineSlot) { if (isRepresentation || !canCreateAppt) 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 (
{/* عنوان */}

نوبت‌ ها

{/* نوار آمار */} {/* نوار ابزار (بیرونِ کارت، مطابق طرح) */}
{/* سمت راست: تاریخ + سرویس + سوییچ نما (مطابق طرح) */} setFilters(f => ({ ...f, itemUuid: v }))} /> {isAdmin && adminLocations.length > 1 && (
({ value: locKey(l), label: l.type === 'personal' ? `مطب شخصی${l.title ? ` — ${l.title}` : ''}` : l.title, }))} value={adminLocation ? locKey(adminLocation) : null} onChange={v => setAdminLocKey(v ? String(v) : null)} placeholder="محل نوبت‌دهی..." height={44} />
)}
{/* سمت چپ: فیلتر + افزودن نوبت */} {/* رزرو منبع‌محور مسیر جداست چون جستجویش از تقاطع تقویم منابع می‌آید، نه از اسلات‌های یک پزشک؛ ادغامشان در یک فرم، هر دو را گیج می‌کرد. */} {!isRepresentation && canCreateAppt && ( )} {!isRepresentation && canCreateAppt && ( )}
{/* کارت اصلی — تب دکترها (هدر) + محتوا */}
{showDoctorTabs && ( )} {/* منابعِ همین پزشک، نه همهٔ منابع: ارتباط پزشک↔منبع روی خودِ منبع تعریف شده (پزشک ناظر)، پس کاربر بین نوبت‌های پزشک و دستگاه‌های تحت نظرش جابه‌جا می‌شود. */} {supervisedResources.length > 0 && (
نوبت‌دهی {/* تب نخست راهِ بازگشت است: بدون آن، برگشتن از منبع به خودِ پزشک فقط با کلیک روی تب بالایی ممکن بود و کاربر دنبالش می‌گشت. */} ({ uuid: r.uuid, name: r.name })), ]} selected={selectedResourceUuid} onSelect={selectResource} showAll={false} />
)}
{viewMode === 'table' ? ( <> {filteredAppointments.length > TABLE_PAGE_SIZE && (
)} ) : activeResource ? ( /* منبع اسلات ندارد: تقویمش سرویسی است، پس تایم‌لاینش هم سرویسی ساخته می‌شود — نوبت‌های همین منبع و بازه‌های خالیِ بینشان. */ setBookingResource(activeResource)} onView={openDetail} /> ) : ( <> {!selectedDoctorUuid ? (
برای نمایش زمانبندی، ابتدا یک پزشک انتخاب کنید
) : ( <> {serviceMode && (
نوبت‌دهی سرویسی — نوبت‌ها بر اساس مدت سرویس چیده می‌شوند. {workingRange && ( ساعت کاری: {workingRange.start} - {workingRange.end} )}
)} )} )}
{/* ثبت نوبتِ منبع — همان مودالِ نوبت‌دهی سرویسی، با تقویمِ خودِ منبع */} {bookingResource && ( setBookingResource(null)} onSuccess={() => { qc.invalidateQueries({ queryKey: apptQueryKey }); qc.invalidateQueries({ queryKey: ['resource-day-slots', bookingResource.uuid, selectedDate] }); qc.invalidateQueries({ queryKey: ['appt-today-stats', selectedDate] }); }} /> )} {/* مودال ثبت سریع نوبت */} {bookingSlot && ( setBookingSlot(null)} onSuccess={() => { qc.invalidateQueries({ queryKey: apptQueryKey }); qc.invalidateQueries({ queryKey: slotsQueryKey }); qc.invalidateQueries({ queryKey: ['appt-today-stats', selectedDate] }); }} /> )} {/* مودال فیلترها */} {filtersOpen && ( setFiltersOpen(false)} /> )}
); } /** فیلتر سرویس نوار ابزار — ردیف‌های بارگذاری‌شدهٔ روز را بر اساس سرویس فیلتر می‌کند. */ function ServiceFilterSelect({ value, options, onChange }: { value: string; options: { uuid: string; name: string }[]; onChange: (v: string) => void; }) { return (
({ value: s.uuid, label: s.name }))} value={value || null} onChange={v => onChange(v ? String(v) : '')} placeholder="سرویس مورد نظر را انتخاب کنید..." isClearable height={44} />
); }