Files
clinicpro/assets/admin/components/TreatmentCaseEditModal.tsx
T
hamedandClaude Opus 5 d3f7812d15 feat(admin): table/card views for treatment cases, matching the patients list
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>
2026-08-07 15:13:27 +03:30

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>
);
}