# انتخاب محل نوبت‌دهی (مطب شخصی / کلینیک) در جریان رزرو ## پروژه `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` قابل ساخت است. **انجام شد:** `booking_locations` حالا فیلد `opening_hours` دارد (شیفت‌های فعال هفته با نام انگلیسی روز)، پس `openingHoursSpecification` مستقیماً از همان ساخته می‌شود و به endpoint جدیدی نیاز نیست. - هر صفحه باید `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` هر دو باید سبز باشند.