import { useEffect, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { PlusIcon, TrashIcon, XMarkIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline'; import Switch from './ui/Switch'; import SearchableSelect from './ui/SearchableSelect'; import Input from './ui/Input'; import ConfirmDialog from './ui/ConfirmDialog'; import { api, ApiError, type ApiResponse } from '../lib/api'; interface ProtocolStep { step_number: number; offset_days: number; } interface ProtocolStaff { uuid: string; name: string; } interface TreatmentProtocol { uuid: string; /** آیا هیچ منبعی این سرویس را ارائه می‌دهد؛ نبودش رزرو را قفل نمی‌کند ولی باید دیده شود. */ service_has_resources?: boolean; active: boolean; total_sessions: number; supervisor: { uuid: string; name: string } | null; steps: ProtocolStep[]; staff: ProtocolStaff[]; } interface StaffRow { uuid: string; full_name: string; active?: boolean; } interface DoctorRow { uuid: string; name: string; } /** پیش‌فرضِ روشن‌کردن سوییچ: کوتاه‌ترین دوره‌ای که معنی دارد. */ const DEFAULT_STEPS: ProtocolStep[] = [ { step_number: 1, offset_days: 0 }, { step_number: 2, offset_days: 30 }, ]; const MIN_STEPS = 2; /** خط جداکنندهٔ بخش‌ها — سه بخشِ مستقل نباید به‌هم چسبیده به نظر برسند. */ function Divider() { return
; } /** * «طول درمان» یک سرویس. * * وجودِ پروتکل خودش سوییچ است — سرویس بدون پروتکل تک‌جلسه‌ای است — پس روشن‌کردن یعنی * ساختن و خاموش‌کردن یعنی حذف. چون خاموش‌کردن برگشت‌ناپذیر است و گام‌ها و پرسنل را با * خودش می‌برد، با تأیید صریح انجام می‌شود نه با یک کلیک. * * فاصلهٔ هر گام از **جلسهٔ قبل** است، نه از شروع دوره، چون فاصلهٔ درمان به آخرین جلسه * گره خورده نه به روز باز شدن پرونده. */ export default function TreatmentProtocolTab({ serviceUuid, canEdit }: { serviceUuid: string; canEdit: boolean; }) { const qc = useQueryClient(); const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['treatment-protocol', serviceUuid], queryFn: () => api.get>( `/api/v1/service-item/${serviceUuid}/treatment-protocol`, ), }); const { data: staffData } = useQuery({ queryKey: ['staff-list-for-protocol'], queryFn: () => api.get>('/api/v1/staff'), staleTime: 60_000, }); const { data: doctorData } = useQuery({ queryKey: ['my-clinic-doctors'], queryFn: () => api.get>('/api/v1/my/clinic-doctors'), staleTime: 60_000, }); const protocol = data?.data ?? null; const [enabled, setEnabled] = useState(false); const [steps, setSteps] = useState(DEFAULT_STEPS); const [staffUuids, setStaffUuids] = useState([]); const [supervisor, setSupervisor] = useState(null); const [confirmOff, setConfirmOff] = useState(false); useEffect(() => { setEnabled(protocol !== null); setSteps(protocol?.steps?.length ? protocol.steps : DEFAULT_STEPS); setStaffUuids(protocol?.staff?.map((s) => s.uuid) ?? []); setSupervisor(protocol?.supervisor?.uuid ?? null); }, [protocol]); const staffOptions = (staffData?.data ?? []) .filter((s) => s.active !== false) .map((s) => ({ value: s.uuid, label: s.full_name })); const doctorOptions = (doctorData?.data?.data ?? []).map((d) => ({ value: d.uuid, label: d.name })); const save = useMutation({ mutationFn: () => api.put>( `/api/v1/service-item/${serviceUuid}/treatment-protocol`, { steps: steps.map((s, i) => ({ step_number: i + 1, offset_days: s.offset_days })), staff_uuids: staffUuids, supervisor_doctor_uuid: supervisor, }, ), onSuccess: () => { toast.success('طول درمان ذخیره شد'); qc.invalidateQueries({ queryKey: ['treatment-protocol', serviceUuid] }); }, onError: (e) => toast.error(e instanceof ApiError ? e.message : 'ذخیرهٔ طول درمان ناموفق بود'), }); const remove = useMutation({ mutationFn: () => api.delete>(`/api/v1/service-item/${serviceUuid}/treatment-protocol`), onSuccess: () => { toast.success('طول درمان خاموش شد'); setConfirmOff(false); qc.invalidateQueries({ queryKey: ['treatment-protocol', serviceUuid] }); }, onError: (e) => toast.error(e instanceof ApiError ? e.message : 'خاموش‌کردن ناموفق بود'), }); /** * روشن‌کردن بی‌خطر است و فوری اعمال می‌شود؛ خاموش‌کردن پروتکل را با همهٔ گام‌ها و * پرسنلش پاک می‌کند، پس تأیید می‌خواهد. */ const toggle = (on: boolean) => { if (on) { setEnabled(true); return; } if (protocol === null) { setEnabled(false); return; } setConfirmOff(true); }; const setOffset = (index: number, value: number) => { setSteps((prev) => prev.map((s, i) => (i === index ? { ...s, offset_days: value } : s))); }; const addStep = () => { setSteps((prev) => [ ...prev, { step_number: prev.length + 1, offset_days: prev[prev.length - 1]?.offset_days || 30 }, ]); }; const removeStep = (index: number) => { setSteps((prev) => (prev.length <= MIN_STEPS ? prev : prev.filter((_, i) => i !== index))); }; const addStaff = (uuid: string | number | null) => { const id = uuid === null ? '' : String(uuid); if (id && !staffUuids.includes(id)) setStaffUuids((prev) => [...prev, id]); }; if (isLoading) { return
در حال بارگذاری...
; } /** * بدون این، شکستِ کوئری `protocol = null` می‌داد و صفحه سوییچِ خاموش نشان می‌داد — * یعنی به مدیر می‌گفت این سرویس تک‌جلسه‌ای است، در حالی که فقط خواندن شکست خورده. */ if (isError) { return (
طول درمان این سرویس خوانده نشد تا وقتی خطا برطرف نشده، وضعیت واقعی این سرویس معلوم نیست.
); } const dirty = enabled && staffUuids.length > 0; return ( <>
{enabled && protocol?.service_has_resources === false && (
هیچ دستگاهی به این سرویس وصل نیست رزرو قفل نمی‌شود، ولی منشی می‌تواند این سرویس را روی هر منبعی ثبت کند و اپراتور فرم دستگاه درست را نمی‌بیند. در «منابع» مشخص کنید کدام دستگاه‌ها این سرویس را می‌دهند.
)} {enabled && ( <>

جلسات دوره{' '} ({steps.length} جلسه)

فاصلهٔ هر جلسه از جلسهٔ قبل حساب می‌شود، نه از شروع دوره. اگر بیمار دیر بیاید، بقیهٔ دوره هم جابه‌جا می‌شود.

{steps.map((step, index) => (
جلسهٔ {index + 1} {index === 0 ? ( شروع دوره — همان روز اولین نوبت ) : ( <> setOffset(index, Number(e.target.value || 0))} aria-label={`فاصلهٔ جلسهٔ ${index + 1} از جلسهٔ قبل به روز`} style={{ width: 96 }} /> روز بعد از جلسهٔ قبل )} {canEdit && index > 0 && ( )}
))} {canEdit && ( )}

پرسنل مجاز

منشی هنگام رزرو فقط از میان همین‌ها انتخاب می‌کند.

{canEdit && ( !staffUuids.includes(String(o.value)))} value={null} onChange={addStaff} placeholder="افزودن پرسنل..." ariaLabel="افزودن پرسنل مجاز" /> )} {staffUuids.length === 0 ? ( حداقل یک پرسنل الزامی است — بدون آن ذخیره نمی‌شود. ) : (
{staffUuids.map((uuid) => ( {staffOptions.find((o) => String(o.value) === uuid)?.label ?? uuid} {canEdit && ( )} ))}
)}

پزشک ناظر

پاسخگوی بالینی دوره. لازم نیست خودش درمان را انجام دهد.

setSupervisor(v === null ? null : String(v))} placeholder="بدون پزشک ناظر" isClearable isDisabled={!canEdit} ariaLabel="پزشک ناظر دوره" />
)}
{/* نوار ذخیرهٔ چسبان: فرم بلند است و دکمهٔ ته صفحه یعنی اسکرول اجباری، به‌ویژه در موبایل. */} {enabled && canEdit && (
{staffUuids.length === 0 ? 'برای ذخیره حداقل یک پرسنل مجاز انتخاب کنید' : `${steps.length} جلسه · ${staffUuids.length} پرسنل مجاز`}
)} remove.mutate()} onCancel={() => setConfirmOff(false)} /> ); }