refactor(admin): stop asking the operator to re-pick the session's device

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 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-07 13:00:29 +03:30
co-authored by Claude Opus 5
parent 072e10d0ba
commit cc2e630c40
2 changed files with 179 additions and 38 deletions
@@ -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<typeof import('react-router-dom')>('react-router-dom');
return { ...actual, useParams: () => ({ uuid: 'ses-1' }) };
});
import { api } from '../lib/api';
import StaffSessionDetailPage from './StaffSessionDetailPage';
const get = api.get as ReturnType<typeof vi.fn>;
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(<StaffSessionDetailPage />);
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(<StaffSessionDetailPage />);
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(<StaffSessionDetailPage />);
fireEvent.click(await screen.findByRole('button', { name: 'ثبت اطلاعات این ناحیه' }));
expect(screen.getByLabelText('دستگاه ناحیهٔ صورت')).toBeInTheDocument();
expect(screen.getByText(/روی دستگاهی ثبت نشده/)).toBeInTheDocument();
});
});
+72 -38
View File
@@ -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 (
<>
<PageHeader
title={`جلسهٔ ${session.session_number} از ${session.total_sessions}`}
title={`جلسهٔ ${formatNumber(session.session_number)} از ${formatNumber(session.total_sessions)}`}
backTo="/admin/my-sessions"
/>
@@ -103,7 +103,7 @@ export default function StaffSessionDetailPage() {
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 12.5, color: 'var(--text-2)' }}>
{session.appointment && <span>تاریخ: {formatDate(session.appointment.slot_start)}</span>}
{session.performed_by && <span>اپراتور: {session.performed_by.name}</span>}
<span>{settled} از {areas.length} ناحیه انجام شده</span>
<span>{formatNumber(settled)} از {formatNumber(areas.length)} ناحیه انجام شده</span>
{elapsed && (
<span style={{ fontVariantNumeric: 'tabular-nums' }}>
{session.finished_at === null ? 'در حال انجام: ' : 'مدت جلسه: '}{elapsed}
@@ -159,7 +159,7 @@ export default function StaffSessionDetailPage() {
/>
{settled < areas.length && (
<span style={{ fontSize: 12.5, color: 'var(--warning)' }}>
{areas.length - settled} ناحیه هنوز تکمیل نشده است جلسه با همین وضعیت بسته میشود.
{formatNumber(areas.length - settled)} ناحیه هنوز تکمیل نشده است جلسه با همین وضعیت بسته میشود.
</span>
)}
<button
@@ -189,7 +189,9 @@ function AreaCard({ area, devices, forms, disabled, onSkip, onComplete, saving }
const [open, setOpen] = useState(false);
const [values, setValues] = useState<Record<string, string>>({});
const [areaNote, setAreaNote] = useState('');
// پیش‌فرض همان دستگاهی است که از نوبت به ارث رسیده؛ اپراتور فقط اگر لازم شد عوضش می‌کند.
// دستگاه از نوبت به ارث می‌رسد و همان می‌ماند. این فلگ فقط برای موردِ نادرِ
// «نوبت روی دستگاه اشتباه ثبت شده» است، نه بخشی از جریان عادی.
const [changingDevice, setChangingDevice] = useState(false);
const [resourceUuid, setResourceUuid] = useState<string | null>(area.resource?.uuid ?? null);
const fields = resourceUuid ? forms[resourceUuid] ?? [] : [];
const settled = area.status === 'completed' || area.status === 'skipped';
@@ -252,20 +254,43 @@ function AreaCard({ area, devices, forms, disabled, onSkip, onComplete, saving }
{!settled && open && (
<div style={{ display: 'grid', gap: 10 }}>
<label className="field" style={{ display: 'grid', gap: 4 }}>
<span style={{ fontSize: 12.5 }}>دستگاه</span>
<SearchableSelect
options={devices.map((d) => ({ value: d.uuid, label: d.name }))}
value={resourceUuid}
onChange={(v) => setResourceUuid(v === null ? null : String(v))}
placeholder="بدون دستگاه"
isClearable
ariaLabel={`دستگاه ناحیهٔ ${area.area.name}`}
/>
<span className="field-hint">
پیشفرض همان دستگاه نوبت است. فرم زیر از روی همین دستگاه ساخته میشود.
</span>
</label>
{/* دستگاه هنگام ثبت نوبت انتخاب شده و روی رکورد ناحیه نشسته است؛ پرسیدن
دوباره‌اش یعنی همان تصمیم دو بار گرفته شود. فقط وقتی نوبت روی هیچ
منبعی نبوده انتخاب لازم است. */}
{area.resource !== null && !changingDevice ? (
<div className="field-block">
<label>دستگاه</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 13, fontWeight: 600 }}>{area.resource.name}</span>
<button
type="button"
className="btn ghost sm"
onClick={() => setChangingDevice(true)}
>
تغییر
</button>
</div>
<span className="field-hint">از نوبت این جلسه آمده. فرم زیر از روی همین دستگاه ساخته میشود.</span>
</div>
) : (
<div className="field-block">
<label htmlFor={`device-${area.uuid}`}>دستگاه</label>
<SearchableSelect
inputId={`device-${area.uuid}`}
options={devices.map((d) => ({ value: d.uuid, label: d.name }))}
value={resourceUuid}
onChange={(v) => setResourceUuid(v === null ? null : String(v))}
placeholder="بدون دستگاه"
isClearable
ariaLabel={`دستگاه ناحیهٔ ${area.area.name}`}
/>
<span className="field-hint">
{area.resource === null
? 'نوبت این جلسه روی دستگاهی ثبت نشده — دستگاه را انتخاب کنید.'
: 'فرم زیر از روی همین دستگاه ساخته می‌شود.'}
</span>
</div>
)}
{resourceUuid !== null && fields.length === 0 && (
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
@@ -273,14 +298,17 @@ function AreaCard({ area, devices, forms, disabled, onSkip, onComplete, saving }
</span>
)}
{/* `.field` خودش باکسِ اینپوت است؛ لیبل و SearchableSelect داخلش یعنی دو
باکس تودرتو. لیبلِ بالای فیلد کارِ `.field-block` است. */}
{fields.map((field) => (
<label key={field.key} className="field" style={{ display: 'grid', gap: 4 }}>
<span style={{ fontSize: 12.5 }}>
{field.label}{field.required ? ' *' : ''}
</span>
<div key={field.key} className="field-block">
<label htmlFor={`p-${area.uuid}-${field.key}`}>
{field.label}{field.required ? <span className="req"> *</span> : null}
</label>
{field.type === 'select' ? (
<SearchableSelect
inputId={`p-${area.uuid}-${field.key}`}
options={(field.options ?? []).map((o) => ({ value: String(o), label: String(o) }))}
value={values[field.key] ?? null}
onChange={(v) => setValues((p) => ({ ...p, [field.key]: v === null ? '' : String(v) }))}
@@ -288,23 +316,29 @@ function AreaCard({ area, devices, forms, disabled, onSkip, onComplete, saving }
ariaLabel={field.label}
/>
) : (
<input
type={field.type === 'number' ? 'number' : 'text'}
value={values[field.key] ?? ''}
onChange={(e) => setValues((p) => ({ ...p, [field.key]: e.target.value }))}
aria-label={field.label}
/>
<div className="field">
<input
id={`p-${area.uuid}-${field.key}`}
type={field.type === 'number' ? 'number' : 'text'}
inputMode={field.type === 'number' ? 'numeric' : undefined}
value={values[field.key] ?? ''}
onChange={(e) => setValues((p) => ({ ...p, [field.key]: e.target.value }))}
/>
</div>
)}
</label>
</div>
))}
<textarea
value={areaNote}
onChange={(e) => setAreaNote(e.target.value)}
placeholder="یادداشت این ناحیه (اختیاری)"
rows={2}
aria-label={`یادداشت ناحیهٔ ${area.area.name}`}
/>
<div className="field-block">
<label htmlFor={`note-${area.uuid}`}>یادداشت این ناحیه <span className="opt">(اختیاری)</span></label>
<textarea
id={`note-${area.uuid}`}
className="cp-textarea"
value={areaNote}
onChange={(e) => setAreaNote(e.target.value)}
rows={2}
/>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="btn primary sm" onClick={submit} disabled={saving}>