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:
@@ -157,6 +157,21 @@ Feature composites (not generic) live one level up in `components/*.tsx`.
|
|||||||
(همان دکمهٔ صفحهٔ سرویسها) و رفتارش در `hooks/useGoBack.ts` متمرکز است: یک قدم عقب در تاریخچهٔ
|
(همان دکمهٔ صفحهٔ سرویسها) و رفتارش در `hooks/useGoBack.ts` متمرکز است: یک قدم عقب در تاریخچهٔ
|
||||||
پنل، و در ورود مستقیم/رفرش (`location.key === 'default'`) رفتن به `fallback`.
|
پنل، و در ورود مستقیم/رفرش (`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
|
## Conventions
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ import type { Appointment } from '../types';
|
|||||||
|
|
||||||
const get = api.get as ReturnType<typeof vi.fn>;
|
const get = api.get as ReturnType<typeof vi.fn>;
|
||||||
const patch = api.patch 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 = {
|
const appt: Appointment = {
|
||||||
uuid: 'ap1', patient_name: 'مریم خلیلی', patient_mobile: '09136549874',
|
uuid: 'ap1', patient_name: 'مریم خلیلی', patient_mobile: '09136549874',
|
||||||
@@ -28,11 +35,13 @@ const appt: Appointment = {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
get.mockReset(); patch.mockReset();
|
get.mockReset(); patch.mockReset();
|
||||||
get.mockImplementation((url: string) => {
|
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: [] } });
|
if (url === '/api/v1/patient/rec1/wallet') return Promise.resolve({ success: true, data: { balance_rials: 500000, recent_transactions: [] } });
|
||||||
return Promise.resolve({ success: true, data: [] });
|
return Promise.resolve({ success: true, data: [] });
|
||||||
});
|
});
|
||||||
patch.mockResolvedValue({ success: true, data: {} });
|
patch.mockResolvedValue({ success: true, data: {} });
|
||||||
|
post.mockReset();
|
||||||
|
navigate.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
function openMenu() {
|
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 () => {
|
it('info modal shows appointment details and patient wallet balance', async () => {
|
||||||
openMenu();
|
openMenu();
|
||||||
fireEvent.click(screen.getByText('مشاهده'));
|
fireEvent.click(screen.getByText('مشاهده'));
|
||||||
@@ -107,7 +143,7 @@ describe('AppointmentActionsMenu (عملیات نوبت)', () => {
|
|||||||
|
|
||||||
it('replace modal picks an existing patient from the record search', async () => {
|
it('replace modal picks an existing patient from the record search', async () => {
|
||||||
get.mockImplementation((url: string) => {
|
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' },
|
{ uuid: 'rec9', user_name: 'پریسا همتی', user_mobile: '09120009999' },
|
||||||
] });
|
] });
|
||||||
return Promise.resolve({ success: true, data: [] });
|
return Promise.resolve({ success: true, data: [] });
|
||||||
|
|||||||
@@ -39,11 +39,38 @@ const toEpoch = (isoDate: string, time: string) => tehranWallClockToUnix(isoDate
|
|||||||
*/
|
*/
|
||||||
export async function findRecordUuid(mobile: string): Promise<string | null> {
|
export async function findRecordUuid(mobile: string): Promise<string | null> {
|
||||||
const res: any = await api.get(
|
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;
|
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
|
* «شارژ کیف پول» accent link (appointment create/edit forms) — deep-links the
|
||||||
* patient's wallet tab, where the manual top-up modal lives.
|
* patient's wallet tab, where the manual top-up modal lives.
|
||||||
@@ -120,14 +147,14 @@ export default function AppointmentActionsMenu({
|
|||||||
const goToServiceRegistration = async () => {
|
const goToServiceRegistration = async () => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
try {
|
try {
|
||||||
const recordUuid = await findRecordUuid(appointment.patient_mobile);
|
const recordUuid = await ensureRecordUuid(appointment);
|
||||||
if (!recordUuid) {
|
if (!recordUuid) {
|
||||||
toast.error("پروندهای برای این بیمار یافت نشد");
|
toast.error("این نوبت شماره تماس ندارد؛ ابتدا نوبت را ویرایش کنید");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigate(`/admin/patients/${recordUuid}/session/new`);
|
navigate(`/admin/patients/${recordUuid}/session/new`);
|
||||||
} catch {
|
} catch (e: any) {
|
||||||
toast.error("خطا در یافتن پرونده بیمار");
|
toast.error(e?.message || "خطا در یافتن پرونده بیمار");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -672,7 +699,7 @@ export function ReplaceAppointmentModal({
|
|||||||
queryKey: ["replace-patients", patientSearch],
|
queryKey: ["replace-patients", patientSearch],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get(
|
api.get(
|
||||||
`/api/v1/patient?search=${encodeURIComponent(patientSearch)}&limit=10`,
|
`/api/v1/patients?search=${encodeURIComponent(patientSearch)}&limit=10`,
|
||||||
),
|
),
|
||||||
enabled: patientSearch.trim().length >= 2,
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
get.mockReset();
|
||||||
post.mockReset();
|
post.mockReset();
|
||||||
useAuthStore.setState({ primaryRole: 'doctor', dbUuid: 'doc1' } as any);
|
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' } });
|
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 — افزودن نوبت', () => {
|
describe('AppointmentCreatePage — افزودن نوبت', () => {
|
||||||
it('posts a new appointment with the entered patient (happy path)', async () => {
|
it('posts a new appointment with the entered patient (happy path)', async () => {
|
||||||
renderWithProviders(<AppointmentCreatePage />, { route: '/admin/appointments/new' });
|
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: '09121234567' } });
|
||||||
fireEvent.change(screen.getByPlaceholderText('کد ملی مراجعه کننده'), { target: { value: '1234567891' } });
|
fireEvent.change(screen.getByPlaceholderText('کد ملی مراجعه کننده'), { target: { value: '1234567891' } });
|
||||||
|
|
||||||
|
await pickSlot();
|
||||||
fireEvent.click(screen.getByText('ثبت اطلاعات'));
|
fireEvent.click(screen.getByText('ثبت اطلاعات'));
|
||||||
|
|
||||||
await waitFor(() => expect(post).toHaveBeenCalled());
|
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: 'دندان' } }] } });
|
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'))
|
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' }] } });
|
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: [] });
|
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: '09121234567' } });
|
||||||
fireEvent.change(screen.getByPlaceholderText('کد ملی مراجعه کننده'), { target: { value: '1234567891' } });
|
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;
|
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'] });
|
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)', () => {
|
it('keeps the submit button disabled until a valid patient is entered (boundary)', () => {
|
||||||
renderWithProviders(<AppointmentCreatePage />, { route: '/admin/appointments/new' });
|
renderWithProviders(<AppointmentCreatePage />, { route: '/admin/appointments/new' });
|
||||||
const btn = screen.getByText('ثبت اطلاعات') as HTMLButtonElement;
|
const btn = screen.getByText('ثبت اطلاعات') as HTMLButtonElement;
|
||||||
@@ -96,6 +167,7 @@ describe('AppointmentCreatePage — افزودن نوبت', () => {
|
|||||||
get.mockImplementation((url: string) => {
|
get.mockImplementation((url: string) => {
|
||||||
if (url.startsWith('/api/v1/patients?'))
|
if (url.startsWith('/api/v1/patients?'))
|
||||||
return Promise.resolve({ success: true, data: [{ uuid: 'rec1', user_name: 'حامد حسینی', user_mobile: '09210671745', user_national_code: '0012345675' }] });
|
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: [] });
|
return Promise.resolve({ success: true, data: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -104,6 +176,7 @@ describe('AppointmentCreatePage — افزودن نوبت', () => {
|
|||||||
fireEvent.change(screen.getByPlaceholderText('جستجوی نام، شماره تماس، شماره پرونده...'), { target: { value: 'حامد' } });
|
fireEvent.change(screen.getByPlaceholderText('جستجوی نام، شماره تماس، شماره پرونده...'), { target: { value: 'حامد' } });
|
||||||
fireEvent.click(await screen.findByText(/حامد حسینی/));
|
fireEvent.click(await screen.findByText(/حامد حسینی/));
|
||||||
|
|
||||||
|
await pickSlot();
|
||||||
const btn = screen.getByText('ثبت اطلاعات') as HTMLButtonElement;
|
const btn = screen.getByText('ثبت اطلاعات') as HTMLButtonElement;
|
||||||
await waitFor(() => expect(btn).not.toBeDisabled());
|
await waitFor(() => expect(btn).not.toBeDisabled());
|
||||||
|
|
||||||
@@ -121,6 +194,7 @@ describe('AppointmentCreatePage — افزودن نوبت', () => {
|
|||||||
return Promise.resolve({ success: true, data: [{ uuid: 'i1', name: 'لیزر' }, { uuid: 'i2', name: 'ماساژ' }] });
|
return Promise.resolve({ success: true, data: [{ uuid: 'i1', name: 'لیزر' }, { uuid: 'i2', name: 'ماساژ' }] });
|
||||||
if (url === '/api/v1/service-items/sec2')
|
if (url === '/api/v1/service-items/sec2')
|
||||||
return Promise.resolve({ success: true, data: [{ uuid: 'i3', name: 'پاکسازی' }] });
|
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: [] });
|
return Promise.resolve({ success: true, data: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -153,6 +227,7 @@ describe('AppointmentCreatePage — افزودن نوبت', () => {
|
|||||||
fireEvent.click(screen.getByLabelText('حذف ماساژ'));
|
fireEvent.click(screen.getByLabelText('حذف ماساژ'));
|
||||||
expect(screen.getByText('سرویسهای انتخابشده (2)')).toBeInTheDocument();
|
expect(screen.getByText('سرویسهای انتخابشده (2)')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await pickSlot();
|
||||||
fireEvent.click(screen.getByText('ثبت اطلاعات'));
|
fireEvent.click(screen.getByText('ثبت اطلاعات'));
|
||||||
await waitFor(() => expect(post).toHaveBeenCalled());
|
await waitFor(() => expect(post).toHaveBeenCalled());
|
||||||
const [, body] = post.mock.calls[0];
|
const [, body] = post.mock.calls[0];
|
||||||
@@ -164,6 +239,7 @@ describe('AppointmentCreatePage — افزودن نوبت', () => {
|
|||||||
get.mockImplementation((url: string) => {
|
get.mockImplementation((url: string) => {
|
||||||
if (url.startsWith('/api/v1/patients?'))
|
if (url.startsWith('/api/v1/patients?'))
|
||||||
return Promise.resolve({ success: true, data: [{ uuid: 'rec2', user_name: 'حامد حسینی', user_mobile: '09210671745', user_national_code: null }] });
|
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: [] });
|
return Promise.resolve({ success: true, data: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -175,8 +251,9 @@ describe('AppointmentCreatePage — افزودن نوبت', () => {
|
|||||||
const btn = screen.getByText('ثبت اطلاعات') as HTMLButtonElement;
|
const btn = screen.getByText('ثبت اطلاعات') as HTMLButtonElement;
|
||||||
expect(btn).toBeDisabled();
|
expect(btn).toBeDisabled();
|
||||||
|
|
||||||
// کد ملیِ خالیِ رکورد را دستی وارد کن → فعال میشود
|
// کد ملیِ خالیِ رکورد را دستی وارد کن + انتخاب اسلات → فعال میشود
|
||||||
fireEvent.change(screen.getByPlaceholderText('کد ملی مراجعه کننده'), { target: { value: '0012345675' } });
|
fireEvent.change(screen.getByPlaceholderText('کد ملی مراجعه کننده'), { target: { value: '0012345675' } });
|
||||||
|
await pickSlot();
|
||||||
await waitFor(() => expect(btn).not.toBeDisabled());
|
await waitFor(() => expect(btn).not.toBeDisabled());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import SearchableSelect from '../components/ui/SearchableSelect';
|
|||||||
import { WalletChargeLink } from '../components/AppointmentActions';
|
import { WalletChargeLink } from '../components/AppointmentActions';
|
||||||
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
|
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
|
||||||
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
|
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
|
||||||
|
import SlotPicker, { type PickedSlot } from '../components/appointments/SlotPicker';
|
||||||
import { tehranWallClockToUnix, rialToToman, tomanToRial } from '../lib/utils';
|
import { tehranWallClockToUnix, rialToToman, tomanToRial } from '../lib/utils';
|
||||||
import BackButton from '../components/ui/BackButton';
|
import BackButton from '../components/ui/BackButton';
|
||||||
import { digitsOnly, todayIso } from '../lib/utils';
|
import { digitsOnly, todayIso } from '../lib/utils';
|
||||||
@@ -69,6 +70,26 @@ export default function AppointmentCreatePage() {
|
|||||||
});
|
});
|
||||||
const patients = useMemo(() => patientsQ.data?.data ?? [], [patientsQ.data]);
|
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(''); // بخشِ در حال مرور (برای دیدن سرویسهایش)
|
const [sectionUuid, setSectionUuid] = useState(''); // بخشِ در حال مرور (برای دیدن سرویسهایش)
|
||||||
// سرویسهای انتخابشده — انباشته از چند بخش؛ هر کدام قابل حذف. نام را نگه میداریم تا در chip نشان دهیم.
|
// سرویسهای انتخابشده — انباشته از چند بخش؛ هر کدام قابل حذف. نام را نگه میداریم تا در chip نشان دهیم.
|
||||||
@@ -95,10 +116,16 @@ export default function AppointmentCreatePage() {
|
|||||||
|
|
||||||
// ── زمان نوبت
|
// ── زمان نوبت
|
||||||
const [date, setDate] = useState(params.get('date') || today);
|
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 [duration, setDuration] = useState(40);
|
||||||
const [start, setStart] = useState('15:00');
|
const [start, setStart] = useState('15:00');
|
||||||
const [end, setEnd] = useState(addMinutes('15:00', 40));
|
const [end, setEnd] = useState(addMinutes('15:00', 40));
|
||||||
useEffect(() => { setEnd(addMinutes(start, duration)); }, [start, duration]);
|
useEffect(() => { setEnd(addMinutes(start, duration)); }, [start, duration]);
|
||||||
|
// تعویض پزشک/تاریخ ⇒ اسلات انتخابشده دیگر معتبر نیست.
|
||||||
|
useEffect(() => { setSlotPick(null); }, [doctorUuid, date]);
|
||||||
|
|
||||||
// ── بیعانه / وضعیت / توضیحات
|
// ── بیعانه / وضعیت / توضیحات
|
||||||
const [depositRequired, setDepositRequired] = useState(false);
|
const [depositRequired, setDepositRequired] = useState(false);
|
||||||
@@ -125,17 +152,20 @@ export default function AppointmentCreatePage() {
|
|||||||
const effectiveNationalCode = (picked?.user_national_code || nationalCode).replace(/\D/g, '');
|
const effectiveNationalCode = (picked?.user_national_code || nationalCode).replace(/\D/g, '');
|
||||||
const timingValid = serviceMode
|
const timingValid = serviceMode
|
||||||
? (servicePick.serviceUuids.length > 0 && !!servicePick.slot)
|
? (servicePick.serviceUuids.length > 0 && !!servicePick.slot)
|
||||||
: (!!start && !!end);
|
: (manualTime ? (!!start && !!end) : !!slotPick);
|
||||||
const valid = !!doctorUuid && !!date && effectiveName.length >= 2 && effectiveMobile.length >= 10
|
const valid = !!doctorUuid && !!date && effectiveName.length >= 2 && effectiveMobile.length >= 10
|
||||||
&& effectiveNationalCode.length === 10 && timingValid && (!requireVisit || visitPriceToman > 0);
|
&& effectiveNationalCode.length === 10 && timingValid && (!requireVisit || visitPriceToman > 0);
|
||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const createEndpoint = primaryRole === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/my/appointment';
|
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> = {
|
const payload: Record<string, unknown> = {
|
||||||
doctor_uuid: doctorUuid,
|
doctor_uuid: doctorUuid,
|
||||||
slot_start: serviceMode ? servicePick.slot!.start : toEpoch(date, start),
|
slot_start: slotStart,
|
||||||
slot_end: serviceMode ? servicePick.slot!.end : toEpoch(date, end),
|
slot_end: slotEnd,
|
||||||
patient_name: effectiveName,
|
patient_name: effectiveName,
|
||||||
patient_mobile: effectiveMobile,
|
patient_mobile: effectiveMobile,
|
||||||
patient_national_code: effectiveNationalCode,
|
patient_national_code: effectiveNationalCode,
|
||||||
@@ -150,13 +180,25 @@ export default function AppointmentCreatePage() {
|
|||||||
...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}),
|
...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}),
|
||||||
...(note.trim() ? { note: note.trim() } : {}),
|
...(note.trim() ? { note: note.trim() } : {}),
|
||||||
};
|
};
|
||||||
// نوبت همیشه «ثبت شده» متولد میشود؛ قطعیکردن یک عملِ جداست که هزینهها را
|
// سرور نوبت را «ثبتشده» متولد میکند؛ این صفحه اما نوبتِ **قطعی** میسازد، پس
|
||||||
// نشان میدهد و پرداخت میگیرد (مودال «قطعی کردن نوبت»).
|
// بلافاصله همان endpoint قطعیکردن صدا زده میشود (بدون پرداخت — پرداخت از
|
||||||
return api.post(createEndpoint, payload);
|
// مودال «قطعی کردن» یا صفحهٔ پرداخت انجام میشود).
|
||||||
|
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: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['appointments'] });
|
qc.invalidateQueries({ queryKey: ['appointments'] });
|
||||||
toast.success('نوبت با موفقیت ثبت شد');
|
toast.success('نوبت قطعی ثبت شد');
|
||||||
// به همان روزِ نوبت برگرد، نه امروز.
|
// به همان روزِ نوبت برگرد، نه امروز.
|
||||||
navigate(`/admin/appointments?date=${date}`);
|
navigate(`/admin/appointments?date=${date}`);
|
||||||
},
|
},
|
||||||
@@ -195,6 +237,30 @@ export default function AppointmentCreatePage() {
|
|||||||
{/* مراجعه کننده */}
|
{/* مراجعه کننده */}
|
||||||
<div style={{ ...sectionTitle, marginTop: isDoctor ? 0 : 18 }}>اطلاعات مراجعه کننده :</div>
|
<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 && (
|
{!newPatient && (
|
||||||
<>
|
<>
|
||||||
@@ -289,6 +355,8 @@ export default function AppointmentCreatePage() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* مشخصات سرویس */}
|
{/* مشخصات سرویس */}
|
||||||
<div style={sectionTitle}>مشخصات سرویس</div>
|
<div style={sectionTitle}>مشخصات سرویس</div>
|
||||||
@@ -423,26 +491,52 @@ export default function AppointmentCreatePage() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 12 }}>
|
<>
|
||||||
<div>
|
<div style={{ maxWidth: 400, marginBottom: 12 }}>
|
||||||
<label style={label}>انتخاب تاریخ</label>
|
<label style={label}>انتخاب تاریخ</label>
|
||||||
<div style={{ marginTop: 6 }}><PersianDateInput value={date} onChange={setDate} /></div>
|
<div style={{ marginTop: 6 }}><PersianDateInput value={date} onChange={setDate} /></div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label style={label}>زمان پیش فرض (دقیقه)</label>
|
{!manualTime ? (
|
||||||
<div className="field" style={{ marginTop: 6, height: 44 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
<input aria-label="زمان پیش فرض" type="text" inputMode="numeric" value={duration} onChange={e => setDuration(Math.max(5, Number(digitsOnly(e.target.value)) || 0))} dir="ltr" />
|
<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>
|
) : (
|
||||||
<div>
|
<>
|
||||||
<label style={label}>ساعت شروع</label>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16, marginBottom: 8 }}>
|
||||||
<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>
|
<div className="field" style={{ marginTop: 6, height: 44 }}>
|
||||||
<label style={label}>ساعت پایان</label>
|
<input aria-label="زمان پیش فرض" type="text" inputMode="numeric" value={duration} onChange={e => setDuration(Math.max(5, Number(digitsOnly(e.target.value)) || 0))} dir="ltr" />
|
||||||
<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>
|
||||||
</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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* بیعانه */}
|
{/* بیعانه */}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { EyeIcon, PencilIcon, TrashIcon, PlusIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
import { EyeIcon, PencilIcon, TrashIcon, PlusIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
|
import { useUrlState, pageOf } from '../hooks/useUrlState';
|
||||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||||
import type { Blog } from '../types';
|
import type { Blog } from '../types';
|
||||||
import { formatDate } from '../lib/utils';
|
import { formatDate } from '../lib/utils';
|
||||||
@@ -21,9 +22,14 @@ const STATUS_FILTERS = [
|
|||||||
export default function BlogsPage() {
|
export default function BlogsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [page, setPage] = useState(1);
|
// وضعیت لیست در URL میماند تا «بازگشت» از صفحهٔ جزئیات، همین فیلترها و صفحه را برگرداند.
|
||||||
const [search, setSearch] = useState('');
|
const [urlState, setUrlState] = useUrlState({ page: '1', search: '', status: '' });
|
||||||
const [statusFilter, setStatusFilter] = useState('');
|
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 [deleteTarget, setDeleteTarget] = useState<Blog | null>(null);
|
||||||
const limit = 15;
|
const limit = 15;
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
|
import { useUrlState, pageOf } from '../hooks/useUrlState';
|
||||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||||
import type { Clinic } from '../types';
|
import type { Clinic } from '../types';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
@@ -39,9 +40,14 @@ export default function ClinicsPage() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const primaryRole = useAuthStore(s => s.primaryRole);
|
const primaryRole = useAuthStore(s => s.primaryRole);
|
||||||
const isRepresentation = primaryRole === 'representation';
|
const isRepresentation = primaryRole === 'representation';
|
||||||
const [page, setPage] = useState(1);
|
// وضعیت لیست در URL میماند تا «بازگشت» از صفحهٔ جزئیات، همین فیلترها و صفحه را برگرداند.
|
||||||
const [search, setSearch] = useState('');
|
const [urlState, setUrlState] = useUrlState({ page: '1', search: '', status: '' });
|
||||||
const [statusFilter, setStatusFilter] = useState('');
|
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 [deleteTarget, setDeleteTarget] = useState<Clinic | null>(null);
|
||||||
const [mobileTarget, setMobileTarget] = useState<{ uuid: string; name: string; mobile_number?: string | null } | null>(null);
|
const [mobileTarget, setMobileTarget] = useState<{ uuid: string; name: string; mobile_number?: string | null } | null>(null);
|
||||||
const [addOpen, setAddOpen] = useState(false);
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
|
import { useUrlState, pageOf } from '../hooks/useUrlState';
|
||||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||||
import { formatDate, formatNumber, displayDoctorName } from '../lib/utils';
|
import { formatDate, formatNumber, displayDoctorName } from '../lib/utils';
|
||||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||||
@@ -101,26 +102,37 @@ export default function DoctorsPage() {
|
|||||||
const primaryRole = useAuthStore(s => s.primaryRole);
|
const primaryRole = useAuthStore(s => s.primaryRole);
|
||||||
const isRepresentation = primaryRole === 'representation';
|
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 [limit] = useState(25);
|
||||||
const [searchInput, setSearchInput] = useState('');
|
// فیلد جستجو local میماند (تایپ روان)؛ مقدارِ debounceشده به URL میرود.
|
||||||
const [search, setSearch] = useState('');
|
const [searchInput, setSearchInput] = useState(urlState.search);
|
||||||
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');
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<AdminDoctor | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<AdminDoctor | null>(null);
|
||||||
const [mobileTarget, setMobileTarget] = useState<{ uuid: string; name: string; mobile_number?: string | null } | null>(null);
|
const [mobileTarget, setMobileTarget] = useState<{ uuid: string; name: string; mobile_number?: string | null } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setTimeout(() => { setSearch(searchInput); setPage(1); }, 350);
|
const t = setTimeout(() => setUrlState({ search: searchInput, page: '1' }), 350);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [searchInput]);
|
}, [searchInput]);
|
||||||
|
|
||||||
useEffect(() => { setPage(1); }, [status, ownerStatus, specialtyId, provinceId, cityId]);
|
|
||||||
|
|
||||||
// ── Queries ──
|
// ── Queries ──
|
||||||
|
|
||||||
const statsQ = useQuery({
|
const statsQ = useQuery({
|
||||||
|
|||||||
@@ -328,7 +328,8 @@ describe('PatientDetailPage (پرونده تبدار)', () => {
|
|||||||
expect(screen.getByText('ساعت:')).toBeInTheDocument();
|
expect(screen.getByText('ساعت:')).toBeInTheDocument();
|
||||||
// toolbar buttons link to the existing appointment pages
|
// 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/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 () => {
|
it('shows the empty state on the نوبتها tab when there are no appointments', async () => {
|
||||||
|
|||||||
@@ -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 }}>
|
<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)" /> نوبت رزرو
|
<AddTurn color="var(--primary)" /> نوبت رزرو
|
||||||
</Link>
|
</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)" /> نوبت جدید
|
<AddTurn color="var(--on-primary)" /> نوبت جدید
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
UserIcon, EllipsisHorizontalIcon,
|
UserIcon, EllipsisHorizontalIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
|
import { useUrlState, pageOf } from '../hooks/useUrlState';
|
||||||
import type { ApiResponse } from '../lib/api';
|
import type { ApiResponse } from '../lib/api';
|
||||||
import type { PatientRecord } from '../types';
|
import type { PatientRecord } from '../types';
|
||||||
import { formatNumber, toDate } from '../lib/utils';
|
import { formatNumber, toDate } from '../lib/utils';
|
||||||
@@ -105,14 +106,43 @@ export default function PatientsListPage() {
|
|||||||
const { can } = usePermissions();
|
const { can } = usePermissions();
|
||||||
const canCreate = can('patients', 'create');
|
const canCreate = can('patients', 'create');
|
||||||
const canUpdate = can('patients', 'update');
|
const canUpdate = can('patients', 'update');
|
||||||
const [page, setPage] = useState(1);
|
// وضعیت لیست (جستجو، فیلترها، نما، صفحه) در URL میماند تا «بازگشت» از پرونده،
|
||||||
const [search, setSearch] = useState('');
|
// دقیقاً همین نما را برگرداند — نه لیست خام صفحهٔ اول.
|
||||||
const [view, setView] = useState<'table' | 'card'>('card');
|
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 [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 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) });
|
const qs = new URLSearchParams({ page: String(page), limit: String(pageSize) });
|
||||||
if (search) qs.set('search', search);
|
if (search) qs.set('search', search);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
BanknotesIcon, ClockIcon, CreditCardIcon, ArrowDownTrayIcon,
|
BanknotesIcon, ClockIcon, CreditCardIcon, ArrowDownTrayIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
|
import { useUrlState, pageOf } from '../hooks/useUrlState';
|
||||||
import type { PaginatedResponse } from '../lib/api';
|
import type { PaginatedResponse } from '../lib/api';
|
||||||
import type { Payment } from '../types';
|
import type { Payment } from '../types';
|
||||||
import { formatDate, formatRial, maskMobile } from '../lib/utils';
|
import { formatDate, formatRial, maskMobile } from '../lib/utils';
|
||||||
@@ -24,9 +25,14 @@ const STATUS_FILTERS = [
|
|||||||
|
|
||||||
export default function PaymentsPage() {
|
export default function PaymentsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [page, setPage] = useState(1);
|
// وضعیت لیست در URL میماند تا «بازگشت» از صفحهٔ جزئیات، همین فیلترها و صفحه را برگرداند.
|
||||||
const [search, setSearch] = useState('');
|
const [urlState, setUrlState] = useUrlState({ page: '1', search: '', status: '' });
|
||||||
const [statusFilter, setStatusFilter] = useState('');
|
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 limit = 15;
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
|
import { useUrlState, pageOf } from '../hooks/useUrlState';
|
||||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||||
import MobileInput from '../components/ui/MobileInput';
|
import MobileInput from '../components/ui/MobileInput';
|
||||||
import { iranMobileSchema } from '../lib/utils';
|
import { iranMobileSchema } from '../lib/utils';
|
||||||
@@ -33,9 +34,14 @@ type FormData = z.infer<typeof schema>;
|
|||||||
export default function RepresentationsPage() {
|
export default function RepresentationsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [page, setPage] = useState(1);
|
// وضعیت لیست در URL میماند تا «بازگشت» از صفحهٔ جزئیات، همین فیلترها و صفحه را برگرداند.
|
||||||
const [search, setSearch] = useState('');
|
const [urlState, setUrlState] = useUrlState({ page: '1', search: '', city: '' });
|
||||||
const [cityFilter, setCityFilter] = useState<number | null>(null);
|
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 [addOpen, setAddOpen] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Representation | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Representation | null>(null);
|
||||||
const limit = 15;
|
const limit = 15;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { TrashIcon, PencilIcon, MagnifyingGlassIcon, CurrencyDollarIcon } from '
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
|
import { useUrlState, pageOf } from '../hooks/useUrlState';
|
||||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||||
import type { Secretary, SecretaryPermissions } from '../types';
|
import type { Secretary, SecretaryPermissions } from '../types';
|
||||||
import { formatDate, formatNumber, maskMobile } from '../lib/utils';
|
import { formatDate, formatNumber, maskMobile } from '../lib/utils';
|
||||||
@@ -228,8 +229,12 @@ function PermissionsMatrix({
|
|||||||
|
|
||||||
export default function SecretariesPage() {
|
export default function SecretariesPage() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [page, setPage] = useState(1);
|
// وضعیت لیست در URL میماند تا «بازگشت» از صفحهٔ جزئیات، همین جستجو و صفحه را برگرداند.
|
||||||
const [search, setSearch] = useState('');
|
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 [editTarget, setEditTarget] = useState<Secretary | null>(null);
|
||||||
const [editPerms, setEditPerms] = useState<SecretaryPermissions>(DEFAULT_PERMISSIONS);
|
const [editPerms, setEditPerms] = useState<SecretaryPermissions>(DEFAULT_PERMISSIONS);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Secretary | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Secretary | null>(null);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { EyeIcon, CheckIcon, XMarkIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
import { EyeIcon, CheckIcon, XMarkIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
|
import { useUrlState, pageOf } from '../hooks/useUrlState';
|
||||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||||
import type { Settlement } from '../types';
|
import type { Settlement } from '../types';
|
||||||
import { formatDate, formatRial } from '../lib/utils';
|
import { formatDate, formatRial } from '../lib/utils';
|
||||||
@@ -24,9 +25,14 @@ const STATUS_FILTERS = [
|
|||||||
export default function SettlementsPage() {
|
export default function SettlementsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [page, setPage] = useState(1);
|
// وضعیت لیست در URL میماند تا «بازگشت» از صفحهٔ جزئیات، همین فیلترها و صفحه را برگرداند.
|
||||||
const [search, setSearch] = useState('');
|
const [urlState, setUrlState] = useUrlState({ page: '1', search: '', status: 'pending' });
|
||||||
const [statusFilter, setStatusFilter] = useState('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 [approveTarget, setApproveTarget] = useState<Settlement | null>(null);
|
||||||
const [rejectTarget, setRejectTarget] = useState<Settlement | null>(null);
|
const [rejectTarget, setRejectTarget] = useState<Settlement | null>(null);
|
||||||
const [rejectReason, setRejectReason] = useState('');
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
|
import { useUrlState, pageOf } from '../hooks/useUrlState';
|
||||||
import Portal from '../components/ui/Portal';
|
import Portal from '../components/ui/Portal';
|
||||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||||
import { formatDate, formatNumber, maskMobile } from '../lib/utils';
|
import { formatDate, formatNumber, maskMobile } from '../lib/utils';
|
||||||
@@ -170,22 +171,27 @@ export default function UsersPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const qc = useQueryClient();
|
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 [limit] = useState(25);
|
||||||
const [searchInput, setSearchInput] = useState('');
|
// فیلد جستجو local میماند (تایپ روان)؛ مقدارِ debounceشده به URL میرود.
|
||||||
const [search, setSearch] = useState('');
|
const [searchInput, setSearchInput] = useState(urlState.search);
|
||||||
const [role, setRole] = useState('');
|
|
||||||
const [status, setStatus] = useState('');
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<AdminUser | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<AdminUser | null>(null);
|
||||||
const [roleTarget, setRoleTarget] = useState<AdminUser | null>(null);
|
const [roleTarget, setRoleTarget] = useState<AdminUser | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setTimeout(() => { setSearch(searchInput); setPage(1); }, 350);
|
const t = setTimeout(() => setUrlState({ search: searchInput, page: '1' }), 350);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [searchInput]);
|
}, [searchInput]);
|
||||||
|
|
||||||
useEffect(() => { setPage(1); }, [role, status]);
|
|
||||||
|
|
||||||
// ── Queries ──
|
// ── Queries ──
|
||||||
|
|
||||||
const statsQ = useQuery({
|
const statsQ = useQuery({
|
||||||
|
|||||||
@@ -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`) هم ذخیره میشود.
|
> **هویت بیمار بر پایهی کد ملی:** کد ملی روی **پروفایل** بیمار ذخیره میشود (`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` جدا میشود).
|
> **نامِ نمایشدادهشدهی بیمار:** چون بیمار با کد ملی به پروفایل واقعیاش 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`
|
### Response `201`
|
||||||
```json
|
```json
|
||||||
|
|||||||
Reference in New Issue
Block a user