Files
clinicpro/assets/admin/pages/TreatmentCoursePage.tsx
T
hamedandClaude Opus 5 5c754244f2 feat(admin): finish the screens that were stopping one step short
Five places where the data existed and the screen did not use it.

Booking a whole course had no button because it needs a doctor and the course
does not carry one — each session can be with a different doctor. The page now
asks for the doctor the same way the resource booking page does, and the button
explains that it is all-or-nothing before it is pressed.

A course whose package does not cover the remaining sessions is still valid —
the rest is simply charged normally — but nobody was told. The course response
carries package_balance and the shortfall, and the page warns. Before session
six, not during it.

The credit ledger already returned who recorded a row and which appointment it
belonged to, and showed neither. An adjustable ledger without the name of the
person who adjusted it is half an audit trail.

Version history printed a JSON blob of each version's effects, which does not
answer the question anyone actually has: what changed? It now diffs each
version against the previous one, field by field, and says so plainly when a
version changed nothing meaningful.

A resource with no calendar showed "—" for utilization. Null means undefined,
not zero, and the next step is always the same: set up the calendar. It is a
link now. The report range also accepts a custom from/to, kept in the URL like
the rest.

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

344 lines
14 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 { useQuery } from '@tanstack/react-query';
import { api, type ApiResponse } from '../lib/api';
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, bookAll } = useTreatmentCourse(courseUuid);
const { branches } = useBranches();
const { can } = usePermissions();
const canManage = can('appointment_settings', 'update');
const [branchUuid, setBranchUuid] = useState('');
const [doctorUuid, setDoctorUuid] = useState('');
const [abandoning, setAbandoning] = useState(false);
const [reason, setReason] = useState('');
const { suggestion } = useNextSlotSuggestion(courseUuid, branchUuid || undefined);
/** همان اندپوینتی که صفحهٔ رزرو منبع‌محور استفاده می‌کند — منشی فقط پزشکان خودش را می‌بیند. */
const doctorsQuery = useQuery<ApiResponse<{ data: { uuid: string; name: string }[] }>>({
queryKey: ['booking-doctors'],
queryFn: () => api.get('/api/v1/my/clinic-doctors'),
staleTime: 60_000,
});
const doctors = doctorsQuery.data?.data?.data ?? [];
/**
* دوره‌ای که وسطش لغو شده، بی‌صدا کِش می‌آید: جلسه به «برنامه‌ریزی‌شده» برمی‌گردد و
* هیچ‌کس خبردار نمی‌شود.
*
* هشدارِ پیشنهاد فقط وقتی می‌آید که شعبه انتخاب شده باشد؛ این یکی از خودِ دوره حساب
* می‌شود، پس بلافاصله دیده می‌شود. مبنا آخرین جلسهٔ **دارای تاریخ** است — همان لنگری
* که پروتکل با آن فاصله می‌سنجد.
*/
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>
{/* پکیجی که کفاف جلسات باقی‌مانده را نمی‌دهد، دوره را باطل نمی‌کند — بقیه‌اش
نقدی می‌شود. ولی باید قبل از جلسهٔ ششم دانسته شود، نه سرِ آن. */}
{(course.package_shortfall ?? 0) > 0 && (
<span
style={{
fontSize: 13,
lineHeight: 1.8,
color: 'var(--warning)',
background: 'var(--warning-bg)',
borderRadius: 'var(--r-sm)',
padding: '8px 10px',
}}
>
اعتبار پکیج برای {formatNumber(course.package_shortfall ?? 0)} جلسه کم می‌آید
(مانده: {formatNumber(course.package_balance ?? 0)}). بقیهٔ جلسات با قیمت
عادی حساب می‌شوند.
</span>
)}
{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' && (
<div className="field-block" style={{ minWidth: 220, margin: 0 }}>
<label>پزشک برای رزرو گروهی</label>
<SearchableSelect
value={doctorUuid}
onChange={(v) => setDoctorUuid(String(v ?? ''))}
options={doctors.map((d) => ({ value: d.uuid, label: d.name }))}
placeholder="انتخاب پزشک"
/>
</div>
)}
{canManage && course.status === 'active' && (
<button
type="button"
className="btn primary sm"
disabled={branchUuid === '' || doctorUuid === '' || bookAll.isPending}
title={
branchUuid === '' || doctorUuid === ''
? 'شعبه و پزشک را انتخاب کنید'
: 'همهٔ جلسات برنامه‌ریزی‌شده یکجا رزرو می‌شوند — همه یا هیچ'
}
onClick={() => bookAll.mutate({ branch_uuid: branchUuid, doctor_uuid: doctorUuid })}
>
رزرو همهٔ جلسات
</button>
)}
{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>
);
}