import React, { useState } from 'react'; import { useParams } from 'react-router-dom'; import PageHeader from '../components/ui/PageHeader'; import DataTable, { type Column } from '../components/ui/DataTable'; import ConfirmDialog from '../components/ui/ConfirmDialog'; import SearchableSelect from '../components/ui/SearchableSelect'; import { formatDate, formatNumber } from '../lib/utils'; import { usePermissions } from '../hooks/usePermissions'; import { useBranches } from '../hooks/useBranches'; import { useNextSlotSuggestion, useTreatmentCourse } from '../hooks/useCourses'; import type { CourseSessionRow } from '../types'; const SESSION_STATUS: Record = { planned: { label: 'برنامه‌ریزی‌شده', className: 'badge' }, booked: { label: 'رزروشده', className: 'badge amber' }, completed: { label: 'انجام‌شده', className: 'badge green' }, skipped: { label: 'رد‌شده', className: 'badge red' }, }; /** * یک دورهٔ درمان: پیشرفت، جلسات، و پیشنهاد تاریخ جلسهٔ بعدی. * * پیشنهاد به شعبه وابسته است (ظرفیت هر شعبه فرق دارد)، پس تا شعبه انتخاب نشود چیزی * پرسیده نمی‌شود. */ export default function TreatmentCoursePage() { const { courseUuid } = useParams<{ courseUuid: string }>(); const { course, loading, abandon } = useTreatmentCourse(courseUuid); const { branches } = useBranches(); const { can } = usePermissions(); const canManage = can('appointment_settings', 'update'); const [branchUuid, setBranchUuid] = useState(''); const [abandoning, setAbandoning] = useState(false); const [reason, setReason] = useState(''); const { suggestion } = useNextSlotSuggestion(courseUuid, branchUuid || undefined); /** * دوره‌ای که وسطش لغو شده، بی‌صدا کِش می‌آید: جلسه به «برنامه‌ریزی‌شده» برمی‌گردد و * هیچ‌کس خبردار نمی‌شود. * * هشدارِ پیشنهاد فقط وقتی می‌آید که شعبه انتخاب شده باشد؛ این یکی از خودِ دوره حساب * می‌شود، پس بلافاصله دیده می‌شود. مبنا آخرین جلسهٔ **دارای تاریخ** است — همان لنگری * که پروتکل با آن فاصله می‌سنجد. */ const overdue = React.useMemo(() => { if (!course || course.status !== 'active') return null; const sessions = course.sessions ?? []; const dated = sessions.filter((s) => s.slot_start !== null); const remaining = sessions.filter((s) => s.status === 'planned').length; if (dated.length === 0 || remaining === 0) return null; const last = Math.max(...dated.map((s) => s.slot_start ?? 0)); const days = Math.floor((Date.now() / 1000 - last) / 86400); return days > course.max_days ? days : null; }, [course]); /** * فاصلهٔ **واقعی** با جلسهٔ قبلی، نه فاصلهٔ پروتکل. * * پروتکل می‌گوید چه باید می‌شد؛ این می‌گوید چه شد. تفاوتشان همان چیزی است که نشان * می‌دهد دوره دارد کِش می‌آید — و بدون این ستون، اپراتور باید دو تاریخ را در ذهنش * تفریق کند. */ const gapBefore = (session: CourseSessionRow): number | null => { const dated = (course?.sessions ?? []) .filter((s) => s.slot_start !== null) .sort((a, b) => (a.slot_start ?? 0) - (b.slot_start ?? 0)); const index = dated.findIndex((s) => s.session_number === session.session_number); if (index <= 0) return null; return Math.round(((dated[index].slot_start ?? 0) - (dated[index - 1].slot_start ?? 0)) / 86400); }; const columns: Column[] = [ { key: 'session_number', header: 'جلسه', render: (s) => {s.session_number}, }, { key: 'status', header: 'وضعیت', render: (s) => ( {SESSION_STATUS[s.status].label} ), }, { key: 'slot_start', header: 'تاریخ نوبت', render: (s) => ( {s.slot_start === null ? '—' : formatDate(s.slot_start)} ), }, { key: 'gap', header: 'فاصله با قبلی', render: (s) => { const gap = gapBefore(s); if (gap === null) return ; const tooLong = course !== undefined && gap > course.max_days; return ( {formatNumber(gap)} روز ); }, }, { key: 'params', header: 'پارامتر', render: (s) => ( {Object.entries(s.params).length === 0 ? '—' : Object.entries(s.params) .map(([k, v]) => `${k}: ${v}`) .join('، ')} ), }, { key: 'completed_at', header: 'انجام‌شده در', render: (s) => ( {s.completed_at === null ? '—' : formatDate(s.completed_at)} ), }, ]; return (
{course && (
جلسهٔ {course.progress.completed} از {course.progress.total} انجام شده رزروشده: {course.progress.booked} · باقی‌مانده: {course.progress.planned} فاصله: حداقل {course.min_days} · ایده‌آل {course.ideal_days} · حداکثر {course.max_days} روز {course.status !== 'active' && ( {course.status === 'completed' ? 'تمام‌شده' : 'رهاشده'} )}
{overdue !== null && ( {formatNumber(overdue)} روز از آخرین جلسه گذشته — بیشتر از حداکثر{' '} {formatNumber(course.max_days)} روزِ پروتکل. جلسهٔ بعدی را دوباره زمان‌بندی کنید. )} {course.abandon_reason && ( دلیل رهاکردن: {course.abandon_reason} )}
setBranchUuid(String(v ?? ''))} options={branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))} placeholder="انتخاب شعبه" />
{canManage && course.status === 'active' && ( )}
{suggestion && suggestion.session_number !== null && (
جلسهٔ بعدی: {suggestion.session_number} {suggestion.ideal_at !== undefined && ` · تاریخ ایده‌آل ${formatDate(suggestion.ideal_at)}`} {suggestion.range && ( بازهٔ مجاز: {formatDate(suggestion.range.min)} تا {formatDate(suggestion.range.max)} )} {suggestion.suggested_slots.length > 0 && ( نزدیک‌ترین وقت‌ها:{' '} {suggestion.suggested_slots.map((s) => formatDate(s.start)).join('، ')} )} {/* ترجیح است نه الزام: موتور همان منبع را جلوتر می‌آورد ولی اگر آزاد نباشد منبع دیگری می‌دهد. متن هم همین را می‌گوید تا انتظار اشتباه نسازد. */} {course.preferred_resource_name && ( ترجیح دوره: {course.preferred_resource_name} — اگر آزاد نباشد منبع دیگری پیشنهاد می‌شود. )} {suggestion.warning && ( {suggestion.warning} )}
)}
)}
setAbandoning(false)} onConfirm={async () => { await abandon.mutateAsync(reason.trim() || 'رهاکردن دوره'); setAbandoning(false); }} >
setReason(e.target.value)} placeholder="مثلاً: انصراف بیمار" />
); }