import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { MagnifyingGlassIcon, PencilSquareIcon, XMarkIcon } from '@heroicons/react/24/outline'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import PageHeader from '../components/ui/PageHeader'; import StatusBadge from '../components/ui/StatusBadge'; import { formatDate, formatDateTime, formatNumber } from '../lib/utils'; import { useUrlState } from '../hooks/useUrlState'; import PersianDateInput from '../components/ui/PersianDateInput'; import TreatmentCaseEditModal from '../components/TreatmentCaseEditModal'; import type { TreatmentCaseSummary, StaffTreatmentSession, SlotSuggestionResponse } from '../types'; const TABS = [ { id: 'cases', label: 'پرونده‌های درمان' }, { id: 'unbooked', label: 'جلسات بدون نوبت' }, ] as const; type TabId = typeof TABS[number]['id']; const CASE_STATUS_LABEL: Record = { active: 'در جریان', completed: 'تمام شده', abandoned: 'رها شده', }; /** * پرونده‌های درمان و کارِ باقی‌ماندهٔ منشی. * * صفِ «جلسات بدون نوبت» عمداً کنار فهرست پرونده‌هاست نه صفحهٔ جدا: هر دو یک سؤال * را جواب می‌دهند — «کدام بیمار در چه مرحله‌ای است و چه کاری مانده». */ export default function TreatmentCasesPage() { const [urlState, setUrlState] = useUrlState({ tab: 'cases', status: '', q: '', from: '', to: '' }); const tab = (TABS.some((t) => t.id === urlState.tab) ? urlState.tab : 'cases') as TabId; return ( <>
{TABS.map((t) => ( ))}
{tab === 'cases' ? setUrlState({ status: s })} search={urlState.q} onSearch={(q) => setUrlState({ q })} from={urlState.from} onFrom={(from) => setUrlState({ from })} to={urlState.to} onTo={(to) => setUrlState({ to })} /> : } ); } const STATUS_FILTERS = [ ['', 'همه'], ['active', 'در جریان'], ['completed', 'تمام شده'], ['abandoned', 'رها شده'], ] as const; function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo }: { status: string; onStatus: (s: string) => void; search: string; onSearch: (s: string) => void; /** بازهٔ تاریخِ باز شدن پرونده، `YYYY-MM-DD` میلادی. خالی = بدون کران. */ from: string; onFrom: (s: string) => void; to: string; onTo: (s: string) => void; }) { // فیلد جستجو محلی می‌ماند و فقط مقدار نهایی به URL می‌رود؛ وگرنه هر حرف یک ورودی // تاریخچه می‌سازد و «بازگشت» بی‌معنی می‌شود. const [term, setTerm] = useState(search); useEffect(() => setTerm(search), [search]); useEffect(() => { const t = setTimeout(() => { if (term !== search) onSearch(term); }, 350); return () => clearTimeout(t); }, [term]); const [editing, setEditing] = useState(null); const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['treatment-cases', status, search, from, to], queryFn: () => { const qs = new URLSearchParams(); if (status) qs.set('status', status); if (search) qs.set('q', search); if (from) qs.set('from', from); if (to) qs.set('to', to); const suffix = qs.toString(); return api.get>( `/api/v1/treatment-cases${suffix ? `?${suffix}` : ''}`, ); }, staleTime: 30_000, }); const cases = data?.data ?? []; return ( <>
setTerm(e.target.value)} placeholder="نام بیمار، پرسنل، موبایل، کد ملی، شمارهٔ پرونده یا سرویس" aria-label="جستجوی پرونده" /> {term !== '' && ( )}
{STATUS_FILTERS.map(([v, label]) => ( ))}
{/* بازه روی تاریخِ باز شدن پرونده است — همان چیزی که در کارت زیر «شروع» می‌آید. */}
شروع {/* دو تاریخ و «تا»ی بینشان یک واحدند: اگر جدا بشکنند، «تا» از فیلدش می‌افتد و معلوم نیست کران بالا کدام است. */}
تا
{(from !== '' || to !== '') && ( )}
{from !== '' && to !== '' && from > to && (
«از تاریخ» بعد از «تا تاریخ» است، پس هیچ پرونده‌ای در این بازه نمی‌افتد.
)} {isLoading ? (
{[0, 1].map((i) =>
)}
) : isError ? ( /* خطای سرور نباید «پرونده‌ای یافت نشد» خوانده شود — آن یعنی جستجو نتیجه نداشت. */
خواندن پرونده‌ها ناموفق بود.
) : cases.length === 0 ? (
{search ? `برای «${search}» پرونده‌ای پیدا نشد.` : (from !== '' || to !== '') ? 'در این بازهٔ تاریخ پرونده‌ای باز نشده است.' : 'پرونده‌ای یافت نشد. پرونده وقتی ساخته می‌شود که نوبتِ سرویسی با «طول درمان» قطعی شود.'}
) : (
{cases.map((c) => setEditing(c.uuid)} />)}
)} {editing !== null && ( setEditing(null)} /> )} ); } function CaseCard({ item: c, onEdit }: { item: TreatmentCaseSummary; onEdit: () => void }) { const percent = c.total_sessions > 0 ? Math.round((c.completed_sessions / c.total_sessions) * 100) : 0; return (
{/* بیمار سرتیتر است نه سرویس: دو پروندهٔ یک سرویس فقط با نام بیمار از هم جدا می‌شوند. */} {c.patient.name || 'بیمار بدون نام'} {CASE_STATUS_LABEL[c.status]} {formatNumber(c.completed_sessions)} از {formatNumber(c.total_sessions)} جلسه
{c.service.name} {c.patient.mobile} {/* ساعت هم لازم است: چند پروندهٔ یک روز فقط با ساعت از هم جدا می‌شوند. */} شروع: {formatDateTime(c.opened_at)} {c.supervisor && پزشک ناظر: {c.supervisor.name}} {/* انجام‌دهنده از جلسات می‌آید (سابقه) و اختصاص‌یافته برنامه است؛ تا وقتی جلسه‌ای انجام نشده، همان برنامه را نشان می‌دهیم. */} {c.performed_by.length > 0 ? ( انجام‌دهنده: {c.performed_by.map((s) => s.name).join('، ')} ) : c.assigned_staff.length > 0 ? ( اپراتور: {c.assigned_staff.map((s) => s.name).join('، ')} (هنوز انجام نشده) ) : null} {c.areas.length > 0 && نواحی: {c.areas.map((a) => a.name).join('، ')}}
{/* `` نیتیو ظاهر مرورگر را می‌گیرد و با توکن‌های تم نمی‌خواند. */}
); } function UnbookedTab() { const { data, isLoading } = useQuery({ queryKey: ['treatment-sessions-unbooked'], queryFn: () => api.get>('/api/v1/treatment-sessions/unbooked?within_days=14'), staleTime: 30_000, }); const sessions = data?.data ?? []; return ( <>

جلساتی که سررسیدشان رسیده و هنوز نوبت نگرفته‌اند. رزرو عمداً خودکار نیست — وقتِ مناسب را باید با خود بیمار هماهنگ کرد.

{isLoading ? (
در حال بارگذاری...
) : sessions.length === 0 ? (
جلسه‌ای در انتظار رزرو نیست.
) : (
{sessions.map((s) => (
{s.service_name} جلسهٔ {s.session_number} از {s.total_sessions}
{s.due_at !== null && ( سررسید: {formatDate(s.due_at)} )}
))}
)} ); } /** * وقت‌های آزادِ همان دستگاهی که جلسهٔ قبلی رویش انجام شد. * * پیشنهاد است نه رزرو: منشی با بیمار هماهنگ می‌کند و بعد از فرم عادی نوبت ثبتش * می‌کند. خودکار رزرو کردن یعنی سیستم به‌جای بیمار تصمیم بگیرد و بعد او نیاید. */ function SlotSuggestions({ sessionUuid }: { sessionUuid: string }) { const [open, setOpen] = useState(false); const { data, isLoading, isError } = useQuery({ queryKey: ['session-slot-suggestions', sessionUuid], queryFn: () => api.get>( `/api/v1/treatment-session/${sessionUuid}/slot-suggestions?days=14`, ), enabled: open, staleTime: 60_000, }); if (!open) { return (
ثبت نوبت این جلسه
); } const days = data?.data?.days ?? []; return (
{isLoading && در حال جست‌وجوی وقت...} {isError && ( دستگاهی برای پیشنهاد وقت مشخص نیست — این جلسه هنوز روی هیچ دستگاهی انجام نشده. )} {!isLoading && !isError && days.length === 0 && ( در دو هفتهٔ آینده وقت آزادی روی این دستگاه نیست. )} {days.slice(0, 3).map((day) => (
{formatDate(Math.floor(new Date(day.date).getTime() / 1000))} {day.slots.slice(0, 6).map((slot) => ( {slot.start_time} ))}
))}
); }