From d3f7812d15583e2ccc96576fb80a895985842159 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 7 Aug 2026 15:13:27 +0330 Subject: [PATCH] feat(admin): table/card views for treatment cases, matching the patients list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/TreatmentCaseEditModal.tsx | 10 +- .../admin/pages/TreatmentCasesPage.test.tsx | 33 +++++ assets/admin/pages/TreatmentCasesPage.tsx | 128 ++++++++++++++++-- 3 files changed, 158 insertions(+), 13 deletions(-) diff --git a/assets/admin/components/TreatmentCaseEditModal.tsx b/assets/admin/components/TreatmentCaseEditModal.tsx index a499eac2..86d065a2 100644 --- a/assets/admin/components/TreatmentCaseEditModal.tsx +++ b/assets/admin/components/TreatmentCaseEditModal.tsx @@ -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 }:
- {staff.length === 0 ? ( + {/* «در حال خواندن» با «تعریف نشده» یکی نیست؛ بدون این تفکیک، فهرستِ هنوز + نیامده به‌صورت «پرسنلی ندارید» خوانده می‌شد. */} + {staffLoading ? ( + در حال خواندن فهرست پرسنل… + ) : staff.length === 0 ? ( پرسنلی در این محیط تعریف نشده است. ) : (
diff --git a/assets/admin/pages/TreatmentCasesPage.test.tsx b/assets/admin/pages/TreatmentCasesPage.test.tsx index 4479b209..c5765f71 100644 --- a/assets/admin/pages/TreatmentCasesPage.test.tsx +++ b/assets/admin/pages/TreatmentCasesPage.test.tsx @@ -47,6 +47,8 @@ describe('صفحهٔ پرونده‌های درمان', () => { mockList([caseRow()]); renderWithProviders(); + 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(); + 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(); + 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(); + await screen.findByText('محمد رسولی'); + fireEvent.click(screen.getByLabelText('نمایش کارتی')); expect(await screen.findByText(/اپراتور: پرسنل۲/)).toBeInTheDocument(); }); + /** نمای پیش‌فرض جدول است — همان الگوی صفحهٔ پرونده‌ها، با سوییچ به کارتی. */ + it('پیش‌فرض جدولی است و به کارتی سوییچ می‌شود', async () => { + mockList([caseRow()]); + + renderWithProviders(); + 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(); + + const cell = await screen.findByRole('cell', { name: 'پرسنل۱' }); + expect(cell).toBeInTheDocument(); + }); + it('دکمهٔ ویرایش مودال را باز می‌کند', async () => { mockList([caseRow()]); diff --git a/assets/admin/pages/TreatmentCasesPage.tsx b/assets/admin/pages/TreatmentCasesPage.tsx index 23d2729c..a89f57a1 100644 --- a/assets/admin/pages/TreatmentCasesPage.tsx +++ b/assets/admin/pages/TreatmentCasesPage.tsx @@ -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 = { * را جواب می‌دهند — «کدام بیمار در چه مرحله‌ای است و چه کاری مانده». */ 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 })} /> : } @@ -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 } )}
+
+ {([['table', 'نمایش جدولی', PatientsGridView], ['card', 'نمایش کارتی', PatientsCategoryView]] as const).map( + ([v, label, Icon]) => ( + + ), + )} +
+
{STATUS_FILTERS.map(([v, label]) => (