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,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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* بیعانه */}
|
||||
|
||||
Reference in New Issue
Block a user