feat(treatment): search and edit for treatment cases
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>
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../test/utils';
|
||||
|
||||
vi.mock('../lib/api', () => ({
|
||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||
ApiError: class extends Error {},
|
||||
}));
|
||||
|
||||
import { api } from '../lib/api';
|
||||
import TreatmentCasesPage from './TreatmentCasesPage';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
|
||||
function caseRow(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
uuid: 'case-1',
|
||||
status: 'active',
|
||||
total_sessions: 3,
|
||||
completed_sessions: 1,
|
||||
opened_at: 1_786_000_000,
|
||||
closed_at: null,
|
||||
service: { uuid: 'svc-1', name: 'لیزر توتال' },
|
||||
supervisor: { uuid: 'doc-1', name: 'پزشک مدیسا' },
|
||||
patient: { record_uuid: 'rec-1', name: 'محمد رسولی', mobile: '09120001111', record_number: '۱۲' },
|
||||
areas: [{ uuid: 'ca-1', name: 'دست', category_uuid: 'cat-1' }],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/** فقط اندپوینت فهرست را جواب میدهد؛ بقیه خالی. */
|
||||
function mockList(rows: unknown[]) {
|
||||
get.mockImplementation((url: string) => {
|
||||
if (url.startsWith('/api/v1/treatment-cases')) return Promise.resolve({ success: true, data: rows });
|
||||
return Promise.resolve({ success: true, data: [] });
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
});
|
||||
|
||||
describe('صفحهٔ پروندههای درمان', () => {
|
||||
it('نام بیمار سرتیتر کارت است، نه نام سرویس', async () => {
|
||||
mockList([caseRow()]);
|
||||
|
||||
renderWithProviders(<TreatmentCasesPage />);
|
||||
|
||||
const name = await screen.findByText('محمد رسولی');
|
||||
expect(name.tagName).toBe('STRONG');
|
||||
expect(screen.getByText('لیزر توتال')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
/** جستجو باید به سرور برود، نه اینکه فهرست را در مرورگر فیلتر کند. */
|
||||
it('عبارت جستجو را بهصورت پارامتر q میفرستد', async () => {
|
||||
mockList([caseRow()]);
|
||||
|
||||
renderWithProviders(<TreatmentCasesPage />);
|
||||
await screen.findByText('محمد رسولی');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('جستجوی پرونده'), { target: { value: 'رسولی' } });
|
||||
|
||||
await waitFor(
|
||||
() => expect(get.mock.calls.some(
|
||||
(c: unknown[]) => typeof c[0] === 'string' && c[0].includes('q=%D8%B1%D8%B3%D9%88%D9%84%DB%8C'),
|
||||
)).toBe(true),
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('فیلتر وضعیت بهصورت status میرود', async () => {
|
||||
mockList([caseRow()]);
|
||||
|
||||
renderWithProviders(<TreatmentCasesPage />);
|
||||
await screen.findByText('محمد رسولی');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'رها شده' }));
|
||||
|
||||
await waitFor(() => expect(get.mock.calls.some(
|
||||
(c: unknown[]) => typeof c[0] === 'string' && c[0].includes('status=abandoned'),
|
||||
)).toBe(true));
|
||||
});
|
||||
|
||||
/** نتیجهٔ خالیِ جستجو با «هنوز پروندهای ساخته نشده» یکی نیست. */
|
||||
it('خالیِ جستجو پیام خودش را دارد', async () => {
|
||||
mockList([]);
|
||||
|
||||
renderWithProviders(<TreatmentCasesPage />);
|
||||
await screen.findByText(/پروندهای یافت نشد/);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('جستجوی پرونده'), { target: { value: 'هیچ' } });
|
||||
|
||||
expect(await screen.findByText(/برای «هیچ» پروندهای پیدا نشد/, {}, { timeout: 2000 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('خطا را از فهرست خالی جدا میکند', async () => {
|
||||
get.mockRejectedValue(new Error('boom'));
|
||||
|
||||
renderWithProviders(<TreatmentCasesPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/خواندن پروندهها ناموفق بود/)).toBeInTheDocument());
|
||||
expect(screen.queryByText(/پروندهای یافت نشد/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('دکمهٔ ویرایش مودال را باز میکند', async () => {
|
||||
mockList([caseRow()]);
|
||||
|
||||
renderWithProviders(<TreatmentCasesPage />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /ویرایش/ }));
|
||||
|
||||
expect(await screen.findByText('ویرایش پروندهٔ درمان')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { MagnifyingGlassIcon, PencilSquareIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import StatusBadge from '../components/ui/StatusBadge';
|
||||
import { formatDate, formatDateTime } from '../lib/utils';
|
||||
import { formatDate, formatDateTime, formatNumber } from '../lib/utils';
|
||||
import { useUrlState } from '../hooks/useUrlState';
|
||||
import TreatmentCaseEditModal from '../components/TreatmentCaseEditModal';
|
||||
import type { TreatmentCaseSummary, StaffTreatmentSession, SlotSuggestionResponse } from '../types';
|
||||
|
||||
const TABS = [
|
||||
@@ -29,7 +31,7 @@ const CASE_STATUS_LABEL: Record<TreatmentCaseSummary['status'], string> = {
|
||||
* را جواب میدهند — «کدام بیمار در چه مرحلهای است و چه کاری مانده».
|
||||
*/
|
||||
export default function TreatmentCasesPage() {
|
||||
const [urlState, setUrlState] = useUrlState({ tab: 'cases', status: '' });
|
||||
const [urlState, setUrlState] = useUrlState({ tab: 'cases', status: '', q: '' });
|
||||
const tab = (TABS.some((t) => t.id === urlState.tab) ? urlState.tab : 'cases') as TabId;
|
||||
|
||||
return (
|
||||
@@ -50,18 +52,53 @@ export default function TreatmentCasesPage() {
|
||||
</div>
|
||||
|
||||
{tab === 'cases'
|
||||
? <CasesTab status={urlState.status} onStatus={(s) => setUrlState({ status: s })} />
|
||||
? <CasesTab
|
||||
status={urlState.status}
|
||||
onStatus={(s) => setUrlState({ status: s })}
|
||||
search={urlState.q}
|
||||
onSearch={(q) => setUrlState({ q })}
|
||||
/>
|
||||
: <UnbookedTab />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CasesTab({ status, onStatus }: { status: string; onStatus: (s: string) => void }) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['treatment-cases', status],
|
||||
queryFn: () => api.get<ApiResponse<TreatmentCaseSummary[]>>(
|
||||
`/api/v1/treatment-cases${status ? `?status=${status}` : ''}`,
|
||||
),
|
||||
const STATUS_FILTERS = [
|
||||
['', 'همه'],
|
||||
['active', 'در جریان'],
|
||||
['completed', 'تمام شده'],
|
||||
['abandoned', 'رها شده'],
|
||||
] as const;
|
||||
|
||||
function CasesTab({ status, onStatus, search, onSearch }: {
|
||||
status: string;
|
||||
onStatus: (s: string) => void;
|
||||
search: string;
|
||||
onSearch: (s: string) => void;
|
||||
}) {
|
||||
// فیلد جستجو محلی میماند و فقط مقدار نهایی به URL میرود؛ وگرنه هر حرف یک ورودی
|
||||
// تاریخچه میسازد و «بازگشت» بیمعنی میشود.
|
||||
const [term, setTerm] = useState(search);
|
||||
useEffect(() => setTerm(search), [search]);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => { if (term !== search) onSearch(term); }, 350);
|
||||
return () => clearTimeout(t);
|
||||
}, [term]);
|
||||
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['treatment-cases', status, search],
|
||||
queryFn: () => {
|
||||
const qs = new URLSearchParams();
|
||||
if (status) qs.set('status', status);
|
||||
if (search) qs.set('q', search);
|
||||
const suffix = qs.toString();
|
||||
|
||||
return api.get<ApiResponse<TreatmentCaseSummary[]>>(
|
||||
`/api/v1/treatment-cases${suffix ? `?${suffix}` : ''}`,
|
||||
);
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
@@ -69,59 +106,115 @@ function CasesTab({ status, onStatus }: { status: string; onStatus: (s: string)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="toolbar" style={{ marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{[['', 'همه'], ['active', 'در جریان'], ['completed', 'تمام شده'], ['abandoned', 'رها شده']].map(([v, label]) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
className={`btn ${status === v ? 'primary' : 'secondary'} sm`}
|
||||
onClick={() => onStatus(v)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center', marginBottom: 14 }}>
|
||||
<div className="field" style={{ flex: '1 1 260px', maxWidth: 380 }}>
|
||||
<MagnifyingGlassIcon style={{ width: 16, height: 16, flexShrink: 0, color: 'var(--text-3)' }} />
|
||||
<input
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
placeholder="نام بیمار، موبایل، کد ملی، شمارهٔ پرونده یا سرویس"
|
||||
aria-label="جستجوی پرونده"
|
||||
/>
|
||||
{term !== '' && (
|
||||
<button type="button" className="mini-btn" aria-label="پاک کردن جستجو" onClick={() => setTerm('')}>
|
||||
<XMarkIcon style={{ width: 15, height: 15 }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="seg">
|
||||
{STATUS_FILTERS.map(([v, label]) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
className={status === v ? 'on' : ''}
|
||||
aria-pressed={status === v}
|
||||
onClick={() => onStatus(v)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</div>
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{[0, 1].map((i) => <div key={i} className="card" style={{ height: 96 }} />)}
|
||||
</div>
|
||||
) : isError ? (
|
||||
/* خطای سرور نباید «پروندهای یافت نشد» خوانده شود — آن یعنی جستجو نتیجه نداشت. */
|
||||
<div className="card card-pad" 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>
|
||||
) : cases.length === 0 ? (
|
||||
<div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}>
|
||||
پروندهای یافت نشد. پرونده وقتی ساخته میشود که نوبتِ سرویسی با «طول درمان» قطعی شود.
|
||||
<div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)', lineHeight: 1.9 }}>
|
||||
{search
|
||||
? `برای «${search}» پروندهای پیدا نشد.`
|
||||
: 'پروندهای یافت نشد. پرونده وقتی ساخته میشود که نوبتِ سرویسی با «طول درمان» قطعی شود.'}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{cases.map((c) => (
|
||||
<div key={c.uuid} className="card card-pad" style={{ display: 'grid', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<strong style={{ fontSize: 14 }}>{c.service.name}</strong>
|
||||
<span className={`badge ${c.status === 'active' ? 'blue' : c.status === 'completed' ? 'green' : 'gray'}`}>
|
||||
<span className="bdot" />{CASE_STATUS_LABEL[c.status]}
|
||||
</span>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
|
||||
{c.completed_sessions} از {c.total_sessions} جلسه
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 12.5, color: 'var(--text-2)' }}>
|
||||
<span>شروع: {formatDate(c.opened_at)}</span>
|
||||
{c.supervisor && <span>پزشک ناظر: {c.supervisor.name}</span>}
|
||||
{c.areas.length > 0 && <span>نواحی: {c.areas.map((a) => a.name).join('، ')}</span>}
|
||||
</div>
|
||||
|
||||
<progress
|
||||
value={c.completed_sessions}
|
||||
max={c.total_sessions}
|
||||
aria-label={`پیشرفت دوره: ${c.completed_sessions} از ${c.total_sessions}`}
|
||||
style={{ width: '100%', height: 6 }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{cases.map((c) => <CaseCard key={c.uuid} item={c} onEdit={() => setEditing(c.uuid)} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing !== null && (
|
||||
<TreatmentCaseEditModal caseUuid={editing} onClose={() => setEditing(null)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CaseCard({ item: c, onEdit }: { item: TreatmentCaseSummary; onEdit: () => void }) {
|
||||
const percent = c.total_sessions > 0
|
||||
? Math.round((c.completed_sessions / c.total_sessions) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="card card-pad" style={{ display: 'grid', gap: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
{/* بیمار سرتیتر است نه سرویس: دو پروندهٔ یک سرویس فقط با نام بیمار از هم جدا میشوند. */}
|
||||
<strong style={{ fontSize: 14 }}>{c.patient.name || 'بیمار بدون نام'}</strong>
|
||||
<span className={`badge ${c.status === 'active' ? 'blue' : c.status === 'completed' ? 'green' : 'gray'}`}>
|
||||
<span className="bdot" />{CASE_STATUS_LABEL[c.status]}
|
||||
</span>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
|
||||
{formatNumber(c.completed_sessions)} از {formatNumber(c.total_sessions)} جلسه
|
||||
</span>
|
||||
<button type="button" className="btn secondary sm" style={{ marginInlineStart: 'auto' }} onClick={onEdit}>
|
||||
<PencilSquareIcon style={{ width: 15, height: 15 }} />
|
||||
ویرایش
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 12.5, color: 'var(--text-2)' }}>
|
||||
<span>{c.service.name}</span>
|
||||
<span style={{ direction: 'ltr' }}>{c.patient.mobile}</span>
|
||||
<span>شروع: {formatDate(c.opened_at)}</span>
|
||||
{c.supervisor && <span>پزشک ناظر: {c.supervisor.name}</span>}
|
||||
{c.areas.length > 0 && <span>نواحی: {c.areas.map((a) => a.name).join('، ')}</span>}
|
||||
</div>
|
||||
|
||||
{/* `<progress>` نیتیو ظاهر مرورگر را میگیرد و با توکنهای تم نمیخواند. */}
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuenow={c.completed_sessions}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={c.total_sessions}
|
||||
aria-label={`پیشرفت دوره: ${c.completed_sessions} از ${c.total_sessions}`}
|
||||
style={{ height: 6, borderRadius: 999, background: 'var(--surface-3)', overflow: 'hidden' }}
|
||||
>
|
||||
<div style={{
|
||||
width: `${percent}%`, height: '100%', borderRadius: 999,
|
||||
background: c.status === 'completed' ? 'var(--success)' : 'var(--primary)',
|
||||
transition: 'width .3s var(--ease)',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UnbookedTab() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['treatment-sessions-unbooked'],
|
||||
|
||||
@@ -1322,19 +1322,33 @@ export interface StaffTreatmentSession extends TreatmentSessionSummary {
|
||||
resource_name?: string | null;
|
||||
}
|
||||
|
||||
export type TreatmentCaseStatus = 'active' | 'completed' | 'abandoned';
|
||||
|
||||
export interface TreatmentCaseSummary {
|
||||
uuid: string;
|
||||
status: 'active' | 'completed' | 'abandoned';
|
||||
status: TreatmentCaseStatus;
|
||||
total_sessions: number;
|
||||
completed_sessions: number;
|
||||
opened_at: number;
|
||||
closed_at: number | null;
|
||||
service: { uuid: string; name: string };
|
||||
supervisor: { uuid: string; name: string } | null;
|
||||
areas: Array<{ uuid: string; name: string }>;
|
||||
/** بدون بیمار، دو پروندهٔ یک سرویس در فهرست از هم قابل تشخیص نیستند. */
|
||||
patient: {
|
||||
record_uuid: string;
|
||||
name: string | null;
|
||||
mobile: string;
|
||||
record_number: string | null;
|
||||
};
|
||||
areas: Array<{ uuid: string; name: string; category_uuid: string | null }>;
|
||||
sessions?: TreatmentSessionSummary[];
|
||||
}
|
||||
|
||||
/** پاسخ `GET /api/v1/treatment-case/{uuid}` — پرونده بهعلاوهٔ نواحیِ قابل انتخاب. */
|
||||
export interface TreatmentCaseDetail extends TreatmentCaseSummary {
|
||||
available_areas: Array<{ uuid: string; name: string }>;
|
||||
}
|
||||
|
||||
/** تعریف یک فیلد فرم ثبت درمان، از `ResourceType.field_schema`. */
|
||||
export interface TreatmentFormField {
|
||||
key: string;
|
||||
|
||||
+37
-2
@@ -209,6 +209,7 @@ single-session again. Idempotent: deleting a service that has no protocol still
|
||||
| Query | توضیح |
|
||||
|---|---|
|
||||
| `status` | `active` \| `completed` \| `abandoned` — نبودش یعنی همه |
|
||||
| `q` | جستجو روی نام بیمار، موبایل، کد ملی، شمارهٔ پرونده و نام سرویس |
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -220,13 +221,47 @@ single-session again. Idempotent: deleting a service that has no protocol still
|
||||
"closed_at": null,
|
||||
"service": { "uuid": "…", "name": "لیزر توتال" },
|
||||
"supervisor": { "uuid": "…", "name": "دکتر ناظر" },
|
||||
"areas": [ { "uuid": "…", "name": "بیکینی" } ]
|
||||
"patient": {
|
||||
"record_uuid": "…",
|
||||
"name": "محمد رسولی",
|
||||
"mobile": "09120001111",
|
||||
"record_number": "۱۲"
|
||||
},
|
||||
"areas": [ { "uuid": "…", "name": "بیکینی", "category_uuid": "…" } ]
|
||||
}
|
||||
```
|
||||
|
||||
`areas[].uuid` شناسهٔ همان ردیفِ ناحیه است و `category_uuid` شناسهٔ دستهٔ کاتالوگ.
|
||||
ویرایش با دومی کار میکند؛ `null` یعنی دسته حذف شده و ناحیه فقط در سابقه مانده.
|
||||
|
||||
## GET `/api/v1/treatment-case/{uuid}`
|
||||
|
||||
همان شکل، بهعلاوهٔ `sessions`. پروندهٔ محیط دیگر `404` میگیرد.
|
||||
همان شکل، بهعلاوهٔ `sessions` و `available_areas` — نواحیِ قابل انتخاب برای همین
|
||||
سرویس، تا فرم ویرایش اندپوینت دومی نخواهد. پروندهٔ محیط دیگر `404` میگیرد.
|
||||
|
||||
## PATCH `/api/v1/treatment-case/{uuid}`
|
||||
|
||||
ویرایش پروندهٔ درمان. هر فیلد اختیاری است؛ فقط کلیدهای فرستادهشده اعمال میشوند.
|
||||
|
||||
| فیلد | توضیح |
|
||||
|---|---|
|
||||
| `status` | `active` \| `completed` \| `abandoned`. برگرداندن به `active` پروندهٔ بسته را باز میکند و `closed_at` را پاک میکند |
|
||||
| `supervisor_doctor_uuid` | پزشک ناظر؛ `null` یعنی بدون ناظر |
|
||||
| `area_uuids` | فهرست **دستهٔ کاتالوگ**، جایگزین کامل. حداقل یکی |
|
||||
| `total_sessions` | بین `TreatmentProtocol::MIN_STEPS` و `MAX_STEPS`. کمکردن جلسات را از انتها حذف میکند |
|
||||
|
||||
مرزِ ثابت: **هیچ ویرایشی سابقهٔ انجامشده را بازنویسی نمیکند.**
|
||||
|
||||
| کد | HTTP | فیلد | شرط |
|
||||
|---|---|---|---|
|
||||
| ERR_VALIDATION_001 | 422 | `status` | وضعیت نامعتبر |
|
||||
| ERR_VALIDATION_001 | 422 | `area_uuids` | فهرست خالی یا نامعتبر |
|
||||
| ERR_VALIDATION_001 | 422 | `total_sessions` | خارج از بازهٔ مجاز |
|
||||
| ERR_NOT_FOUND_001 | 404 | `supervisor_doctor_uuid` / `area_uuids` | پزشک یا ناحیه یافت نشد |
|
||||
| ERR_CONFLICT_001 | 409 | `area_uuids` | ناحیه در جلسهای ثبت شده و حذف نمیشود |
|
||||
| ERR_CONFLICT_001 | 409 | `total_sessions` | کمتر از جلساتی که نوبت دارند یا انجام شدهاند |
|
||||
|
||||
قواعدش در `TreatmentCaseEditor` است نه کنترلر.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Treatment\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\ClinicService\Entity\CatalogCategory;
|
||||
use App\Doctor\Service\AddressResolver;
|
||||
use App\Resource\Repository\ClinicResourceRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
@@ -14,6 +15,8 @@ use App\Treatment\Entity\TreatmentSession;
|
||||
use App\Treatment\Repository\TreatmentCaseRepository;
|
||||
use App\Treatment\Repository\TreatmentSessionRepository;
|
||||
use App\Treatment\Service\NextSessionSlotFinder;
|
||||
use App\Treatment\Service\TreatmentCaseEditor;
|
||||
use App\Treatment\Service\TreatmentCaseOpener;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -32,6 +35,8 @@ class TreatmentCaseController extends BaseController
|
||||
private readonly NextSessionSlotFinder $slotFinder,
|
||||
private readonly TenantOwnershipChecker $ownership,
|
||||
private readonly AddressResolver $branches,
|
||||
private readonly TreatmentCaseOpener $opener,
|
||||
private readonly TreatmentCaseEditor $editor,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/treatment-cases', name: 'treatment_case_list', methods: ['GET'])]
|
||||
@@ -42,16 +47,50 @@ class TreatmentCaseController extends BaseController
|
||||
$status = $request->query->get('status');
|
||||
$status = is_string($status) && $status !== '' ? $status : null;
|
||||
|
||||
$q = $request->query->get('q');
|
||||
$q = is_string($q) ? trim($q) : '';
|
||||
|
||||
return $this->success(array_map(
|
||||
static fn (TreatmentCase $c): array => $c->toArray(),
|
||||
$this->cases->findForTenant($entityType, $entityId, $status),
|
||||
$this->cases->findForTenant($entityType, $entityId, $status, $q !== '' ? $q : null),
|
||||
));
|
||||
}
|
||||
|
||||
#[Route('/api/v1/treatment-case/{uuid}', name: 'treatment_case_show', methods: ['GET'])]
|
||||
public function show(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||
{
|
||||
return $this->success($this->requireCase($user, $uuid)->toArray(withSessions: true));
|
||||
$case = $this->requireCase($user, $uuid);
|
||||
|
||||
/**
|
||||
* نواحیِ قابل انتخاب کنار خودِ پرونده میآید، وگرنه فرم ویرایش باید حدس بزند
|
||||
* کدام دستهها مجازند یا اندپوینت دومی برای همان یک سؤال ساخته شود.
|
||||
*/
|
||||
return $this->success($case->toArray(withSessions: true) + [
|
||||
'available_areas' => array_map(
|
||||
static fn (CatalogCategory $c): array => ['uuid' => $c->getUuid(), 'name' => $c->getName()],
|
||||
$this->opener->resolveAreas($case->getServiceItem()),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* ویرایش پروندهٔ باز — وضعیت، پزشک ناظر، نواحی و تعداد جلسات.
|
||||
*
|
||||
* قواعدش در {@see TreatmentCaseEditor} است نه اینجا: هیچ ویرایشی نباید سابقهٔ
|
||||
* انجامشده را بازنویسی کند و آن تصمیم جای کنترلر نیست.
|
||||
*/
|
||||
#[Route('/api/v1/treatment-case/{uuid}', name: 'treatment_case_update', methods: ['PATCH'])]
|
||||
public function update(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true);
|
||||
|
||||
if (!is_array($data)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
|
||||
}
|
||||
|
||||
$case = $this->editor->update($this->requireCase($user, $uuid), $data);
|
||||
|
||||
return $this->success($case->toArray(withSessions: true));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -155,6 +155,54 @@ class TreatmentCase
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** پروندهای که اشتباه بسته شده دوباره باز میشود؛ `closedAt` باید پاک شود وگرنه «بستهٔ فعال» میماند. */
|
||||
public function reopen(): self
|
||||
{
|
||||
$this->status = self::STATUS_ACTIVE;
|
||||
$this->closedAt = null;
|
||||
$this->touch();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setSupervisorDoctor(?Doctor $doctor): self
|
||||
{
|
||||
$this->supervisorDoctor = $doctor;
|
||||
$this->touch();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* تعداد جلساتِ همین پرونده، مستقل از پروتکل سرویس.
|
||||
*
|
||||
* پروتکل الگوی پیشفرض است نه قرارداد: بیمار ممکن است به جلسهٔ کمتر یا بیشتر
|
||||
* نیاز داشته باشد بدون اینکه سرویس برای بقیه عوض شود.
|
||||
*/
|
||||
public function setTotalSessions(int $total): self
|
||||
{
|
||||
$this->totalSessions = $total;
|
||||
$this->touch();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeArea(TreatmentCaseArea $area): self
|
||||
{
|
||||
$this->areas->removeElement($area);
|
||||
$this->touch();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeSession(TreatmentSession $session): self
|
||||
{
|
||||
$this->sessions->removeElement($session);
|
||||
$this->touch();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function toArray(bool $withSessions = false): array
|
||||
{
|
||||
$data = [
|
||||
@@ -168,6 +216,16 @@ class TreatmentCase
|
||||
'uuid' => $this->serviceItem->getUuid(),
|
||||
'name' => $this->serviceItem->getName(),
|
||||
],
|
||||
/**
|
||||
* بدون بیمار، دو پروندهٔ یک سرویس از هم قابل تشخیص نیستند — فهرست
|
||||
* پروندهها بدونش چند کارتِ یکسان است.
|
||||
*/
|
||||
'patient' => [
|
||||
'record_uuid' => $this->patientRecord->getUuid(),
|
||||
'name' => $this->patientRecord->getUser()->getRealName(),
|
||||
'mobile' => $this->patientRecord->getUser()->getMobileNumber(),
|
||||
'record_number' => $this->patientRecord->getRecordNumber(),
|
||||
],
|
||||
'supervisor' => $this->supervisorDoctor === null ? null : [
|
||||
'uuid' => $this->supervisorDoctor->getUuid(),
|
||||
'name' => $this->supervisorDoctor->getName(),
|
||||
|
||||
@@ -62,6 +62,11 @@ class TreatmentCaseArea
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'name' => $this->nameSnapshot,
|
||||
/**
|
||||
* فرم ویرایش با دستهٔ کاتالوگ کار میکند نه با این ردیف. `null` یعنی دسته
|
||||
* حذف شده — ناحیه هنوز در سابقه هست ولی دیگر قابل انتخاب نیست.
|
||||
*/
|
||||
'category_uuid' => $this->category?->getUuid(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,8 +39,17 @@ class TreatmentCaseRepository extends ServiceEntityRepository
|
||||
}
|
||||
|
||||
/** @return TreatmentCase[] */
|
||||
public function findForTenant(string $entityType, int $entityId, ?string $status = null): array
|
||||
{
|
||||
/**
|
||||
* @param ?string $q جستجو روی نام بیمار، موبایل، کد ملی، شمارهٔ پرونده و نام سرویس.
|
||||
* منشی همان کلیدی را میزند که در فرم نوبت میزند، پس هر چهار
|
||||
* شناسهٔ بیمار باید بگیرد نه فقط نام.
|
||||
*/
|
||||
public function findForTenant(
|
||||
string $entityType,
|
||||
int $entityId,
|
||||
?string $status = null,
|
||||
?string $q = null,
|
||||
): array {
|
||||
$qb = $this->createQueryBuilder('c')
|
||||
->where('c.entityType = :type')
|
||||
->andWhere('c.entityId = :id')
|
||||
@@ -52,6 +61,17 @@ class TreatmentCaseRepository extends ServiceEntityRepository
|
||||
$qb->andWhere('c.status = :status')->setParameter('status', $status);
|
||||
}
|
||||
|
||||
if ($q !== null && $q !== '') {
|
||||
$qb->join('c.patientRecord', 'pr')
|
||||
->join('pr.user', 'u')
|
||||
->join('c.serviceItem', 'si')
|
||||
->andWhere(
|
||||
'u.realName LIKE :q OR u.mobileNumber LIKE :q OR u.nationalCode LIKE :q'
|
||||
. ' OR pr.recordNumber LIKE :q OR si.name LIKE :q',
|
||||
)
|
||||
->setParameter('q', '%' . $q . '%');
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
namespace App\Treatment\Service;
|
||||
|
||||
use App\ClinicService\Entity\CatalogCategory;
|
||||
use App\ClinicService\Repository\CatalogCategoryRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Treatment\Entity\TreatmentCase;
|
||||
use App\Treatment\Entity\TreatmentCaseArea;
|
||||
use App\Treatment\Entity\TreatmentProtocol;
|
||||
use App\Treatment\Entity\TreatmentSession;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* ویرایش پروندهٔ درمانِ باز.
|
||||
*
|
||||
* پرونده بعد از باز شدن سند است نه فرم: بیمار وسط دوره پزشک ناظرش عوض میشود، ناحیه
|
||||
* اضافه میکند، یا جلسه کم و زیاد میشود. ولی هیچ ویرایشی نباید سابقهٔ انجامشده را
|
||||
* بازنویسی کند — همان قاعدهای که `TreatmentCaseArea::$nameSnapshot` را ساخت.
|
||||
*
|
||||
* پس همهٔ قواعد اینجا جمعاند و کنترلر فقط ورودی را عبور میدهد.
|
||||
*/
|
||||
final class TreatmentCaseEditor
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DoctorRepository $doctors,
|
||||
private readonly CatalogCategoryRepository $categories,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function update(TreatmentCase $case, array $data): TreatmentCase
|
||||
{
|
||||
if (array_key_exists('status', $data)) {
|
||||
$this->applyStatus($case, (string) $data['status']);
|
||||
}
|
||||
|
||||
if (array_key_exists('supervisor_doctor_uuid', $data)) {
|
||||
$this->applySupervisor($case, $data['supervisor_doctor_uuid']);
|
||||
}
|
||||
|
||||
if (array_key_exists('area_uuids', $data)) {
|
||||
$this->applyAreas($case, $data['area_uuids']);
|
||||
}
|
||||
|
||||
if (array_key_exists('total_sessions', $data)) {
|
||||
$this->applyTotalSessions($case, (int) $data['total_sessions']);
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $case;
|
||||
}
|
||||
|
||||
private function applyStatus(TreatmentCase $case, string $status): void
|
||||
{
|
||||
$allowed = [TreatmentCase::STATUS_ACTIVE, TreatmentCase::STATUS_COMPLETED, TreatmentCase::STATUS_ABANDONED];
|
||||
|
||||
if (!in_array($status, $allowed, true)) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'وضعیت پرونده نامعتبر است', 422, 'status');
|
||||
}
|
||||
|
||||
if ($status === $case->getStatus()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$status === TreatmentCase::STATUS_ACTIVE ? $case->reopen() : $case->close($status);
|
||||
}
|
||||
|
||||
private function applySupervisor(TreatmentCase $case, mixed $uuid): void
|
||||
{
|
||||
if ($uuid === null || $uuid === '') {
|
||||
$case->setSupervisorDoctor(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$doctor = $this->doctors->findByUuid((string) $uuid);
|
||||
|
||||
if ($doctor === null) {
|
||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404, 'supervisor_doctor_uuid');
|
||||
}
|
||||
|
||||
$case->setSupervisorDoctor($doctor);
|
||||
}
|
||||
|
||||
/**
|
||||
* نواحی جایگزین میشوند، ولی ناحیهای که جلسهای رویش ثبت شده حذف نمیشود.
|
||||
*
|
||||
* حذفش یعنی پاک کردن سابقهٔ درمان — `SessionAreaRecord` به همان ردیف اشاره دارد و
|
||||
* پرونده باید بگوید جلسهٔ قبل روی چه ناحیهای انجام شد.
|
||||
*
|
||||
* @param mixed $uuids فهرست uuid دستههای کاتالوگ
|
||||
*/
|
||||
private function applyAreas(TreatmentCase $case, mixed $uuids): void
|
||||
{
|
||||
if (!is_array($uuids)) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'فهرست نواحی نامعتبر است', 422, 'area_uuids');
|
||||
}
|
||||
|
||||
$wanted = array_values(array_unique(array_map('strval', $uuids)));
|
||||
|
||||
if ($wanted === []) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'حداقل یک ناحیه لازم است', 422, 'area_uuids');
|
||||
}
|
||||
|
||||
/** @var array<string, CatalogCategory> $categories */
|
||||
$categories = [];
|
||||
|
||||
foreach ($wanted as $uuid) {
|
||||
$category = $this->categories->findByUuid($uuid);
|
||||
|
||||
if ($category === null) {
|
||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'ناحیه یافت نشد', 404, 'area_uuids');
|
||||
}
|
||||
|
||||
$categories[$uuid] = $category;
|
||||
}
|
||||
|
||||
$existing = [];
|
||||
|
||||
foreach ($case->getAreas() as $area) {
|
||||
$key = $area->getCategory()?->getUuid();
|
||||
|
||||
if ($key !== null) {
|
||||
$existing[$key] = $area;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($case->getAreas()->toArray() as $area) {
|
||||
$key = $area->getCategory()?->getUuid();
|
||||
|
||||
if ($key !== null && in_array($key, $wanted, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->areaHasRecords($area)) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_CONFLICT_001,
|
||||
sprintf('ناحیهٔ «%s» در جلسهای ثبت شده و حذف نمیشود', $area->getName()),
|
||||
409,
|
||||
'area_uuids',
|
||||
);
|
||||
}
|
||||
|
||||
$case->removeArea($area);
|
||||
$this->em->remove($area);
|
||||
}
|
||||
|
||||
$order = 0;
|
||||
|
||||
foreach ($wanted as $uuid) {
|
||||
if (!isset($existing[$uuid])) {
|
||||
$case->addArea(new TreatmentCaseArea($case, $categories[$uuid], $order));
|
||||
}
|
||||
|
||||
++$order;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* جلسه اضافه میشود یا از انتها کم — ولی هرگز جلسهای که نوبت گرفته یا انجام شده.
|
||||
*
|
||||
* کفِ مجاز تعداد جلساتی است که دیگر دستنخوردنیاند، نه عددی ثابت.
|
||||
*/
|
||||
private function applyTotalSessions(TreatmentCase $case, int $total): void
|
||||
{
|
||||
if ($total < TreatmentProtocol::MIN_STEPS || $total > TreatmentProtocol::MAX_STEPS) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
sprintf('تعداد جلسات باید بین %d و %d باشد', TreatmentProtocol::MIN_STEPS, TreatmentProtocol::MAX_STEPS),
|
||||
422,
|
||||
'total_sessions',
|
||||
);
|
||||
}
|
||||
|
||||
$sessions = $case->getSessions()->toArray();
|
||||
usort($sessions, static fn (TreatmentSession $a, TreatmentSession $b): int
|
||||
=> $a->getSessionNumber() <=> $b->getSessionNumber());
|
||||
|
||||
$locked = 0;
|
||||
|
||||
foreach ($sessions as $session) {
|
||||
if ($this->isRemovable($session)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$locked = max($locked, $session->getSessionNumber());
|
||||
}
|
||||
|
||||
if ($total < $locked) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_CONFLICT_001,
|
||||
sprintf('%d جلسه انجام شده یا نوبت دارد؛ تعداد کمتر از آن ممکن نیست', $locked),
|
||||
409,
|
||||
'total_sessions',
|
||||
);
|
||||
}
|
||||
|
||||
for ($i = count($sessions) - 1; $i >= 0 && count($sessions) > $total; --$i) {
|
||||
$session = $sessions[$i];
|
||||
|
||||
if ($session->getSessionNumber() <= $total || !$this->isRemovable($session)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$case->removeSession($session);
|
||||
$this->em->remove($session);
|
||||
array_splice($sessions, $i, 1);
|
||||
}
|
||||
|
||||
for ($number = count($sessions) + 1; $number <= $total; ++$number) {
|
||||
$case->addSession(new TreatmentSession($case, $number));
|
||||
}
|
||||
|
||||
$case->setTotalSessions($total);
|
||||
}
|
||||
|
||||
private function areaHasRecords(TreatmentCaseArea $area): bool
|
||||
{
|
||||
return (int) $this->em->createQuery(
|
||||
'SELECT COUNT(r.id) FROM App\Treatment\Entity\SessionAreaRecord r WHERE r.caseArea = :area',
|
||||
)->setParameter('area', $area)->getSingleScalarResult() > 0;
|
||||
}
|
||||
|
||||
/** جلسهای که نه نوبت دارد نه شروع شده، هنوز فقط یک برنامه است. */
|
||||
private function isRemovable(TreatmentSession $session): bool
|
||||
{
|
||||
return $session->getAppointment() === null
|
||||
&& $session->getStartedAt() === null
|
||||
&& in_array($session->getStatus(), [TreatmentSession::STATUS_PLANNED, TreatmentSession::STATUS_CANCELLED], true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Treatment;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Entity\CatalogCategory;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Tests\ApiTestCase;
|
||||
use App\Treatment\Entity\TreatmentCase;
|
||||
use App\Treatment\Entity\TreatmentCaseArea;
|
||||
use App\Treatment\Entity\TreatmentProtocol;
|
||||
use App\Treatment\Entity\TreatmentProtocolStep;
|
||||
use App\Treatment\Entity\TreatmentSession;
|
||||
use App\Treatment\Repository\TreatmentCaseRepository;
|
||||
use App\Treatment\Service\TreatmentCaseEditor;
|
||||
|
||||
/**
|
||||
* ویرایش پروندهٔ باز، با یک مرز ثابت: سابقهٔ انجامشده بازنویسی نمیشود.
|
||||
*/
|
||||
class TreatmentCaseEditTest extends ApiTestCase
|
||||
{
|
||||
private function editor(): TreatmentCaseEditor
|
||||
{
|
||||
return static::getContainer()->get(TreatmentCaseEditor::class);
|
||||
}
|
||||
|
||||
private function cases(): TreatmentCaseRepository
|
||||
{
|
||||
return static::getContainer()->get(TreatmentCaseRepository::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{TreatmentCase, Clinic, ServiceItem, array<string, CatalogCategory>}
|
||||
*/
|
||||
private function scenario(string $patientName = 'سارا کاظمی'): array
|
||||
{
|
||||
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر ویرایش');
|
||||
$this->em->persist($doctor);
|
||||
|
||||
$clinic = new Clinic($this->createUser(['ROLE_CLINIC']));
|
||||
$clinic->setName('کلینیک ویرایش ' . uniqid());
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
$section = new ServiceSection('clinic', (int) $clinic->getId(), 'لیزر');
|
||||
$this->em->persist($section);
|
||||
|
||||
$categories = [];
|
||||
foreach (['دست', 'پا', 'صورت'] as $name) {
|
||||
$c = new CatalogCategory('clinic', (int) $clinic->getId(), $name);
|
||||
$this->em->persist($c);
|
||||
$categories[$name] = $c;
|
||||
}
|
||||
|
||||
$service = new ServiceItem($section, 'لیزر بدن', 4_000_000);
|
||||
$this->em->persist($service);
|
||||
$this->em->flush();
|
||||
|
||||
$protocol = new TreatmentProtocol($service);
|
||||
$this->em->persist($protocol);
|
||||
$protocol->replaceSteps([
|
||||
new TreatmentProtocolStep($protocol, 1, 0),
|
||||
new TreatmentProtocolStep($protocol, 2, 15),
|
||||
new TreatmentProtocolStep($protocol, 3, 30),
|
||||
]);
|
||||
$this->em->flush();
|
||||
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$patient->setRealName($patientName);
|
||||
$record = new PatientRecord('clinic', (int) $clinic->getId(), $patient, 'clinic', (int) $clinic->getId());
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
$case = new TreatmentCase('clinic', (int) $clinic->getId(), $record, $service, $protocol);
|
||||
$this->em->persist($case);
|
||||
$case->addArea(new TreatmentCaseArea($case, $categories['دست'], 0));
|
||||
$case->addArea(new TreatmentCaseArea($case, $categories['پا'], 1));
|
||||
foreach ([1, 2, 3] as $n) {
|
||||
$case->addSession(new TreatmentSession($case, $n));
|
||||
}
|
||||
$this->em->flush();
|
||||
|
||||
return [$case, $clinic, $service, $categories];
|
||||
}
|
||||
|
||||
private function sessionNumbers(TreatmentCase $case): array
|
||||
{
|
||||
$numbers = array_map(
|
||||
static fn (TreatmentSession $s): int => $s->getSessionNumber(),
|
||||
$case->getSessions()->toArray(),
|
||||
);
|
||||
sort($numbers);
|
||||
|
||||
return $numbers;
|
||||
}
|
||||
|
||||
public function testStatusCanBeClosedAndReopened(): void
|
||||
{
|
||||
[$case] = $this->scenario();
|
||||
|
||||
$this->editor()->update($case, ['status' => TreatmentCase::STATUS_ABANDONED]);
|
||||
self::assertSame(TreatmentCase::STATUS_ABANDONED, $case->getStatus());
|
||||
self::assertNotNull($case->getClosedAt());
|
||||
|
||||
// بازگرداندن باید closedAt را پاک کند، وگرنه پرونده «بستهٔ فعال» میماند.
|
||||
$this->editor()->update($case, ['status' => TreatmentCase::STATUS_ACTIVE]);
|
||||
self::assertSame(TreatmentCase::STATUS_ACTIVE, $case->getStatus());
|
||||
self::assertNull($case->getClosedAt());
|
||||
}
|
||||
|
||||
public function testSupervisorCanBeChangedAndCleared(): void
|
||||
{
|
||||
[$case, $clinic] = $this->scenario();
|
||||
|
||||
$other = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تازه');
|
||||
$this->em->persist($other);
|
||||
$clinic->getDoctors()->add($other);
|
||||
$this->em->flush();
|
||||
|
||||
$this->editor()->update($case, ['supervisor_doctor_uuid' => $other->getUuid()]);
|
||||
self::assertSame($other->getId(), $case->getSupervisorDoctor()?->getId());
|
||||
|
||||
$this->editor()->update($case, ['supervisor_doctor_uuid' => null]);
|
||||
self::assertNull($case->getSupervisorDoctor());
|
||||
}
|
||||
|
||||
public function testAreasAreReplaced(): void
|
||||
{
|
||||
[$case, , , $categories] = $this->scenario();
|
||||
|
||||
$this->editor()->update($case, ['area_uuids' => [
|
||||
$categories['دست']->getUuid(),
|
||||
$categories['صورت']->getUuid(),
|
||||
]]);
|
||||
|
||||
$names = array_map(static fn ($a) => $a->getName(), $case->getAreas()->toArray());
|
||||
sort($names);
|
||||
self::assertSame(['دست', 'صورت'], $names);
|
||||
}
|
||||
|
||||
/** حذف ناحیهای که جلسهای رویش ثبت شده یعنی پاک کردن سابقهٔ درمان. */
|
||||
public function testAnAreaWithRecordsCannotBeRemoved(): void
|
||||
{
|
||||
[$case, , , $categories] = $this->scenario();
|
||||
|
||||
$area = $case->getAreas()->first();
|
||||
$session = $case->getSessions()->first();
|
||||
$this->em->persist(new \App\Treatment\Entity\SessionAreaRecord($session, $area));
|
||||
$this->em->flush();
|
||||
|
||||
$this->expectException(\App\Shared\Exception\AppException::class);
|
||||
$this->editor()->update($case, ['area_uuids' => [$categories['صورت']->getUuid()]]);
|
||||
}
|
||||
|
||||
public function testSessionsGrowAndShrink(): void
|
||||
{
|
||||
[$case] = $this->scenario();
|
||||
|
||||
$this->editor()->update($case, ['total_sessions' => 5]);
|
||||
self::assertSame([1, 2, 3, 4, 5], $this->sessionNumbers($case));
|
||||
self::assertSame(5, $case->getTotalSessions());
|
||||
|
||||
$this->editor()->update($case, ['total_sessions' => 2]);
|
||||
self::assertSame([1, 2], $this->sessionNumbers($case));
|
||||
self::assertSame(2, $case->getTotalSessions());
|
||||
}
|
||||
|
||||
/** جلسهای که نوبت گرفته کفِ تعداد را بالا میبرد؛ حذفش یعنی گم شدن یک نوبت واقعی. */
|
||||
public function testSessionsCannotDropBelowBookedWork(): void
|
||||
{
|
||||
[$case, $clinic] = $this->scenario();
|
||||
|
||||
$sessions = $case->getSessions()->toArray();
|
||||
usort($sessions, static fn ($a, $b) => $a->getSessionNumber() <=> $b->getSessionNumber());
|
||||
|
||||
$appointment = $this->newAppointment(
|
||||
$clinic->getDoctors()->first(),
|
||||
$this->createUser(['ROLE_USER']),
|
||||
1_795_000_000,
|
||||
1_795_001_800,
|
||||
$clinic,
|
||||
);
|
||||
$this->em->persist($appointment);
|
||||
$this->em->flush();
|
||||
$sessions[2]->attachAppointment($appointment);
|
||||
$this->em->flush();
|
||||
|
||||
$this->expectException(\App\Shared\Exception\AppException::class);
|
||||
$this->editor()->update($case, ['total_sessions' => 2]);
|
||||
}
|
||||
|
||||
public function testTotalSessionsIsBounded(): void
|
||||
{
|
||||
[$case] = $this->scenario();
|
||||
|
||||
$this->expectException(\App\Shared\Exception\AppException::class);
|
||||
$this->editor()->update($case, ['total_sessions' => 1]);
|
||||
}
|
||||
|
||||
/** جستجو باید همان کلیدی را بگیرد که منشی در فرم نوبت میزند. */
|
||||
public function testSearchMatchesPatientAndService(): void
|
||||
{
|
||||
$name = 'نازنین ' . uniqid();
|
||||
[$case, $clinic] = $this->scenario($name);
|
||||
|
||||
$type = 'clinic';
|
||||
$id = (int) $clinic->getId();
|
||||
|
||||
self::assertCount(1, $this->cases()->findForTenant($type, $id, null, $name));
|
||||
self::assertCount(1, $this->cases()->findForTenant($type, $id, null, 'لیزر بدن'));
|
||||
self::assertSame([], $this->cases()->findForTenant($type, $id, null, 'چیزی که نیست'));
|
||||
self::assertCount(1, $this->cases()->findForTenant($type, $id, null, null));
|
||||
|
||||
self::assertSame($case->getId(), $this->cases()->findForTenant($type, $id, null, $name)[0]->getId());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user