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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user