import React, { useEffect, useRef, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useNavigate, useSearchParams } 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, formatTime } 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'; // اجزای طرح نوبت‌های 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 ServiceSlotPicker from '../components/appointments/ServiceSlotPicker'; import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices'; 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; } interface PatientLookup { found: boolean; name?: string | null; mobile?: string; national_code?: string | null } export function NewAppointmentModal({ slot, onClose, onSuccess, serviceMode = false, services = [], date, }: { slot: BookingSlot; onClose: () => void; onSuccess: () => void; serviceMode?: boolean; services?: import('../hooks/useDoctorBookingServices').BookingService[]; date?: string; }) { const [mobile, setMobile] = useState(''); const [lookup, setLookup] = useState(null); const [patientName, setPatientName] = useState(''); const [nationalCode, setNationalCode] = useState(''); const [pick, setPick] = useState<{ serviceUuids: string[]; slot: { start: number; end: number } | null }>({ serviceUuids: [], slot: null }); const role = useAuthStore(s => s.primaryRole); const createEndpoint = role === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/my/appointment'; const mobileValid = /^09\d{9}$/.test(mobile); const serviceTimingValid = !serviceMode || (pick.serviceUuids.length > 0 && !!pick.slot); // یک بیمارِ یافت‌شده که کد ملی دارد، بدون فرم اضافی قابل استفاده است. const foundWithNationalCode = !!lookup?.found && !!lookup.national_code; const needsDetails = lookup !== null && !foundWithNationalCode; // یافت‌نشده، یا یافت‌شده بدون کد ملی const effectiveName = foundWithNationalCode ? (lookup?.name ?? '') : patientName.trim(); const effectiveNationalCode = foundWithNationalCode ? (lookup?.national_code ?? '') : nationalCode; const detailsValid = effectiveName.length >= 2 && effectiveNationalCode.length === 10; const isValid = mobileValid && (foundWithNationalCode || (needsDetails && detailsValid)) && serviceTimingValid; const search = useMutation({ mutationFn: () => api.get(`/api/v1/my/appointment/patient-lookup?mobile=${encodeURIComponent(mobile)}`), onSuccess: (res: any) => { const data: PatientLookup = res?.data ?? { found: false }; setLookup(data); setPatientName(data.found ? (data.name ?? '') : ''); setNationalCode(data.found ? (data.national_code ?? '') : ''); }, onError: (e: any) => toast.error(e?.response?.data?.errors?.[0]?.message ?? e?.message ?? 'خطا در جستجو'), }); const mutation = useMutation({ mutationFn: () => api.post(createEndpoint, { doctor_uuid: slot.doctor_uuid, slot_start: serviceMode ? pick.slot!.start : slot.start, slot_end: serviceMode ? pick.slot!.end : slot.end, patient_mobile: mobile, patient_name: effectiveName, patient_national_code: effectiveNationalCode, ...(serviceMode ? { service_item_uuids: pick.serviceUuids } : {}), }), 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, }; // تغییر موبایل نتیجه‌ی جستجوی قبلی را باطل می‌کند تا کاربر دوباره جستجو کند. function onMobileChange(v: string) { // ارقام فارسی/عربی → انگلیسی، فقط رقم، حداکثر ۱۱ رقم (کیبورد فارسی هم پذیرفته می‌شود). const normalized = v .replace(/[۰-۹]/g, d => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d))) .replace(/[٠-٩]/g, d => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d))) .replace(/\D/g, '') .slice(0, 11); setMobile(normalized); if (lookup !== null) { setLookup(null); setPatientName(''); setNationalCode(''); } } return (
e.stopPropagation()}>
ثبت نوبت
{serviceMode ? slot.doctor_name : `${slot.start_time} تا ${slot.end_time} — ${slot.doctor_name}`}
{serviceMode && date && (
)}
onMobileChange(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && mobileValid && !search.isPending) search.mutate(); }} placeholder="مثال: 09123456789" style={{ ...inputSx, direction: 'ltr' }} autoFocus />
{foundWithNationalCode && (
بیمار یافت شد
{lookup?.name}
کد ملی: {lookup?.national_code}
)} {needsDetails && ( <>
{lookup?.found ? 'برای این بیمار کد ملی ثبت نشده — لطفاً تکمیل کنید:' : 'کاربری با این شماره یافت نشد — بیمار جدید:'}
setPatientName(e.target.value)} placeholder="مثال: علی محمدی" style={inputSx} />
setNationalCode(e.target.value.replace(/\D/g, '').slice(0, 10))} placeholder="کد ملی ۱۰ رقمی" 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 [params] = useSearchParams(); const today = new Date().toISOString().slice(0, 10); // پس از ویرایش/ثبت، صفحه با ?date=... باز می‌شود تا همان روز نمایش داده شود. const [selectedDate, setSelectedDate] = useState(params.get('date') || 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 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; const showDoctorTabs = isMultiDoctorClinic && doctors.length >= 1; const showDoctorCol = isAdmin && !selectedDoctorUuid; // در کلینیک، اولین دکتر به‌صورت پیش‌فرض انتخاب می‌شود تا زمانبندی مثل طرح پر باشد. useEffect(() => { if (isClinic && !selectedDoctorUuid && doctors.length > 0) { setSelectedDoctorUuid(doctors[0].uuid); } }, [isClinic, selectedDoctorUuid, doctors]); // ── 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, }); // ── روش نوبت‌دهی پزشکِ انتخاب‌شده (سرویسی/اسلاتی) const { bookingMode, services } = useDoctorBookingServices(selectedDoctorUuid); 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 fmt = (ts: number) => formatTime(ts); 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 booked = [...activeByStart.values()].sort((a, b) => Number(a.slot_start) - Number(b.slot_start)); const now = Math.floor(Date.now() / 1000); const out: TimelineSlot[] = []; // بازهٔ خالیِ قابل‌رزرو؛ اگر روی «اکنون» بیفتد به اکنون بریده می‌شود و بازه‌های // کاملاً گذشته حذف می‌شوند تا فقط زمان قابل‌رزرو باقی بماند. const pushFree = (start: number, end: number) => { const s = start < now ? now : start; if (end <= s) return; out.push({ start: s, end, start_time: fmt(s), end_time: fmt(end), is_available: true, appointment: null, cancelled_appointment: null }); }; rawSessions.forEach((session: any) => { const slots = (session.slots as any[]) ?? []; if (!slots.length) return; const dayStart = Number(slots[0].start) - parseHM(session.start_time); const winStart = dayStart + parseHM(session.start_time); const winEnd = dayStart + parseHM(session.end_time); const inWin = booked.filter(a => Number(a.slot_start) >= winStart && Number(a.slot_start) < winEnd); let cursor = winStart; inWin.forEach(a => { const s = Number(a.slot_start), e = Number(a.slot_end); if (s > cursor) pushFree(cursor, s); out.push({ start: s, end: e, start_time: fmt(s), end_time: fmt(e), is_available: false, appointment: a, cancelled_appointment: null, }); cursor = Math.max(cursor, e); }); if (cursor < winEnd) pushFree(cursor, winEnd); }); return out; } 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) 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 }))} />
{/* سمت چپ: فیلتر + افزودن نوبت */} {!isRepresentation && ( )}
{/* کارت اصلی — تب دکترها (هدر) + محتوا */}
{showDoctorTabs && ( )}
{viewMode === 'table' ? ( <> {filteredAppointments.length > TABLE_PAGE_SIZE && (
)} ) : ( <> {!selectedDoctorUuid ? (
برای نمایش زمانبندی، ابتدا یک پزشک انتخاب کنید
) : ( <> {serviceMode && (
نوبت‌دهی سرویسی — نوبت‌ها بر اساس مدت سرویس چیده می‌شوند. {workingRange && ( ساعت کاری: {workingRange.start} - {workingRange.end} )}
)} )} )}
{/* مودال ثبت سریع نوبت */} {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 ( ); }