The list had no way to tell two cases apart. TreatmentCase::toArray() carried
no patient, so four cases of the same service rendered as four identical
cards — same service, same supervisor, same date, same areas. Search would have
been meaningless without fixing that first, so the payload now carries the
patient (name, mobile, record number) and the card leads with the name.
Search: `?q=` on the list endpoint, matching patient name, mobile, national
code, record number and service name — the same keys a secretary already types
into the booking form. It lives in the URL via useUrlState, debounced, so back
and refresh keep the view.
Edit: PATCH /api/v1/treatment-case/{uuid} covering status, supervising doctor,
areas and session count, driven from a modal on the list. Rules live in
TreatmentCaseEditor, not the controller, around one boundary: no edit may
overwrite work already done. An area with session records cannot be removed, and
the session count cannot drop below the sessions that are booked or finished —
both 409, both tested. Reopening a closed case clears closed_at.
`areas[]` now also exposes `category_uuid`; the edit form selects catalog
categories, while `uuid` identifies the snapshot row.
Page fixes from the redesign checklist: the status filter was a hand-rolled
primary/secondary button pair, now `.seg` with `.on`; the raw `<progress>` bar
took the browser's own appearance and ignored the theme tokens, now a token-
styled bar with an explicit progressbar role; session counts go through
formatNumber; a failed request rendered as "no cases found", which reads as an
empty clinic rather than a broken one, and an empty search now says so in its
own words.
Adds the test files neither the page nor the case editor had.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
228 lines
9.0 KiB
TypeScript
228 lines
9.0 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 }
|
|
|
|
/**
|
|
* ویرایش پروندهٔ درمان.
|
|
*
|
|
* پرونده بعد از باز شدن سند است نه فرم، پس فقط چیزهایی اینجا هستند که واقعاً وسط دوره
|
|
* عوض میشوند. سرور جلوی ویرایشی را که سابقه را بازنویسی کند میگیرد؛ فرم آن خطا را
|
|
* نشان میدهد، تکرارش نمیکند.
|
|
*/
|
|
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 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}
|
|
onSaved={() => {
|
|
qc.invalidateQueries({ queryKey: ['treatment-cases'] });
|
|
qc.invalidateQueries({ queryKey: ['treatment-case', caseUuid] });
|
|
onClose();
|
|
}}
|
|
onClose={onClose}
|
|
/>
|
|
)}
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function EditForm({ detail, doctors, doctorsLoading, onSaved, onClose }: {
|
|
detail: TreatmentCaseDetail;
|
|
doctors: DoctorRow[];
|
|
doctorsLoading: 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 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,
|
|
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 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 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>
|
|
);
|
|
}
|