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