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>
344 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|