From 1f4f7e692720484835075e01e077d620b6a43a22 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 7 Aug 2026 11:29:09 +0330 Subject: [PATCH] refactor(admin): make the treatment plan tab usable and reversible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The day-offset field was a bare input inside `.field`, and `.field` is itself the 40px bordered input box — so the number rendered as plain text with no border and nothing said it could be edited. It now uses the shared Input in numeric mode, which also normalises Persian digits on the way in. Turning the switch off deleted the protocol, its steps and its staff list on a single click with no confirmation. It now asks first, and says what will be lost and what will not: open treatment cases are untouched. A failed read left `protocol` null, which the tab rendered as a switch in the off position — telling the manager this service is single-session when in truth the request had simply failed. That state now shows an error with a retry. Both icon-only buttons were hand-padded down to 18px; the runtime probe flagged them in all four views. They use `.mini-btn`, which the design system already sizes to 32px and to 44px on touch. The probe now reports one short control on this page, and the same one on tabs this change never touched. The save button sat at the bottom of a long form, so saving on a phone meant scrolling past everything. `.save-bar` was already in the stylesheet for exactly this; it now also states what is about to be saved, and why the button is disabled when no staff are selected. Co-Authored-By: Claude Opus 5 (1M context) --- .../admin/components/TreatmentProtocolTab.tsx | 361 +++++++++++------- 1 file changed, 233 insertions(+), 128 deletions(-) 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)} + /> + ); }