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>
283 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|