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
+4
View File
@@ -75,6 +75,8 @@ import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage
import PatientsListPage from './pages/PatientsListPage';
import InventoryPage from './pages/InventoryPage';
import BranchesPage from './pages/BranchesPage';
import CourseProtocolsPage from './pages/CourseProtocolsPage';
import TreatmentCoursePage from './pages/TreatmentCoursePage';
import PackagesPage from './pages/PackagesPage';
import PatientPackageLedgerPage from './pages/PatientPackageLedgerPage';
import PoliciesPage from './pages/PoliciesPage';
@@ -297,6 +299,8 @@ export default function App() {
<Route path="policies/:policyUuid/simulate" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><PolicySimulationPage /></RoleRoute>} />
<Route path="packages" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><PackagesPage /></RoleRoute>} />
<Route path="patient-package/:patientPackageUuid/ledger" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><PatientPackageLedgerPage /></RoleRoute>} />
<Route path="course-protocols" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><CourseProtocolsPage /></RoleRoute>} />
<Route path="treatment-course/:courseUuid" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><TreatmentCoursePage /></RoleRoute>} />
<Route path="resources" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourcesPage /></RoleRoute>} />
<Route path="resources/types" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourceTypesPage /></RoleRoute>} />
<Route path="resources/skills" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><SkillsPage /></RoleRoute>} />
@@ -2,7 +2,7 @@ import React from 'react';
import {
CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon,
BanknotesIcon, UsersIcon, ShieldCheckIcon,
TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, CubeIcon, ScaleIcon, RectangleStackIcon,
TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, CubeIcon, ScaleIcon, RectangleStackIcon, ArrowPathRoundedSquareIcon,
} from '@heroicons/react/24/outline';
import PurchaseSubscriptionSidebar from './PurchaseSubscriptionSidebar';
@@ -34,6 +34,7 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [
{ key: 'holidays', label: 'تعطیلات رسمی', icon: CalendarDaysIcon, to: '/admin/holidays', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'policies', label: 'قوانین', icon: ScaleIcon, to: '/admin/policies', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'packages', label: 'پکیج‌ها', icon: RectangleStackIcon, to: '/admin/packages', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'course-protocols', label: 'پروتکل دوره', icon: ArrowPathRoundedSquareIcon, to: '/admin/course-protocols', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial', perm: ['payments', 'view'] },
{ key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, to: '/admin/my-secretaries' },
{ key: 'staff', label: 'پرسنل', icon: UserPlusIcon, to: '/admin/staff', perm: ['staff', 'view'] },
+138
View File
@@ -0,0 +1,138 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api, ApiError, type ApiResponse } from '../lib/api';
import type { CourseProtocol, NextSlotSuggestion, TreatmentCourse } from '../types';
/**
* پروتکل دوره و دورهٔ بیمار.
*
* `progress` هرگز در فرانت حساب نمی‌شود — سرور می‌دهدش، چون همان‌جاست که وضعیت جلسات
* منبع حقیقت است.
*/
const PROTOCOLS_KEY = ['course-protocols'];
function fail(e: unknown, fallback: string) {
toast.error(e instanceof ApiError ? e.message : fallback);
}
export function useCourseProtocols() {
const qc = useQueryClient();
const query = useQuery({
queryKey: PROTOCOLS_KEY,
queryFn: () => api.get<ApiResponse<CourseProtocol[]>>('/api/v1/course-protocols'),
});
const invalidate = () => qc.invalidateQueries({ queryKey: PROTOCOLS_KEY });
const create = useMutation({
mutationFn: (body: Record<string, unknown>) =>
api.post<ApiResponse<CourseProtocol>>('/api/v1/course-protocols', body),
onSuccess: () => {
toast.success('پروتکل ساخته شد');
invalidate();
},
onError: (e) => fail(e, 'ساخت پروتکل ناموفق بود'),
});
const update = useMutation({
mutationFn: ({ uuid, body }: { uuid: string; body: Record<string, unknown> }) =>
api.patch<ApiResponse<CourseProtocol>>(`/api/v1/course-protocol/${uuid}`, body),
onSuccess: () => {
toast.success('پروتکل به‌روزرسانی شد');
invalidate();
},
onError: (e) => fail(e, 'به‌روزرسانی پروتکل ناموفق بود'),
});
const deactivate = useMutation({
mutationFn: (uuid: string) => api.delete<ApiResponse<CourseProtocol>>(`/api/v1/course-protocol/${uuid}`),
onSuccess: () => {
toast.success('پروتکل غیرفعال شد');
invalidate();
},
onError: (e) => fail(e, 'غیرفعال‌سازی ناموفق بود'),
});
return { protocols: query.data?.data ?? [], loading: query.isLoading, create, update, deactivate };
}
export function usePatientCourses(patientUuid: string | undefined) {
const qc = useQueryClient();
const key = ['patient-courses', patientUuid];
const query = useQuery({
queryKey: key,
queryFn: () => api.get<ApiResponse<TreatmentCourse[]>>(`/api/v1/patient/${patientUuid}/courses`),
enabled: !!patientUuid,
});
const start = useMutation({
mutationFn: (body: { protocol_uuid: string; patient_package_uuid?: string }) =>
api.post<ApiResponse<TreatmentCourse>>('/api/v1/treatment-course', {
patient_uuid: patientUuid,
...body,
}),
onSuccess: () => {
toast.success('دوره شروع شد');
qc.invalidateQueries({ queryKey: key });
},
onError: (e) => fail(e, 'شروع دوره ناموفق بود'),
});
return { courses: query.data?.data ?? [], loading: query.isLoading, start };
}
export function useTreatmentCourse(courseUuid: string | undefined) {
const qc = useQueryClient();
const key = ['treatment-course', courseUuid];
const query = useQuery({
queryKey: key,
queryFn: () => api.get<ApiResponse<TreatmentCourse>>(`/api/v1/treatment-course/${courseUuid}`),
enabled: !!courseUuid,
});
const invalidate = () => {
qc.invalidateQueries({ queryKey: key });
qc.invalidateQueries({ queryKey: ['patient-courses'] });
};
const abandon = useMutation({
mutationFn: (reason: string) =>
api.post<ApiResponse<TreatmentCourse>>(`/api/v1/treatment-course/${courseUuid}/abandon`, { reason }),
onSuccess: () => {
toast.success('دوره رها شد');
invalidate();
},
onError: (e) => fail(e, 'رهاکردن دوره ناموفق بود'),
});
const bookAll = useMutation({
mutationFn: (body: { branch_uuid: string; doctor_uuid: string }) =>
api.post<ApiResponse<{ booked: number; remaining: number; message: string | null }>>(
`/api/v1/treatment-course/${courseUuid}/book-all`,
body,
),
onSuccess: (res) => {
toast.success(res.data.message ?? `${res.data.booked} جلسه رزرو شد`);
invalidate();
},
onError: (e) => fail(e, 'رزرو جلسات ناموفق بود'),
});
return { course: query.data?.data, loading: query.isLoading, abandon, bookAll };
}
export function useNextSlotSuggestion(courseUuid: string | undefined, branchUuid: string | undefined) {
const query = useQuery({
queryKey: ['course-next-slot', courseUuid, branchUuid],
queryFn: () =>
api.get<ApiResponse<NextSlotSuggestion>>(
`/api/v1/treatment-course/${courseUuid}/next-slot-suggestion?branch_uuid=${branchUuid}`,
),
enabled: !!courseUuid && !!branchUuid,
});
return { suggestion: query.data?.data, loading: query.isLoading };
}
+349
View File
@@ -0,0 +1,349 @@
import React, { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
import PageHeader from '../components/ui/PageHeader';
import DataTable, { type Column } from '../components/ui/DataTable';
import Modal from '../components/ui/Modal';
import SearchableSelect from '../components/ui/SearchableSelect';
import { ActiveBadge } from '../components/ui/StatusBadge';
import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
import { useCourseProtocols } from '../hooks/useCourses';
import { api, type ApiResponse } from '../lib/api';
import type { CourseProtocol, ServiceItem } from '../types';
interface StepDraft {
session_number: number;
energy: string;
}
interface Draft {
uuid?: string;
service_uuid: string;
session_count: number;
min_days: number;
ideal_days: number;
max_days: number;
prefer_same_resource: boolean;
steps: StepDraft[];
}
const EMPTY: Draft = {
service_uuid: '',
session_count: 6,
min_days: 21,
ideal_days: 28,
max_days: 45,
prefer_same_resource: true,
steps: [],
};
/**
* پروتکل دوره per سرویس.
*
* سه فاصله سه معنا دارند و ترتیبشان اجباری است؛ فرم همان‌جا می‌گوید، نه اینکه بگذارد
* کاربر ذخیره کند و ۴۲۲ بگیرد.
*/
export default function CourseProtocolsPage() {
const { protocols, loading, create, update, deactivate } = useCourseProtocols();
const { can } = usePermissions();
const canManage = can('appointment_settings', 'update');
const [urlState, setUrlState] = useUrlState({ search: '' });
const [draft, setDraft] = useState<Draft | null>(null);
const { data: servicesData } = useQuery({
queryKey: ['service-items-for-courses'],
queryFn: () => api.get<ApiResponse<ServiceItem[]>>('/api/v1/service-items'),
staleTime: 60_000,
});
const services = servicesData?.data ?? [];
const rows = useMemo(() => {
const q = urlState.search.trim();
return protocols.filter((p) => q === '' || p.service_name.includes(q));
}, [protocols, urlState.search]);
const orderInvalid = draft !== null && !(draft.min_days <= draft.ideal_days && draft.ideal_days <= draft.max_days);
const columns: Column<CourseProtocol>[] = [
{
key: 'service_name',
header: 'سرویس',
render: (p) => <span style={{ fontWeight: 600 }}>{p.service_name}</span>,
},
{
key: 'session_count',
header: 'تعداد جلسه',
render: (p) => <span style={{ fontSize: 13 }}>{p.session_count}</span>,
},
{
key: 'spacing',
header: 'فاصله (روز)',
render: (p) => (
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>
حداقل {p.min_days} · ایدهآل {p.ideal_days} · حداکثر {p.max_days}
</span>
),
},
{
key: 'steps',
header: 'پارامتر جلسات',
render: (p) => (
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
{p.steps.length === 0 ? '—' : `${p.steps.length} جلسه پارامتر دارد`}
</span>
),
},
{
key: 'active',
header: 'وضعیت',
render: (p) => <ActiveBadge active={p.active} />,
},
];
const save = async () => {
if (!draft) return;
const body = {
service_uuid: draft.service_uuid,
session_count: draft.session_count,
min_days: draft.min_days,
ideal_days: draft.ideal_days,
max_days: draft.max_days,
prefer_same_resource: draft.prefer_same_resource,
steps: draft.steps
.filter((s) => s.energy.trim() !== '')
.map((s) => ({ session_number: s.session_number, params: { energy: Number(s.energy) } })),
};
if (draft.uuid) {
await update.mutateAsync({ uuid: draft.uuid, body });
} else {
await create.mutateAsync(body);
}
setDraft(null);
};
return (
<div className="fade-in">
<PageHeader
title="پروتکل دوره"
description="دورهٔ چندجلسه‌ای هر خدمت: تعداد جلسه و فاصلهٔ مجاز بین جلسات."
backTo="/admin/settings-menu"
action={
canManage ? (
<button type="button" className="btn primary sm" onClick={() => setDraft({ ...EMPTY, steps: [] })}>
<PlusIcon style={{ width: 15 }} /> پروتکل تازه
</button>
) : undefined
}
/>
<DataTable
columns={columns}
data={rows}
loading={loading}
searchValue={urlState.search}
onSearchChange={(v) => setUrlState({ search: v })}
searchPlaceholder="جستجو در پروتکل‌ها..."
emptyMessage="هنوز پروتکلی تعریف نشده است"
actions={(p) =>
canManage ? (
<div style={{ display: 'flex', gap: 6 }}>
<button
type="button"
className="btn secondary sm"
onClick={() =>
setDraft({
uuid: p.uuid,
service_uuid: p.service_uuid,
session_count: p.session_count,
min_days: p.min_days,
ideal_days: p.ideal_days,
max_days: p.max_days,
prefer_same_resource: p.prefer_same_resource,
steps: p.steps.map((s) => ({
session_number: s.session_number,
energy: String(s.params.energy ?? ''),
})),
})
}
>
ویرایش
</button>
{p.active && (
<button
type="button"
className="btn secondary sm"
disabled={deactivate.isPending}
onClick={() => deactivate.mutate(p.uuid)}
>
غیرفعال
</button>
)}
</div>
) : null
}
/>
<Modal
open={draft !== null}
title={draft?.uuid ? 'ویرایش پروتکل' : 'پروتکل تازه'}
onClose={() => setDraft(null)}
footer={
<>
<button
type="button"
className="btn primary"
disabled={
!draft ||
(!draft.uuid && draft.service_uuid === '') ||
draft.session_count < 2 ||
orderInvalid ||
create.isPending ||
update.isPending
}
onClick={save}
>
ذخیره
</button>
<button type="button" className="btn secondary" onClick={() => setDraft(null)}>
انصراف
</button>
</>
}
>
{draft && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{!draft.uuid && (
<div className="field">
<label>سرویس</label>
<SearchableSelect
value={draft.service_uuid}
onChange={(v) => setDraft({ ...draft, service_uuid: String(v ?? '') })}
options={services.map((s) => ({ value: s.uuid, label: s.name }))}
placeholder="انتخاب سرویس"
/>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>هر سرویس یک پروتکل دارد.</span>
</div>
)}
<div className="field" style={{ maxWidth: 200 }}>
<label htmlFor="cp-sessions">تعداد جلسه</label>
<input
id="cp-sessions"
className="input"
type="number"
min={2}
value={draft.session_count}
onChange={(e) => setDraft({ ...draft, session_count: Number(e.target.value) })}
/>
{draft.session_count < 2 && (
<span style={{ fontSize: 12, color: 'var(--danger)' }}>
دورهٔ کمتر از دو جلسه همان نوبت تکی است.
</span>
)}
</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{([
['min_days', 'حداقل (روز)'],
['ideal_days', 'ایده‌آل (روز)'],
['max_days', 'حداکثر (روز)'],
] as const).map(([key, label]) => (
<div className="field" key={key} style={{ maxWidth: 150 }}>
<label htmlFor={`cp-${key}`}>{label}</label>
<input
id={`cp-${key}`}
className="input"
type="number"
min={1}
value={draft[key]}
onChange={(e) => setDraft({ ...draft, [key]: Number(e.target.value) })}
/>
</div>
))}
</div>
{orderInvalid && (
<span style={{ fontSize: 12, color: 'var(--danger)' }}>
ترتیب باید حداقل ایدهآل حداکثر باشد.
</span>
)}
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<input
type="checkbox"
checked={draft.prefer_same_resource}
onChange={(e) => setDraft({ ...draft, prefer_same_resource: e.target.checked })}
/>
تا حد امکان همان منبع جلسهٔ قبل
</label>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
<h3 style={{ fontSize: 14, margin: '0 0 10px' }}>پارامتر جلسات (اختیاری)</h3>
{draft.steps.map((step, index) => (
<div key={index} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<input
className="input"
style={{ maxWidth: 110 }}
type="number"
min={1}
max={draft.session_count}
value={step.session_number}
onChange={(e) =>
setDraft({
...draft,
steps: draft.steps.map((s, i) =>
i === index ? { ...s, session_number: Number(e.target.value) } : s,
),
})
}
/>
<input
className="input"
style={{ maxWidth: 140 }}
type="number"
placeholder="سطح انرژی"
value={step.energy}
onChange={(e) =>
setDraft({
...draft,
steps: draft.steps.map((s, i) => (i === index ? { ...s, energy: e.target.value } : s)),
})
}
/>
<button
type="button"
className="btn secondary sm"
onClick={() => setDraft({ ...draft, steps: draft.steps.filter((_, i) => i !== index) })}
aria-label="حذف پارامتر"
>
<TrashIcon style={{ width: 15 }} />
</button>
</div>
))}
<button
type="button"
className="btn secondary sm"
onClick={() =>
setDraft({
...draft,
steps: [...draft.steps, { session_number: draft.steps.length + 1, energy: '' }],
})
}
>
<PlusIcon style={{ width: 15 }} /> افزودن پارامتر جلسه
</button>
</div>
</div>
)}
</Modal>
</div>
);
}
+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>
);
}
+196
View File
@@ -0,0 +1,196 @@
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>
);
}
+72
View File
@@ -1251,3 +1251,75 @@ export interface CreditLedger {
package: PatientPackage;
rows: CreditLedgerRow[];
}
// ── دورهٔ درمان (تسک ۱۲) ──────────────────────────────────────────────────────
export interface CourseProtocolStep {
session_number: number;
params: Record<string, string | number | boolean>;
override_duration_minutes: number | null;
}
export interface CourseProtocol {
uuid: string;
service_uuid: string;
service_name: string;
session_count: number;
min_days: number;
ideal_days: number;
max_days: number;
prefer_same_resource: boolean;
active: boolean;
steps: CourseProtocolStep[];
created_at: number;
}
export interface CourseSessionRow {
uuid: string;
session_number: number;
params: Record<string, string | number | boolean>;
appointment_uuid: string | null;
slot_start: number | null;
status: 'planned' | 'booked' | 'completed' | 'skipped';
completed_at: number | null;
}
export interface CourseProgress {
completed: number;
booked: number;
planned: number;
skipped: number;
total: number;
next_session_number: number | null;
next_params: Record<string, string | number | boolean>;
last_completed_at: number | null;
}
export interface TreatmentCourse {
uuid: string;
patient_uuid: string;
service_uuid: string;
service_name: string;
protocol_uuid: string;
session_count: number;
min_days: number;
ideal_days: number;
max_days: number;
patient_package_uuid: string | null;
preferred_resource_uuid: string | null;
status: 'active' | 'completed' | 'abandoned';
abandon_reason: string | null;
started_at: number;
completed_at: number | null;
progress: CourseProgress;
sessions?: CourseSessionRow[];
}
export interface NextSlotSuggestion {
session_number: number | null;
params: Record<string, string | number | boolean>;
ideal_at?: number;
range?: { min: number; max: number };
suggested_slots: { start: number; end: number }[];
warning: string | null;
}