Files
clinicpro/assets/admin/pages/StaffTreatmentSessionsPage.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

161 lines
7.0 KiB
TypeScript

import { Link } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { ChevronLeftIcon, ClipboardDocumentListIcon, CpuChipIcon } from '@heroicons/react/24/outline';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import PageHeader from '../components/ui/PageHeader';
import StatusBadge from '../components/ui/StatusBadge';
import { formatNumber } from '../lib/utils';
import type { StaffTreatmentSession } from '../types';
/**
* «جلسات امروز من» — نقطهٔ ورود پرسنل به کار روز.
*
* فهرست از نوبتِ متصل به جلسه می‌آید نه از سررسید تخمینی: کارِ امروز چیزی است که
* برایش وقت گرفته شده.
*
* صفحه صف است نه گزارش، پس دو تصمیم را تحمیل می‌کند: کارِ باقی‌مانده بالای کارِ
* تمام‌شده می‌نشیند، و فهرست خودش تازه می‌شود چون منشی وسط روز نوبت اضافه می‌کند و
* اپراتور روی همین صفحه نشسته است.
*/
/** برای *امروز* بسته‌اند: غیبت هم دوباره برنامه‌ریزی می‌شود، نه همین امروز. */
const SETTLED_STATUSES = ['done', 'cancelled', 'no_show'];
export default function StaffTreatmentSessionsPage() {
const { data, isLoading, isError, refetch, isFetching } = useQuery({
queryKey: ['staff-treatment-sessions'],
queryFn: () => api.get<ApiResponse<StaffTreatmentSession[]>>('/api/v1/dashboard/staff/treatment-sessions'),
staleTime: 30_000,
refetchInterval: 60_000,
});
const sessions = data?.data ?? [];
const pending = sessions.filter((s) => !SETTLED_STATUSES.includes(s.status));
const done = sessions.filter((s) => SETTLED_STATUSES.includes(s.status));
return (
<>
<PageHeader
title="جلسات امروز من"
action={
<button type="button" className="btn secondary sm" onClick={() => refetch()} disabled={isFetching}>
{isFetching ? 'در حال به‌روزرسانی…' : 'به‌روزرسانی'}
</button>
}
/>
{isLoading ? (
<div style={{ display: 'grid', gap: 12 }}>
{[0, 1].map((i) => <div key={i} className="card" style={{ height: 84 }} />)}
</div>
) : isError ? (
/* بدون این شاخه، خطای سرور «امروز جلسه‌ای ندارید» خوانده می‌شد و اپراتور
کارِ روزش را از دست می‌داد بی‌آنکه بداند چیزی خراب است. */
<div className="card card-pad" style={{ display: 'grid', gap: 10, justifyItems: 'center', padding: 32 }}>
<span style={{ fontSize: 13.5, color: 'var(--danger)' }}>خواندن جلسات امروز ناموفق بود.</span>
<button type="button" className="btn secondary sm" onClick={() => refetch()}>تلاش دوباره</button>
</div>
) : sessions.length === 0 ? (
<div className="card card-pad" style={{ display: 'grid', gap: 8, justifyItems: 'center', padding: 40 }}>
<ClipboardDocumentListIcon style={{ width: 40, height: 40, color: 'var(--text-3)' }} />
<span style={{ fontSize: 13.5, color: 'var(--text-2)' }}>امروز جلسه‌ای برای شما ثبت نشده است</span>
</div>
) : (
<div style={{ display: 'grid', gap: 20 }}>
<Group title="در انتظار انجام" sessions={pending} emptyText="کار باقی‌مانده‌ای ندارید." />
{done.length > 0 && <Group title="تمام‌شده" sessions={done} />}
</div>
)}
</>
);
}
function Group({ title, sessions, emptyText }: {
title: string;
sessions: StaffTreatmentSession[];
emptyText?: string;
}) {
return (
<section>
<h2 style={{
display: 'flex', alignItems: 'center', gap: 8, margin: '0 0 10px',
fontSize: 13.5, fontWeight: 700, color: 'var(--text-2)',
}}>
{title}
<span style={{ fontSize: 12, fontWeight: 400, color: 'var(--text-3)' }}>
({formatNumber(sessions.length)})
</span>
</h2>
{sessions.length === 0 ? (
<div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}>{emptyText}</div>
) : (
<div style={{ display: 'grid', gap: 10 }}>
{sessions.map((s) => <SessionRow key={s.uuid} session={s} />)}
</div>
)}
</section>
);
}
/**
* کل ردیف لینک است، نه دکمه‌ای در گوشه‌اش.
*
* هر ردیف دقیقاً یک کار دارد — رفتن به همان جلسه — و تکرار یک دکمهٔ بلند در هر
* کارت، هدف کلیک را کوچک‌تر می‌کرد نه بزرگ‌تر.
*/
function SessionRow({ session: s }: { session: StaffTreatmentSession }) {
const settled = s.areas?.filter((a) => a.status === 'completed' || a.status === 'skipped').length ?? 0;
const total = s.areas?.length ?? 0;
const time = s.appointment
? new Date(s.appointment.slot_start * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })
: '—';
return (
<Link
to={`/admin/my-sessions/${s.uuid}`}
className="card card-pad"
style={{
display: 'flex', alignItems: 'center', gap: 14,
color: 'inherit', textDecoration: 'none',
}}
>
{/* ساعت لنگر ردیف است: اپراتور اول «چه وقتی» را می‌خواند، بعد «برای که». */}
<span style={{
flexShrink: 0, minWidth: 58, fontSize: 16, fontWeight: 700,
color: 'var(--primary-700)', fontVariantNumeric: 'tabular-nums',
}}>
{time}
</span>
<div style={{ flex: 1, minWidth: 0, display: 'grid', gap: 5 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<strong style={{ fontSize: 14 }}>{s.patient_name || 'بیمار نامشخص'}</strong>
<StatusBadge type="treatment-session" value={s.status} />
</div>
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap', fontSize: 12.5, color: 'var(--text-2)' }}>
<span>{s.service_name}</span>
<span style={{ color: 'var(--text-3)' }}>
جلسهٔ {formatNumber(s.session_number)} از {formatNumber(s.total_sessions)}
</span>
{s.resource_name && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: 'var(--text-3)' }}>
<CpuChipIcon style={{ width: 14, height: 14 }} />
{s.resource_name}
</span>
)}
{total > 0 && (
<span style={{ color: settled === total ? 'var(--success)' : 'var(--text-3)' }}>
{formatNumber(settled)} از {formatNumber(total)} ناحیه
</span>
)}
</div>
</div>
<ChevronLeftIcon style={{ width: 18, height: 18, flexShrink: 0, color: 'var(--text-3)' }} />
</Link>
);
}