Starting a session created area records with no device, and the panel only ever read the device it never set — so every "اتمام این ناحیه" came back 422 with "دستگاه این ناحیه مشخص نیست". The backend tests passed because they sent resource_uuid explicitly; from the UI the flow was unusable end to end. The device now inherits from the appointment's resource, which the secretary already chose at booking; asking the operator again is taking one decision twice. The session screen offers a picker per area on top of that, because one session really does run bikini on an alexandrite and underarms on a diode. Treating without a device is allowed: botox is an injection, and requiring a device would make clinics invent a fake resource per injection. Sending readings with no device is still rejected — there would be no schema to validate against. A protocol whose service has no ResourceServiceOffering rows now says so in the tab where the manager is standing. It does not block booking: "no offering means any resource" is a deliberate, tested rule. But silence meant the gap surfaced only when the operator was already in front of a patient. Also adds the live timer the spec asked for, and wires slot-suggestions into the unbooked queue — the endpoint existed and tested green but no screen called it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
301 lines
12 KiB
TypeScript
301 lines
12 KiB
TypeScript
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 Switch from './ui/Switch';
|
||
import SearchableSelect from './ui/SearchableSelect';
|
||
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 },
|
||
];
|
||
|
||
/**
|
||
* «طول درمان» یک سرویس.
|
||
*
|
||
* وجودِ پروتکل خودش سوییچ است — سرویس بدون پروتکل تکجلسهای است — پس روشنکردن یعنی
|
||
* ساختن و خاموشکردن یعنی حذف. فاصلهٔ هر گام از **جلسهٔ قبل** است، نه از شروع دوره،
|
||
* چون فاصلهٔ درمان به آخرین جلسه گره خورده نه به روز باز شدن پرونده.
|
||
*/
|
||
export default function TreatmentProtocolTab({ serviceUuid, canEdit }: {
|
||
serviceUuid: string;
|
||
canEdit: boolean;
|
||
}) {
|
||
const qc = useQueryClient();
|
||
|
||
const { data, isLoading } = useQuery({
|
||
queryKey: ['treatment-protocol', serviceUuid],
|
||
queryFn: () => api.get<ApiResponse<TreatmentProtocol | null>>(
|
||
`/api/v1/service-item/${serviceUuid}/treatment-protocol`,
|
||
),
|
||
});
|
||
|
||
const { data: staffData } = useQuery({
|
||
queryKey: ['staff-list-for-protocol'],
|
||
queryFn: () => api.get<ApiResponse<StaffRow[]>>('/api/v1/staff'),
|
||
staleTime: 60_000,
|
||
});
|
||
|
||
const { data: doctorData } = useQuery({
|
||
queryKey: ['my-clinic-doctors'],
|
||
queryFn: () => api.get<ApiResponse<{ data: DoctorRow[] }>>('/api/v1/my/clinic-doctors'),
|
||
staleTime: 60_000,
|
||
});
|
||
|
||
const protocol = data?.data ?? null;
|
||
|
||
const [enabled, setEnabled] = useState(false);
|
||
const [steps, setSteps] = useState<ProtocolStep[]>(DEFAULT_STEPS);
|
||
const [staffUuids, setStaffUuids] = useState<string[]>([]);
|
||
const [supervisor, setSupervisor] = useState<string | null>(null);
|
||
|
||
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<ApiResponse<TreatmentProtocol>>(
|
||
`/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<ApiResponse<null>>(`/api/v1/service-item/${serviceUuid}/treatment-protocol`),
|
||
onSuccess: () => {
|
||
toast.success('طول درمان خاموش شد');
|
||
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();
|
||
};
|
||
|
||
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 <= 2 ? 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 <div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</div>;
|
||
}
|
||
|
||
return (
|
||
<div className="card card-pad" style={{ display: 'grid', gap: 16 }}>
|
||
<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 && (
|
||
<>
|
||
<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>
|
||
|
||
{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 }}>
|
||
شروع دوره — همان روز اولین نوبت
|
||
</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>
|
||
)}
|
||
</div>
|
||
))}
|
||
|
||
{canEdit && (
|
||
<button type="button" className="btn secondary sm" onClick={addStep} style={{ justifySelf: 'start' }}>
|
||
<PlusIcon style={{ width: 16, height: 16 }} /> افزودن جلسه
|
||
</button>
|
||
)}
|
||
</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>
|
||
|
||
{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 && (
|
||
<button
|
||
type="button"
|
||
className="btn ghost sm"
|
||
onClick={() => setStaffUuids((prev) => prev.filter((u) => u !== uuid))}
|
||
aria-label="حذف این پرسنل"
|
||
style={{ padding: 0, minWidth: 18, height: 18 }}
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
</span>
|
||
))}
|
||
</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 primary"
|
||
onClick={() => save.mutate()}
|
||
disabled={save.isPending || staffUuids.length === 0}
|
||
style={{ justifySelf: 'start' }}
|
||
>
|
||
{save.isPending ? 'در حال ذخیره...' : 'ذخیرهٔ طول درمان'}
|
||
</button>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|