The staff field started empty even when the service's treatment protocol already named who may perform it — a decision made once in the service settings and then asked again on every booking. The modal now reads that protocol and preselects its first staff member, but only until the user touches the field; otherwise a manual choice would be wiped on the next service change. A protocol with no staff leaves it empty and does not block submission. Renames the four user-facing 'اپراتور' strings to 'پرسنل', matching the record in /admin/staff that they all refer to. ResourcesPage keeps the word: there it names a kind of bookable resource (doctor, operator, room, device), not a ClinicStaff row. Drops a test whose premise the default invalidated; the two new ones cover both sides — protocol with staff sends staff_uuid, protocol without staff sends none. 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>
|
|
);
|
|
}
|