From f426a98ee6015124be3244f1b7b364551a712342 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 14 Jul 2026 00:07:00 +0330 Subject: [PATCH] =?UTF-8?q?feat(appointments):=20reserve=20list=20page=20(?= =?UTF-8?q?=D9=86=D9=88=D8=A8=D8=AA=20=D9=87=D8=A7=DB=8C=20=D8=B1=D8=B2?= =?UTF-8?q?=D8=B1=D9=88=20=D8=B4=D8=AF=D9=87)=20=E2=80=94=20phase=20D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReserveAppointmentsPage lists day-level reserve entries (/my/appointments?reserve=1, paginated) with the reserve-table.pdf columns (مراجعه کننده/شماره تماس/تاریخ/سرویس/پرسنل/وضعیت/عملیات). The row menu offers only the design's three actions — مشاهده (shared info modal), ویرایش (edit page) and انتقال به لیست نوبت ها (shared transfer modal flipping is_reserve back to a live slot). «نوبت رزرو» opens NewAppointmentDrawer in isReserve mode; clinics pick the doctor first (doctor-list), doctors book for themselves via dbUuid. Routed at /admin/appointments/reserve (before the :uuid route) and added to the sidebar under نوبت‌ها for the three management role blocks. Co-Authored-By: Claude Fable 5 --- assets/admin/App.tsx | 2 + assets/admin/components/layout/Sidebar.tsx | 15 ++ .../pages/ReserveAppointmentsPage.test.tsx | 64 ++++++ .../admin/pages/ReserveAppointmentsPage.tsx | 204 ++++++++++++++++++ 4 files changed, 285 insertions(+) create mode 100644 assets/admin/pages/ReserveAppointmentsPage.test.tsx create mode 100644 assets/admin/pages/ReserveAppointmentsPage.tsx diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 9b1acb28..ad5cce43 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -15,6 +15,7 @@ import ClinicDetailPage from './pages/ClinicDetailPage'; import AppointmentsPage from './pages/AppointmentsPage'; import AppointmentDetailPage from './pages/AppointmentDetailPage'; import AppointmentEditPage from './pages/AppointmentEditPage'; +import ReserveAppointmentsPage from './pages/ReserveAppointmentsPage'; import PaymentsPage from './pages/PaymentsPage'; import PaymentDetailPage from './pages/PaymentDetailPage'; import SettlementsPage from './pages/SettlementsPage'; @@ -152,6 +153,7 @@ export default function App() { {/* نوبت‌ها — همه نقش‌ها به‌جز نماینده */} } /> + } /> } /> } /> diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx index 524e1086..c8b1f8fa 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -89,6 +89,11 @@ function buildSections( icon: CalendarDaysIcon, label: "نوبت‌ها", }, + { + to: "/admin/appointments/reserve", + icon: CalendarDaysIcon, + label: "نوبت‌های رزرو", + }, { to: "/admin/payments", icon: CreditCardIcon, @@ -198,6 +203,11 @@ function buildSections( icon: CalendarDaysIcon, label: "نوبت‌ها", }, + { + to: "/admin/appointments/reserve", + icon: CalendarDaysIcon, + label: "نوبت‌های رزرو", + }, { to: "/admin/patients", icon: FolderOpenIcon, @@ -350,6 +360,11 @@ function buildSections( icon: CalendarDaysIcon, label: "نوبت‌ها", }, + { + to: "/admin/appointments/reserve", + icon: CalendarDaysIcon, + label: "نوبت‌های رزرو", + }, { to: "/admin/patients", icon: FolderOpenIcon, diff --git a/assets/admin/pages/ReserveAppointmentsPage.test.tsx b/assets/admin/pages/ReserveAppointmentsPage.test.tsx new file mode 100644 index 00000000..48fcfaf1 --- /dev/null +++ b/assets/admin/pages/ReserveAppointmentsPage.test.tsx @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent } 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 { useAuthStore } from '../stores/authStore'; +import ReserveAppointmentsPage from './ReserveAppointmentsPage'; + +const get = api.get as ReturnType; + +beforeEach(() => { + get.mockReset(); + useAuthStore.setState({ primaryRole: 'doctor', dbUuid: 'doc1' } as any); + get.mockImplementation((url: string) => { + if (url.startsWith('/api/v1/my/appointments?reserve=1')) return Promise.resolve({ + success: true, + data: [{ + uuid: 'r1', patient_name: 'دنیا خلیلی', patient_mobile: '09165401233', + doctor_uuid: 'doc1', doctor_name: 'دکتر احمدی', + slot_start: 1735639200, slot_end: 1735639200, + appointment_date: '2024-12-31', appointment_time: '00:00', end_time: '00:00', + status: 'pending', version: 1, created_at: '', is_reserve: true, + service_item: { uuid: 'i1', name: 'لیزر توتال' }, staff: { uuid: 's1', full_name: 'سحر ایمانی' }, + }], + meta: { totalRecords: 1, totalPages: 1, currentPage: 1 }, + }); + return Promise.resolve({ success: true, data: [] }); + }); +}); + +describe('ReserveAppointmentsPage (نوبت های رزرو شده)', () => { + it('lists reserve entries with the design columns', async () => { + renderWithProviders(); + expect(screen.getByText('نوبت های رزرو شده')).toBeInTheDocument(); + expect(await screen.findByText('دنیا خلیلی')).toBeInTheDocument(); + expect(screen.getByText('لیزر توتال')).toBeInTheDocument(); + expect(screen.getByText('سحر ایمانی')).toBeInTheDocument(); + expect(screen.getByText('تاریخ')).toBeInTheDocument(); + // fetched with the reserve flag + expect(get.mock.calls.some(([u]) => String(u).includes('reserve=1'))).toBe(true); + }); + + it('row menu offers only the three reserve actions', async () => { + renderWithProviders(); + await screen.findByText('دنیا خلیلی'); + fireEvent.click(screen.getByRole('button', { name: 'عملیات' })); + expect(screen.getByText('مشاهده')).toBeInTheDocument(); + expect(screen.getByText('ویرایش')).toBeInTheDocument(); + expect(screen.getByText('انتقال به لیست نوبت ها')).toBeInTheDocument(); + expect(screen.queryByText('جایگزینی نوبت')).toBeNull(); + }); + + it('opens the reserve create drawer from «نوبت رزرو»', async () => { + renderWithProviders(); + await screen.findByText('دنیا خلیلی'); + fireEvent.click(screen.getByRole('button', { name: /نوبت رزرو$/ })); + expect(await screen.findByText('اضافه کردن نوبت رزرو')).toBeInTheDocument(); + }); +}); diff --git a/assets/admin/pages/ReserveAppointmentsPage.tsx b/assets/admin/pages/ReserveAppointmentsPage.tsx new file mode 100644 index 00000000..d16838ee --- /dev/null +++ b/assets/admin/pages/ReserveAppointmentsPage.tsx @@ -0,0 +1,204 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import ReactDOM from 'react-dom'; +import { useNavigate } from 'react-router-dom'; +import { + PlusIcon, EllipsisHorizontalIcon, EyeIcon, PencilIcon, ArrowDownOnSquareIcon, + UserCircleIcon, PhoneIcon, +} from '@heroicons/react/24/outline'; +import { api } from '../lib/api'; +import type { PaginatedResponse } from '../lib/api'; +import type { Appointment } from '../types'; +import { formatDate } from '../lib/utils'; +import { useAuthStore } from '../stores/authStore'; +import AppointmentStatusDropdown from '../components/ui/AppointmentStatusDropdown'; +import Pagination from '../components/ui/Pagination'; +import NewAppointmentDrawer from '../components/NewAppointmentDrawer'; +import { AppointmentInfoModal, TransferReserveModal } from '../components/AppointmentActions'; + +const LIMIT = 20; + +/** Row menu for reserve entries — the design offers only مشاهده/ویرایش/انتقال. */ +function ReserveRowMenu({ appointment, queryKey }: { appointment: Appointment; queryKey: unknown[] }) { + const [open, setOpen] = useState(false); + const [modal, setModal] = useState(null); + const [pos, setPos] = useState<{ top: number; right: number } | null>(null); + const btnRef = useRef(null); + const menuRef = useRef(null); + const navigate = useNavigate(); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + const t = e.target as Node; + if (!btnRef.current?.contains(t) && !menuRef.current?.contains(t)) setOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open]); + + const items = [ + { label: 'مشاهده', icon: EyeIcon, onClick: () => { setOpen(false); setModal('info'); } }, + { label: 'ویرایش', icon: PencilIcon, onClick: () => { setOpen(false); navigate(`/admin/appointments/${appointment.uuid}/edit`); } }, + { label: 'انتقال به لیست نوبت ها', icon: ArrowDownOnSquareIcon, onClick: () => { setOpen(false); setModal('transfer'); } }, + ]; + + return ( + <> + + {open && pos && ReactDOM.createPortal( +
+ {items.map(({ label, icon: Icon, onClick }) => ( + + ))} +
, + document.body, + )} + {modal === 'info' && setModal(null)} />} + {modal === 'transfer' && setModal(null)} />} + + ); +} + +/** نوبت‌های رزرو شده (reserve-table.pdf) — day-level reserve entries with transfer back to the live list. */ +export default function ReserveAppointmentsPage() { + const [page, setPage] = useState(1); + const [drawerOpen, setDrawerOpen] = useState(false); + const primaryRole = useAuthStore(s => s.primaryRole); + const dbUuid = useAuthStore(s => s.dbUuid); + const isClinic = primaryRole === 'clinic'; + // the doctor the new reserve is booked for (doctors book for themselves) + const [doctorUuid, setDoctorUuid] = useState(primaryRole === 'doctor' && dbUuid ? dbUuid : ''); + + const clinicDoctorsQ = useQuery({ + queryKey: ['clinic-doctors', dbUuid], + queryFn: () => api.get(`/api/v1/clinic/doctor-list/${dbUuid}`), + enabled: isClinic && !!dbUuid, + }); + const clinicDoctors: { uuid: string; name: string }[] = clinicDoctorsQ.data?.data?.data ?? []; + + const queryKey = ['reserve-appointments', page]; + const { data, isLoading } = useQuery>({ + queryKey, + queryFn: () => api.get(`/api/v1/my/appointments?reserve=1&page=${page}&limit=${LIMIT}`), + }); + const items = data?.data ?? []; + const total = data?.meta?.totalRecords ?? 0; + + const today = new Date(); + const defaultDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; + + const th: React.CSSProperties = { padding: '10px 14px', textAlign: 'right', fontWeight: 600, color: 'var(--text-2)', whiteSpace: 'nowrap' }; + const td: React.CSSProperties = { padding: '10px 14px', textAlign: 'right', color: 'var(--text)', verticalAlign: 'middle' }; + + return ( +
+
+

نوبت های رزرو شده

+
+ {isClinic && ( + + )} + {primaryRole !== 'representation' && ( + + )} +
+
+ +
+ {isLoading ? ( +
در حال بارگذاری...
+ ) : items.length === 0 ? ( +
نوبت رزروی ثبت نشده است
+ ) : ( +
+ + + + + + + + + + + + + + + {items.map((a, i) => ( + + + + + + + + + + + ))} + +
ردیفمراجعه کنندهشماره تماستاریخسرویسپرسنلوضعیتعملیات
{((page - 1) * LIMIT + i + 1).toLocaleString('fa-IR')} +
+ + {a.patient_name || '—'} +
+
+
+ + {a.patient_mobile} +
+
{formatDate(a.slot_start)}{a.service_item?.name || '—'}{a.staff?.full_name || '—'} + +
+
+ )} +
+ + {total > LIMIT && ( +
+ +
+ )} + + {drawerOpen && ( + setDrawerOpen(false)} + isReserve + /> + )} +
+ ); +}