- Updated DoctorPage component to accept bookingResources prop for appointment list. - Added serviceQuery function to serialize service item UUIDs for API requests. - Introduced new API endpoints for fetching booking resources and resource slots. - Enhanced tests for new resource-based booking functionality, including resource selection and service availability. - Created ResourceSelect component for selecting appointment types, including doctor and resource options. - Updated appointment submission logic to include resource_uuid in payload when applicable. - Ensured UI reflects changes in booking flow without disrupting existing doctor-centric experience.
338 lines
13 KiB
JavaScript
338 lines
13 KiB
JavaScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import moment from "moment-jalaali";
|
|
import { request } from "@/services/response";
|
|
import Cookies from "js-cookie";
|
|
import Container from "./Container";
|
|
import { safeJsonParse } from "@/lib/sanitize";
|
|
|
|
const defaultData = {
|
|
phone: { value: "", isEdit: false },
|
|
national_code: { value: "", isEdit: true },
|
|
name: { value: "", isEdit: true },
|
|
family: { value: "", isEdit: true },
|
|
gender: { value: "", isEdit: true },
|
|
insurance_id: { value: "", isEdit: true },
|
|
basic_insurance: { value: "", isEdit: true },
|
|
supplementary_insurance: { value: "", isEdit: true },
|
|
};
|
|
|
|
function buildProfileData(profile, phone) {
|
|
return {
|
|
uuid: profile.uuid,
|
|
phone: { value: phone, isEdit: false },
|
|
national_code: {
|
|
value: profile.national_code || "",
|
|
isEdit: !profile.national_code_approved,
|
|
},
|
|
name: { value: profile.label || "", isEdit: true },
|
|
family: { value: profile.family || "", isEdit: true },
|
|
gender: { value: profile.gender || "", isEdit: true },
|
|
insurance_id: { value: profile.insurance_id || "", isEdit: true },
|
|
basic_insurance: {
|
|
value: profile.basic_insurance_id ? { id: profile.basic_insurance_id } : "",
|
|
isEdit: true,
|
|
},
|
|
supplementary_insurance: {
|
|
value: profile.supplementary_insurance_id
|
|
? { id: profile.supplementary_insurance_id }
|
|
: "",
|
|
isEdit: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
function AppointmentPage({ doctor, disabledDates, matchedCity, initialClinicUuid = null }) {
|
|
const [step, setStep] = useState(0);
|
|
const [isForAnother, setIsForAnother] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
const [prevData, setPrevData] = useState(defaultData);
|
|
const [data, setData] = useState(defaultData);
|
|
|
|
// Login states
|
|
const [num, setNum] = useState("");
|
|
const [uuid, setUuid] = useState("");
|
|
const [isSendMsg, setIsSendMsg] = useState(false);
|
|
|
|
// Appointment slot states
|
|
const [selectedSlot, setSelectedSlot] = useState(null);
|
|
const [selectedDate, setSelectedDate] = useState(null);
|
|
const [appointmentId, setAppointmentId] = useState(null);
|
|
const [appointmentExpiresAt, setAppointmentExpiresAt] = useState(null);
|
|
|
|
// Service-based booking (روش نوبتدهی سرویسی)
|
|
const [selectedServiceUuids, setSelectedServiceUuids] = useState([]);
|
|
|
|
// نوبتدهی منبعمحور: دستگاه/اتاق/یونیتِ همین پزشک در همین محل.
|
|
// `selectedResource === null` یعنی «نوبت با پزشک» — همان جریان همیشگی.
|
|
const [resources, setResources] = useState([]);
|
|
const [selectedResource, setSelectedResource] = useState(null);
|
|
const [resourceConfirmed, setResourceConfirmed] = useState(false);
|
|
|
|
// محل نوبتدهی: مطب شخصی یا یکی از کلینیکهایی که پزشک در آن برنامه دارد.
|
|
const [bookingLocations, setBookingLocations] = useState([]);
|
|
const [selectedLocation, setSelectedLocation] = useState(null);
|
|
const [locationConfirmed, setLocationConfirmed] = useState(false);
|
|
const [locationsLoading, setLocationsLoading] = useState(true);
|
|
// روزی که کاربر در تقویم مرور میکند (timestamp) — با selectedDate فرق دارد،
|
|
// که فقط پس از تأیید اسلات ست میشود.
|
|
const [browsingDate, setBrowsingDate] = useState(null);
|
|
|
|
useEffect(() => {
|
|
if (!doctor?.uuid) return;
|
|
request
|
|
.getBookingLocations(doctor.uuid)
|
|
.then((res) => {
|
|
const d = res?.data?.data ?? res?.data ?? {};
|
|
const list = Array.isArray(d.booking_locations) ? d.booking_locations : [];
|
|
setBookingLocations(list);
|
|
|
|
// آرایه از قبل بر اساس زودترین نوبت آزاد مرتب است؛ دوباره مرتبش نکن.
|
|
const fromUrl = initialClinicUuid
|
|
? list.find((l) => l.clinic_uuid === initialClinicUuid)
|
|
: null;
|
|
setSelectedLocation(fromUrl ?? list[0] ?? null);
|
|
setLocationConfirmed(Boolean(fromUrl) || list.length <= 1);
|
|
})
|
|
.catch(() => setBookingLocations([]))
|
|
.finally(() => setLocationsLoading(false));
|
|
}, [doctor?.uuid, initialClinicUuid]);
|
|
|
|
// با تغییر روز، فقط پرچم available_on_date تازه میشود — انتخاب کاربر دستنخورده
|
|
// میماند، وگرنه هر بار جابهجایی در تقویم مرحله را ریست میکرد.
|
|
useEffect(() => {
|
|
if (!doctor?.uuid || !browsingDate) return;
|
|
|
|
const date = moment.unix(browsingDate).format("YYYY-MM-DD");
|
|
request
|
|
.getBookingLocations(doctor.uuid, date)
|
|
.then((res) => {
|
|
const d = res?.data?.data ?? res?.data ?? {};
|
|
const byKey = new Map(
|
|
(Array.isArray(d.booking_locations) ? d.booking_locations : []).map((l) => [
|
|
l.clinic_uuid ?? "personal",
|
|
l.available_on_date,
|
|
])
|
|
);
|
|
setBookingLocations((prev) =>
|
|
prev.map((l) => ({
|
|
...l,
|
|
available_on_date: byKey.get(l.clinic_uuid ?? "personal") ?? false,
|
|
}))
|
|
);
|
|
})
|
|
.catch(() => {});
|
|
}, [doctor?.uuid, browsingDate]);
|
|
|
|
// منابع **مستقل از محلها** گرفته میشوند و یکبار برای همهٔ محیطهای پزشک.
|
|
//
|
|
// وابستهکردنش به محل، پزشکی را که خودش برنامهٔ نوبتدهی ندارد ولی دستگاهش دارد از
|
|
// دسترس خارج میکرد: «محل» از برنامهٔ هفتگیِ پزشک میآید و برای او خالی است، در حالی
|
|
// که دستگاه تقویم و شعبهٔ خودش را دارد. هر منبع `clinic_uuid` خودش را همراه دارد.
|
|
//
|
|
// خطا عمداً بیصدا است — پزشکِ بدون منبع و خطای شبکه هر دو یعنی «مرحلهٔ منبع را نشان
|
|
// نده»، و جریان پزشکمحور باید دستنخورده ادامه یابد.
|
|
useEffect(() => {
|
|
if (!doctor?.uuid) return;
|
|
|
|
let cancelled = false;
|
|
request
|
|
.getBookingResources(doctor.uuid)
|
|
.then((res) => {
|
|
if (cancelled) return;
|
|
const d = res?.data?.data ?? res?.data ?? {};
|
|
setResources(Array.isArray(d.resources) ? d.resources : []);
|
|
})
|
|
.catch(() => !cancelled && setResources([]));
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [doctor?.uuid]);
|
|
|
|
// منابعِ محلِ انتخابشده. وقتی محلی وجود ندارد (پزشکِ بدون برنامهٔ هفتگی) همهٔ منابع
|
|
// نشان داده میشوند، چون خودشان محیطشان را همراه دارند.
|
|
const locationResources =
|
|
bookingLocations.length === 0
|
|
? resources
|
|
: resources.filter(
|
|
(r) => (r.clinic_uuid ?? null) === (selectedLocation?.clinic_uuid ?? null)
|
|
);
|
|
|
|
// روش نوبتدهی و سرویسها per-location هستند: یک پزشک میتواند در مطب شخصی
|
|
// اسلاتی و در کلینیک سرویسی باشد.
|
|
//
|
|
// منبع این را بازنویسی میکند: منبع اسلاتِ ثابت ندارد و زمانهایش همیشه از مدتِ
|
|
// سرویسهای انتخابشده ساخته میشوند، حتی اگر برنامهٔ همان محل اسلاتی باشد.
|
|
const bookingMode =
|
|
selectedResource !== null || selectedLocation?.booking_mode === "service"
|
|
? "service"
|
|
: "slot";
|
|
const bookingServices = selectedResource?.services ?? selectedLocation?.services ?? [];
|
|
|
|
// تعویض محل، انتخابهای وابسته را باطل میکند؛ وگرنه ترکیب سرویسِ یک محل با
|
|
// اسلات محل دیگر ساخته میشود و ثبت نوبت با ۴۲۲ رد میشود.
|
|
//
|
|
// باطلسازی آبشاری است: محل ← منبع ← سرویس ← روز ← ساعت. هر سطح، همهٔ سطوح
|
|
// پایینترش را پاک میکند.
|
|
const changeLocation = (location) => {
|
|
setSelectedLocation(location);
|
|
setLocationConfirmed(true);
|
|
setSelectedResource(null);
|
|
setResourceConfirmed(false);
|
|
setSelectedServiceUuids([]);
|
|
setSelectedSlot(null);
|
|
setSelectedDate(null);
|
|
};
|
|
|
|
// انتخاب «نوبت با پزشک» (`resource === null`) هم یک انتخاب است و مرحله را میبندد.
|
|
const changeResource = (resource) => {
|
|
setSelectedResource(resource);
|
|
setResourceConfirmed(true);
|
|
setSelectedServiceUuids([]);
|
|
setSelectedSlot(null);
|
|
setSelectedDate(null);
|
|
};
|
|
|
|
const reopenResourceChoice = () => setResourceConfirmed(false);
|
|
|
|
// منبعی وجود ندارد ⇒ مرحله اصلاً رندر نمیشود و تجربهٔ فعلی بدون تغییر میماند.
|
|
const resourceStepNeeded = locationResources.length > 0 && !resourceConfirmed;
|
|
|
|
// بازگشت به مرحلهٔ انتخاب محل
|
|
const reopenLocationChoice = () => setLocationConfirmed(false);
|
|
|
|
// محلِ انتخابشده در روزِ در حال مرور بسته است. نباید بیصدا فهرست خالی نشان داد.
|
|
const selectedClosedOnDate =
|
|
Boolean(browsingDate) && selectedLocation?.available_on_date === false;
|
|
|
|
useEffect(() => {
|
|
const fetchUserData = async () => {
|
|
setIsLoading(true);
|
|
|
|
const userInfo = Cookies.get("userInfo");
|
|
const parsedData = safeJsonParse(userInfo);
|
|
const usernameFromCookie = parsedData?.username || "";
|
|
|
|
// ابتدا شماره موبایل را از Cookie ست میکنیم
|
|
if (usernameFromCookie) {
|
|
setData(prev => ({
|
|
...prev,
|
|
phone: { value: usernameFromCookie, isEdit: false },
|
|
}));
|
|
}
|
|
|
|
// سپس اگر uuid داریم، بقیه اطلاعات را از API میگیریم
|
|
if (userInfo && parsedData) {
|
|
try {
|
|
const res = await request.getUserProfile(parsedData.uuid);
|
|
const profile = res?.data?.data;
|
|
|
|
if (profile) {
|
|
const newData = buildProfileData(profile, usernameFromCookie);
|
|
setData(newData);
|
|
setPrevData(newData);
|
|
}
|
|
} catch (error) {
|
|
// در صورت خطا، شماره موبایل حفظ میشود و بقیه فیلدها قابل ویرایش میمانند
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
} else {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchUserData();
|
|
}, []); // فقط یک بار در mount اولیه اجرا میشه
|
|
|
|
// useEffect جداگانه برای زمانی که step تغییر میکنه
|
|
useEffect(() => {
|
|
const refetchUserData = async () => {
|
|
if (step === 3) {
|
|
const userInfo = Cookies.get("userInfo");
|
|
const parsedData = safeJsonParse(userInfo);
|
|
const usernameFromCookie = parsedData?.username || "";
|
|
|
|
if (userInfo && parsedData && !data.uuid) {
|
|
try {
|
|
const res = await request.getUserProfile(parsedData.uuid);
|
|
const profile = res?.data?.data;
|
|
|
|
if (profile) {
|
|
const newData = buildProfileData(profile, usernameFromCookie);
|
|
setData(newData);
|
|
setPrevData(newData);
|
|
}
|
|
} catch (error) {
|
|
// در صورت خطا، فرم خالی قابل ویرایش باقی میماند
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
refetchUserData();
|
|
}, [step]);
|
|
|
|
|
|
if (isLoading || locationsLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-screen">
|
|
<div className="flex flex-col items-center gap-4">
|
|
<div className="w-12 h-12 border-4 border-[#5559CE] border-t-transparent rounded-full animate-spin"></div>
|
|
<p className="text-[#3B3B3B] text-[16px]">در حال بارگذاری...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Container
|
|
step={step}
|
|
data={data}
|
|
doctor={doctor}
|
|
setData={setData}
|
|
setStep={setStep}
|
|
prevData={prevData}
|
|
matchedCity={matchedCity}
|
|
isForAnother={isForAnother}
|
|
setIsForAnother={setIsForAnother}
|
|
disabledDates={disabledDates}
|
|
num={num}
|
|
setNum={setNum}
|
|
uuid={uuid}
|
|
setUuid={setUuid}
|
|
isSendMsg={isSendMsg}
|
|
setIsSendMsg={setIsSendMsg}
|
|
selectedSlot={selectedSlot}
|
|
setSelectedSlot={setSelectedSlot}
|
|
selectedDate={selectedDate}
|
|
setSelectedDate={setSelectedDate}
|
|
appointmentId={appointmentId}
|
|
setAppointmentId={setAppointmentId}
|
|
appointmentExpiresAt={appointmentExpiresAt}
|
|
setAppointmentExpiresAt={setAppointmentExpiresAt}
|
|
bookingMode={bookingMode}
|
|
bookingServices={bookingServices}
|
|
selectedServiceUuids={selectedServiceUuids}
|
|
setSelectedServiceUuids={setSelectedServiceUuids}
|
|
bookingLocations={bookingLocations}
|
|
selectedLocation={selectedLocation}
|
|
locationConfirmed={locationConfirmed}
|
|
changeLocation={changeLocation}
|
|
reopenLocationChoice={reopenLocationChoice}
|
|
resources={locationResources}
|
|
selectedResource={selectedResource}
|
|
resourceStepNeeded={resourceStepNeeded}
|
|
changeResource={changeResource}
|
|
reopenResourceChoice={reopenResourceChoice}
|
|
onDateChange={setBrowsingDate}
|
|
selectedClosedOnDate={selectedClosedOnDate}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export default AppointmentPage;
|