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>
283 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|