- 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.
407 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|