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