Files
clinicpro/assets/admin/pages/CourseProtocolsPage.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

350 lines
12 KiB
TypeScript

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>
);
}