Files
clinicpro/assets/admin/pages/AppointmentsPage.tsx
T
hamedandClaude Opus 5 c688f48583 fix(appointments): give the resource strip a way back to the doctor
Returning from a resource to the doctor's own booking meant clicking the doctor
tab in the row above, which nobody found. The resource strip now leads with a
"نوبت‌های خود پزشک" tab that clears the resource, so going in and coming back
happen in the same row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 13:08:31 +03:30

1020 lines
49 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, ChevronDownIcon, CalendarDaysIcon,
AdjustmentsHorizontalIcon, ClockIcon, MagnifyingGlassIcon, CheckCircleIcon,
} from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { PaginatedResponse, ApiResponse } from '../lib/api';
import type { Appointment, ClinicResource } from '../types';
import { formatDate, toGregorianDate, todayIso, formatTime, digitsOnly, sanitizeMobileInput, rialToToman, tomanToRial, formatRial } from '../lib/utils';
import PriceInput from '../components/ui/PriceInput';
import Modal from '../components/ui/Modal';
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 ResourceBookingModal from '../components/appointments/ResourceBookingModal';
import ResourceDayPanel from '../components/appointments/ResourceDayPanel';
import { useUrlState } from '../hooks/useUrlState';
import TurnsTimeline from '../components/appointments/TurnsTimeline';
import TurnsTable from '../components/appointments/TurnsTable';
import { usePermissions } from '../hooks/usePermissions';
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 ? 'var(--danger)' : '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, clinicUuid = null,
}: {
slot: BookingSlot;
onClose: () => void;
onSuccess: () => void;
serviceMode?: boolean;
services?: import('../hooks/useDoctorBookingServices').BookingService[];
date?: string;
/** محل نوبت — بدون آن backend نوبت را به مطب شخصی نسبت می‌دهد. */
clinicUuid?: string | null;
}) {
const [mobile, setMobile] = useState('');
const [lookup, setLookup] = useState<PatientLookup | null>(null);
const [patientName, setPatientName] = useState('');
const [nationalCode, setNationalCode] = useState('');
// معیار جستجوی بیمار: کد ملی (پیش‌فرض) یا موبایل.
const [searchBy, setSearchBy] = useState<'mobile' | 'national'>('national');
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 خطای ۴۲۲ می‌دهد.
//
// قیمت باید از تنظیمات نوبت‌دهیِ *پزشکِ همین اسلات* بیاید، نه از entity کاربر جاری؛
// منشی/کلینیک قیمت خودشان را ندارند و فیلد صفر می‌ماند. اگر دسترسی به تنظیمات آن
// پزشک نبود (۴۰۳)، به تنظیمات خودِ کاربر برمی‌گردیم تا فلگ الزامی‌بودن از دست نرود.
type Pricing = { data: { free_visit_price_rials: number; require_visit_price: boolean } };
const doctorPricingQ = useQuery<Pricing>({
queryKey: ['insurance-pricing', slot.doctor_uuid],
queryFn: () => api.get(`/api/v1/insurance-pricing?doctor_uuid=${encodeURIComponent(slot.doctor_uuid)}`),
retry: false,
});
const selfPricingQ = useQuery<Pricing>({
queryKey: ['insurance-pricing'],
queryFn: () => api.get('/api/v1/insurance-pricing'),
enabled: doctorPricingQ.isError,
});
const pricing = (doctorPricingQ.data ?? selfPricingQ.data) as any;
const freeVisit = pricing?.data?.free_visit_price_rials ?? 0;
const requireVisit = pricing?.data?.require_visit_price ?? false;
const pricingLoading = doctorPricingQ.isLoading || selfPricingQ.isLoading;
const [visitPriceToman, setVisitPriceToman] = useState(0);
const [visitPriceTouched, setVisitPriceTouched] = useState(false);
useEffect(() => {
if (!visitPriceTouched && freeVisit > 0) setVisitPriceToman(rialToToman(freeVisit));
}, [freeVisit, visitPriceTouched]);
// هزینه ویزیت اختیاری داخل کلپسِ بسته می‌نشیند؛ وقتی الزامی است کلپس همیشه باز است.
const [visitPriceOpen, setVisitPriceOpen] = useState(false);
const visitPriceExpanded = requireVisit || visitPriceOpen;
const mobileValid = /^09\d{9}$/.test(mobile);
const nationalCodeValid = /^\d{10}$/.test(nationalCode);
// اعتبار کلید جستجو بسته به معیار انتخاب‌شده.
const searchValid = searchBy === 'mobile' ? mobileValid : nationalCodeValid;
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: () => {
const q = searchBy === 'mobile'
? `mobile=${encodeURIComponent(mobile)}`
: `national_code=${encodeURIComponent(nationalCode)}`;
return api.get(`/api/v1/my/appointment/patient-lookup?${q}`);
},
onSuccess: (res: any) => {
const data: PatientLookup = res?.data ?? { found: false };
setLookup(data);
setPatientName(data.found ? (data.name ?? '') : '');
// موبایل و کد ملیِ بیمارِ یافت‌شده را پر می‌کنیم تا ثبت مستقل از معیار جستجو کار کند.
if (data.found) {
if (data.mobile) setMobile(data.mobile);
setNationalCode(data.national_code ?? '');
} else if (searchBy === 'mobile') {
setNationalCode('');
}
},
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,
...(clinicUuid ? { clinic_uuid: clinicUuid } : {}),
...(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);
},
});
// تغییر کلید جستجو نتیجه‌ی جستجوی قبلی را باطل می‌کند تا کاربر دوباره جستجو کند.
function invalidateLookup() {
if (lookup !== null) { setLookup(null); setPatientName(''); }
}
function onMobileChange(v: string) {
// ارقام فارسی/عربی → انگلیسی، فقط رقم، حداکثر ۱۱ رقم (کیبورد فارسی هم پذیرفته می‌شود).
setMobile(sanitizeMobileInput(v));
if (lookup !== null) { setLookup(null); setPatientName(''); if (searchBy === 'mobile') setNationalCode(''); }
}
function onNationalSearchChange(v: string) {
setNationalCode(digitsOnly(v, 10));
invalidateLookup();
}
// جابه‌جایی معیار جستجو همه‌چیز را از نو شروع می‌کند.
function onSwitchSearchBy(mode: 'mobile' | 'national') {
setSearchBy(mode);
setLookup(null); setPatientName('');
setMobile(''); setNationalCode('');
}
const priceHint = pricingLoading
? 'در حال خواندن تعرفهٔ پزشک…'
: freeVisit > 0
? `تعرفهٔ نوبت‌دهی ${slot.doctor_name}: ${formatRial(freeVisit)} — در صورت نیاز تغییر دهید`
: 'برای این پزشک تعرفه‌ای ثبت نشده — در صورت نیاز مبلغ را وارد کنید';
return (
<Modal
open
title="ثبت نوبت"
size="sm"
onClose={onClose}
footer={
<>
<button className="btn ghost" onClick={onClose}>انصراف</button>
<button
className="btn primary"
onClick={() => mutation.mutate()}
disabled={!isValid || mutation.isPending}
>
{mutation.isPending ? 'در حال ثبت…' : 'ثبت نوبت'}
</button>
</>
}
>
{/* اسلات انتخاب‌شده */}
<div style={{
display: 'flex', alignItems: 'center', gap: 10, marginBottom: 18,
padding: '10px 14px', borderRadius: 'var(--r-sm)', background: 'var(--primary-soft)',
}}>
<ClockIcon style={{ width: 18, height: 18, color: 'var(--primary-700)', flexShrink: 0 }} />
<span style={{ fontSize: 13.5, color: 'var(--text)' }}>
{serviceMode ? slot.doctor_name : `${slot.start_time} تا ${slot.end_time}`}
</span>
{!serviceMode && (
<span style={{ fontSize: 12.5, color: 'var(--text-2)', marginInlineStart: 'auto' }}>
{slot.doctor_name}
</span>
)}
</div>
{serviceMode && date && (
<div style={{ marginBottom: 18 }}>
<ServiceSlotPicker
doctorUuid={slot.doctor_uuid}
date={date}
services={services}
onSelect={setPick}
clinicUuidOverride={clinicUuid}
/>
</div>
)}
<div className="field-block" style={{ marginBottom: 14 }}>
<label>جستجوی بیمار <span className="req">*</span></label>
{/* انتخاب معیار جستجو: موبایل یا کد ملی */}
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
{(['national', 'mobile'] as const).map(mode => (
<button
key={mode}
type="button"
className={`btn sm ${searchBy === mode ? 'primary' : 'ghost'}`}
onClick={() => onSwitchSearchBy(mode)}
style={{ flex: 1 }}
>
{mode === 'mobile' ? 'شماره موبایل' : 'کد ملی'}
</button>
))}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<div className="field" style={{ flex: 1 }}>
{searchBy === 'mobile' ? (
<input
type="tel"
inputMode="numeric"
maxLength={11}
value={mobile}
onChange={e => onMobileChange(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }}
placeholder="مثال: 09123456789"
style={{ direction: 'ltr' }}
autoFocus
/>
) : (
<input
type="text"
inputMode="numeric"
lang="en"
maxLength={10}
value={nationalCode}
onChange={e => onNationalSearchChange(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }}
placeholder="کد ملی ۱۰ رقمی"
style={{ direction: 'ltr' }}
autoFocus
/>
)}
</div>
<button
className="btn soft"
onClick={() => search.mutate()}
disabled={!searchValid || search.isPending}
style={{ whiteSpace: 'nowrap' }}
>
<MagnifyingGlassIcon style={{ width: 16, height: 16 }} />
{search.isPending ? '...' : 'جستجو'}
</button>
</div>
</div>
{foundWithNationalCode && (
<div style={{
marginBottom: 16, padding: '10px 14px', borderRadius: 'var(--r-sm)',
background: 'var(--success-bg)', fontSize: 13,
display: 'flex', alignItems: 'center', gap: 10,
}}>
<CheckCircleIcon style={{ width: 20, height: 20, color: 'var(--success)', flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 700, color: 'var(--success)', fontSize: 12 }}>بیمار یافت شد</div>
<div style={{ fontWeight: 700, color: 'var(--text)' }}>{lookup?.name}</div>
<div style={{ color: 'var(--text-2)', fontSize: 12 }}>کد ملی: {lookup?.national_code}</div>
</div>
</div>
)}
{needsDetails && (
<>
<div style={{
fontSize: 12.5, color: 'var(--text-2)', marginBottom: 12,
padding: '9px 12px', borderRadius: 'var(--r-sm)', background: 'var(--warning-bg)',
}}>
{lookup?.found ? 'برای این بیمار کد ملی ثبت نشده — لطفاً تکمیل کنید:' : 'بیماری با این مشخصات یافت نشد — بیمار جدید:'}
</div>
<div className="field-block" style={{ marginBottom: 14 }}>
<label>نام و نام خانوادگی بیمار <span className="req">*</span></label>
<div className="field">
<input
type="text"
value={patientName}
onChange={e => setPatientName(e.target.value)}
placeholder="مثال: علی محمدی"
/>
</div>
</div>
{/* در جستجو با کد ملی، موبایل هنوز نامعلوم است و برای ثبت لازم می‌شود. */}
{searchBy === 'national' && (
<div className="field-block" style={{ marginBottom: 14 }}>
<label>شماره موبایل بیمار <span className="req">*</span></label>
<div className="field">
<input
type="tel"
inputMode="numeric"
maxLength={11}
value={mobile}
onChange={e => setMobile(sanitizeMobileInput(e.target.value))}
placeholder="مثال: 09123456789"
style={{ direction: 'ltr' }}
/>
</div>
</div>
)}
{/* در جستجو با کد ملی، همان مقدار کلیدِ جستجو استفاده می‌شود و فیلد تکراری لازم نیست. */}
{searchBy === 'mobile' && (
<div className="field-block" style={{ marginBottom: 14 }}>
<label>کد ملی بیمار <span className="req">*</span></label>
<div className="field">
<input
type="text"
inputMode="numeric"
lang="en"
maxLength={10}
value={nationalCode}
onChange={e => setNationalCode(digitsOnly(e.target.value, 10))}
placeholder="کد ملی ۱۰ رقمی"
style={{ direction: 'ltr' }}
/>
</div>
</div>
)}
</>
)}
<div className="field-block">
{requireVisit ? (
<label>
هزینه ویزیت (تومان)<span className="req"> *</span>
</label>
) : (
// سرِ کلپس — با کلیک باز/بسته می‌شود (فقط وقتی اختیاری است).
<button
type="button"
onClick={() => setVisitPriceOpen(o => !o)}
aria-expanded={visitPriceExpanded}
style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
background: 'none', border: 'none', cursor: 'pointer', font: 'inherit',
padding: 0, color: 'var(--text)',
}}
>
<span>هزینه ویزیت (تومان) <span className="opt">(اختیاری)</span></span>
<ChevronDownIcon
style={{
width: 15, height: 15, marginInlineStart: 'auto', flexShrink: 0,
transition: 'transform .2s var(--ease)',
transform: visitPriceExpanded ? 'rotate(180deg)' : 'none',
}}
/>
</button>
)}
{visitPriceExpanded && (
<>
<div
className="field"
style={requireVisit && visitPriceToman <= 0 ? { borderColor: 'var(--danger)' } : undefined}
>
<PriceInput
value={visitPriceToman}
onChange={(v) => { setVisitPriceToman(v); setVisitPriceTouched(true); }}
suffix="تومان"
/>
</div>
{requireVisit && visitPriceToman <= 0
? <span className="field-err">هزینه ویزیت الزامی است</span>
: <span className="field-hint">{priceHint}</span>}
{freeVisit > 0 && visitPriceToman !== rialToToman(freeVisit) && (
<button
type="button"
className="btn ghost sm"
style={{ marginTop: 8, alignSelf: 'flex-start' }}
onClick={() => { setVisitPriceToman(rialToToman(freeVisit)); setVisitPriceTouched(true); }}
>
استفاده از تعرفهٔ پزشک
</button>
)}
</>
)}
</div>
</Modal>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 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<TurnsViewMode>('timeline');
const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>(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<ClinicResource | null>(null);
const activeResource = bookableResources.find((r) => r.uuid === selectedResourceUuid) ?? null;
/**
* منبع زیرمجموعهٔ پزشک است، نه رقیبش: انتخاب یک منبع فقط نما را داخل همان پزشک
* تنگ می‌کند. پاک کردن پزشک، نوبت‌دهی خودِ او را از دسترس خارج می‌کرد.
*/
const selectResource = (uuid: string) => setUrlState({ resource: uuid });
/** برگشت به خودِ پزشک: نمای منبع بسته می‌شود. */
const selectDoctor = (uuid: string) => {
setSelectedDoctorUuid(uuid);
if (selectedResourceUuid) setUrlState({ resource: '' });
};
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, 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<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; has_schedule?: boolean }[] }>>({
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<string, string>();
// پزشکِ بدون برنامهٔ کاری تب نمی‌گیرد — تبش جز یک تایم‌لاین همیشه‌خالی چیزی ندارد.
// نبودِ فیلد (مسیر ادمین) یعنی «نمی‌دانیم»، پس پنهان نمی‌شود.
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<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 || 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<ApiResponse<any>>({
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<string | null>(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<ApiResponse<any>>({
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<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 || !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 (
<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 }))}
/>
{isAdmin && adminLocations.length > 1 && (
<div style={{ minWidth: 220 }}>
<SearchableSelect
options={adminLocations.map((l: any) => ({
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}
/>
</div>
)}
<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 && canCreateAppt && (
<button
className="btn secondary sm"
onClick={() => navigate('/admin/resource-booking')}
style={{ display: 'flex', alignItems: 'center', gap: 5 }}
>
نوبت منبع‌محور
</button>
)}
{!isRepresentation && canCreateAppt && (
<button
className="btn primary sm"
onClick={() => {
// تب منبع فعال است ⇒ نوبت برای همان منبع، با سرویس‌های خودش.
if (activeResource) { setBookingResource(activeResource); return; }
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={selectDoctor}
showAll={isAdmin}
/>
)}
{/* منابعِ همین پزشک، نه همهٔ منابع: ارتباط پزشک↔منبع روی خودِ منبع تعریف شده
(پزشک ناظر)، پس کاربر بین نوبت‌های پزشک و دستگاه‌های تحت نظرش جابه‌جا می‌شود. */}
{supervisedResources.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '0 16px', borderBottom: '1px solid var(--border)' }}>
<span style={{ fontSize: 12, color: 'var(--text-3)', flexShrink: 0 }}>نوبت‌دهی</span>
{/* تب نخست راهِ بازگشت است: بدون آن، برگشتن از منبع به خودِ پزشک فقط با
کلیک روی تب بالایی ممکن بود و کاربر دنبالش می‌گشت. */}
<DoctorTabs
doctors={[
{ uuid: '', name: 'نوبت‌های خود پزشک' },
...supervisedResources.map((r) => ({ uuid: r.uuid, name: r.name })),
]}
selected={selectedResourceUuid}
onSelect={selectResource}
showAll={false}
/>
</div>
)}
<div style={{ padding: 16 }}>
{viewMode === 'table' ? (
<>
<TurnsTable
items={pagedAppointments}
loading={apptQuery.isLoading}
queryKey={apptQueryKey}
showDoctor={showDoctorCol}
canManage={canManageAppt}
canCancel={canCancelAppt}
/>
{filteredAppointments.length > TABLE_PAGE_SIZE && (
<div style={{ marginTop: 14 }}>
<Pagination page={tablePage} total={filteredAppointments.length} limit={TABLE_PAGE_SIZE} onPageChange={setTablePage} />
</div>
)}
</>
) : activeResource ? (
/* منبع اسلات ندارد: تقویمش سرویسی است، پس تایم‌لاین اسلاتیِ پزشک اینجا
اصلاً رندر نمی‌شود — فهرست نوبت‌های همین منبع و یک ورودیِ سرویسی. */
<ResourceDayPanel
resource={activeResource}
appointments={filteredAppointments}
loading={apptQuery.isLoading}
canCreate={!isRepresentation && canCreateAppt}
onBook={() => setBookingResource(activeResource)}
onView={openDetail}
/>
) : (
<>
{!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}
errorMessage={slotsQuery.isError ? ((slotsQuery.error as Error)?.message || 'خطای نامشخص') : null}
/>
</>
)}
</>
)}
</div>
</div>
</div>
{bookingResource && (
<ResourceBookingModal
resource={bookingResource}
onClose={() => setBookingResource(null)}
onBooked={() => qc.invalidateQueries({ queryKey: apptQueryKey })}
/>
)}
{/* مودال ثبت سریع نوبت */}
{bookingSlot && (
<NewAppointmentModal
slot={bookingSlot}
serviceMode={serviceMode}
services={services}
date={selectedDate}
clinicUuid={effectiveClinicUuid}
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>
);
}