From cc2e630c408fa2a14c72ac67721588bcbfcb5e17 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 7 Aug 2026 13:00:29 +0330 Subject: [PATCH] refactor(admin): stop asking the operator to re-pick the session's device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The appointment is booked on a device, and SessionExecutor already stamps that device onto every area record when the session starts. The area form still showed a device select — pre-filled, but a select — so the operator was asked to make a decision that had already been made at booking time. The inherited device is now read-only text. A "تغییر" button reveals the select, for the one case that still needs it: the appointment was booked on the wrong device. An appointment with no resource at all still gets the select outright, with a hint saying why. Also in this file: the parameter fields wrapped a label and a SearchableSelect inside `.field`, which is itself the bordered input box — two nested boxes. They are `.field-block` now, each control labelled by id. The note textarea gets `cp-textarea`; it had no styling at all. Session and area counts go through formatNumber, so the page no longer mixes Latin and Persian digits. Adds the test file the page never had. Co-Authored-By: Claude Opus 5 --- .../pages/StaffSessionDetailPage.test.tsx | 107 +++++++++++++++++ assets/admin/pages/StaffSessionDetailPage.tsx | 110 ++++++++++++------ 2 files changed, 179 insertions(+), 38 deletions(-) create mode 100644 assets/admin/pages/StaffSessionDetailPage.test.tsx diff --git a/assets/admin/pages/StaffSessionDetailPage.test.tsx b/assets/admin/pages/StaffSessionDetailPage.test.tsx new file mode 100644 index 00000000..e560af8d --- /dev/null +++ b/assets/admin/pages/StaffSessionDetailPage.test.tsx @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent } 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 {}, +})); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useParams: () => ({ uuid: 'ses-1' }) }; +}); + +import { api } from '../lib/api'; +import StaffSessionDetailPage from './StaffSessionDetailPage'; + +const get = api.get as ReturnType; + +const DEVICE = { uuid: 'res-1', name: 'کندلا ۲۰۲۳', type: 'laser' }; +const OTHER = { uuid: 'res-2', name: 'دستگاه دیگر', type: 'laser' }; + +function detail(areaResource: typeof DEVICE | null) { + return { + uuid: 'ses-1', + session_number: 1, + total_sessions: 3, + status: 'in_progress', + due_at: null, + started_at: 1_786_000_000, + finished_at: null, + note: null, + performed_by: null, + appointment: null, + case: { + uuid: 'case-1', + status: 'active', + total_sessions: 3, + completed_sessions: 0, + opened_at: 1_786_000_000, + closed_at: null, + service: { uuid: 'svc-1', name: 'لیزر توتال' }, + supervisor: null, + areas: [], + }, + devices: [ + { uuid: DEVICE.uuid, name: DEVICE.name }, + { uuid: OTHER.uuid, name: OTHER.name }, + ], + forms: { 'res-1': [] }, + areas: [{ + uuid: 'area-1', + area: { uuid: 'ca-1', name: 'صورت' }, + status: 'pending', + parameters: null, + started_at: null, + finished_at: null, + note: null, + resource: areaResource, + }], + }; +} + +beforeEach(() => { + get.mockReset(); +}); + +describe('صفحهٔ انجام جلسه — دستگاه ناحیه', () => { + /** + * دستگاه هنگام ثبت نوبت انتخاب شده و روی رکورد ناحیه نشسته؛ اپراتور نباید همان + * تصمیم را دوباره بگیرد. + */ + it('دستگاهِ به‌ارث‌رسیده را فقط نشان می‌دهد، انتخاب نمی‌خواهد', async () => { + get.mockResolvedValue({ success: true, data: detail(DEVICE) }); + + renderWithProviders(); + + fireEvent.click(await screen.findByRole('button', { name: 'ثبت اطلاعات این ناحیه' })); + + expect(screen.getByText(/از نوبت این جلسه آمده/)).toBeInTheDocument(); + expect(screen.queryByLabelText('دستگاه ناحیهٔ صورت')).not.toBeInTheDocument(); + }); + + /** موردِ نادرِ «نوبت روی دستگاه اشتباه» نباید بن‌بست شود. */ + it('با «تغییر» انتخابگر ظاهر می‌شود', async () => { + get.mockResolvedValue({ success: true, data: detail(DEVICE) }); + + renderWithProviders(); + + fireEvent.click(await screen.findByRole('button', { name: 'ثبت اطلاعات این ناحیه' })); + fireEvent.click(screen.getByRole('button', { name: 'تغییر' })); + + expect(screen.getByLabelText('دستگاه ناحیهٔ صورت')).toBeInTheDocument(); + }); + + /** نوبتِ بدون منبع هنوز باید راهی برای انتخاب داشته باشد. */ + it('اگر نوبت روی دستگاهی نبوده، انتخابگر را می‌آورد', async () => { + get.mockResolvedValue({ success: true, data: detail(null) }); + + renderWithProviders(); + + fireEvent.click(await screen.findByRole('button', { name: 'ثبت اطلاعات این ناحیه' })); + + expect(screen.getByLabelText('دستگاه ناحیهٔ صورت')).toBeInTheDocument(); + expect(screen.getByText(/روی دستگاهی ثبت نشده/)).toBeInTheDocument(); + }); +}); diff --git a/assets/admin/pages/StaffSessionDetailPage.tsx b/assets/admin/pages/StaffSessionDetailPage.tsx index dba65b7f..c8390a41 100644 --- a/assets/admin/pages/StaffSessionDetailPage.tsx +++ b/assets/admin/pages/StaffSessionDetailPage.tsx @@ -6,7 +6,7 @@ import { api, ApiError, type ApiResponse } from '../lib/api'; import PageHeader from '../components/ui/PageHeader'; import StatusBadge from '../components/ui/StatusBadge'; import SearchableSelect from '../components/ui/SearchableSelect'; -import { formatDate } from '../lib/utils'; +import { formatDate, formatNumber } from '../lib/utils'; import { useElapsed } from '../hooks/useElapsed'; import type { SessionAreaRecord, StaffSessionDetail, TreatmentDevice, TreatmentFormField } from '../types'; @@ -52,7 +52,7 @@ export default function StaffSessionDetailPage() { ), onSuccess: (res) => { const left = res?.data?.unsettled_areas ?? 0; - toast.success(left > 0 ? `جلسه بسته شد — ${left} ناحیه تکمیل نشده بود` : 'جلسه با موفقیت تمام شد'); + toast.success(left > 0 ? `جلسه بسته شد — ${formatNumber(left)} ناحیه تکمیل نشده بود` : 'جلسه با موفقیت تمام شد'); refresh(); }, onError: (e) => fail(e, 'اتمام جلسه ناموفق بود'), @@ -90,7 +90,7 @@ export default function StaffSessionDetailPage() { return ( <> @@ -103,7 +103,7 @@ export default function StaffSessionDetailPage() {
{session.appointment && تاریخ: {formatDate(session.appointment.slot_start)}} {session.performed_by && اپراتور: {session.performed_by.name}} - {settled} از {areas.length} ناحیه انجام شده + {formatNumber(settled)} از {formatNumber(areas.length)} ناحیه انجام شده {elapsed && ( {session.finished_at === null ? 'در حال انجام: ' : 'مدت جلسه: '}{elapsed} @@ -159,7 +159,7 @@ export default function StaffSessionDetailPage() { /> {settled < areas.length && ( - {areas.length - settled} ناحیه هنوز تکمیل نشده است — جلسه با همین وضعیت بسته می‌شود. + {formatNumber(areas.length - settled)} ناحیه هنوز تکمیل نشده است — جلسه با همین وضعیت بسته می‌شود. )} +
+ از نوبت این جلسه آمده. فرم زیر از روی همین دستگاه ساخته می‌شود. + + ) : ( +
+ + ({ value: d.uuid, label: d.name }))} + value={resourceUuid} + onChange={(v) => setResourceUuid(v === null ? null : String(v))} + placeholder="بدون دستگاه" + isClearable + ariaLabel={`دستگاه ناحیهٔ ${area.area.name}`} + /> + + {area.resource === null + ? 'نوبت این جلسه روی دستگاهی ثبت نشده — دستگاه را انتخاب کنید.' + : 'فرم زیر از روی همین دستگاه ساخته می‌شود.'} + +
+ )} {resourceUuid !== null && fields.length === 0 && ( @@ -273,14 +298,17 @@ function AreaCard({ area, devices, forms, disabled, onSkip, onComplete, saving } )} + {/* `.field` خودش باکسِ اینپوت است؛ لیبل و SearchableSelect داخلش یعنی دو + باکس تودرتو. لیبلِ بالای فیلد کارِ `.field-block` است. */} {fields.map((field) => ( -