import React, { useCallback, useMemo, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import PageHeader from '../components/ui/PageHeader'; import SearchableSelect from '../components/ui/SearchableSelect'; import HoldCountdown from '../components/HoldCountdown'; import { formatDate, formatNumber } from '../lib/utils'; import { useBranches } from '../hooks/useBranches'; import { useAllServiceItems } from '../hooks/useServiceCatalog'; import { REASON_LABELS, useAvailabilitySearch, useHold, type AvailableSlot, type HoldResult, } from '../hooks/useResourceBooking'; import { api, ApiError, type ApiResponse } from '../lib/api'; import { useQuery } from '@tanstack/react-query'; const DAY = 86400; function timeOf(ts: number): string { return new Date(ts * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }); } /** * رزرو نوبت در حالت منبع‌محور. * * سه مرحلهٔ جدا و عمداً جدا: جستجو → گرفتن موقت → ثبت نهایی. بین دومی و سومی یک مهلت * هست و صفحه آن را با شمارش معکوس نشان می‌دهد؛ رزروی که در سکوت منقضی شود، اپراتور را * با یک ۴۰۹ بی‌توضیح تنها می‌گذارد. */ export default function ResourceBookingPage() { const navigate = useNavigate(); const [params] = useSearchParams(); const { branches } = useBranches(); const { items: services } = useAllServiceItems(); const { create, release, confirm, rebook } = useHold(); /** * حالت جابه‌جایی — همان سه مرحله، با یک تفاوت در گام آخر. * * صفحهٔ جدا نساختیم چون جستجو و رزرو موقت دقیقاً همان‌اند؛ چیزی که فرق می‌کند فقط * این است که در پایان به‌جای «ثبت نوبت تازه»، نوبت موجود جابه‌جا می‌شود و زمان قدیم * در همان درخواست آزاد می‌شود. */ const rebookUuid = params.get('rebook'); const [serviceUuid, setServiceUuid] = useState(''); const [branchUuid, setBranchUuid] = useState(''); const [days, setDays] = useState('7'); const [searching, setSearching] = useState(false); const [doctorUuid, setDoctorUuid] = useState(''); const [hold, setHold] = useState(null); const [expired, setExpired] = useState(false); /** جایگزینی منبع per نقش، فقط برای همان زمانِ انتخاب‌شده. */ const [picked, setPicked] = useState | null>(null); const [pickedSlot, setPickedSlot] = useState(null); /** * پزشکِ نوبت — ثبت نهایی بدونش ممکن نیست. * * منشی از اندپوینت احرازشده می‌گیرد تا فقط پزشکان تخصیص‌یافته‌اش بیایند؛ همان * قاعده‌ای که صفحهٔ نوبت‌ها از قبل دارد. */ const doctorsQuery = useQuery>({ queryKey: ['booking-doctors'], queryFn: () => api.get('/api/v1/my/clinic-doctors'), staleTime: 60_000, }); const doctors = doctorsQuery.data?.data?.data ?? []; const range = useMemo(() => { const from = Math.floor(Date.now() / 1000); return { from, to: from + Number(days) * DAY }; }, [days]); const { result, loading, error, refetch } = useAvailabilitySearch( { serviceUuid, branchUuid, from: range.from, to: range.to }, searching, ); const onExpired = useCallback(() => setExpired(true), []); const chooseSlot = (slot: AvailableSlot) => { setPickedSlot(slot); setPicked( Object.fromEntries( Object.entries(slot.assignment).map(([role, resources]) => [role, resources.map((r) => r.uuid)]), ), ); }; /** * گزینه‌های جایگزین یک نقش: منابعی که در **همین زمان** پیشنهاد شده‌اند. * * فهرست کاملِ منابع شعبه اینجا غلط است — منبعی که موتور برای این زمان نداده، آزاد * نبوده، و نشان دادنش یعنی اپراتور چیزی انتخاب کند که ۴۰۹ می‌گیرد. */ const optionsFor = (role: string): { value: string; label: string }[] => (pickedSlot?.assignment[role] ?? []).map((r) => ({ value: r.uuid, label: r.name })); const takeHold = async () => { if (!pickedSlot || !picked) return; try { const created = await create.mutateAsync({ service_uuid: serviceUuid, branch_uuid: branchUuid, start: pickedSlot.start, assignment: picked, }); setHold(created.data); setExpired(false); } catch (e) { // ۴۰۹ یعنی همین لحظه کس دیگری گرفت. گفتنش کافی نیست: کاربر باید بلافاصله // جایگزین ببیند، وگرنه باید دستی دوباره جستجو بزند و بختش را از نو امتحان کند. if (e instanceof ApiError && e.status === 409) { setPickedSlot(null); setPicked(null); await refetch(); } } }; const reasonText = result?.reason ? REASON_LABELS[result.reason] ?? result.reason : null; return (
{ setServiceUuid(String(v ?? '')); setPickedSlot(null); }} options={services.map((s) => ({ value: s.uuid, label: s.name }))} placeholder="انتخاب خدمت" />
{ setBranchUuid(String(v ?? '')); setPickedSlot(null); }} options={branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))} placeholder="انتخاب شعبه" />
setDays(String(v ?? '7'))} options={[ { value: '7', label: 'یک هفته' }, { value: '30', label: 'یک ماه' }, { value: '90', label: 'سه ماه' }, ]} />
{error && (
{error instanceof ApiError ? error.message : 'جستجوی وقت ناموفق بود'}
)} {result && (
مدت نوبت: {formatNumber(result.plan.total_minutes)} دقیقه {formatNumber(result.plan.segments.length)} بخش · {formatNumber(result.slots.length)} وقت پیدا شد
{/* فهرست خالی خطا نیست؛ دلیلش را می‌گوییم تا کاربر حدس نزند. */} {result.slots.length === 0 && reasonText && ( {reasonText} )}
)} {result && result.slots.length > 0 && (
{result.slots.slice(0, 100).map((slot) => ( ))}
تاریخ ساعت منابع پیشنهادی
{formatDate(slot.start)} {timeOf(slot.start)} – {timeOf(slot.end)} {Object.values(slot.assignment) .flat() .map((r) => r.name) .join('، ')}
)} {pickedSlot && (

{formatDate(pickedSlot.start)} · {timeOf(pickedSlot.start)}

{hold && !expired && } {expired && ( مهلت تمام شد — دوباره جستجو کنید )}
{Object.entries(pickedSlot.assignment).map(([role, resources]) => (
{role}
setPicked((prev) => ({ ...(prev ?? {}), [role]: [String(v ?? '')] })) } options={optionsFor(role)} isDisabled={hold !== null} />
))}
فهرست هر نقش فقط منابعی است که در همین زمان آزادند؛ تخصیص منابع به بیمار نمایش داده نمی‌شود. {/* در جابه‌جایی پزشک عوض نمی‌شود؛ پرسیدنش یعنی دعوت به تغییری که خواسته نشده. */} {!rebookUuid && (
setDoctorUuid(String(v ?? ''))} options={doctors.map((d) => ({ value: d.uuid, label: d.name }))} placeholder="انتخاب پزشک" />
)}
{hold === null ? ( ) : ( <> {rebookUuid ? ( ) : ( )} )}
)}
); }