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>
This commit is contained in:
hamed
2026-08-07 12:51:26 +03:30
co-authored by Claude Opus 5
parent 1366a7f15c
commit 8875b8c64a
6 changed files with 263 additions and 34 deletions
@@ -0,0 +1,110 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import { api } from '../lib/api';
import StaffTreatmentSessionsPage from './StaffTreatmentSessionsPage';
const get = api.get as ReturnType<typeof vi.fn>;
const AT = Math.floor(new Date('2026-08-07T09:30:00Z').getTime() / 1000);
function session(over: Record<string, unknown> = {}) {
return {
uuid: 'ses-1',
case_uuid: 'case-1',
service_name: 'لیزر توتال',
patient_name: 'محمد رسولی',
resource_name: 'کندلا ۲۰۲۳',
session_number: 2,
total_sessions: 3,
status: 'booked',
due_at: AT,
started_at: null,
finished_at: null,
note: null,
performed_by: null,
appointment: { uuid: 'a-1', slot_start: AT, slot_end: AT + 1800, status: 'confirmed' },
areas: [{ status: 'completed' }, { status: 'pending' }],
...over,
};
}
beforeEach(() => {
get.mockReset();
});
describe('صفحهٔ جلسات امروز پرسنل', () => {
it('نام بیمار و دستگاه را نشان می‌دهد، نه فقط نام سرویس', async () => {
get.mockResolvedValue({ success: true, data: [session()] });
renderWithProviders(<StaffTreatmentSessionsPage />);
expect(await screen.findByText('محمد رسولی')).toBeInTheDocument();
expect(screen.getByText('کندلا ۲۰۲۳')).toBeInTheDocument();
expect(screen.getByText('لیزر توتال')).toBeInTheDocument();
});
/** صف است: کار باقی‌مانده باید بالای کارِ تمام‌شده بنشیند. */
it('کار باقی‌مانده را از کار تمام‌شده جدا می‌کند', async () => {
get.mockResolvedValue({
success: true,
data: [
session({ uuid: 's-done', status: 'done', patient_name: 'بیمار تمام‌شده' }),
session({ uuid: 's-open', status: 'booked', patient_name: 'بیمار در انتظار' }),
],
});
renderWithProviders(<StaffTreatmentSessionsPage />);
const pendingHead = await screen.findByText('در انتظار انجام');
const doneHead = screen.getByText('تمام‌شده');
expect(pendingHead.compareDocumentPosition(doneHead) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
const open = screen.getByText('بیمار در انتظار');
const done = screen.getByText('بیمار تمام‌شده');
expect(open.compareDocumentPosition(done) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
/** غیبت برای امروز بسته است، هرچند جلسه دوباره برنامه‌ریزی می‌شود. */
it('غیبت را در گروه تمام‌شده می‌گذارد', async () => {
get.mockResolvedValue({ success: true, data: [session({ status: 'no_show' })] });
renderWithProviders(<StaffTreatmentSessionsPage />);
expect(await screen.findByText('تمام‌شده')).toBeInTheDocument();
expect(screen.getByText('کار باقی‌مانده‌ای ندارید.')).toBeInTheDocument();
});
/** خطای سرور نباید «امروز جلسه‌ای ندارید» خوانده شود. */
it('خطا را از فهرست خالی جدا می‌کند', async () => {
get.mockRejectedValue(new Error('boom'));
renderWithProviders(<StaffTreatmentSessionsPage />);
await waitFor(() => expect(screen.getByText(/خواندن جلسات امروز ناموفق بود/)).toBeInTheDocument());
expect(screen.queryByText(/جلسه‌ای برای شما ثبت نشده/)).not.toBeInTheDocument();
});
it('فهرست خالی پیام خودش را دارد', async () => {
get.mockResolvedValue({ success: true, data: [] });
renderWithProviders(<StaffTreatmentSessionsPage />);
expect(await screen.findByText(/جلسه‌ای برای شما ثبت نشده/)).toBeInTheDocument();
});
it('کل ردیف لینکِ همان جلسه است', async () => {
get.mockResolvedValue({ success: true, data: [session()] });
renderWithProviders(<StaffTreatmentSessionsPage />);
const row = (await screen.findByText('محمد رسولی')).closest('a');
expect(row).toHaveAttribute('href', '/admin/my-sessions/ses-1');
});
});
+123 -32
View File
@@ -1,11 +1,11 @@
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { ClipboardDocumentListIcon } from '@heroicons/react/24/outline';
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 { formatDate } from '../lib/utils';
import { formatNumber } from '../lib/utils';
import type { StaffTreatmentSession } from '../types';
/**
@@ -13,57 +13,148 @@ import type { StaffTreatmentSession } from '../types';
*
* فهرست از نوبتِ متصل به جلسه می‌آید نه از سررسید تخمینی: کارِ امروز چیزی است که
* برایش وقت گرفته شده.
*
* صفحه صف است نه گزارش، پس دو تصمیم را تحمیل می‌کند: کارِ باقی‌مانده بالای کارِ
* تمام‌شده می‌نشیند، و فهرست خودش تازه می‌شود چون منشی وسط روز نوبت اضافه می‌کند و
* اپراتور روی همین صفحه نشسته است.
*/
/** برای *امروز* بسته‌اند: غیبت هم دوباره برنامه‌ریزی می‌شود، نه همین امروز. */
const SETTLED_STATUSES = ['done', 'cancelled', 'no_show'];
export default function StaffTreatmentSessionsPage() {
const { data, isLoading } = useQuery({
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="جلسات امروز من" />
<PageHeader
title="جلسات امروز من"
action={
<button type="button" className="btn secondary sm" onClick={() => refetch()} disabled={isFetching}>
{isFetching ? 'در حال به‌روزرسانی…' : 'به‌روزرسانی'}
</button>
}
/>
{isLoading ? (
<div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</div>
<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: 12 }}>
{sessions.map((s) => {
const settled = s.areas?.filter((a) => a.status === 'completed' || a.status === 'skipped').length ?? 0;
const total = s.areas?.length ?? 0;
return (
<div key={s.uuid} className="card card-pad" style={{ display: 'grid', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<strong style={{ fontSize: 14 }}>{s.service_name}</strong>
<StatusBadge type="treatment-session" value={s.status} />
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
جلسهٔ {s.session_number} از {s.total_sessions}
</span>
</div>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 12.5, color: 'var(--text-2)' }}>
{s.appointment && <span>ساعت {new Date(s.appointment.slot_start * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })}</span>}
{s.appointment && <span>{formatDate(s.appointment.slot_start)}</span>}
{total > 0 && <span>{settled} از {total} ناحیه انجام شده</span>}
</div>
<Link to={`/admin/my-sessions/${s.uuid}`} className="btn primary sm" style={{ justifySelf: 'start' }}>
مشاهده جزئیات و انجام جلسه
</Link>
</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>
);
}
+4
View File
@@ -505,6 +505,10 @@ body {
border-radius: var(--r-lg); box-shadow: var(--shadow-sm);
}
.card-pad { padding: var(--card-pad); }
/* کارتی که خودش لینک است. بدون بازخورد hover، کاربر نمی‌فهمد کل ردیف قابل کلیک
است و دنبال دکمه می‌گردد. */
a.card { transition: .14s var(--ease); }
a.card:hover { border-color: var(--primary); box-shadow: var(--shadow); }
.card-head {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: var(--card-pad) var(--card-pad) 0;
+3
View File
@@ -1317,6 +1317,9 @@ export interface TreatmentSessionSummary {
export interface StaffTreatmentSession extends TreatmentSessionSummary {
case_uuid: string;
service_name: string;
/** فقط در صفِ اپراتور می‌آید، نه در هر جای دیگری که جلسه سریالایز می‌شود. */
patient_name?: string | null;
resource_name?: string | null;
}
export interface TreatmentCaseSummary {