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) => ( -