feat(booking): show only locations that can actually be booked that day

The site offered a "personal practice" for a doctor who has no personal address
at all — the schedule existed but its shifts pointed at the clinic's address, so
there was nowhere to go. The backend now filters those out; this consumes the
filtered contract and adds the per-day dimension.

- getBookingLocations takes an optional date and the appointment page refetches
  on it, merging available_on_date into the existing list rather than replacing
  it, so browsing the calendar never resets the user's choice.
- The browsed day had to be lifted out of the Date step: selectedDate is only
  set once a slot is confirmed, far too late to drive availability.
- A location closed on the chosen day renders disabled with «در این روز نوبت
  ندارد», and when every location is closed the step says so instead of showing
  an empty slot list. If the already-selected location closes, a notice appears
  with a link back to the picker — silently showing nothing was the failure mode
  worth avoiding.
- Doctor profile: workLocation in the Physician JSON-LD is limited to addresses
  that appear in booking_locations, since schema.org presents them as places a
  patient can attend. The address card still lists the others — they are real
  practice details — tagged «بدون نوبت‌دهی آنلاین».

Verified end-to-end with a temporary unused address on the test doctor: the
visible card listed both and tagged the unused one, while workLocation carried
only the bookable one. The row was removed afterwards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-18 14:44:49 +03:30
co-authored by Claude Opus 4.8
parent f4dd73e55e
commit b37096048c
11 changed files with 294 additions and 18 deletions
@@ -0,0 +1,176 @@
# نمایش محل‌های نوبت‌دهی فقط بر اساس برنامهٔ همان روز
## پروژه
`nobat724_front`
پرامپت همتای backend که **باید اول اجرا شود**:
`clinicpro/.claude/prompt/fix-booking-context-slots-and-phantom-locations.md`
## زمینه
صفحهٔ رزرو حالا محل نوبت‌دهی (مطب شخصی / کلینیک) را از
`GET /api/v1/appointment-booking-locations/{doctorUuid}` می‌گیرد و کاربر یکی را انتخاب می‌کند.
اما این فهرست **وضعیت واقعی رزرو در یک روز مشخص** را نشان نمی‌دهد. برای «دکتر تست»
(`bcabb3a8-cae3-45ec-876c-548f9c1e1569`) گزینهٔ «مطب شخصی» نمایش داده می‌شود، در حالی که در
دیتابیس این پزشک **هیچ آدرس شخصی ثبت‌شده‌ای ندارد** و شیفت برنامهٔ شخصی‌اش هم
`location_id = NULL` است. یعنی محلی که اصلاً قابل رزرو نیست، به بیمار پیشنهاد می‌شود.
## مشکل / هدف
قاعدهٔ درست نمایش یک محل:
1. برای آن محل حداقل یک **آدرس ثبت‌شده** وجود داشته باشد، **و**
2. در برنامهٔ کاری، همان آدرس برای شیفت‌های آن محل **انتخاب شده** باشد، **و**
3. برای **روز انتخاب‌شده** حداقل یک اسلات آزاد داشته باشد.
اگر هر کدام برقرار نباشد، آن محل نباید به‌عنوان گزینهٔ قابل انتخاب نمایش داده شود — نه در
`/appointment/[doctorId]` و نه در پروفایل پزشک `/doctor/[slug]`.
شرط‌های ۱ و ۲ در backend اعمال می‌شوند (پرامپت همتا). این پرامپت شرط ۳ و مصرف درست فهرست را
پوشش می‌دهد.
## قرارداد API بعد از تغییر backend
```
GET /api/v1/appointment-booking-locations/{doctorUuid}
GET /api/v1/appointment-booking-locations/{doctorUuid}?date=YYYY-MM-DD
```
- بدون `date`: فقط محل‌های **معتبر** (آدرس دارند و شیفت روی آدرس دارند). محل بدون آدرس دیگر
اصلاً برنمی‌گردد.
- با `date`: هر آیتم فیلد `available_on_date` (بولین) می‌گیرد و پاسخ `date` را echo می‌کند.
- `opening_hours` هر آیتم حالا `day_index` و `location_id` هم دارد.
بقیهٔ قرارداد بدون تغییر: مرتب بر اساس `next_available_at` صعودی، پاسخ double-nested
(`json.data.data`).
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `services/response.js` | `getBookingLocations` — باید `date` بگیرد |
| `components/appointment/index.js` | `AppointmentPage` — نگه‌دارندهٔ `bookingLocations` و `selectedLocation` |
| `components/appointment/Container.js` | مرحلهٔ انتخاب محل / سرویس / تاریخ |
| `components/appointment/location/LocationSelect.js` | کارت‌های انتخاب محل |
| `components/appointment/date/index.js` | مرحلهٔ تاریخ + دکمهٔ «تغییر محل» |
| `app/component/date/datePicker/index.js` | تقویم ماه (`getMonthAvailability`) |
| `app/doctor/[slug]/page.js` | پروفایل پزشک + JSON-LD |
| `components/doctor/detailDoctor/cards/locations/index.js` | کارت «موقعیت مکانی» در پروفایل |
## وضعیت فعلی
### فهرست محل‌ها وابسته به روز نیست
`components/appointment/index.js` — یک‌بار در mount گرفته می‌شود و تا آخر ثابت می‌ماند:
```js
useEffect(() => {
if (!doctor?.uuid) return;
request
.getBookingLocations(doctor.uuid)
.then((res) => {
const d = res?.data?.data ?? res?.data ?? {};
const list = Array.isArray(d.booking_locations) ? d.booking_locations : [];
setBookingLocations(list);
...
})
```
`selectedDate` بعداً در همین کامپوننت ست می‌شود ولی هیچ‌وقت به این فراخوانی برنمی‌گردد.
### پروفایل پزشک همهٔ آدرس‌ها را نشان می‌دهد
`app/doctor/[slug]/page.js``workLocation` در JSON-LD و کارت «موقعیت مکانی» از
`getDoctorAddresses(doctor.id)` می‌آیند که **همهٔ** آدرس‌ها را برمی‌گرداند، مستقل از اینکه در
برنامهٔ نوبت‌دهی استفاده شده باشند یا نه.
## وظایف
### ۱. `date` در لایهٔ سرویس
`services/response.js`:
```js
getBookingLocations: (doctor_uuid, date = null) =>
api.get(`api/v1/appointment-booking-locations/${doctor_uuid}`, {
params: date ? { date } : {},
...removeTokenHead,
}),
```
### ۲. واکشی دوباره با تغییر روز
در `components/appointment/index.js`:
- فهرست اولیه بدون `date` گرفته شود (برای مرحلهٔ انتخاب محل، قبل از انتخاب روز).
- بعد از انتخاب روز، دوباره با `date` گرفته شود و `available_on_date` روی کارت‌ها اعمال شود.
`selectedDate` در این کامپوننت **timestamp** است؛ برای پارامتر API باید به `YYYY-MM-DD` تبدیل شود
(از `moment-jalaali` که در پروژه هست استفاده کن، همان الگوی
`app/component/date/dateTime/index.js` که `moment.unix(date).format("YYYY-MM-DD")` می‌زند).
**حالت مرزی مهم:** اگر روزِ انتخاب‌شده محلِ انتخاب‌شده را غیرفعال کند
(`available_on_date === false`)، نباید بی‌صدا اسلات خالی نشان دهی. یا کاربر را به مرحلهٔ انتخاب
محل برگردان با پیام روشن، یا خودکار به اولین محلِ باز در آن روز سوییچ کن و این جابه‌جایی را
اطلاع بده. **بی‌صدا نگه‌داشتن محلِ بسته = صفحهٔ خالی بدون توضیح.**
### ۳. UI کارت‌های محل
`components/appointment/location/LocationSelect.js`:
- وقتی `available_on_date === false`، کارت غیرفعال شود (`disabled`، `cursor-not-allowed`،
کم‌رنگ) با برچسب «در این روز نوبت ندارد».
- کارت غیرفعال قابل کلیک نباشد.
- اگر **هیچ** محلی در آن روز باز نبود، پیام روشن بده و کاربر را به انتخاب روز دیگر هدایت کن.
از تم و کامپوننت‌های موجود استفاده کن (MUI v5 + Tailwind، RTL، فونت Vazir) — طراحی جدید نساز.
### ۴. تقویم ماه هم per-location است
`app/component/date/datePicker/index.js` الان `clinicUuid` می‌گیرد و `getMonthAvailability` را با
آن صدا می‌زند — این درست است و نیازی به تغییر ندارد. فقط مطمئن شو بعد از سوییچ محل، کش ماه‌ها
پاک می‌شود (همان effect موجود روی `clinicUuid`).
### ۵. پروفایل پزشک — فقط محل‌های قابل رزرو
در `app/doctor/[slug]/page.js`:
- `getBookingLocations` (بدون `date`) را server-side بگیر — همین الان برای `availableService` و
`openingHoursSpecification` گرفته می‌شود.
- کارت «موقعیت مکانی» (`components/doctor/detailDoctor/cards/locations/`) و `workLocation` در
JSON-LD باید **فقط** آدرس‌هایی را نشان دهند که `location_uuid` آن‌ها در `booking_locations`
آمده است.
```js
const bookableUuids = new Set(
bookingLocations.map((l) => l.location_uuid).filter(Boolean)
);
const bookableAddresses = addresses.filter((a) => bookableUuids.has(a.uuid));
```
- **تصمیم لازم:** آدرسی که ثبت شده ولی در هیچ برنامه‌ای استفاده نشده، اطلاعات واقعی مطب است و
حذف کاملش از پروفایل ممکن است خواسته نباشد. پیشنهاد: در کارت «موقعیت مکانی» نمایش داده شود
ولی بدون دکمهٔ «دریافت نوبت»، و در JSON-LD **نیاید** (چون schema.org آن را قابل مراجعه اعلام
می‌کند). اگر تصمیم دیگری گرفتی، در PR بنویس.
### ۶. لینک CTA
`components/doctor/appointmentList/index.js` — اگر پروفایل محل مشخصی را برجسته کرد، CTA همان را
حمل کند: `/appointment/${doctorSlug}?clinic_uuid=${clinicUuid}`. اگر محلی برجسته نشده، پارامتر
ندهد تا صفحهٔ رزرو خودش انتخابگر را نشان دهد (رفتار فعلی، درست است).
## نکات مهم
- **backend اول.** تا وقتی فیلتر سمت سرور اعمال نشده، «مطب شخصی» جعلی همچنان برمی‌گردد و کار
فرانت قابل تست نیست.
- پاسخ double-nested: `json?.data?.data ?? json?.data`.
- endpoint عمومی است و `removeTokenHead` می‌گیرد؛ برای رزرو نهایی `{ requireAuth: true }`.
- تاریخ‌ها شمسی با `jalali-moment` / `moment-jalaali`؛ رشته‌های جدید فارسی؛ RTL.
- slug پزشک = `uuid`.
- هر صفحه `generateMetadata` صادر کند و `params` همیشه `await` شود.
- تست دستی: «دکتر تست» (`bcabb3a8-cae3-45ec-876c-548f9c1e1569`) — بعد از فیلتر backend نباید هیچ
گزینهٔ «مطب شخصی» ببیند، فقط کلینیک «علی بهروزی». روزی که کلینیک شیفت ندارد هم باید محل را
غیرفعال نشان دهد.
- بعد از تغییرات: `npm run lint` و `npm run build` هر دو سبز.
+11 -2
View File
@@ -129,6 +129,14 @@ async function Doctor({ params }) {
? await Promise.all([getDoctorAddresses(doctor.id), getBookingLocations(doctor.uuid)])
: [[], []];
// آدرسی که در هیچ برنامهٔ نوبت‌دهی استفاده نشده، محل مراجعه نیست. در کارت
// «موقعیت مکانی» می‌ماند (اطلاعات واقعی مطب است) ولی به JSON-LD نمی‌رود، چون
// schema.org آن را محلی قابل‌مراجعه اعلام می‌کند.
const bookableAddressUuids = new Set(
bookingLocations.map((l) => l.location_uuid).filter(Boolean)
);
const bookableAddresses = addresses.filter((a) => bookableAddressUuids.has(a.uuid));
// ساعات کاری per-location است؛ با uuid آدرس به هر محل وصل می‌شود.
const openingHoursByLocation = bookingLocations.reduce((acc, location) => {
if (location.location_uuid && location.opening_hours?.length) {
@@ -191,8 +199,8 @@ async function Doctor({ params }) {
}),
})),
}),
...(addresses.length > 0 && {
workLocation: addresses.map((addr) => ({
...(bookableAddresses.length > 0 && {
workLocation: bookableAddresses.map((addr) => ({
"@type": "MedicalClinic",
name: addr.clinic_name || addr.name || `مطب دکتر ${doctor.name}`,
address: {
@@ -258,6 +266,7 @@ async function Doctor({ params }) {
comments={comments}
rateAggregate={rateAggregate}
addresses={addresses}
bookableAddressUuids={[...bookableAddressUuids]}
slug={slug}
/>
</Layout>
+4
View File
@@ -51,6 +51,8 @@ function Container({
locationConfirmed,
changeLocation,
reopenLocationChoice,
onDateChange,
selectedClosedOnDate,
}) {
const serviceMode = bookingMode === "service";
const clinicUuid = selectedLocation?.clinic_uuid ?? null;
@@ -92,6 +94,8 @@ function Container({
selectedLocation={selectedLocation}
clinicUuid={clinicUuid}
onChangeLocation={multiLocation ? reopenLocationChoice : null}
onDateChange={onDateChange}
closedOnDate={selectedClosedOnDate}
/>
);
}
+24 -1
View File
@@ -14,9 +14,16 @@ function Date({
selectedLocation = null,
clinicUuid = null,
onChangeLocation,
onDateChange,
closedOnDate = false,
}) {
const [date, setDate] = useState();
const pickDate = (value) => {
setDate(value);
onDateChange?.(value);
};
return (
<div className="w-full lg:w-[61%]">
<div
@@ -44,8 +51,24 @@ function Date({
)}
</div>
)}
{closedOnDate && (
<div className="mb-3 rounded-[8px] border border-solid border-[#F17732] bg-[rgba(241,119,50,0.08)] p-[12px]">
<p className="text-[13px] text-[#B4541F] leading-relaxed">
«{selectedLocation?.title}» در روز انتخابشده نوبتدهی ندارد.
{onChangeLocation && (
<button
type="button"
onClick={onChangeLocation}
className="mr-1 font-medium text-[#5559CE] hover:underline"
>
انتخاب محل دیگر
</button>
)}
</p>
</div>
)}
<Time
setDate={setDate}
setDate={pickDate}
isStep
disabledDates={disabledDates}
clinicUuid={clinicUuid}
+36
View File
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import moment from "moment-jalaali";
import { request } from "@/services/response";
import Cookies from "js-cookie";
import Container from "./Container";
@@ -69,6 +70,9 @@ function AppointmentPage({ doctor, disabledDates, matchedCity, initialClinicUuid
const [selectedLocation, setSelectedLocation] = useState(null);
const [locationConfirmed, setLocationConfirmed] = useState(false);
const [locationsLoading, setLocationsLoading] = useState(true);
// روزی که کاربر در تقویم مرور می‌کند (timestamp) — با selectedDate فرق دارد،
// که فقط پس از تأیید اسلات ست می‌شود.
const [browsingDate, setBrowsingDate] = useState(null);
useEffect(() => {
if (!doctor?.uuid) return;
@@ -90,6 +94,32 @@ function AppointmentPage({ doctor, disabledDates, matchedCity, initialClinicUuid
.finally(() => setLocationsLoading(false));
}, [doctor?.uuid, initialClinicUuid]);
// با تغییر روز، فقط پرچم available_on_date تازه می‌شود — انتخاب کاربر دست‌نخورده
// می‌ماند، وگرنه هر بار جابه‌جایی در تقویم مرحله را ریست می‌کرد.
useEffect(() => {
if (!doctor?.uuid || !browsingDate) return;
const date = moment.unix(browsingDate).format("YYYY-MM-DD");
request
.getBookingLocations(doctor.uuid, date)
.then((res) => {
const d = res?.data?.data ?? res?.data ?? {};
const byKey = new Map(
(Array.isArray(d.booking_locations) ? d.booking_locations : []).map((l) => [
l.clinic_uuid ?? "personal",
l.available_on_date,
])
);
setBookingLocations((prev) =>
prev.map((l) => ({
...l,
available_on_date: byKey.get(l.clinic_uuid ?? "personal") ?? false,
}))
);
})
.catch(() => {});
}, [doctor?.uuid, browsingDate]);
// روش نوبت‌دهی و سرویس‌ها per-location هستند: یک پزشک می‌تواند در مطب شخصی
// اسلاتی و در کلینیک سرویسی باشد.
const bookingMode = selectedLocation?.booking_mode === "service" ? "service" : "slot";
@@ -108,6 +138,10 @@ function AppointmentPage({ doctor, disabledDates, matchedCity, initialClinicUuid
// بازگشت به مرحلهٔ انتخاب محل
const reopenLocationChoice = () => setLocationConfirmed(false);
// محلِ انتخاب‌شده در روزِ در حال مرور بسته است. نباید بی‌صدا فهرست خالی نشان داد.
const selectedClosedOnDate =
Boolean(browsingDate) && selectedLocation?.available_on_date === false;
useEffect(() => {
const fetchUserData = async () => {
setIsLoading(true);
@@ -223,6 +257,8 @@ function AppointmentPage({ doctor, disabledDates, matchedCity, initialClinicUuid
locationConfirmed={locationConfirmed}
changeLocation={changeLocation}
reopenLocationChoice={reopenLocationChoice}
onDateChange={setBrowsingDate}
selectedClosedOnDate={selectedClosedOnDate}
/>
);
}
@@ -14,26 +14,39 @@ function nextAvailableLabel(timestamp) {
* درست است و اینجا دوباره مرتب نمی‌شود.
*/
function LocationSelect({ locations = [], selected, onSelect }) {
const anyOpen = locations.some((l) => l.available_on_date !== false);
return (
<div className="w-full max-w-[520px] mx-auto">
<h2 className="text-[16px] font-bold text-[#3B3B3B] mb-4">۱. انتخاب محل نوبتدهی</h2>
{!anyOpen && locations.length > 0 && (
<p className="mb-3 text-[13px] text-[#B4541F]">
در روز انتخابشده هیچکدام از محلها نوبتدهی ندارند. روز دیگری را انتخاب کنید.
</p>
)}
<div className="flex flex-col gap-2">
{locations.map((location) => {
const key = location.clinic_uuid ?? location.location_uuid ?? "personal";
const active =
(selected?.clinic_uuid ?? null) === (location.clinic_uuid ?? null);
const nextLabel = nextAvailableLabel(location.next_available_at);
// null یعنی روزی انتخاب نشده؛ فقط false به معنی «این روز بسته است».
const closed = location.available_on_date === false;
return (
<button
key={key}
type="button"
onClick={() => onSelect(location)}
disabled={closed}
onClick={() => !closed && onSelect(location)}
className={`w-full text-right p-[16px] rounded-[8px] border border-solid transition-colors ${
active
? "border-[#5559CE] bg-[rgba(85,89,206,0.06)]"
: "border-[#EFEFEF] bg-[#FFF] hover:border-[#C7C9EC]"
closed
? "border-[#EFEFEF] bg-[#FAFAFA] opacity-60 cursor-not-allowed"
: active
? "border-[#5559CE] bg-[rgba(85,89,206,0.06)]"
: "border-[#EFEFEF] bg-[#FFF] hover:border-[#C7C9EC]"
}`}
>
<div className="flex items-center justify-between gap-[8px]">
@@ -55,10 +68,12 @@ function LocationSelect({ locations = [], selected, onSelect }) {
<p
className={`mt-[8px] text-[12px] ${
nextLabel ? "text-[#009D79]" : "text-[#A1A1A1]"
closed ? "text-[#B4541F]" : nextLabel ? "text-[#009D79]" : "text-[#A1A1A1]"
}`}
>
{nextLabel ?? "فعلاً نوبت خالی ندارد"}
{closed
? "در این روز نوبت ندارد"
: nextLabel ?? "فعلاً نوبت خالی ندارد"}
</p>
</button>
);
@@ -11,7 +11,7 @@ import RoutingC from "@/components/icons/RoutingC";
// Leaflet به window نیاز دارد → فقط کلاینت
const MapView = dynamic(() => import("./MapView"), { ssr: false });
function Item({ data }) {
function Item({ data, bookable = true }) {
const [isOpen, setIsOpen] = useState(false);
const [open, setOpen] = useState(false);
@@ -29,7 +29,14 @@ function Item({ data }) {
onClick={() => setIsOpen(!isOpen)}
className="!px-[2px] !py-[4px] !flex !items-center !justify-between !w-full"
>
<p className="text-[#616161] text-[16px] font-medium">{data.name}</p>
<p className="text-[#616161] text-[16px] font-medium">
{data.name}
{!bookable && (
<span className="mr-2 text-[11px] font-normal text-[#A1A1A1]">
(بدون نوبتدهی آنلاین)
</span>
)}
</p>
<div
className={` transition-all duration-500 ${isOpen ? "rotate-180" : "rotate-0"} `}
>
@@ -1,8 +1,10 @@
import CustomLoading from "@/app/component/loading/Custom";
import Item from "./Item";
function Locations({ addresses }) {
function Locations({ addresses, bookableAddressUuids = [] }) {
if (!addresses?.length) return null;
const bookable = new Set(bookableAddressUuids);
return (
<div>
<p className="text-[#525252] text-[16px] md:text-[18px] lg:text-[20px] font-bold">
@@ -12,7 +14,7 @@ function Locations({ addresses }) {
{addresses.map((item, idx) => (
<CustomLoading key={idx} width="full" height={40}>
<li className="w-full">
<Item data={item} />
<Item data={item} bookable={bookable.has(item.uuid)} />
{addresses.length > idx + 1 && (
<span className="w-full h-px bg-[#EFEFEF] block my-[12px] md:my-[14px] lg:my-[16px]"></span>
)}
+2 -2
View File
@@ -5,7 +5,7 @@ import AboutDcotor from "./cards/AboutDcotor";
import Comments from "./cards/comments";
import Locations from "./cards/locations";
function DetailDoctor({ doctor, comments, rateAggregate, addresses }) {
function DetailDoctor({ doctor, comments, rateAggregate, addresses, bookableAddressUuids = [] }) {
return (
<div className="w-full lg:w-[59%]">
<div className="hidden lg:flex">
@@ -14,7 +14,7 @@ function DetailDoctor({ doctor, comments, rateAggregate, addresses }) {
<Title doctor={doctor} />
<Link />
<AboutDcotor doctor={doctor} />
<Locations addresses={addresses} />
<Locations addresses={addresses} bookableAddressUuids={bookableAddressUuids} />
<Comments
doctor={doctor}
comments={comments}
+2 -1
View File
@@ -5,7 +5,7 @@ import DetailDoctor from "./detailDoctor";
import Share from "./detailDoctor/Share";
import CustomLoading from "@/app/component/loading/Custom";
function DoctorPage({ doctor, comments, rateAggregate, addresses, slug }) {
function DoctorPage({ doctor, comments, rateAggregate, addresses, bookableAddressUuids = [], slug }) {
return (
<div className="pt-[92px] sm:pt-[120px] mt:pt-[148px] lg:pt-[176px] mt-[px] padding-responsive">
<div className="flex items-center justify-between">
@@ -39,6 +39,7 @@ function DoctorPage({ doctor, comments, rateAggregate, addresses, slug }) {
comments={comments}
rateAggregate={rateAggregate}
addresses={addresses}
bookableAddressUuids={bookableAddressUuids}
/>
<AppointmentList doctor={doctor} doctorSlug={slug} />
</div>
+5 -2
View File
@@ -57,8 +57,11 @@ export const request = {
api.patch(`api/v1/appointment-settings/weekly-schedule/${uuid}`),
deleteAppointmentWeeklySchedule: (uuid) =>
api.delete(`api/v1/appointment-settings/weekly-schedule/${uuid}`),
getBookingLocations: (doctor_uuid) =>
api.get(`api/v1/appointment-booking-locations/${doctor_uuid}`, removeTokenHead),
getBookingLocations: (doctor_uuid, date = null) =>
api.get(`api/v1/appointment-booking-locations/${doctor_uuid}`, {
params: date ? { date } : {},
...removeTokenHead,
}),
getAppointmentSlots: (doctor_uuid, date, clinic_uuid = null) =>
api.get(
`api/v1/appointment-slots?doctor_uuid=${doctor_uuid}&date=${date}` +