fix(doctor): derive booking state from booking_locations
The profile decided "نوبتدهی غیرفعال است" from `doctor.active` alone, while the page already had `booking_locations` — the more precise source, since the backend only returns locations that are genuinely bookable. The two could disagree, and for a doctor bookable only at a clinic they did. A shared `bookingState` helper now drives both the desktop card and the mobile bar: any location means bookable, the label comes from the earliest `next_available_at`, and locations with no capacity yet read as "فعلاً نوبت خالی ندارد" rather than disabled. With no locations at all it falls back to the previous `doctor.active` / `free_turn` fields. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
# وضعیت «نوبتدهی فعال/غیرفعال» پروفایل پزشک از booking_locations
|
||||
|
||||
## پروژه
|
||||
|
||||
`nobat724_front`
|
||||
|
||||
پرامپت همتای backend که **باید اول اجرا شود**:
|
||||
`clinicpro/.claude/prompt/fix-public-booking-state-and-admin-context-slots.md`
|
||||
|
||||
## زمینه
|
||||
|
||||
برای «دکتر تست» (`bcabb3a8-cae3-45ec-876c-548f9c1e1569`) صفحهٔ
|
||||
`/doctor/bcabb3a8-…` پیام «نوبتدهی غیرفعال است» نشان میدهد در حالی که نوبتدهی
|
||||
کلینیکش فعال است و همین صفحه، `booking_locations` معتبر (با `next_available_at` غیرتهی)
|
||||
را server-side میگیرد.
|
||||
|
||||
علت (با curl تأیید شد): فیلدهای `active` / `free_turn` پاسخ `GET /api/v1/doctor/{uuid}`
|
||||
در backend فقط از **برنامهٔ شخصی** پزشک ساخته میشدند؛ برنامهٔ شخصیِ دکتر تست غیرفعال
|
||||
است و برنامهٔ کلینیکش دیده نمیشد. پرامپت همتا این را با تجمیع همهٔ برنامهها اصلاح میکند.
|
||||
|
||||
این پرامپت سمت سایت را defensive میکند: بنر و دکمهٔ نوبتدهی پروفایل نباید فقط به
|
||||
`doctor.active` تکیه کند وقتی خودِ صفحه دادهٔ دقیقتر (`booking_locations`) را در دست دارد.
|
||||
دو منبع نباید بتوانند حرف متناقض بزنند.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
قاعدهٔ واحد برای پروفایل پزشک:
|
||||
|
||||
- **نوبتدهی فعال** ⇔ `booking_locations` غیرخالی است (backend فقط محلهای واقعاً
|
||||
قابلرزرو را برمیگرداند: آدرسدار + شیفت فعال روی همان آدرس).
|
||||
- برچسب «اولین نوبت آزاد» از کمینهٔ `next_available_at` بین محلها؛ اگر همه `null` بودند،
|
||||
محلها فعالند ولی فعلاً ظرفیت ندارند → «فعلاً نوبت خالی ندارد» (نه «غیرفعال»).
|
||||
- `doctor.active === false` همراه با `booking_locations` خالی → «نوبتدهی غیرفعال است»
|
||||
(رفتار فعلی، درست).
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `app/doctor/[slug]/page.js:43,128-150` | `getBookingLocations` server-side — از قبل موجود |
|
||||
| `components/doctor/appointmentList/index.js:8,20-24` | نوار موبایل — `doctor?.active === false \|\| !doctor?.free_turn` |
|
||||
| `components/doctor/appointmentList/ItemAppointment.js:10,42-56` | کارت دسکتاپ — `bookingDisabled = turn?.active === false` |
|
||||
| `app/component/ItemDoctor.js:89` | کارت لیست پزشکان — فقط فیلد backend (بدون تغییر) |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
### کارت دسکتاپ — `components/doctor/appointmentList/ItemAppointment.js:10`
|
||||
|
||||
```js
|
||||
function ItemAppointment({ turn, loading, doctorSlug }) {
|
||||
const bookingDisabled = !loading && turn?.active === false;
|
||||
...
|
||||
{bookingDisabled || !turn?.free_turn
|
||||
? "نوبتدهی غیرفعال است"
|
||||
: `اولین نوبت آزاد: ${turn.free_turn}`}
|
||||
```
|
||||
|
||||
### نوار موبایل — `components/doctor/appointmentList/index.js:20-24`
|
||||
|
||||
```js
|
||||
{doctor?.active === false || !doctor?.free_turn
|
||||
? "نوبتدهی غیرفعال است"
|
||||
: `اولین نوبت آزاد: ${doctor.free_turn}`}
|
||||
```
|
||||
|
||||
`AppointmentList` از `app/doctor/[slug]/page.js` رندر میشود که `bookingLocations` را
|
||||
همانجا دارد (`:128-129`) ولی به این کامپوننت پاس نمیدهد.
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. پاسدادن `bookingLocations` به AppointmentList
|
||||
|
||||
در `app/doctor/[slug]/page.js` همان آرایهٔ گرفتهشده را prop بده:
|
||||
|
||||
```jsx
|
||||
<AppointmentList doctor={doctor} doctorSlug={slug} bookingLocations={bookingLocations} />
|
||||
```
|
||||
|
||||
(نام prop و محل رندر را با کد واقعی صفحه تطبیق بده — `AppointmentList` ممکن است از طریق
|
||||
کامپوننت میانی رندر شود؛ در آن صورت prop را از همان مسیر عبور بده.)
|
||||
|
||||
### ۲. قاعدهٔ واحد فعال/غیرفعال
|
||||
|
||||
یک helper کوچک در همان `components/doctor/appointmentList/` (نه util سراسری جدید):
|
||||
|
||||
```js
|
||||
export function bookingState(doctor, bookingLocations) {
|
||||
const hasLocations = Array.isArray(bookingLocations) && bookingLocations.length > 0;
|
||||
if (!hasLocations && doctor?.active === false) return { enabled: false, label: "نوبتدهی غیرفعال است" };
|
||||
if (!hasLocations) return { enabled: doctor?.active !== false, label: doctor?.free_turn ? `اولین نوبت آزاد: ${doctor.free_turn}` : "نوبتدهی غیرفعال است" };
|
||||
const earliest = bookingLocations
|
||||
.map((l) => l.next_available_at)
|
||||
.filter((t) => t != null)
|
||||
.sort((a, b) => a - b)[0] ?? null;
|
||||
return {
|
||||
enabled: true,
|
||||
label: earliest != null
|
||||
? `اولین نوبت آزاد: ${formatJalali(earliest)}`
|
||||
: "فعلاً نوبت خالی ندارد",
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- `formatJalali` با `jalali-moment`/`moment-jalaali` موجود پروژه: روزِ هفته + ساعت
|
||||
(مثل «شنبه ۰۹:۰۰») تا با فرمت `free_turn` backend همخانواده باشد. الگوی
|
||||
`moment.unix(ts)` مثل `app/component/date/dateTime/index.js`.
|
||||
- هر دو کامپوننت (`index.js` نوار موبایل و `ItemAppointment.js`) از همین helper استفاده
|
||||
کنند؛ شرطهای تکراری فعلی حذف شوند.
|
||||
- دکمهٔ «دریافت نوبت» با `enabled === true` فعال است حتی وقتی `earliest === null`
|
||||
(صفحهٔ رزرو خودش روزهای بدون ظرفیت را نشان میدهد).
|
||||
|
||||
### ۳. کارت لیست پزشکان — بدون تغییر
|
||||
|
||||
`app/component/ItemDoctor.js:89` فقط `free_turn`/`active` پاسخ لیست را دارد و
|
||||
`booking_locations` برای هر آیتم لیست fetch نمیشود (N درخواست اضافه ممنوع). بعد از فیکس
|
||||
backend همین فیلدها درست میشوند — این فایل را دست نزن.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **backend اول.** قبل از اجرای پرامپت همتا، `doctor.active` برای دکتر تست همچنان false
|
||||
برمیگردد؛ helper این را میپوشاند ولی تست کامل فقط بعد از هر دو ممکن است.
|
||||
- پاسخهای API double-nested: `json?.data?.data ?? json?.data` — `getBookingLocations`
|
||||
موجود در صفحه همین را رعایت میکند؛ عوضش نکن.
|
||||
- `booking_locations` فقط محلهای معتبر را دارد (فیلتر backend از پرامپتهای قبلی) —
|
||||
دوباره فیلتر نکن.
|
||||
- رشتههای جدید فارسی، RTL، تم موجود (MUI v5 + Tailwind، فونت Vazir) — طراحی جدید نساز.
|
||||
- slug پزشک = `uuid`؛ `params` همیشه `await`؛ صفحه `generateMetadata` دارد — دست نزن.
|
||||
- تست دستی: `/doctor/bcabb3a8-cae3-45ec-876c-548f9c1e1569` باید «اولین نوبت آزاد» و دکمهٔ
|
||||
فعال «دریافت نوبت» نشان دهد (دکتر تست فقط برنامهٔ کلینیکی فعال دارد). یک پزشک واقعاً
|
||||
غیرفعال (بدون هیچ محل) همچنان «نوبتدهی غیرفعال است» ببیند.
|
||||
- بعد از تغییرات: `npm run lint` و `npm run build` هر دو سبز.
|
||||
@@ -267,6 +267,7 @@ async function Doctor({ params }) {
|
||||
rateAggregate={rateAggregate}
|
||||
addresses={addresses}
|
||||
bookableAddressUuids={[...bookableAddressUuids]}
|
||||
bookingLocations={bookingLocations}
|
||||
slug={slug}
|
||||
/>
|
||||
</Layout>
|
||||
|
||||
@@ -5,9 +5,11 @@ import GreenCalendarTurn from "@/components/icons/GreenCalendarTurn";
|
||||
import { Button } from "@mui/material";
|
||||
import Link from "next/link";
|
||||
import DoctorAvatar from "@/app/component/DoctorAvatar";
|
||||
import { bookingState } from "./bookingState";
|
||||
|
||||
function ItemAppointment({ turn, loading, doctorSlug }) {
|
||||
const bookingDisabled = !loading && turn?.active === false;
|
||||
function ItemAppointment({ turn, loading, doctorSlug, booking }) {
|
||||
const state = booking ?? bookingState(turn, []);
|
||||
const bookingDisabled = !loading && !state.enabled;
|
||||
return (
|
||||
<li
|
||||
key={turn.id}
|
||||
@@ -39,9 +41,7 @@ function ItemAppointment({ turn, loading, doctorSlug }) {
|
||||
<GreenCalendarTurn />
|
||||
<TextLoading width={100} height={15} loading={loading}>
|
||||
<p className="text-[#05BA58] text-[12px] font-medium">
|
||||
{bookingDisabled || !turn?.free_turn
|
||||
? "نوبتدهی غیرفعال است"
|
||||
: `اولین نوبت آزاد: ${turn.free_turn}`}
|
||||
{state.label}
|
||||
</p>
|
||||
</TextLoading>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import moment from "moment-jalaali";
|
||||
|
||||
const DAY_NAMES = [
|
||||
"یکشنبه",
|
||||
"دوشنبه",
|
||||
"سهشنبه",
|
||||
"چهارشنبه",
|
||||
"پنجشنبه",
|
||||
"جمعه",
|
||||
"شنبه",
|
||||
];
|
||||
|
||||
const formatJalali = (ts) => {
|
||||
const m = moment.unix(ts);
|
||||
return `${DAY_NAMES[m.day()]} ${m.format("HH:mm")}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* قاعدهٔ واحد فعال/غیرفعال بودن نوبتدهی در پروفایل پزشک.
|
||||
*
|
||||
* booking_locations دقیقتر از doctor.active است: backend فقط محلهای واقعاً
|
||||
* قابلرزرو را برمیگرداند. تا وقتی حداقل یک محل هست، نوبتدهی فعال است — حتی
|
||||
* اگر فیلد قدیمی active به هر دلیل false باشد. بدون محل، همان فیلدهای doctor
|
||||
* ملاک میمانند (سازگاری با پزشک بدون برنامه).
|
||||
*/
|
||||
export function bookingState(doctor, bookingLocations) {
|
||||
const hasLocations =
|
||||
Array.isArray(bookingLocations) && bookingLocations.length > 0;
|
||||
|
||||
if (!hasLocations) {
|
||||
const enabled = doctor?.active !== false && !!doctor?.free_turn;
|
||||
return {
|
||||
enabled,
|
||||
label: enabled
|
||||
? `اولین نوبت آزاد: ${doctor.free_turn}`
|
||||
: "نوبتدهی غیرفعال است",
|
||||
};
|
||||
}
|
||||
|
||||
const earliest =
|
||||
bookingLocations
|
||||
.map((l) => l.next_available_at)
|
||||
.filter((t) => t != null)
|
||||
.sort((a, b) => a - b)[0] ?? null;
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
label:
|
||||
earliest != null
|
||||
? `اولین نوبت آزاد: ${formatJalali(earliest)}`
|
||||
: "فعلاً نوبت خالی ندارد",
|
||||
};
|
||||
}
|
||||
@@ -4,24 +4,30 @@ import { Button } from "@mui/material";
|
||||
import ItemAppointment from "./ItemAppointment";
|
||||
import ArrowLeftD from "@/components/icons/ArrowLeftD";
|
||||
import Link from "next/link";
|
||||
import { bookingState } from "./bookingState";
|
||||
|
||||
function AppointmentList({ doctor, loading, doctorSlug }) {
|
||||
function AppointmentList({ doctor, loading, doctorSlug, bookingLocations = [] }) {
|
||||
const booking = bookingState(doctor, bookingLocations);
|
||||
return (
|
||||
<>
|
||||
<ul className="hidden sticky top-[122px] sm:top-[150px] mt:top-[178px] lg:top-[206px] lg:flex w-[38%] flex-col justify-start items-center gap-8">
|
||||
{doctor && (
|
||||
<ItemAppointment loading={loading} turn={doctor} key={doctor.id} doctorSlug={doctorSlug} />
|
||||
<ItemAppointment
|
||||
loading={loading}
|
||||
turn={doctor}
|
||||
key={doctor.id}
|
||||
doctorSlug={doctorSlug}
|
||||
booking={booking}
|
||||
/>
|
||||
)}
|
||||
</ul>
|
||||
<div
|
||||
className="fixed lg:hidden bg-[#FFF] border-t border-transparent border-t-[#D7D7D7] border-solid bottom-0 right-0 w-full p-4 flex items-center justify-between"
|
||||
>
|
||||
<p className="text-[#05BA58] text-[11px] font-normal">
|
||||
{doctor?.active === false || !doctor?.free_turn
|
||||
? "نوبتدهی غیرفعال است"
|
||||
: `اولین نوبت آزاد: ${doctor.free_turn}`}
|
||||
{booking.label}
|
||||
</p>
|
||||
{doctor?.active === false ? (
|
||||
{!booking.enabled ? (
|
||||
<Button
|
||||
variant="contained"
|
||||
className="!py-2 !px-4 gap-1 !text-[14px] !font-medium"
|
||||
|
||||
@@ -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, bookableAddressUuids = [], slug }) {
|
||||
function DoctorPage({ doctor, comments, rateAggregate, addresses, bookableAddressUuids = [], bookingLocations = [], 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">
|
||||
@@ -41,7 +41,7 @@ function DoctorPage({ doctor, comments, rateAggregate, addresses, bookableAddres
|
||||
addresses={addresses}
|
||||
bookableAddressUuids={bookableAddressUuids}
|
||||
/>
|
||||
<AppointmentList doctor={doctor} doctorSlug={slug} />
|
||||
<AppointmentList doctor={doctor} doctorSlug={slug} bookingLocations={bookingLocations} />
|
||||
</div>
|
||||
<ClaimProfileSection doctor={doctor} />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user