Files
clinicpro/assets/admin/pages/TreatmentCoursePage.tsx
T
hamedandClaude Opus 5 fc504f4415 feat(course): treatment courses with protocol-driven session planning
Laser is six to eight sessions; the previous design only knew single
appointments, which is the exception rather than the rule.

- CourseProtocol per service: session count and three distinct spacings —
  min is the earliest that is clinically allowed, ideal is best, max is where
  the course starts losing its effect
- Starting a course creates every session up front as `planned` and copies the
  protocol's numbers and per-session params, so changing the protocol tomorrow
  leaves a running course alone
- Suggestions anchor on the last *completed* session, not the course start:
  when session 2 slips, session 3 moves with it
- Slots are ranked by distance from ideal, not by earliest available — day 21
  is worse than day 27 when 28 is the target
- book-all is all-or-nothing inside one transaction, with a moving anchor and a
  90-day horizon; sessions past the horizon stay planned and are reported, not
  treated as failures
- The effective minimum is the stricter of the protocol and the task-09 spacing
  policy, so a clinic rule never fights the protocol
- Cancelling one session returns only that session to planned; abandoning a
  course does not cancel its appointments, which stays an explicit decision

One active course per (patient, service) via active_course_key, the same
partial-uniqueness trick as Appointment::activeSlotKey.

Admin: CourseProtocolsPage, TreatmentCoursePage and a courses tab on the
patient record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:33:07 +03:30

197 lines
7.7 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 } 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 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: '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" 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.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" 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>
)}
{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">
<label htmlFor="abandon-reason">دلیل</label>
<input
id="abandon-reason"
className="input"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="مثلاً: انصراف بیمار"
/>
</div>
</ConfirmDialog>
</div>
);
}