Files
clinicpro/assets/admin/pages/StaffSessionDetailPage.tsx
T
hamedandClaude Opus 5 c4099f0f63 feat(admin): default the booking staff from the service's protocol, and call it پرسنل
The staff field started empty even when the service's treatment protocol already
named who may perform it — a decision made once in the service settings and then
asked again on every booking. The modal now reads that protocol and preselects
its first staff member, but only until the user touches the field; otherwise a
manual choice would be wiped on the next service change. A protocol with no
staff leaves it empty and does not block submission.

Renames the four user-facing 'اپراتور' strings to 'پرسنل', matching the record
in /admin/staff that they all refer to. ResourcesPage keeps the word: there it
names a kind of bookable resource (doctor, operator, room, device), not a
ClinicStaff row.

Drops a test whose premise the default invalidated; the two new ones cover both
sides — protocol with staff sends staff_uuid, protocol without staff sends none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:28:13 +03:30

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>
);
}