Files
clinicpro/assets/admin/pages/AppointmentsPage.tsx
T
hamedandClaude Opus 4.8 7a0654f8ba fix(booking): carry the clinic context through the panel and drop phantom locations
Two faults, one root: the per-context booking work updated ScheduleSection but
left the rest of the panel calling slot endpoints without clinic_uuid. Absent
clinic_uuid means the personal practice, so the panel asked about a schedule the
doctor barely uses and got nothing back.

- useClinicContext() resolves the current environment once and is used by the
  appointments page, useDoctorBookingServices, ServiceSlotPicker and both
  queries in NewAppointmentDrawer (a fifth call site a sweep turned up). It
  returns null in a doctor's personal environment so the mirror-image bug — a
  doctor seeing the clinic's schedule at their own practice — cannot appear.
  clinicUuid is part of every query key; without it the cache leaks across
  environments.
- appointment-slots returns empty_reason (no_schedule | holiday | day_off |
  outside_window). TurnsTimeline rendered «این روز تعطیل است» for any empty day,
  which is what the bug report actually saw; it now says which of the four it is.
- booking-locations lists a location only when the context has an address and an
  active shift points at it. The dev data had three "personal" schedules whose
  shifts referenced the clinic's address, so the public site advertised a
  personal practice that could never be booked.
- ?date= adds available_on_date per location, validated as a real calendar date.
- MyAppointmentsController and AdminApiController resolved the appointment
  address with no context and could store the wrong one. Both now go through the
  new BookingContextResolver, which also replaces AppointmentController's private
  copy of the same membership check.
- app:schedule:audit-locations reports shifts pointing at a missing or foreign
  address; --fix deactivates them rather than deleting.

Verified against the reported doctor: same date, no clinic_uuid -> 0 sessions,
with it -> 1 session; a full week matches the configured Sat/Tue/Wed/Thu.

Suite: 417 tests, 2 failures — both pre-existing and unrelated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 14:35:31 +03:30

