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.
This commit is contained in:
hamed
2026-07-29 21:04:40 +03:30
parent e0e8fbd1e4
commit 684cf1f783
20 changed files with 668 additions and 79 deletions
+15
View File
@@ -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
@@ -13,6 +13,13 @@ import type { Appointment } from '../types';
const get = api.get as ReturnType<typeof vi.fn>;
const patch = api.patch as ReturnType<typeof vi.fn>;
const post = api.post as ReturnType<typeof vi.fn>;
const navigate = vi.fn();
vi.mock('react-router-dom', async () => ({
...(await vi.importActual<typeof import('react-router-dom')>('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: [] });
+33 -6
View File
@@ -39,11 +39,38 @@ const toEpoch = (isoDate: string, time: string) => tehranWallClockToUnix(isoDate
*/
export async function findRecordUuid(mobile: string): Promise<string | null> {
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<string | null> {
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,
});
@@ -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<ApiResponse<{ sessions: Session[]; empty_reason?: { title?: string; hint?: string } | null }>>({
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 <div style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '6px 0' }}>در حال دریافت زمانهای خالی...</div>;
}
if (sessions.length === 0) {
return (
<div style={{ fontSize: 12.5, color: 'var(--danger)', margin: '6px 0' }}>
{emptyReason?.title ?? 'برای این روز برنامهٔ کاری تعریف نشده است'}
{emptyReason?.hint ? <span style={{ color: 'var(--text-3)' }}> {emptyReason.hint}</span> : null}
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{sessions.map((s, i) => (
<div key={`${s.start_time}-${i}`}>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 6 }} dir="ltr">
{s.start_time} {s.end_time}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{s.slots.map((slot) => {
const active = value?.start === slot.start;
return (
<button
key={slot.start}
type="button"
dir="ltr"
disabled={!slot.is_available}
title={slot.is_available ? undefined : 'این زمان قبلاً رزرو شده است'}
onClick={() => onSelect(active ? null : { start: slot.start, end: slot.end, start_time: slot.start_time })}
style={{
fontSize: 13, padding: '6px 12px', borderRadius: 'var(--r-sm)', fontFamily: 'inherit',
cursor: slot.is_available ? 'pointer' : 'not-allowed',
opacity: slot.is_available ? 1 : 0.45,
border: active ? '1px solid var(--primary)' : '1px solid var(--border)',
background: active ? 'var(--primary)' : 'var(--surface)',
color: active ? 'var(--on-primary)' : 'var(--text)',
textDecoration: slot.is_available ? 'none' : 'line-through',
}}
>
{slot.start_time}
</button>
);
})}
</div>
</div>
))}
</div>
);
}
+101
View File
@@ -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 (
<div>
<span data-testid="qs">{location.search}</span>
<span data-testid="state">{`${state.page}|${state.search}|${state.status}`}</span>
<button onClick={() => setState({ search: 'مریم', page: '1' })}>جستجو</button>
<button onClick={() => setState({ status: 'paid', page: '1' })}>فیلتر</button>
<button onClick={() => setState({ page: '3' })}>صفحه ۳</button>
<button onClick={() => { setState({ status: 'paid' }); setState({ page: '2' }); }}>دو تغییر پشتسرهم</button>
<a href="/detail" onClick={(e) => { e.preventDefault(); window.history.pushState({}, '', '/detail'); }}>x</a>
</div>
);
}
function DetailPage() {
return (
<>
<span>جزئیات</span>
<BackButton fallback="/list" />
</>
);
}
function renderApp(entries: string[] = ['/list']) {
return render(
<MemoryRouter initialEntries={entries}>
<Routes>
<Route path="/list" element={<ListPage />} />
<Route path="/detail" element={<DetailPage />} />
</Routes>
</MemoryRouter>,
);
}
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(
<MemoryRouter initialEntries={['/list?page=3&search=مریم&status=paid', '/detail']} initialIndex={1}>
<Routes>
<Route path="/list" element={<ListPage />} />
<Route path="/detail" element={<DetailPage />} />
</Routes>
</MemoryRouter>,
);
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);
});
});
+52
View File
@@ -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<T extends Record<string, string>>(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<T>, 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);
}
@@ -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(<AppointmentCreatePage />, { 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(<AppointmentCreatePage />, { 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(<AppointmentCreatePage />, { 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(<AppointmentCreatePage />, { 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(<AppointmentCreatePage />, { 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());
});
});
+117 -23
View File
@@ -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<ApiResponse<PatientRow & { user_name?: string }>>({
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<PickedSlot | null>(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<string, unknown> = {
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() {
{/* مراجعه کننده */}
<div style={{ ...sectionTitle, marginTop: isDoctor ? 0 : 18 }}>اطلاعات مراجعه کننده :</div>
{/* آمده از پروندهٔ بیمار: مراجعه‌کننده معلوم است، جستجو معنا ندارد. */}
{fromRecordUuid ? (
<div style={{ maxWidth: 400, border: '1px solid var(--border-2)', borderRadius: 'var(--r-sm)', padding: 12, marginBottom: 8 }}>
<div style={{ fontSize: 13.5, fontWeight: 600, marginBottom: 6 }}>
{picked?.user_name ?? 'در حال بارگذاری پرونده...'}
<span style={{ color: 'var(--text-3)', direction: 'ltr', fontWeight: 400 }}> {picked?.user_mobile ?? ''}</span>
</div>
{picked && !/^\d{10}$/.test(effectiveNationalCode) ? (
<>
<label style={label}>کد ملی (در پرونده ثبت نشده وارد کنید)</label>
<div className="field" style={{ marginTop: 6, height: 44 }}>
<DigitInput value={nationalCode} onChange={setNationalCode} maxDigits={10}
placeholder="کد ملی مراجعه کننده" />
</div>
</>
) : (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: 13 }}>
<span style={{ color: 'var(--text-3)' }}>کد ملی</span>
<span dir="ltr" style={{ fontWeight: 600 }}>{effectiveNationalCode}</span>
</div>
)}
</div>
) : (
<>
{/* حالت جستجوی مراجعه‌کنندهٔ موجود */}
{!newPatient && (
<>
@@ -289,6 +355,8 @@ export default function AppointmentCreatePage() {
</div>
</>
)}
</>
)}
{/* مشخصات سرویس */}
<div style={sectionTitle}>مشخصات سرویس</div>
@@ -423,26 +491,52 @@ export default function AppointmentCreatePage() {
</div>
</>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 12 }}>
<div>
<>
<div style={{ maxWidth: 400, marginBottom: 12 }}>
<label style={label}>انتخاب تاریخ</label>
<div style={{ marginTop: 6 }}><PersianDateInput value={date} onChange={setDate} /></div>
</div>
<div>
<label style={label}>زمان پیش فرض (دقیقه)</label>
<div className="field" style={{ marginTop: 6, height: 44 }}>
<input aria-label="زمان پیش فرض" type="text" inputMode="numeric" value={duration} onChange={e => setDuration(Math.max(5, Number(digitsOnly(e.target.value)) || 0))} dir="ltr" />
{!manualTime ? (
<div style={{ marginBottom: 12 }}>
<label style={label}>انتخاب زمان از برنامهٔ پزشک</label>
<div style={{ marginTop: 6 }}>
{doctorUuid ? (
<SlotPicker doctorUuid={doctorUuid} date={date} value={slotPick} onSelect={setSlotPick} />
) : (
<div style={{ fontSize: 12.5, color: 'var(--text-3)' }}>ابتدا پزشک را انتخاب کنید.</div>
)}
</div>
<button type="button" className="btn sm ghost" style={{ marginTop: 8 }}
onClick={() => { setManualTime(true); setSlotPick(null); }}>
ثبت خارج از برنامه (ورود دستی ساعت)
</button>
</div>
</div>
<div>
<label style={label}>ساعت شروع</label>
<div className="field" style={{ marginTop: 6, height: 44 }}><input aria-label="ساعت شروع" type="time" value={start} onChange={e => setStart(e.target.value)} dir="ltr" /></div>
</div>
<div>
<label style={label}>ساعت پایان</label>
<div className="field" style={{ marginTop: 6, height: 44 }}><input aria-label="ساعت پایان" type="time" value={end} onChange={e => setEnd(e.target.value)} dir="ltr" /></div>
</div>
</div>
) : (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16, marginBottom: 8 }}>
<div>
<label style={label}>زمان پیش فرض (دقیقه)</label>
<div className="field" style={{ marginTop: 6, height: 44 }}>
<input aria-label="زمان پیش فرض" type="text" inputMode="numeric" value={duration} onChange={e => setDuration(Math.max(5, Number(digitsOnly(e.target.value)) || 0))} dir="ltr" />
</div>
</div>
<div>
<label style={label}>ساعت شروع</label>
<div className="field" style={{ marginTop: 6, height: 44 }}><input aria-label="ساعت شروع" type="time" value={start} onChange={e => setStart(e.target.value)} dir="ltr" /></div>
</div>
<div>
<label style={label}>ساعت پایان</label>
<div className="field" style={{ marginTop: 6, height: 44 }}><input aria-label="ساعت پایان" type="time" value={end} onChange={e => setEnd(e.target.value)} dir="ltr" /></div>
</div>
</div>
<button type="button" className="btn sm ghost" style={{ marginBottom: 12 }}
onClick={() => setManualTime(false)}>
بازگشت به انتخاب از برنامهٔ پزشک
</button>
</>
)}
</>
)}
{/* بیعانه */}
+9 -3
View File
@@ -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<Blog | null>(null);
const limit = 15;
+9 -3
View File
@@ -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<Clinic | null>(null);
const [mobileTarget, setMobileTarget] = useState<{ uuid: string; name: string; mobile_number?: string | null } | null>(null);
const [addOpen, setAddOpen] = useState(false);
+24 -12
View File
@@ -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<AdminDoctor | null>(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({
@@ -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 () => {
+2 -1
View File
@@ -1001,7 +1001,8 @@ function AppointmentsTab({ uuid, q }: {
<Link to="/admin/appointments/reserve" className="flex items-center justify-center gap-2 rounded-[8px]" style={{ height: 48, minWidth: 137, border: '1px solid var(--primary)', color: 'var(--primary)', textDecoration: 'none', padding: '0 16px', fontSize: 14 }}>
<AddTurn color="var(--primary)" /> نوبت رزرو
</Link>
<Link to="/admin/appointments/new" className="flex items-center justify-center gap-2 rounded-[8px]" style={{ height: 48, minWidth: 137, background: 'var(--primary)', color: 'var(--on-primary)', textDecoration: 'none', padding: '0 16px', fontSize: 14 }}>
{/* `record` یعنی مراجعه‌کننده از همین پرونده می‌آید؛ صفحهٔ ثبت نوبت دیگر جستجو نمی‌خواهد. */}
<Link to={`/admin/appointments/new?record=${uuid}`} className="flex items-center justify-center gap-2 rounded-[8px]" style={{ height: 48, minWidth: 137, background: 'var(--primary)', color: 'var(--on-primary)', textDecoration: 'none', padding: '0 16px', fontSize: 14 }}>
<AddTurn color="var(--on-primary)" /> نوبت جدید
</Link>
</div>
+36 -6
View File
@@ -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<PatientFilters>({});
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);
+9 -3
View File
@@ -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({
+9 -3
View File
@@ -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<typeof schema>;
export default function RepresentationsPage() {
const navigate = useNavigate();
const qc = useQueryClient();
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [cityFilter, setCityFilter] = useState<number | null>(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<Representation | null>(null);
const limit = 15;
+7 -2
View File
@@ -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<Secretary | null>(null);
const [editPerms, setEditPerms] = useState<SecretaryPermissions>(DEFAULT_PERMISSIONS);
const [deleteTarget, setDeleteTarget] = useState<Secretary | null>(null);
+9 -3
View File
@@ -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<Settlement | null>(null);
const [rejectTarget, setRejectTarget] = useState<Settlement | null>(null);
const [rejectReason, setRejectReason] = useState('');
+14 -8
View File
@@ -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<AdminUser | null>(null);
const [roleTarget, setRoleTarget] = useState<AdminUser | 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); }, [role, status]);
// ── Queries ──
const statsQ = useQuery({
+4
View File
@@ -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