refactor(admin): make the treatment plan tab usable and reversible

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) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-07 11:29:09 +03:30
co-authored by Claude Opus 5
parent a63de2a52c
commit 1f4f7e6927
+233 -128
View File
@@ -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 <hr style={{ border: 0, borderTop: '1px solid var(--border)', margin: 0 }} />;
}
/**
* «طول درمان» یک سرویس.
*
* وجودِ پروتکل خودش سوییچ است — سرویس بدون پروتکل تک‌جلسه‌ای است — پس روشن‌کردن یعنی
* ساختن و خاموش‌کردن یعنی حذف. فاصلهٔ هر گام از **جلسهٔ قبل** است، نه از شروع دوره،
* چون فاصلهٔ درمان به آخرین جلسه گره خورده نه به روز باز شدن پرونده.
* ساختن و خاموش‌کردن یعنی حذف. چون خاموش‌کردن برگشت‌ناپذیر است و گام‌ها و پرسنل را با
* خودش می‌برد، با تأیید صریح انجام می‌شود نه با یک کلیک.
*
* فاصلهٔ هر گام از **جلسهٔ قبل** است، نه از شروع دوره، چون فاصلهٔ درمان به آخرین جلسه
* گره خورده نه به روز باز شدن پرونده.
*/
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<ApiResponse<TreatmentProtocol | null>>(
`/api/v1/service-item/${serviceUuid}/treatment-protocol`,
@@ -82,6 +94,7 @@ export default function TreatmentProtocolTab({ serviceUuid, canEdit }: {
const [steps, setSteps] = useState<ProtocolStep[]>(DEFAULT_STEPS);
const [staffUuids, setStaffUuids] = useState<string[]>([]);
const [supervisor, setSupervisor] = useState<string | null>(null);
const [confirmOff, setConfirmOff] = useState(false);
useEffect(() => {
setEnabled(protocol !== null);
@@ -116,14 +129,28 @@ export default function TreatmentProtocolTab({ serviceUuid, canEdit }: {
mutationFn: () => api.delete<ApiResponse<null>>(`/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 <div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</div>;
}
/**
* بدون این، شکستِ کوئری `protocol = null` می‌داد و صفحه سوییچِ خاموش نشان می‌داد —
* یعنی به مدیر می‌گفت این سرویس تک‌جلسه‌ای است، در حالی که فقط خواندن شکست خورده.
*/
if (isError) {
return (
<div className="card card-pad" style={{ display: 'grid', gap: 10, justifyItems: 'start' }}>
<strong style={{ fontSize: 13.5 }}>طول درمان این سرویس خوانده نشد</strong>
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
تا وقتی خطا برطرف نشده، وضعیت واقعی این سرویس معلوم نیست.
</span>
<button type="button" className="btn secondary sm" onClick={() => refetch()}>
تلاش دوباره
</button>
</div>
);
}
const dirty = enabled && staffUuids.length > 0;
return (
<div className="card card-pad" style={{ display: 'grid', gap: 16 }}>
<Switch
checked={enabled}
onChange={toggle}
disabled={!canEdit}
label="طول درمان"
hint="سرویس‌هایی که در چند جلسه انجام می‌شوند — لیزر، بوتاکس، مزوتراپی. خاموش یعنی تک‌جلسه‌ای."
/>
<>
<div className="card card-pad" style={{ display: 'grid', gap: 18 }}>
<Switch
checked={enabled}
onChange={toggle}
disabled={!canEdit}
label="طول درمان"
hint="سرویس‌هایی که در چند جلسه انجام می‌شوند — لیزر، بوتاکس، مزوتراپی. خاموش یعنی تک‌جلسه‌ای."
/>
{enabled && protocol !== null && protocol.service_has_resources === false && (
<div
className="card card-pad"
style={{ background: 'var(--warning-bg)', display: 'grid', gap: 4 }}
>
<strong style={{ fontSize: 13 }}>هیچ دستگاهی به این سرویس وصل نیست</strong>
<span style={{ fontSize: 12.5, color: 'var(--text-2)', lineHeight: 1.9 }}>
رزرو قفل نمیشود، ولی منشی میتواند این سرویس را روی هر منبعی ثبت کند و اپراتور فرم
دستگاه درست را نمیبیند. در «منابع» مشخص کنید کدام دستگاهها این سرویس را میدهند.
</span>
</div>
)}
{enabled && protocol?.service_has_resources === false && (
<div
style={{
display: 'flex', gap: 10, padding: '12px 14px',
background: 'var(--warning-bg)', borderRadius: 'var(--r-sm)',
}}
>
<ExclamationTriangleIcon style={{ width: 18, height: 18, color: 'var(--warning)', flexShrink: 0 }} />
<div style={{ display: 'grid', gap: 4 }}>
<strong style={{ fontSize: 13 }}>هیچ دستگاهی به این سرویس وصل نیست</strong>
<span style={{ fontSize: 12.5, color: 'var(--text-2)', lineHeight: 1.9 }}>
رزرو قفل نمیشود، ولی منشی میتواند این سرویس را روی هر منبعی ثبت کند و اپراتور فرم
دستگاه درست را نمیبیند. در «منابع» مشخص کنید کدام دستگاهها این سرویس را میدهند.
</span>
</div>
</div>
)}
{enabled && (
<>
<section style={{ display: 'grid', gap: 10 }}>
<h3 style={{ margin: 0, fontSize: 13.5 }}>
جلسات دوره <span style={{ color: 'var(--text-3)', fontWeight: 400 }}>({steps.length} جلسه)</span>
</h3>
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.9 }}>
فاصلهٔ هر جلسه از <b>جلسهٔ قبل</b> حساب میشود، نه از شروع دوره. اگر بیمار دیر بیاید،
بقیهٔ دوره هم جابهجا میشود.
</p>
{enabled && (
<>
<Divider />
{steps.map((step, index) => (
<div key={index} className="field" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ minWidth: 62, fontSize: 13 }}>جلسهٔ {index + 1}</span>
{index === 0 ? (
<span style={{ fontSize: 12.5, color: 'var(--text-3)', flex: 1 }}>
شروع دوره همان روز اولین نوبت
<section style={{ display: 'grid', gap: 12 }}>
<div>
<h3 className="section-title" style={{ margin: 0, fontSize: 15 }}>
جلسات دوره{' '}
<span style={{ color: 'var(--text-3)', fontWeight: 400, fontSize: 13 }}>
({steps.length} جلسه)
</span>
) : (
<>
<input
type="number"
min={1}
value={step.offset_days}
disabled={!canEdit}
onChange={(e) => setOffset(index, Number(e.target.value))}
aria-label={`فاصلهٔ جلسهٔ ${index + 1} از جلسهٔ قبل به روز`}
style={{ width: 96 }}
/>
<span style={{ fontSize: 12.5, color: 'var(--text-3)', flex: 1 }}>روز بعد از جلسهٔ قبل</span>
</>
)}
{canEdit && steps.length > 2 && (
<button
type="button"
className="btn ghost sm"
onClick={() => removeStep(index)}
aria-label={`حذف جلسهٔ ${index + 1}`}
>
<TrashIcon style={{ width: 16, height: 16 }} />
</button>
)}
</h3>
<p style={{ margin: '6px 0 0', fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.9 }}>
فاصلهٔ هر جلسه از <b>جلسهٔ قبل</b> حساب میشود، نه از شروع دوره. اگر بیمار دیر بیاید،
بقیهٔ دوره هم جابهجا میشود.
</p>
</div>
))}
{canEdit && (
<button type="button" className="btn secondary sm" onClick={addStep} style={{ justifySelf: 'start' }}>
<PlusIcon style={{ width: 16, height: 16 }} /> افزودن جلسه
</button>
)}
</section>
{steps.map((step, index) => (
<div
key={index}
style={{
display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap',
paddingBottom: 10,
borderBottom: index < steps.length - 1 ? '1px dashed var(--border)' : 'none',
}}
>
<span style={{ minWidth: 68, fontSize: 13, fontWeight: 600 }}>جلسهٔ {index + 1}</span>
<section style={{ display: 'grid', gap: 8 }}>
<h3 style={{ margin: 0, fontSize: 13.5 }}>پرسنل مجاز</h3>
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-3)' }}>
منشی هنگام رزرو فقط از میان همینها انتخاب میکند.
</p>
{index === 0 ? (
<span style={{ fontSize: 12.5, color: 'var(--text-3)', flex: 1, minWidth: 180 }}>
شروع دوره همان روز اولین نوبت
</span>
) : (
<>
<Input
numeric
value={String(step.offset_days)}
disabled={!canEdit}
onChange={(e) => setOffset(index, Number(e.target.value || 0))}
aria-label={`فاصلهٔ جلسهٔ ${index + 1} از جلسهٔ قبل به روز`}
style={{ width: 96 }}
/>
<span style={{ fontSize: 12.5, color: 'var(--text-3)', flex: 1, minWidth: 140 }}>
روز بعد از جلسهٔ قبل
</span>
</>
)}
{canEdit && (
<SearchableSelect
options={staffOptions.filter((o) => !staffUuids.includes(String(o.value)))}
value={null}
onChange={addStaff}
placeholder="افزودن پرسنل..."
ariaLabel="افزودن پرسنل مجاز"
/>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{staffUuids.length === 0 && (
<span style={{ fontSize: 12.5, color: 'var(--danger)' }}>حداقل یک پرسنل الزامی است</span>
)}
{staffUuids.map((uuid) => (
<span key={uuid} className="badge" style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{staffOptions.find((o) => String(o.value) === uuid)?.label ?? uuid}
{canEdit && (
{canEdit && index > 0 && (
<button
type="button"
className="btn ghost sm"
onClick={() => setStaffUuids((prev) => prev.filter((u) => u !== uuid))}
aria-label="حذف این پرسنل"
style={{ padding: 0, minWidth: 18, height: 18 }}
className="mini-btn danger"
onClick={() => removeStep(index)}
disabled={steps.length <= MIN_STEPS}
title={steps.length <= MIN_STEPS ? 'دوره حداقل دو جلسه دارد' : 'حذف این جلسه'}
aria-label={`حذف جلسهٔ ${index + 1}`}
>
×
<TrashIcon style={{ width: 16, height: 16 }} />
</button>
)}
</span>
</div>
))}
</div>
</section>
<section style={{ display: 'grid', gap: 8 }}>
<h3 style={{ margin: 0, fontSize: 13.5 }}>پزشک ناظر</h3>
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-3)' }}>
پاسخگوی بالینی دوره. لازم نیست خودش درمان را انجام دهد.
</p>
<SearchableSelect
options={doctorOptions}
value={supervisor}
onChange={(v) => setSupervisor(v === null ? null : String(v))}
placeholder="بدون پزشک ناظر"
isClearable
isDisabled={!canEdit}
ariaLabel="پزشک ناظر دوره"
/>
</section>
{canEdit && (
<button type="button" className="btn secondary sm" onClick={addStep} style={{ justifySelf: 'start' }}>
<PlusIcon style={{ width: 16, height: 16 }} /> افزودن جلسه
</button>
)}
</section>
{canEdit && (
<Divider />
<section style={{ display: 'grid', gap: 10 }}>
<div>
<h3 className="section-title" style={{ margin: 0, fontSize: 15 }}>پرسنل مجاز</h3>
<p style={{ margin: '6px 0 0', fontSize: 12.5, color: 'var(--text-3)' }}>
منشی هنگام رزرو فقط از میان همینها انتخاب میکند.
</p>
</div>
{canEdit && (
<SearchableSelect
options={staffOptions.filter((o) => !staffUuids.includes(String(o.value)))}
value={null}
onChange={addStaff}
placeholder="افزودن پرسنل..."
ariaLabel="افزودن پرسنل مجاز"
/>
)}
{staffUuids.length === 0 ? (
<span style={{ fontSize: 12.5, color: 'var(--danger)' }}>
حداقل یک پرسنل الزامی است بدون آن ذخیره نمیشود.
</span>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{staffUuids.map((uuid) => (
<span
key={uuid}
className="badge"
style={{ display: 'inline-flex', alignItems: 'center', gap: 2, paddingInlineEnd: 2 }}
>
{staffOptions.find((o) => String(o.value) === uuid)?.label ?? uuid}
{canEdit && (
<button
type="button"
className="mini-btn danger"
onClick={() => setStaffUuids((prev) => prev.filter((u) => u !== uuid))}
aria-label="حذف این پرسنل"
>
<XMarkIcon style={{ width: 14, height: 14 }} />
</button>
)}
</span>
))}
</div>
)}
</section>
<Divider />
<section style={{ display: 'grid', gap: 10 }}>
<div>
<h3 className="section-title" style={{ margin: 0, fontSize: 15 }}>پزشک ناظر</h3>
<p style={{ margin: '6px 0 0', fontSize: 12.5, color: 'var(--text-3)' }}>
پاسخگوی بالینی دوره. لازم نیست خودش درمان را انجام دهد.
</p>
</div>
<SearchableSelect
options={doctorOptions}
value={supervisor}
onChange={(v) => setSupervisor(v === null ? null : String(v))}
placeholder="بدون پزشک ناظر"
isClearable
isDisabled={!canEdit}
ariaLabel="پزشک ناظر دوره"
/>
</section>
</>
)}
</div>
{/* نوار ذخیرهٔ چسبان: فرم بلند است و دکمهٔ ته صفحه یعنی اسکرول اجباری، به‌ویژه در موبایل. */}
{enabled && canEdit && (
<div className="save-bar">
<div className="sb-msg">
{staffUuids.length === 0
? 'برای ذخیره حداقل یک پرسنل مجاز انتخاب کنید'
: `${steps.length} جلسه · ${staffUuids.length} پرسنل مجاز`}
</div>
<div className="sb-actions">
<button
type="button"
className="btn primary"
onClick={() => save.mutate()}
disabled={save.isPending || staffUuids.length === 0}
style={{ justifySelf: 'start' }}
disabled={save.isPending || !dirty}
>
{save.isPending ? 'در حال ذخیره...' : 'ذخیرهٔ طول درمان'}
</button>
)}
</>
</div>
</div>
)}
</div>
<ConfirmDialog
open={confirmOff}
title="خاموش کردن طول درمان"
message="با این کار پروتکل این سرویس همراه با گام‌ها و فهرست پرسنل مجازش حذف می‌شود و سرویس دوباره تک‌جلسه‌ای می‌گردد. پرونده‌های درمانِ باز دست نمی‌خورند."
confirmLabel="خاموش کن"
danger
loading={remove.isPending}
onConfirm={() => remove.mutate()}
onCancel={() => setConfirmOff(false)}
/>
</>
);
}