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>
This commit is contained in:
hamed
2026-08-07 14:55:01 +03:30
co-authored by Claude Opus 5
parent 12c0d57e1a
commit b78f7311cf
13 changed files with 416 additions and 6 deletions
@@ -16,6 +16,7 @@ const STATUS_OPTIONS: Array<{ value: TreatmentCaseStatus; label: string }> = [
];
interface DoctorRow { uuid: string; name?: string | null; full_name?: string | null }
interface StaffRow { uuid: string; full_name: string }
/**
* ویرایش پروندهٔ درمان.
@@ -43,6 +44,12 @@ export default function TreatmentCaseEditModal({ caseUuid, onClose }: {
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 (
@@ -59,6 +66,7 @@ export default function TreatmentCaseEditModal({ caseUuid, onClose }: {
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] });
@@ -71,10 +79,11 @@ export default function TreatmentCaseEditModal({ caseUuid, onClose }: {
);
}
function EditForm({ detail, doctors, doctorsLoading, onSaved, onClose }: {
function EditForm({ detail, doctors, doctorsLoading, staff, onSaved, onClose }: {
detail: TreatmentCaseDetail;
doctors: DoctorRow[];
doctorsLoading: boolean;
staff: StaffRow[];
onSaved: () => void;
onClose: () => void;
}) {
@@ -84,6 +93,7 @@ function EditForm({ detail, doctors, doctorsLoading, onSaved, onClose }: {
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));
// ناحیه‌ای که دسته‌اش حذف شده در سابقه هست ولی دیگر قابل انتخاب نیست — باید دیده
// شود، وگرنه کاربر فکر می‌کند فرم آن را انداخته است.
@@ -99,6 +109,7 @@ function EditForm({ detail, doctors, doctorsLoading, onSaved, onClose }: {
status,
supervisor_doctor_uuid: supervisor,
area_uuids: areas,
staff_uuids: staffUuids,
total_sessions: Number(total) || 0,
}),
onSuccess: () => { toast.success('پرونده به‌روزرسانی شد'); onSaved(); },
@@ -107,6 +118,8 @@ function EditForm({ detail, doctors, doctorsLoading, onSaved, onClose }: {
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;
@@ -193,6 +206,48 @@ function EditForm({ detail, doctors, doctorsLoading, onSaved, onClose }: {
)}
</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 }}>
@@ -24,6 +24,8 @@ function caseRow(over: Record<string, unknown> = {}) {
supervisor: { uuid: 'doc-1', name: 'پزشک مدیسا' },
patient: { record_uuid: 'rec-1', name: 'محمد رسولی', mobile: '09120001111', record_number: '۱۲' },
areas: [{ uuid: 'ca-1', name: 'دست', category_uuid: 'cat-1' }],
assigned_staff: [],
performed_by: [],
...over,
};
}
@@ -124,6 +126,24 @@ describe('صفحهٔ پرونده‌های درمان', () => {
expect(start.textContent).toMatch(/:/);
});
/** «چه کسی انجامش داد» سؤالِ اولِ مدیر است وقتی پرونده را در فهرست می‌بیند. */
it('نام پرسنل انجام‌دهنده را روی کارت می‌آورد', async () => {
mockList([caseRow({ performed_by: [{ uuid: 'st-1', name: 'پرسنل۱' }] })]);
renderWithProviders(<TreatmentCasesPage />);
expect(await screen.findByText(/انجام‌دهنده: پرسنل۱/)).toBeInTheDocument();
});
/** تا وقتی جلسه‌ای انجام نشده، برنامه را نشان می‌دهیم نه هیچ. */
it('اگر هنوز انجام نشده، اپراتورِ اختصاص‌یافته را نشان می‌دهد', async () => {
mockList([caseRow({ assigned_staff: [{ uuid: 'st-2', name: 'پرسنل۲' }] })]);
renderWithProviders(<TreatmentCasesPage />);
expect(await screen.findByText(/اپراتور: پرسنل۲/)).toBeInTheDocument();
});
it('دکمهٔ ویرایش مودال را باز می‌کند', async () => {
mockList([caseRow()]);
+10 -1
View File
@@ -124,7 +124,7 @@ function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo }
<input
value={term}
onChange={(e) => setTerm(e.target.value)}
placeholder="نام بیمار، موبایل، کد ملی، شمارهٔ پرونده یا سرویس"
placeholder="نام بیمار، پرسنل، موبایل، کد ملی، شمارهٔ پرونده یا سرویس"
aria-label="جستجوی پرونده"
/>
{term !== '' && (
@@ -242,6 +242,15 @@ function CaseCard({ item: c, onEdit }: { item: TreatmentCaseSummary; onEdit: ()
{/* ساعت هم لازم است: چند پروندهٔ یک روز فقط با ساعت از هم جدا می‌شوند. */}
<span>شروع: {formatDateTime(c.opened_at)}</span>
{c.supervisor && <span>پزشک ناظر: {c.supervisor.name}</span>}
{/* انجام‌دهنده از جلسات می‌آید (سابقه) و اختصاص‌یافته برنامه است؛ تا وقتی
جلسه‌ای انجام نشده، همان برنامه را نشان می‌دهیم. */}
{c.performed_by.length > 0 ? (
<span>انجامدهنده: {c.performed_by.map((s) => s.name).join('، ')}</span>
) : c.assigned_staff.length > 0 ? (
<span style={{ color: 'var(--text-3)' }}>
اپراتور: {c.assigned_staff.map((s) => s.name).join('، ')} (هنوز انجام نشده)
</span>
) : null}
{c.areas.length > 0 && <span>نواحی: {c.areas.map((a) => a.name).join('، ')}</span>}
</div>
+4
View File
@@ -1341,6 +1341,10 @@ export interface TreatmentCaseSummary {
record_number: string | null;
};
areas: Array<{ uuid: string; name: string; category_uuid: string | null }>;
/** اپراتورهای اختصاص‌یافته — برنامه. خالی یعنی «هر کسی که پروتکل مجاز دانسته». */
assigned_staff: Array<{ uuid: string; name: string }>;
/** اپراتورهایی که واقعاً جلسه‌ای از این پرونده را انجام داده‌اند — سابقه. */
performed_by: Array<{ uuid: string; name: string }>;
sessions?: TreatmentSessionSummary[];
}