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:
@@ -18,10 +18,35 @@ beforeEach(() => {
|
||||
get.mockReset();
|
||||
post.mockReset();
|
||||
useAuthStore.setState({ primaryRole: 'doctor', dbUuid: 'doc1' } as any);
|
||||
get.mockResolvedValue({ success: true, data: [] });
|
||||
get.mockImplementation((url: string) => (
|
||||
url.startsWith('/api/v1/appointment-slots')
|
||||
? Promise.resolve(SLOTS_RESPONSE)
|
||||
: Promise.resolve({ success: true, data: [] })
|
||||
));
|
||||
post.mockResolvedValue({ success: true, data: { uuid: 'new1' } });
|
||||
});
|
||||
|
||||
|
||||
/** اسلاتهای واقعیِ برنامهٔ پزشک — حالت اسلاتی دیگر ساعت دستی نمیگیرد. */
|
||||
const SLOTS_RESPONSE = {
|
||||
success: true,
|
||||
data: {
|
||||
sessions: [{
|
||||
start_time: '15:00', end_time: '16:00',
|
||||
slots: [
|
||||
{ start: 1754000000, end: 1754001800, start_time: '15:00', end_time: '15:30', is_available: true },
|
||||
{ start: 1754001800, end: 1754003600, start_time: '15:30', end_time: '16:00', is_available: false },
|
||||
],
|
||||
}],
|
||||
empty_reason: null,
|
||||
},
|
||||
};
|
||||
|
||||
/** انتخاب اولین اسلات آزاد (حالت اسلاتی). */
|
||||
async function pickSlot() {
|
||||
fireEvent.click(await screen.findByRole('button', { name: '15:00' }));
|
||||
}
|
||||
|
||||
describe('AppointmentCreatePage — افزودن نوبت', () => {
|
||||
it('posts a new appointment with the entered patient (happy path)', async () => {
|
||||
renderWithProviders(<AppointmentCreatePage />, { route: '/admin/appointments/new' });
|
||||
@@ -31,6 +56,7 @@ describe('AppointmentCreatePage — افزودن نوبت', () => {
|
||||
fireEvent.change(screen.getByPlaceholderText('شماره تماس مراجعه کننده'), { target: { value: '09121234567' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('کد ملی مراجعه کننده'), { target: { value: '1234567891' } });
|
||||
|
||||
await pickSlot();
|
||||
fireEvent.click(screen.getByText('ثبت اطلاعات'));
|
||||
|
||||
await waitFor(() => expect(post).toHaveBeenCalled());
|
||||
@@ -45,6 +71,7 @@ describe('AppointmentCreatePage — افزودن نوبت', () => {
|
||||
return Promise.resolve({ success: true, data: { booking_mode: 'service', buffer_minutes: 5, services: [{ uuid: 'sv1', name: 'عصبکشی', duration_minutes: 30, price_rials: 500000, service_section: { uuid: 'sec1', name: 'دندان' } }] } });
|
||||
if (url.startsWith('/api/v1/appointment-service-slots'))
|
||||
return Promise.resolve({ success: true, data: { total_duration_minutes: 30, buffer_minutes: 5, start_times: [{ start: 1754000000, end: 1754001800, start_time: '15:00' }] } });
|
||||
if (url.startsWith('/api/v1/appointment-slots')) return Promise.resolve(SLOTS_RESPONSE);
|
||||
return Promise.resolve({ success: true, data: [] });
|
||||
});
|
||||
|
||||
@@ -55,8 +82,10 @@ describe('AppointmentCreatePage — افزودن نوبت', () => {
|
||||
fireEvent.change(screen.getByPlaceholderText('شماره تماس مراجعه کننده'), { target: { value: '09121234567' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('کد ملی مراجعه کننده'), { target: { value: '1234567891' } });
|
||||
|
||||
// صبر تا حالت سرویسی واقعاً رندر شود (پیش از آن، حالت اسلاتی است).
|
||||
await waitFor(() => expect(document.getElementById('service-mode-section-select')).not.toBeNull());
|
||||
// حالت سرویس: ورودی ساعت شروع نباید باشد
|
||||
await waitFor(() => expect(screen.queryByLabelText('ساعت شروع')).toBeNull());
|
||||
expect(screen.queryByLabelText('ساعت شروع')).toBeNull();
|
||||
|
||||
// حالت سرویسی هم «بخش → سرویس» است: ابتدا بخش، سپس سرویس
|
||||
const secInput = document.getElementById('service-mode-section-select') as HTMLInputElement;
|
||||
@@ -72,6 +101,48 @@ describe('AppointmentCreatePage — افزودن نوبت', () => {
|
||||
expect(body).toMatchObject({ doctor_uuid: 'doc1', slot_start: 1754000000, slot_end: 1754001800, service_item_uuids: ['sv1'] });
|
||||
});
|
||||
|
||||
it('حالت اسلاتی: اسلات پرشده قابل انتخاب نیست', async () => {
|
||||
renderWithProviders(<AppointmentCreatePage />, { route: '/admin/appointments/new' });
|
||||
|
||||
const taken = await screen.findByRole('button', { name: '15:30' });
|
||||
expect(taken).toBeDisabled();
|
||||
expect(await screen.findByRole('button', { name: '15:00' })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('نوبتِ ساختهشده بلافاصله قطعی میشود', async () => {
|
||||
renderWithProviders(<AppointmentCreatePage />, { route: '/admin/appointments/new' });
|
||||
|
||||
fireEvent.click(screen.getByText('مراجعه کننده جدید'));
|
||||
fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی مراجعه کننده'), { target: { value: 'علی محمدی' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('شماره تماس مراجعه کننده'), { target: { value: '09121234567' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('کد ملی مراجعه کننده'), { target: { value: '1234567891' } });
|
||||
await pickSlot();
|
||||
fireEvent.click(screen.getByText('ثبت اطلاعات'));
|
||||
|
||||
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/appointment/new1/confirm', { payments: [] }));
|
||||
});
|
||||
|
||||
it('ورود از پروندهٔ بیمار: مراجعهکننده پیشپر میشود و جستجو نمایش داده نمیشود', async () => {
|
||||
get.mockImplementation((url: string) => {
|
||||
if (url === '/api/v1/patient/rec7')
|
||||
return Promise.resolve({ success: true, data: { uuid: 'rec7', user_name: 'مریم خلیلی', user_mobile: '09136549874', user_national_code: '0012345675' } });
|
||||
if (url.startsWith('/api/v1/appointment-slots')) return Promise.resolve(SLOTS_RESPONSE);
|
||||
return Promise.resolve({ success: true, data: [] });
|
||||
});
|
||||
|
||||
renderWithProviders(<AppointmentCreatePage />, { route: '/admin/appointments/new?record=rec7' });
|
||||
|
||||
expect(await screen.findByText('مریم خلیلی')).toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText('جستجوی نام، شماره تماس، شماره پرونده...')).toBeNull();
|
||||
expect(screen.queryByText('مراجعه کننده جدید')).toBeNull();
|
||||
|
||||
await pickSlot();
|
||||
fireEvent.click(screen.getByText('ثبت اطلاعات'));
|
||||
await waitFor(() => expect(post).toHaveBeenCalled());
|
||||
const [, body] = post.mock.calls[0];
|
||||
expect(body).toMatchObject({ patient_name: 'مریم خلیلی', patient_mobile: '09136549874', patient_national_code: '0012345675' });
|
||||
});
|
||||
|
||||
it('keeps the submit button disabled until a valid patient is entered (boundary)', () => {
|
||||
renderWithProviders(<AppointmentCreatePage />, { route: '/admin/appointments/new' });
|
||||
const btn = screen.getByText('ثبت اطلاعات') as HTMLButtonElement;
|
||||
@@ -96,6 +167,7 @@ describe('AppointmentCreatePage — افزودن نوبت', () => {
|
||||
get.mockImplementation((url: string) => {
|
||||
if (url.startsWith('/api/v1/patients?'))
|
||||
return Promise.resolve({ success: true, data: [{ uuid: 'rec1', user_name: 'حامد حسینی', user_mobile: '09210671745', user_national_code: '0012345675' }] });
|
||||
if (url.startsWith('/api/v1/appointment-slots')) return Promise.resolve(SLOTS_RESPONSE);
|
||||
return Promise.resolve({ success: true, data: [] });
|
||||
});
|
||||
|
||||
@@ -104,6 +176,7 @@ describe('AppointmentCreatePage — افزودن نوبت', () => {
|
||||
fireEvent.change(screen.getByPlaceholderText('جستجوی نام، شماره تماس، شماره پرونده...'), { target: { value: 'حامد' } });
|
||||
fireEvent.click(await screen.findByText(/حامد حسینی/));
|
||||
|
||||
await pickSlot();
|
||||
const btn = screen.getByText('ثبت اطلاعات') as HTMLButtonElement;
|
||||
await waitFor(() => expect(btn).not.toBeDisabled());
|
||||
|
||||
@@ -121,6 +194,7 @@ describe('AppointmentCreatePage — افزودن نوبت', () => {
|
||||
return Promise.resolve({ success: true, data: [{ uuid: 'i1', name: 'لیزر' }, { uuid: 'i2', name: 'ماساژ' }] });
|
||||
if (url === '/api/v1/service-items/sec2')
|
||||
return Promise.resolve({ success: true, data: [{ uuid: 'i3', name: 'پاکسازی' }] });
|
||||
if (url.startsWith('/api/v1/appointment-slots')) return Promise.resolve(SLOTS_RESPONSE);
|
||||
return Promise.resolve({ success: true, data: [] });
|
||||
});
|
||||
|
||||
@@ -153,6 +227,7 @@ describe('AppointmentCreatePage — افزودن نوبت', () => {
|
||||
fireEvent.click(screen.getByLabelText('حذف ماساژ'));
|
||||
expect(screen.getByText('سرویسهای انتخابشده (2)')).toBeInTheDocument();
|
||||
|
||||
await pickSlot();
|
||||
fireEvent.click(screen.getByText('ثبت اطلاعات'));
|
||||
await waitFor(() => expect(post).toHaveBeenCalled());
|
||||
const [, body] = post.mock.calls[0];
|
||||
@@ -164,6 +239,7 @@ describe('AppointmentCreatePage — افزودن نوبت', () => {
|
||||
get.mockImplementation((url: string) => {
|
||||
if (url.startsWith('/api/v1/patients?'))
|
||||
return Promise.resolve({ success: true, data: [{ uuid: 'rec2', user_name: 'حامد حسینی', user_mobile: '09210671745', user_national_code: null }] });
|
||||
if (url.startsWith('/api/v1/appointment-slots')) return Promise.resolve(SLOTS_RESPONSE);
|
||||
return Promise.resolve({ success: true, data: [] });
|
||||
});
|
||||
|
||||
@@ -175,8 +251,9 @@ describe('AppointmentCreatePage — افزودن نوبت', () => {
|
||||
const btn = screen.getByText('ثبت اطلاعات') as HTMLButtonElement;
|
||||
expect(btn).toBeDisabled();
|
||||
|
||||
// کد ملیِ خالیِ رکورد را دستی وارد کن → فعال میشود
|
||||
// کد ملیِ خالیِ رکورد را دستی وارد کن + انتخاب اسلات → فعال میشود
|
||||
fireEvent.change(screen.getByPlaceholderText('کد ملی مراجعه کننده'), { target: { value: '0012345675' } });
|
||||
await pickSlot();
|
||||
await waitFor(() => expect(btn).not.toBeDisabled());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import { WalletChargeLink } from '../components/AppointmentActions';
|
||||
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
|
||||
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
|
||||
import SlotPicker, { type PickedSlot } from '../components/appointments/SlotPicker';
|
||||
import { tehranWallClockToUnix, rialToToman, tomanToRial } from '../lib/utils';
|
||||
import BackButton from '../components/ui/BackButton';
|
||||
import { digitsOnly, todayIso } from '../lib/utils';
|
||||
@@ -69,6 +70,26 @@ export default function AppointmentCreatePage() {
|
||||
});
|
||||
const patients = useMemo(() => patientsQ.data?.data ?? [], [patientsQ.data]);
|
||||
|
||||
// ورود از تب «نوبتها»ی پروندهٔ بیمار: مراجعهکننده از قبل معلوم است، پس بهجای
|
||||
// جستجو، خودِ پرونده خوانده و قفل میشود.
|
||||
const fromRecordUuid = params.get('record') ?? '';
|
||||
const recordQ = useQuery<ApiResponse<PatientRow & { user_name?: string }>>({
|
||||
queryKey: ['create-appt-record', fromRecordUuid],
|
||||
queryFn: () => api.get(`/api/v1/patient/${fromRecordUuid}`),
|
||||
enabled: !!fromRecordUuid,
|
||||
});
|
||||
useEffect(() => {
|
||||
const rec: any = (recordQ.data?.data as any)?.data ?? recordQ.data?.data;
|
||||
if (!rec) return;
|
||||
setPicked({
|
||||
uuid: rec.uuid,
|
||||
user_name: rec.user_name,
|
||||
user_mobile: rec.user_mobile,
|
||||
user_national_code: rec.user_national_code ?? rec.profile?.national_code,
|
||||
});
|
||||
setNationalCode(String(rec.user_national_code ?? rec.profile?.national_code ?? '').replace(/\D/g, '').slice(0, 10));
|
||||
}, [recordQ.data]);
|
||||
|
||||
// ── مشخصات سرویس
|
||||
const [sectionUuid, setSectionUuid] = useState(''); // بخشِ در حال مرور (برای دیدن سرویسهایش)
|
||||
// سرویسهای انتخابشده — انباشته از چند بخش؛ هر کدام قابل حذف. نام را نگه میداریم تا در chip نشان دهیم.
|
||||
@@ -95,10 +116,16 @@ export default function AppointmentCreatePage() {
|
||||
|
||||
// ── زمان نوبت
|
||||
const [date, setDate] = useState(params.get('date') || today);
|
||||
// حالت اسلاتی: انتخاب از اسلاتهای واقعیِ برنامهٔ پزشک. ورود دستیِ ساعت فقط وقتی
|
||||
// باقی میماند که پزشک برای آن روز اصلاً برنامهای نداشته باشد (نوبت خارج از برنامه).
|
||||
const [slotPick, setSlotPick] = useState<PickedSlot | null>(null);
|
||||
const [manualTime, setManualTime] = useState(false);
|
||||
const [duration, setDuration] = useState(40);
|
||||
const [start, setStart] = useState('15:00');
|
||||
const [end, setEnd] = useState(addMinutes('15:00', 40));
|
||||
useEffect(() => { setEnd(addMinutes(start, duration)); }, [start, duration]);
|
||||
// تعویض پزشک/تاریخ ⇒ اسلات انتخابشده دیگر معتبر نیست.
|
||||
useEffect(() => { setSlotPick(null); }, [doctorUuid, date]);
|
||||
|
||||
// ── بیعانه / وضعیت / توضیحات
|
||||
const [depositRequired, setDepositRequired] = useState(false);
|
||||
@@ -125,17 +152,20 @@ export default function AppointmentCreatePage() {
|
||||
const effectiveNationalCode = (picked?.user_national_code || nationalCode).replace(/\D/g, '');
|
||||
const timingValid = serviceMode
|
||||
? (servicePick.serviceUuids.length > 0 && !!servicePick.slot)
|
||||
: (!!start && !!end);
|
||||
: (manualTime ? (!!start && !!end) : !!slotPick);
|
||||
const valid = !!doctorUuid && !!date && effectiveName.length >= 2 && effectiveMobile.length >= 10
|
||||
&& effectiveNationalCode.length === 10 && timingValid && (!requireVisit || visitPriceToman > 0);
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async () => {
|
||||
const createEndpoint = primaryRole === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/my/appointment';
|
||||
const slotStart = serviceMode ? servicePick.slot!.start : (slotPick ? slotPick.start : toEpoch(date, start));
|
||||
const slotEnd = serviceMode ? servicePick.slot!.end : (slotPick ? slotPick.end : toEpoch(date, end));
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
doctor_uuid: doctorUuid,
|
||||
slot_start: serviceMode ? servicePick.slot!.start : toEpoch(date, start),
|
||||
slot_end: serviceMode ? servicePick.slot!.end : toEpoch(date, end),
|
||||
slot_start: slotStart,
|
||||
slot_end: slotEnd,
|
||||
patient_name: effectiveName,
|
||||
patient_mobile: effectiveMobile,
|
||||
patient_national_code: effectiveNationalCode,
|
||||
@@ -150,13 +180,25 @@ export default function AppointmentCreatePage() {
|
||||
...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}),
|
||||
...(note.trim() ? { note: note.trim() } : {}),
|
||||
};
|
||||
// نوبت همیشه «ثبت شده» متولد میشود؛ قطعیکردن یک عملِ جداست که هزینهها را
|
||||
// نشان میدهد و پرداخت میگیرد (مودال «قطعی کردن نوبت»).
|
||||
return api.post(createEndpoint, payload);
|
||||
// سرور نوبت را «ثبتشده» متولد میکند؛ این صفحه اما نوبتِ **قطعی** میسازد، پس
|
||||
// بلافاصله همان endpoint قطعیکردن صدا زده میشود (بدون پرداخت — پرداخت از
|
||||
// مودال «قطعی کردن» یا صفحهٔ پرداخت انجام میشود).
|
||||
const created: any = await api.post(createEndpoint, payload);
|
||||
const uuid = created?.data?.uuid ?? created?.data?.data?.uuid;
|
||||
if (!uuid) return created;
|
||||
|
||||
try {
|
||||
await api.post(`/api/v1/appointment/${uuid}/confirm`, { payments: [] });
|
||||
} catch (e: any) {
|
||||
// نوبت ثبت شده و اسلات را گرفته؛ فقط قطعی نشده. کاربر باید بداند.
|
||||
toast.error(e?.message || 'نوبت ثبت شد ولی قطعی نشد؛ از لیست نوبتها قطعی کنید');
|
||||
}
|
||||
|
||||
return created;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['appointments'] });
|
||||
toast.success('نوبت با موفقیت ثبت شد');
|
||||
toast.success('نوبت قطعی ثبت شد');
|
||||
// به همان روزِ نوبت برگرد، نه امروز.
|
||||
navigate(`/admin/appointments?date=${date}`);
|
||||
},
|
||||
@@ -195,6 +237,30 @@ export default function AppointmentCreatePage() {
|
||||
{/* مراجعه کننده */}
|
||||
<div style={{ ...sectionTitle, marginTop: isDoctor ? 0 : 18 }}>اطلاعات مراجعه کننده :</div>
|
||||
|
||||
{/* آمده از پروندهٔ بیمار: مراجعهکننده معلوم است، جستجو معنا ندارد. */}
|
||||
{fromRecordUuid ? (
|
||||
<div style={{ maxWidth: 400, border: '1px solid var(--border-2)', borderRadius: 'var(--r-sm)', padding: 12, marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, marginBottom: 6 }}>
|
||||
{picked?.user_name ?? 'در حال بارگذاری پرونده...'}
|
||||
<span style={{ color: 'var(--text-3)', direction: 'ltr', fontWeight: 400 }}> {picked?.user_mobile ?? ''}</span>
|
||||
</div>
|
||||
{picked && !/^\d{10}$/.test(effectiveNationalCode) ? (
|
||||
<>
|
||||
<label style={label}>کد ملی (در پرونده ثبت نشده — وارد کنید)</label>
|
||||
<div className="field" style={{ marginTop: 6, height: 44 }}>
|
||||
<DigitInput value={nationalCode} onChange={setNationalCode} maxDigits={10}
|
||||
placeholder="کد ملی مراجعه کننده" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: 13 }}>
|
||||
<span style={{ color: 'var(--text-3)' }}>کد ملی</span>
|
||||
<span dir="ltr" style={{ fontWeight: 600 }}>{effectiveNationalCode}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* حالت جستجوی مراجعهکنندهٔ موجود */}
|
||||
{!newPatient && (
|
||||
<>
|
||||
@@ -289,6 +355,8 @@ export default function AppointmentCreatePage() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* مشخصات سرویس */}
|
||||
<div style={sectionTitle}>مشخصات سرویس</div>
|
||||
@@ -423,26 +491,52 @@ export default function AppointmentCreatePage() {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 12 }}>
|
||||
<div>
|
||||
<>
|
||||
<div style={{ maxWidth: 400, marginBottom: 12 }}>
|
||||
<label style={label}>انتخاب تاریخ</label>
|
||||
<div style={{ marginTop: 6 }}><PersianDateInput value={date} onChange={setDate} /></div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>زمان پیش فرض (دقیقه)</label>
|
||||
<div className="field" style={{ marginTop: 6, height: 44 }}>
|
||||
<input aria-label="زمان پیش فرض" type="text" inputMode="numeric" value={duration} onChange={e => setDuration(Math.max(5, Number(digitsOnly(e.target.value)) || 0))} dir="ltr" />
|
||||
|
||||
{!manualTime ? (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={label}>انتخاب زمان از برنامهٔ پزشک</label>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
{doctorUuid ? (
|
||||
<SlotPicker doctorUuid={doctorUuid} date={date} value={slotPick} onSelect={setSlotPick} />
|
||||
) : (
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-3)' }}>ابتدا پزشک را انتخاب کنید.</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="btn sm ghost" style={{ marginTop: 8 }}
|
||||
onClick={() => { setManualTime(true); setSlotPick(null); }}>
|
||||
ثبت خارج از برنامه (ورود دستی ساعت)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>ساعت شروع</label>
|
||||
<div className="field" style={{ marginTop: 6, height: 44 }}><input aria-label="ساعت شروع" type="time" value={start} onChange={e => setStart(e.target.value)} dir="ltr" /></div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>ساعت پایان</label>
|
||||
<div className="field" style={{ marginTop: 6, height: 44 }}><input aria-label="ساعت پایان" type="time" value={end} onChange={e => setEnd(e.target.value)} dir="ltr" /></div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16, marginBottom: 8 }}>
|
||||
<div>
|
||||
<label style={label}>زمان پیش فرض (دقیقه)</label>
|
||||
<div className="field" style={{ marginTop: 6, height: 44 }}>
|
||||
<input aria-label="زمان پیش فرض" type="text" inputMode="numeric" value={duration} onChange={e => setDuration(Math.max(5, Number(digitsOnly(e.target.value)) || 0))} dir="ltr" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>ساعت شروع</label>
|
||||
<div className="field" style={{ marginTop: 6, height: 44 }}><input aria-label="ساعت شروع" type="time" value={start} onChange={e => setStart(e.target.value)} dir="ltr" /></div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>ساعت پایان</label>
|
||||
<div className="field" style={{ marginTop: 6, height: 44 }}><input aria-label="ساعت پایان" type="time" value={end} onChange={e => setEnd(e.target.value)} dir="ltr" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn sm ghost" style={{ marginBottom: 12 }}
|
||||
onClick={() => setManualTime(false)}>
|
||||
بازگشت به انتخاب از برنامهٔ پزشک
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* بیعانه */}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { EyeIcon, PencilIcon, TrashIcon, PlusIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import { useUrlState, pageOf } from '../hooks/useUrlState';
|
||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
import type { Blog } from '../types';
|
||||
import { formatDate } from '../lib/utils';
|
||||
@@ -21,9 +22,14 @@ const STATUS_FILTERS = [
|
||||
export default function BlogsPage() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
// وضعیت لیست در URL میماند تا «بازگشت» از صفحهٔ جزئیات، همین فیلترها و صفحه را برگرداند.
|
||||
const [urlState, setUrlState] = useUrlState({ page: '1', search: '', status: '' });
|
||||
const page = pageOf(urlState.page);
|
||||
const search = urlState.search;
|
||||
const statusFilter = urlState.status;
|
||||
const setPage = (p: number) => setUrlState({ page: String(p) });
|
||||
const setSearch = (v: string) => setUrlState({ search: v, page: '1' });
|
||||
const setStatusFilter = (v: string) => setUrlState({ status: v, page: '1' });
|
||||
const [deleteTarget, setDeleteTarget] = useState<Blog | null>(null);
|
||||
const limit = 15;
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { api } from '../lib/api';
|
||||
import { useUrlState, pageOf } from '../hooks/useUrlState';
|
||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
import type { Clinic } from '../types';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
@@ -39,9 +40,14 @@ export default function ClinicsPage() {
|
||||
const qc = useQueryClient();
|
||||
const primaryRole = useAuthStore(s => s.primaryRole);
|
||||
const isRepresentation = primaryRole === 'representation';
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
// وضعیت لیست در URL میماند تا «بازگشت» از صفحهٔ جزئیات، همین فیلترها و صفحه را برگرداند.
|
||||
const [urlState, setUrlState] = useUrlState({ page: '1', search: '', status: '' });
|
||||
const page = pageOf(urlState.page);
|
||||
const search = urlState.search;
|
||||
const statusFilter = urlState.status;
|
||||
const setPage = (p: number) => setUrlState({ page: String(p) });
|
||||
const setSearch = (v: string) => setUrlState({ search: v, page: '1' });
|
||||
const setStatusFilter = (v: string) => setUrlState({ status: v, page: '1' });
|
||||
const [deleteTarget, setDeleteTarget] = useState<Clinic | null>(null);
|
||||
const [mobileTarget, setMobileTarget] = useState<{ uuid: string; name: string; mobile_number?: string | null } | null>(null);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import { useUrlState, pageOf } from '../hooks/useUrlState';
|
||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
import { formatDate, formatNumber, displayDoctorName } from '../lib/utils';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
@@ -101,26 +102,37 @@ export default function DoctorsPage() {
|
||||
const primaryRole = useAuthStore(s => s.primaryRole);
|
||||
const isRepresentation = primaryRole === 'representation';
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
// وضعیت لیست در URL میماند تا «بازگشت» از پروفایل پزشک، همین فیلترها و صفحه را برگرداند.
|
||||
const [urlState, setUrlState] = useUrlState({
|
||||
page: '1', search: '', status: '', owner: '', specialty: '', province: '', city: '', view: 'table',
|
||||
});
|
||||
const page = pageOf(urlState.page);
|
||||
const search = urlState.search;
|
||||
const status = urlState.status;
|
||||
const ownerStatus = urlState.owner;
|
||||
const specialtyId = urlState.specialty;
|
||||
const provinceId = urlState.province;
|
||||
const cityId = urlState.city;
|
||||
const view: 'table' | 'grid' = urlState.view === 'grid' ? 'grid' : 'table';
|
||||
const setPage = (p: number) => setUrlState({ page: String(p) });
|
||||
const setStatus = (v: string) => setUrlState({ status: v, page: '1' });
|
||||
const setOwnerStatus = (v: string) => setUrlState({ owner: v, page: '1' });
|
||||
const setSpecialty = (v: string) => setUrlState({ specialty: v, page: '1' });
|
||||
const setProvinceId = (v: string) => setUrlState({ province: v, city: '', page: '1' });
|
||||
const setCityId = (v: string) => setUrlState({ city: v, page: '1' });
|
||||
const setView = (v: 'table' | 'grid') => setUrlState({ view: v });
|
||||
|
||||
const [limit] = useState(25);
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [ownerStatus, setOwnerStatus] = useState('');
|
||||
const [specialtyId, setSpecialty] = useState('');
|
||||
const [provinceId, setProvinceId] = useState('');
|
||||
const [cityId, setCityId] = useState('');
|
||||
const [view, setView] = useState<'table' | 'grid'>('table');
|
||||
// فیلد جستجو local میماند (تایپ روان)؛ مقدارِ debounceشده به URL میرود.
|
||||
const [searchInput, setSearchInput] = useState(urlState.search);
|
||||
const [deleteTarget, setDeleteTarget] = useState<AdminDoctor | null>(null);
|
||||
const [mobileTarget, setMobileTarget] = useState<{ uuid: string; name: string; mobile_number?: string | null } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => { setSearch(searchInput); setPage(1); }, 350);
|
||||
const t = setTimeout(() => setUrlState({ search: searchInput, page: '1' }), 350);
|
||||
return () => clearTimeout(t);
|
||||
}, [searchInput]);
|
||||
|
||||
useEffect(() => { setPage(1); }, [status, ownerStatus, specialtyId, provinceId, cityId]);
|
||||
|
||||
// ── Queries ──
|
||||
|
||||
const statsQ = useQuery({
|
||||
|
||||
@@ -328,7 +328,8 @@ describe('PatientDetailPage (پرونده تبدار)', () => {
|
||||
expect(screen.getByText('ساعت:')).toBeInTheDocument();
|
||||
// toolbar buttons link to the existing appointment pages
|
||||
expect(screen.getByRole('link', { name: /نوبت رزرو/ })).toHaveAttribute('href', '/admin/appointments/reserve');
|
||||
expect(screen.getByRole('link', { name: /نوبت جدید/ })).toHaveAttribute('href', '/admin/appointments/new');
|
||||
// `record` همراه لینک میرود تا صفحهٔ ثبت نوبت، مراجعهکننده را پیشپر کند و جستجو نخواهد.
|
||||
expect(screen.getByRole('link', { name: /نوبت جدید/ })).toHaveAttribute('href', '/admin/appointments/new?record=r1');
|
||||
});
|
||||
|
||||
it('shows the empty state on the نوبتها tab when there are no appointments', async () => {
|
||||
|
||||
@@ -1001,7 +1001,8 @@ function AppointmentsTab({ uuid, q }: {
|
||||
<Link to="/admin/appointments/reserve" className="flex items-center justify-center gap-2 rounded-[8px]" style={{ height: 48, minWidth: 137, border: '1px solid var(--primary)', color: 'var(--primary)', textDecoration: 'none', padding: '0 16px', fontSize: 14 }}>
|
||||
<AddTurn color="var(--primary)" /> نوبت رزرو
|
||||
</Link>
|
||||
<Link to="/admin/appointments/new" className="flex items-center justify-center gap-2 rounded-[8px]" style={{ height: 48, minWidth: 137, background: 'var(--primary)', color: 'var(--on-primary)', textDecoration: 'none', padding: '0 16px', fontSize: 14 }}>
|
||||
{/* `record` یعنی مراجعهکننده از همین پرونده میآید؛ صفحهٔ ثبت نوبت دیگر جستجو نمیخواهد. */}
|
||||
<Link to={`/admin/appointments/new?record=${uuid}`} className="flex items-center justify-center gap-2 rounded-[8px]" style={{ height: 48, minWidth: 137, background: 'var(--primary)', color: 'var(--on-primary)', textDecoration: 'none', padding: '0 16px', fontSize: 14 }}>
|
||||
<AddTurn color="var(--on-primary)" /> نوبت جدید
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
UserIcon, EllipsisHorizontalIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { api } from '../lib/api';
|
||||
import { useUrlState, pageOf } from '../hooks/useUrlState';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import type { PatientRecord } from '../types';
|
||||
import { formatNumber, toDate } from '../lib/utils';
|
||||
@@ -105,14 +106,43 @@ export default function PatientsListPage() {
|
||||
const { can } = usePermissions();
|
||||
const canCreate = can('patients', 'create');
|
||||
const canUpdate = can('patients', 'update');
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [view, setView] = useState<'table' | 'card'>('card');
|
||||
// وضعیت لیست (جستجو، فیلترها، نما، صفحه) در URL میماند تا «بازگشت» از پرونده،
|
||||
// دقیقاً همین نما را برگرداند — نه لیست خام صفحهٔ اول.
|
||||
const [urlState, setUrlState] = useUrlState({
|
||||
page: '1', search: '', view: 'card',
|
||||
gender: '', insurance_id: '', service_status: '', has_debt: '', tags: '',
|
||||
admitted_from: '', admitted_to: '',
|
||||
});
|
||||
const page = pageOf(urlState.page);
|
||||
const search = urlState.search;
|
||||
const view: 'table' | 'card' = urlState.view === 'table' ? 'table' : 'card';
|
||||
const setPage = (p: number) => setUrlState({ page: String(p) });
|
||||
const setSearch = (v: string) => setUrlState({ search: v, page: '1' });
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [filters, setFilters] = useState<PatientFilters>({});
|
||||
|
||||
const filters: PatientFilters = useMemo(() => ({
|
||||
...(urlState.gender ? { gender: urlState.gender } : {}),
|
||||
...(urlState.insurance_id ? { insurance_id: urlState.insurance_id } : {}),
|
||||
...(urlState.service_status ? { service_status: urlState.service_status } : {}),
|
||||
...(urlState.has_debt ? { has_debt: true } : {}),
|
||||
...(urlState.tags ? { tags: urlState.tags.split(',') } : {}),
|
||||
...(urlState.admitted_from ? { admitted_from: urlState.admitted_from } : {}),
|
||||
...(urlState.admitted_to ? { admitted_to: urlState.admitted_to } : {}),
|
||||
}), [urlState]);
|
||||
|
||||
const setFilters = (f: PatientFilters) => setUrlState({
|
||||
gender: f.gender ?? '',
|
||||
insurance_id: f.insurance_id ?? '',
|
||||
service_status: f.service_status ?? '',
|
||||
has_debt: f.has_debt ? '1' : '',
|
||||
tags: f.tags?.join(',') ?? '',
|
||||
admitted_from: f.admitted_from ?? '',
|
||||
admitted_to: f.admitted_to ?? '',
|
||||
page: '1',
|
||||
});
|
||||
|
||||
const pageSize = view === 'card' ? CARD_PAGE_SIZE : TABLE_PAGE_SIZE;
|
||||
const switchView = (v: 'table' | 'card') => { setView(v); setPage(1); };
|
||||
const switchView = (v: 'table' | 'card') => setUrlState({ view: v, page: '1' });
|
||||
|
||||
const qs = new URLSearchParams({ page: String(page), limit: String(pageSize) });
|
||||
if (search) qs.set('search', search);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
BanknotesIcon, ClockIcon, CreditCardIcon, ArrowDownTrayIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { api } from '../lib/api';
|
||||
import { useUrlState, pageOf } from '../hooks/useUrlState';
|
||||
import type { PaginatedResponse } from '../lib/api';
|
||||
import type { Payment } from '../types';
|
||||
import { formatDate, formatRial, maskMobile } from '../lib/utils';
|
||||
@@ -24,9 +25,14 @@ const STATUS_FILTERS = [
|
||||
|
||||
export default function PaymentsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
// وضعیت لیست در URL میماند تا «بازگشت» از صفحهٔ جزئیات، همین فیلترها و صفحه را برگرداند.
|
||||
const [urlState, setUrlState] = useUrlState({ page: '1', search: '', status: '' });
|
||||
const page = pageOf(urlState.page);
|
||||
const search = urlState.search;
|
||||
const statusFilter = urlState.status;
|
||||
const setPage = (p: number) => setUrlState({ page: String(p) });
|
||||
const setSearch = (v: string) => setUrlState({ search: v, page: '1' });
|
||||
const setStatusFilter = (v: string) => setUrlState({ status: v, page: '1' });
|
||||
const limit = 15;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
|
||||
@@ -7,6 +7,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import { useUrlState, pageOf } from '../hooks/useUrlState';
|
||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
import MobileInput from '../components/ui/MobileInput';
|
||||
import { iranMobileSchema } from '../lib/utils';
|
||||
@@ -33,9 +34,14 @@ type FormData = z.infer<typeof schema>;
|
||||
export default function RepresentationsPage() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [cityFilter, setCityFilter] = useState<number | null>(null);
|
||||
// وضعیت لیست در URL میماند تا «بازگشت» از صفحهٔ جزئیات، همین فیلترها و صفحه را برگرداند.
|
||||
const [urlState, setUrlState] = useUrlState({ page: '1', search: '', city: '' });
|
||||
const page = pageOf(urlState.page);
|
||||
const search = urlState.search;
|
||||
const cityFilter = urlState.city === '' ? null : Number(urlState.city);
|
||||
const setPage = (p: number) => setUrlState({ page: String(p) });
|
||||
const setSearch = (v: string) => setUrlState({ search: v, page: '1' });
|
||||
const setCityFilter = (v: number | null) => setUrlState({ city: v === null ? '' : String(v), page: '1' });
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Representation | null>(null);
|
||||
const limit = 15;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { TrashIcon, PencilIcon, MagnifyingGlassIcon, CurrencyDollarIcon } from '
|
||||
import { Link } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import { useUrlState, pageOf } from '../hooks/useUrlState';
|
||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
import type { Secretary, SecretaryPermissions } from '../types';
|
||||
import { formatDate, formatNumber, maskMobile } from '../lib/utils';
|
||||
@@ -228,8 +229,12 @@ function PermissionsMatrix({
|
||||
|
||||
export default function SecretariesPage() {
|
||||
const qc = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
// وضعیت لیست در URL میماند تا «بازگشت» از صفحهٔ جزئیات، همین جستجو و صفحه را برگرداند.
|
||||
const [urlState, setUrlState] = useUrlState({ page: '1', search: '' });
|
||||
const page = pageOf(urlState.page);
|
||||
const search = urlState.search;
|
||||
const setPage = (p: number) => setUrlState({ page: String(p) });
|
||||
const setSearch = (v: string) => setUrlState({ search: v, page: '1' });
|
||||
const [editTarget, setEditTarget] = useState<Secretary | null>(null);
|
||||
const [editPerms, setEditPerms] = useState<SecretaryPermissions>(DEFAULT_PERMISSIONS);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Secretary | null>(null);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { EyeIcon, CheckIcon, XMarkIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import { useUrlState, pageOf } from '../hooks/useUrlState';
|
||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
import type { Settlement } from '../types';
|
||||
import { formatDate, formatRial } from '../lib/utils';
|
||||
@@ -24,9 +25,14 @@ const STATUS_FILTERS = [
|
||||
export default function SettlementsPage() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('pending');
|
||||
// وضعیت لیست در URL میماند تا «بازگشت» از صفحهٔ جزئیات، همین فیلترها و صفحه را برگرداند.
|
||||
const [urlState, setUrlState] = useUrlState({ page: '1', search: '', status: 'pending' });
|
||||
const page = pageOf(urlState.page);
|
||||
const search = urlState.search;
|
||||
const statusFilter = urlState.status;
|
||||
const setPage = (p: number) => setUrlState({ page: String(p) });
|
||||
const setSearch = (v: string) => setUrlState({ search: v, page: '1' });
|
||||
const setStatusFilter = (v: string) => setUrlState({ status: v, page: '1' });
|
||||
const [approveTarget, setApproveTarget] = useState<Settlement | null>(null);
|
||||
const [rejectTarget, setRejectTarget] = useState<Settlement | null>(null);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import { useUrlState, pageOf } from '../hooks/useUrlState';
|
||||
import Portal from '../components/ui/Portal';
|
||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
import { formatDate, formatNumber, maskMobile } from '../lib/utils';
|
||||
@@ -170,22 +171,27 @@ export default function UsersPage() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
// وضعیت لیست در URL میماند تا «بازگشت» از پروفایل کاربر، همین فیلترها و صفحه را برگرداند.
|
||||
const [urlState, setUrlState] = useUrlState({ page: '1', search: '', role: '', status: '' });
|
||||
const page = pageOf(urlState.page);
|
||||
const search = urlState.search;
|
||||
const role = urlState.role;
|
||||
const status = urlState.status;
|
||||
const setPage = (p: number) => setUrlState({ page: String(p) });
|
||||
const setRole = (v: string) => setUrlState({ role: v, page: '1' });
|
||||
const setStatus = (v: string) => setUrlState({ status: v, page: '1' });
|
||||
|
||||
const [limit] = useState(25);
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [role, setRole] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
// فیلد جستجو local میماند (تایپ روان)؛ مقدارِ debounceشده به URL میرود.
|
||||
const [searchInput, setSearchInput] = useState(urlState.search);
|
||||
const [deleteTarget, setDeleteTarget] = useState<AdminUser | null>(null);
|
||||
const [roleTarget, setRoleTarget] = useState<AdminUser | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => { setSearch(searchInput); setPage(1); }, 350);
|
||||
const t = setTimeout(() => setUrlState({ search: searchInput, page: '1' }), 350);
|
||||
return () => clearTimeout(t);
|
||||
}, [searchInput]);
|
||||
|
||||
useEffect(() => { setPage(1); }, [role, status]);
|
||||
|
||||
// ── Queries ──
|
||||
|
||||
const statsQ = useQuery({
|
||||
|
||||
Reference in New Issue
Block a user