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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -285,6 +285,20 @@ single-session again. Idempotent: deleting a service that has no protocol still
|
||||
|
||||
### «جلسات امروز من» یک صف است
|
||||
|
||||
هر ردیف علاوه بر فیلدهای معمولِ جلسه، اینها را هم دارد:
|
||||
|
||||
| فیلد | توضیح |
|
||||
|---|---|
|
||||
| `case_uuid` | پروندهٔ درمانِ همین جلسه |
|
||||
| `service_name` | نام سرویس |
|
||||
| `patient_name` | نام بیمار، از نوبتِ متصل — `null` اگر جلسه نوبت ندارد |
|
||||
| `resource_name` | دستگاهِ نوبت — `null` اگر نوبت روی منبع نبوده |
|
||||
|
||||
`patient_name` و `resource_name` فقط در همین اندپوینت اضافه میشوند، نه در
|
||||
`TreatmentSession::toArray()`؛ صفِ اپراتور بدون نام بیمار بیمعنی است ولی هویت بیمار
|
||||
نباید در هر مصرفکنندهٔ دیگرِ آن متد هم بنشیند.
|
||||
|
||||
|
||||
جلسهٔ امروز از سه راه به یک اپراتور میرسد:
|
||||
|
||||
- جلسهای که خودش برداشته — `TreatmentSession.performedBy` هنگام «شروع جلسه» ست میشود.
|
||||
|
||||
@@ -57,9 +57,16 @@ class SessionExecutionController extends BaseController
|
||||
$staff = $this->requireStaff($user);
|
||||
|
||||
return $this->success(array_map(
|
||||
/**
|
||||
* نام بیمار و دستگاه اینجا اضافه میشوند نه در `TreatmentSession::toArray()`:
|
||||
* صفِ اپراتور بدون نام بیمار بیمعنی است، ولی هویت بیمار نباید در هر
|
||||
* مصرفکنندهٔ دیگرِ همان متد هم بنشیند.
|
||||
*/
|
||||
static fn (TreatmentSession $s): array => $s->toArray(withAreas: true) + [
|
||||
'case_uuid' => $s->getTreatmentCase()->getUuid(),
|
||||
'service_name' => $s->getTreatmentCase()->getServiceItem()->getName(),
|
||||
'case_uuid' => $s->getTreatmentCase()->getUuid(),
|
||||
'service_name' => $s->getTreatmentCase()->getServiceItem()->getName(),
|
||||
'patient_name' => $s->getAppointment()?->getPatientName(),
|
||||
'resource_name' => $s->getAppointment()?->getResource()?->getName(),
|
||||
],
|
||||
$this->sessions->findTodayForStaff(
|
||||
$staff,
|
||||
|
||||
Reference in New Issue
Block a user