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 */}
+
+
+
+
+ {/* 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 ? (
+
نوبتی ثبت نشده است
+ ) : (
+
+ )}
+
+ );
+}
+
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());
+ }
+}