Files
nobat724_front/components/appointment/index.js
T
hamed 54bebbd41a feat: Implement resource-based appointment booking flow
- 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.
2026-08-09 10:45:52 +03:30

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;