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