Files
clinicpro/assets/admin/components/TreatmentProtocolTab.tsx
T
hamedandClaude Opus 5 d8aabe5d0a feat(admin): treatment plan tab, staff session screens and the device form editor
Three screens, each reusing what already exists rather than inventing a parallel
look. The treatment plan lives as a tab on the service page next to categories,
because the course belongs to the service; the switch is the protocol's existence
rather than a separate boolean that could disagree with the step list. Each step
asks for days since the previous session, which is how the interval actually
works and what the form should therefore say.

The staff screens are the flow from the reference screenshots: today's sessions,
then a session where each area is started, recorded and closed on its own. Field
inputs are built from the schema the server sends per resource type, so a clinic
adding an RF device sees its own form here without a code change.

StatusBadge gains treatment session and area states rather than a second badge
component sitting beside it, and the resource type modal grows a field editor so
the operator form is configured where the device is defined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:29:49 +03:30

286 lines
11 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;
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 && (
<>
<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>
);
}