Skipping an area recorded only that it was skipped. Why it was skipped is clinical history — the next session needs to read it — so `skip` now takes an optional note, the same way completing an area already did, and the panel asks for it inline instead of firing on the first click. An operator finds out mid-laser that they closed the wrong area, and until now had to carry that mistake to the end of the session. `reopen` puts a settled area — completed or skipped — back to in_progress and clears finished_at, keeping the recorded parameters and note so they can be seen and overwritten. It stops at the same boundary everything else in this domain stops at: once the session is finished the record is history, and reopening it is 409. Also drops /admin/my-services. The staff role has one job — today's sessions — and the dashboard already lists the services they may perform, so the page was a second place to read the same list. Route, page, sidebar entry and the two links to it are gone; the services stat card is no longer a link because it no longer has a destination. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
407 lines
18 KiB
TypeScript
407 lines
18 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 { ArrowUturnRightIcon } from '@heroicons/react/24/outline';
|
|
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: (payload: { areaUuid: string; note: string }) =>
|
|
api.post<ApiResponse<unknown>>(`${BASE}/session-area/${payload.areaUuid}/skip`, {
|
|
note: payload.note || undefined,
|
|
}),
|
|
onSuccess: () => { toast.success('این ناحیه صرفنظر شد'); refresh(); },
|
|
onError: (e) => fail(e, 'صرفنظر از ناحیه ناموفق بود'),
|
|
});
|
|
|
|
// اشتباهِ حین کار: ناحیهای که زودتر بسته شده تا وقتی جلسه باز است برمیگردد.
|
|
const reopenArea = useMutation({
|
|
mutationFn: (areaUuid: string) => api.post<ApiResponse<unknown>>(`${BASE}/session-area/${areaUuid}/reopen`, {}),
|
|
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={(note) => skipArea.mutate({ areaUuid: area.uuid, note })}
|
|
onReopen={() => reopenArea.mutate(area.uuid)}
|
|
onComplete={(body) => completeArea.mutate({ areaUuid: area.uuid, body })}
|
|
saving={completeArea.isPending}
|
|
reopening={reopenArea.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, onReopen, onComplete, saving, reopening }: {
|
|
area: SessionAreaRecord;
|
|
devices: TreatmentDevice[];
|
|
forms: Record<string, TreatmentFormField[]>;
|
|
disabled: boolean;
|
|
onSkip: (note: string) => void;
|
|
onReopen: () => void;
|
|
onComplete: (body: Record<string, unknown>) => void;
|
|
saving: boolean;
|
|
reopening: boolean;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const [values, setValues] = useState<Record<string, string>>({});
|
|
const [areaNote, setAreaNote] = useState('');
|
|
// دستگاه از نوبت به ارث میرسد و همان میماند. این فلگ فقط برای موردِ نادرِ
|
|
// «نوبت روی دستگاه اشتباه ثبت شده» است، نه بخشی از جریان عادی.
|
|
const [changingDevice, setChangingDevice] = useState(false);
|
|
const [skipping, setSkipping] = useState(false);
|
|
const [skipNote, setSkipNote] = useState('');
|
|
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 && (
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<button type="button" className="btn secondary sm" onClick={onReopen} disabled={reopening}>
|
|
<ArrowUturnRightIcon style={{ width: 14, height: 14 }} />
|
|
{reopening ? 'در حال بازکردن…' : 'بازکردن دوباره'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{!settled && !disabled && !open && !skipping && (
|
|
<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={() => setSkipping(true)}>
|
|
صرفنظر از این ناحیه
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* «چرا انجام نشد» بخشی از سابقهٔ درمان است؛ جلسهٔ بعد باید بتوان خواندش. */}
|
|
{!settled && !disabled && skipping && (
|
|
<div className="field-block">
|
|
<label htmlFor={`skip-${area.uuid}`}>دلیل صرفنظر <span className="opt">(اختیاری)</span></label>
|
|
<textarea
|
|
id={`skip-${area.uuid}`}
|
|
className="cp-textarea"
|
|
value={skipNote}
|
|
onChange={(e) => setSkipNote(e.target.value)}
|
|
rows={2}
|
|
placeholder="مثال: پوست این ناحیه تحریک بود"
|
|
/>
|
|
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
|
<button type="button" className="btn secondary sm" onClick={() => onSkip(skipNote.trim())}>
|
|
ثبت صرفنظر
|
|
</button>
|
|
<button type="button" className="btn ghost sm" onClick={() => { setSkipping(false); setSkipNote(''); }}>
|
|
انصراف
|
|
</button>
|
|
</div>
|
|
</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>
|
|
);
|
|
}
|