Files
clinicpro/assets/admin/components/TreatmentProtocolTab.tsx
T
hamedandClaude Opus 5 a63de2a52c fix(treatment): let the operator actually record an area
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>
2026-08-07 11:08:48 +03:30

301 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}