Files
clinicpro/assets/admin/components/TreatmentCaseEditModal.tsx
hamedandClaude Opus 5 c4099f0f63 feat(admin): default the booking staff from the service's protocol, and call it پرسنل
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>
2026-08-07 16:28:13 +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>
);
}