feat(treatment): skip reasons and reopening for session areas

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>
This commit is contained in:
hamed
2026-08-07 14:28:30 +03:30
co-authored by Claude Opus 5
parent a6180bc7e7
commit a182b05e1f
12 changed files with 236 additions and 116 deletions
+57 -6
View File
@@ -7,6 +7,7 @@ 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';
@@ -59,11 +60,21 @@ export default function StaffSessionDetailPage() {
});
const skipArea = useMutation({
mutationFn: (areaUuid: string) => api.post<ApiResponse<unknown>>(`${BASE}/session-area/${areaUuid}/skip`, {}),
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),
@@ -140,9 +151,11 @@ export default function StaffSessionDetailPage() {
devices={session.devices}
forms={session.forms}
disabled={finished}
onSkip={() => skipArea.mutate(area.uuid)}
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>
@@ -177,14 +190,16 @@ export default function StaffSessionDetailPage() {
);
}
function AreaCard({ area, devices, forms, disabled, onSkip, onComplete, saving }: {
function AreaCard({ area, devices, forms, disabled, onSkip, onReopen, onComplete, saving, reopening }: {
area: SessionAreaRecord;
devices: TreatmentDevice[];
forms: Record<string, TreatmentFormField[]>;
disabled: boolean;
onSkip: () => void;
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>>({});
@@ -192,6 +207,8 @@ function AreaCard({ area, devices, forms, disabled, onSkip, onComplete, saving }
// دستگاه از نوبت به ارث می‌رسد و همان می‌ماند. این فلگ فقط برای موردِ نادرِ
// «نوبت روی دستگاه اشتباه ثبت شده» است، نه بخشی از جریان عادی.
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';
@@ -241,17 +258,51 @@ function AreaCard({ area, devices, forms, disabled, onSkip, onComplete, saving }
<span style={{ fontSize: 12.5, color: 'var(--text-2)' }}>یادداشت: {area.note}</span>
)}
{!settled && !disabled && !open && (
{/* ناحیهٔ بسته‌شده تا وقتی جلسه باز است برمی‌گردد: اپراتور وسط لیزر می‌فهمد
اشتباه زده و نباید تا پایان جلسه با آن بماند. */}
{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={onSkip}>
<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 }}>
{/* دستگاه هنگام ثبت نوبت انتخاب شده و روی رکورد ناحیه نشسته است؛ پرسیدن