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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user