Files
clinicpro/assets/admin/components/TreatmentCaseEditModal.tsx
T
hamedandClaude Opus 5 b78f7311cf feat(treatment): per-case operators, shown on the list and searchable
A treatment case said which doctor supervised it but never who actually did the
work, so the list could not answer the first question a manager asks about a
course: who performed it.

Two separate things now travel with the case. `performed_by` is history —
derived from the sessions' performedBy, so it only ever reports what happened.
`assigned_staff` is plan — a new treatment_case_staff table, editable from the
modal, saying who is meant to handle this patient's course. The card shows the
first and falls back to the second while nothing has been performed yet.

Search matches both. A manager typing an operator's name wants that person's
work, and work already done is part of it.

Assignment also narrows the operator queue: a case with assigned staff shows its
sessions only to those people, because a patient who started a multi-session
course with one operator should keep them. An unassigned case keeps the existing
protocol rule, and an empty list means "anyone the protocol allows" rather than
"nobody" — the same "no rows is not a restriction" convention used elsewhere.

Unlike areas, removing an operator erases nothing: a finished session carries its
real operator on itself and never consults this list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:55:01 +03:30

283 lines
11 KiB
TypeScript

import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api, ApiError } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import Modal from './ui/Modal';
import Input from './ui/Input';
import SearchableSelect from './ui/SearchableSelect';
import { formatNumber } from '../lib/utils';
import type { TreatmentCaseDetail, TreatmentCaseStatus } from '../types';
const STATUS_OPTIONS: Array<{ value: TreatmentCaseStatus; label: string }> = [
{ value: 'active', label: 'در جریان' },
{ value: 'completed', label: 'تمام شده' },
{ value: 'abandoned', label: 'رها شده' },
];
interface DoctorRow { uuid: string; name?: string | null; full_name?: string | null }
interface StaffRow { uuid: string; full_name: string }
/**
* ویرایش پروندهٔ درمان.
*
* پرونده بعد از باز شدن سند است نه فرم، پس فقط چیزهایی اینجا هستند که واقعاً وسط دوره
* عوض می‌شوند. سرور جلوی ویرایشی را که سابقه را بازنویسی کند می‌گیرد؛ فرم آن خطا را
* نشان می‌دهد، تکرارش نمی‌کند.
*/
export default function TreatmentCaseEditModal({ caseUuid, onClose }: {
caseUuid: string;
onClose: () => void;
}) {
const qc = useQueryClient();
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['treatment-case', caseUuid],
queryFn: () => api.get<ApiResponse<TreatmentCaseDetail>>(`/api/v1/treatment-case/${caseUuid}`),
});
// همان اندپوینتی که صفحهٔ نوبت‌ها می‌خواند: فقط پزشکانِ مجازِ همین محیط.
// پاسخش دو لایه تو در تو است (`data.data`) — الگوی شناخته‌شدهٔ همین اندپوینت.
const doctorsQ = useQuery<ApiResponse<{ data: DoctorRow[] }>>({
queryKey: ['clinic-doctors-lite'],
queryFn: () => api.get('/api/v1/my/clinic-doctors'),
staleTime: 60_000,
});
const staffQ = useQuery<ApiResponse<StaffRow[]>>({
queryKey: ['staff-list'],
queryFn: () => api.get('/api/v1/staff'),
staleTime: 60_000,
});
const detail = data?.data;
return (
<Modal open title="ویرایش پروندهٔ درمان" size="sm" onClose={onClose} footer={null}>
{isLoading ? (
<div style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</div>
) : isError || !detail ? (
<div style={{ display: 'grid', gap: 10, justifyItems: 'start' }}>
<span style={{ fontSize: 13, color: 'var(--danger)' }}>خواندن پرونده ناموفق بود.</span>
<button type="button" className="btn secondary sm" onClick={() => refetch()}>تلاش دوباره</button>
</div>
) : (
<EditForm
detail={detail}
doctors={doctorsQ.data?.data?.data ?? []}
doctorsLoading={doctorsQ.isLoading}
staff={staffQ.data?.data ?? []}
onSaved={() => {
qc.invalidateQueries({ queryKey: ['treatment-cases'] });
qc.invalidateQueries({ queryKey: ['treatment-case', caseUuid] });
onClose();
}}
onClose={onClose}
/>
)}
</Modal>
);
}
function EditForm({ detail, doctors, doctorsLoading, staff, onSaved, onClose }: {
detail: TreatmentCaseDetail;
doctors: DoctorRow[];
doctorsLoading: boolean;
staff: StaffRow[];
onSaved: () => void;
onClose: () => void;
}) {
const [status, setStatus] = useState<TreatmentCaseStatus>(detail.status);
const [supervisor, setSupervisor] = useState<string | null>(detail.supervisor?.uuid ?? null);
const [total, setTotal] = useState(String(detail.total_sessions));
const [areas, setAreas] = useState<string[]>(
detail.areas.map((a) => a.category_uuid).filter((u): u is string => u !== null),
);
const [staffUuids, setStaffUuids] = useState<string[]>(detail.assigned_staff.map((s) => s.uuid));
// ناحیه‌ای که دسته‌اش حذف شده در سابقه هست ولی دیگر قابل انتخاب نیست — باید دیده
// شود، وگرنه کاربر فکر می‌کند فرم آن را انداخته است.
const orphanAreas = useMemo(
() => detail.areas.filter((a) => a.category_uuid === null).map((a) => a.name),
[detail.areas],
);
const minTotal = detail.completed_sessions;
const save = useMutation({
mutationFn: () => api.patch<ApiResponse<TreatmentCaseDetail>>(`/api/v1/treatment-case/${detail.uuid}`, {
status,
supervisor_doctor_uuid: supervisor,
area_uuids: areas,
staff_uuids: staffUuids,
total_sessions: Number(total) || 0,
}),
onSuccess: () => { toast.success('پرونده به‌روزرسانی شد'); onSaved(); },
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'ویرایش پرونده ناموفق بود'),
});
const toggleArea = (uuid: string) =>
setAreas((prev) => prev.includes(uuid) ? prev.filter((u) => u !== uuid) : [...prev, uuid]);
const toggleStaff = (uuid: string) =>
setStaffUuids((prev) => prev.includes(uuid) ? prev.filter((u) => u !== uuid) : [...prev, uuid]);
const totalValid = Number(total) >= 2 && Number(total) <= 60;
const valid = areas.length > 0 && totalValid;
return (
<div style={{ display: 'grid', gap: 16 }}>
<div style={{
padding: '10px 14px', borderRadius: 'var(--r-sm)', background: 'var(--primary-soft)',
display: 'flex', gap: 14, flexWrap: 'wrap', fontSize: 13,
}}>
<span>
<span style={{ color: 'var(--text-3)' }}>بیمار: </span>
<b>{detail.patient.name || 'بدون نام'}</b>
</span>
<span>
<span style={{ color: 'var(--text-3)' }}>سرویس: </span>
<b>{detail.service.name}</b>
</span>
</div>
<div className="field-block">
<label>وضعیت پرونده</label>
<div className="seg" style={{ display: 'flex' }}>
{STATUS_OPTIONS.map((o) => (
<button
key={o.value}
type="button"
className={status === o.value ? 'on' : ''}
aria-pressed={status === o.value}
onClick={() => setStatus(o.value)}
style={{ flex: 1, justifyContent: 'center' }}
>
{o.label}
</button>
))}
</div>
</div>
<div className="field-block">
<label htmlFor="case-supervisor">پزشک ناظر</label>
<SearchableSelect
inputId="case-supervisor"
options={doctors.map((d) => ({ value: d.uuid, label: d.name ?? d.full_name ?? '' }))}
value={supervisor}
onChange={(v) => setSupervisor(v === null ? null : String(v))}
placeholder="بدون پزشک ناظر"
isLoading={doctorsLoading}
isClearable
height={40}
/>
</div>
<div className="field-block">
<label>نواحی درمان</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{detail.available_areas.map((a) => {
const on = areas.includes(a.uuid);
return (
<button
key={a.uuid}
type="button"
role="checkbox"
aria-checked={on}
onClick={() => toggleArea(a.uuid)}
style={{
minHeight: 36, padding: '6px 12px', borderRadius: 'var(--r-sm)',
cursor: 'pointer', fontFamily: 'inherit', fontSize: 13,
border: on ? '1px solid var(--primary)' : '1px solid var(--border)',
background: on ? 'var(--primary-soft)' : 'var(--surface)',
color: on ? 'var(--primary-700)' : 'var(--text-2)',
fontWeight: on ? 600 : 400,
}}
>
{a.name}
</button>
);
})}
</div>
{areas.length === 0 && <span className="field-err">حداقل یک ناحیه لازم است</span>}
{orphanAreas.length > 0 && (
<span className="field-hint">
نواحیِ «{orphanAreas.join('، ')}» در سابقه هستند ولی دسته‌بندی‌شان حذف شده و قابل انتخاب نیستند.
</span>
)}
</div>
<div className="field-block">
<label>اپراتور</label>
{staff.length === 0 ? (
<span className="field-hint">پرسنلی در این محیط تعریف نشده است.</span>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{staff.map((p) => {
const on = staffUuids.includes(p.uuid);
return (
<button
key={p.uuid}
type="button"
role="checkbox"
aria-checked={on}
onClick={() => toggleStaff(p.uuid)}
style={{
minHeight: 36, padding: '6px 12px', borderRadius: 'var(--r-sm)',
cursor: 'pointer', fontFamily: 'inherit', fontSize: 13,
border: on ? '1px solid var(--primary)' : '1px solid var(--border)',
background: on ? 'var(--primary-soft)' : 'var(--surface)',
color: on ? 'var(--primary-700)' : 'var(--text-2)',
fontWeight: on ? 600 : 400,
}}
>
{p.full_name}
</button>
);
})}
</div>
)}
<span className="field-hint">
{staffUuids.length === 0
? 'خالی یعنی هر پرسنلِ مجازِ این سرویس می‌تواند جلسات را انجام دهد.'
: 'جلسات این پرونده فقط در صف همین افراد دیده می‌شود.'}
</span>
{detail.performed_by.length > 0 && (
<span className="field-hint">
تا اینجا انجام‌دهنده: {detail.performed_by.map((p) => p.name).join('، ')}
</span>
)}
</div>
<div className="field-block">
<label htmlFor="case-total">تعداد جلسات</label>
<div className="field" style={{ maxWidth: 140 }}>
<Input
id="case-total"
numeric
className=""
value={total}
onChange={(e) => setTotal(e.target.value.replace(/\D/g, '').slice(0, 2))}
/>
</div>
<span className={totalValid ? 'field-hint' : 'field-err'}>
{totalValid
? `${formatNumber(minTotal)} جلسه انجام شده. جلسه‌ای که نوبت دارد یا انجام شده حذف نمی‌شود.`
: 'تعداد جلسات باید بین ۲ و ۶۰ باشد'}
</span>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-start' }}>
<button
type="button"
className="btn primary"
onClick={() => save.mutate()}
disabled={!valid || save.isPending}
>
{save.isPending ? 'در حال ذخیره…' : 'ذخیره'}
</button>
<button type="button" className="btn ghost" onClick={onClose}>انصراف</button>
</div>
</div>
);
}