Files
nobat724_front/.claude/prompt/public-resource-booking-ui.md
T
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

17 KiB
Raw Blame History

رزرو آنلاین منبع‌محور در جریان نوبت‌گیری

پروژه

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 است:

// 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 ... /> );
}

باطل‌سازی با تعویض محل:

// components/appointment/index.js
const changeLocation = (location) => {
  setSelectedLocation(location);
  setLocationConfirmed(true);
  setSelectedServiceUuids([]);
  setSelectedSlot(null);
  setSelectedDate(null);
};

اسلات‌ها امروز فقط دو حالت دارند:

// 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);

و روزهای غیرفعال تقویم فقط پزشک‌محورند:

// app/component/date/datePicker/index.js
request
  .getMonthAvailability(doctorUuid, year, month, clinicUuid)

payload ثبت نوبت هنوز resource_uuid ندارد:

// 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 (این اندپوینت‌ها عمومی‌اند):

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

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 این مرحله بعد از تأیید محل و قبل از انتخاب سرویس بنشیند:

} 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 یک شاخهٔ سوم:

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 رد شود: DateHourDateTime. همان زنجیره‌ای که clinicUuid امروز طی می‌کند.

در app/component/date/datePicker/index.js، وقتی resourceUuid داده شده، getResourceMonthAvailability(resourceUuid, year, month, selectedServiceUuids) جای getMonthAvailability بنشیند. کشِ loadedMonths باید با تغییر resourceUuid هم پاک شود — دقیقاً همان useEffectی که امروز برای clinicUuid هست:

useEffect(() => {
  loadedMonths.current = new Set();
  setDisabledSet(new Set());
  setOnlineEnabled(true);
  setAutoSelected(false);
}, [clinicUuid]);          // ← resourceUuid هم به آرایهٔ وابستگی اضافه شود

نحوه تست: یک منبع با شیفتِ فقط دوشنبه بساز. در تقویم سایت، همهٔ روزها جز دوشنبه‌ها باید غیرفعال باشند. روی یک دوشنبه بزن و ساعت‌ها را ببین.

۵. resource_uuid در payload ثبت نوبت

در components/appointment/detail/SubmitData.js:

// نوبتِ منبع‌محور: 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، این سه فراخوانی را دستی بررسی کن.