diff --git a/.claude/prompt/booking-locations-multi-context.md b/.claude/prompt/booking-locations-multi-context.md new file mode 100644 index 0000000..25269f9 --- /dev/null +++ b/.claude/prompt/booking-locations-multi-context.md @@ -0,0 +1,355 @@ +# انتخاب محل نوبت‌دهی (مطب شخصی / کلینیک) در جریان رزرو + +## پروژه + +`nobat724_front` — پرامپت همتای backend که **قبلاً اجرا شده** است: +`clinicpro/.claude/prompt/context-separation-clinic-vs-doctor-booking.md` + +قرارداد API از سمت backend آماده است؛ این پرامپت فقط سمت مصرف‌کننده را می‌نویسد. + +## زمینه + +تا پیش از این، هر پزشک در کل سیستم **یک** برنامهٔ نوبت‌دهی داشت (`weekly_schedules` با +`UNIQUE(doctor_id)`). حالا برنامه per-context است: یک برنامهٔ مطب شخصی + یکی به ازای هر کلینیکی +که پزشک در آن عضو است. ستون `clinic_id` روی `weekly_schedules`، `date_overrides` و `holidays` +اضافه شده (`NULL` = مطب شخصی). + +سرویس‌ها هم polymorphic‌اند و بین این دو محیط **هرگز** مشترک نمی‌شوند: سرویس‌های کلینیک فقط در +نوبت‌دهی کلینیک و سرویس‌های شخصی فقط در مطب شخصی. قیمت و مدت سرویس بین محل‌ها متفاوت است. + +## مشکل / هدف + +**این یک شکست خاموش است، نه یک قابلیت جدید.** + +همهٔ endpointهای رزرو حالا پارامتر اختیاری `clinic_uuid` می‌گیرند و **نبودِ آن یعنی «مطب شخصی»** — +نه «هر محلی که پیدا شد». سایت الان هیچ‌جا `clinic_uuid` نمی‌فرستد، پس: + +- برای پزشکی که فقط در کلینیک کار می‌کند، سایت **هیچ نوبتی نشان نمی‌دهد** (برنامهٔ شخصی ندارد). +- برای پزشکی که هر دو را دارد، سایت فقط ظرفیت مطب شخصی را نشان می‌دهد و بیمار بدون اطلاع نوبت + را در محل اشتباه رزرو می‌کند. +- در حالت سرویسی، `POST /api/v1/appointment` با سرویسی که به آن محل تعلق ندارد `422` می‌گیرد. + +هدف: بیمار **محل** را آگاهانه انتخاب کند و آن انتخاب تا لحظهٔ ثبت نوبت حمل شود. + +## قرارداد API (آمادهٔ مصرف) + +### endpoint جدید + +``` +GET /api/v1/appointment-booking-locations/{doctorUuid} (عمومی، بدون auth) +``` + +```json +{ + "success": true, + "data": { + "doctor_uuid": "550e8400-…", + "booking_locations": [ + { + "location_uuid": "0f0b…", + "type": "personal", + "title": "مطب شخصی", + "address": "یزد، خیابان …", + "clinic_uuid": null, + "booking_mode": "slot", + "buffer_minutes": 0, + "services": [], + "next_available_at": 1755000000 + }, + { + "location_uuid": "7c21…", + "type": "clinic", + "title": "کلینیک علی بهروزی", + "address": "یزد، بلوار …", + "clinic_uuid": "41e325c4-e825-4067-8438-5d828ecaee09", + "booking_mode": "service", + "buffer_minutes": 10, + "services": [ + { "uuid": "…", "name": "ویزیت", "duration_minutes": 20, "price_rials": 500000, + "service_section": { "uuid": "…", "name": "عمومی" } } + ], + "next_available_at": 1754900000 + } + ] + } +} +``` + +نکات قرارداد: + +- آرایه **از قبل مرتب است** بر اساس `next_available_at` صعودی؛ محل‌های بدون ظرفیت آخر می‌آیند. + پس `booking_locations[0]` پیش‌فرضِ درست است — دوباره مرتب نکن. +- `next_available_at` می‌تواند `null` باشد (تا ۳۰ روز آینده ظرفیتی نیست). +- `location_uuid` می‌تواند `null` باشد (آن محیط هنوز آدرس ثبت‌شده ندارد) — برای انتخاب از + `clinic_uuid` استفاده کن، نه `location_uuid`. +- `booking_mode` **per-location** است: یک پزشک می‌تواند در مطب شخصی اسلاتی و در کلینیک سرویسی باشد. +- `services` فقط در حالت `service` پُر است. +- پاسخ double-nested است (`json.data.data`) مثل بقیهٔ endpointها. + +### پارامتر جدید روی endpointهای موجود + +| endpoint | محل پارامتر | +|---|---| +| `GET /api/v1/appointment-slots` | query `clinic_uuid` | +| `GET /api/v1/appointment-service-slots` | query `clinic_uuid` | +| `GET /api/v1/appointment-booking-services/{doctorUuid}` | query `clinic_uuid` | +| `GET /api/v1/appointment-settings/month-availability/{doctorUuid}` | query `clinic_uuid` | +| `POST /api/v1/appointment` | body `clinic_uuid` | + +هر چهار `GET` مقدار `clinic_uuid` را در پاسخ echo می‌کنند تا کلاینت بفهمد کدام محیط جواب داده. +اگر پزشک عضو آن کلینیک نباشد → `404 ERR_VALIDATION_002` («محل نوبت‌دهی یافت نشد»). +سرویسِ متعلق به محیط دیگر در `POST` → `422 ERR_VALIDATION_001` +(«سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد»). + +مستندات کامل: `clinicpro/docs/api/appointment.md` → بخش «Booking context (2026-07)». + +## فایل‌های مرتبط + +| فایل | نقش | +|---|---| +| `services/response.js:52-72` | همهٔ فراخوانی‌های رزرو | +| `components/appointment/index.js:45` | `AppointmentPage` — نگه‌دارندهٔ کل state رزرو | +| `components/appointment/Container.js:15-113` | مسیریابی مرحله‌ها (prop drilling) | +| `components/appointment/service/index.js:11` | `ServiceSelect` | +| `components/appointment/date/index.js` | wrapper مرحلهٔ تاریخ (`locateVisit` مرده) | +| `app/component/date/datePicker/index.js:24` | `DatePicker` — تقویم ماه + `getMonthAvailability` | +| `app/component/date/dateTime/index.js:11` | `DateTime` — اسلات‌ها + resolve آدرس | +| `app/component/date/dateTime/hours/index.js:17-26` | کارت «آدرس مطب:» | +| `components/appointment/detail/SubmitData.js:138-160` | payload نهایی `postAppointment` | +| `components/appointment/location/Address.js` | سایدبار آدرس‌ها (نمایشی) | +| `app/doctor/[slug]/page.js:107-157` | JSON-LD صفحهٔ پزشک | +| `components/doctor/appointmentList/index.js:33` | CTA ورود به رزرو | +| `lib/appointmentSlots.js` | `adaptSlots` / `adaptServiceSlots` | + +## وضعیت فعلی + +### ۱. هیچ فراخوانی‌ای محل را نمی‌فرستد + +`services/response.js:52-72`: + +```js + getAppointmentSlots: (doctor_uuid, date) => + api.get( + `api/v1/appointment-slots?doctor_uuid=${doctor_uuid}&date=${date}`, + removeTokenHead + ), + getMonthAvailability: (doctor_uuid, year, month) => + api.get(`api/v1/appointment-settings/month-availability/${doctor_uuid}`, { + params: { year, month }, + ...removeTokenHead, + }), + getBookingServices: (doctor_uuid) => + api.get(`api/v1/appointment-booking-services/${doctor_uuid}`, removeTokenHead), + getServiceSlots: (doctor_uuid, date, serviceItemUuids = []) => + api.get( + `api/v1/appointment-service-slots?doctor_uuid=${doctor_uuid}&date=${date}` + + serviceItemUuids + .map((u) => `&service_item_uuids[]=${encodeURIComponent(u)}`) + .join(""), + removeTokenHead + ), + postAppointment: (data) => api.post(`api/v1/appointment`, data, { requireAuth: true }), +``` + +### ۲. آدرس فقط نمایشی است و از `doctor.address` می‌آید + +`app/component/date/dateTime/index.js:33-35` — تنها جایی که آدرس یک اسلات حل می‌شود: + +```js + const locationAddress = hour?.location_id + ? doctor?.address?.find((a) => String(a.id) === String(hour.location_id))?.address ?? null + : null; +``` + +هیچ محلی قابل انتخاب نیست و هیچ id محلی در state رزرو ذخیره نمی‌شود. + +### ۳. payload نهایی + +`components/appointment/detail/SubmitData.js:138-158`: + +```js +const appointmentPayload = { + doctor_uuid: doctor.uuid, + slot_start: selectedSlot.start, + slot_end: selectedSlot.end, + for_self: !isForAnother, + note: data?.patient_reason?.value || "", + patient_national_code: data?.national_code?.value || "", + patient_gender: data?.gender?.value?.id || data?.gender?.value || "", + city_id: cityId ?? null, + ...(selectedServiceUuids?.length ? { service_item_uuids: selectedServiceUuids } : {}), + ...(isForAnother ? { patient_name, patient_mobile, patient_reason } : {}), +}; +``` + +### ۴. state مرده + +`components/appointment/date/index.js:15` — `locateVisit` با `doctor?.multiwork` گیت شده، مقدار +اولیه‌اش `true` است و هیچ فرزندی `false` نمی‌کند. عملاً کد مرده است. **حذفش کن** و جای آن انتخاب +واقعی محل بنشان. + +### ۵. JSON-LD بدون ساعات کاری + +`app/doctor/[slug]/page.js:107-157` — `workLocation` وجود دارد ولی هیچ +`openingHoursSpecification` و هیچ `availableService` ندارد. + +## وظایف + +### ۱. لایهٔ سرویس — `services/response.js` + +یک helper بساز تا `clinic_uuid` تکرار نشود: + +```js +const withClinic = (params, clinicUuid) => + clinicUuid ? { ...params, clinic_uuid: clinicUuid } : params; +``` + +و امضاها را گسترش بده (پارامتر آخر، اختیاری، تا هیچ call site موجودی نشکند): + +```js + getBookingLocations: (doctor_uuid) => + api.get(`api/v1/appointment-booking-locations/${doctor_uuid}`, removeTokenHead), + + getAppointmentSlots: (doctor_uuid, date, clinic_uuid = null) => + api.get("api/v1/appointment-slots", { + params: withClinic({ doctor_uuid, date }, clinic_uuid), + ...removeTokenHead, + }), + + getMonthAvailability: (doctor_uuid, year, month, clinic_uuid = null) => + api.get(`api/v1/appointment-settings/month-availability/${doctor_uuid}`, { + params: withClinic({ year, month }, clinic_uuid), + ...removeTokenHead, + }), + + getBookingServices: (doctor_uuid, clinic_uuid = null) => + api.get(`api/v1/appointment-booking-services/${doctor_uuid}`, { + params: withClinic({}, clinic_uuid), + ...removeTokenHead, + }), + + getServiceSlots: (doctor_uuid, date, serviceItemUuids = [], clinic_uuid = null) => + api.get("api/v1/appointment-service-slots", { + params: withClinic({ doctor_uuid, date, "service_item_uuids[]": serviceItemUuids }, clinic_uuid), + ...removeTokenHead, + }), +``` + +**دقت:** `getAppointmentSlots` و `getServiceSlots` الان query را دستی می‌چسبانند. با انتقال به +`params`، سریال‌سازی آرایهٔ `service_item_uuids[]` توسط axios انجام می‌شود — بررسی کن خروجی همچنان +`?service_item_uuids[]=a&service_item_uuids[]=b` باشد (نه `service_item_uuids[0]=a`). اگر نبود، +`paramsSerializer` بده یا همان روش دستی را با افزودن `clinic_uuid` نگه دار. + +### ۲. state انتخاب محل — `components/appointment/index.js` + +`AppointmentPage` نگه‌دارندهٔ state است؛ محل هم همان‌جا بنشیند: + +```js +const [bookingLocations, setBookingLocations] = useState([]); +const [selectedLocation, setSelectedLocation] = useState(null); // یک آیتم از booking_locations +``` + +هنگام mount، `getBookingLocations(doctor.uuid)` را صدا بزن. سپس: + +- اگر `?clinic_uuid=` یا `?location=` در URL بود → همان را انتخاب کن. +- وگرنه `booking_locations[0]` (آرایه از قبل بر اساس زودترین نوبت آزاد مرتب است). +- اگر آرایه خالی بود → پیام «برای این پزشک نوبت‌دهی آنلاین فعال نیست» و ادامه نده. + +**بحرانی:** `booking_mode` و `services` را از `selectedLocation` بخوان، نه از فراخوانی جدای +`getBookingServices`. `booking_locations` هر دو را از قبل دارد و یک درخواست کمتر می‌زند. `state`های +`bookingMode` و `bookingServices` موجود (`index.js:46-67`) باید از `selectedLocation` مشتق شوند: + +```js +const bookingMode = selectedLocation?.booking_mode ?? "slot"; +const bookingServices = selectedLocation?.services ?? []; +``` + +`getBookingServices` را فقط برای سازگاری در `response.js` نگه دار ولی از این جریان حذفش کن. + +### ۳. تعویض محل باید state وابسته را پاک کند + +وقتی کاربر محل را عوض می‌کند، این‌ها **باید** reset شوند وگرنه ترکیب نامعتبر ساخته می‌شود +(سرویس کلینیک A + اسلات کلینیک B → خطای ۴۲۲ در انتها): + +```js +const changeLocation = (loc) => { + setSelectedLocation(loc); + setSelectedServiceUuids([]); + setSelectedSlot(null); + setSelectedDate(null); + setStep(loc.booking_mode === "service" ? SERVICE_STEP : DATE_STEP); +}; +``` + +### ۴. UI انتخاب محل + +اگر `booking_locations.length > 1`، یک مرحله/کارت انتخاب محل **قبل از** انتخاب سرویس و تاریخ +نشان بده. اگر فقط یک محل بود، مرحله را نشان نده و مستقیم انتخابش کن. + +هر کارت: `title`، `address`، و برچسب زودترین نوبت. برای `next_available_at` از `jalali-moment` +استفاده کن (همان الگوی بقیهٔ پروژه) و اگر `null` بود «فعلاً نوبت خالی ندارد» با ظاهر غیرفعال. + +`components/appointment/location/Address.js` (سایدبار نمایشی) باید محل انتخاب‌شده را برجسته کند. +`components/appointment/date/index.js` → `locateVisit` مرده را حذف کن. + +**الزامی:** از کامپوننت‌ها، تم، فونت (Vazir) و MUI v5 + Tailwind موجود استفاده کن — طراحی جدید +نساز. RTL رعایت شود. + +### ۵. حمل محل تا لحظهٔ ثبت + +`app/component/date/datePicker/index.js` و `app/component/date/dateTime/index.js` باید +`clinicUuid` را prop بگیرند و به فراخوانی‌ها بدهند. `DatePicker` الان `doctorUuid` را از +`useParams().doctorId` می‌خواند (`index.js:25-26`) — `clinicUuid` را به‌صورت prop بده، نه از URL. + +`components/appointment/detail/SubmitData.js:138`: + +```js +const appointmentPayload = { + doctor_uuid: doctor.uuid, + clinic_uuid: selectedLocation?.clinic_uuid ?? null, + slot_start: selectedSlot.start, + ... +}; +``` + +آدرس نمایشی در `app/component/date/dateTime/index.js:33-35` را از `selectedLocation.address` +بگیر — نه از `doctor.address.find(...)`، چون `location_id` عددی است و در محیط کلینیک ممکن است در +`doctor.address` نباشد. + +### ۶. لینک‌پذیری و SEO + +- CTA در `components/doctor/appointmentList/index.js:33` باید محل را حمل کند وقتی صفحهٔ پزشک + محلی را برجسته کرده: `/appointment/${doctorSlug}?clinic_uuid=${clinicUuid}`. +- `app/appointment/[doctorId]/page.js` باید `searchParams` را بخواند (و `await` کند طبق App Router). +- `app/doctor/[slug]/page.js` — JSON-LD را با ساعات کاری هر محل غنی کن. `booking_locations` را + server-side بگیر (با `fetchReq` از `lib/req.js` و `revalidate` مشابه `getDoctorAddresses`) و برای + هر محل یک `MedicalClinic` با `openingHoursSpecification` بساز و به `workLocation` وصل کن. + در حالت سرویسی، `availableService` هم از `services` قابل ساخت است. + + اگر endpoint ساعات هفتگی عمومی در دسترس نبود، این بند را محدود کن به افزودن + `availableService` و در PR ذکر کن که `openingHoursSpecification` نیاز به یک endpoint عمومی + جدید در `clinicpro` دارد. +- هر صفحه باید `generateMetadata` داشته باشد و `params` همیشه `await` شود. + +### ۷. سازگاری و حالت‌های مرزی + +- پزشکی که فقط مطب شخصی دارد → یک آیتم با `type: "personal"`؛ UI نباید تغییری حس شود. +- پزشکی که فقط کلینیک دارد → قبلاً هیچ نوبتی نشان داده نمی‌شد؛ حالا باید کار کند. **این را حتماً + دستی تست کن.** +- `booking_locations` خالی → پیام روشن، نه صفحهٔ خالی. +- `422` سرویسِ ناسازگار → پیام فارسی خطای backend را نشان بده (پیام آماده است). + +## نکات مهم + +- **این تغییر بدون به‌روزرسانی این سایت، ظرفیت واقعی پزشکان کلینیکی را از سایت حذف می‌کند.** + اولویت بالا. +- پاسخ‌ها double-nested هستند: `json?.data?.data ?? json?.data`. +- برای auth از `{ requireAuth: true }` استفاده کن (کوکی `access_token` → Bearer). endpointهای + اسلات عمومی‌اند و `removeTokenHead` می‌گیرند. +- slug پزشک = `uuid`. +- تاریخ‌ها شمسی (`jalali-moment`)، رشته‌های جدید فارسی. +- شهر از subdomain: server با `lib/getStateInfo.js`، client با `useProvince()`. `city_id` موجود در + payload را دست نزن. +- تست دستی با کلینیک `41e325c4-e825-4067-8438-5d828ecaee09` و «دکتر تست» (`09100652121`) که در + همان کلینیک سرویس bookable دارد. +- بعد از تغییرات: `npm run lint` و `npm run build` هر دو باید سبز باشند.