diff --git a/.claude/prompt/public-resource-booking-ui.md b/.claude/prompt/public-resource-booking-ui.md new file mode 100644 index 0000000..545e6c5 --- /dev/null +++ b/.claude/prompt/public-resource-booking-ui.md @@ -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 = ( +

+ نوبت‌دهی آنلاین برای این پزشک فعال نیست. +

+ ); +} else if (multiLocation && !locationConfirmed) { + dateStep = ( + + ); +} else if (serviceMode && selectedServiceUuids.length === 0) { + dateStep = ( + setSelectedServiceUuids(uuids)} /> + ); +} else { + dateStep = ( ); +} +``` + +باطل‌سازی با تعویض محل: + +```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/` باز کن. +بعد همان سرویس را در پنل خاموش کن و رفرش بزن — مرحلهٔ منبع باید ناپدید شود. + +### ۳. کامپوننت `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 = ( + + ); +} 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`، این سه فراخوانی را دستی بررسی کن. diff --git a/app/component/date/datePicker/index.js b/app/component/date/datePicker/index.js index 97c3b7f..b7f6b62 100644 --- a/app/component/date/datePicker/index.js +++ b/app/component/date/datePicker/index.js @@ -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"); diff --git a/app/component/date/dateTime/index.js b/app/component/date/dateTime/index.js index 3364f2d..8229701 100644 --- a/app/component/date/dateTime/index.js +++ b/app/component/date/dateTime/index.js @@ -17,6 +17,7 @@ function DateTime({ serviceMode = false, selectedServiceUuids = [], clinicUuid = null, + resourceUuid = null, selectedLocation = null, }) { const [value, setValue] = useState(0); @@ -48,11 +49,17 @@ function DateTime({ setHour(); const dateStr = moment.unix(date).format("YYYY-MM-DD"); - const req = serviceMode + // منبع تقویم خودش را دارد، پس زمان‌هایش از اندپوینت خودش می‌آید نه از برنامهٔ + // هفتگی پزشک. شکل پاسخ همان `start_times` است، پس adapter مشترک می‌ماند. + const req = resourceUuid ? request - .getServiceSlots(doctor.uuid, dateStr, selectedServiceUuids, clinicUuid) + .getResourceSlots(resourceUuid, dateStr, selectedServiceUuids) .then(adaptServiceSlots) - : request.getAppointmentSlots(doctor.uuid, dateStr, clinicUuid).then(adaptSlots); + : serviceMode + ? request + .getServiceSlots(doctor.uuid, dateStr, selectedServiceUuids, clinicUuid) + .then(adaptServiceSlots) + : request.getAppointmentSlots(doctor.uuid, dateStr, clinicUuid).then(adaptSlots); req .then((parsed) => { @@ -69,7 +76,7 @@ function DateTime({ setAppo("در حال حاضر، نوبتی برای این روز موجود نمی‌باشد."); setValue(0); }); - }, [date, doctor?.uuid, serviceMode, selectedServiceUuids, clinicUuid]); + }, [date, doctor?.uuid, serviceMode, selectedServiceUuids, clinicUuid, resourceUuid]); return (
diff --git a/app/component/date/resourceSlots.test.js b/app/component/date/resourceSlots.test.js new file mode 100644 index 0000000..d027bac --- /dev/null +++ b/app/component/date/resourceSlots.test.js @@ -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: () =>
})); +vi.mock('./dateTime/hours', () => ({ default: () =>
})); +vi.mock('./dateTime/SendAppo', () => ({ default: () =>
})); +vi.mock('../Tabs', () => ({ default: () =>
})); + +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(); + + await waitFor(() => expect(request.getAppointmentSlots).toHaveBeenCalled()); + expect(request.getResourceSlots).not.toHaveBeenCalled(); + }); + + it('حالت سرویسیِ پزشک → اسلات سرویسیِ پزشک', async () => { + render( + + ); + + await waitFor(() => expect(request.getServiceSlots).toHaveBeenCalled()); + expect(request.getResourceSlots).not.toHaveBeenCalled(); + }); + + it('با منبع → اسلات منبع، حتی وقتی برنامهٔ محل اسلاتی است', async () => { + render( + + ); + + 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(); + + await waitFor(() => expect(request.getMonthAvailability).toHaveBeenCalled()); + expect(request.getResourceMonthAvailability).not.toHaveBeenCalled(); + }); + + it('با منبع → تقویم منبع، همراه سرویس‌های انتخاب‌شده', async () => { + render(); + + await waitFor(() => + expect(request.getResourceMonthAvailability).toHaveBeenCalledWith( + 'r1', + expect.any(Number), + expect.any(Number), + ['s1'] + ) + ); + expect(request.getMonthAvailability).not.toHaveBeenCalled(); + }); +}); diff --git a/app/doctor/[slug]/page.js b/app/doctor/[slug]/page.js index a646908..4beadc5 100644 --- a/app/doctor/[slug]/page.js +++ b/app/doctor/[slug]/page.js @@ -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} /> diff --git a/components/appointment/Container.js b/components/appointment/Container.js index c2fea86..da84854 100644 --- a/components/appointment/Container.js +++ b/components/appointment/Container.js @@ -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 = (

نوبت‌دهی آنلاین برای این پزشک فعال نیست. @@ -73,6 +90,15 @@ function Container({ onSelect={changeLocation} /> ); + } else if (resourceStepNeeded) { + dateStep = ( + 0} + /> + ); } else if (serviceMode && selectedServiceUuids.length === 0) { dateStep = ( 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} />, ({ default: ({ children }) =>

{children}
})); +vi.mock('./Content', () => ({ default: ({ children }) =>
{children}
})); +vi.mock('./date', () => ({ default: () =>
مرحله-روز
})); +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 }) =>
{children}
})); + +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(); + + expect(screen.getByText('مرحله-روز')).toBeInTheDocument(); + expect(screen.queryByText('انتخاب نوع نوبت')).not.toBeInTheDocument(); + }); + + it('منبع هست و هنوز انتخاب نشده → مرحلهٔ نوع نوبت', () => { + render(); + + expect(screen.getByText('۱. انتخاب نوع نوبت')).toBeInTheDocument(); + expect(screen.getByText('کندلا2021')).toBeInTheDocument(); + expect(screen.queryByText('مرحله-روز')).not.toBeInTheDocument(); + }); + + it('محلِ تأییدنشده مقدم بر مرحلهٔ منبع است', () => { + render( + + ); + + expect(screen.getByText('۱. انتخاب محل نوبت‌دهی')).toBeInTheDocument(); + expect(screen.queryByText('۱. انتخاب نوع نوبت')).not.toBeInTheDocument(); + }); + + it('پزشکِ بدون برنامهٔ هفتگی ولی دارای منبع → مرحلهٔ منبع، نه پیام «فعال نیست»', () => { + render( + + ); + + expect(screen.getByText('۱. انتخاب نوع نوبت')).toBeInTheDocument(); + expect(screen.getByText('کندلا2021')).toBeInTheDocument(); + expect( + screen.queryByText('نوبت‌دهی آنلاین برای این پزشک فعال نیست.') + ).not.toBeInTheDocument(); + // ویزیت مستقیم وجود ندارد، پس کارتش هم نباید باشد. + expect(screen.queryByText('نوبت با پزشک')).not.toBeInTheDocument(); + }); + + it('نه محل و نه منبع → همان پیام «فعال نیست»', () => { + render(); + + expect(screen.getByText('نوبت‌دهی آنلاین برای این پزشک فعال نیست.')).toBeInTheDocument(); + }); + + it('بعد از انتخاب منبع، مرحلهٔ سرویس با سرویس‌های همان منبع می‌آید', () => { + render( + + ); + + expect(screen.getByText('۱. انتخاب سرویس')).toBeInTheDocument(); + expect(screen.getByText('لیزر دست')).toBeInTheDocument(); + }); +}); diff --git a/components/appointment/date/Time/SelectDatePicker.js b/components/appointment/date/Time/SelectDatePicker.js index af5435f..d9348be 100644 --- a/components/appointment/date/Time/SelectDatePicker.js +++ b/components/appointment/date/Time/SelectDatePicker.js @@ -1,7 +1,15 @@ import DatePicker from "@/app/component/date/datePicker"; -function SelectDatePicker({ setDate, disabledDates, clinicUuid }) { - return ; +function SelectDatePicker({ setDate, disabledDates, clinicUuid, resourceUuid = null, selectedServiceUuids = [] }) { + return ( + + ); } export default SelectDatePicker; diff --git a/components/appointment/date/Time/index.js b/components/appointment/date/Time/index.js index b11d6a1..57aa273 100644 --- a/components/appointment/date/Time/index.js +++ b/components/appointment/date/Time/index.js @@ -1,12 +1,18 @@ import SelectDatePicker from "./SelectDatePicker"; -function Time({ isStep, setDate, disabledDates, clinicUuid }) { +function Time({ isStep, setDate, disabledDates, clinicUuid, resourceUuid = null, selectedServiceUuids = [] }) { return (

1. انتخاب روز

- + {!isStep && (
)} diff --git a/components/appointment/date/hour/index.js b/components/appointment/date/hour/index.js index 778365b..4fd9b3b 100644 --- a/components/appointment/date/hour/index.js +++ b/components/appointment/date/hour/index.js @@ -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 (

@@ -15,6 +15,7 @@ function Hour({ doctor, setStep, date, isStep, setSelectedSlot, setSelectedDate, serviceMode={serviceMode} selectedServiceUuids={selectedServiceUuids} clinicUuid={clinicUuid} + resourceUuid={resourceUuid} selectedLocation={selectedLocation} /> {!isStep && ( diff --git a/components/appointment/date/index.js b/components/appointment/date/index.js index 5d39259..c6331da 100644 --- a/components/appointment/date/index.js +++ b/components/appointment/date/index.js @@ -13,7 +13,9 @@ function Date({ onChangeService, selectedLocation = null, clinicUuid = null, + resourceUuid = null, onChangeLocation, + onChangeResource, onDateChange, closedOnDate = false, }) { @@ -29,7 +31,7 @@ function Date({

- {(onChangeLocation || (serviceMode && onChangeService)) && ( + {(onChangeLocation || onChangeResource || (serviceMode && onChangeService)) && (
{onChangeLocation && ( + )} {serviceMode && onChangeService && (
diff --git a/components/appointment/detail/SubmitData.js b/components/appointment/detail/SubmitData.js index 6b6db13..fec59b8 100644 --- a/components/appointment/detail/SubmitData.js +++ b/components/appointment/detail/SubmitData.js @@ -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(), diff --git a/components/appointment/detail/SubmitData.test.js b/components/appointment/detail/SubmitData.test.js new file mode 100644 index 0000000..2edf04c --- /dev/null +++ b/components/appointment/detail/SubmitData.test.js @@ -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 }) =>
{children}
})); +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(); + 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'); + }); +}); diff --git a/components/appointment/detail/index.js b/components/appointment/detail/index.js index 57e132a..0470a05 100644 --- a/components/appointment/detail/index.js +++ b/components/appointment/detail/index.js @@ -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} />
diff --git a/components/appointment/index.js b/components/appointment/index.js index fe11f26..6967c82 100644 --- a/components/appointment/index.js +++ b/components/appointment/index.js @@ -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} /> diff --git a/components/appointment/resource/ResourceSelect.test.js b/components/appointment/resource/ResourceSelect.test.js new file mode 100644 index 0000000..c9d9cf7 --- /dev/null +++ b/components/appointment/resource/ResourceSelect.test.js @@ -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(); + + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(2); + expect(buttons[0]).toHaveTextContent('نوبت با پزشک'); + expect(buttons[1]).toHaveTextContent('کندلا2021'); + }); + + it('نوع منبع به‌عنوان برچسب دیده می‌شود', () => { + render(); + expect(screen.getByText('دستگاه لیزر')).toBeInTheDocument(); + }); + + it('انتخاب «نوبت با پزشک» مقدار null می‌فرستد', async () => { + const onSelect = vi.fn(); + render(); + + await userEvent.click(screen.getByText('نوبت با پزشک')); + expect(onSelect).toHaveBeenCalledWith(null); + }); + + it('انتخاب منبع، خودِ آبجکت منبع را می‌فرستد', async () => { + const onSelect = vi.fn(); + const r = resource(); + render(); + + await userEvent.click(screen.getByText('کندلا2021')); + expect(onSelect).toHaveBeenCalledWith(r); + }); + + it('تا سه سرویس با نام می‌آید و بقیه با +n', () => { + const services = ['الف', 'ب', 'ج', 'د', 'ه'].map((n, i) => ({ uuid: `s${i}`, name: n })); + render(); + + expect(screen.getByText('الف، ب، ج +2')).toBeInTheDocument(); + }); + + it('منبعِ بدون سرویس، زیرنویس خالی نمی‌سازد', () => { + render(); + + // فقط زیرنویسِ کارتِ پزشک می‌ماند. + expect(screen.getAllByRole('button')[1].textContent).toBe('کندلا2021دستگاه لیزر'); + }); + + it('منبعِ بدون type برچسب نمی‌گیرد و نمی‌شکند', () => { + render(); + expect(screen.getByText('کندلا2021')).toBeInTheDocument(); + }); +}); diff --git a/components/appointment/resource/index.js b/components/appointment/resource/index.js new file mode 100644 index 0000000..7a00ce9 --- /dev/null +++ b/components/appointment/resource/index.js @@ -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 ( +
+

۱. انتخاب نوع نوبت

+ +
+ {/* پزشکی که برنامهٔ هفتگی ندارد، ویزیتِ مستقیم هم ندارد؛ نشان‌دادن این کارت + کاربر را به فهرست ساعتِ همیشه‌خالی می‌برد. */} + {allowDoctorOption && ( + + )} + + {resources.map((resource) => { + const active = selected?.uuid === resource.uuid; + const label = servicesLabel(resource.services); + + return ( + + ); + })} +
+
+ ); +} + +export default ResourceSelect; diff --git a/components/doctor/appointmentList/bookingState.js b/components/doctor/appointmentList/bookingState.js index 5882dbd..63615b2 100644 --- a/components/doctor/appointmentList/bookingState.js +++ b/components/doctor/appointmentList/bookingState.js @@ -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; diff --git a/components/doctor/appointmentList/bookingState.test.js b/components/doctor/appointmentList/bookingState.test.js new file mode 100644 index 0000000..1e3a3f0 --- /dev/null +++ b/components/doctor/appointmentList/bookingState.test.js @@ -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('اولین نوبت آزاد: فردا ۱۰:۰۰'); + }); +}); diff --git a/components/doctor/appointmentList/index.js b/components/doctor/appointmentList/index.js index 0d85b1b..f27e5e9 100644 --- a/components/doctor/appointmentList/index.js +++ b/components/doctor/appointmentList/index.js @@ -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 ( <>
    diff --git a/components/doctor/index.js b/components/doctor/index.js index f3e96a9..dcab990 100644 --- a/components/doctor/index.js +++ b/components/doctor/index.js @@ -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} /> - +
diff --git a/services/response.js b/services/response.js index 4d52537..d2ff73c 100644 --- a/services/response.js +++ b/services/response.js @@ -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 }), diff --git a/services/services.test.js b/services/services.test.js index 380a62b..5e4578d 100644 --- a/services/services.test.js +++ b/services/services.test.js @@ -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(''); + } + }); });