Files
clinicpro/assets/admin/pages/TreatmentCoursePage.tsx
T
hamedandClaude Opus 5 e9e61adfee feat(course): show how the course is actually going, not just how it was planned
Three gaps on the treatment-course page, all of them about the difference
between the protocol and reality.

The sessions table listed each date but not the gap between them, leaving the
operator to subtract two Jalali dates in their head. It now shows the real gap
and colours it as a warning past the protocol maximum.

A course cancelled mid-way stretches silently: the session goes back to
planned and nobody is told. The suggestion endpoint does warn, but only once a
branch is picked, so the warning could go unseen indefinitely. The page now
derives "N days since the last session, past the protocol maximum" from the
course itself, so it shows immediately.

The course's preferred resource was applied by the engine but never named in
the UI. The API now returns preferred_resource_name alongside the uuid, and
the text says plainly that it is a preference — the engine moves it up the
list, it does not hold the slot.

Two backend tests that were owed: the stricter of the protocol spacing and a
spacing policy wins (protocol 7 days, policy 21, effective 21 — otherwise a
clinic's safety rule could be bypassed by writing a short protocol), and a
session whose earliest possible date falls outside the 90-day horizon is
skipped rather than failing book-all, leaving the course untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:51:34 +03:30

283 lines
12 KiB
TypeScript

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<CourseSessionRow['status'], { label: string; className: string }> = {
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<CourseSessionRow>[] = [
{
key: 'session_number',
header: 'جلسه',
render: (s) => <span style={{ fontWeight: 600 }}>{s.session_number}</span>,
},
{
key: 'status',
header: 'وضعیت',
render: (s) => (
<span className={SESSION_STATUS[s.status].className}>
<span className="bdot" />
{SESSION_STATUS[s.status].label}
</span>
),
},
{
key: 'slot_start',
header: 'تاریخ نوبت',
render: (s) => (
<span style={{ fontSize: 13 }}>{s.slot_start === null ? '—' : formatDate(s.slot_start)}</span>
),
},
{
key: 'gap',
header: 'فاصله با قبلی',
render: (s) => {
const gap = gapBefore(s);
if (gap === null) return <span style={{ color: 'var(--text-3)' }}></span>;
const tooLong = course !== undefined && gap > course.max_days;
return (
<span
style={{ fontSize: 13, color: tooLong ? 'var(--warning)' : undefined }}
title={tooLong ? `بیش از حداکثر ${formatNumber(course!.max_days)} روزِ پروتکل` : undefined}
>
{formatNumber(gap)} روز
</span>
);
},
},
{
key: 'params',
header: 'پارامتر',
render: (s) => (
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>
{Object.entries(s.params).length === 0
? '—'
: Object.entries(s.params)
.map(([k, v]) => `${k}: ${v}`)
.join('، ')}
</span>
),
},
{
key: 'completed_at',
header: 'انجام‌شده در',
render: (s) => (
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
{s.completed_at === null ? '—' : formatDate(s.completed_at)}
</span>
),
},
];
return (
<div className="fade-in">
<PageHeader
title={course ? `دورهٔ ${course.service_name}` : 'دورهٔ درمان'}
description="پیشرفت دوره، جلسات و پیشنهاد تاریخ جلسهٔ بعدی."
backTo="/admin/patients"
/>
{course && (
<div className="card card-pad" style={{ marginBottom: 16, display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
<span style={{ fontSize: 15 }}>
جلسهٔ <strong>{course.progress.completed}</strong> از {course.progress.total} انجام شده
</span>
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>
رزروشده: {course.progress.booked} · باقی‌مانده: {course.progress.planned}
</span>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
فاصله: حداقل {course.min_days} · ایده‌آل {course.ideal_days} · حداکثر {course.max_days} روز
</span>
{course.status !== 'active' && (
<span className="badge red">
<span className="bdot" />
{course.status === 'completed' ? 'تمام‌شده' : 'رهاشده'}
</span>
)}
</div>
{overdue !== null && (
<span
style={{
fontSize: 13,
lineHeight: 1.8,
color: 'var(--warning)',
background: 'var(--warning-bg)',
borderRadius: 'var(--r-sm)',
padding: '8px 10px',
}}
>
{formatNumber(overdue)} روز از آخرین جلسه گذشته بیشتر از حداکثر{' '}
{formatNumber(course.max_days)} روزِ پروتکل. جلسهٔ بعدی را دوباره زمان‌بندی کنید.
</span>
)}
{course.abandon_reason && (
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>دلیل رهاکردن: {course.abandon_reason}</span>
)}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 10, flexWrap: 'wrap' }}>
<div className="field-block" style={{ minWidth: 220, margin: 0 }}>
<label>شعبه برای پیشنهاد وقت</label>
<SearchableSelect
value={branchUuid}
onChange={(v) => setBranchUuid(String(v ?? ''))}
options={branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
placeholder="انتخاب شعبه"
/>
</div>
{canManage && course.status === 'active' && (
<button type="button" className="btn secondary sm" onClick={() => setAbandoning(true)}>
رهاکردن دوره
</button>
)}
</div>
{suggestion && suggestion.session_number !== null && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 13 }}>
<span>
جلسهٔ بعدی: <strong>{suggestion.session_number}</strong>
{suggestion.ideal_at !== undefined && ` · تاریخ ایده‌آل ${formatDate(suggestion.ideal_at)}`}
</span>
{suggestion.range && (
<span style={{ color: 'var(--text-3)', fontSize: 12 }}>
بازهٔ مجاز: {formatDate(suggestion.range.min)} تا {formatDate(suggestion.range.max)}
</span>
)}
{suggestion.suggested_slots.length > 0 && (
<span style={{ color: 'var(--text-2)' }}>
نزدیک‌ترین وقت‌ها:{' '}
{suggestion.suggested_slots.map((s) => formatDate(s.start)).join('، ')}
</span>
)}
{/* ترجیح است نه الزام: موتور همان منبع را جلوتر می‌آورد ولی اگر آزاد نباشد
منبع دیگری می‌دهد. متن هم همین را می‌گوید تا انتظار اشتباه نسازد. */}
{course.preferred_resource_name && (
<span style={{ color: 'var(--text-3)', fontSize: 12 }}>
ترجیح دوره: {course.preferred_resource_name} اگر آزاد نباشد منبع دیگری
پیشنهاد می‌شود.
</span>
)}
{suggestion.warning && (
<span style={{ color: 'var(--warning)' }}>{suggestion.warning}</span>
)}
</div>
)}
</div>
)}
<div style={{ overflowX: 'auto' }}>
<DataTable
columns={columns}
data={course?.sessions ?? []}
loading={loading}
emptyMessage="این دوره جلسه‌ای ندارد"
/>
</div>
<ConfirmDialog
open={abandoning}
title="رهاکردن دوره"
message="جلسات باقی‌مانده برنامه‌ریزی‌شده می‌مانند و دوره از فهرست فعال خارج می‌شود."
confirmLabel="رهاکن"
danger
loading={abandon.isPending}
onCancel={() => setAbandoning(false)}
onConfirm={async () => {
await abandon.mutateAsync(reason.trim() || 'رهاکردن دوره');
setAbandoning(false);
}}
>
<div className="field-block">
<label htmlFor="abandon-reason">دلیل</label>
<input
id="abandon-reason"
className="input"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="مثلاً: انصراف بیمار"
/>
</div>
</ConfirmDialog>
</div>
);
}