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
@@ -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>
);
}