The list was cards only, so comparing courses across patients meant reading four stacked blocks instead of scanning columns. It now has the same table/card toggle the patients page uses, reusing that page's toggle icons and keeping the choice in the URL so back and refresh hold it. Table is the default here: the operator, status and session progress are the columns a manager scans. Both views derive the operator the same way, through one helper — history (performed_by) when a session has been done, otherwise the plan (assigned_staff), labelled as not yet performed. Also fixes a three-states slip in the operator picker I added last time: it rendered "no staff defined" while the staff list was still loading, which is what an empty list looks like from the user's side. Loading now says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
289 lines
12 KiB
TypeScript
289 lines
12 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 ?? []}
|
|
staffLoading={staffQ.isLoading}
|
|
onSaved={() => {
|
|
qc.invalidateQueries({ queryKey: ['treatment-cases'] });
|
|
qc.invalidateQueries({ queryKey: ['treatment-case', caseUuid] });
|
|
onClose();
|
|
}}
|
|
onClose={onClose}
|
|
/>
|
|
)}
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function EditForm({ detail, doctors, doctorsLoading, staff, staffLoading, onSaved, onClose }: {
|
|
detail: TreatmentCaseDetail;
|
|
doctors: DoctorRow[];
|
|
doctorsLoading: boolean;
|
|
staff: StaffRow[];
|
|
staffLoading: boolean;
|
|
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>
|
|
{/* «در حال خواندن» با «تعریف نشده» یکی نیست؛ بدون این تفکیک، فهرستِ هنوز
|
|
نیامده بهصورت «پرسنلی ندارید» خوانده میشد. */}
|
|
{staffLoading ? (
|
|
<span className="field-hint">در حال خواندن فهرست پرسنل…</span>
|
|
) : 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>
|
|
);
|
|
}
|