- 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.
161 lines
7.0 KiB
TypeScript
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>
|
|
);
|
|
}
|