diff --git a/assets/admin/components/TreatmentProtocolTab.tsx b/assets/admin/components/TreatmentProtocolTab.tsx index af6a1847..2d6ba13e 100644 --- a/assets/admin/components/TreatmentProtocolTab.tsx +++ b/assets/admin/components/TreatmentProtocolTab.tsx @@ -1,9 +1,11 @@ import { useEffect, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; -import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline'; +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 { @@ -44,12 +46,22 @@ const DEFAULT_STEPS: ProtocolStep[] = [ { step_number: 2, offset_days: 30 }, ]; +const MIN_STEPS = 2; + +/** خط جداکنندهٔ بخش‌ها — سه بخشِ مستقل نباید به‌هم چسبیده به نظر برسند. */ +function Divider() { + return
; +} + /** * «طول درمان» یک سرویس. * * وجودِ پروتکل خودش سوییچ است — سرویس بدون پروتکل تک‌جلسه‌ای است — پس روشن‌کردن یعنی - * ساختن و خاموش‌کردن یعنی حذف. فاصلهٔ هر گام از **جلسهٔ قبل** است، نه از شروع دوره، - * چون فاصلهٔ درمان به آخرین جلسه گره خورده نه به روز باز شدن پرونده. + * ساختن و خاموش‌کردن یعنی حذف. چون خاموش‌کردن برگشت‌ناپذیر است و گام‌ها و پرسنل را با + * خودش می‌برد، با تأیید صریح انجام می‌شود نه با یک کلیک. + * + * فاصلهٔ هر گام از **جلسهٔ قبل** است، نه از شروع دوره، چون فاصلهٔ درمان به آخرین جلسه + * گره خورده نه به روز باز شدن پرونده. */ export default function TreatmentProtocolTab({ serviceUuid, canEdit }: { serviceUuid: string; @@ -57,7 +69,7 @@ export default function TreatmentProtocolTab({ serviceUuid, canEdit }: { }) { const qc = useQueryClient(); - const { data, isLoading } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['treatment-protocol', serviceUuid], queryFn: () => api.get>( `/api/v1/service-item/${serviceUuid}/treatment-protocol`, @@ -82,6 +94,7 @@ export default function TreatmentProtocolTab({ serviceUuid, canEdit }: { const [steps, setSteps] = useState(DEFAULT_STEPS); const [staffUuids, setStaffUuids] = useState([]); const [supervisor, setSupervisor] = useState(null); + const [confirmOff, setConfirmOff] = useState(false); useEffect(() => { setEnabled(protocol !== null); @@ -116,14 +129,28 @@ export default function TreatmentProtocolTab({ serviceUuid, canEdit }: { 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) => { - setEnabled(on); - if (!on && protocol !== null) remove.mutate(); + if (on) { + setEnabled(true); + return; + } + + if (protocol === null) { + setEnabled(false); + return; + } + + setConfirmOff(true); }; const setOffset = (index: number, value: number) => { @@ -138,7 +165,7 @@ export default function TreatmentProtocolTab({ serviceUuid, canEdit }: { }; const removeStep = (index: number) => { - setSteps((prev) => (prev.length <= 2 ? prev : prev.filter((_, i) => i !== index))); + setSteps((prev) => (prev.length <= MIN_STEPS ? prev : prev.filter((_, i) => i !== index))); }; const addStaff = (uuid: string | number | null) => { @@ -150,151 +177,229 @@ export default function TreatmentProtocolTab({ serviceUuid, canEdit }: { return
در حال بارگذاری...
; } + /** + * بدون این، شکستِ کوئری `protocol = null` می‌داد و صفحه سوییچِ خاموش نشان می‌داد — + * یعنی به مدیر می‌گفت این سرویس تک‌جلسه‌ای است، در حالی که فقط خواندن شکست خورده. + */ + if (isError) { + return ( +
+ طول درمان این سرویس خوانده نشد + + تا وقتی خطا برطرف نشده، وضعیت واقعی این سرویس معلوم نیست. + + +
+ ); + } + + const dirty = enabled && staffUuids.length > 0; + return ( -
- + <> +
+ - {enabled && protocol !== null && protocol.service_has_resources === false && ( -
- هیچ دستگاهی به این سرویس وصل نیست - - رزرو قفل نمی‌شود، ولی منشی می‌تواند این سرویس را روی هر منبعی ثبت کند و اپراتور فرم - دستگاه درست را نمی‌بیند. در «منابع» مشخص کنید کدام دستگاه‌ها این سرویس را می‌دهند. - -
- )} + {enabled && protocol?.service_has_resources === false && ( +
+ +
+ هیچ دستگاهی به این سرویس وصل نیست + + رزرو قفل نمی‌شود، ولی منشی می‌تواند این سرویس را روی هر منبعی ثبت کند و اپراتور فرم + دستگاه درست را نمی‌بیند. در «منابع» مشخص کنید کدام دستگاه‌ها این سرویس را می‌دهند. + +
+
+ )} - {enabled && ( - <> -
-

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

-

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

+ {enabled && ( + <> + - {steps.map((step, index) => ( -
- جلسهٔ {index + 1} - - {index === 0 ? ( - - شروع دوره — همان روز اولین نوبت +
+
+

+ جلسات دوره{' '} + + ({steps.length} جلسه) - ) : ( - <> - setOffset(index, Number(e.target.value))} - aria-label={`فاصلهٔ جلسهٔ ${index + 1} از جلسهٔ قبل به روز`} - style={{ width: 96 }} - /> - روز بعد از جلسهٔ قبل - - )} - - {canEdit && steps.length > 2 && ( - - )} +

+

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

- ))} - {canEdit && ( - - )} -
+ {steps.map((step, index) => ( +
+ جلسهٔ {index + 1} -
-

پرسنل مجاز

-

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

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

پزشک ناظر

-

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

- setSupervisor(v === null ? null : String(v))} - placeholder="بدون پزشک ناظر" - isClearable - isDisabled={!canEdit} - ariaLabel="پزشک ناظر دوره" - /> -
+ {canEdit && ( + + )} + - {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)} + /> + ); }