Files
nobat724_front/.claude/prompt/public-resource-booking-ui.md
hamed 54bebbd41a 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.
2026-08-09 10:45:52 +03:30

304 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# رزرو آنلاین منبع‌محور در جریان نوبت‌گیری
## پروژه
`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`، این سه فراخوانی را دستی بررسی کن.