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:
hamed
2026-08-07 13:48:57 +03:30
co-authored by Claude Opus 5
parent 00349cdb44
commit 952e09bd6a
11 changed files with 1120 additions and 58 deletions
@@ -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();
});
});
+143 -50
View File
@@ -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'],