Files
clinicpro/.claude/prompt/fix-admin-modal-and-calendar.md
T
hamed 02c34bac8e fix(modal): render Modal with React Portal to center it on the screen
fix(calendar): add type="button" to all buttons in PersianCalendar to prevent form submission
2026-07-02 11:48:34 +03:30

152 lines
9.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# رفع دو باگ Modal و تقویم شمسی در پنل ادمین
## پروژه
`clinicpro` (Admin SPA — React 19 داخل Symfony/Encore).
## زمینه
در پنل ادمین دو باگ UI مستقل وجود دارد که هر دو در `/admin/profile` و سایر صفحات دیده می‌شوند:
1. **مودال وسط صفحه باز نمی‌شود و به پایین صفحه می‌چسبد** — در همهٔ صفحاتی که از کامپوننت مشترک `Modal` استفاده می‌کنند.
2. **تقویم شمسی هنگام کار با دکمه‌های ناوبری بسته می‌شود** — مثلاً در فیلد «تاریخ شروع فعالیت» وقتی وارد نمای انتخاب سال می‌شوی و روی دکمه‌های `<` / `>` (تغییر بازهٔ سال) کلیک می‌کنی، به‌جای جابه‌جایی بازه، کل فرم submit و مودال بسته می‌شود.
## مشکل / هدف
### باگ ۱ — علت
کامپوننت `Modal` محتوای خود را **inline در همان‌جای درخت DOM** رندر می‌کند (بدون Portal). کلاس `.overlay` از `position: fixed; inset: 0; display: grid; place-items: center` استفاده می‌کند که باید نسبت به viewport وسط‌چین کند؛ اما وقتی یکی از عناصر والد یک containing-block برای `position: fixed` بسازد (هر عنصری با `transform` / `filter` / `perspective` / `contain: paint` / `will-change`)، مبنای `fixed` از viewport به آن والد تغییر می‌کند و overlay داخل جعبهٔ بلندِ آن والد کشیده می‌شود؛ در نتیجه `place-items: center` مودال را در وسط آن جعبهٔ بلند (که پایین‌تر از دید کاربر است) قرار می‌دهد، نه وسط صفحه. راه‌حل قطعی و مستقل از اینکه کدام والد مقصر است: رندر Modal با **React Portal روی `document.body`**.
### باگ ۲ — علت
در `PersianCalendar.tsx` هیچ‌کدام از `<button>`ها `type` ندارند. طبق HTML، `<button>` بدون `type` داخل یک `<form>` مقدار پیش‌فرض **`type="submit"`** می‌گیرد. این تقویم داخل فرم ویرایش پروفایل/پزشک رندر می‌شود (`<form id="edit-doctor-form" onSubmit={handleSubmit(...)}>` در `DoctorDetailPage.tsx`)، پس هر کلیک روی دکمه‌های ناوبری (`<` / `>`) یا سلول‌های روز/ماه/سال، فرم را submit می‌کند → mutation ذخیره اجرا می‌شود و در `onSuccess` فرم/مودال بسته می‌شود. راه‌حل: افزودن `type="button"` به **همهٔ** `<button>`های داخل `PersianCalendar`.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `clinicpro/assets/admin/components/ui/Modal.tsx` | کامپوننت مشترک مودال — نیاز به Portal |
| `clinicpro/assets/admin/components/ui/PersianCalendar.tsx` | تقویم شمسی popup — buttonها بدون `type` |
| `clinicpro/assets/admin/pages/DoctorDetailPage.tsx` | مصرف‌کننده؛ تقویم داخل `<form id="edit-doctor-form">` (فقط برای درک زمینه — تغییر لازم ندارد) |
| `clinicpro/assets/admin/styles.css` | کلاس‌های `.overlay` / `.modal` (خط ۶۱۴ به بعد — تغییر لازم ندارد) |
## وضعیت فعلی
### `Modal.tsx` (بدون Portal)
```tsx
import React, { useEffect } from 'react';
import { XMarkIcon } from '@heroicons/react/24/outline';
// ...
export default function Modal({ open, title, size = 'md', onClose, children, footer }: Props) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div className="overlay" onClick={onClose}>
<div className="modal" style={{ maxWidth: sizeMap[size] }} onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<h2>{title}</h2>
<button className="mini-btn" onClick={onClose}>
<XMarkIcon style={{ width: 18, height: 18 }} />
</button>
</div>
<div className="modal-body">{children}</div>
{footer && <div className="modal-foot">{footer}</div>}
</div>
</div>
);
}
```
### `PersianCalendar.tsx` (buttonها بدون `type`) — نمونه‌ها
```tsx
// دکمه‌های ناوبری هدر
<button onClick={mode === 'year' ? () => setYearRangeStart(s => s - 12) : nextMonth} style={{ ...navBtnStyle, ... }}>
<ChevronRightIcon style={{ width: 16, height: 16 }} />
</button>
// ...
<button onClick={mode === 'year' ? () => setYearRangeStart(s => s + 12) : prevMonth} style={{ ...navBtnStyle, ... }}>
<ChevronLeftIcon style={{ width: 16, height: 16 }} />
</button>
// سلول روز
<button key={i} onClick={() => selectDay(day)} style={{ ... }}> {day.toLocaleString('fa-IR')} </button>
// سلول ماه
<button key={m} onClick={() => { setViewMonth(m); setMode('day'); }} style={{ ... }}> {mName} </button>
// سلول سال
<button key={y} onClick={() => { setViewYear(y); setMode('month'); }} style={{ ... }}> {faYear(y)} </button>
```
`mini-btn` بستن در `Modal` هم بدون `type` است و باید اصلاح شود (اگر مودالی داخل فرم قرار گیرد).
## وظایف
### ۱. رندر `Modal` با Portal روی `document.body`
`createPortal` را از `react-dom` وارد کن و کل markup مودال را داخل آن بپیچ:
```tsx
import React, { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { XMarkIcon } from '@heroicons/react/24/outline';
// ... داخل کامپوننت، بعد از `if (!open) return null;`
return createPortal(
<div className="overlay" onClick={onClose}>
<div className="modal" style={{ maxWidth: sizeMap[size] }} onClick={(e) => e.stopPropagation()}>
{/* ... بدون تغییر ... */}
</div>
</div>,
document.body
);
```
نکته‌ها:
- Portal تضمین می‌کند overlay فرزندِ مستقیم `body` باشد، پس `position: fixed` همیشه نسبت به viewport محاسبه می‌شود و باگ چسبیدن به پایین در همهٔ صفحات رفع می‌شود.
- منطق Escape و `onClick` overlay و `stopPropagation` مودال بدون تغییر بماند.
- دکمهٔ بستن `mini-btn` را `type="button"` کن تا اگر مودالی داخل یک `<form>` قرار گرفت، submit ناخواسته رخ ندهد.
### ۲. افزودن `type="button"` به همهٔ `<button>`های `PersianCalendar.tsx`
به هر پنج نوع دکمه `type="button"` اضافه کن:
- دکمهٔ ناوبری راست (`ChevronRightIcon`)
- دکمهٔ ناوبری چپ (`ChevronLeftIcon`)
- سلول‌های روز (day view)
- سلول‌های ماه (month view)
- سلول‌های سال (year view)
نمونه:
```tsx
<button
type="button"
onClick={mode === 'year' ? () => setYearRangeStart(s => s - 12) : nextMonth}
style={{ ...navBtnStyle, visibility: mode === 'month' ? 'hidden' : 'visible' }}
>
<ChevronRightIcon style={{ width: 16, height: 16 }} />
</button>
```
این کار از submit ناخواستهٔ فرمِ دربرگیرنده جلوگیری می‌کند و تقویم هنگام کار با `<` / `>` و انتخاب سال/ماه باز می‌ماند؛ فقط انتخاب «روز» (که `onChange` + `onClose` را صدا می‌زند) آن را می‌بندد.
### ۳. (اختیاری، اگر جای دیگری هم مشکل مشابه بود) بررسی سریر سایر تقویم‌ها
`PersianDatePicker.tsx` (والدِ `PersianCalendar`) دکمهٔ باز/بستن‌اش `<div>` است نه `<button>`، پس مشکل submit ندارد؛ نیازی به تغییر نیست. فقط مطمئن شو `PersianCalendar` (که popup مشترک است) اصلاح شده.
## نکات مهم
- **علت دقیق باگ ۲ = نبود `type="button"` در فرم.** فقط با پوشش «همهٔ» دکمه‌های تقویم رفع می‌شود؛ اگر حتی یکی جا بماند، همان دکمه فرم را submit می‌کند.
- **Portal رفع ریشه‌ای باگ ۱ است** و مستقل از اینکه کدام والد containing-block می‌سازد کار می‌کند؛ نیازی به تغییر `styles.css` نیست.
- بعد از Portal، اطمینان حاصل کن `z-index: 1000` روی `.overlay` هنوز بالاتر از سایر عناصر است (چون حالا فرزند body است معمولاً بالاتر هم می‌آید — مشکلی نیست).
- هیچ کتابخانهٔ جدیدی اضافه نکن؛ `react-dom` از قبل موجود است.
- بعد از تغییر: `ddev exec npx tsc --noEmit --project tsconfig.json` و `ddev exec yarn dev` برای build. سپس دستی در `/admin/profile` تست کن: (الف) باز شدن مودال ویرایش در وسط صفحه؛ (ب) باز ماندن تقویم «تاریخ شروع فعالیت» هنگام کلیک روی `<` / `>` و انتخاب سال، و بسته شدن فقط با انتخاب روز.
- این تغییر فقط UI/رفتار کلاینت است؛ API و مستندات `docs/api/` تغییری لازم ندارند.