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>
356 lines
15 KiB
TypeScript
356 lines
15 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useParams } from 'react-router-dom';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
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, formatNumber } from '../lib/utils';
|
|
import { useElapsed } from '../hooks/useElapsed';
|
|
import type { SessionAreaRecord, StaffSessionDetail, TreatmentDevice, TreatmentFormField } from '../types';
|
|
|
|
const BASE = '/api/v1/dashboard/staff';
|
|
|
|
/**
|
|
* صفحهٔ انجام جلسه.
|
|
*
|
|
* فرمِ هر ناحیه از `forms` میآید که سرور از روی نوع منبع ساخته — پنل فیلدها را حدس
|
|
* نمیزند، پس افزودن دستگاه تازه در تنظیمات همینجا هم ظاهر میشود بدون تغییر کد.
|
|
*/
|
|
export default function StaffSessionDetailPage() {
|
|
const { uuid = '' } = useParams();
|
|
const qc = useQueryClient();
|
|
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ['staff-session', uuid],
|
|
queryFn: () => api.get<ApiResponse<StaffSessionDetail>>(`${BASE}/treatment-session/${uuid}`),
|
|
enabled: uuid !== '',
|
|
});
|
|
|
|
const session = data?.data;
|
|
const [note, setNote] = useState('');
|
|
|
|
const refresh = () => {
|
|
qc.invalidateQueries({ queryKey: ['staff-session', uuid] });
|
|
qc.invalidateQueries({ queryKey: ['staff-treatment-sessions'] });
|
|
};
|
|
|
|
const fail = (e: unknown, fallback: string) =>
|
|
toast.error(e instanceof ApiError ? e.message : fallback);
|
|
|
|
const startSession = useMutation({
|
|
mutationFn: () => api.post<ApiResponse<unknown>>(`${BASE}/treatment-session/${uuid}/start`, {}),
|
|
onSuccess: () => { toast.success('جلسه شروع شد'); refresh(); },
|
|
onError: (e) => fail(e, 'شروع جلسه ناموفق بود'),
|
|
});
|
|
|
|
const finishSession = useMutation({
|
|
mutationFn: () => api.post<ApiResponse<{ unsettled_areas: number }>>(
|
|
`${BASE}/treatment-session/${uuid}/finish`,
|
|
{ note: note || undefined },
|
|
),
|
|
onSuccess: (res) => {
|
|
const left = res?.data?.unsettled_areas ?? 0;
|
|
toast.success(left > 0 ? `جلسه بسته شد — ${formatNumber(left)} ناحیه تکمیل نشده بود` : 'جلسه با موفقیت تمام شد');
|
|
refresh();
|
|
},
|
|
onError: (e) => fail(e, 'اتمام جلسه ناموفق بود'),
|
|
});
|
|
|
|
const skipArea = useMutation({
|
|
mutationFn: (areaUuid: string) => api.post<ApiResponse<unknown>>(`${BASE}/session-area/${areaUuid}/skip`, {}),
|
|
onSuccess: () => { toast.success('این ناحیه صرفنظر شد'); refresh(); },
|
|
onError: (e) => fail(e, 'صرفنظر از ناحیه ناموفق بود'),
|
|
});
|
|
|
|
const completeArea = useMutation({
|
|
mutationFn: (payload: { areaUuid: string; body: Record<string, unknown> }) =>
|
|
api.post<ApiResponse<unknown>>(`${BASE}/session-area/${payload.areaUuid}/complete`, payload.body),
|
|
onSuccess: () => { toast.success('اطلاعات ناحیه ثبت شد'); refresh(); },
|
|
onError: (e) => fail(e, 'ثبت اطلاعات ناحیه ناموفق بود'),
|
|
});
|
|
|
|
// پیش از هر return زودهنگام: ترتیب hookها باید در هر رندر یکی باشد.
|
|
const elapsed = useElapsed(session?.started_at ?? null, session?.finished_at ?? null);
|
|
|
|
if (isLoading) {
|
|
return <div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</div>;
|
|
}
|
|
|
|
if (!session) {
|
|
return <div className="card card-pad" style={{ fontSize: 13 }}>جلسه یافت نشد</div>;
|
|
}
|
|
|
|
const areas = session.areas ?? [];
|
|
const settled = areas.filter((a) => a.status === 'completed' || a.status === 'skipped').length;
|
|
const started = session.started_at !== null;
|
|
const finished = session.status === 'done';
|
|
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
title={`جلسهٔ ${formatNumber(session.session_number)} از ${formatNumber(session.total_sessions)}`}
|
|
backTo="/admin/my-sessions"
|
|
/>
|
|
|
|
<div className="card card-pad" style={{ display: 'grid', gap: 10, marginBottom: 16 }}>
|
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
<strong style={{ fontSize: 14 }}>{session.case.service.name}</strong>
|
|
<StatusBadge type="treatment-session" value={session.status} />
|
|
</div>
|
|
|
|
<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>{formatNumber(settled)} از {formatNumber(areas.length)} ناحیه انجام شده</span>
|
|
{elapsed && (
|
|
<span style={{ fontVariantNumeric: 'tabular-nums' }}>
|
|
{session.finished_at === null ? 'در حال انجام: ' : 'مدت جلسه: '}{elapsed}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{!started && !finished && (
|
|
<button
|
|
type="button"
|
|
className="btn primary"
|
|
onClick={() => startSession.mutate()}
|
|
disabled={startSession.isPending}
|
|
style={{ justifySelf: 'start' }}
|
|
>
|
|
{startSession.isPending ? 'در حال شروع...' : 'شروع جلسه'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<h2 style={{ fontSize: 14, margin: '0 0 10px' }}>نواحی این جلسه</h2>
|
|
|
|
<div style={{ display: 'grid', gap: 12 }}>
|
|
{areas.length === 0 && (
|
|
<div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}>
|
|
برای دیدن نواحی، ابتدا جلسه را شروع کنید.
|
|
</div>
|
|
)}
|
|
|
|
{areas.map((area) => (
|
|
<AreaCard
|
|
key={area.uuid}
|
|
area={area}
|
|
devices={session.devices}
|
|
forms={session.forms}
|
|
disabled={finished}
|
|
onSkip={() => skipArea.mutate(area.uuid)}
|
|
onComplete={(body) => completeArea.mutate({ areaUuid: area.uuid, body })}
|
|
saving={completeArea.isPending}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{started && !finished && (
|
|
<div className="card card-pad" style={{ display: 'grid', gap: 10, marginTop: 16 }}>
|
|
<h2 style={{ fontSize: 14, margin: 0 }}>یادداشت و اتمام جلسه</h2>
|
|
<textarea
|
|
value={note}
|
|
onChange={(e) => setNote(e.target.value)}
|
|
placeholder="یادداشت کلی جلسه (اختیاری)"
|
|
rows={3}
|
|
aria-label="یادداشت کلی جلسه"
|
|
/>
|
|
{settled < areas.length && (
|
|
<span style={{ fontSize: 12.5, color: 'var(--warning)' }}>
|
|
{formatNumber(areas.length - settled)} ناحیه هنوز تکمیل نشده است — جلسه با همین وضعیت بسته میشود.
|
|
</span>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="btn primary"
|
|
onClick={() => finishSession.mutate()}
|
|
disabled={finishSession.isPending}
|
|
style={{ justifySelf: 'start' }}
|
|
>
|
|
{finishSession.isPending ? 'در حال ثبت...' : 'انجام شد'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function AreaCard({ area, devices, forms, disabled, onSkip, onComplete, saving }: {
|
|
area: SessionAreaRecord;
|
|
devices: TreatmentDevice[];
|
|
forms: Record<string, TreatmentFormField[]>;
|
|
disabled: boolean;
|
|
onSkip: () => void;
|
|
onComplete: (body: Record<string, unknown>) => void;
|
|
saving: boolean;
|
|
}) {
|
|
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';
|
|
const elapsed = useElapsed(area.started_at, area.finished_at);
|
|
|
|
useEffect(() => setResourceUuid(area.resource?.uuid ?? null), [area.resource?.uuid]);
|
|
|
|
const submit = () => {
|
|
const parameters: Record<string, string> = {};
|
|
fields.forEach((f) => {
|
|
if (values[f.key] !== undefined && values[f.key] !== '') parameters[f.key] = values[f.key];
|
|
});
|
|
|
|
onComplete({
|
|
resource_uuid: resourceUuid ?? undefined,
|
|
parameters,
|
|
note: areaNote || undefined,
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="card card-pad" style={{ display: 'grid', gap: 10 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
|
<strong style={{ fontSize: 13.5 }}>{area.area.name}</strong>
|
|
<StatusBadge type="treatment-area" value={area.status} />
|
|
{area.resource && (
|
|
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>دستگاه: {area.resource.name}</span>
|
|
)}
|
|
{elapsed && (
|
|
<span style={{ fontSize: 12.5, color: 'var(--text-3)', fontVariantNumeric: 'tabular-nums' }}>
|
|
{area.finished_at === null ? 'در حال انجام: ' : 'مدت: '}{elapsed}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{settled && area.parameters && (
|
|
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 12.5 }}>
|
|
{Object.entries(area.parameters).map(([key, value]) => (
|
|
<span key={key}>
|
|
{fields.find((f) => f.key === key)?.label ?? key}: <b>{String(value)}</b>
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{settled && area.note && (
|
|
<span style={{ fontSize: 12.5, color: 'var(--text-2)' }}>یادداشت: {area.note}</span>
|
|
)}
|
|
|
|
{!settled && !disabled && !open && (
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<button type="button" className="btn primary sm" onClick={() => setOpen(true)}>
|
|
ثبت اطلاعات این ناحیه
|
|
</button>
|
|
<button type="button" className="btn secondary sm" onClick={onSkip}>
|
|
صرفنظر از این ناحیه
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{!settled && open && (
|
|
<div style={{ display: 'grid', gap: 10 }}>
|
|
{/* دستگاه هنگام ثبت نوبت انتخاب شده و روی رکورد ناحیه نشسته است؛ پرسیدن
|
|
دوبارهاش یعنی همان تصمیم دو بار گرفته شود. فقط وقتی نوبت روی هیچ
|
|
منبعی نبوده انتخاب لازم است. */}
|
|
{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)' }}>
|
|
برای این دستگاه فرمی تعریف نشده است. در «تنظیمات ← انواع منابع» میتوانید فیلدها را تعریف کنید.
|
|
</span>
|
|
)}
|
|
|
|
{/* `.field` خودش باکسِ اینپوت است؛ لیبل و SearchableSelect داخلش یعنی دو
|
|
باکس تودرتو. لیبلِ بالای فیلد کارِ `.field-block` است. */}
|
|
{fields.map((field) => (
|
|
<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) }))}
|
|
placeholder={`انتخاب ${field.label}`}
|
|
ariaLabel={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>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
<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}>
|
|
{saving ? 'در حال ثبت...' : 'اتمام این ناحیه'}
|
|
</button>
|
|
<button type="button" className="btn ghost sm" onClick={() => setOpen(false)}>
|
|
انصراف
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|