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
+31 -2
View File
@@ -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}
+142
View File
@@ -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;
+8 -2
View File
@@ -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>
)}
+2 -1
View File
@@ -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 && (
+15 -1
View File
@@ -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>
+4 -1
View File
@@ -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');
});
});
+2
View File
@@ -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>
+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}
/>
@@ -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();
});
});
+84
View File
@@ -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('اولین نوبت آزاد: فردا ۱۰:۰۰');
});
});
+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} />