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:
@@ -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: [] });
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user