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
+16 -6
View File
@@ -21,7 +21,7 @@ function gregorianMonthsOf(jalaliMonth) {
});
}
function DatePicker({ setDate, clinicUuid = null }) {
function DatePicker({ setDate, clinicUuid = null, resourceUuid = null, selectedServiceUuids = [] }) {
const params = useParams();
const doctorUuid = params?.doctorId;
@@ -36,15 +36,20 @@ function DatePicker({ setDate, clinicUuid = null }) {
// هر محل، روزهای غیرفعال و وضعیت نوبت‌دهی آنلاین خودش را دارد؛ کش ماه‌ها باید
// با تعویض محل دور ریخته شود وگرنه تقویم محل قبلی باقی می‌ماند.
//
// منبع هم همین‌طور: تقویم هر دستگاه مالِ خودش است، و مدتِ سرویس‌های انتخاب‌شده
// تعیین می‌کند کدام روز جا دارد — پس تغییر هرکدام یعنی کشِ باطل.
const serviceKey = selectedServiceUuids.join(",");
useEffect(() => {
loadedMonths.current = new Set();
setDisabledSet(new Set());
setOnlineEnabled(true);
setAutoSelected(false);
}, [clinicUuid]);
}, [clinicUuid, resourceUuid, serviceKey]);
useEffect(() => {
if (!doctorUuid) return;
if (!doctorUuid && !resourceUuid) return;
const targets = [
...gregorianMonthsOf(baseMonth),
@@ -55,8 +60,13 @@ function DatePicker({ setDate, clinicUuid = null }) {
targets.forEach(({ year, month }) => {
loadedMonths.current.add(`${year}-${month}`);
request
.getMonthAvailability(doctorUuid, year, month, clinicUuid)
// منبع پرچمِ `online_booking_enabled` ندارد — آن روی برنامهٔ هفتگیِ پزشک است.
// نبودِ فیلد یعنی «روشن»، و شرطِ پایین‌تر فقط با `false` صریح واکنش نشان می‌دهد.
const req = resourceUuid
? request.getResourceMonthAvailability(resourceUuid, year, month, selectedServiceUuids)
: request.getMonthAvailability(doctorUuid, year, month, clinicUuid);
req
.then((res) => {
const data = res?.data ?? {};
if (data.online_booking_enabled === false) setOnlineEnabled(false);
@@ -73,7 +83,7 @@ function DatePicker({ setDate, clinicUuid = null }) {
loadedMonths.current.delete(`${year}-${month}`);
});
});
}, [doctorUuid, baseMonth, clinicUuid]);
}, [doctorUuid, baseMonth, clinicUuid, resourceUuid, serviceKey]);
const isDisabled = (date) => {
const day = moment(date).startOf("day");
+11 -4
View File
@@ -17,6 +17,7 @@ function DateTime({
serviceMode = false,
selectedServiceUuids = [],
clinicUuid = null,
resourceUuid = null,
selectedLocation = null,
}) {
const [value, setValue] = useState(0);
@@ -48,11 +49,17 @@ function DateTime({
setHour();
const dateStr = moment.unix(date).format("YYYY-MM-DD");
const req = serviceMode
// منبع تقویم خودش را دارد، پس زمان‌هایش از اندپوینت خودش می‌آید نه از برنامهٔ
// هفتگی پزشک. شکل پاسخ همان `start_times` است، پس adapter مشترک می‌ماند.
const req = resourceUuid
? request
.getServiceSlots(doctor.uuid, dateStr, selectedServiceUuids, clinicUuid)
.getResourceSlots(resourceUuid, dateStr, selectedServiceUuids)
.then(adaptServiceSlots)
: request.getAppointmentSlots(doctor.uuid, dateStr, clinicUuid).then(adaptSlots);
: serviceMode
? request
.getServiceSlots(doctor.uuid, dateStr, selectedServiceUuids, clinicUuid)
.then(adaptServiceSlots)
: request.getAppointmentSlots(doctor.uuid, dateStr, clinicUuid).then(adaptSlots);
req
.then((parsed) => {
@@ -69,7 +76,7 @@ function DateTime({
setAppo("در حال حاضر، نوبتی برای این روز موجود نمی‌باشد.");
setValue(0);
});
}, [date, doctor?.uuid, serviceMode, selectedServiceUuids, clinicUuid]);
}, [date, doctor?.uuid, serviceMode, selectedServiceUuids, clinicUuid, resourceUuid]);
return (
<div className="w-full">
+85
View File
@@ -0,0 +1,85 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, waitFor } from '@testing-library/react';
vi.mock('@/services/response', () => ({
request: {
getAppointmentSlots: vi.fn(() => Promise.resolve({ data: { sessions: [] } })),
getServiceSlots: vi.fn(() => Promise.resolve({ data: { start_times: [] } })),
getResourceSlots: vi.fn(() => Promise.resolve({ data: { start_times: [] } })),
getMonthAvailability: vi.fn(() => Promise.resolve({ data: {} })),
getResourceMonthAvailability: vi.fn(() => Promise.resolve({ data: {} })),
},
}));
vi.mock('next/navigation', () => ({ useParams: () => ({ doctorId: 'doc-1' }) }));
vi.mock('@/components/common/InlineJalaliMonth', () => ({ default: () => <div /> }));
vi.mock('./dateTime/hours', () => ({ default: () => <div /> }));
vi.mock('./dateTime/SendAppo', () => ({ default: () => <div /> }));
vi.mock('../Tabs', () => ({ default: () => <div /> }));
import { request } from '@/services/response';
import DateTime from './dateTime';
import DatePicker from './datePicker';
// نیمه‌شبِ فردا — تا «زمانِ گذشته» جریان را کوتاه نکند.
const TOMORROW = Math.floor(new Date(new Date().setHours(24, 0, 0, 0)).getTime() / 1000);
describe('DateTime — انتخاب اندپوینت اسلات', () => {
beforeEach(() => vi.clearAllMocks());
it('بدون منبع و بدون حالت سرویسی → اسلات پزشک', async () => {
render(<DateTime date={TOMORROW} doctor={{ uuid: 'doc-1' }} />);
await waitFor(() => expect(request.getAppointmentSlots).toHaveBeenCalled());
expect(request.getResourceSlots).not.toHaveBeenCalled();
});
it('حالت سرویسیِ پزشک → اسلات سرویسیِ پزشک', async () => {
render(
<DateTime date={TOMORROW} doctor={{ uuid: 'doc-1' }} serviceMode selectedServiceUuids={['s1']} />
);
await waitFor(() => expect(request.getServiceSlots).toHaveBeenCalled());
expect(request.getResourceSlots).not.toHaveBeenCalled();
});
it('با منبع → اسلات منبع، حتی وقتی برنامهٔ محل اسلاتی است', async () => {
render(
<DateTime
date={TOMORROW}
doctor={{ uuid: 'doc-1' }}
resourceUuid="r1"
selectedServiceUuids={['s1']}
/>
);
await waitFor(() => expect(request.getResourceSlots).toHaveBeenCalledWith('r1', expect.any(String), ['s1']));
expect(request.getAppointmentSlots).not.toHaveBeenCalled();
expect(request.getServiceSlots).not.toHaveBeenCalled();
});
});
describe('DatePicker — انتخاب اندپوینت تقویم ماه', () => {
beforeEach(() => vi.clearAllMocks());
it('بدون منبع → تقویم پزشک', async () => {
render(<DatePicker setDate={vi.fn()} />);
await waitFor(() => expect(request.getMonthAvailability).toHaveBeenCalled());
expect(request.getResourceMonthAvailability).not.toHaveBeenCalled();
});
it('با منبع → تقویم منبع، همراه سرویس‌های انتخاب‌شده', async () => {
render(<DatePicker setDate={vi.fn()} resourceUuid="r1" selectedServiceUuids={['s1']} />);
await waitFor(() =>
expect(request.getResourceMonthAvailability).toHaveBeenCalledWith(
'r1',
expect.any(Number),
expect.any(Number),
['s1']
)
);
expect(request.getMonthAvailability).not.toHaveBeenCalled();
});
});
+30 -3
View File
@@ -65,6 +65,28 @@ const getBookingLocations = cache(async (doctorUuid) => {
}
});
/**
* منابع قابل رزروِ پزشک (دستگاه، اتاق، یونیت) در همهٔ محیط‌هایش.
*
* جدا از محل‌ها گرفته می‌شود چون منبع تقویم خودش را دارد: پزشکی که برنامهٔ هفتگی ندارد
* هیچ محلی برنمی‌گرداند، ولی ممکن است دستگاهش کاملاً قابل رزرو باشد.
*/
const getBookingResources = cache(async (doctorUuid) => {
if (!doctorUuid) return [];
try {
const res = await fetch(
`${API_URL}/api/v1/appointment-booking-resources/${doctorUuid}`,
{ next: { revalidate: 3600, tags: [`booking-resources-${doctorUuid}`] } }
);
if (!res.ok) return [];
const json = await res.json();
const data = json?.data?.data ?? json?.data ?? {};
return Array.isArray(data.resources) ? data.resources : [];
} catch {
return [];
}
});
export async function generateMetadata({ params }) {
const { slug } = await params;
const { matchedCity } = await getStateInfo();
@@ -144,9 +166,13 @@ async function Doctor({ params }) {
} catch (error) {}
}
const [addresses, bookingLocations] = doctor
? await Promise.all([getDoctorAddresses(doctor.id), getBookingLocations(doctor.uuid)])
: [[], []];
const [addresses, bookingLocations, bookingResources] = doctor
? await Promise.all([
getDoctorAddresses(doctor.id),
getBookingLocations(doctor.uuid),
getBookingResources(doctor.uuid),
])
: [[], [], []];
// آدرسی که در هیچ برنامهٔ نوبت‌دهی استفاده نشده، محل مراجعه نیست. در کارت
// «موقعیت مکانی» می‌ماند (اطلاعات واقعی مطب است) ولی به JSON-LD نمی‌رود، چون
@@ -319,6 +345,7 @@ async function Doctor({ params }) {
addresses={addresses}
bookableAddressUuids={[...bookableAddressUuids]}
bookingLocations={bookingLocations}
bookingResources={bookingResources}
slug={slug}
faq={faq}
/>