From 684cf1f783441cc3cfb5711303bb3530dbd2b9ff Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Wed, 29 Jul 2026 21:04:40 +0330 Subject: [PATCH] feat: implement useUrlState hook for managing URL-based state in admin pages - Refactor multiple admin pages (BlogsPage, ClinicsPage, DoctorsPage, etc.) to utilize the new useUrlState hook for managing pagination, search, and filter states via URL. - Ensure that the state persists in the URL, allowing users to return to the same state when navigating back from detail pages. - Update relevant components to handle state changes appropriately and maintain clean URLs by removing default values. - Add SlotPicker component for selecting appointment slots based on availability. - Create tests for useUrlState to validate its functionality and ensure correct behavior when interacting with the URL. - Update API documentation to reflect changes in appointment creation and slot selection processes. --- CLAUDE.md | 15 ++ .../components/AppointmentActions.test.tsx | 40 ++++- .../admin/components/AppointmentActions.tsx | 39 ++++- .../components/appointments/SlotPicker.tsx | 98 ++++++++++++ assets/admin/hooks/useUrlState.test.tsx | 101 +++++++++++++ assets/admin/hooks/useUrlState.ts | 52 +++++++ .../pages/AppointmentCreatePage.test.tsx | 83 ++++++++++- assets/admin/pages/AppointmentCreatePage.tsx | 140 +++++++++++++++--- assets/admin/pages/BlogsPage.tsx | 12 +- assets/admin/pages/ClinicsPage.tsx | 12 +- assets/admin/pages/DoctorsPage.tsx | 36 +++-- assets/admin/pages/PatientDetailPage.test.tsx | 3 +- assets/admin/pages/PatientDetailPage.tsx | 3 +- assets/admin/pages/PatientsListPage.tsx | 42 +++++- assets/admin/pages/PaymentsPage.tsx | 12 +- assets/admin/pages/RepresentationsPage.tsx | 12 +- assets/admin/pages/SecretariesPage.tsx | 9 +- assets/admin/pages/SettlementsPage.tsx | 12 +- assets/admin/pages/UsersPage.tsx | 22 ++- docs/api/appointment.md | 4 + 20 files changed, 668 insertions(+), 79 deletions(-) create mode 100644 assets/admin/components/appointments/SlotPicker.tsx create mode 100644 assets/admin/hooks/useUrlState.test.tsx create mode 100644 assets/admin/hooks/useUrlState.ts diff --git a/CLAUDE.md b/CLAUDE.md index 54dddf9d..f3043b30 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -157,6 +157,21 @@ Feature composites (not generic) live one level up in `components/*.tsx`. (همان دکمهٔ صفحهٔ سرویس‌ها) و رفتارش در `hooks/useGoBack.ts` متمرکز است: یک قدم عقب در تاریخچهٔ پنل، و در ورود مستقیم/رفرش (`location.key === 'default'`) رفتن به `fallback`. +### وضعیت لیست‌ها در URL، نه در state + +جستجو، فیلترها، شمارهٔ صفحه و نمای هر صفحهٔ لیست باید در query string بنشیند — با +`hooks/useUrlState.ts`: + +```tsx +const [urlState, setUrlState] = useUrlState({ page: '1', search: '', status: '' }); +const page = pageOf(urlState.page); +const setSearch = (v: string) => setUrlState({ search: v, page: '1' }); +``` + +دلیلش «بازگشت» است: `navigate(-1)` همان URL قبلی را برمی‌گرداند، پس هرچه در URL باشد سرِ جایش +برمی‌گردد و هرچه در `useState` باشد با remount می‌پرد. سود جانبی: رفرش و اشتراک لینک همان نما را +می‌دهد. فیلدِ جستجوی debounce‌شده local می‌ماند و فقط مقدار نهایی به URL می‌رود. + --- ## Conventions diff --git a/assets/admin/components/AppointmentActions.test.tsx b/assets/admin/components/AppointmentActions.test.tsx index a9643fc5..e87cb09f 100644 --- a/assets/admin/components/AppointmentActions.test.tsx +++ b/assets/admin/components/AppointmentActions.test.tsx @@ -13,6 +13,13 @@ import type { Appointment } from '../types'; const get = api.get as ReturnType; const patch = api.patch as ReturnType; +const post = api.post as ReturnType; + +const navigate = vi.fn(); +vi.mock('react-router-dom', async () => ({ + ...(await vi.importActual('react-router-dom')), + useNavigate: () => navigate, +})); const appt: Appointment = { uuid: 'ap1', patient_name: 'مریم خلیلی', patient_mobile: '09136549874', @@ -28,11 +35,13 @@ const appt: Appointment = { beforeEach(() => { get.mockReset(); patch.mockReset(); get.mockImplementation((url: string) => { - if (url.startsWith('/api/v1/patient?search=')) return Promise.resolve({ success: true, data: [{ uuid: 'rec1' }] }); + if (url.startsWith('/api/v1/patients?search=')) return Promise.resolve({ success: true, data: [{ uuid: 'rec1' }] }); if (url === '/api/v1/patient/rec1/wallet') return Promise.resolve({ success: true, data: { balance_rials: 500000, recent_transactions: [] } }); return Promise.resolve({ success: true, data: [] }); }); patch.mockResolvedValue({ success: true, data: {} }); + post.mockReset(); + navigate.mockReset(); }); function openMenu() { @@ -48,6 +57,33 @@ describe('AppointmentActionsMenu (عملیات نوبت)', () => { } }); + it('«ثبت سرویس» پروندهٔ موجود بیمار را باز می‌کند', async () => { + openMenu(); + fireEvent.click(screen.getByText('ثبت سرویس')); + + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/admin/patients/rec1/session/new')); + // مسیر جستجو باید همان endpoint واقعی باشد، وگرنه ۴۰۴ می‌گیرد و «خطا در یافتن پرونده» می‌دهد. + expect(get).toHaveBeenCalledWith(expect.stringContaining('/api/v1/patients?search=')); + expect(post).not.toHaveBeenCalled(); + }); + + it('«ثبت سرویس» برای بیمارِ بدون پرونده، اول پرونده می‌سازد', async () => { + get.mockImplementation((url: string) => { + if (url.startsWith('/api/v1/patients?search=')) return Promise.resolve({ success: true, data: [] }); + return Promise.resolve({ success: true, data: [] }); + }); + post.mockResolvedValue({ success: true, data: { uuid: 'rec-new' } }); + + openMenu(); + fireEvent.click(screen.getByText('ثبت سرویس')); + + await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/patient', expect.objectContaining({ + mobile: '09136549874', + name: 'مریم خلیلی', + }))); + expect(navigate).toHaveBeenCalledWith('/admin/patients/rec-new/session/new'); + }); + it('info modal shows appointment details and patient wallet balance', async () => { openMenu(); fireEvent.click(screen.getByText('مشاهده')); @@ -107,7 +143,7 @@ describe('AppointmentActionsMenu (عملیات نوبت)', () => { it('replace modal picks an existing patient from the record search', async () => { get.mockImplementation((url: string) => { - if (url.startsWith('/api/v1/patient?search=')) return Promise.resolve({ success: true, data: [ + if (url.startsWith('/api/v1/patients?search=')) return Promise.resolve({ success: true, data: [ { uuid: 'rec9', user_name: 'پریسا همتی', user_mobile: '09120009999' }, ] }); return Promise.resolve({ success: true, data: [] }); diff --git a/assets/admin/components/AppointmentActions.tsx b/assets/admin/components/AppointmentActions.tsx index 67030854..b600fdd5 100644 --- a/assets/admin/components/AppointmentActions.tsx +++ b/assets/admin/components/AppointmentActions.tsx @@ -39,11 +39,38 @@ const toEpoch = (isoDate: string, time: string) => tehranWallClockToUnix(isoDate */ export async function findRecordUuid(mobile: string): Promise { const res: any = await api.get( - `/api/v1/patient?search=${encodeURIComponent(mobile)}&limit=1`, + `/api/v1/patients?search=${encodeURIComponent(mobile)}&limit=1`, ); return res?.data?.[0]?.uuid ?? null; } +/** + * پروندهٔ بیمارِ یک نوبت — اگر هنوز ساخته نشده باشد، از روی خودِ نوبت ساخته می‌شود. + * «ثبت سرویس» روی نوبتی که صاحبش هنوز پرونده ندارد نباید بن‌بست باشد؛ ساخت پرونده + * روی سرور idempotent است و پروندهٔ موجود را برمی‌گرداند. + */ +export async function ensureRecordUuid(appointment: { + patient_mobile?: string | null; + patient_name?: string | null; + patient_national_code?: string | null; +}): Promise { + const mobile = (appointment.patient_mobile ?? "").trim(); + if (mobile === "") return null; + + const found = await findRecordUuid(mobile); + if (found) return found; + + const res: any = await api.post("/api/v1/patient", { + mobile, + name: (appointment.patient_name ?? "").trim(), + ...(appointment.patient_national_code + ? { national_code: appointment.patient_national_code } + : {}), + }); + + return res?.data?.uuid ?? null; +} + /** * «شارژ کیف پول» accent link (appointment create/edit forms) — deep-links the * patient's wallet tab, where the manual top-up modal lives. @@ -120,14 +147,14 @@ export default function AppointmentActionsMenu({ const goToServiceRegistration = async () => { setOpen(false); try { - const recordUuid = await findRecordUuid(appointment.patient_mobile); + const recordUuid = await ensureRecordUuid(appointment); if (!recordUuid) { - toast.error("پرونده‌ای برای این بیمار یافت نشد"); + toast.error("این نوبت شماره تماس ندارد؛ ابتدا نوبت را ویرایش کنید"); return; } navigate(`/admin/patients/${recordUuid}/session/new`); - } catch { - toast.error("خطا در یافتن پرونده بیمار"); + } catch (e: any) { + toast.error(e?.message || "خطا در یافتن پرونده بیمار"); } }; @@ -672,7 +699,7 @@ export function ReplaceAppointmentModal({ queryKey: ["replace-patients", patientSearch], queryFn: () => api.get( - `/api/v1/patient?search=${encodeURIComponent(patientSearch)}&limit=10`, + `/api/v1/patients?search=${encodeURIComponent(patientSearch)}&limit=10`, ), enabled: patientSearch.trim().length >= 2, }); diff --git a/assets/admin/components/appointments/SlotPicker.tsx b/assets/admin/components/appointments/SlotPicker.tsx new file mode 100644 index 00000000..17651b77 --- /dev/null +++ b/assets/admin/components/appointments/SlotPicker.tsx @@ -0,0 +1,98 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '../../lib/api'; +import type { ApiResponse } from '../../lib/api'; +import { useClinicContext } from '../../hooks/useClinicContext'; + +interface Slot { + start: number; + end: number; + start_time: string; + end_time: string; + is_available: boolean; +} +interface Session { start_time: string; end_time: string; slots: Slot[] } + +export interface PickedSlot { start: number; end: number; start_time: string } + +/** + * انتخاب نوبت در حالت نوبت‌دهی **اسلاتی**: همان اسلات‌های واقعیِ برنامهٔ کاری پزشک + * (`appointment-slots`) که تایم‌لاین هم نشان می‌دهد — نه ساعتِ دستی. اسلات پرشده + * غیرقابل انتخاب است، پس ثبت نوبت روی زمان اشغال یا خارج از برنامه ممکن نیست. + */ +export default function SlotPicker({ + doctorUuid, date, value, onSelect, clinicUuidOverride, +}: { + doctorUuid: string; + date: string; + value: PickedSlot | null; + onSelect: (slot: PickedSlot | null) => void; + /** undefined = محیط جاری؛ مقدار صریح (شامل null) = محل انتخاب‌شده خارج از context */ + clinicUuidOverride?: string | null; +}) { + const contextClinicUuid = useClinicContext(); + const clinicUuid = clinicUuidOverride === undefined ? contextClinicUuid : clinicUuidOverride; + + const q = useQuery>({ + queryKey: ['appt-slot-picker', doctorUuid, date, clinicUuid], + queryFn: () => api.get( + `/api/v1/appointment-slots?doctor_uuid=${doctorUuid}&date=${date}&management=1` + + (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : ''), + ), + enabled: !!doctorUuid && !!date, + }); + + const data = q.data?.data as any; + const sessions: Session[] = data?.sessions ?? []; + const emptyReason = data?.empty_reason; + + if (q.isLoading) { + return
در حال دریافت زمان‌های خالی...
; + } + + if (sessions.length === 0) { + return ( +
+ {emptyReason?.title ?? 'برای این روز برنامهٔ کاری تعریف نشده است'} + {emptyReason?.hint ? — {emptyReason.hint} : null} +
+ ); + } + + return ( +
+ {sessions.map((s, i) => ( +
+
+ {s.start_time} — {s.end_time} +
+
+ {s.slots.map((slot) => { + const active = value?.start === slot.start; + return ( + + ); + })} +
+
+ ))} +
+ ); +} diff --git a/assets/admin/hooks/useUrlState.test.tsx b/assets/admin/hooks/useUrlState.test.tsx new file mode 100644 index 00000000..fb42a45c --- /dev/null +++ b/assets/admin/hooks/useUrlState.test.tsx @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { useUrlState, pageOf } from './useUrlState'; +import BackButton from '../components/ui/BackButton'; + +/** + * قرارداد: وضعیت لیست در URL است، پس «بازگشت» (که همان URL قبلی را برمی‌گرداند) + * فیلتر و صفحه را هم برمی‌گرداند. + */ +function ListPage() { + const [state, setState] = useUrlState({ page: '1', search: '', status: '' }); + const location = useLocation(); + + return ( +
+ {location.search} + {`${state.page}|${state.search}|${state.status}`} + + + + + { e.preventDefault(); window.history.pushState({}, '', '/detail'); }}>x +
+ ); +} + +function DetailPage() { + return ( + <> + جزئیات + + + ); +} + +function renderApp(entries: string[] = ['/list']) { + return render( + + + } /> + } /> + + , + ); +} + +describe('useUrlState', () => { + it('مقدار اولیه را از URL می‌خواند', () => { + renderApp(['/list?page=2&search=علی&status=paid']); + expect(screen.getByTestId('state')).toHaveTextContent('2|علی|paid'); + }); + + it('تغییر فیلتر در query string می‌نشیند و صفحه را یک می‌کند', () => { + renderApp(['/list?page=4']); + fireEvent.click(screen.getByText('فیلتر')); + + expect(screen.getByTestId('qs').textContent).toContain('status=paid'); + expect(screen.getByTestId('state')).toHaveTextContent('1|'); + }); + + it('مقدارِ برابرِ پیش‌فرض از URL حذف می‌شود', () => { + renderApp(['/list?page=3']); + fireEvent.click(screen.getByText('صفحه ۳')); + expect(screen.getByTestId('qs').textContent).toContain('page=3'); + + fireEvent.click(screen.getByText('فیلتر')); // page → '1' = پیش‌فرض + expect(screen.getByTestId('qs').textContent).not.toContain('page='); + }); + + it('دو تغییرِ پشت‌سرهم در یک handler همدیگر را پاک نمی‌کنند', () => { + renderApp(['/list']); + fireEvent.click(screen.getByText('دو تغییر پشت‌سرهم')); + + // مودالِ فیلتر دقیقاً همین کار را می‌کند: setFilters(...) و بعد setPage(1). + expect(screen.getByTestId('state')).toHaveTextContent('2||paid'); + }); + + it('بازگشت از صفحهٔ جزئیات، فیلتر و صفحهٔ لیست را دست‌نخورده برمی‌گرداند', () => { + render( + + + } /> + } /> + + , + ); + + expect(screen.getByText('جزئیات')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /بازگشت/ })); + + expect(screen.getByTestId('state')).toHaveTextContent('3|مریم|paid'); + }); + + it('pageOf مقدار نامعتبر را به ۱ می‌برد', () => { + expect(pageOf('')).toBe(1); + expect(pageOf('0')).toBe(1); + expect(pageOf('abc')).toBe(1); + expect(pageOf('7')).toBe(7); + }); +}); diff --git a/assets/admin/hooks/useUrlState.ts b/assets/admin/hooks/useUrlState.ts new file mode 100644 index 00000000..926b584a --- /dev/null +++ b/assets/admin/hooks/useUrlState.ts @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +/** + * وضعیت لیست (جستجو، فیلترها، صفحه، تب) در query string زندگی می‌کند، نه در state + * کامپوننت. دلیلش «بازگشت»‌ است: `navigate(-1)` همان URL قبلی را برمی‌گرداند، پس هر + * چیزی که در URL باشد سرِ جایش برمی‌گردد و هر چیزی که در state باشد با remount می‌پرد. + * سود جانبی: رفرش و اشتراک لینک هم همان نما را می‌دهد. + * + * مقدارِ برابرِ پیش‌فرض از URL حذف می‌شود تا آدرس تمیز بماند. + */ +export function useUrlState>(defaults: T) { + const [params, setParams] = useSearchParams(); + + const state = useMemo(() => { + const out = { ...defaults }; + (Object.keys(defaults) as (keyof T)[]).forEach((k) => { + const raw = params.get(String(k)); + if (raw !== null) out[k] = raw as T[keyof T]; + }); + return out; + }, [params, defaults]); + + // `setSearchParams` مثل setState صف نمی‌شود؛ دو فراخوانی در یک handler، اگر هر دو از + // `params` فعلی شروع کنند، همدیگر را پاک می‌کنند. این ref آخرین مقدارِ نوشته‌شده را + // نگه می‌دارد تا فراخوانی‌های پشت‌سرهم روی هم انباشته شوند. + const latest = useRef(params); + useEffect(() => { latest.current = params; }, [params]); + + /** + * چند کلید را با هم می‌نویسد. `replace` پیش‌فرض است تا تایپ در فیلد جستجو + * تاریخچه را پر نکند و یک بار «بازگشت» کاربر را از صفحه بیرون ببرد. + */ + const setState = useCallback((patch: Partial, opts?: { push?: boolean }) => { + const next = new URLSearchParams(latest.current); + Object.entries(patch).forEach(([k, v]) => { + const value = v === undefined ? '' : String(v); + if (value === '' || value === defaults[k as keyof T]) next.delete(k); + else next.set(k, value); + }); + + latest.current = next; + setParams(next, { replace: !opts?.push }); + }, [setParams, defaults]); + + return [state, setState] as const; +} + +/** عدد صفحه از URL — همیشه ≥ ۱. */ +export function pageOf(value: string): number { + return Math.max(1, Number(value) || 1); +} diff --git a/assets/admin/pages/AppointmentCreatePage.test.tsx b/assets/admin/pages/AppointmentCreatePage.test.tsx index 79fc7d1d..c9095c81 100644 --- a/assets/admin/pages/AppointmentCreatePage.test.tsx +++ b/assets/admin/pages/AppointmentCreatePage.test.tsx @@ -18,10 +18,35 @@ beforeEach(() => { get.mockReset(); post.mockReset(); useAuthStore.setState({ primaryRole: 'doctor', dbUuid: 'doc1' } as any); - get.mockResolvedValue({ success: true, data: [] }); + get.mockImplementation((url: string) => ( + url.startsWith('/api/v1/appointment-slots') + ? Promise.resolve(SLOTS_RESPONSE) + : Promise.resolve({ success: true, data: [] }) + )); post.mockResolvedValue({ success: true, data: { uuid: 'new1' } }); }); + +/** اسلات‌های واقعیِ برنامهٔ پزشک — حالت اسلاتی دیگر ساعت دستی نمی‌گیرد. */ +const SLOTS_RESPONSE = { + success: true, + data: { + sessions: [{ + start_time: '15:00', end_time: '16:00', + slots: [ + { start: 1754000000, end: 1754001800, start_time: '15:00', end_time: '15:30', is_available: true }, + { start: 1754001800, end: 1754003600, start_time: '15:30', end_time: '16:00', is_available: false }, + ], + }], + empty_reason: null, + }, +}; + +/** انتخاب اولین اسلات آزاد (حالت اسلاتی). */ +async function pickSlot() { + fireEvent.click(await screen.findByRole('button', { name: '15:00' })); +} + describe('AppointmentCreatePage — افزودن نوبت', () => { it('posts a new appointment with the entered patient (happy path)', async () => { renderWithProviders(, { route: '/admin/appointments/new' }); @@ -31,6 +56,7 @@ describe('AppointmentCreatePage — افزودن نوبت', () => { fireEvent.change(screen.getByPlaceholderText('شماره تماس مراجعه کننده'), { target: { value: '09121234567' } }); fireEvent.change(screen.getByPlaceholderText('کد ملی مراجعه کننده'), { target: { value: '1234567891' } }); + await pickSlot(); fireEvent.click(screen.getByText('ثبت اطلاعات')); await waitFor(() => expect(post).toHaveBeenCalled()); @@ -45,6 +71,7 @@ describe('AppointmentCreatePage — افزودن نوبت', () => { return Promise.resolve({ success: true, data: { booking_mode: 'service', buffer_minutes: 5, services: [{ uuid: 'sv1', name: 'عصب‌کشی', duration_minutes: 30, price_rials: 500000, service_section: { uuid: 'sec1', name: 'دندان' } }] } }); if (url.startsWith('/api/v1/appointment-service-slots')) return Promise.resolve({ success: true, data: { total_duration_minutes: 30, buffer_minutes: 5, start_times: [{ start: 1754000000, end: 1754001800, start_time: '15:00' }] } }); + if (url.startsWith('/api/v1/appointment-slots')) return Promise.resolve(SLOTS_RESPONSE); return Promise.resolve({ success: true, data: [] }); }); @@ -55,8 +82,10 @@ describe('AppointmentCreatePage — افزودن نوبت', () => { fireEvent.change(screen.getByPlaceholderText('شماره تماس مراجعه کننده'), { target: { value: '09121234567' } }); fireEvent.change(screen.getByPlaceholderText('کد ملی مراجعه کننده'), { target: { value: '1234567891' } }); + // صبر تا حالت سرویسی واقعاً رندر شود (پیش از آن، حالت اسلاتی است). + await waitFor(() => expect(document.getElementById('service-mode-section-select')).not.toBeNull()); // حالت سرویس: ورودی ساعت شروع نباید باشد - await waitFor(() => expect(screen.queryByLabelText('ساعت شروع')).toBeNull()); + expect(screen.queryByLabelText('ساعت شروع')).toBeNull(); // حالت سرویسی هم «بخش → سرویس» است: ابتدا بخش، سپس سرویس const secInput = document.getElementById('service-mode-section-select') as HTMLInputElement; @@ -72,6 +101,48 @@ describe('AppointmentCreatePage — افزودن نوبت', () => { expect(body).toMatchObject({ doctor_uuid: 'doc1', slot_start: 1754000000, slot_end: 1754001800, service_item_uuids: ['sv1'] }); }); + it('حالت اسلاتی: اسلات پرشده قابل انتخاب نیست', async () => { + renderWithProviders(, { route: '/admin/appointments/new' }); + + const taken = await screen.findByRole('button', { name: '15:30' }); + expect(taken).toBeDisabled(); + expect(await screen.findByRole('button', { name: '15:00' })).not.toBeDisabled(); + }); + + it('نوبتِ ساخته‌شده بلافاصله قطعی می‌شود', async () => { + renderWithProviders(, { route: '/admin/appointments/new' }); + + fireEvent.click(screen.getByText('مراجعه کننده جدید')); + fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی مراجعه کننده'), { target: { value: 'علی محمدی' } }); + fireEvent.change(screen.getByPlaceholderText('شماره تماس مراجعه کننده'), { target: { value: '09121234567' } }); + fireEvent.change(screen.getByPlaceholderText('کد ملی مراجعه کننده'), { target: { value: '1234567891' } }); + await pickSlot(); + fireEvent.click(screen.getByText('ثبت اطلاعات')); + + await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/appointment/new1/confirm', { payments: [] })); + }); + + it('ورود از پروندهٔ بیمار: مراجعه‌کننده پیش‌پر می‌شود و جستجو نمایش داده نمی‌شود', async () => { + get.mockImplementation((url: string) => { + if (url === '/api/v1/patient/rec7') + return Promise.resolve({ success: true, data: { uuid: 'rec7', user_name: 'مریم خلیلی', user_mobile: '09136549874', user_national_code: '0012345675' } }); + if (url.startsWith('/api/v1/appointment-slots')) return Promise.resolve(SLOTS_RESPONSE); + return Promise.resolve({ success: true, data: [] }); + }); + + renderWithProviders(, { route: '/admin/appointments/new?record=rec7' }); + + expect(await screen.findByText('مریم خلیلی')).toBeInTheDocument(); + expect(screen.queryByPlaceholderText('جستجوی نام، شماره تماس، شماره پرونده...')).toBeNull(); + expect(screen.queryByText('مراجعه کننده جدید')).toBeNull(); + + await pickSlot(); + fireEvent.click(screen.getByText('ثبت اطلاعات')); + await waitFor(() => expect(post).toHaveBeenCalled()); + const [, body] = post.mock.calls[0]; + expect(body).toMatchObject({ patient_name: 'مریم خلیلی', patient_mobile: '09136549874', patient_national_code: '0012345675' }); + }); + it('keeps the submit button disabled until a valid patient is entered (boundary)', () => { renderWithProviders(, { route: '/admin/appointments/new' }); const btn = screen.getByText('ثبت اطلاعات') as HTMLButtonElement; @@ -96,6 +167,7 @@ describe('AppointmentCreatePage — افزودن نوبت', () => { get.mockImplementation((url: string) => { if (url.startsWith('/api/v1/patients?')) return Promise.resolve({ success: true, data: [{ uuid: 'rec1', user_name: 'حامد حسینی', user_mobile: '09210671745', user_national_code: '0012345675' }] }); + if (url.startsWith('/api/v1/appointment-slots')) return Promise.resolve(SLOTS_RESPONSE); return Promise.resolve({ success: true, data: [] }); }); @@ -104,6 +176,7 @@ describe('AppointmentCreatePage — افزودن نوبت', () => { fireEvent.change(screen.getByPlaceholderText('جستجوی نام، شماره تماس، شماره پرونده...'), { target: { value: 'حامد' } }); fireEvent.click(await screen.findByText(/حامد حسینی/)); + await pickSlot(); const btn = screen.getByText('ثبت اطلاعات') as HTMLButtonElement; await waitFor(() => expect(btn).not.toBeDisabled()); @@ -121,6 +194,7 @@ describe('AppointmentCreatePage — افزودن نوبت', () => { return Promise.resolve({ success: true, data: [{ uuid: 'i1', name: 'لیزر' }, { uuid: 'i2', name: 'ماساژ' }] }); if (url === '/api/v1/service-items/sec2') return Promise.resolve({ success: true, data: [{ uuid: 'i3', name: 'پاکسازی' }] }); + if (url.startsWith('/api/v1/appointment-slots')) return Promise.resolve(SLOTS_RESPONSE); return Promise.resolve({ success: true, data: [] }); }); @@ -153,6 +227,7 @@ describe('AppointmentCreatePage — افزودن نوبت', () => { fireEvent.click(screen.getByLabelText('حذف ماساژ')); expect(screen.getByText('سرویس‌های انتخاب‌شده (2)')).toBeInTheDocument(); + await pickSlot(); fireEvent.click(screen.getByText('ثبت اطلاعات')); await waitFor(() => expect(post).toHaveBeenCalled()); const [, body] = post.mock.calls[0]; @@ -164,6 +239,7 @@ describe('AppointmentCreatePage — افزودن نوبت', () => { get.mockImplementation((url: string) => { if (url.startsWith('/api/v1/patients?')) return Promise.resolve({ success: true, data: [{ uuid: 'rec2', user_name: 'حامد حسینی', user_mobile: '09210671745', user_national_code: null }] }); + if (url.startsWith('/api/v1/appointment-slots')) return Promise.resolve(SLOTS_RESPONSE); return Promise.resolve({ success: true, data: [] }); }); @@ -175,8 +251,9 @@ describe('AppointmentCreatePage — افزودن نوبت', () => { const btn = screen.getByText('ثبت اطلاعات') as HTMLButtonElement; expect(btn).toBeDisabled(); - // کد ملیِ خالیِ رکورد را دستی وارد کن → فعال می‌شود + // کد ملیِ خالیِ رکورد را دستی وارد کن + انتخاب اسلات → فعال می‌شود fireEvent.change(screen.getByPlaceholderText('کد ملی مراجعه کننده'), { target: { value: '0012345675' } }); + await pickSlot(); await waitFor(() => expect(btn).not.toBeDisabled()); }); }); diff --git a/assets/admin/pages/AppointmentCreatePage.tsx b/assets/admin/pages/AppointmentCreatePage.tsx index 292e6b1b..498a6502 100644 --- a/assets/admin/pages/AppointmentCreatePage.tsx +++ b/assets/admin/pages/AppointmentCreatePage.tsx @@ -13,6 +13,7 @@ import SearchableSelect from '../components/ui/SearchableSelect'; import { WalletChargeLink } from '../components/AppointmentActions'; import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices'; import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker'; +import SlotPicker, { type PickedSlot } from '../components/appointments/SlotPicker'; import { tehranWallClockToUnix, rialToToman, tomanToRial } from '../lib/utils'; import BackButton from '../components/ui/BackButton'; import { digitsOnly, todayIso } from '../lib/utils'; @@ -69,6 +70,26 @@ export default function AppointmentCreatePage() { }); const patients = useMemo(() => patientsQ.data?.data ?? [], [patientsQ.data]); + // ورود از تب «نوبت‌ها»ی پروندهٔ بیمار: مراجعه‌کننده از قبل معلوم است، پس به‌جای + // جستجو، خودِ پرونده خوانده و قفل می‌شود. + const fromRecordUuid = params.get('record') ?? ''; + const recordQ = useQuery>({ + queryKey: ['create-appt-record', fromRecordUuid], + queryFn: () => api.get(`/api/v1/patient/${fromRecordUuid}`), + enabled: !!fromRecordUuid, + }); + useEffect(() => { + const rec: any = (recordQ.data?.data as any)?.data ?? recordQ.data?.data; + if (!rec) return; + setPicked({ + uuid: rec.uuid, + user_name: rec.user_name, + user_mobile: rec.user_mobile, + user_national_code: rec.user_national_code ?? rec.profile?.national_code, + }); + setNationalCode(String(rec.user_national_code ?? rec.profile?.national_code ?? '').replace(/\D/g, '').slice(0, 10)); + }, [recordQ.data]); + // ── مشخصات سرویس const [sectionUuid, setSectionUuid] = useState(''); // بخشِ در حال مرور (برای دیدن سرویس‌هایش) // سرویس‌های انتخاب‌شده — انباشته از چند بخش؛ هر کدام قابل حذف. نام را نگه می‌داریم تا در chip نشان دهیم. @@ -95,10 +116,16 @@ export default function AppointmentCreatePage() { // ── زمان نوبت const [date, setDate] = useState(params.get('date') || today); + // حالت اسلاتی: انتخاب از اسلات‌های واقعیِ برنامهٔ پزشک. ورود دستیِ ساعت فقط وقتی + // باقی می‌ماند که پزشک برای آن روز اصلاً برنامه‌ای نداشته باشد (نوبت خارج از برنامه). + const [slotPick, setSlotPick] = useState(null); + const [manualTime, setManualTime] = useState(false); const [duration, setDuration] = useState(40); const [start, setStart] = useState('15:00'); const [end, setEnd] = useState(addMinutes('15:00', 40)); useEffect(() => { setEnd(addMinutes(start, duration)); }, [start, duration]); + // تعویض پزشک/تاریخ ⇒ اسلات انتخاب‌شده دیگر معتبر نیست. + useEffect(() => { setSlotPick(null); }, [doctorUuid, date]); // ── بیعانه / وضعیت / توضیحات const [depositRequired, setDepositRequired] = useState(false); @@ -125,17 +152,20 @@ export default function AppointmentCreatePage() { const effectiveNationalCode = (picked?.user_national_code || nationalCode).replace(/\D/g, ''); const timingValid = serviceMode ? (servicePick.serviceUuids.length > 0 && !!servicePick.slot) - : (!!start && !!end); + : (manualTime ? (!!start && !!end) : !!slotPick); const valid = !!doctorUuid && !!date && effectiveName.length >= 2 && effectiveMobile.length >= 10 && effectiveNationalCode.length === 10 && timingValid && (!requireVisit || visitPriceToman > 0); const create = useMutation({ mutationFn: async () => { const createEndpoint = primaryRole === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/my/appointment'; + const slotStart = serviceMode ? servicePick.slot!.start : (slotPick ? slotPick.start : toEpoch(date, start)); + const slotEnd = serviceMode ? servicePick.slot!.end : (slotPick ? slotPick.end : toEpoch(date, end)); + const payload: Record = { doctor_uuid: doctorUuid, - slot_start: serviceMode ? servicePick.slot!.start : toEpoch(date, start), - slot_end: serviceMode ? servicePick.slot!.end : toEpoch(date, end), + slot_start: slotStart, + slot_end: slotEnd, patient_name: effectiveName, patient_mobile: effectiveMobile, patient_national_code: effectiveNationalCode, @@ -150,13 +180,25 @@ export default function AppointmentCreatePage() { ...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}), ...(note.trim() ? { note: note.trim() } : {}), }; - // نوبت همیشه «ثبت شده» متولد می‌شود؛ قطعی‌کردن یک عملِ جداست که هزینه‌ها را - // نشان می‌دهد و پرداخت می‌گیرد (مودال «قطعی کردن نوبت»). - return api.post(createEndpoint, payload); + // سرور نوبت را «ثبت‌شده» متولد می‌کند؛ این صفحه اما نوبتِ **قطعی** می‌سازد، پس + // بلافاصله همان endpoint قطعی‌کردن صدا زده می‌شود (بدون پرداخت — پرداخت از + // مودال «قطعی کردن» یا صفحهٔ پرداخت انجام می‌شود). + const created: any = await api.post(createEndpoint, payload); + const uuid = created?.data?.uuid ?? created?.data?.data?.uuid; + if (!uuid) return created; + + try { + await api.post(`/api/v1/appointment/${uuid}/confirm`, { payments: [] }); + } catch (e: any) { + // نوبت ثبت شده و اسلات را گرفته؛ فقط قطعی نشده. کاربر باید بداند. + toast.error(e?.message || 'نوبت ثبت شد ولی قطعی نشد؛ از لیست نوبت‌ها قطعی کنید'); + } + + return created; }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['appointments'] }); - toast.success('نوبت با موفقیت ثبت شد'); + toast.success('نوبت قطعی ثبت شد'); // به همان روزِ نوبت برگرد، نه امروز. navigate(`/admin/appointments?date=${date}`); }, @@ -195,6 +237,30 @@ export default function AppointmentCreatePage() { {/* مراجعه کننده */}
اطلاعات مراجعه کننده :
+ {/* آمده از پروندهٔ بیمار: مراجعه‌کننده معلوم است، جستجو معنا ندارد. */} + {fromRecordUuid ? ( +
+
+ {picked?.user_name ?? 'در حال بارگذاری پرونده...'} + {picked?.user_mobile ?? ''} +
+ {picked && !/^\d{10}$/.test(effectiveNationalCode) ? ( + <> + +
+ +
+ + ) : ( +
+ کد ملی + {effectiveNationalCode} +
+ )} +
+ ) : ( + <> {/* حالت جستجوی مراجعه‌کنندهٔ موجود */} {!newPatient && ( <> @@ -289,6 +355,8 @@ export default function AppointmentCreatePage() { )} + + )} {/* مشخصات سرویس */}
مشخصات سرویس
@@ -423,26 +491,52 @@ export default function AppointmentCreatePage() { ) : ( -
-
+ <> +
-
- -
- setDuration(Math.max(5, Number(digitsOnly(e.target.value)) || 0))} dir="ltr" /> + + {!manualTime ? ( +
+ +
+ {doctorUuid ? ( + + ) : ( +
ابتدا پزشک را انتخاب کنید.
+ )} +
+
-
-
- -
setStart(e.target.value)} dir="ltr" />
-
-
- -
setEnd(e.target.value)} dir="ltr" />
-
-
+ ) : ( + <> +
+
+ +
+ setDuration(Math.max(5, Number(digitsOnly(e.target.value)) || 0))} dir="ltr" /> +
+
+
+ +
setStart(e.target.value)} dir="ltr" />
+
+
+ +
setEnd(e.target.value)} dir="ltr" />
+
+
+ + + )} + )} {/* بیعانه */} diff --git a/assets/admin/pages/BlogsPage.tsx b/assets/admin/pages/BlogsPage.tsx index 01f2bae7..c2d9f328 100644 --- a/assets/admin/pages/BlogsPage.tsx +++ b/assets/admin/pages/BlogsPage.tsx @@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom'; import { EyeIcon, PencilIcon, TrashIcon, PlusIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../lib/api'; +import { useUrlState, pageOf } from '../hooks/useUrlState'; import type { ApiResponse, PaginatedResponse } from '../lib/api'; import type { Blog } from '../types'; import { formatDate } from '../lib/utils'; @@ -21,9 +22,14 @@ const STATUS_FILTERS = [ export default function BlogsPage() { const navigate = useNavigate(); const qc = useQueryClient(); - const [page, setPage] = useState(1); - const [search, setSearch] = useState(''); - const [statusFilter, setStatusFilter] = useState(''); + // وضعیت لیست در URL می‌ماند تا «بازگشت» از صفحهٔ جزئیات، همین فیلترها و صفحه را برگرداند. + const [urlState, setUrlState] = useUrlState({ page: '1', search: '', status: '' }); + const page = pageOf(urlState.page); + const search = urlState.search; + const statusFilter = urlState.status; + const setPage = (p: number) => setUrlState({ page: String(p) }); + const setSearch = (v: string) => setUrlState({ search: v, page: '1' }); + const setStatusFilter = (v: string) => setUrlState({ status: v, page: '1' }); const [deleteTarget, setDeleteTarget] = useState(null); const limit = 15; diff --git a/assets/admin/pages/ClinicsPage.tsx b/assets/admin/pages/ClinicsPage.tsx index 192379bc..b0c52f3b 100644 --- a/assets/admin/pages/ClinicsPage.tsx +++ b/assets/admin/pages/ClinicsPage.tsx @@ -14,6 +14,7 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { api } from '../lib/api'; +import { useUrlState, pageOf } from '../hooks/useUrlState'; import type { ApiResponse, PaginatedResponse } from '../lib/api'; import type { Clinic } from '../types'; import { useAuthStore } from '../stores/authStore'; @@ -39,9 +40,14 @@ export default function ClinicsPage() { const qc = useQueryClient(); const primaryRole = useAuthStore(s => s.primaryRole); const isRepresentation = primaryRole === 'representation'; - const [page, setPage] = useState(1); - const [search, setSearch] = useState(''); - const [statusFilter, setStatusFilter] = useState(''); + // وضعیت لیست در URL می‌ماند تا «بازگشت» از صفحهٔ جزئیات، همین فیلترها و صفحه را برگرداند. + const [urlState, setUrlState] = useUrlState({ page: '1', search: '', status: '' }); + const page = pageOf(urlState.page); + const search = urlState.search; + const statusFilter = urlState.status; + const setPage = (p: number) => setUrlState({ page: String(p) }); + const setSearch = (v: string) => setUrlState({ search: v, page: '1' }); + const setStatusFilter = (v: string) => setUrlState({ status: v, page: '1' }); const [deleteTarget, setDeleteTarget] = useState(null); const [mobileTarget, setMobileTarget] = useState<{ uuid: string; name: string; mobile_number?: string | null } | null>(null); const [addOpen, setAddOpen] = useState(false); diff --git a/assets/admin/pages/DoctorsPage.tsx b/assets/admin/pages/DoctorsPage.tsx index a2a9204d..1ca15e0f 100644 --- a/assets/admin/pages/DoctorsPage.tsx +++ b/assets/admin/pages/DoctorsPage.tsx @@ -8,6 +8,7 @@ import { import { XMarkIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../lib/api'; +import { useUrlState, pageOf } from '../hooks/useUrlState'; import type { ApiResponse, PaginatedResponse } from '../lib/api'; import { formatDate, formatNumber, displayDoctorName } from '../lib/utils'; import ConfirmDialog from '../components/ui/ConfirmDialog'; @@ -101,26 +102,37 @@ export default function DoctorsPage() { const primaryRole = useAuthStore(s => s.primaryRole); const isRepresentation = primaryRole === 'representation'; - const [page, setPage] = useState(1); + // وضعیت لیست در URL می‌ماند تا «بازگشت» از پروفایل پزشک، همین فیلترها و صفحه را برگرداند. + const [urlState, setUrlState] = useUrlState({ + page: '1', search: '', status: '', owner: '', specialty: '', province: '', city: '', view: 'table', + }); + const page = pageOf(urlState.page); + const search = urlState.search; + const status = urlState.status; + const ownerStatus = urlState.owner; + const specialtyId = urlState.specialty; + const provinceId = urlState.province; + const cityId = urlState.city; + const view: 'table' | 'grid' = urlState.view === 'grid' ? 'grid' : 'table'; + const setPage = (p: number) => setUrlState({ page: String(p) }); + const setStatus = (v: string) => setUrlState({ status: v, page: '1' }); + const setOwnerStatus = (v: string) => setUrlState({ owner: v, page: '1' }); + const setSpecialty = (v: string) => setUrlState({ specialty: v, page: '1' }); + const setProvinceId = (v: string) => setUrlState({ province: v, city: '', page: '1' }); + const setCityId = (v: string) => setUrlState({ city: v, page: '1' }); + const setView = (v: 'table' | 'grid') => setUrlState({ view: v }); + const [limit] = useState(25); - const [searchInput, setSearchInput] = useState(''); - const [search, setSearch] = useState(''); - const [status, setStatus] = useState(''); - const [ownerStatus, setOwnerStatus] = useState(''); - const [specialtyId, setSpecialty] = useState(''); - const [provinceId, setProvinceId] = useState(''); - const [cityId, setCityId] = useState(''); - const [view, setView] = useState<'table' | 'grid'>('table'); + // فیلد جستجو local می‌ماند (تایپ روان)؛ مقدارِ debounce‌شده به URL می‌رود. + const [searchInput, setSearchInput] = useState(urlState.search); const [deleteTarget, setDeleteTarget] = useState(null); const [mobileTarget, setMobileTarget] = useState<{ uuid: string; name: string; mobile_number?: string | null } | null>(null); useEffect(() => { - const t = setTimeout(() => { setSearch(searchInput); setPage(1); }, 350); + const t = setTimeout(() => setUrlState({ search: searchInput, page: '1' }), 350); return () => clearTimeout(t); }, [searchInput]); - useEffect(() => { setPage(1); }, [status, ownerStatus, specialtyId, provinceId, cityId]); - // ── Queries ── const statsQ = useQuery({ diff --git a/assets/admin/pages/PatientDetailPage.test.tsx b/assets/admin/pages/PatientDetailPage.test.tsx index 2890639a..5da5c113 100644 --- a/assets/admin/pages/PatientDetailPage.test.tsx +++ b/assets/admin/pages/PatientDetailPage.test.tsx @@ -328,7 +328,8 @@ describe('PatientDetailPage (پرونده تب‌دار)', () => { 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'); + // `record` همراه لینک می‌رود تا صفحهٔ ثبت نوبت، مراجعه‌کننده را پیش‌پر کند و جستجو نخواهد. + expect(screen.getByRole('link', { name: /نوبت جدید/ })).toHaveAttribute('href', '/admin/appointments/new?record=r1'); }); it('shows the empty state on the نوبت‌ها tab when there are no appointments', async () => { diff --git a/assets/admin/pages/PatientDetailPage.tsx b/assets/admin/pages/PatientDetailPage.tsx index 66c906c7..a5dcda64 100644 --- a/assets/admin/pages/PatientDetailPage.tsx +++ b/assets/admin/pages/PatientDetailPage.tsx @@ -1001,7 +1001,8 @@ function AppointmentsTab({ uuid, q }: { نوبت رزرو - + {/* `record` یعنی مراجعه‌کننده از همین پرونده می‌آید؛ صفحهٔ ثبت نوبت دیگر جستجو نمی‌خواهد. */} + نوبت جدید
diff --git a/assets/admin/pages/PatientsListPage.tsx b/assets/admin/pages/PatientsListPage.tsx index 5003a5fb..c455b67b 100644 --- a/assets/admin/pages/PatientsListPage.tsx +++ b/assets/admin/pages/PatientsListPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Link, useNavigate } from 'react-router-dom'; import { @@ -6,6 +6,7 @@ import { UserIcon, EllipsisHorizontalIcon, } from '@heroicons/react/24/outline'; import { api } from '../lib/api'; +import { useUrlState, pageOf } from '../hooks/useUrlState'; import type { ApiResponse } from '../lib/api'; import type { PatientRecord } from '../types'; import { formatNumber, toDate } from '../lib/utils'; @@ -105,14 +106,43 @@ export default function PatientsListPage() { const { can } = usePermissions(); const canCreate = can('patients', 'create'); const canUpdate = can('patients', 'update'); - const [page, setPage] = useState(1); - const [search, setSearch] = useState(''); - const [view, setView] = useState<'table' | 'card'>('card'); + // وضعیت لیست (جستجو، فیلترها، نما، صفحه) در URL می‌ماند تا «بازگشت» از پرونده، + // دقیقاً همین نما را برگرداند — نه لیست خام صفحهٔ اول. + const [urlState, setUrlState] = useUrlState({ + page: '1', search: '', view: 'card', + gender: '', insurance_id: '', service_status: '', has_debt: '', tags: '', + admitted_from: '', admitted_to: '', + }); + const page = pageOf(urlState.page); + const search = urlState.search; + const view: 'table' | 'card' = urlState.view === 'table' ? 'table' : 'card'; + const setPage = (p: number) => setUrlState({ page: String(p) }); + const setSearch = (v: string) => setUrlState({ search: v, page: '1' }); const [filterOpen, setFilterOpen] = useState(false); - const [filters, setFilters] = useState({}); + + const filters: PatientFilters = useMemo(() => ({ + ...(urlState.gender ? { gender: urlState.gender } : {}), + ...(urlState.insurance_id ? { insurance_id: urlState.insurance_id } : {}), + ...(urlState.service_status ? { service_status: urlState.service_status } : {}), + ...(urlState.has_debt ? { has_debt: true } : {}), + ...(urlState.tags ? { tags: urlState.tags.split(',') } : {}), + ...(urlState.admitted_from ? { admitted_from: urlState.admitted_from } : {}), + ...(urlState.admitted_to ? { admitted_to: urlState.admitted_to } : {}), + }), [urlState]); + + const setFilters = (f: PatientFilters) => setUrlState({ + gender: f.gender ?? '', + insurance_id: f.insurance_id ?? '', + service_status: f.service_status ?? '', + has_debt: f.has_debt ? '1' : '', + tags: f.tags?.join(',') ?? '', + admitted_from: f.admitted_from ?? '', + admitted_to: f.admitted_to ?? '', + page: '1', + }); const pageSize = view === 'card' ? CARD_PAGE_SIZE : TABLE_PAGE_SIZE; - const switchView = (v: 'table' | 'card') => { setView(v); setPage(1); }; + const switchView = (v: 'table' | 'card') => setUrlState({ view: v, page: '1' }); const qs = new URLSearchParams({ page: String(page), limit: String(pageSize) }); if (search) qs.set('search', search); diff --git a/assets/admin/pages/PaymentsPage.tsx b/assets/admin/pages/PaymentsPage.tsx index 67f79a58..7f670d23 100644 --- a/assets/admin/pages/PaymentsPage.tsx +++ b/assets/admin/pages/PaymentsPage.tsx @@ -6,6 +6,7 @@ import { BanknotesIcon, ClockIcon, CreditCardIcon, ArrowDownTrayIcon, } from '@heroicons/react/24/outline'; import { api } from '../lib/api'; +import { useUrlState, pageOf } from '../hooks/useUrlState'; import type { PaginatedResponse } from '../lib/api'; import type { Payment } from '../types'; import { formatDate, formatRial, maskMobile } from '../lib/utils'; @@ -24,9 +25,14 @@ const STATUS_FILTERS = [ export default function PaymentsPage() { const navigate = useNavigate(); - const [page, setPage] = useState(1); - const [search, setSearch] = useState(''); - const [statusFilter, setStatusFilter] = useState(''); + // وضعیت لیست در URL می‌ماند تا «بازگشت» از صفحهٔ جزئیات، همین فیلترها و صفحه را برگرداند. + const [urlState, setUrlState] = useUrlState({ page: '1', search: '', status: '' }); + const page = pageOf(urlState.page); + const search = urlState.search; + const statusFilter = urlState.status; + const setPage = (p: number) => setUrlState({ page: String(p) }); + const setSearch = (v: string) => setUrlState({ search: v, page: '1' }); + const setStatusFilter = (v: string) => setUrlState({ status: v, page: '1' }); const limit = 15; const { data, isLoading } = useQuery({ diff --git a/assets/admin/pages/RepresentationsPage.tsx b/assets/admin/pages/RepresentationsPage.tsx index 1d02391b..0e2878e6 100644 --- a/assets/admin/pages/RepresentationsPage.tsx +++ b/assets/admin/pages/RepresentationsPage.tsx @@ -7,6 +7,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { toast } from 'sonner'; import { api } from '../lib/api'; +import { useUrlState, pageOf } from '../hooks/useUrlState'; import type { ApiResponse, PaginatedResponse } from '../lib/api'; import MobileInput from '../components/ui/MobileInput'; import { iranMobileSchema } from '../lib/utils'; @@ -33,9 +34,14 @@ type FormData = z.infer; export default function RepresentationsPage() { const navigate = useNavigate(); const qc = useQueryClient(); - const [page, setPage] = useState(1); - const [search, setSearch] = useState(''); - const [cityFilter, setCityFilter] = useState(null); + // وضعیت لیست در URL می‌ماند تا «بازگشت» از صفحهٔ جزئیات، همین فیلترها و صفحه را برگرداند. + const [urlState, setUrlState] = useUrlState({ page: '1', search: '', city: '' }); + const page = pageOf(urlState.page); + const search = urlState.search; + const cityFilter = urlState.city === '' ? null : Number(urlState.city); + const setPage = (p: number) => setUrlState({ page: String(p) }); + const setSearch = (v: string) => setUrlState({ search: v, page: '1' }); + const setCityFilter = (v: number | null) => setUrlState({ city: v === null ? '' : String(v), page: '1' }); const [addOpen, setAddOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const limit = 15; diff --git a/assets/admin/pages/SecretariesPage.tsx b/assets/admin/pages/SecretariesPage.tsx index 66e5d99a..130b3e66 100644 --- a/assets/admin/pages/SecretariesPage.tsx +++ b/assets/admin/pages/SecretariesPage.tsx @@ -4,6 +4,7 @@ import { TrashIcon, PencilIcon, MagnifyingGlassIcon, CurrencyDollarIcon } from ' import { Link } from 'react-router-dom'; import { toast } from 'sonner'; import { api } from '../lib/api'; +import { useUrlState, pageOf } from '../hooks/useUrlState'; import type { ApiResponse, PaginatedResponse } from '../lib/api'; import type { Secretary, SecretaryPermissions } from '../types'; import { formatDate, formatNumber, maskMobile } from '../lib/utils'; @@ -228,8 +229,12 @@ function PermissionsMatrix({ export default function SecretariesPage() { const qc = useQueryClient(); - const [page, setPage] = useState(1); - const [search, setSearch] = useState(''); + // وضعیت لیست در URL می‌ماند تا «بازگشت» از صفحهٔ جزئیات، همین جستجو و صفحه را برگرداند. + const [urlState, setUrlState] = useUrlState({ page: '1', search: '' }); + const page = pageOf(urlState.page); + const search = urlState.search; + const setPage = (p: number) => setUrlState({ page: String(p) }); + const setSearch = (v: string) => setUrlState({ search: v, page: '1' }); const [editTarget, setEditTarget] = useState(null); const [editPerms, setEditPerms] = useState(DEFAULT_PERMISSIONS); const [deleteTarget, setDeleteTarget] = useState(null); diff --git a/assets/admin/pages/SettlementsPage.tsx b/assets/admin/pages/SettlementsPage.tsx index 1d603971..a8e4594b 100644 --- a/assets/admin/pages/SettlementsPage.tsx +++ b/assets/admin/pages/SettlementsPage.tsx @@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom'; import { EyeIcon, CheckIcon, XMarkIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../lib/api'; +import { useUrlState, pageOf } from '../hooks/useUrlState'; import type { ApiResponse, PaginatedResponse } from '../lib/api'; import type { Settlement } from '../types'; import { formatDate, formatRial } from '../lib/utils'; @@ -24,9 +25,14 @@ const STATUS_FILTERS = [ export default function SettlementsPage() { const navigate = useNavigate(); const qc = useQueryClient(); - const [page, setPage] = useState(1); - const [search, setSearch] = useState(''); - const [statusFilter, setStatusFilter] = useState('pending'); + // وضعیت لیست در URL می‌ماند تا «بازگشت» از صفحهٔ جزئیات، همین فیلترها و صفحه را برگرداند. + const [urlState, setUrlState] = useUrlState({ page: '1', search: '', status: 'pending' }); + const page = pageOf(urlState.page); + const search = urlState.search; + const statusFilter = urlState.status; + const setPage = (p: number) => setUrlState({ page: String(p) }); + const setSearch = (v: string) => setUrlState({ search: v, page: '1' }); + const setStatusFilter = (v: string) => setUrlState({ status: v, page: '1' }); const [approveTarget, setApproveTarget] = useState(null); const [rejectTarget, setRejectTarget] = useState(null); const [rejectReason, setRejectReason] = useState(''); diff --git a/assets/admin/pages/UsersPage.tsx b/assets/admin/pages/UsersPage.tsx index c315f8ea..1187ebb8 100644 --- a/assets/admin/pages/UsersPage.tsx +++ b/assets/admin/pages/UsersPage.tsx @@ -8,6 +8,7 @@ import { } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../lib/api'; +import { useUrlState, pageOf } from '../hooks/useUrlState'; import Portal from '../components/ui/Portal'; import type { ApiResponse, PaginatedResponse } from '../lib/api'; import { formatDate, formatNumber, maskMobile } from '../lib/utils'; @@ -170,22 +171,27 @@ export default function UsersPage() { const navigate = useNavigate(); const qc = useQueryClient(); - const [page, setPage] = useState(1); + // وضعیت لیست در URL می‌ماند تا «بازگشت» از پروفایل کاربر، همین فیلترها و صفحه را برگرداند. + const [urlState, setUrlState] = useUrlState({ page: '1', search: '', role: '', status: '' }); + const page = pageOf(urlState.page); + const search = urlState.search; + const role = urlState.role; + const status = urlState.status; + const setPage = (p: number) => setUrlState({ page: String(p) }); + const setRole = (v: string) => setUrlState({ role: v, page: '1' }); + const setStatus = (v: string) => setUrlState({ status: v, page: '1' }); + const [limit] = useState(25); - const [searchInput, setSearchInput] = useState(''); - const [search, setSearch] = useState(''); - const [role, setRole] = useState(''); - const [status, setStatus] = useState(''); + // فیلد جستجو local می‌ماند (تایپ روان)؛ مقدارِ debounce‌شده به URL می‌رود. + const [searchInput, setSearchInput] = useState(urlState.search); const [deleteTarget, setDeleteTarget] = useState(null); const [roleTarget, setRoleTarget] = useState(null); useEffect(() => { - const t = setTimeout(() => { setSearch(searchInput); setPage(1); }, 350); + const t = setTimeout(() => setUrlState({ search: searchInput, page: '1' }), 350); return () => clearTimeout(t); }, [searchInput]); - useEffect(() => { setPage(1); }, [role, status]); - // ── Queries ── const statsQ = useQuery({ diff --git a/docs/api/appointment.md b/docs/api/appointment.md index 6235e6a6..7ebe1fec 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -735,6 +735,10 @@ Create a new appointment for a patient. Used by doctor/clinic/secretary to book > **هویت بیمار بر پایه‌ی کد ملی:** کد ملی روی **پروفایل** بیمار ذخیره می‌شود (`profiles.national_code`، یکتا). بیمار **اول با کد ملیِ پروفایل** پیدا می‌شود، سپس با موبایل. پس یک شخص می‌تواند چند موبایل داشته باشد ولی پرونده‌اش (`PatientRecord`) یکتا می‌ماند. اگر موبایلی که پروفایلش کد ملی دیگری دارد دوباره با کد ملی متفاوت ارسال شود، خطای 422 برمی‌گردد. اگر هیچ بیماری یافت نشود، کاربر جدید (`ROLE_USER`) به‌همراه پروفایلِ حاملِ همان کد ملی ساخته می‌شود. موبایلِ واردشده در هر نوبت به‌صورت snapshot روی خودِ نوبت (`patient_mobile`) هم ذخیره می‌شود. > > **نامِ نمایش‌داده‌شده‌ی بیمار:** چون بیمار با کد ملی به پروفایل واقعی‌اش resolve می‌شود، snapshotِ نامِ نوبت (`patient_name`) از **نامِ همان پروفایل** (`User.realName`) پر می‌شود، نه از نامِ تایپ‌شده در مودال. نامِ ورودی فقط وقتی روی نوبت می‌نشیند که بیمار کاملاً جدید باشد و نامی نداشته باشد. لیستِ `GET /api/v1/my/appointments` هم همین را نشان می‌دهد (`override_name` تنها برای رزروِ عمومیِ «برای شخص دیگر» — که `user` صاحب حساب است — از `realName` جدا می‌شود). +> +> **وضعیت نوبتِ ساخته‌شده:** این endpoint نوبت را همیشه `pending` می‌سازد. صفحهٔ «افزودن نوبت» پنل (`/admin/appointments/new`) نوبتِ **قطعی** می‌سازد، پس بلافاصله پس از ساخت، خودش `POST /api/v1/appointment/{uuid}/confirm` را با `payments: []` صدا می‌زند. اگر آن مرحله شکست بخورد، نوبت `pending` می‌ماند (اسلات همچنان اشغال است) و به کاربر گفته می‌شود از لیست نوبت‌ها قطعی کند. +> +> **انتخاب زمان در پنل:** در حالت نوبت‌دهی **اسلاتی**، صفحهٔ افزودن نوبت زمان را از `GET /api/v1/appointment-slots` می‌گیرد و فقط اسلاتِ `is_available` قابل انتخاب است؛ ورود دستیِ ساعت فقط به‌عنوان «ثبت خارج از برنامه» باقی مانده (مثلاً روزی که پزشک برنامهٔ کاری ندارد). در حالت **سرویسی**، زمان‌ها از `GET /api/v1/appointment-service-slots` می‌آیند. ### Response `201` ```json