feat(appointments): reserve list page (نوبت های رزرو شده) — phase D
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 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ import ClinicDetailPage from './pages/ClinicDetailPage';
|
|||||||
import AppointmentsPage from './pages/AppointmentsPage';
|
import AppointmentsPage from './pages/AppointmentsPage';
|
||||||
import AppointmentDetailPage from './pages/AppointmentDetailPage';
|
import AppointmentDetailPage from './pages/AppointmentDetailPage';
|
||||||
import AppointmentEditPage from './pages/AppointmentEditPage';
|
import AppointmentEditPage from './pages/AppointmentEditPage';
|
||||||
|
import ReserveAppointmentsPage from './pages/ReserveAppointmentsPage';
|
||||||
import PaymentsPage from './pages/PaymentsPage';
|
import PaymentsPage from './pages/PaymentsPage';
|
||||||
import PaymentDetailPage from './pages/PaymentDetailPage';
|
import PaymentDetailPage from './pages/PaymentDetailPage';
|
||||||
import SettlementsPage from './pages/SettlementsPage';
|
import SettlementsPage from './pages/SettlementsPage';
|
||||||
@@ -152,6 +153,7 @@ export default function App() {
|
|||||||
|
|
||||||
{/* نوبتها — همه نقشها بهجز نماینده */}
|
{/* نوبتها — همه نقشها بهجز نماینده */}
|
||||||
<Route path="appointments" element={<RoleRoute roles={['admin', 'clinic', 'doctor', 'secretary']}><AppointmentsPage /></RoleRoute>} />
|
<Route path="appointments" element={<RoleRoute roles={['admin', 'clinic', 'doctor', 'secretary']}><AppointmentsPage /></RoleRoute>} />
|
||||||
|
<Route path="appointments/reserve" element={<RoleRoute roles={['admin', 'clinic', 'doctor', 'secretary']}><ReserveAppointmentsPage /></RoleRoute>} />
|
||||||
<Route path="appointments/:uuid" element={<RoleRoute roles={['admin', 'clinic', 'doctor', 'secretary']}><AppointmentDetailPage /></RoleRoute>} />
|
<Route path="appointments/:uuid" element={<RoleRoute roles={['admin', 'clinic', 'doctor', 'secretary']}><AppointmentDetailPage /></RoleRoute>} />
|
||||||
<Route path="appointments/:uuid/edit" element={<RoleRoute roles={['admin', 'clinic', 'doctor', 'secretary']}><AppointmentEditPage /></RoleRoute>} />
|
<Route path="appointments/:uuid/edit" element={<RoleRoute roles={['admin', 'clinic', 'doctor', 'secretary']}><AppointmentEditPage /></RoleRoute>} />
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,11 @@ function buildSections(
|
|||||||
icon: CalendarDaysIcon,
|
icon: CalendarDaysIcon,
|
||||||
label: "نوبتها",
|
label: "نوبتها",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: "/admin/appointments/reserve",
|
||||||
|
icon: CalendarDaysIcon,
|
||||||
|
label: "نوبتهای رزرو",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
to: "/admin/payments",
|
to: "/admin/payments",
|
||||||
icon: CreditCardIcon,
|
icon: CreditCardIcon,
|
||||||
@@ -198,6 +203,11 @@ function buildSections(
|
|||||||
icon: CalendarDaysIcon,
|
icon: CalendarDaysIcon,
|
||||||
label: "نوبتها",
|
label: "نوبتها",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: "/admin/appointments/reserve",
|
||||||
|
icon: CalendarDaysIcon,
|
||||||
|
label: "نوبتهای رزرو",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
to: "/admin/patients",
|
to: "/admin/patients",
|
||||||
icon: FolderOpenIcon,
|
icon: FolderOpenIcon,
|
||||||
@@ -350,6 +360,11 @@ function buildSections(
|
|||||||
icon: CalendarDaysIcon,
|
icon: CalendarDaysIcon,
|
||||||
label: "نوبتها",
|
label: "نوبتها",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: "/admin/appointments/reserve",
|
||||||
|
icon: CalendarDaysIcon,
|
||||||
|
label: "نوبتهای رزرو",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
to: "/admin/patients",
|
to: "/admin/patients",
|
||||||
icon: FolderOpenIcon,
|
icon: FolderOpenIcon,
|
||||||
|
|||||||
@@ -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<typeof vi.fn>;
|
||||||
|
|
||||||
|
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(<ReserveAppointmentsPage />);
|
||||||
|
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(<ReserveAppointmentsPage />);
|
||||||
|
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(<ReserveAppointmentsPage />);
|
||||||
|
await screen.findByText('دنیا خلیلی');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /نوبت رزرو$/ }));
|
||||||
|
expect(await screen.findByText('اضافه کردن نوبت رزرو')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 | 'info' | 'transfer'>(null);
|
||||||
|
const [pos, setPos] = useState<{ top: number; right: number } | null>(null);
|
||||||
|
const btnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<>
|
||||||
|
<button ref={btnRef} aria-label="عملیات" className="btn sm ghost" style={{ color: 'var(--primary)', gap: 4 }}
|
||||||
|
onClick={() => {
|
||||||
|
if (!open && btnRef.current) {
|
||||||
|
const r = btnRef.current.getBoundingClientRect();
|
||||||
|
setPos({ top: r.bottom + 4, right: window.innerWidth - r.right });
|
||||||
|
}
|
||||||
|
setOpen(o => !o);
|
||||||
|
}}>
|
||||||
|
<EllipsisHorizontalIcon style={{ width: 18 }} /> عملیات
|
||||||
|
</button>
|
||||||
|
{open && pos && ReactDOM.createPortal(
|
||||||
|
<div ref={menuRef} style={{
|
||||||
|
position: 'fixed', top: pos.top, right: pos.right, zIndex: 9000,
|
||||||
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--r)', boxShadow: 'var(--shadow-lg)', minWidth: 190, overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{items.map(({ label, icon: Icon, onClick }) => (
|
||||||
|
<button key={label} onClick={onClick} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '9px 12px',
|
||||||
|
fontSize: 13, background: 'transparent', border: 'none', cursor: 'pointer',
|
||||||
|
color: 'var(--text)', fontFamily: 'inherit', textAlign: 'right',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = 'var(--surface-2)')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}>
|
||||||
|
<Icon style={{ width: 15, color: 'var(--text-2)' }} /> {label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
{modal === 'info' && <AppointmentInfoModal appointment={appointment} queryKey={queryKey} onClose={() => setModal(null)} />}
|
||||||
|
{modal === 'transfer' && <TransferReserveModal appointment={appointment} queryKey={queryKey} onClose={() => 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<any>({
|
||||||
|
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<PaginatedResponse<Appointment>>({
|
||||||
|
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 (
|
||||||
|
<div className="fade-in" style={{ padding: '20px 24px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 10, marginBottom: 16 }}>
|
||||||
|
<h1 style={{ fontSize: 17, fontWeight: 800 }}>نوبت های رزرو شده</h1>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
{isClinic && (
|
||||||
|
<select aria-label="پزشک" value={doctorUuid} onChange={e => setDoctorUuid(e.target.value)}
|
||||||
|
style={{ height: 34, borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 13, fontFamily: 'inherit', padding: '0 10px' }}>
|
||||||
|
<option value="">انتخاب پزشک...</option>
|
||||||
|
{clinicDoctors.map(d => <option key={d.uuid} value={d.uuid}>{d.name}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{primaryRole !== 'representation' && (
|
||||||
|
<button className="btn primary sm" disabled={!doctorUuid}
|
||||||
|
onClick={() => setDrawerOpen(true)} style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||||
|
<PlusIcon style={{ width: 15 }} /> نوبت رزرو
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r)', overflow: 'hidden' }}>
|
||||||
|
{isLoading ? (
|
||||||
|
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>نوبت رزروی ثبت نشده است</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '1px solid var(--border)', background: 'var(--surface-2)' }}>
|
||||||
|
<th style={th}>ردیف</th>
|
||||||
|
<th style={th}>مراجعه کننده</th>
|
||||||
|
<th style={th}>شماره تماس</th>
|
||||||
|
<th style={th}>تاریخ</th>
|
||||||
|
<th style={th}>سرویس</th>
|
||||||
|
<th style={th}>پرسنل</th>
|
||||||
|
<th style={th}>وضعیت</th>
|
||||||
|
<th style={th}>عملیات</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((a, i) => (
|
||||||
|
<tr key={a.uuid} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<td style={td}>{((page - 1) * LIMIT + i + 1).toLocaleString('fa-IR')}</td>
|
||||||
|
<td style={td}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<UserCircleIcon style={{ width: 18, color: 'var(--text-3)' }} />
|
||||||
|
{a.patient_name || '—'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ ...td, direction: 'ltr', textAlign: 'right' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, justifyContent: 'flex-end' }}>
|
||||||
|
<PhoneIcon style={{ width: 14, color: 'var(--text-3)' }} />
|
||||||
|
{a.patient_mobile}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ ...td, fontWeight: 600 }}>{formatDate(a.slot_start)}</td>
|
||||||
|
<td style={td}>{a.service_item?.name || '—'}</td>
|
||||||
|
<td style={td}>{a.staff?.full_name || '—'}</td>
|
||||||
|
<td style={td}>
|
||||||
|
<AppointmentStatusDropdown uuid={a.uuid} currentStatus={a.status} version={a.version} queryKey={queryKey} />
|
||||||
|
</td>
|
||||||
|
<td style={td}><ReserveRowMenu appointment={a} queryKey={queryKey} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{total > LIMIT && (
|
||||||
|
<div style={{ marginTop: 14 }}>
|
||||||
|
<Pagination page={page} total={total} limit={LIMIT} onPageChange={setPage} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{drawerOpen && (
|
||||||
|
<NewAppointmentDrawer
|
||||||
|
doctorUuid={doctorUuid}
|
||||||
|
defaultDate={defaultDate}
|
||||||
|
queryKey={queryKey}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
isReserve
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user