From 165a0a2240315d1a46369b84f0f89e35a0149d36 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Wed, 15 Jul 2026 23:48:40 +0330 Subject: [PATCH] feat(appointment): implement service-based booking flow with service selection and slot adaptation --- .../prompt/service-based-online-booking.md | 107 ++++++++++++++++++ app/component/date/dateTime/index.js | 60 ++++++---- components/appointment/Container.js | 35 ++++-- components/appointment/date/hour/index.js | 4 +- components/appointment/date/index.js | 22 +++- components/appointment/detail/SubmitData.js | 4 +- components/appointment/detail/index.js | 2 + components/appointment/index.js | 21 ++++ components/appointment/service/index.js | 89 +++++++++++++++ lib/appointmentSlots.js | 16 +++ services/response.js | 10 ++ 11 files changed, 337 insertions(+), 33 deletions(-) create mode 100644 .claude/prompt/service-based-online-booking.md create mode 100644 components/appointment/service/index.js diff --git a/.claude/prompt/service-based-online-booking.md b/.claude/prompt/service-based-online-booking.md new file mode 100644 index 0000000..24bcbfe --- /dev/null +++ b/.claude/prompt/service-based-online-booking.md @@ -0,0 +1,107 @@ +# نوبت‌دهی آنلاین بر اساس سرویس (Service-first booking) + +## پروژه + +`nobat724_front` (سایت عمومی نوبت‌دهی). +**Cross-repo:** وابسته به پرامپت backend `clinicpro/.claude/prompt/service-based-booking.md` — آن **اول** اجرا شود؛ این پرامپت endpoint و متای آن را مصرف می‌کند. + +## زمینه + +جریان فعلیِ نوبت‌گیری آنلاین یک wizard چندمرحله‌ای است بدون انتخاب سرویس: +`/appointment/[doctorId]` → روز → ساعت (اسلات ثابت) → لاگین/OTP → فرم بیمار → پرداخت. +اسلات‌ها از `GET /api/v1/appointment-slots?doctor_uuid&date` می‌آیند و همه هم‌اندازه‌اند. + +بک‌اند حالت جدید «نوبت‌دهی بر اساس سرویس» را اضافه می‌کند: مدت نوبت = مجموع مدت سرویس‌های انتخابی، و endpoint جدید زمان‌های خالیِ کافی را برمی‌گرداند. سایت باید در این حالت **اول سرویس** را از بیمار بگیرد، سپس فقط زمان‌های خالیِ کافی را نشان دهد. + +## هدف / spec انگلیسی + +When a doctor uses `booking_mode = service`, insert a service-selection step **before** the date/time step. The patient picks one or more services; the site fetches candidate start times sized to the summed service duration from the new backend endpoint, shows only those, locks the chosen time via `postAppointment` (with the service items), then continues to the existing login/pay flow. When the doctor is in `slot` mode, the current flow is unchanged. «نوبت آزاد» (reserve) is secretary-only and never shown online. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `services/response.js` (L52 `getAppointmentSlots`, L57 `getMonthAvailability`, L62 `postAppointment`, L76 `getDoctorServices`) | افزودن `getServiceSlots` + رساندن `service_item_uuids` به postAppointment | +| `components/appointment/index.js` (L45 `AppointmentPage`, state L59-62) | افزودن state سرویس‌های انتخابی + مدت | +| `components/appointment/Container.js` (L42 `elements[]` step router) | افزودن مرحلهٔ انتخاب سرویس در ابتدای wizard (حالت سرویس) | +| `app/component/date/dateTime/index.js` (L29-52 fetch اسلات) | در حالت سرویس، فراخوانی endpoint سرویس با مدت مجموع | +| `lib/appointmentSlots.js` (L1 `adaptSlots`, L17 `hasAvailable`) | adapter برای پاسخ `start_times` | +| `components/appointment/detail/SubmitData.js` (L138-165 payload) | افزودن `service_item_uuids` به appointmentPayload | +| `app/appointment/[doctorId]/page.js` (L9, doctor fetch L17) | رساندن `booking_mode` و لیست سرویس‌ها به AppointmentPage | + +## وضعیت فعلی (کد واقعی) + +### fetch اسلات ثابت +```js +// app/component/date/dateTime/index.js:29 +request.getAppointmentSlots(doctor.uuid, dateStr).then((res) => { + const parsed = adaptSlots(res); // data.sessions[].slots + ... +}); +``` +```js +// services/response.js:52 +getAppointmentSlots: (doctor_uuid, date) => + api.get(`api/v1/appointment-slots?doctor_uuid=${doctor_uuid}&date=${date}`), +``` + +### payload رزرو — بدون سرویس +```js +// components/appointment/detail/SubmitData.js:138 +const appointmentPayload = { + doctor_uuid, slot_start: selectedSlot.start, slot_end: selectedSlot.end, + for_self, patient_national_code, patient_gender, city_id, +}; // هیچ service_id ندارد +request.postAppointment(appointmentPayload); // POST api/v1/appointment +``` + +### step router +```js +// components/appointment/Container.js:42 +const elements = [Date, Login, Verify, Detail, Paying, SuccessPay, FailedPay]; +// step: 0 روز/ساعت, 1 لاگین, 2 OTP, 3 فرم, 4 پرداخت ... +``` + +## وظایف + +### ۱. سرویس در response.js + +```js +// services/response.js +getServiceSlots: (doctor_uuid, date, serviceItemUuids) => { + const q = serviceItemUuids.map(u => `service_item_uuids[]=${encodeURIComponent(u)}`).join('&'); + return api.get(`api/v1/appointment-service-slots?doctor_uuid=${doctor_uuid}&date=${date}&${q}`); +}, +``` +و اجازهٔ `service_item_uuids` در `postAppointment` (فقط pass-through payload). +منبع لیست سرویس‌های پزشک: `getDoctorServices` (L76, `api/v1/categorys/doctor_services`) یا endpoint سرویس‌های واقعیِ کلینیک اگر پزشک ServiceItem دارد — بررسی کن کدام سرویس‌ها `duration_minutes` دارند (فقط همان‌ها قابل‌انتخاب برای این جریان‌اند). + +### ۲. تشخیص حالت پزشک + +از پاسخ doctor یا از `getMonthAvailability` مقدار `booking_mode` را بخوان (backend آن را در متای weekly-schedule دارد؛ اگر در پاسخ doctor نبود، از یک فیلد مناسب که backend اضافه می‌کند). در `AppointmentPage`: +- `booking_mode === 'service'` → مرحلهٔ انتخاب سرویس فعال شود. +- در غیر این صورت → **دقیقاً** جریان فعلی (هیچ تغییری). + +### ۳. مرحلهٔ انتخاب سرویس (فقط حالت سرویس) + +- کامپوننت جدید `components/appointment/service/index.js`: لیست سرویس‌های پزشک با مدت و قیمت؛ انتخاب یک/چند سرویس؛ نمایش «مدت کل» = Σ `duration_minutes`. +- در `Container.js` این مرحله را **قبل** از انتخاب روز قرار بده (حالت سرویس) و state سرویس‌ها را در `AppointmentPage` نگه‌دار. + +### ۴. نمایش فقط زمان‌های کافی + +- در `app/component/date/dateTime/index.js`: اگر حالت سرویس است، به‌جای `getAppointmentSlots` از `getServiceSlots(uuid, dateStr, selectedServiceUuids)` استفاده کن. +- `lib/appointmentSlots.js`: یک adapter برای پاسخ `data.start_times` (آرایهٔ `{start,end,start_time,location_id}`) اضافه کن که همان ساختار مورد انتظارِ لیست ساعت را بسازد (هر مورد یک دکمهٔ زمان). چون همه از قبل «کافی» هستند، `is_available=true`. +- اگر `start_times` خالی بود: پیام «برای این سرویس در این روز زمان خالی کافی نیست» + هدایت به روز بعدِ دارای ظرفیت (از `getMonthAvailability` برای فعال/غیرفعال بودن روزها استفاده کن). + +### ۵. قفل زمان هنگام ثبت + +- در `SubmitData.js` به `appointmentPayload` کلید `service_item_uuids: [...]` اضافه کن (حالت سرویس). `slot_start` از انتخاب می‌آید؛ `slot_end` را backend از مدت سرویس محاسبه می‌کند (به مقدار کلاینت اعتماد نمی‌شود) ولی همان `selectedSlot.end` را هم بفرست تا سازگاری حفظ شود. +- منطق قفل دو-مرحله‌ای موجود (`postAppointment` → `expires_at` → شمارش معکوس در `paying/index.js` → redirect به `payment/order`) دست‌نخورده بماند؛ فقط payload سرویس اضافه می‌شود. مدیریت خطای 409 (reset به step 0، `SubmitData.js:178`) همان بماند. + +## نکات مهم + +- **«نوبت آزاد» آنلاین نیست.** هیچ مسیری برای `is_reserve` در سایت اضافه نکن؛ فقط منشی در پنل. (در سایت «رزرو» فقط برچسب بازاریابی/عنوان است، نه مدل داده — `Container.js:102`, `payment/[uuid]/page.js:206`.) +- **حالت اسلاتی دست‌نخورده:** وقتی `booking_mode !== 'service'`، هیچ کامپوننت/فراخوانیِ فعلی نباید تغییر رفتار بدهد. مرحلهٔ سرویس فقط شرطی render شود. +- **فقط سرویس‌های دارای مدت:** سرویسی که `duration_minutes` ندارد نباید در این جریان قابل‌انتخاب باشد (backend هم ۴۲۲ می‌دهد) — در UI غیرفعال/مخفی کن. +- **الگوهای پروژه:** App Router، `await params`؛ فراخوانی API از `services/response.js` (interceptor پاسخ را در `services/api.js:73` به `response.data` تبدیل می‌کند)؛ تاریخ Jalali با `moment`/`jalali-moment`؛ RTL، فونت Vazir، MUI v5 + Tailwind؛ رشته‌های UI فارسی. slug پزشک = `uuid`. +- **تست:** به `nobat724-test-suite` پروژه اضافه کن — رندر مرحلهٔ سرویس در حالت سرویس، عدم‌رندر در حالت اسلاتی، محاسبهٔ مدت کل، adapter `start_times`، افزودن `service_item_uuids` به payload. diff --git a/app/component/date/dateTime/index.js b/app/component/date/dateTime/index.js index b04375e..0fb6c7d 100644 --- a/app/component/date/dateTime/index.js +++ b/app/component/date/dateTime/index.js @@ -6,9 +6,17 @@ import Hours from "./hours"; import Tabs from "../../Tabs"; import { request } from "@/services/response"; import SendAppo from "./SendAppo"; -import { adaptSlots, hasAvailable } from "@/lib/appointmentSlots"; +import { adaptSlots, adaptServiceSlots, hasAvailable } from "@/lib/appointmentSlots"; -function DateTime({ date, doctor, setStep, setSelectedSlot, setSelectedDate }) { +function DateTime({ + date, + doctor, + setStep, + setSelectedSlot, + setSelectedDate, + serviceMode = false, + selectedServiceUuids = [], +}) { const [value, setValue] = useState(0); const [hour, setHour] = useState(); const [appo, setAppo] = useState("تاریخ مورد نظرتان را انتخاب کنید."); @@ -27,29 +35,35 @@ function DateTime({ date, doctor, setStep, setSelectedSlot, setSelectedDate }) { : null; useEffect(() => { - if (date && doctor?.uuid) { - setAppo("loading"); - setHour(); - const dateStr = moment.unix(date).format("YYYY-MM-DD"); - request - .getAppointmentSlots(doctor.uuid, dateStr) - .then((res) => { - const parsed = adaptSlots(res); - if (!parsed.length) { - setAppo("در حال حاضر، نوبتی برای این روز موجود نمی‌باشد."); - setValue(0); - return; - } - setAppo(parsed); - const firstAvailable = parsed.findIndex((s) => hasAvailable(s.slots)); - setValue(firstAvailable >= 0 ? firstAvailable : 0); - }) - .catch(() => { + if (!date || !doctor?.uuid) return; + if (serviceMode && selectedServiceUuids.length === 0) return; + + setAppo("loading"); + setHour(); + const dateStr = moment.unix(date).format("YYYY-MM-DD"); + + const req = serviceMode + ? request + .getServiceSlots(doctor.uuid, dateStr, selectedServiceUuids) + .then(adaptServiceSlots) + : request.getAppointmentSlots(doctor.uuid, dateStr).then(adaptSlots); + + req + .then((parsed) => { + if (!parsed.length) { setAppo("در حال حاضر، نوبتی برای این روز موجود نمی‌باشد."); setValue(0); - }); - } - }, [date, doctor?.uuid]); + return; + } + setAppo(parsed); + const firstAvailable = parsed.findIndex((s) => hasAvailable(s.slots)); + setValue(firstAvailable >= 0 ? firstAvailable : 0); + }) + .catch(() => { + setAppo("در حال حاضر، نوبتی برای این روز موجود نمی‌باشد."); + setValue(0); + }); + }, [date, doctor?.uuid, serviceMode, selectedServiceUuids]); return (
diff --git a/components/appointment/Container.js b/components/appointment/Container.js index 987b368..a437b42 100644 --- a/components/appointment/Container.js +++ b/components/appointment/Container.js @@ -1,4 +1,5 @@ import Date from "./date"; +import ServiceSelect from "./service"; // Components import Content from "./Content"; @@ -38,15 +39,34 @@ function Container({ setAppointmentId, appointmentExpiresAt, setAppointmentExpiresAt, + // Service-based booking + bookingMode, + bookingServices, + selectedServiceUuids, + setSelectedServiceUuids, }) { + const serviceMode = bookingMode === "service"; + const dateStep = + serviceMode && selectedServiceUuids.length === 0 ? ( + setSelectedServiceUuids(uuids)} + /> + ) : ( + setSelectedServiceUuids([])} + /> + ); + const elements = [ - , + dateStep, ,

