diff --git a/assets/admin/pages/StaffTreatmentSessionsPage.test.tsx b/assets/admin/pages/StaffTreatmentSessionsPage.test.tsx new file mode 100644 index 00000000..ab0391a2 --- /dev/null +++ b/assets/admin/pages/StaffTreatmentSessionsPage.test.tsx @@ -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; + +const AT = Math.floor(new Date('2026-08-07T09:30:00Z').getTime() / 1000); + +function session(over: Record = {}) { + 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(); + + 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(); + + 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(); + + expect(await screen.findByText('تمام‌شده')).toBeInTheDocument(); + expect(screen.getByText('کار باقی‌مانده‌ای ندارید.')).toBeInTheDocument(); + }); + + /** خطای سرور نباید «امروز جلسه‌ای ندارید» خوانده شود. */ + it('خطا را از فهرست خالی جدا می‌کند', async () => { + get.mockRejectedValue(new Error('boom')); + + renderWithProviders(); + + await waitFor(() => expect(screen.getByText(/خواندن جلسات امروز ناموفق بود/)).toBeInTheDocument()); + expect(screen.queryByText(/جلسه‌ای برای شما ثبت نشده/)).not.toBeInTheDocument(); + }); + + it('فهرست خالی پیام خودش را دارد', async () => { + get.mockResolvedValue({ success: true, data: [] }); + + renderWithProviders(); + + expect(await screen.findByText(/جلسه‌ای برای شما ثبت نشده/)).toBeInTheDocument(); + }); + + it('کل ردیف لینکِ همان جلسه است', async () => { + get.mockResolvedValue({ success: true, data: [session()] }); + + renderWithProviders(); + + const row = (await screen.findByText('محمد رسولی')).closest('a'); + expect(row).toHaveAttribute('href', '/admin/my-sessions/ses-1'); + }); +}); diff --git a/assets/admin/pages/StaffTreatmentSessionsPage.tsx b/assets/admin/pages/StaffTreatmentSessionsPage.tsx index d02a0966..82378786 100644 --- a/assets/admin/pages/StaffTreatmentSessionsPage.tsx +++ b/assets/admin/pages/StaffTreatmentSessionsPage.tsx @@ -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>('/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 ( <> - + refetch()} disabled={isFetching}> + {isFetching ? 'در حال به‌روزرسانی…' : 'به‌روزرسانی'} + + } + /> {isLoading ? ( -
در حال بارگذاری...
+
+ {[0, 1].map((i) =>
)} +
+ ) : isError ? ( + /* بدون این شاخه، خطای سرور «امروز جلسه‌ای ندارید» خوانده می‌شد و اپراتور + کارِ روزش را از دست می‌داد بی‌آنکه بداند چیزی خراب است. */ +
+ خواندن جلسات امروز ناموفق بود. + +
) : sessions.length === 0 ? (
امروز جلسه‌ای برای شما ثبت نشده است
) : ( -
- {sessions.map((s) => { - const settled = s.areas?.filter((a) => a.status === 'completed' || a.status === 'skipped').length ?? 0; - const total = s.areas?.length ?? 0; - - return ( -
-
- {s.service_name} - - - جلسهٔ {s.session_number} از {s.total_sessions} - -
- -
- {s.appointment && ساعت {new Date(s.appointment.slot_start * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })}} - {s.appointment && {formatDate(s.appointment.slot_start)}} - {total > 0 && {settled} از {total} ناحیه انجام شده} -
- - - مشاهده جزئیات و انجام جلسه - -
- ); - })} +
+ + {done.length > 0 && }
)} ); } + +function Group({ title, sessions, emptyText }: { + title: string; + sessions: StaffTreatmentSession[]; + emptyText?: string; +}) { + return ( +
+

+ {title} + + ({formatNumber(sessions.length)}) + +

+ + {sessions.length === 0 ? ( +
{emptyText}
+ ) : ( +
+ {sessions.map((s) => )} +
+ )} +
+ ); +} + +/** + * کل ردیف لینک است، نه دکمه‌ای در گوشه‌اش. + * + * هر ردیف دقیقاً یک کار دارد — رفتن به همان جلسه — و تکرار یک دکمهٔ بلند در هر + * کارت، هدف کلیک را کوچک‌تر می‌کرد نه بزرگ‌تر. + */ +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 ( + + {/* ساعت لنگر ردیف است: اپراتور اول «چه وقتی» را می‌خواند، بعد «برای که». */} + + {time} + + +
+
+ {s.patient_name || 'بیمار نامشخص'} + +
+ +
+ {s.service_name} + + جلسهٔ {formatNumber(s.session_number)} از {formatNumber(s.total_sessions)} + + {s.resource_name && ( + + + {s.resource_name} + + )} + {total > 0 && ( + + {formatNumber(settled)} از {formatNumber(total)} ناحیه + + )} +
+
+ + + + ); +} diff --git a/assets/admin/styles.css b/assets/admin/styles.css index 4f4eac2a..9dd46720 100644 --- a/assets/admin/styles.css +++ b/assets/admin/styles.css @@ -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; diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index e691a83f..84e70b4f 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -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 { diff --git a/docs/api/treatment.md b/docs/api/treatment.md index b4cea97d..bfc1917a 100644 --- a/docs/api/treatment.md +++ b/docs/api/treatment.md @@ -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` هنگام «شروع جلسه» ست می‌شود. diff --git a/src/Treatment/Controller/SessionExecutionController.php b/src/Treatment/Controller/SessionExecutionController.php index c43ec31a..fe96fb93 100644 --- a/src/Treatment/Controller/SessionExecutionController.php +++ b/src/Treatment/Controller/SessionExecutionController.php @@ -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,