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>
This commit is contained in:
hamed
2026-07-31 11:33:07 +03:30
co-authored by Claude Opus 5
parent edf22e0552
commit fc504f4415
30 changed files with 3502 additions and 75 deletions
+80 -1
View File
@@ -3,12 +3,14 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useSearchParams, Link, useNavigate } from 'react-router-dom';
import {
ChevronRightIcon, PencilIcon, ClipboardDocumentCheckIcon, DocumentTextIcon, RectangleStackIcon,
ArrowPathRoundedSquareIcon,
CalendarDaysIcon, CreditCardIcon, BanknotesIcon, ChatBubbleLeftRightIcon,
PhoneArrowUpRightIcon, PaperClipIcon, ClipboardDocumentListIcon,
ArrowUpTrayIcon, TrashIcon, DocumentIcon, UserIcon,
} from '@heroicons/react/24/outline';
import { PlusIcon } from '@heroicons/react/24/outline';
import { usePackages, usePatientPackages } from '../hooks/usePackages';
import { useCourseProtocols, usePatientCourses } from '../hooks/useCourses';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
@@ -42,7 +44,7 @@ import {
} from '../lib/patientForm';
import { usePermissions } from '../hooks/usePermissions';
type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'packages' | 'notes' | 'callcenter' | 'attach' | 'records';
type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'packages' | 'courses' | 'notes' | 'callcenter' | 'attach' | 'records';
const TABS: { key: TabKey; label: string; icon: (c: string) => React.ReactNode }[] = [
{ key: 'services', label: 'سرویس‌ها', icon: (c) => <TabServices color={c} /> },
@@ -51,6 +53,7 @@ const TABS: { key: TabKey; label: string; icon: (c: string) => React.ReactNode }
{ key: 'payments', label: 'پرداخت‌ها', icon: (c) => <TabCard color={c} /> },
{ key: 'wallet', label: 'کیف پول', icon: (c) => <TabWallet color={c} /> },
{ key: 'packages', label: 'پکیج‌ها', icon: (c) => <RectangleStackIcon style={{ width: 18, color: c }} /> },
{ key: 'courses', label: 'دوره‌های درمان', icon: (c) => <ArrowPathRoundedSquareIcon style={{ width: 18, color: c }} /> },
{ key: 'notes', label: 'یادداشت‌ها', icon: (c) => <DocumentTextIcon style={{ width: 18, color: c }} /> },
{ key: 'callcenter', label: 'کال سنتر', icon: (c) => <TabCall color={c} /> },
{ key: 'attach', label: 'ضمیمه', icon: (c) => <TabAttach color={c} /> },
@@ -264,6 +267,8 @@ export default function PatientDetailPage() {
<WalletTab uuid={uuid!} />
) : tab === 'packages' ? (
<PackagesTab uuid={uuid!} />
) : tab === 'courses' ? (
<CoursesTab uuid={uuid!} />
) : tab === 'callcenter' ? (
<CallCenterTab uuid={uuid!} />
) : tab === 'attach' ? (
@@ -1108,3 +1113,77 @@ function PackagesTab({ uuid }: { uuid: string }) {
</div>
);
}
/**
* دوره‌های درمان بیمار.
*
* پیشرفت از سرور می‌آید («۳ از ۸»)؛ فرانت نمی‌شمارد، چون وضعیت جلسات آن‌جاست.
*/
function CoursesTab({ uuid }: { uuid: string }) {
const { courses, loading, start } = usePatientCourses(uuid);
const { protocols } = useCourseProtocols();
const [selected, setSelected] = useState('');
const active = protocols.filter((p) => p.active);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div className="card" style={{ display: 'flex', alignItems: 'flex-end', gap: 10, flexWrap: 'wrap' }}>
<div className="field" style={{ minWidth: 240, margin: 0 }}>
<label>شروع دورهٔ تازه</label>
<SearchableSelect
value={selected}
onChange={(v) => setSelected(String(v ?? ''))}
options={active.map((p) => ({
value: p.uuid,
label: `${p.service_name}${p.session_count} جلسه`,
}))}
placeholder="انتخاب پروتکل"
/>
</div>
<button
type="button"
className="btn primary sm"
disabled={selected === '' || start.isPending}
onClick={async () => {
await start.mutateAsync({ protocol_uuid: selected });
setSelected('');
}}
>
شروع دوره
</button>
</div>
{loading ? (
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری</div>
) : courses.length === 0 ? (
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>
این بیمار دورهٔ درمانی ندارد
</div>
) : (
courses.map((c) => (
<div key={c.uuid} className="card" style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<span style={{ fontWeight: 600 }}>{c.service_name}</span>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>شروع {formatDate(c.started_at)}</span>
</div>
<span style={{ fontSize: 14 }}>
جلسهٔ <strong>{c.progress.completed}</strong> از {c.progress.total}
</span>
{c.status === 'active' ? (
<span className="badge green"><span className="bdot" />در جریان</span>
) : (
<span className="badge"><span className="bdot" />{c.status === 'completed' ? 'تمام‌شده' : 'رهاشده'}</span>
)}
<Link className="btn secondary sm" style={{ marginRight: 'auto' }} to={`/admin/treatment-course/${c.uuid}`}>
جزئیات دوره
</Link>
</div>
))
)}
</div>
);
}