@@ -13,6 +13,8 @@ function Hour({ doctor, setStep, date, locateVisit, isStep, setSelectedSlot, set locateVisit={locateVisit} setSelectedSlot={setSelectedSlot} setSelectedDate={setSelectedDate} + serviceMode={serviceMode} + selectedServiceUuids={selectedServiceUuids} /> {!isStep && (

diff --git a/components/appointment/date/index.js b/components/appointment/date/index.js index 1ea0824..8a9b1c0 100644 --- a/components/appointment/date/index.js +++ b/components/appointment/date/index.js @@ -2,7 +2,16 @@ import { useState } from "react"; import Hour from "./hour"; import Time from "./Time"; -function Date({ doctor, setStep, disabledDates, setSelectedSlot, setSelectedDate }) { +function Date({ + doctor, + setStep, + disabledDates, + setSelectedSlot, + setSelectedDate, + serviceMode = false, + selectedServiceUuids = [], + onChangeService, +}) { const [locateVisit, setLocateVisit] = useState(true); const [date, setDate] = useState(); @@ -11,6 +20,15 @@ function Date({ doctor, setStep, disabledDates, setSelectedSlot, setSelectedDate
+ {serviceMode && onChangeService && ( + + )}
diff --git a/components/appointment/detail/SubmitData.js b/components/appointment/detail/SubmitData.js index 1933e06..5a6532f 100644 --- a/components/appointment/detail/SubmitData.js +++ b/components/appointment/detail/SubmitData.js @@ -7,7 +7,7 @@ import Cookies from "js-cookie"; import { setAccessToken } from "@/lib/tokenStore"; import { useProvince } from "@/context/ProvinceProvider"; -function SubmitData({ setStep, data, prevData, setErrors, doctor, isForAnother, selectedSlot, selectedDate, setAppointmentId, setAppointmentExpiresAt }) { +function SubmitData({ setStep, data, prevData, setErrors, doctor, isForAnother, selectedSlot, selectedDate, setAppointmentId, setAppointmentExpiresAt, selectedServiceUuids = [] }) { const [loading, setLoading] = useState(false); const { cityId } = useProvince(); const newStep = () => setStep((prev) => prev + 1); @@ -146,6 +146,8 @@ function SubmitData({ setStep, data, prevData, setErrors, doctor, isForAnother, patient_gender: data?.gender?.value?.id || data?.gender?.value || "", // شناسه‌ی شهرِ دامنه‌ی جاری؛ backend برای گاردِ پورسانت نماینده استفاده می‌کند. city_id: cityId ?? null, + // حالت نوبت‌دهی سرویسی: backend مدت و slot_end را از این سرویس‌ها بازمحاسبه می‌کند. + ...(selectedServiceUuids?.length ? { service_item_uuids: selectedServiceUuids } : {}), ...(isForAnother ? { patient_name: [data?.name?.value, data?.family?.value].filter(Boolean).join(" ").trim(), diff --git a/components/appointment/detail/index.js b/components/appointment/detail/index.js index 7b892bf..9c99554 100644 --- a/components/appointment/detail/index.js +++ b/components/appointment/detail/index.js @@ -20,6 +20,7 @@ function Detail({ selectedDate, setAppointmentId, setAppointmentExpiresAt, + selectedServiceUuids = [], }) { const [insurance, setInsurance] = useState(); const [supplementaryInsurance, setSupplementaryInsurance] = useState(); @@ -120,6 +121,7 @@ function Detail({ selectedDate={selectedDate} setAppointmentId={setAppointmentId} setAppointmentExpiresAt={setAppointmentExpiresAt} + selectedServiceUuids={selectedServiceUuids} /> diff --git a/components/appointment/index.js b/components/appointment/index.js index e88e839..f5bc846 100644 --- a/components/appointment/index.js +++ b/components/appointment/index.js @@ -61,6 +61,23 @@ function AppointmentPage({ doctor, disabledDates, matchedCity }) { const [appointmentId, setAppointmentId] = useState(null); const [appointmentExpiresAt, setAppointmentExpiresAt] = useState(null); + // Service-based booking (روش نوبت‌دهی سرویسی) + const [bookingMode, setBookingMode] = useState("slot"); + const [bookingServices, setBookingServices] = useState([]); + const [selectedServiceUuids, setSelectedServiceUuids] = useState([]); + + useEffect(() => { + if (!doctor?.uuid) return; + request + .getBookingServices(doctor.uuid) + .then((res) => { + const d = res?.data?.data ?? res?.data ?? {}; + setBookingMode(d.booking_mode === "service" ? "service" : "slot"); + setBookingServices(Array.isArray(d.services) ? d.services : []); + }) + .catch(() => setBookingMode("slot")); + }, [doctor?.uuid]); + useEffect(() => { const fetchUserData = async () => { setIsLoading(true); @@ -167,6 +184,10 @@ function AppointmentPage({ doctor, disabledDates, matchedCity }) { setAppointmentId={setAppointmentId} appointmentExpiresAt={appointmentExpiresAt} setAppointmentExpiresAt={setAppointmentExpiresAt} + bookingMode={bookingMode} + bookingServices={bookingServices} + selectedServiceUuids={selectedServiceUuids} + setSelectedServiceUuids={setSelectedServiceUuids} /> ); } diff --git a/components/appointment/service/index.js b/components/appointment/service/index.js new file mode 100644 index 0000000..9728ebb --- /dev/null +++ b/components/appointment/service/index.js @@ -0,0 +1,89 @@ +"use client"; + +import { useState } from "react"; + +function toToman(rials) { + if (!rials) return null; + return Math.round(rials / 10).toLocaleString("fa-IR"); +} + +// مرحلهٔ انتخاب سرویس (فقط حالت نوبت‌دهی سرویسی) — پیش از انتخاب روز. +function ServiceSelect({ services = [], onContinue }) { + const [draft, setDraft] = useState([]); + + const toggle = (uuid) => + setDraft((prev) => + prev.includes(uuid) ? prev.filter((u) => u !== uuid) : [...prev, uuid] + ); + + const totalMinutes = services + .filter((s) => draft.includes(s.uuid)) + .reduce((sum, s) => sum + (Number(s.duration_minutes) || 0), 0); + + return ( +
+

۱. انتخاب سرویس

+ + {services.length === 0 ? ( +

+ در حال حاضر سرویسی برای نوبت‌دهی آنلاین تعریف نشده است. +

+ ) : ( +
+ {services.map((s) => { + const active = draft.includes(s.uuid); + const toman = toToman(s.price_rials); + return ( + + ); + })} +
+ )} + + {draft.length > 0 && ( +
+ مدت کل: {totalMinutes} دقیقه +
+ )} + + +
+ ); +} + +export default ServiceSelect; diff --git a/lib/appointmentSlots.js b/lib/appointmentSlots.js index a7b7fb8..87b4b95 100644 --- a/lib/appointmentSlots.js +++ b/lib/appointmentSlots.js @@ -14,6 +14,22 @@ export function adaptSlots(slotsResponse) { })); } +// حالت سرویسی: پاسخ appointment-service-slots فقط start_times دارد (همه کافی). +// آن‌ها را در یک session قالب‌بندی می‌کنیم تا مثل حالت اسلاتی رندر شوند. +export function adaptServiceSlots(slotsResponse) { + const starts = + slotsResponse?.data?.start_times ?? slotsResponse?.start_times ?? []; + if (!starts.length) return []; + return [ + { + start_time: starts[0].start_time, + end_time: starts[starts.length - 1].start_time, + label: "زمان‌های خالی", + slots: starts.map((s) => ({ ...s, is_available: true })), + }, + ]; +} + export function hasAvailable(slots) { return Array.isArray(slots) && slots.some((slot) => slot.is_available); } diff --git a/services/response.js b/services/response.js index 4bc9790..6cca86d 100644 --- a/services/response.js +++ b/services/response.js @@ -59,6 +59,16 @@ export const request = { 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 }), getMyAppointments: (params) => api.get(`api/v1/appointments/user`, { params, requireAuth: true }), getPaymentConfig: () => api.get(`api/v1/payment/config`, { requireAuth: true }),