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:
@@ -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");
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user