diff --git a/assets/admin/components/AppointmentTurnCard.test.tsx b/assets/admin/components/AppointmentTurnCard.test.tsx new file mode 100644 index 00000000..d98cd1cb --- /dev/null +++ b/assets/admin/components/AppointmentTurnCard.test.tsx @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from 'vitest'; +import { screen } from '@testing-library/react'; +import { renderWithProviders } from '../test/utils'; + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +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 AppointmentTurnCard, { type AppointmentCardData } from './AppointmentTurnCard'; + +const base: AppointmentCardData = { + uuid: 'a1', starts_at: 1754000000, status: 'confirmed', version: 1, + doctor_name: 'دکتر ژیلا فتحی', service_name: null, +}; + +describe('AppointmentTurnCard', () => { + it('renders title, date/time labels, doctor and the live status label', () => { + renderWithProviders(); + expect(screen.getByText('نوبت')).toBeInTheDocument(); // no service → generic title + expect(screen.getByText('تاریخ:')).toBeInTheDocument(); + expect(screen.getByText('ساعت:')).toBeInTheDocument(); + expect(screen.getByText('پرسنل:')).toBeInTheDocument(); + expect(screen.getByText('دکتر ژیلا فتحی')).toBeInTheDocument(); + expect(screen.getByText('قطعی شده')).toBeInTheDocument(); // confirmed via STATUS_META + }); + + it('falls back to «—» when the doctor is missing', () => { + renderWithProviders(); + expect(screen.getByText('—')).toBeInTheDocument(); + }); + + it('prefers the service name as the title when present', () => { + renderWithProviders(); + expect(screen.getByText('لیزر')).toBeInTheDocument(); + }); +}); diff --git a/assets/admin/components/AppointmentTurnCard.tsx b/assets/admin/components/AppointmentTurnCard.tsx new file mode 100644 index 00000000..d8bfa328 --- /dev/null +++ b/assets/admin/components/AppointmentTurnCard.tsx @@ -0,0 +1,93 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { formatDate, formatTime } from '../lib/utils'; +import { + FilesServiceSuccess, FilesServiceMore, CalendarD, ClockP, UserD, StatusGlobe, +} from './icons/FilesServiceIcons'; +import AppointmentStatusDropdown from './ui/AppointmentStatusDropdown'; + +export interface AppointmentCardData { + uuid: string; + starts_at: number; + status: string; + version: number; + doctor_name?: string | null; + service_name?: string | null; +} + +/** + * label/value row inside the turn card — mirrors tauri ServiceInfoRow + * (separatedValues variant): icon+label on one side, value (optionally chip) on + * the other. + */ +function InfoRow({ icon, label, value, chip = false, valueStyle }: { + icon: ReactNode; label: string; value: ReactNode; chip?: boolean; valueStyle?: CSSProperties; +}) { + return ( +
+
+ {icon} + {label}: +
+ + {value} + +
+ ); +} + +/** + * A patient «نوبت» card — ported pixel-for-pixel from tauri + * files/services/TurnsCard. The status uses the admin's live + * AppointmentStatusDropdown (backed by PATCH /appointment/{uuid}/status) + * instead of the tauri mock. + */ +export default function AppointmentTurnCard({ appointment, queryKey }: { + appointment: AppointmentCardData; + queryKey: unknown[]; +}) { + const title = appointment.service_name || 'نوبت'; + return ( +
+ {/* Header */} +
+
+ +
+ {title} +
+
+ +
+ +
+ + {/* Middle: date & time chips */} +
+ } label="تاریخ" chip value={formatDate(appointment.starts_at)} /> + } label="ساعت" chip value={formatTime(appointment.starts_at)} /> +
+ +
+ + {/* Bottom: personnel & status */} +
+ } label="پرسنل" value={appointment.doctor_name || '—'} valueStyle={{ fontWeight: 500 }} /> + } + label="وضعیت" + value={} + /> +
+
+ ); +} diff --git a/assets/admin/components/icons/FilesServiceIcons.tsx b/assets/admin/components/icons/FilesServiceIcons.tsx index dcdc00c4..14ca16fa 100644 --- a/assets/admin/components/icons/FilesServiceIcons.tsx +++ b/assets/admin/components/icons/FilesServiceIcons.tsx @@ -192,3 +192,50 @@ export function TabBody({ color = '#616161', style }: IconProps) { ); } + +/* ── Turn card info-row icons (tauri CalendarD / ClockP / UserD / status) ───── */ + +export function CalendarD({ color = '#616161', size = 20, style }: IconProps & { size?: number }) { + return ( + + + + + + + + + + + + + ); +} + +export function ClockP({ color = '#525252', size = 20, style }: IconProps & { size?: number }) { + return ( + + + + + ); +} + +export function UserD({ color = '#616161', size = 20, style }: IconProps & { size?: number }) { + return ( + + + + + ); +} + +export function StatusGlobe({ color = '#616161', size = 20, style }: IconProps & { size?: number }) { + return ( + + + + + + ); +} diff --git a/assets/admin/pages/PatientDetailPage.test.tsx b/assets/admin/pages/PatientDetailPage.test.tsx index 178a12ae..e27cb88c 100644 --- a/assets/admin/pages/PatientDetailPage.test.tsx +++ b/assets/admin/pages/PatientDetailPage.test.tsx @@ -169,4 +169,34 @@ describe('PatientDetailPage (پرونده تب‌دار)', () => { expect(await screen.findByPlaceholderText('متن پیام...')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'ارسال' })).toBeInTheDocument(); }); + + it('renders appointment turn cards + toolbar on the نوبت‌ها tab', async () => { + get.mockImplementation((url: string) => { + if (url === '/api/v1/patient/r1') return Promise.resolve({ success: true, data: { + uuid: 'r1', user_name: 'ساغر صابری', record_number: 'P-1001', created_at: 1700000000, profile: null, + } }); + if (url === '/api/v1/patient/r1/appointments') return Promise.resolve({ success: true, data: [ + { uuid: 'a1', starts_at: 1754000000, ends_at: 1754001800, status: 'confirmed', version: 1, doctor_name: 'دکتر راد', service_name: null }, + ] }); + return Promise.resolve({ success: true, data: [] }); + }); + renderDetail(); + await loaded(); + fireEvent.click(screen.getByText('نوبت‌ها')); + // card: generic title + doctor + live status label (confirmed → قطعی شده) + expect(await screen.findByText('دکتر راد')).toBeInTheDocument(); + expect(screen.getByText('قطعی شده')).toBeInTheDocument(); + expect(screen.getByText('تاریخ:')).toBeInTheDocument(); + expect(screen.getByText('ساعت:')).toBeInTheDocument(); + // toolbar buttons link to the existing appointment pages + expect(screen.getByRole('link', { name: /نوبت رزرو/ })).toHaveAttribute('href', '/admin/appointments/reserve'); + expect(screen.getByRole('link', { name: /نوبت جدید/ })).toHaveAttribute('href', '/admin/appointments/new'); + }); + + it('shows the empty state on the نوبت‌ها tab when there are no appointments', async () => { + renderDetail(); + await loaded(); + fireEvent.click(screen.getByText('نوبت‌ها')); + expect(await screen.findByText('نوبتی ثبت نشده است')).toBeInTheDocument(); + }); }); diff --git a/assets/admin/pages/PatientDetailPage.tsx b/assets/admin/pages/PatientDetailPage.tsx index eeac6d5c..47bbf6a5 100644 --- a/assets/admin/pages/PatientDetailPage.tsx +++ b/assets/admin/pages/PatientDetailPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams, useSearchParams, Link } from 'react-router-dom'; import { @@ -20,7 +20,9 @@ import PersianDateInput from '../components/ui/PersianDateInput'; import PriceInput from '../components/ui/PriceInput'; import PatientCaseBanner, { Breadcrumb } from '../components/PatientCaseBanner'; import SessionServiceCard, { type SessionCardData } from '../components/SessionServiceCard'; +import AppointmentTurnCard, { type AppointmentCardData } from '../components/AppointmentTurnCard'; import InvoiceSummaryModal from '../components/InvoiceSummaryModal'; +import SearchableSelect from '../components/ui/SearchableSelect'; import { TurnsFilter, AddTurn } from '../components/icons/FilesToolbarIcons'; import { TabServices, TabInfo, TabCalendar, TabCard, TabWallet, TabSMS, TabCall, TabAttach, TabBody, @@ -231,8 +233,7 @@ export default function PatientDetailPage() { )}
) : tab === 'appointments' ? ( - ({ title: a.service_name || a.doctor_name || 'نوبت', meta: a.date ? formatDate(a.date) : (a.starts_at ? formatDate(a.starts_at) : ''), badge: a.status_label || a.status })} /> + ) : tab === 'payments' ? ( ({ title: formatRial(p.amount_rials), meta: [p.created_at ? formatDate(p.created_at) : '', p.gateway].filter(Boolean).join(' · '), badge: PAYMENT_STATUS[p.status] || p.status })} /> @@ -688,6 +689,73 @@ function WalletTab({ uuid }: { uuid: string }) { ); } +const APPT_SORT_OPTS = [ + { value: 'newest', label: 'جدیدترین' }, + { value: 'oldest', label: 'قدیمی‌ترین' }, + { value: 'reserved', label: 'رزرو شده' }, + { value: 'done', label: 'انجام شده' }, + { value: 'cancelled', label: 'لغو شده' }, +]; + +const CANCELLED_STATUSES = ['cancelled_by_doctor', 'cancelled_by_user', 'no_show', 'expired']; + +/** + * نوبت‌ها — the appointments tab, ported from tauri TurnsSection: a sort/filter + * toolbar + reserve/new buttons, over a grid of AppointmentTurnCard. Sorting and + * filtering are client-side over the already-fetched list (tauri leaves them inert). + */ +function AppointmentsTab({ uuid, q }: { + uuid: string; + q: { data?: ApiResponse; isLoading: boolean }; +}) { + const [sort, setSort] = useState('newest'); + const items = q.data?.data ?? []; + const queryKey = ['patient-appointments', uuid]; + + const shown = useMemo(() => { + let list = [...items]; + if (sort === 'reserved') list = list.filter((a) => a.status !== 'completed' && !CANCELLED_STATUSES.includes(a.status)); + else if (sort === 'done') list = list.filter((a) => a.status === 'completed'); + else if (sort === 'cancelled') list = list.filter((a) => CANCELLED_STATUSES.includes(a.status)); + list.sort((a, b) => (sort === 'oldest' ? a.starts_at - b.starts_at : b.starts_at - a.starts_at)); + return list; + }, [items, sort]); + + return ( +
+ {/* toolbar: sort + filter (right of RTL) · reserve/new buttons (left) */} +
+
+
+ setSort(String(v ?? 'newest'))} height={48} /> +
+ +
+
+ + نوبت رزرو + + + نوبت جدید + +
+
+ + {q.isLoading ? ( +
در حال بارگذاری...
+ ) : shown.length === 0 ? ( +
نوبتی ثبت نشده است
+ ) : ( +
+ {shown.map((a) => )} +
+ )} +
+ ); +} + function TabList({ q, emptyLabel, row }: { q: { data?: ApiResponse; isLoading: boolean }; emptyLabel: string; diff --git a/docs/api/patient.md b/docs/api/patient.md index 1f950f70..26e5d3a7 100644 --- a/docs/api/patient.md +++ b/docs/api/patient.md @@ -371,6 +371,7 @@ GET /api/v1/patient/{uuid}/appointments "starts_at": 1754000000, "ends_at": 1754001800, "status": "confirmed", + "version": 1, "doctor_name": "دکتر ژیلا فتحی", "service_name": null, "price_rials": null, @@ -380,7 +381,7 @@ GET /api/v1/patient/{uuid}/appointments } ``` -`status` یکی از: `pending`، `confirmed`، `completed`، `cancelled_by_doctor`، `cancelled_by_user`، `no_show`، `expired`. فیلدهای `service_name`/`price_rials` فعلاً همیشه `null` هستند (نوبت خدمت/قیمت مستقل ندارد). +`status` یکی از: `pending`، `confirmed`، `completed`، `cancelled_by_doctor`، `cancelled_by_user`، `no_show`، `expired`. فیلدهای `service_name`/`price_rials` فعلاً همیشه `null` هستند (نوبت خدمت/قیمت مستقل ندارد). `version` نسخهٔ خوش‌بینانهٔ (optimistic-lock) نوبت است و برای فراخوانی `PATCH /api/v1/appointment/{uuid}/status` لازم است. **Errors:** diff --git a/src/Patient/Controller/PatientController.php b/src/Patient/Controller/PatientController.php index 591efae4..daf96a62 100644 --- a/src/Patient/Controller/PatientController.php +++ b/src/Patient/Controller/PatientController.php @@ -830,6 +830,7 @@ class PatientController extends BaseController 'starts_at' => $a->getSlotStart(), 'ends_at' => $a->getSlotEnd(), 'status' => $a->getStatus(), + 'version' => $a->getVersion(), 'doctor_name' => $a->getDoctor()->getName(), 'service_name' => null, 'price_rials' => null, diff --git a/tests/Patient/PatientAppointmentsTest.php b/tests/Patient/PatientAppointmentsTest.php new file mode 100644 index 00000000..561095e9 --- /dev/null +++ b/tests/Patient/PatientAppointmentsTest.php @@ -0,0 +1,97 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر ژیلا فتحی'); + $this->em->persist($doctor); + $this->em->flush(); + + $patient = $this->createUser(['ROLE_USER']); + $record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId()); + $this->em->persist($record); + $this->em->flush(); + + return [$owner, $doctor, $record]; + } + + private function appointment(Doctor $doctor, PatientRecord $record, int $slotStart): Appointment + { + $appt = new Appointment($doctor, $record->getUser(), $slotStart, $slotStart + 1800); + $this->em->persist($appt); + $this->em->flush(); + + return $appt; + } + + public function testListReturnsShapeWithVersion(): void + { + [$owner, $doctor, $record] = $this->recordFor(); + $this->appointment($doctor, $record, 1_754_000_000); + + $list = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/appointments', $owner); + self::assertSame(200, $this->responseCode()); + self::assertCount(1, $list['data']); + + $row = $list['data'][0]; + foreach (['uuid', 'starts_at', 'ends_at', 'status', 'version', 'doctor_name'] as $key) { + self::assertArrayHasKey($key, $row); + } + self::assertSame(1, $row['version']); + self::assertSame('pending', $row['status']); + self::assertSame('دکتر ژیلا فتحی', $row['doctor_name']); + self::assertSame(1_754_000_000, $row['starts_at']); + } + + public function testSortedByStartDescending(): void + { + [$owner, $doctor, $record] = $this->recordFor(); + $this->appointment($doctor, $record, 1_754_000_000); + $this->appointment($doctor, $record, 1_755_000_000); + + $list = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/appointments', $owner); + self::assertCount(2, $list['data']); + self::assertSame(1_755_000_000, $list['data'][0]['starts_at']); + self::assertSame(1_754_000_000, $list['data'][1]['starts_at']); + } + + public function testEmptyWhenNoAppointments(): void + { + [$owner, , $record] = $this->recordFor(); + + $list = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/appointments', $owner); + self::assertSame(200, $this->responseCode()); + self::assertCount(0, $list['data']); + } + + public function testNotFoundForUnknownRecord(): void + { + [$owner] = $this->recordFor(); + $this->authJson('GET', '/api/v1/patient/00000000-0000-0000-0000-000000000000/appointments', $owner); + self::assertSame(404, $this->responseCode()); + } + + public function testOwnershipScoped(): void + { + [, $doctor, $record] = $this->recordFor(); + $this->appointment($doctor, $record, 1_754_000_000); + + [$other] = $this->recordFor(); + $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/appointments', $other); + self::assertSame(404, $this->responseCode()); + } +}