700 lines
32 KiB
TypeScript

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, digitsOnly, sanitizeMobileInput, rialToToman, tomanToRial } from '../lib/utils';
import PriceInput from '../components/ui/PriceInput';
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 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<HTMLDivElement>(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 (
<div ref={ref} style={{ display: 'flex', alignItems: 'center', gap: 2, position: 'relative' }}>
<button className="btn sm" style={navBtnSx} onClick={() => addDays(1)}>
<ChevronRightIcon style={{ width: 15, height: 15 }} />
</button>
<div style={{
padding: '0 14px', height: 44,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 'var(--r-sm)', minWidth: 130, gap: 1,
}}>
<span style={{ fontSize: 13, fontWeight: 700, lineHeight: 1.2, color: isFriday ? '#ef4444' : 'var(--text)' }}>
{weekDay}
</span>
<span style={{ fontSize: 11, color: 'var(--text-2)', lineHeight: 1.2 }}>
{formatDate(date)}
</span>
</div>
<button className="btn sm" style={navBtnSx} onClick={() => addDays(-1)}>
<ChevronLeftIcon style={{ width: 15, height: 15 }} />
</button>
<button className="btn sm" style={navBtnSx} onClick={() => setShowCal(c => !c)}>
<CalendarDaysIcon style={{ width: 15, height: 15 }} />
</button>
{showCal && (
<PersianCalendar value={date} onChange={v => { onChange(v); setShowCal(false); }} onClose={() => setShowCal(false)} />
)}
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 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<PatientLookup | null>(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';
// هزینه ویزیت — الزامی بودن از تنظیمات «الزامی کردن هزینه ویزیت». فیلد UI تومان،
// API ریالی (visit_price_rials). بدون این مقدار، وقتی فلگ فعال است backend خطای ۴۲۲ می‌دهد.
const pricingQ = useQuery<{ data: { free_visit_price_rials: number; require_visit_price: boolean } }>({
queryKey: ['insurance-pricing'],
queryFn: () => api.get('/api/v1/insurance-pricing'),
});
const freeVisit = (pricingQ.data as any)?.data?.free_visit_price_rials ?? 0;
const requireVisit = (pricingQ.data as any)?.data?.require_visit_price ?? false;
const [visitPriceToman, setVisitPriceToman] = useState(0);
const [visitPriceTouched, setVisitPriceTouched] = useState(false);
useEffect(() => {
if (!visitPriceTouched && freeVisit > 0) setVisitPriceToman(rialToToman(freeVisit));
}, [freeVisit, visitPriceTouched]);
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 visitPriceValid = !requireVisit || visitPriceToman > 0;
const isValid = mobileValid && (foundWithNationalCode || (needsDetails && detailsValid)) && serviceTimingValid && visitPriceValid;
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 } : {}),
...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}),
}),
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) {
// ارقام فارسی/عربی → انگلیسی، فقط رقم، حداکثر ۱۱ رقم (کیبورد فارسی هم پذیرفته می‌شود).
setMobile(sanitizeMobileInput(v));
if (lookup !== null) { setLookup(null); setPatientName(''); setNationalCode(''); }
}
return (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 500,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}} onClick={onClose}>
<div style={{
background: 'var(--surface)', borderRadius: 'var(--r)', padding: 24,
minWidth: 320, maxWidth: 400, width: '90vw', boxShadow: 'var(--shadow-lg)',
}} onClick={e => e.stopPropagation()}>
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 4 }}>ثبت نوبت</div>
<div style={{ fontSize: 13, color: 'var(--text-2)', marginBottom: 16 }}>
{serviceMode
? slot.doctor_name
: `${slot.start_time} تا ${slot.end_time}${slot.doctor_name}`}
</div>
{serviceMode && date && (
<div style={{ marginBottom: 16 }}>
<ServiceSlotPicker
doctorUuid={slot.doctor_uuid}
date={date}
services={services}
onSelect={setPick}
/>
</div>
)}
<div style={{ marginBottom: 12 }}>
<label style={labelSx}>شماره موبایل بیمار *</label>
<div style={{ display: 'flex', gap: 8 }}>
<input
type="tel"
inputMode="numeric"
maxLength={11}
value={mobile}
onChange={e => onMobileChange(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && mobileValid && !search.isPending) search.mutate(); }}
placeholder="مثال: 09123456789"
style={{ ...inputSx, direction: 'ltr' }}
autoFocus
/>
<button
className="btn sm"
onClick={() => search.mutate()}
disabled={!mobileValid || search.isPending}
style={{ whiteSpace: 'nowrap' }}
>
{search.isPending ? '...' : 'جستجو'}
</button>
</div>
</div>
{foundWithNationalCode && (
<div style={{
marginBottom: 16, padding: '10px 12px', borderRadius: 'var(--r-sm)',
background: 'var(--success-bg)', border: '1px solid var(--success)', fontSize: 13,
}}>
<div style={{ fontWeight: 700, color: 'var(--success)', marginBottom: 2 }}>بیمار یافت شد</div>
<div style={{ color: 'var(--text)' }}>{lookup?.name}</div>
<div style={{ color: 'var(--text-2)', direction: 'ltr', textAlign: 'right' }}>کد ملی: {lookup?.national_code}</div>
</div>
)}
{needsDetails && (
<>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 10 }}>
{lookup?.found ? 'برای این بیمار کد ملی ثبت نشده — لطفاً تکمیل کنید:' : 'کاربری با این شماره یافت نشد — بیمار جدید:'}
</div>
<div style={{ marginBottom: 12 }}>
<label style={labelSx}>نام و نام خانوادگی بیمار *</label>
<input
type="text"
value={patientName}
onChange={e => setPatientName(e.target.value)}
placeholder="مثال: علی محمدی"
style={inputSx}
/>
</div>
<div style={{ marginBottom: 16 }}>
<label style={labelSx}>کد ملی بیمار *</label>
<input
type="text"
inputMode="numeric"
lang="en"
maxLength={10}
value={nationalCode}
onChange={e => setNationalCode(digitsOnly(e.target.value, 10))}
placeholder="کد ملی ۱۰ رقمی"
style={{ ...inputSx, direction: 'ltr' }}
/>
</div>
</>
)}
<div style={{ marginBottom: 16 }}>
<label style={labelSx}>
هزینه ویزیت (تومان){requireVisit && <span style={{ color: 'var(--danger)' }}> *</span>}
</label>
<PriceInput
value={visitPriceToman}
onChange={(v) => { setVisitPriceToman(v); setVisitPriceTouched(true); }}
style={{ ...inputSx, direction: 'ltr' }}
/>
{requireVisit && visitPriceToman <= 0 && (
<div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 6 }}>هزینه ویزیت الزامی است</div>
)}
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button className="btn sm" onClick={onClose}>انصراف</button>
<button
className="btn primary sm"
onClick={() => mutation.mutate()}
disabled={!isValid || mutation.isPending}
>
{mutation.isPending ? '...' : 'ثبت نوبت'}
</button>
</div>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 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 clinicUuid = useClinicContext();
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<TurnsViewMode>('timeline');
const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>(isDoctor && dbUuid ? dbUuid : '');
const [bookingSlot, setBookingSlot] = useState<BookingSlot | null>(null);
const [filtersOpen, setFiltersOpen] = useState(false);
const [filters, setFilters] = useState<AppointmentFilters>(EMPTY_FILTERS);
const qc = useQueryClient();
// ── Today stats
const statsQuery = useQuery<ApiResponse<TodayStats>>({
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<PaginatedResponse<Appointment>>({
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<ApiResponse<{ data: { uuid: string; name: string }[] }>>({
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<string, string>();
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<string, string>();
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)
// clinicUuid باید در کلید کش باشد، وگرنه برنامهٔ یک محیط برای محیط دیگر نشان داده می‌شود.
const slotsQueryKey = ['appt-slots', selectedDoctorUuid, selectedDate, clinicUuid];
const slotsQuery = useQuery<ApiResponse<any>>({
queryKey: slotsQueryKey,
queryFn: () => api.get(
`/api/v1/appointment-slots?doctor_uuid=${selectedDoctorUuid}&date=${selectedDate}` +
(clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : '')
),
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<number, Appointment>();
const cancelledByStart = new Map<number, Appointment>();
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 (
<div style={{ padding: '20px 24px' }}>
<div style={{ maxWidth: 1050, margin: '0 auto' }}>
{/* عنوان */}
<h1 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text)', marginBottom: 16 }}>نوبت‌ ها</h1>
{/* نوار آمار */}
<TurnsStatInfo stats={stats} />
{/* نوار ابزار (بیرونِ کارت، مطابق طرح) */}
<div style={{
display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', marginBottom: 16,
}}>
{/* سمت راست: تاریخ + سرویس + سوییچ نما (مطابق طرح) */}
<DateNavigator date={selectedDate} onChange={setSelectedDate} />
<ServiceFilterSelect
value={filters.itemUuid}
options={serviceOptions}
onChange={(v) => setFilters(f => ({ ...f, itemUuid: v }))}
/>
<TurnsViewToggle viewMode={viewMode} onChange={setViewMode} />
<div style={{ flex: 1 }} />
{/* سمت چپ: فیلتر + افزودن نوبت */}
<button
aria-label="فیلترها"
className="btn sm"
onClick={() => setFiltersOpen(true)}
style={{
border: `1px solid ${filtersActive ? 'var(--primary)' : 'var(--border)'}`,
color: filtersActive ? 'var(--primary)' : 'var(--text-2)',
background: 'var(--surface)',
}}
>
<AdjustmentsHorizontalIcon style={{ width: 16 }} />
</button>
{!isRepresentation && (
<button
className="btn primary sm"
onClick={() => {
const q = selectedDoctorUuid ? `?doctor=${selectedDoctorUuid}&date=${selectedDate}` : `?date=${selectedDate}`;
navigate(`/admin/appointments/new${q}`);
}}
style={{ display: 'flex', alignItems: 'center', gap: 5 }}
>
<PlusIcon style={{ width: 15, height: 15 }} />
افزودن نوبت
</button>
)}
</div>
{/* کارت اصلی — تب دکترها (هدر) + محتوا */}
<div style={{
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 'var(--r)', overflow: 'hidden',
}}>
{showDoctorTabs && (
<DoctorTabs doctors={doctors} selected={selectedDoctorUuid} onSelect={setSelectedDoctorUuid} showAll={isAdmin} />
)}
<div style={{ padding: 16 }}>
{viewMode === 'table' ? (
<>
<TurnsTable
items={pagedAppointments}
loading={apptQuery.isLoading}
queryKey={apptQueryKey}
showDoctor={showDoctorCol}
/>
{filteredAppointments.length > TABLE_PAGE_SIZE && (
<div style={{ marginTop: 14 }}>
<Pagination page={tablePage} total={filteredAppointments.length} limit={TABLE_PAGE_SIZE} onPageChange={setTablePage} />
</div>
)}
</>
) : (
<>
{!selectedDoctorUuid ? (
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>
برای نمایش زمانبندی، ابتدا یک پزشک انتخاب کنید
</div>
) : (
<>
{serviceMode && (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
padding: '8px 12px', marginBottom: 12, borderRadius: 'var(--r-sm)',
background: 'var(--surface-2)', border: '1px solid var(--border)', fontSize: 12.5, color: 'var(--text-2)',
}}>
<span>نوبت‌دهی سرویسی نوبت‌ها بر اساس مدت سرویس چیده می‌شوند.</span>
{workingRange && (
<span dir="ltr" style={{ color: 'var(--text-3)' }}>ساعت کاری: {workingRange.start} - {workingRange.end}</span>
)}
</div>
)}
<TurnsTimeline
slots={timelineSlots}
loading={apptQuery.isLoading || slotsQuery.isLoading}
queryKey={apptQueryKey}
onView={openDetail}
onBook={handleSlotClick}
emptyReason={(slotsQuery.data?.data as any)?.empty_reason ?? null}
/>
</>
)}
</>
)}
</div>
</div>
</div>
{/* مودال ثبت سریع نوبت */}
{bookingSlot && (
<NewAppointmentModal
slot={bookingSlot}
serviceMode={serviceMode}
services={services}
date={selectedDate}
onClose={() => setBookingSlot(null)}
onSuccess={() => {
qc.invalidateQueries({ queryKey: apptQueryKey });
qc.invalidateQueries({ queryKey: slotsQueryKey });
qc.invalidateQueries({ queryKey: ['appt-today-stats', selectedDate] });
}}
/>
)}
{/* مودال فیلترها */}
{filtersOpen && (
<AppointmentFiltersModal value={filters} onApply={setFilters} onClose={() => setFiltersOpen(false)} />
)}
</div>
);
}
/** فیلتر سرویس نوار ابزار — ردیف‌های بارگذاری‌شدهٔ روز را بر اساس سرویس فیلتر می‌کند. */
function ServiceFilterSelect({ value, options, onChange }: {
value: string; options: { uuid: string; name: string }[]; onChange: (v: string) => void;
}) {
return (
<div style={{ minWidth: 280 }}>
<SearchableSelect
options={options.map(s => ({ value: s.uuid, label: s.name }))}
value={value || null}
onChange={v => onChange(v ? String(v) : '')}
placeholder="سرویس مورد نظر را انتخاب کنید..."
isClearable
height={44}
/>
</div>
);
}