feat(admin): table/card views for treatment cases, matching the patients list

The list was cards only, so comparing courses across patients meant reading four
stacked blocks instead of scanning columns. It now has the same table/card
toggle the patients page uses, reusing that page's toggle icons and keeping the
choice in the URL so back and refresh hold it. Table is the default here: the
operator, status and session progress are the columns a manager scans.

Both views derive the operator the same way, through one helper — history
(performed_by) when a session has been done, otherwise the plan
(assigned_staff), labelled as not yet performed.

Also fixes a three-states slip in the operator picker I added last time: it
rendered "no staff defined" while the staff list was still loading, which is
what an empty list looks like from the user's side. Loading now says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-07 15:13:27 +03:30
co-authored by Claude Opus 5
parent 9cabb28355
commit d3f7812d15
3 changed files with 158 additions and 13 deletions
@@ -67,6 +67,7 @@ export default function TreatmentCaseEditModal({ caseUuid, onClose }: {
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] });
@@ -79,11 +80,12 @@ export default function TreatmentCaseEditModal({ caseUuid, onClose }: {
);
}
function EditForm({ detail, doctors, doctorsLoading, staff, onSaved, onClose }: {
function EditForm({ detail, doctors, doctorsLoading, staff, staffLoading, onSaved, onClose }: {
detail: TreatmentCaseDetail;
doctors: DoctorRow[];
doctorsLoading: boolean;
staff: StaffRow[];
staffLoading: boolean;
onSaved: () => void;
onClose: () => void;
}) {
@@ -208,7 +210,11 @@ function EditForm({ detail, doctors, doctorsLoading, staff, onSaved, onClose }:
<div className="field-block">
<label>اپراتور</label>
{staff.length === 0 ? (
{/* «در حال خواندن» با «تعریف نشده» یکی نیست؛ بدون این تفکیک، فهرستِ هنوز
نیامده به‌صورت «پرسنلی ندارید» خوانده می‌شد. */}
{staffLoading ? (
<span className="field-hint">در حال خواندن فهرست پرسنل</span>
) : staff.length === 0 ? (
<span className="field-hint">پرسنلی در این محیط تعریف نشده است.</span>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
@@ -47,6 +47,8 @@ describe('صفحهٔ پرونده‌های درمان', () => {
mockList([caseRow()]);
renderWithProviders(<TreatmentCasesPage />);
await screen.findByText('محمد رسولی');
fireEvent.click(screen.getByLabelText('نمایش کارتی'));
const name = await screen.findByText('محمد رسولی');
expect(name.tagName).toBe('STRONG');
@@ -120,6 +122,8 @@ describe('صفحهٔ پرونده‌های درمان', () => {
mockList([caseRow({ opened_at: 1_786_000_000 })]);
renderWithProviders(<TreatmentCasesPage />);
await screen.findByText('محمد رسولی');
fireEvent.click(screen.getByLabelText('نمایش کارتی'));
// formatDateTime ساعت را با «:» می‌آورد؛ formatDate نمی‌آورد.
const start = await screen.findByText(/^شروع:/);
@@ -131,6 +135,8 @@ describe('صفحهٔ پرونده‌های درمان', () => {
mockList([caseRow({ performed_by: [{ uuid: 'st-1', name: 'پرسنل۱' }] })]);
renderWithProviders(<TreatmentCasesPage />);
await screen.findByText('محمد رسولی');
fireEvent.click(screen.getByLabelText('نمایش کارتی'));
expect(await screen.findByText(/انجام‌دهنده: پرسنل۱/)).toBeInTheDocument();
});
@@ -140,10 +146,37 @@ describe('صفحهٔ پرونده‌های درمان', () => {
mockList([caseRow({ assigned_staff: [{ uuid: 'st-2', name: 'پرسنل۲' }] })]);
renderWithProviders(<TreatmentCasesPage />);
await screen.findByText('محمد رسولی');
fireEvent.click(screen.getByLabelText('نمایش کارتی'));
expect(await screen.findByText(/اپراتور: پرسنل۲/)).toBeInTheDocument();
});
/** نمای پیش‌فرض جدول است — همان الگوی صفحهٔ پرونده‌ها، با سوییچ به کارتی. */
it('پیش‌فرض جدولی است و به کارتی سوییچ می‌شود', async () => {
mockList([caseRow()]);
renderWithProviders(<TreatmentCasesPage />);
await screen.findByText('محمد رسولی');
expect(screen.getByRole('columnheader', { name: 'پرسنل' })).toBeInTheDocument();
fireEvent.click(screen.getByLabelText('نمایش کارتی'));
expect(screen.queryByRole('columnheader', { name: 'پرسنل' })).not.toBeInTheDocument();
fireEvent.click(screen.getByLabelText('نمایش جدولی'));
expect(screen.getByRole('columnheader', { name: 'پرسنل' })).toBeInTheDocument();
});
it('ستون پرسنل در جدول پر می‌شود', async () => {
mockList([caseRow({ performed_by: [{ uuid: 'st-1', name: 'پرسنل۱' }] })]);
renderWithProviders(<TreatmentCasesPage />);
const cell = await screen.findByRole('cell', { name: 'پرسنل۱' });
expect(cell).toBeInTheDocument();
});
it('دکمهٔ ویرایش مودال را باز می‌کند', async () => {
mockList([caseRow()]);
+117 -11
View File
@@ -10,6 +10,7 @@ import { formatDate, formatDateTime, formatNumber } from '../lib/utils';
import { useUrlState } from '../hooks/useUrlState';
import PersianDateInput from '../components/ui/PersianDateInput';
import TreatmentCaseEditModal from '../components/TreatmentCaseEditModal';
import { PatientsGridView, PatientsCategoryView } from '../components/icons/FilesToolbarIcons';
import type { TreatmentCaseSummary, StaffTreatmentSession, SlotSuggestionResponse } from '../types';
const TABS = [
@@ -32,7 +33,7 @@ const CASE_STATUS_LABEL: Record<TreatmentCaseSummary['status'], string> = {
* را جواب می‌دهند — «کدام بیمار در چه مرحله‌ای است و چه کاری مانده».
*/
export default function TreatmentCasesPage() {
const [urlState, setUrlState] = useUrlState({ tab: 'cases', status: '', q: '', from: '', to: '' });
const [urlState, setUrlState] = useUrlState({ tab: 'cases', status: '', q: '', from: '', to: '', view: 'table' });
const tab = (TABS.some((t) => t.id === urlState.tab) ? urlState.tab : 'cases') as TabId;
return (
@@ -62,6 +63,8 @@ export default function TreatmentCasesPage() {
onFrom={(from) => setUrlState({ from })}
to={urlState.to}
onTo={(to) => setUrlState({ to })}
view={urlState.view === 'card' ? 'card' : 'table'}
onView={(v) => setUrlState({ view: v })}
/>
: <UnbookedTab />}
</>
@@ -75,7 +78,7 @@ const STATUS_FILTERS = [
['abandoned', 'رها شده'],
] as const;
function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo }: {
function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo, view, onView }: {
status: string;
onStatus: (s: string) => void;
search: string;
@@ -85,6 +88,9 @@ function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo }
onFrom: (s: string) => void;
to: string;
onTo: (s: string) => void;
/** جدولی یا کارتی — همان الگوی صفحهٔ پرونده‌ها، در URL تا «بازگشت» نما را نپراند. */
view: 'table' | 'card';
onView: (v: 'table' | 'card') => void;
}) {
// فیلد جستجو محلی می‌ماند و فقط مقدار نهایی به URL می‌رود؛ وگرنه هر حرف یک ورودی
// تاریخچه می‌سازد و «بازگشت» بی‌معنی می‌شود.
@@ -134,6 +140,29 @@ function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo }
)}
</div>
<div style={{
display: 'flex', flexShrink: 0, overflow: 'hidden',
border: '1px solid var(--border-2)', borderRadius: 4,
}}>
{([['table', 'نمایش جدولی', PatientsGridView], ['card', 'نمایش کارتی', PatientsCategoryView]] as const).map(
([v, label, Icon]) => (
<button
key={v}
type="button"
aria-label={label}
aria-pressed={view === v}
onClick={() => onView(v)}
style={{
padding: 8, border: 'none', cursor: 'pointer', display: 'grid', placeItems: 'center',
background: view === v ? 'var(--primary-soft)' : 'transparent',
}}
>
<Icon color={view === v ? 'var(--primary)' : 'var(--text-2)'} />
</button>
),
)}
</div>
<div className="seg">
{STATUS_FILTERS.map(([v, label]) => (
<button
@@ -201,6 +230,8 @@ function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo }
? 'در این بازهٔ تاریخ پرونده‌ای باز نشده است.'
: 'پرونده‌ای یافت نشد. پرونده وقتی ساخته می‌شود که نوبتِ سرویسی با «طول درمان» قطعی شود.'}
</div>
) : view === 'table' ? (
<CasesTable cases={cases} onEdit={setEditing} />
) : (
<div style={{ display: 'grid', gap: 12 }}>
{cases.map((c) => <CaseCard key={c.uuid} item={c} onEdit={() => setEditing(c.uuid)} />)}
@@ -214,10 +245,89 @@ function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo }
);
}
/**
* انجام‌دهنده از جلسات می‌آید (سابقه) و اختصاص‌یافته برنامه است؛ تا وقتی جلسه‌ای
* انجام نشده، همان برنامه را نشان می‌دهیم. هر دو نما یک قاعده دارند.
*/
function operatorOf(c: TreatmentCaseSummary): { text: string; planned: boolean } | null {
if (c.performed_by.length > 0) {
return { text: c.performed_by.map((s) => s.name).join('، '), planned: false };
}
if (c.assigned_staff.length > 0) {
return { text: c.assigned_staff.map((s) => s.name).join('، '), planned: true };
}
return null;
}
const TABLE_HEADS = ['ردیف', 'بیمار', 'سرویس', 'پرسنل', 'وضعیت', 'جلسات', 'شروع', 'عملیات'];
function CasesTable({ cases, onEdit }: { cases: TreatmentCaseSummary[]; onEdit: (uuid: string) => void }) {
return (
<div style={{
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 'var(--r-lg)', overflow: 'auto',
}}>
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 900 }}>
<thead>
<tr style={{ background: 'var(--surface-2)', color: 'var(--text-2)', fontSize: 13 }}>
{TABLE_HEADS.map((h) => (
<th key={h} style={{ padding: '12px 14px', textAlign: 'center', fontWeight: 600, whiteSpace: 'nowrap' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{cases.map((c, i) => {
const operator = operatorOf(c);
return (
<tr key={c.uuid} style={{ borderTop: '1px solid var(--border)', fontSize: 13.5, textAlign: 'center' }}>
<td style={{ padding: '12px 14px', color: 'var(--text-3)' }}>{formatNumber(i + 1)}</td>
<td style={{ padding: '12px 14px' }}>
<div style={{ fontWeight: 600 }}>{c.patient.name || 'بیمار بدون نام'}</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', direction: 'ltr' }}>{c.patient.mobile}</div>
</td>
<td style={{ padding: '12px 14px' }}>{c.service.name}</td>
<td style={{ padding: '12px 14px', color: operator?.planned ? 'var(--text-3)' : 'var(--text)' }}>
{operator === null ? '—' : operator.planned ? `${operator.text} (هنوز انجام نشده)` : operator.text}
</td>
<td style={{ padding: '12px 14px' }}>
<span className={`badge ${c.status === 'active' ? 'blue' : c.status === 'completed' ? 'green' : 'gray'}`}>
<span className="bdot" />{CASE_STATUS_LABEL[c.status]}
</span>
</td>
<td style={{ padding: '12px 14px', whiteSpace: 'nowrap' }}>
{formatNumber(c.completed_sessions)} از {formatNumber(c.total_sessions)}
</td>
<td style={{ padding: '12px 14px', whiteSpace: 'nowrap' }}>{formatDateTime(c.opened_at)}</td>
<td style={{ padding: '12px 14px' }}>
<button
type="button"
className="mini-btn"
aria-label={`ویرایش پروندهٔ ${c.patient.name ?? ''}`}
onClick={() => onEdit(c.uuid)}
style={{ color: 'var(--accent)' }}
>
<PencilSquareIcon style={{ width: 18, height: 18 }} />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
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;
const operator = operatorOf(c);
return (
<div className="card card-pad" style={{ display: 'grid', gap: 10 }}>
@@ -242,15 +352,11 @@ function CaseCard({ item: c, onEdit }: { item: TreatmentCaseSummary; onEdit: ()
{/* ساعت هم لازم است: چند پروندهٔ یک روز فقط با ساعت از هم جدا می‌شوند. */}
<span>شروع: {formatDateTime(c.opened_at)}</span>
{c.supervisor && <span>پزشک ناظر: {c.supervisor.name}</span>}
{/* انجام‌دهنده از جلسات می‌آید (سابقه) و اختصاص‌یافته برنامه است؛ تا وقتی
جلسه‌ای انجام نشده، همان برنامه را نشان می‌دهیم. */}
{c.performed_by.length > 0 ? (
<span>انجامدهنده: {c.performed_by.map((s) => s.name).join('، ')}</span>
) : c.assigned_staff.length > 0 ? (
<span style={{ color: 'var(--text-3)' }}>
اپراتور: {c.assigned_staff.map((s) => s.name).join('، ')} (هنوز انجام نشده)
</span>
) : null}
{operator !== null && (
operator.planned
? <span style={{ color: 'var(--text-3)' }}>اپراتور: {operator.text} (هنوز انجام نشده)</span>
: <span>انجامدهنده: {operator.text}</span>
)}
{c.areas.length > 0 && <span>نواحی: {c.areas.map((a) => a.name).join('، ')}</span>}
</div>