import { useEffect, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ClipboardDocumentListIcon, CpuChipIcon, MagnifyingGlassIcon, MinusIcon, PencilSquareIcon, PlusIcon, XMarkIcon, } from '@heroicons/react/24/outline'; import { api } from '../../lib/api'; import type { ApiResponse } from '../../lib/api'; import StatusBadge from '../ui/StatusBadge'; import TreatmentCaseEditModal from '../TreatmentCaseEditModal'; import Pagination from '../ui/Pagination'; import NewAppointmentModal from '../appointments/NewAppointmentModal'; import { useResourceBookingServices } from '../../hooks/useResourceBookingServices'; import { useClinicContext } from '../../hooks/useClinicContext'; import { useUrlState } from '../../hooks/useUrlState'; import { formatDate, formatDateTime, formatNumber } from '../../lib/utils'; import type { TreatmentCaseSummary, SessionAreaRecord } from '../../types'; interface PlanSession { uuid: string; session_number: number; total_sessions: number; status: string; planned_at: number | null; is_estimate: boolean; appointment: { uuid: string; slot_start: number } | null; performed_by: { uuid: string; name: string } | null; areas?: SessionAreaRecord[]; } interface PlanResponse { case: TreatmentCaseSummary & { patient_national_code: string | null }; resource: { uuid: string; name: string } | null; sessions: PlanSession[]; } /** * آینهٔ `TreatmentProtocol::MIN_STEPS/MAX_STEPS` در بک‌اند. * * قفلِ واقعی سمت سرور است؛ این فقط جلوی درخواستی را می‌گیرد که جوابش از پیش معلوم * است — دکمه‌ای که می‌دانیم ۴۲۲ می‌گیرد نباید فعال بماند. */ const MIN_SESSIONS = 2; const MAX_SESSIONS = 60; const CASE_STATUS_LABEL: Record = { active: 'در جریان', completed: 'تمام شده', abandoned: 'رها شده', }; const SESSION_STATUS_TEXT: Record = { planned: 'برنامه‌ریزی شده', booked: 'زمان‌بندی شده', in_progress: 'در حال انجام', done: 'انجام شد', cancelled: 'لغو شده', no_show: 'غیبت', }; const SETTLED = ['done', 'cancelled', 'no_show']; const PAGE_SIZE = 8; /** `planned_at` (ثانیه) → `YYYY-MM-DD` میلادی، همان چیزی که مودال می‌خواهد. */ function isoDay(ts: number): string { const d = new Date(ts * 1000); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } /** جستجو روی همان چیزهایی که در کارت دیده می‌شوند، نه فیلدهای پنهان. */ function matches(s: PlanSession, serviceName: string, term: string): boolean { if (term === '') return true; return [ serviceName, s.performed_by?.name ?? '', SESSION_STATUS_TEXT[s.status] ?? s.status, String(s.session_number), formatNumber(s.session_number), ...(s.areas ?? []).map((a) => a.area.name), ].join(' ').includes(term); } /** * دوره‌های درمانِ همین بیمار. * * دو نما دارد و نه یکی: فهرستِ کارتِ دوره‌ها، و با کلیک، جزئیاتِ همان دوره. یک دورهٔ * شصت‌جلسه‌ای بازشده کنار بقیه، صفحه را غیرقابل‌خواندن می‌کرد. * * انتخاب در URL می‌نشیند تا «بازگشت» مرورگر همان دوره را برگرداند. */ export default function PatientTreatmentTab({ recordUuid }: { recordUuid: string }) { const [urlState, setUrlState] = useUrlState({ case: '', view: 'sessions' }); const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['patient-treatment-cases', recordUuid], queryFn: () => api.get>( `/api/v1/treatment-cases?record=${encodeURIComponent(recordUuid)}`, ), enabled: recordUuid !== '', staleTime: 30_000, }); const cases = data?.data ?? []; const selected = cases.find((c) => c.uuid === urlState.case) ?? null; if (isLoading) { return (
{[0, 1].map((i) =>
)}
); } if (isError) { return (
خواندن دوره‌های این بیمار ناموفق بود.
); } if (cases.length === 0) { return (
این بیمار دورهٔ درمان ندارد.
دوره وقتی ساخته می‌شود که نوبتِ سرویسی با «طول درمان» قطعی شود.
); } if (selected !== null) { return ( setUrlState({ view: v })} onBack={() => setUrlState({ case: '', view: 'sessions' })} /> ); } return (
{cases.map((c) => ( setUrlState({ case: c.uuid, view: 'sessions' })} /> ))}
); } /** کارتِ خلاصهٔ یک دوره — کل کارت دکمه است، نه لینکی در گوشه‌اش. */ function CaseCard({ summary: c, onOpen }: { summary: TreatmentCaseSummary; onOpen: () => void }) { const percent = c.total_sessions > 0 ? Math.round((c.completed_sessions / c.total_sessions) * 100) : 0; const operator = c.performed_by.length > 0 ? c.performed_by.map((s) => s.name).join('، ') : c.assigned_staff.map((s) => s.name).join('، '); return ( ); } function CaseDetail({ summary, view, onView, onBack }: { summary: TreatmentCaseSummary; view: 'sessions' | 'upcoming'; onView: (v: 'sessions' | 'upcoming') => void; onBack: () => void; }) { const [term, setTerm] = useState(''); const [page, setPage] = useState(1); const [editing, setEditing] = useState(false); const qc = useQueryClient(); useEffect(() => setPage(1), [term, view]); /** * کم/زیاد کردن جلسات همین دوره. * * پیش از این فقط از صفحهٔ «دوره‌های درمان» و پشت یک مودال ممکن بود، در حالی که * تصمیمش سرِ پروندهٔ بیمار گرفته می‌شود. سرور جلسهٔ انجام‌شده یا نوبت‌دار را حذف * نمی‌کند و ۴۰۹ برمی‌گرداند؛ پیام همان خطا نشان داده می‌شود چون دلیلش را دقیق‌تر * از هر متن ثابتی می‌گوید (چند جلسه قفل است). */ const setTotalSessions = useMutation({ mutationFn: (total: number) => api.patch>(`/api/v1/treatment-case/${summary.uuid}`, { total_sessions: total }), onSuccess: () => { qc.invalidateQueries({ queryKey: ['patient-treatment-cases'] }); qc.invalidateQueries({ queryKey: ['treatment-case-plan', summary.uuid] }); }, onError: (e: Error) => toast.error(e.message || 'تغییر تعداد جلسات ناموفق بود'), }); const total = summary.total_sessions; const completed = summary.completed_sessions; const percent = total > 0 ? Math.round((completed / total) * 100) : 0; const busy = setTotalSessions.isPending; // کفِ واقعی، جلسات قفل‌شده است؛ سرور همان را با ۴۰۹ می‌گوید ولی دکمهٔ فعالِ // بی‌اثر بدتر از دکمهٔ خاموش است. const minAllowed = Math.max(MIN_SESSIONS, completed); const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['treatment-case-plan', summary.uuid], queryFn: () => api.get>(`/api/v1/treatment-case/${summary.uuid}/plan`), staleTime: 30_000, }); const all = data?.data?.sessions ?? []; const scoped = view === 'upcoming' ? all.filter((s) => !SETTLED.includes(s.status)) : all; const sessions = scoped.filter((s) => matches(s, summary.service.name, term.trim())); const paged = sessions.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); const upcomingCount = all.filter((s) => !SETTLED.includes(s.status)).length; return (
{summary.service.name} {CASE_STATUS_LABEL[summary.status]}
{/* تعداد جلسات — تصمیمی که سرِ همین صفحه گرفته می‌شود، پس همین‌جا هم تغییر می‌کند. */}
تعداد جلسات
{formatNumber(total)}
{formatNumber(completed)} از {formatNumber(total)} جلسه انجام شده
{total <= minAllowed && ( {completed >= total ? 'همهٔ جلسه‌ها انجام شده‌اند؛ کم کردن ممکن نیست.' : `کمتر از ${formatNumber(MIN_SESSIONS)} جلسه ممکن نیست.`} )}
{([['sessions', 'جزئیات دوره'], ['upcoming', `نوبت‌های آینده (${formatNumber(upcomingCount)})`]] as const).map( ([v, label]) => ( ), )}
setTerm(e.target.value)} placeholder="پرسنل، وضعیت، ناحیه یا شمارهٔ جلسه" aria-label="جستجوی جلسات" /> {term !== '' && ( )}
{isLoading ? (
{[0, 1].map((i) =>
)}
) : isError ? (
خواندن تقویم این دوره ناموفق بود.
) : sessions.length === 0 ? (
{term !== '' ? `جلسه‌ای با «${term}» پیدا نشد.` : view === 'upcoming' ? 'جلسهٔ باقی‌مانده‌ای در این دوره نیست.' : 'این دوره جلسه‌ای ندارد.'}
) : (
{paged.map((s) => ( ))}
)} {editing && ( setEditing(false)} /> )}
); } function SessionCard({ session: s, summary, resource, nationalCode }: { session: PlanSession; summary: TreatmentCaseSummary; resource: { uuid: string; name: string } | null; nationalCode: string | null; }) { const [booking, setBooking] = useState(false); /** * سابقه بسته می‌آید و کارِ پیشِ رو باز. * * جلسهٔ انجام‌شده سه ناحیه با خوانده‌هایشان دارد و بازِ پیش‌فرض، فهرست را دفن * می‌کند؛ ولی جلسه‌ای که باید رزرو شود، دکمه‌اش دلیلِ وجودش است و پشت یک کلیک * اضافه نباید بماند. */ const [open, setOpen] = useState(!SETTLED.includes(s.status)); const qc = useQueryClient(); const clinicUuid = useClinicContext(); const supervisor = summary.supervisor; // سرویس‌ها از تقویم منبع می‌آیند نه از برنامهٔ پزشک — دورهٔ لیزر روی دستگاه رزرو // می‌شود و برنامهٔ پزشکِ ناظر سرویسی برای انتخاب ندارد. const { services } = useResourceBookingServices(booking ? resource?.uuid : null); const bookable = !SETTLED.includes(s.status) && s.appointment === null && summary.status === 'active'; const areas = s.areas ?? []; // جلسه‌ای که نه ناحیه‌ای ثبت کرده و نه کاری برای انجام دارد، بدنه‌ای ندارد که باز // شود؛ کلپسِ خالی فقط یک کلیک بی‌نتیجه است. const hasBody = areas.length > 0 || bookable; const head = (
جلسهٔ {formatNumber(s.session_number)} از {formatNumber(s.total_sessions)} {s.planned_at === null ? '—' : formatDateTime(s.planned_at)} {/* تخمین با واقعیت یکی نیست و باید در خودِ کارت معلوم باشد. */} {s.is_estimate && تخمینی} {s.performed_by && ( پرسنل: {s.performed_by.name} )} {/* شمارهٔ نواحی در سرِ بسته می‌ماند تا باز کردن، حدس نباشد. */} {areas.length > 0 && !open && ( {formatNumber(areas.length)} ناحیه )} {hasBody && ( )}
); return (
{hasBody ? ( ) : head} {/* آنچه واقعاً انجام شد: ناحیه، دستگاه و خوانده‌هایش. */} {open && areas.length > 0 && (
{areas.map((a) => (
{a.area.name} {a.resource && ( {a.resource.name} )} {a.parameters && Object.entries(a.parameters).map(([k, v]) => ( {k}: {String(v)} ))} {a.note && یادداشت: {a.note}}
))}
)} {open && bookable && ( /* بدون دستگاه، فرم سرویسی برای انتخاب ندارد؛ دکمهٔ خاموشِ بی‌توضیح بدتر از نبودنش است، پس دلیلش نوشته می‌شود. */ resource === null ? ( هنوز هیچ جلسه‌ای از این دوره روی دستگاهی انجام نشده — نوبت را از صفحهٔ نوبت‌ها ثبت کنید. ) : ( ) )} {booking && resource && ( setBooking(false)} onSuccess={() => { qc.invalidateQueries({ queryKey: ['treatment-case-plan', summary.uuid] }); qc.invalidateQueries({ queryKey: ['patient-treatment-cases'] }); qc.invalidateQueries({ queryKey: ['patient-appointments'] }); }} /> )}
); }