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.
This commit is contained in:
hamed
2026-08-09 10:45:52 +03:30
parent 1668ef8890
commit 54bebbd41a
23 changed files with 1094 additions and 32 deletions
+73 -2
View File
@@ -65,6 +65,12 @@ function AppointmentPage({ doctor, disabledDates, matchedCity, initialClinicUuid
// 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);
@@ -120,21 +126,81 @@ function AppointmentPage({ doctor, disabledDates, matchedCity, initialClinicUuid
.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 = selectedLocation?.booking_mode === "service" ? "service" : "slot";
const bookingServices = selectedLocation?.services ?? [];
//
// منبع این را بازنویسی می‌کند: منبع اسلاتِ ثابت ندارد و زمان‌هایش همیشه از مدتِ
// سرویس‌های انتخاب‌شده ساخته می‌شوند، حتی اگر برنامهٔ همان محل اسلاتی باشد.
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);
@@ -257,6 +323,11 @@ function AppointmentPage({ doctor, disabledDates, matchedCity, initialClinicUuid
locationConfirmed={locationConfirmed}
changeLocation={changeLocation}
reopenLocationChoice={reopenLocationChoice}
resources={locationResources}
selectedResource={selectedResource}
resourceStepNeeded={resourceStepNeeded}
changeResource={changeResource}
reopenResourceChoice={reopenResourceChoice}
onDateChange={setBrowsingDate}
selectedClosedOnDate={selectedClosedOnDate}
/>