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 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: '' }); 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 })} /> : } ); } const STATUS_FILTERS = [ ['', 'همه'], ['active', 'در جریان'], ['completed', 'تمام شده'], ['abandoned', 'رها شده'], ] as const; function CasesTab({ status, onStatus, search, onSearch }: { status: string; onStatus: (s: string) => void; search: string; onSearch: (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], queryFn: () => { const qs = new URLSearchParams(); if (status) qs.set('status', status); if (search) qs.set('q', search); 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]) => ( ))}
{isLoading ? (
{[0, 1].map((i) =>
)}
) : isError ? ( /* خطای سرور نباید «پرونده‌ای یافت نشد» خوانده شود — آن یعنی جستجو نتیجه نداشت. */
خواندن پرونده‌ها ناموفق بود.
) : cases.length === 0 ? (
{search ? `برای «${search}» پرونده‌ای پیدا نشد.` : 'پرونده‌ای یافت نشد. پرونده وقتی ساخته می‌شود که نوبتِ سرویسی با «طول درمان» قطعی شود.'}
) : (
{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} شروع: {formatDate(c.opened_at)} {c.supervisor && پزشک ناظر: {c.supervisor.name}} {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} ))}
))}
); }