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
@@ -22,10 +22,20 @@ const formatJalali = (ts) => {
* قابل‌رزرو را برمی‌گرداند. تا وقتی حداقل یک محل هست، نوبت‌دهی فعال است — حتی
* اگر فیلد قدیمی active به هر دلیل false باشد. بدون محل، همان فیلدهای doctor
* ملاک می‌مانند (سازگاری با پزشک بدون برنامه).
*
* منبع مسیر سومی است: دستگاه و اتاق تقویم خودشان را دارند و به برنامهٔ هفتگیِ پزشک
* وابسته نیستند. پزشکی که خودش نوبت آنلاین نمی‌دهد ولی دستگاهش می‌دهد، «نوبت‌دهی
* غیرفعال» نیست — فقط ویزیت مستقیم ندارد.
*/
export function bookingState(doctor, bookingLocations) {
export function bookingState(doctor, bookingLocations, bookingResources = []) {
const hasLocations =
Array.isArray(bookingLocations) && bookingLocations.length > 0;
const hasResources =
Array.isArray(bookingResources) && bookingResources.length > 0;
if (!hasLocations && hasResources) {
return { enabled: true, label: "رزرو نوبت دستگاه و خدمات" };
}
if (!hasLocations) {
const enabled = doctor?.active !== false && !!doctor?.free_turn;
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { bookingState } from './bookingState';
const LOCATION = { next_available_at: null };
const RESOURCE = { uuid: 'r1', name: 'کندلا2021' };
describe('bookingState', () => {
it('محل دارد → فعال', () => {
expect(bookingState({ active: true }, [LOCATION]).enabled).toBe(true);
});
it('نه محل و نه منبع و نه free_turn → غیرفعال', () => {
const state = bookingState({ active: true, free_turn: null }, [], []);
expect(state.enabled).toBe(false);
expect(state.label).toBe('نوبت‌دهی غیرفعال است');
});
it('بدون محل ولی با منبع → فعال', () => {
const state = bookingState({ active: true, free_turn: null }, [], [RESOURCE]);
expect(state.enabled).toBe(true);
expect(state.label).toBe('رزرو نوبت دستگاه و خدمات');
});
it('پزشکِ غیرفعال ولی دارای منبعِ قابل رزرو → همچنان فعال', () => {
// منبع تقویم خودش را دارد؛ پرچم قدیمیِ پزشک آن را خاموش نمی‌کند.
expect(bookingState({ active: false, free_turn: null }, [], [RESOURCE]).enabled).toBe(true);
});
it('محل مقدم بر منبع است — برچسبِ زودترین نوبت حفظ می‌شود', () => {
const state = bookingState({ active: true }, [{ next_available_at: null }], [RESOURCE]);
expect(state.label).toBe('فعلاً نوبت خالی ندارد');
});
it('بدون محل و بدون منبع ولی با free_turn → همان رفتار قبلی', () => {
const state = bookingState({ active: true, free_turn: 'فردا ۱۰:۰۰' }, [], []);
expect(state.enabled).toBe(true);
expect(state.label).toBe('اولین نوبت آزاد: فردا ۱۰:۰۰');
});
});
+2 -2
View File
@@ -6,8 +6,8 @@ import ArrowLeftD from "@/components/icons/ArrowLeftD";
import Link from "next/link";
import { bookingState } from "./bookingState";
function AppointmentList({ doctor, loading, doctorSlug, bookingLocations = [] }) {
const booking = bookingState(doctor, bookingLocations);
function AppointmentList({ doctor, loading, doctorSlug, bookingLocations = [], bookingResources = [] }) {
const booking = bookingState(doctor, bookingLocations, bookingResources);
return (
<>
<ul className="hidden sticky top-[122px] sm:top-[150px] mt:top-[178px] lg:top-[206px] lg:flex w-[38%] flex-col justify-start items-center gap-8">
+7 -2
View File
@@ -15,7 +15,7 @@ const specialtyHref = (name) => {
return slug ? `/specialties/${slug}` : `/doctors?specialty=${encodeURIComponent(name)}`;
};
function DoctorPage({ doctor, comments, rateAggregate, addresses, bookableAddressUuids = [], bookingLocations = [], slug, faq = [] }) {
function DoctorPage({ doctor, comments, rateAggregate, addresses, bookableAddressUuids = [], bookingLocations = [], bookingResources = [], slug, faq = [] }) {
const { primary: primarySpecialty } = splitSpecialties(doctor?.specialties);
return (
@@ -52,7 +52,12 @@ function DoctorPage({ doctor, comments, rateAggregate, addresses, bookableAddres
addresses={addresses}
bookableAddressUuids={bookableAddressUuids}
/>
<AppointmentList doctor={doctor} doctorSlug={slug} bookingLocations={bookingLocations} />
<AppointmentList
doctor={doctor}
doctorSlug={slug}
bookingLocations={bookingLocations}
bookingResources={bookingResources}
/>
</div>
<ClaimProfileSection doctor={doctor} />
<Faq items={faq} />