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:
@@ -67,6 +67,7 @@ export default function TreatmentCaseEditModal({ caseUuid, onClose }: {
|
|||||||
doctors={doctorsQ.data?.data?.data ?? []}
|
doctors={doctorsQ.data?.data?.data ?? []}
|
||||||
doctorsLoading={doctorsQ.isLoading}
|
doctorsLoading={doctorsQ.isLoading}
|
||||||
staff={staffQ.data?.data ?? []}
|
staff={staffQ.data?.data ?? []}
|
||||||
|
staffLoading={staffQ.isLoading}
|
||||||
onSaved={() => {
|
onSaved={() => {
|
||||||
qc.invalidateQueries({ queryKey: ['treatment-cases'] });
|
qc.invalidateQueries({ queryKey: ['treatment-cases'] });
|
||||||
qc.invalidateQueries({ queryKey: ['treatment-case', caseUuid] });
|
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;
|
detail: TreatmentCaseDetail;
|
||||||
doctors: DoctorRow[];
|
doctors: DoctorRow[];
|
||||||
doctorsLoading: boolean;
|
doctorsLoading: boolean;
|
||||||
staff: StaffRow[];
|
staff: StaffRow[];
|
||||||
|
staffLoading: boolean;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -208,7 +210,11 @@ function EditForm({ detail, doctors, doctorsLoading, staff, onSaved, onClose }:
|
|||||||
|
|
||||||
<div className="field-block">
|
<div className="field-block">
|
||||||
<label>اپراتور</label>
|
<label>اپراتور</label>
|
||||||
{staff.length === 0 ? (
|
{/* «در حال خواندن» با «تعریف نشده» یکی نیست؛ بدون این تفکیک، فهرستِ هنوز
|
||||||
|
نیامده بهصورت «پرسنلی ندارید» خوانده میشد. */}
|
||||||
|
{staffLoading ? (
|
||||||
|
<span className="field-hint">در حال خواندن فهرست پرسنل…</span>
|
||||||
|
) : staff.length === 0 ? (
|
||||||
<span className="field-hint">پرسنلی در این محیط تعریف نشده است.</span>
|
<span className="field-hint">پرسنلی در این محیط تعریف نشده است.</span>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ describe('صفحهٔ پروندههای درمان', () => {
|
|||||||
mockList([caseRow()]);
|
mockList([caseRow()]);
|
||||||
|
|
||||||
renderWithProviders(<TreatmentCasesPage />);
|
renderWithProviders(<TreatmentCasesPage />);
|
||||||
|
await screen.findByText('محمد رسولی');
|
||||||
|
fireEvent.click(screen.getByLabelText('نمایش کارتی'));
|
||||||
|
|
||||||
const name = await screen.findByText('محمد رسولی');
|
const name = await screen.findByText('محمد رسولی');
|
||||||
expect(name.tagName).toBe('STRONG');
|
expect(name.tagName).toBe('STRONG');
|
||||||
@@ -120,6 +122,8 @@ describe('صفحهٔ پروندههای درمان', () => {
|
|||||||
mockList([caseRow({ opened_at: 1_786_000_000 })]);
|
mockList([caseRow({ opened_at: 1_786_000_000 })]);
|
||||||
|
|
||||||
renderWithProviders(<TreatmentCasesPage />);
|
renderWithProviders(<TreatmentCasesPage />);
|
||||||
|
await screen.findByText('محمد رسولی');
|
||||||
|
fireEvent.click(screen.getByLabelText('نمایش کارتی'));
|
||||||
|
|
||||||
// formatDateTime ساعت را با «:» میآورد؛ formatDate نمیآورد.
|
// formatDateTime ساعت را با «:» میآورد؛ formatDate نمیآورد.
|
||||||
const start = await screen.findByText(/^شروع:/);
|
const start = await screen.findByText(/^شروع:/);
|
||||||
@@ -131,6 +135,8 @@ describe('صفحهٔ پروندههای درمان', () => {
|
|||||||
mockList([caseRow({ performed_by: [{ uuid: 'st-1', name: 'پرسنل۱' }] })]);
|
mockList([caseRow({ performed_by: [{ uuid: 'st-1', name: 'پرسنل۱' }] })]);
|
||||||
|
|
||||||
renderWithProviders(<TreatmentCasesPage />);
|
renderWithProviders(<TreatmentCasesPage />);
|
||||||
|
await screen.findByText('محمد رسولی');
|
||||||
|
fireEvent.click(screen.getByLabelText('نمایش کارتی'));
|
||||||
|
|
||||||
expect(await screen.findByText(/انجامدهنده: پرسنل۱/)).toBeInTheDocument();
|
expect(await screen.findByText(/انجامدهنده: پرسنل۱/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -140,10 +146,37 @@ describe('صفحهٔ پروندههای درمان', () => {
|
|||||||
mockList([caseRow({ assigned_staff: [{ uuid: 'st-2', name: 'پرسنل۲' }] })]);
|
mockList([caseRow({ assigned_staff: [{ uuid: 'st-2', name: 'پرسنل۲' }] })]);
|
||||||
|
|
||||||
renderWithProviders(<TreatmentCasesPage />);
|
renderWithProviders(<TreatmentCasesPage />);
|
||||||
|
await screen.findByText('محمد رسولی');
|
||||||
|
fireEvent.click(screen.getByLabelText('نمایش کارتی'));
|
||||||
|
|
||||||
expect(await screen.findByText(/اپراتور: پرسنل۲/)).toBeInTheDocument();
|
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 () => {
|
it('دکمهٔ ویرایش مودال را باز میکند', async () => {
|
||||||
mockList([caseRow()]);
|
mockList([caseRow()]);
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { formatDate, formatDateTime, formatNumber } from '../lib/utils';
|
|||||||
import { useUrlState } from '../hooks/useUrlState';
|
import { useUrlState } from '../hooks/useUrlState';
|
||||||
import PersianDateInput from '../components/ui/PersianDateInput';
|
import PersianDateInput from '../components/ui/PersianDateInput';
|
||||||
import TreatmentCaseEditModal from '../components/TreatmentCaseEditModal';
|
import TreatmentCaseEditModal from '../components/TreatmentCaseEditModal';
|
||||||
|
import { PatientsGridView, PatientsCategoryView } from '../components/icons/FilesToolbarIcons';
|
||||||
import type { TreatmentCaseSummary, StaffTreatmentSession, SlotSuggestionResponse } from '../types';
|
import type { TreatmentCaseSummary, StaffTreatmentSession, SlotSuggestionResponse } from '../types';
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
@@ -32,7 +33,7 @@ const CASE_STATUS_LABEL: Record<TreatmentCaseSummary['status'], string> = {
|
|||||||
* را جواب میدهند — «کدام بیمار در چه مرحلهای است و چه کاری مانده».
|
* را جواب میدهند — «کدام بیمار در چه مرحلهای است و چه کاری مانده».
|
||||||
*/
|
*/
|
||||||
export default function TreatmentCasesPage() {
|
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;
|
const tab = (TABS.some((t) => t.id === urlState.tab) ? urlState.tab : 'cases') as TabId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -62,6 +63,8 @@ export default function TreatmentCasesPage() {
|
|||||||
onFrom={(from) => setUrlState({ from })}
|
onFrom={(from) => setUrlState({ from })}
|
||||||
to={urlState.to}
|
to={urlState.to}
|
||||||
onTo={(to) => setUrlState({ to })}
|
onTo={(to) => setUrlState({ to })}
|
||||||
|
view={urlState.view === 'card' ? 'card' : 'table'}
|
||||||
|
onView={(v) => setUrlState({ view: v })}
|
||||||
/>
|
/>
|
||||||
: <UnbookedTab />}
|
: <UnbookedTab />}
|
||||||
</>
|
</>
|
||||||
@@ -75,7 +78,7 @@ const STATUS_FILTERS = [
|
|||||||
['abandoned', 'رها شده'],
|
['abandoned', 'رها شده'],
|
||||||
] as const;
|
] 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;
|
status: string;
|
||||||
onStatus: (s: string) => void;
|
onStatus: (s: string) => void;
|
||||||
search: string;
|
search: string;
|
||||||
@@ -85,6 +88,9 @@ function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo }
|
|||||||
onFrom: (s: string) => void;
|
onFrom: (s: string) => void;
|
||||||
to: string;
|
to: string;
|
||||||
onTo: (s: string) => void;
|
onTo: (s: string) => void;
|
||||||
|
/** جدولی یا کارتی — همان الگوی صفحهٔ پروندهها، در URL تا «بازگشت» نما را نپراند. */
|
||||||
|
view: 'table' | 'card';
|
||||||
|
onView: (v: 'table' | 'card') => void;
|
||||||
}) {
|
}) {
|
||||||
// فیلد جستجو محلی میماند و فقط مقدار نهایی به URL میرود؛ وگرنه هر حرف یک ورودی
|
// فیلد جستجو محلی میماند و فقط مقدار نهایی به URL میرود؛ وگرنه هر حرف یک ورودی
|
||||||
// تاریخچه میسازد و «بازگشت» بیمعنی میشود.
|
// تاریخچه میسازد و «بازگشت» بیمعنی میشود.
|
||||||
@@ -134,6 +140,29 @@ function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo }
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="seg">
|
||||||
{STATUS_FILTERS.map(([v, label]) => (
|
{STATUS_FILTERS.map(([v, label]) => (
|
||||||
<button
|
<button
|
||||||
@@ -201,6 +230,8 @@ function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo }
|
|||||||
? 'در این بازهٔ تاریخ پروندهای باز نشده است.'
|
? 'در این بازهٔ تاریخ پروندهای باز نشده است.'
|
||||||
: 'پروندهای یافت نشد. پرونده وقتی ساخته میشود که نوبتِ سرویسی با «طول درمان» قطعی شود.'}
|
: 'پروندهای یافت نشد. پرونده وقتی ساخته میشود که نوبتِ سرویسی با «طول درمان» قطعی شود.'}
|
||||||
</div>
|
</div>
|
||||||
|
) : view === 'table' ? (
|
||||||
|
<CasesTable cases={cases} onEdit={setEditing} />
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'grid', gap: 12 }}>
|
<div style={{ display: 'grid', gap: 12 }}>
|
||||||
{cases.map((c) => <CaseCard key={c.uuid} item={c} onEdit={() => setEditing(c.uuid)} />)}
|
{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 }) {
|
function CaseCard({ item: c, onEdit }: { item: TreatmentCaseSummary; onEdit: () => void }) {
|
||||||
const percent = c.total_sessions > 0
|
const percent = c.total_sessions > 0
|
||||||
? Math.round((c.completed_sessions / c.total_sessions) * 100)
|
? Math.round((c.completed_sessions / c.total_sessions) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
|
const operator = operatorOf(c);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card card-pad" style={{ display: 'grid', gap: 10 }}>
|
<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>
|
<span>شروع: {formatDateTime(c.opened_at)}</span>
|
||||||
{c.supervisor && <span>پزشک ناظر: {c.supervisor.name}</span>}
|
{c.supervisor && <span>پزشک ناظر: {c.supervisor.name}</span>}
|
||||||
{/* انجامدهنده از جلسات میآید (سابقه) و اختصاصیافته برنامه است؛ تا وقتی
|
{operator !== null && (
|
||||||
جلسهای انجام نشده، همان برنامه را نشان میدهیم. */}
|
operator.planned
|
||||||
{c.performed_by.length > 0 ? (
|
? <span style={{ color: 'var(--text-3)' }}>اپراتور: {operator.text} (هنوز انجام نشده)</span>
|
||||||
<span>انجامدهنده: {c.performed_by.map((s) => s.name).join('، ')}</span>
|
: <span>انجامدهنده: {operator.text}</span>
|
||||||
) : c.assigned_staff.length > 0 ? (
|
)}
|
||||||
<span style={{ color: 'var(--text-3)' }}>
|
|
||||||
اپراتور: {c.assigned_staff.map((s) => s.name).join('، ')} (هنوز انجام نشده)
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{c.areas.length > 0 && <span>نواحی: {c.areas.map((a) => a.name).join('، ')}</span>}
|
{c.areas.length > 0 && <span>نواحی: {c.areas.map((a) => a.name).join('، ')}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user