Files
clinicpro/assets/admin/pages/StaffTreatmentSessionsPage.tsx
T
hamedandClaude Opus 5 8875b8c64a refactor(admin): rebuild the staff sessions page around the operator's question
The page an operator opens to see the day's work did not say who any of it was
for. TreatmentSession::toArray() carries no patient, so the list showed a
service name, a time, and a repeated 40-char button — the same three lines for
every row, with the finished work leading.

The endpoint now sends patient_name and resource_name. They are added in the
controller next to case_uuid/service_name rather than in toArray(), so patient
identity does not leak into every other consumer of that method.

The list is now a queue: unfinished work first, settled work (done, cancelled,
no-show) below it, each group counted. Every row leads with its time, names the
patient, and carries the service, device, session number and area progress on
one meta line. The whole row is the link, so the repeated button is gone.

Also fixes three things the redesign checklist calls out: Latin digits in the
session and area counts (formatNumber), a date repeated on every row of a page
whose title is "today", and a missing error state — a failed request rendered
as "no sessions today", which reads as an empty day rather than a broken one.

Adds the test file the page never had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:51:26 +03:30

161 lines
7.0 KiB
TypeScript

import { Link } from 'react-router-dom';
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>
);
}