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
@@ -0,0 +1,303 @@
# رزرو آنلاین منبع‌محور در جریان نوبت‌گیری
## پروژه
`nobat724_front` (سایت عمومی).
پرامپت همتا در backend: `clinicpro/.claude/prompt/public-resource-booking-api.md`.
**آن باید اول اجرا و merge شود** — این پرامپت سه اندپوینتی را مصرف می‌کند که آنجا ساخته می‌شوند.
## زمینه
در کلینیک‌پرو، «منبع» هر چیزی است که ممکن است اشغال باشد: پزشک، اپراتور، دستگاه، اتاق، یونیت.
هر منبع تقویم مستقل خودش را دارد و سرویس‌هایی که ارائه می‌دهد، با مدت و قیمتِ اختصاصیِ همان منبع،
روی خودش تعریف می‌شوند. مالک کلینیک با توگل «نمایش در نوبت‌دهی آنلاین» روی هر سرویس تعیین می‌کند
که آن سرویس در سایت دیده شود یا نه.
جریان فعلیِ نوبت‌گیری سایت فقط پزشک‌محور است: محل → (در حالت سرویسی) سرویس → روز → ساعت.
## مشکل / هدف
بیمار نمی‌تواند روی یک دستگاه یا اتاقِ مشخص نوبت بگیرد. هدف: افزودن یک مرحلهٔ «انتخاب منبع» به
همان جریان، بدون شکستن مسیر فعلی.
**تصمیم دامنه (تأییدشده):**
- نقطهٔ ورود فقط `/appointment/[doctorId]` است. صفحهٔ کلینیک در این تسک دست نمی‌خورد.
- فقط منابعِ همان پزشک نمایش داده می‌شوند (backend خودش فیلتر می‌کند؛ سایت فیلتر اضافه نمی‌زند).
- اگر پزشک در آن محل هیچ منبعی نداشته باشد، **هیچ تغییری در تجربهٔ فعلی دیده نمی‌شود**
مرحلهٔ منبع اصلاً رندر نمی‌شود.
## معیار پذیرش
- ✅ موفق:
- پزشکی که در محل انتخاب‌شده منبعِ قابل رزرو دارد: بعد از تأیید محل، مرحلهٔ «انتخاب منبع»
دیده می‌شود با کارت «نوبت با پزشک» به‌علاوهٔ یک کارت به ازای هر منبع.
- انتخاب یک منبع → فهرست سرویس‌های **همان منبع** با مدت و قیمتِ همان منبع → تقویم → ساعت‌ها
از `appointment-resource-slots` → ثبت نوبت با `resource_uuid` در payload → پاسخ ۲۰۱ و رفتن
به مرحلهٔ پرداخت، مثل جریان فعلی.
- انتخاب «نوبت با پزشک» → دقیقاً جریان امروز، بدون هیچ تفاوتی.
- ❌ خطا:
- خطای شبکه یا ۴۲۲ روی `getBookingResources` → مرحلهٔ منبع رندر نشود و جریان پزشک‌محور ادامه
یابد. صفحه نباید سفید شود یا در حالت loading گیر کند.
- ثبت نوبت با ۴۰۹ (منبع در آن لحظه پر شد) → پیام فارسی به کاربر و برگشت به مرحلهٔ ساعت،
نه خطای خام.
- ⚠️ مرزی:
- پزشک بدون منبع → صفر تغییر در UI فعلی (این را صریح تست کن، نه فرض).
- منبعی که در روز انتخاب‌شده شیفت ندارد → پیام «در حال حاضر، نوبتی برای این روز موجود نمی‌باشد.»
مثل امروز، نه فهرست خالی و بی‌توضیح.
- تعویض محل بعد از انتخاب منبع → منبع و سرویس و ساعتِ انتخاب‌شده باید باطل شوند. امروز
`changeLocation` همین کار را برای سرویس و اسلات می‌کند؛ منبع هم باید به آن اضافه شود.
- تعویض منبع بعد از انتخاب سرویس → سرویس‌ها باطل شوند (سرویسِ منبع الف روی منبع ب معتبر نیست
و backend با ۴۲۲ رد می‌کند).
- منبعِ با ظرفیت بیش از ۱: ساعت‌ها ممکن است با نوبتِ موجود هم‌پوشان باشند — این درست است و
سایت نباید فیلتر اضافه بزند.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `services/response.js` | wrapper همهٔ فراخوانی‌های API |
| `components/appointment/index.js` | state جریان نوبت: محل، سرویس، اسلات |
| `components/appointment/Container.js` | تصمیمِ اینکه کدام مرحله رندر شود |
| `components/appointment/location/LocationSelect.js` | الگوی مرجعِ ظاهرِ کارت انتخاب |
| `components/appointment/service/index.js` | مرحلهٔ انتخاب سرویس |
| `components/appointment/date/index.js`, `date/hour/index.js` | مرحلهٔ روز و ساعت |
| `app/component/date/dateTime/index.js` | فراخوانیِ اسلات‌ها |
| `app/component/date/datePicker/index.js` | تقویم و روزهای غیرفعال ماه |
| `components/appointment/detail/SubmitData.js` | ساخت payload و `postAppointment` |
| `lib/appointmentSlots.js` | `adaptServiceSlots` — همان شکل `start_times` |
## وضعیت فعلی
انتخاب مرحله در `Container.js` یک زنجیرهٔ `if/else` است:
```jsx
// components/appointment/Container.js
let dateStep;
if (bookingLocations.length === 0) {
dateStep = (
<p className="w-full max-w-[520px] mx-auto py-[40px] text-center text-[14px] text-[#7A7A7A]">
نوبتدهی آنلاین برای این پزشک فعال نیست.
</p>
);
} else if (multiLocation && !locationConfirmed) {
dateStep = (
<LocationSelect locations={bookingLocations} selected={selectedLocation} onSelect={changeLocation} />
);
} else if (serviceMode && selectedServiceUuids.length === 0) {
dateStep = (
<ServiceSelect services={bookingServices} onContinue={(uuids) => setSelectedServiceUuids(uuids)} />
);
} else {
dateStep = ( <Date ... /> );
}
```
باطل‌سازی با تعویض محل:
```jsx
// components/appointment/index.js
const changeLocation = (location) => {
setSelectedLocation(location);
setLocationConfirmed(true);
setSelectedServiceUuids([]);
setSelectedSlot(null);
setSelectedDate(null);
};
```
اسلات‌ها امروز فقط دو حالت دارند:
```jsx
// app/component/date/dateTime/index.js
const req = serviceMode
? request
.getServiceSlots(doctor.uuid, dateStr, selectedServiceUuids, clinicUuid)
.then(adaptServiceSlots)
: request.getAppointmentSlots(doctor.uuid, dateStr, clinicUuid).then(adaptSlots);
```
و روزهای غیرفعال تقویم فقط پزشک‌محورند:
```jsx
// app/component/date/datePicker/index.js
request
.getMonthAvailability(doctorUuid, year, month, clinicUuid)
```
payload ثبت نوبت هنوز `resource_uuid` ندارد:
```jsx
// components/appointment/detail/SubmitData.js
const appointmentPayload = {
doctor_uuid: doctor.uuid,
clinic_uuid: clinicUuid,
slot_start: selectedSlot.start,
slot_end: selectedSlot.end,
// ...
...(selectedServiceUuids?.length ? { service_item_uuids: selectedServiceUuids } : {}),
```
## وظایف
### ۱. سه wrapper جدید در `services/response.js`
کنار همتاهای پزشک‌محور، با همان سبک و همان `removeTokenHead` (این اندپوینت‌ها عمومی‌اند):
```js
getBookingResources: (doctor_uuid, clinic_uuid = null) =>
api.get(
`api/v1/appointment-booking-resources/${doctor_uuid}${clinicQuery(clinic_uuid, "?")}`,
removeTokenHead
),
getResourceSlots: (resource_uuid, date, serviceItemUuids = []) =>
api.get(
`api/v1/appointment-resource-slots?resource_uuid=${resource_uuid}&date=${date}` +
serviceItemUuids
.map((u) => `&service_item_uuids[]=${encodeURIComponent(u)}`)
.join(""),
removeTokenHead
),
getResourceMonthAvailability: (resource_uuid, year, month, serviceItemUuids = []) =>
api.get(`api/v1/appointment-resource-month-availability/${resource_uuid}`, {
params: { year, month },
...removeTokenHead,
}),
```
نکته: `service_item_uuids[]` آرایه است و `params` اکسیوس آن را با `[]` سریالایز نمی‌کند — به همین
دلیل `getServiceSlots` موجود هم دستی رشته می‌سازد. برای `month-availability` هم همان کار را بکن
(رشتهٔ query دستی)، وگرنه backend سرویس‌ها را نمی‌بیند و مدت را نمی‌تواند حساب کند.
**نحوه تست:** در devtools، هر سه فراخوانی باید بدون هدر `Authorization` بروند و ۲۰۰ بگیرند.
### ۲. state منبع در `components/appointment/index.js`
```jsx
const [resources, setResources] = useState([]);
const [selectedResource, setSelectedResource] = useState(null); // null = نوبت با پزشک
const [resourceConfirmed, setResourceConfirmed] = useState(false);
```
- با تغییر `selectedLocation`، منابع همان محل گرفته شوند:
`request.getBookingResources(doctor.uuid, selectedLocation?.clinic_uuid ?? null)`.
خطا → `setResources([])` (جریان فعلی ادامه یابد؛ سکوت عمدی است، نه فراموشی — کامنتش را بنویس).
- `changeLocation` باید `selectedResource`، `resourceConfirmed` و `resources` را هم باطل کند.
- تابع `changeResource(resource)` که `selectedServiceUuids`، `selectedSlot`، `selectedDate` را
صفر می‌کند و `resourceConfirmed = true` می‌گذارد.
- وقتی `resources.length === 0`، مقدار `resourceConfirmed` باید از ابتدا `true` باشد تا مرحله
اصلاً رندر نشود.
- سرویس‌هایی که به `ServiceSelect` می‌روند: اگر منبع انتخاب شده، `selectedResource.services`؛
وگرنه همان `selectedLocation?.services` امروز.
- وقتی منبع انتخاب شده، جریان **همیشه** سرویسی است (منبع اسلاتِ ثابت ندارد)، حتی اگر
`booking_mode` آن محل `slot` باشد.
**نحوه تست:** با `09390039833 / 09390039833` در پنل `/admin` یک منبع بساز، به آن یک سرویس با
«نمایش در نوبت‌دهی آنلاین» روشن وصل کن، سپس صفحهٔ نوبتِ همان پزشک را در
`http://yazd-nobat.localhost:3000/appointment/<doctor-uuid>` باز کن.
بعد همان سرویس را در پنل خاموش کن و رفرش بزن — مرحلهٔ منبع باید ناپدید شود.
### ۳. کامپوننت `components/appointment/resource/index.js`
کارت‌های انتخاب، **دقیقاً با ظاهر و کلاس‌های `LocationSelect.js` و `ServiceSelect`** — طراحی
جدید نساز، رنگ جدید معرفی نکن. رنگ‌های موجود: `#5559CE` (اصلی)، `#3B3B3B` (متن)، `#7A7A7A`
(متن ثانویه)، `#EFEFEF` (خط).
محتوای هر کارت:
- کارت اول همیشه: «نوبت با پزشک» با زیرعنوانِ کوتاه — انتخابش یعنی `changeResource(null)`.
- هر منبع: `name` به‌عنوان عنوان، `type.name` به‌عنوان برچسبِ نوع، تعداد سرویس‌ها و کوتاه‌ترین
مدت به‌عنوان زیرنویس.
- عنوان مرحله: «۱. انتخاب نوع نوبت» — و در نتیجه سرویس «۲.» و ساعت «۳.» می‌شود. شماره‌های
موجود در `ServiceSelect` و `Hour` باید هماهنگ شوند، وگرنه دو مرحله هر دو «۱.» می‌شوند.
در `Container.js` این مرحله **بعد از** تأیید محل و **قبل از** انتخاب سرویس بنشیند:
```jsx
} else if (resources.length > 0 && !resourceConfirmed) {
dateStep = (
<ResourceSelect resources={resources} onSelect={changeResource} />
);
} else if (serviceMode && selectedServiceUuids.length === 0) {
```
و در مرحلهٔ روز، دکمهٔ «← تغییر نوع نوبت» کنار «تغییر محل» و «تغییر سرویس» موجود در
`components/appointment/date/index.js` اضافه شود (همان سبک، همان کلاس).
**نحوه تست:** هر سه دکمهٔ بازگشت را بزن و مطمئن شو انتخاب‌های پایین‌دستی پاک می‌شوند — مثلاً
بعد از «تغییر نوع نوبت»، سرویس قبلی نباید هنوز انتخاب باشد.
### ۴. اسلات‌ها و تقویمِ منبع
در `app/component/date/dateTime/index.js` یک شاخهٔ سوم:
```jsx
const req = resourceUuid
? request
.getResourceSlots(resourceUuid, dateStr, selectedServiceUuids)
.then(adaptServiceSlots)
: serviceMode
? request.getServiceSlots(...).then(adaptServiceSlots)
: request.getAppointmentSlots(...).then(adaptSlots);
```
`adaptServiceSlots` بدون تغییر کار می‌کند: پاسخ منبع هم `start_times` با همان شکل
`{start, end, start_time, end_time}` می‌دهد. **`lib/appointmentSlots.js` را دست نزن.**
`resourceUuid` باید از `Container` تا `DateTime` رد شود:
`Date``Hour``DateTime`. همان زنجیره‌ای که `clinicUuid` امروز طی می‌کند.
در `app/component/date/datePicker/index.js`، وقتی `resourceUuid` داده شده،
`getResourceMonthAvailability(resourceUuid, year, month, selectedServiceUuids)` جای
`getMonthAvailability` بنشیند. کشِ `loadedMonths` باید با تغییر `resourceUuid` هم پاک شود —
دقیقاً همان `useEffect`ی که امروز برای `clinicUuid` هست:
```jsx
useEffect(() => {
loadedMonths.current = new Set();
setDisabledSet(new Set());
setOnlineEnabled(true);
setAutoSelected(false);
}, [clinicUuid]); // ← resourceUuid هم به آرایهٔ وابستگی اضافه شود
```
**نحوه تست:** یک منبع با شیفتِ فقط دوشنبه بساز. در تقویم سایت، همهٔ روزها جز دوشنبه‌ها باید
غیرفعال باشند. روی یک دوشنبه بزن و ساعت‌ها را ببین.
### ۵. `resource_uuid` در payload ثبت نوبت
در `components/appointment/detail/SubmitData.js`:
```jsx
// نوبتِ منبع‌محور: backend مدت و slot_end را از زنجیرهٔ حلِ همین منبع بازمحاسبه
// می‌کند، پس عددِ کلاینت فقط پیشنهاد است.
...(resourceUuid ? { resource_uuid: resourceUuid } : {}),
```
`resourceUuid` باید از `Container` به `Detail` و از آنجا به `SubmitData` برسد — همان مسیری که
`selectedServiceUuids` و `clinicUuid` امروز طی می‌کنند.
مدیریت ۴۰۹: پاسخ backend در این حالت `{ success: false, errors: [{ code, message, field: 'resource_uuid' }] }`
است. پیام فارسیِ همان errors نمایش داده شود و کاربر به مرحلهٔ ساعت برگردد.
**نحوه تست (سناریوی کامل):**
۱. در پنل، منبع + سرویسِ روشن بساز.
۲. در سایت نوبت بگیر تا مرحلهٔ پرداخت.
۳. در پنل `/admin/appointments` نوبت را ببین: باید فیلد منبع را نشان دهد.
۴. برای تست ۴۰۹: همان بازه را از پنل روی همان منبع رزرو کن، بعد در تبِ سایت ثبت را بزن.
## نکات مهم
- **صفر تغییر برای پزشک بدون منبع.** هر تغییری که مسیر فعلی را حتی یک کلیک عوض کند، اشتباه است.
این را با یک پزشکِ بدون منبع دستی تست کن، نه با خواندن کد.
- **طراحی جدید ممنوع.** مرحلهٔ منبع باید از کامپوننت‌ها، کلاس‌ها و رنگ‌های موجود ساخته شود.
`LocationSelect.js` مرجع ظاهر است.
- RTL، فارسی، تقویم جلالی — مثل بقیهٔ جریان. عدد مدت و قیمت با `toLocaleString("fa-IR")` مثل
`toToman()` موجود در `ServiceSelect`.
- **باطل‌سازیِ آبشاری** یک قاعده است نه سه‌تا استثنا: محل → منبع → سرویس → روز → ساعت. تغییر هر
سطح، همهٔ سطوح پایین‌ترش را پاک می‌کند. اگر این را در یک تابع متمرکز کنی، جای سه `setState`
پراکنده، خطای بعدی خودش را نشان می‌دهد.
- **`durations[]` نفرست.** backend در مسیر عمومی آن را نمی‌پذیرد؛ override مدت ابزار پنل است.
- **cross-repo:** تغییر شکل پاسخ در backend اینجا خطای build نمی‌دهد. بعد از هر تغییر در
`clinicpro`، این سه فراخوانی را دستی بررسی کن.
+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");
+9 -2
View File
@@ -17,6 +17,7 @@ function DateTime({
serviceMode = false,
selectedServiceUuids = [],
clinicUuid = null,
resourceUuid = null,
selectedLocation = null,
}) {
const [value, setValue] = useState(0);
@@ -48,7 +49,13 @@ function DateTime({
setHour();
const dateStr = moment.unix(date).format("YYYY-MM-DD");
const req = serviceMode
// منبع تقویم خودش را دارد، پس زمان‌هایش از اندپوینت خودش می‌آید نه از برنامهٔ
// هفتگی پزشک. شکل پاسخ همان `start_times` است، پس adapter مشترک می‌ماند.
const req = resourceUuid
? request
.getResourceSlots(resourceUuid, dateStr, selectedServiceUuids)
.then(adaptServiceSlots)
: serviceMode
? request
.getServiceSlots(doctor.uuid, dateStr, selectedServiceUuids, clinicUuid)
.then(adaptServiceSlots)
@@ -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}
/>
+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} />
+29 -3
View File
@@ -9,6 +9,15 @@ import api from "./api";
const clinicQuery = (clinicUuid, prefix = "&") =>
clinicUuid ? `${prefix}clinic_uuid=${encodeURIComponent(clinicUuid)}` : "";
/**
* آرایه را با `[]` سریالایز می‌کند؛ `params` اکسیوس این کار را نمی‌کند و backend
* فهرست سرویس‌ها را خالی می‌بیند.
*/
const serviceQuery = (serviceItemUuids = []) =>
serviceItemUuids
.map((u) => `&service_item_uuids[]=${encodeURIComponent(u)}`)
.join("");
export const request = {
sendCode: (mobile, altcha, domain) =>
api.post(
@@ -81,12 +90,29 @@ export const request = {
getServiceSlots: (doctor_uuid, date, serviceItemUuids = [], clinic_uuid = null) =>
api.get(
`api/v1/appointment-service-slots?doctor_uuid=${doctor_uuid}&date=${date}` +
serviceItemUuids
.map((u) => `&service_item_uuids[]=${encodeURIComponent(u)}`)
.join("") +
serviceQuery(serviceItemUuids) +
clinicQuery(clinic_uuid),
removeTokenHead
),
// نوبت‌دهی منبع‌محور: منبع تقویم خودش را دارد، پس اسلات و تقویمِ ماهش هم از
// اندپوینت‌های خودش می‌آید نه از برنامهٔ هفتگی پزشک.
getBookingResources: (doctor_uuid, clinic_uuid = null) =>
api.get(
`api/v1/appointment-booking-resources/${doctor_uuid}${clinicQuery(clinic_uuid, "?")}`,
removeTokenHead
),
getResourceSlots: (resource_uuid, date, serviceItemUuids = []) =>
api.get(
`api/v1/appointment-resource-slots?resource_uuid=${encodeURIComponent(resource_uuid)}&date=${date}` +
serviceQuery(serviceItemUuids),
removeTokenHead
),
getResourceMonthAvailability: (resource_uuid, year, month, serviceItemUuids = []) =>
api.get(
`api/v1/appointment-resource-month-availability/${resource_uuid}?year=${year}&month=${month}` +
serviceQuery(serviceItemUuids),
removeTokenHead
),
postAppointment: (data) => api.post(`api/v1/appointment`, data, { requireAuth: true }),
getMyAppointments: (params) => api.get(`api/v1/appointments/user`, { params, requireAuth: true }),
getPaymentConfig: () => api.get(`api/v1/payment/config`, { requireAuth: true }),
+52
View File
@@ -73,4 +73,56 @@ describe('request wrappers — قرارداد به لایه‌ی API', () => {
request.postAppointment({ slot: 5 });
expect(api.post).toHaveBeenCalledWith('api/v1/appointment', { slot: 5 }, { requireAuth: true });
});
// --- نوبت‌دهی منبع‌محور ---
it('getBookingResources → بدون کلینیک، مسیر ساده', () => {
request.getBookingResources('doc-1');
expect(api.get.mock.calls[0][0]).toBe('api/v1/appointment-booking-resources/doc-1');
});
it('getBookingResources → با کلینیک، clinic_uuid با ? می‌آید', () => {
request.getBookingResources('doc-1', 'cl-1');
expect(api.get.mock.calls[0][0]).toBe(
'api/v1/appointment-booking-resources/doc-1?clinic_uuid=cl-1'
);
});
it('getResourceSlots → هر سرویس یک service_item_uuids[] جدا می‌گیرد', () => {
request.getResourceSlots('res-1', '2026-08-10', ['s1', 's2']);
const url = api.get.mock.calls[0][0];
expect(url).toContain('resource_uuid=res-1');
expect(url).toContain('date=2026-08-10');
expect(url).toContain('&service_item_uuids[]=s1');
expect(url).toContain('&service_item_uuids[]=s2');
});
it('getResourceSlots → uuid با کاراکتر ویژه encode می‌شود', () => {
request.getResourceSlots('res-1', '2026-08-10', ['a/b']);
expect(api.get.mock.calls[0][0]).toContain('service_item_uuids[]=a%2Fb');
});
it('getResourceSlots → فهرست خالی هیچ پارامتر سرویسی نمی‌سازد', () => {
request.getResourceSlots('res-1', '2026-08-10', []);
expect(api.get.mock.calls[0][0]).not.toContain('service_item_uuids');
});
it('getResourceMonthAvailability → سال و ماه و سرویس‌ها در query', () => {
request.getResourceMonthAvailability('res-1', 2026, 9, ['s1']);
const url = api.get.mock.calls[0][0];
expect(url).toContain('api/v1/appointment-resource-month-availability/res-1');
expect(url).toContain('year=2026');
expect(url).toContain('month=9');
expect(url).toContain('&service_item_uuids[]=s1');
});
it('هر سه اندپوینت عمومی‌اند — هدر Authorization حذف می‌شود', () => {
request.getBookingResources('doc-1');
request.getResourceSlots('res-1', '2026-08-10', ['s1']);
request.getResourceMonthAvailability('res-1', 2026, 9, ['s1']);
for (const call of api.get.mock.calls) {
expect(call[1]?.headers?.Authorization).toBe('');
}
});
});