Files
clinicpro/assets/admin/pages/StaffSessionDetailPage.tsx
T
hamed 6876135a53 feat: add BlogBodySanitizer for HTML sanitization on article save
- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks.
- Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content.
- Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
2026-08-07 21:13:38 +03:30

407 lines
18 KiB
TypeScript

import { useEffect, useState } from 'react';
import { useParams } from 'react-router';
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>
);
}