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:
@@ -1,5 +1,6 @@
|
||||
import Date from "./date";
|
||||
import ServiceSelect from "./service";
|
||||
import ResourceSelect from "./resource";
|
||||
import LocationSelect from "./location/LocationSelect";
|
||||
|
||||
// Components
|
||||
@@ -53,13 +54,29 @@ function Container({
|
||||
reopenLocationChoice,
|
||||
onDateChange,
|
||||
selectedClosedOnDate,
|
||||
// نوبتدهی منبعمحور (دستگاه / اتاق / یونیت)
|
||||
resources = [],
|
||||
selectedResource = null,
|
||||
resourceStepNeeded = false,
|
||||
changeResource,
|
||||
reopenResourceChoice,
|
||||
}) {
|
||||
const serviceMode = bookingMode === "service";
|
||||
const clinicUuid = selectedLocation?.clinic_uuid ?? null;
|
||||
const multiLocation = bookingLocations.length > 1;
|
||||
const resourceUuid = selectedResource?.uuid ?? null;
|
||||
|
||||
// محیطِ رزرو از خودِ منبع خوانده میشود وقتی منبعی انتخاب شده: پزشکِ بدون برنامهٔ
|
||||
// هفتگی هیچ محلی ندارد که `clinic_uuid` از آن بیاید، ولی دستگاهش محیطش را میداند.
|
||||
const clinicUuid = selectedResource
|
||||
? selectedResource.clinic_uuid ?? null
|
||||
: selectedLocation?.clinic_uuid ?? null;
|
||||
|
||||
// «محل ندارد» دیگر به معنی «نوبتدهی ندارد» نیست: منبع تقویم خودش را دارد، پس پزشکی
|
||||
// که برنامهٔ هفتگی ندارد ولی دستگاهش قابل رزرو است باید به مرحلهٔ منبع برسد.
|
||||
const noBookingAtAll = bookingLocations.length === 0 && resources.length === 0;
|
||||
|
||||
let dateStep;
|
||||
if (bookingLocations.length === 0) {
|
||||
if (noBookingAtAll) {
|
||||
dateStep = (
|
||||
<p className="w-full max-w-[520px] mx-auto py-[40px] text-center text-[14px] text-[#7A7A7A]">
|
||||
نوبتدهی آنلاین برای این پزشک فعال نیست.
|
||||
@@ -73,6 +90,15 @@ function Container({
|
||||
onSelect={changeLocation}
|
||||
/>
|
||||
);
|
||||
} else if (resourceStepNeeded) {
|
||||
dateStep = (
|
||||
<ResourceSelect
|
||||
resources={resources}
|
||||
selected={selectedResource}
|
||||
onSelect={changeResource}
|
||||
allowDoctorOption={bookingLocations.length > 0}
|
||||
/>
|
||||
);
|
||||
} else if (serviceMode && selectedServiceUuids.length === 0) {
|
||||
dateStep = (
|
||||
<ServiceSelect
|
||||
@@ -93,7 +119,9 @@ function Container({
|
||||
onChangeService={() => setSelectedServiceUuids([])}
|
||||
selectedLocation={selectedLocation}
|
||||
clinicUuid={clinicUuid}
|
||||
resourceUuid={resourceUuid}
|
||||
onChangeLocation={multiLocation ? reopenLocationChoice : null}
|
||||
onChangeResource={resources.length > 0 ? reopenResourceChoice : null}
|
||||
onDateChange={onDateChange}
|
||||
closedOnDate={selectedClosedOnDate}
|
||||
/>
|
||||
@@ -134,6 +162,7 @@ function Container({
|
||||
setAppointmentExpiresAt={setAppointmentExpiresAt}
|
||||
selectedServiceUuids={selectedServiceUuids}
|
||||
clinicUuid={clinicUuid}
|
||||
resourceUuid={resourceUuid}
|
||||
/>,
|
||||
<Paying
|
||||
setStep={setStep}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
// جریان نوبت به کوکی، تم، تقویم و لایهی API وابسته است؛ اینجا فقط تصمیمِ
|
||||
// «کدام مرحله رندر میشود» سنجیده میشود، پس بقیه mock میشوند.
|
||||
vi.mock('@/components/layout', () => ({ default: ({ children }) => <div>{children}</div> }));
|
||||
vi.mock('./Content', () => ({ default: ({ children }) => <div>{children}</div> }));
|
||||
vi.mock('./date', () => ({ default: () => <div>مرحله-روز</div> }));
|
||||
vi.mock('@/components/appointment/paying', () => ({ default: () => null }));
|
||||
vi.mock('@/components/appointment/detail', () => ({ default: () => null }));
|
||||
vi.mock('@/components/appointment/failedPay', () => ({ default: () => null }));
|
||||
vi.mock('@/components/appointment/successPay', () => ({ default: () => null }));
|
||||
vi.mock('@/components/register/LogInPage', () => ({ default: () => null }));
|
||||
vi.mock('@/components/register/verificationPage', () => ({ default: () => null }));
|
||||
vi.mock('@/components/register/LayoutRegister', () => ({ default: ({ children }) => <div>{children}</div> }));
|
||||
|
||||
import Container from './Container';
|
||||
|
||||
const RESOURCE = {
|
||||
uuid: 'r1',
|
||||
name: 'کندلا2021',
|
||||
type: { name: 'دستگاه لیزر' },
|
||||
services: [{ uuid: 's1', name: 'لیزر دست', duration_minutes: 20 }],
|
||||
};
|
||||
|
||||
const LOCATION = { clinic_uuid: null, title: 'مطب شخصی', booking_mode: 'slot', services: [] };
|
||||
|
||||
const props = (over = {}) => ({
|
||||
step: 0,
|
||||
data: {},
|
||||
doctor: { uuid: 'd1' },
|
||||
setData: vi.fn(),
|
||||
setStep: vi.fn(),
|
||||
prevData: {},
|
||||
matchedCity: {},
|
||||
isForAnother: false,
|
||||
setIsForAnother: vi.fn(),
|
||||
disabledDates: [],
|
||||
setNum: vi.fn(),
|
||||
setUuid: vi.fn(),
|
||||
setIsSendMsg: vi.fn(),
|
||||
setSelectedSlot: vi.fn(),
|
||||
setSelectedDate: vi.fn(),
|
||||
setAppointmentId: vi.fn(),
|
||||
setAppointmentExpiresAt: vi.fn(),
|
||||
bookingMode: 'slot',
|
||||
bookingServices: [],
|
||||
selectedServiceUuids: [],
|
||||
setSelectedServiceUuids: vi.fn(),
|
||||
bookingLocations: [LOCATION],
|
||||
selectedLocation: LOCATION,
|
||||
locationConfirmed: true,
|
||||
changeLocation: vi.fn(),
|
||||
reopenLocationChoice: vi.fn(),
|
||||
onDateChange: vi.fn(),
|
||||
selectedClosedOnDate: false,
|
||||
resources: [],
|
||||
selectedResource: null,
|
||||
resourceStepNeeded: false,
|
||||
changeResource: vi.fn(),
|
||||
reopenResourceChoice: vi.fn(),
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('مرحلهٔ انتخاب نوع نوبت در جریان رزرو', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('پزشکِ بدون منبع → هیچ تغییری؛ مستقیم مرحلهٔ روز', () => {
|
||||
render(<Container {...props()} />);
|
||||
|
||||
expect(screen.getByText('مرحله-روز')).toBeInTheDocument();
|
||||
expect(screen.queryByText('انتخاب نوع نوبت')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('منبع هست و هنوز انتخاب نشده → مرحلهٔ نوع نوبت', () => {
|
||||
render(<Container {...props({ resources: [RESOURCE], resourceStepNeeded: true })} />);
|
||||
|
||||
expect(screen.getByText('۱. انتخاب نوع نوبت')).toBeInTheDocument();
|
||||
expect(screen.getByText('کندلا2021')).toBeInTheDocument();
|
||||
expect(screen.queryByText('مرحله-روز')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('محلِ تأییدنشده مقدم بر مرحلهٔ منبع است', () => {
|
||||
render(
|
||||
<Container
|
||||
{...props({
|
||||
bookingLocations: [LOCATION, { ...LOCATION, clinic_uuid: 'c2', title: 'کلینیک' }],
|
||||
locationConfirmed: false,
|
||||
resources: [RESOURCE],
|
||||
resourceStepNeeded: true,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('۱. انتخاب محل نوبتدهی')).toBeInTheDocument();
|
||||
expect(screen.queryByText('۱. انتخاب نوع نوبت')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('پزشکِ بدون برنامهٔ هفتگی ولی دارای منبع → مرحلهٔ منبع، نه پیام «فعال نیست»', () => {
|
||||
render(
|
||||
<Container
|
||||
{...props({
|
||||
bookingLocations: [],
|
||||
selectedLocation: null,
|
||||
resources: [{ ...RESOURCE, clinic_uuid: 'c5' }],
|
||||
resourceStepNeeded: true,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('۱. انتخاب نوع نوبت')).toBeInTheDocument();
|
||||
expect(screen.getByText('کندلا2021')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('نوبتدهی آنلاین برای این پزشک فعال نیست.')
|
||||
).not.toBeInTheDocument();
|
||||
// ویزیت مستقیم وجود ندارد، پس کارتش هم نباید باشد.
|
||||
expect(screen.queryByText('نوبت با پزشک')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('نه محل و نه منبع → همان پیام «فعال نیست»', () => {
|
||||
render(<Container {...props({ bookingLocations: [], selectedLocation: null })} />);
|
||||
|
||||
expect(screen.getByText('نوبتدهی آنلاین برای این پزشک فعال نیست.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('بعد از انتخاب منبع، مرحلهٔ سرویس با سرویسهای همان منبع میآید', () => {
|
||||
render(
|
||||
<Container
|
||||
{...props({
|
||||
resources: [RESOURCE],
|
||||
selectedResource: RESOURCE,
|
||||
resourceStepNeeded: false,
|
||||
bookingMode: 'service',
|
||||
bookingServices: RESOURCE.services,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('۱. انتخاب سرویس')).toBeInTheDocument();
|
||||
expect(screen.getByText('لیزر دست')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,15 @@
|
||||
import DatePicker from "@/app/component/date/datePicker";
|
||||
|
||||
function SelectDatePicker({ setDate, disabledDates, clinicUuid }) {
|
||||
return <DatePicker setDate={setDate} disabledDates={disabledDates} clinicUuid={clinicUuid} />;
|
||||
function SelectDatePicker({ setDate, disabledDates, clinicUuid, resourceUuid = null, selectedServiceUuids = [] }) {
|
||||
return (
|
||||
<DatePicker
|
||||
setDate={setDate}
|
||||
disabledDates={disabledDates}
|
||||
clinicUuid={clinicUuid}
|
||||
resourceUuid={resourceUuid}
|
||||
selectedServiceUuids={selectedServiceUuids}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectDatePicker;
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import SelectDatePicker from "./SelectDatePicker";
|
||||
|
||||
function Time({ isStep, setDate, disabledDates, clinicUuid }) {
|
||||
function Time({ isStep, setDate, disabledDates, clinicUuid, resourceUuid = null, selectedServiceUuids = [] }) {
|
||||
return (
|
||||
<div className="flex relative mt-[24px] flex-col items-start gap-[19px] justify-start">
|
||||
<p className="text-[#3B3B3B] text-[14px] md:text-[16px] font-bold">
|
||||
1. انتخاب روز
|
||||
</p>
|
||||
<SelectDatePicker setDate={setDate} disabledDates={disabledDates} clinicUuid={clinicUuid} />
|
||||
<SelectDatePicker
|
||||
setDate={setDate}
|
||||
disabledDates={disabledDates}
|
||||
clinicUuid={clinicUuid}
|
||||
resourceUuid={resourceUuid}
|
||||
selectedServiceUuids={selectedServiceUuids}
|
||||
/>
|
||||
{!isStep && (
|
||||
<div className="absolute left-0 top-0 w-full h-full bg-[rgba(255,255,255,0.84)]"></div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import DateTime from "@/app/component/date/dateTime";
|
||||
|
||||
function Hour({ doctor, setStep, date, isStep, setSelectedSlot, setSelectedDate, serviceMode = false, selectedServiceUuids = [], clinicUuid = null, selectedLocation = null }) {
|
||||
function Hour({ doctor, setStep, date, isStep, setSelectedSlot, setSelectedDate, serviceMode = false, selectedServiceUuids = [], clinicUuid = null, resourceUuid = null, selectedLocation = null }) {
|
||||
return (
|
||||
<div className="flex relative mt-[24px] flex-col items-start gap-[12px] justify-start">
|
||||
<p className="text-[#3B3B3B] text-[14px] md:text-[16px] font-bold">
|
||||
@@ -15,6 +15,7 @@ function Hour({ doctor, setStep, date, isStep, setSelectedSlot, setSelectedDate,
|
||||
serviceMode={serviceMode}
|
||||
selectedServiceUuids={selectedServiceUuids}
|
||||
clinicUuid={clinicUuid}
|
||||
resourceUuid={resourceUuid}
|
||||
selectedLocation={selectedLocation}
|
||||
/>
|
||||
{!isStep && (
|
||||
|
||||
@@ -13,7 +13,9 @@ function Date({
|
||||
onChangeService,
|
||||
selectedLocation = null,
|
||||
clinicUuid = null,
|
||||
resourceUuid = null,
|
||||
onChangeLocation,
|
||||
onChangeResource,
|
||||
onDateChange,
|
||||
closedOnDate = false,
|
||||
}) {
|
||||
@@ -29,7 +31,7 @@ function Date({
|
||||
<div
|
||||
className="p-0 lg:p-[24px] rounded-[8px] border border-solid border-transparent lg:border-[#EFEFEF] bg-transparent lg:bg-[#FFF]"
|
||||
>
|
||||
{(onChangeLocation || (serviceMode && onChangeService)) && (
|
||||
{(onChangeLocation || onChangeResource || (serviceMode && onChangeService)) && (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
{onChangeLocation && (
|
||||
<button
|
||||
@@ -40,6 +42,15 @@ function Date({
|
||||
← تغییر محل{selectedLocation?.title ? ` (${selectedLocation.title})` : ""}
|
||||
</button>
|
||||
)}
|
||||
{onChangeResource && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChangeResource}
|
||||
className="text-[13px] text-[#5559CE] hover:underline"
|
||||
>
|
||||
← تغییر نوع نوبت
|
||||
</button>
|
||||
)}
|
||||
{serviceMode && onChangeService && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -72,6 +83,8 @@ function Date({
|
||||
isStep
|
||||
disabledDates={disabledDates}
|
||||
clinicUuid={clinicUuid}
|
||||
resourceUuid={resourceUuid}
|
||||
selectedServiceUuids={selectedServiceUuids}
|
||||
/>
|
||||
<Hour
|
||||
isStep
|
||||
@@ -83,6 +96,7 @@ function Date({
|
||||
serviceMode={serviceMode}
|
||||
selectedServiceUuids={selectedServiceUuids}
|
||||
clinicUuid={clinicUuid}
|
||||
resourceUuid={resourceUuid}
|
||||
selectedLocation={selectedLocation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import Cookies from "js-cookie";
|
||||
import { setAccessToken } from "@/lib/tokenStore";
|
||||
import { useProvince } from "@/context/ProvinceProvider";
|
||||
|
||||
function SubmitData({ setStep, data, prevData, setErrors, doctor, isForAnother, selectedSlot, selectedDate, setAppointmentId, setAppointmentExpiresAt, selectedServiceUuids = [], clinicUuid = null }) {
|
||||
function SubmitData({ setStep, data, prevData, setErrors, doctor, isForAnother, selectedSlot, selectedDate, setAppointmentId, setAppointmentExpiresAt, selectedServiceUuids = [], clinicUuid = null, resourceUuid = null }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { cityId } = useProvince();
|
||||
const newStep = () => setStep((prev) => prev + 1);
|
||||
@@ -150,6 +150,9 @@ function SubmitData({ setStep, data, prevData, setErrors, doctor, isForAnother,
|
||||
city_id: cityId ?? null,
|
||||
// حالت نوبتدهی سرویسی: backend مدت و slot_end را از این سرویسها بازمحاسبه میکند.
|
||||
...(selectedServiceUuids?.length ? { service_item_uuids: selectedServiceUuids } : {}),
|
||||
// نوبت منبعمحور: مدت از زنجیرهٔ حلِ همین منبع بازمحاسبه میشود، پس
|
||||
// slot_end فرستادهشده فقط پیشنهاد است.
|
||||
...(resourceUuid ? { resource_uuid: resourceUuid } : {}),
|
||||
...(isForAnother
|
||||
? {
|
||||
patient_name: [data?.name?.value, data?.family?.value].filter(Boolean).join(" ").trim(),
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
vi.mock('@/services/response', () => ({
|
||||
request: {
|
||||
postAppointment: vi.fn(() => Promise.resolve({ data: { data: { uuid: 'a1' } } })),
|
||||
patchUserProfile: vi.fn(() => Promise.resolve({})),
|
||||
postUserProfile: vi.fn(() => Promise.resolve({})),
|
||||
},
|
||||
}));
|
||||
vi.mock('@/context/ProvinceProvider', () => ({ useProvince: () => ({ cityId: 7 }) }));
|
||||
vi.mock('js-cookie', () => ({ default: { get: () => undefined, set: vi.fn() } }));
|
||||
vi.mock('@/lib/tokenStore', () => ({ setAccessToken: vi.fn() }));
|
||||
vi.mock('../paying/ButtonFixed', () => ({ default: ({ children }) => <div>{children}</div> }));
|
||||
vi.mock('@/components/icons/ArrowLeftB', () => ({ default: () => null }));
|
||||
|
||||
import { request } from '@/services/response';
|
||||
import SubmitData from './SubmitData';
|
||||
|
||||
const VALID = {
|
||||
uuid: 'p1',
|
||||
phone: { value: '09120000000' },
|
||||
national_code: { value: '0012345678' },
|
||||
name: { value: 'علی' },
|
||||
family: { value: 'رضایی' },
|
||||
gender: { value: 'male' },
|
||||
basic_insurance: { value: { id: 3 } },
|
||||
};
|
||||
|
||||
const props = (over = {}) => ({
|
||||
setStep: vi.fn(),
|
||||
data: VALID,
|
||||
prevData: VALID,
|
||||
setErrors: vi.fn(),
|
||||
doctor: { uuid: 'doc-1' },
|
||||
isForAnother: false,
|
||||
selectedSlot: { start: 1786339800, end: 1786341000 },
|
||||
selectedDate: 1786339800,
|
||||
setAppointmentId: vi.fn(),
|
||||
setAppointmentExpiresAt: vi.fn(),
|
||||
selectedServiceUuids: ['s1'],
|
||||
clinicUuid: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
const submit = async (over) => {
|
||||
render(<SubmitData {...props(over)} />);
|
||||
await userEvent.click(screen.getByText('ثبت اطلاعات'));
|
||||
return request.postAppointment.mock.calls[0]?.[0];
|
||||
};
|
||||
|
||||
describe('payload ثبت نوبت', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('نوبت با پزشک → بدون resource_uuid', async () => {
|
||||
const payload = await submit();
|
||||
|
||||
expect(payload).toBeDefined();
|
||||
expect(payload).not.toHaveProperty('resource_uuid');
|
||||
expect(payload.service_item_uuids).toEqual(['s1']);
|
||||
});
|
||||
|
||||
it('نوبت منبعمحور → resource_uuid فرستاده میشود', async () => {
|
||||
const payload = await submit({ resourceUuid: 'r1' });
|
||||
|
||||
expect(payload.resource_uuid).toBe('r1');
|
||||
expect(payload.doctor_uuid).toBe('doc-1');
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ function Detail({
|
||||
setAppointmentExpiresAt,
|
||||
selectedServiceUuids = [],
|
||||
clinicUuid = null,
|
||||
resourceUuid = null,
|
||||
}) {
|
||||
const [insurance, setInsurance] = useState();
|
||||
const [supplementaryInsurance, setSupplementaryInsurance] = useState();
|
||||
@@ -124,6 +125,7 @@ function Detail({
|
||||
setAppointmentExpiresAt={setAppointmentExpiresAt}
|
||||
selectedServiceUuids={selectedServiceUuids}
|
||||
clinicUuid={clinicUuid}
|
||||
resourceUuid={resourceUuid}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ResourceSelect from './index';
|
||||
|
||||
const resource = (over = {}) => ({
|
||||
uuid: 'r1',
|
||||
name: 'کندلا2021',
|
||||
type: { code: 'laser_device', name: 'دستگاه لیزر' },
|
||||
services: [{ uuid: 's1', name: 'لیزر دست', duration_minutes: 20, price_rials: 2000000 }],
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('ResourceSelect', () => {
|
||||
it('کارت «نوبت با پزشک» همیشه اول میآید', () => {
|
||||
render(<ResourceSelect resources={[resource()]} onSelect={vi.fn()} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(2);
|
||||
expect(buttons[0]).toHaveTextContent('نوبت با پزشک');
|
||||
expect(buttons[1]).toHaveTextContent('کندلا2021');
|
||||
});
|
||||
|
||||
it('نوع منبع بهعنوان برچسب دیده میشود', () => {
|
||||
render(<ResourceSelect resources={[resource()]} onSelect={vi.fn()} />);
|
||||
expect(screen.getByText('دستگاه لیزر')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('انتخاب «نوبت با پزشک» مقدار null میفرستد', async () => {
|
||||
const onSelect = vi.fn();
|
||||
render(<ResourceSelect resources={[resource()]} onSelect={onSelect} />);
|
||||
|
||||
await userEvent.click(screen.getByText('نوبت با پزشک'));
|
||||
expect(onSelect).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('انتخاب منبع، خودِ آبجکت منبع را میفرستد', async () => {
|
||||
const onSelect = vi.fn();
|
||||
const r = resource();
|
||||
render(<ResourceSelect resources={[r]} onSelect={onSelect} />);
|
||||
|
||||
await userEvent.click(screen.getByText('کندلا2021'));
|
||||
expect(onSelect).toHaveBeenCalledWith(r);
|
||||
});
|
||||
|
||||
it('تا سه سرویس با نام میآید و بقیه با +n', () => {
|
||||
const services = ['الف', 'ب', 'ج', 'د', 'ه'].map((n, i) => ({ uuid: `s${i}`, name: n }));
|
||||
render(<ResourceSelect resources={[resource({ services })]} onSelect={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText('الف، ب، ج +2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('منبعِ بدون سرویس، زیرنویس خالی نمیسازد', () => {
|
||||
render(<ResourceSelect resources={[resource({ services: [] })]} onSelect={vi.fn()} />);
|
||||
|
||||
// فقط زیرنویسِ کارتِ پزشک میماند.
|
||||
expect(screen.getAllByRole('button')[1].textContent).toBe('کندلا2021دستگاه لیزر');
|
||||
});
|
||||
|
||||
it('منبعِ بدون type برچسب نمیگیرد و نمیشکند', () => {
|
||||
render(<ResourceSelect resources={[resource({ type: null })]} onSelect={vi.fn()} />);
|
||||
expect(screen.getByText('کندلا2021')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
const MAX_PREVIEW_SERVICES = 3;
|
||||
|
||||
/**
|
||||
* زیرنویس کارت: نام سرویسها تا سهتا و بعد «+n».
|
||||
*
|
||||
* شمارِ خالی («۳ سرویس») به بیمار نمیگوید این دستگاه به کارش میآید یا نه، و او را
|
||||
* مجبور میکند کورکورانه کلیک کند.
|
||||
*/
|
||||
function servicesLabel(services = []) {
|
||||
if (services.length === 0) return null;
|
||||
|
||||
const shown = services.slice(0, MAX_PREVIEW_SERVICES).map((s) => s.name);
|
||||
const rest = services.length - shown.length;
|
||||
|
||||
return rest > 0 ? `${shown.join("، ")} +${rest}` : shown.join("، ");
|
||||
}
|
||||
|
||||
/**
|
||||
* انتخاب نوع نوبت — پیش از انتخاب سرویس و روز، و فقط وقتی این پزشک در این محل
|
||||
* دستکم یک منبعِ قابل رزرو دارد.
|
||||
*
|
||||
* «منبع» واژهٔ داخلی است و به بیمار نشان داده نمیشود؛ کارتها نام واقعی دستگاه/اتاق
|
||||
* و نوعشان را نشان میدهند. کارت اول همیشه جریان همیشگی است: نوبت با خودِ پزشک.
|
||||
*/
|
||||
function ResourceSelect({ resources = [], selected = null, onSelect, allowDoctorOption = true }) {
|
||||
const cardClass = (active) =>
|
||||
`w-full text-right p-[16px] rounded-[8px] border border-solid transition-colors ${
|
||||
active
|
||||
? "border-[#5559CE] bg-[rgba(85,89,206,0.06)]"
|
||||
: "border-[#EFEFEF] bg-[#FFF] hover:border-[#C7C9EC]"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[520px] mx-auto">
|
||||
<h2 className="text-[16px] font-bold text-[#3B3B3B] mb-4">۱. انتخاب نوع نوبت</h2>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* پزشکی که برنامهٔ هفتگی ندارد، ویزیتِ مستقیم هم ندارد؛ نشاندادن این کارت
|
||||
کاربر را به فهرست ساعتِ همیشهخالی میبرد. */}
|
||||
{allowDoctorOption && (
|
||||
<button type="button" onClick={() => onSelect(null)} className={cardClass(selected === null)}>
|
||||
<p className="text-[14px] md:text-[16px] font-bold text-[#3B3B3B]">نوبت با پزشک</p>
|
||||
<p className="mt-[6px] text-[13px] text-[#616161] font-normal">
|
||||
ویزیت معمول در ساعتهای کاری پزشک
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{resources.map((resource) => {
|
||||
const active = selected?.uuid === resource.uuid;
|
||||
const label = servicesLabel(resource.services);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={resource.uuid}
|
||||
type="button"
|
||||
onClick={() => onSelect(resource)}
|
||||
className={cardClass(active)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-[8px]">
|
||||
<p className="text-[14px] md:text-[16px] font-bold text-[#3B3B3B]">
|
||||
{resource.name}
|
||||
</p>
|
||||
{resource.type?.name && (
|
||||
<span className="text-[11px] text-[#5559CE] bg-[rgba(85,89,206,0.10)] rounded-[4px] px-[6px] py-[2px] shrink-0">
|
||||
{resource.type.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{label && (
|
||||
<p className="mt-[6px] text-[13px] text-[#616161] font-normal">{label}</p>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResourceSelect;
|
||||
@@ -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('اولین نوبت آزاد: فردا ۱۰:۰۰');
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user