- 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.
304 lines
17 KiB
Markdown
304 lines
17 KiB
Markdown
# رزرو آنلاین منبعمحور در جریان نوبتگیری
|
||
|
||
## پروژه
|
||
|
||
`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`، این سه فراخوانی را دستی بررسی کن.
|