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