Files
clinicpro/.claude/prompt/settings-clinic-doctors-tab.md
T
hamed 6ab7ed38b8 feat: refactor clinic management into a dedicated settings tab
- Removed MyClinicPage and redirected its functionality to a new ClinicDoctorsPage.
- Created ClinicDoctorsManager component for managing doctors and invitations within the settings layout.
- Updated backend permissions to allow clinic owners to detach doctors, alongside admins.
- Adjusted API documentation to reflect new permission structure.
- Updated tests to cover new functionality and permissions.
- Modified sidebar and settings menu to reflect the new structure and role-based visibility.
2026-07-17 21:56:27 +03:30

207 lines
17 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.
# انتقال مدیریت کلینیک و پزشکان کلینیک به یک تب مجزا در تنظیمات (نقش‌محور)
## پروژه
`clinicpro` (پنل ادمین React + یک اصلاح کوچک permission در Backend Symfony — همان ریپو، cross-repo نیست).
## زمینه
کاربری که به‌عنوان **مدیر/مالک کلینیک** ثبت‌نام می‌کند `primaryRole === 'clinic'` می‌گیرد (برچسب «مالک کلینیک»). امروز در منوی تنظیمات یک تب به نام **«مدیریت مطب»** وجود دارد (`key: 'clinic'`) که به `/admin/my-clinic` می‌رود؛ آن صفحه بلافاصله به `/admin/clinics/{dbUuid}` = `ClinicDetailPage` **ری‌دایرکت** می‌کند. یعنی با کلیک روی تب تنظیمات، کاربر از پوسته‌ی تنظیمات (`SettingsLayout`) خارج می‌شود و به یک صفحه‌ی جنریک ادمین (همان صفحه‌ای که مدیرکل برای هر کلینیک می‌بیند) پرتاب می‌شود. این صفحه هم مدیریت اطلاعات کلینیک و هم مدیریت پزشکانِ کلینیک (لیست/دعوت/تعلیق/حذف دعوتنامه/جداسازی پزشک) را در خود دارد.
دو مشکل:
1. مدیریت پزشکانِ کلینیک به‌جای اینکه یک تب مستقل و تمیز داخل تنظیمات باشد، داخل یک صفحه‌ی بزرگ ادمین قاطی شده و کاربر را از تنظیمات بیرون می‌برد.
2. تب «مدیریت مطب» در **sidebar دسکتاپِ تنظیمات** (`PurchaseSubscriptionSidebar`) اصلاً نقش‌محور نیست و برای همه (از جمله پزشک مهمان) نمایش داده می‌شود — این در `SettingsLayout.test.tsx` هم به‌عنوان رفتار فعلی ثبت شده.
## هدف
1. یک **تب مجزا** در تنظیمات به نام **«پزشکان کلینیک»** ساخته شود که کل مدیریت کلینیک و پزشکانِ کلینیک را **داخل پوسته‌ی تنظیمات** (`SettingsLayout`) در دسترس بگذارد.
2. تب فعلی **«مدیریت مطب»** به‌طور کامل از هر دو منوی تنظیمات حذف شود (موبایل: `SETTINGS_MENU`؛ دسکتاپ: `PurchaseSubscriptionSidebar`).
3. تمام امکانات مدیریت کلینیک + پزشکانِ کلینیک از همان تب جدید در دسترس باشد.
4. کنترل دسترسی نقش‌محور:
* **مدیر کلینیک** (`primaryRole === 'clinic'`): افزودن، ویرایش، حذف/جداسازی و مدیریت کامل پزشکانِ کلینیک.
* **پزشک** (`primaryRole === 'doctor'`): این تب مدیریتی را **اصلاً نبیند** و به تنظیمات مدیریتی کلینیک دسترسی نداشته باشد؛ فقط بخش‌های مربوط به خودش (پروفایل/نوبت‌دهی/دعوت‌نامه‌های دریافتی خودش که جای دیگری است).
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `assets/admin/components/layout/SettingsLayout.tsx` | منبع حقیقت `SETTINGS_MENU` (منوی موبایل) + `menuForRole()` + پوسته‌ی تنظیمات |
| `assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx` | sidebar دسکتاپِ تنظیمات؛ `NAV_ITEMS` مستقل و **بدون نقش‌گِیت** |
| `assets/admin/pages/MyClinicPage.tsx` | تب فعلی «مدیریت مطب» → فقط ری‌دایرکت به `ClinicDetailPage` |
| `assets/admin/pages/ClinicDetailPage.tsx` | صفحه‌ی جنریک ادمین؛ بلوک «پزشکان + دعوتنامه‌ها» (خطوط ~۴۷۶–۹۹۰) منبع کد قابل‌استخراج |
| `assets/admin/App.tsx` | جدول route؛ `RoleRoute` (خط ۱۱۷) و route `my-clinic` (خط ۱۹۱) |
| `assets/admin/stores/authStore.ts` | `primaryRole`, `dbUuid`, `context` (`ContextItem.scope`) |
| `assets/admin/components/layout/SettingsLayout.test.tsx` | تست رفتار فعلی نمایش «مدیریت مطب» |
| `assets/admin/pages/SettingsMenuPage.test.tsx` | تست منوی موبایل |
| `assets/admin/components/layout/Sidebar.tsx` (خط ~۲۰۶) | لینک `/admin/my-clinic` در نویگیشن اصلی |
| `src/Clinic/Controller/ClinicController.php` (خط ۳۵۵–۳۵۶) | endpoint جداسازی پزشک — **`ROLE_ADMIN` only** |
| `src/ClinicInvitation/Controller/ClinicInvitationController.php` | endpointهای دعوت/لیست/تعلیق/حذف دعوتنامه (`IS_AUTHENTICATED_FULLY`) |
## وضعیت فعلی
### منوی تنظیمات موبایل — `SettingsLayout.tsx`
```tsx
export const SETTINGS_MENU: SettingsMenuItem[] = [
{ key: 'subscription', label: 'خرید اشتراک', icon: CreditCardIcon, to: '/admin/subscription' },
{ key: 'doctor', label: 'مدیریت پزشک', icon: UserIcon, to: '/admin/profile', roles: ['doctor'] },
{ key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/appointment-settings', roles: ['doctor'] },
{ key: 'clinic', label: 'مدیریت مطب', icon: BuildingOffice2Icon, to: '/admin/my-clinic', roles: ['clinic'] },
// ...
];
export function menuForRole(role: string | null | undefined): SettingsMenuItem[] {
return SETTINGS_MENU.filter((i) => !i.roles || (role != null && i.roles.includes(role)));
}
```
### sidebar دسکتاپ — `PurchaseSubscriptionSidebar.tsx` (بدون نقش‌گِیت!)
```tsx
const NAV_ITEMS: NavItem[] = [
// ...
{ key: 'clinic', label: 'مدیریت مطب', to: '/admin/my-clinic' }, // برای همه‌ی نقش‌ها دیده می‌شود
// ...
];
export default function PurchaseSubscriptionSidebar({ active }: { active: string }) { /* هیچ نقشی نمی‌گیرد */ }
```
### تب فعلی — `MyClinicPage.tsx` (فقط ری‌دایرکت، از تنظیمات خارج می‌شود)
```tsx
function MyClinicPageContent() {
const { dbUuid, fetchMe } = useAuthStore();
const navigate = useNavigate();
useEffect(() => {
if (dbUuid) navigate(`/admin/clinics/${dbUuid}`, { replace: true }); // ← به ClinicDetailPage می‌پرد
// ...
}, [dbUuid, fetchMe, navigate]);
// ...
}
```
### route فعلی — `App.tsx:191`
```tsx
<Route path="my-clinic" element={<RoleRoute roles={['clinic']}><MyClinicPage /></RoleRoute>} />
```
### endpointهای موجود مدیریت پزشکانِ کلینیک (از `ClinicDetailPage.tsx`)
```
GET /api/v1/clinic/doctor-list/{uuid} لیست پزشکان کلینیک
GET /api/v1/admin/clinic/{uuid}/invitations?limit=50 لیست دعوتنامه‌ها
POST /api/v1/admin/clinic/{uuid}/invite-doctor دعوت پزشک (InviteDoctorModal)
POST /api/v1/admin/clinic/invitation/{invUuid}/resend ارسال مجدد
PATCH /api/v1/admin/clinic/invitation/{invUuid}/status تعلیق/فعال
DELETE /api/v1/admin/clinic/invitation/{invUuid} حذف دعوتنامه
DELETE /api/v1/admin/clinic/{uuid}/doctor/{doctorUuid} جداسازی پزشک ← ROLE_ADMIN only ⚠️
```
### مشکل permission در Backend — `ClinicController.php:355`
```php
#[Route('/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}', methods: ['DELETE'])]
#[IsGranted('ROLE_ADMIN')] // ← مالک کلینیک نمی‌تواند پزشک را جدا کند
public function detachDoctor(...) { ... }
```
## وظایف
### ۱. Backend — اجازه‌ی جداسازی پزشک به مالک کلینیک
هدف task شماره ۴ این است که مدیر کلینیک بتواند پزشک را حذف/جدا کند، ولی endpoint جداسازی الان `ROLE_ADMIN` است.
- در `src/Clinic/Controller/ClinicController.php` متد `detachDoctor` (خط ۳۵۵): گارد `#[IsGranted('ROLE_ADMIN')]` را به `#[IsGranted('IS_AUTHENTICATED_FULLY')]` تغییر بده و **داخل متد/سرویس یک بررسی مالکیت** اضافه کن: کاربر فعلی یا ادمین باشد یا مالک همان کلینیک (`clinicUuid`). اگر نه → `throw new AppException(ErrorCodes::ERR_FORBIDDEN, null, 403)`.
- **اول بگرد**: احتمالاً همین الگوی بررسی مالکیت در `invite-doctor`/`invitations` (که `IS_AUTHENTICATED_FULLY` هستند) در سرویس `ClinicInvitation` وجود دارد — همان helper را دوباره استفاده کن، کد جدید ننویس.
- endpointهای دعوتنامه را هم بررسی کن که مالک کلینیک (نه فقط ادمین) بتواند صدایشان بزند؛ اگر بررسی مالکیت ندارند، همان helper را اضافه کن.
- پس از تغییر، فایل `docs/api/clinic.md` (و در صورت لزوم مستندِ ClinicInvitation) را در همین session به‌روزرسانی کن (Standing Rule).
- تست: PHPUnit برای سه حالت — مالک کلینیک (موفق)، پزشک/کاربر غیرمالک (۴۰۳)، ادمین (موفق).
> اگر بررسی مالکیت روی این endpointها از قبل به‌شکل کامل وجود دارد، فقط گارد `ROLE_ADMIN` را شل کن و دلیلش را در توضیح PR/commit بنویس.
### ۲. استخراج بلوک مدیریت پزشکان به یک کامپوننت مشترک (SOLID)
`ClinicDetailPage.tsx` بلوک «پزشکان + دعوتنامه‌ها» را در خطوط ~۸۴۷–۹۹۰ دارد (tab پزشکان/دعوتنامه‌ها، دعوت، ارسال مجدد، تعلیق، حذف دعوتنامه، جداسازی پزشک، `InviteDoctorModal`، `ConfirmDialog` جداسازی). این منطق نباید کپی شود.
- یک کامپوننت جدید بساز: `assets/admin/components/ClinicDoctorsManager.tsx` با prop `clinicUuid: string` و `readOnly?: boolean`.
- تمام state/queryها/mutationهای مربوط به `doctorsQ`, `invitationsQ`, `resendInvMut`, `changeInvStatusMut`, `deleteInvMut`, `detachDoctorMut`, `inviteOpen`, `detachDoctorConfirm` را به این کامپوننت منتقل کن (از `ClinicDetailPage` بردار).
- `ClinicDetailPage.tsx` را ریفکتور کن تا همین کامپوننت مشترک را با `clinicUuid={uuid}` رندر کند (رفتار صفحه‌ی ادمین نباید تغییر کند).
- endpointها و envelopeها دقیقاً همان‌های فعلی (`data?.data ?? raw`).
### ۳. صفحه‌ی تنظیماتِ تب جدید — `ClinicDoctorsPage.tsx`
- فایل جدید: `assets/admin/pages/ClinicDoctorsPage.tsx`.
- `dbUuid` مالک کلینیک را از `useAuthStore` بگیر (اگر خالی بود `fetchMe()` مثل `MyClinicPage`). سپس **داخل** `SettingsLayout` رندر کن — نه ری‌دایرکت:
```tsx
export default function ClinicDoctorsPage() {
const { dbUuid, fetchMe } = useAuthStore();
useEffect(() => { if (!dbUuid) fetchMe(); }, [dbUuid, fetchMe]);
return (
<SettingsLayout active="clinic-doctors">
{dbUuid
? <ClinicDoctorsManager clinicUuid={dbUuid} />
: <div style={{ padding: 40, textAlign: 'center' }}>
<p style={{ color: 'var(--text-3)', fontSize: 14 }}>در حال بارگذاری اطلاعات کلینیک...</p>
</div>}
</SettingsLayout>
);
}
```
- اگر می‌خواهی ویرایش اطلاعات کلینیک (نام/تلفن/تخصص‌ها/بیمه/گالری/آدرس) هم زیر همین تب باشد (task: «تمام امکانات مدیریت کلینیک»)، یک دکمه/لینک «ویرایش اطلاعات کلینیک» به همان `ClinicDetailPage` بگذار یا آن بلوک‌ها را هم به کامپوننت مشترک اضافه کن. **پیشنهاد:** برای این iteration فقط مدیریت پزشکان + دعوت را داخل تب بیاور و ویرایش اطلاعات کلینیک را با یک لینک به صفحه‌ی موجود نگه‌دار تا صفحه‌ی تنظیمات سبک بماند؛ اگر کاربر مدیریت کامل خواست، در وظیفه‌ی جدا انجام شود.
### ۴. route جدید + حذف route قدیمی — `App.tsx`
- route جدید (به‌جای/کنار `my-clinic`) با گارد نقش:
```tsx
<Route
path="settings/clinic-doctors"
element={
<RoleRoute roles={['clinic']} blockClinicScope>
<ClinicDoctorsPage />
</RoleRoute>
}
/>
```
- `RoleRoute` قبلاً `roles=['clinic']` را چک می‌کند و با `blockClinicScope` پزشکِ مهمان در scope کلینیک را هم رد می‌کند — همین برای task شماره ۴ کافی است (پزشک به تب مدیریتی نمی‌رسد).
- route قدیمی `my-clinic` و `MyClinicPage` را حذف کن؛ اگر لینک قدیمی ممکن است جایی باز شود، یک ری‌دایرکت از `my-clinic` به `settings/clinic-doctors` بگذار.
### ۵. به‌روزرسانی هر دو منوی تنظیمات
- در `SettingsLayout.tsx` آیتم `{ key:'clinic', label:'مدیریت مطب', ... }` را حذف و جایگزین کن با:
```tsx
{ key: 'clinic-doctors', label: 'پزشکان کلینیک', icon: BuildingOffice2Icon, to: '/admin/settings/clinic-doctors', roles: ['clinic'] },
```
- در `PurchaseSubscriptionSidebar.tsx`:
- آیتم `{ key:'clinic', label:'مدیریت مطب', to:'/admin/my-clinic' }` را حذف و با `{ key:'clinic-doctors', label:'پزشکان کلینیک', to:'/admin/settings/clinic-doctors' }` جایگزین کن.
- این sidebar را **نقش‌محور** کن: `primaryRole` را از `useAuthStore` بگیر و `NAV_ITEMS` را با همان منطق `menuForRole` فیلتر کن (به `NavItem` فیلد اختیاری `roles?: string[]` اضافه کن و به آیتم `clinic-doctors` بده `roles: ['clinic']`، به آیتم `doctor`/`appointment` هم `roles:['doctor']` مطابق `SETTINGS_MENU`). این باعث می‌شود پزشک تب «پزشکان کلینیک» را در دسکتاپ هم نبیند (رفع باگ فعلی).
### ۶. اصلاح لینک نویگیشن اصلی — `Sidebar.tsx`
- خط ~۲۰۶ که `/admin/my-clinic` می‌سازد را به `/admin/settings/clinic-doctors` تغییر بده (فقط برای نقش `clinic`). منطق نقش همان‌جا را حفظ کن.
### ۷. تست‌ها
- `SettingsLayout.test.tsx`: تست فعلی که انتظار دارد «مدیریت مطب» دیده شود را به‌روز کن — حالا:
* برای `role='clinic'` باید «پزشکان کلینیک» دیده شود و «مدیریت مطب» **نباشد**.
* برای `role='doctor'` باید «پزشکان کلینیک» **دیده نشود** (چون دسکتاپ حالا نقش‌محور است — کامنت قدیمیِ «desktop sidebar is not role-gated» را هم اصلاح کن).
- `SettingsMenuPage.test.tsx`: `menuForRole('doctor')` نباید `clinic-doctors` بدهد؛ `menuForRole('clinic')` باید بدهد.
- تست جدید برای `ClinicDoctorsManager` (رندر لیست پزشکان از mock، نمایش دکمه‌های مدیریت وقتی `readOnly` نیست).
- `yarn test` و `npx tsc --noEmit` باید سبز شوند.
## نکات مهم
- **نقش‌ها:** `admin` (مدیرکل) · `clinic` (مالک/مدیر کلینیک — برچسب «مالک کلینیک») · `doctor` · `secretary` · `representation` · `user`. «مدیر کلینیک» در این سیستم = `primaryRole === 'clinic'`. پزشکِ مهمانِ دعوت‌شده = `doctor` با `context.scope === 'clinic'` که با `blockClinicScope` در `RoleRoute` رد می‌شود.
- **envelope:** لیست پزشکان و دعوتنامه‌ها با `data?.data ?? raw` استخراج می‌شوند (double-nest احتمالی). دقیقاً از الگوی فعلی `ClinicDetailPage` کپی کن، تغییر نده.
- **تاریخ‌ها:** timestampهای Unix (مثل `expires_at`)؛ انقضا با `Date.now()/1000 > inv.expires_at` سنجیده می‌شود — همین را نگه‌دار.
- **SearchableSelect:** طبق قانون پروژه هر جا `select` لازم شد از `SearchableSelect` استفاده کن، نه `<select>` بومی.
- **رشته‌ها فارسی، RTL.** دکمه‌ها/بج‌ها/آیکن‌ها از همان کلاس‌های موجود (`btn`, `badge`, `mini-btn`, `seg`).
- **گارد سطح UI کافی نیست:** چون پزشک نباید بتواند مدیریت کند، هم UI را گِیت کن (`RoleRoute` + منوی نقش‌محور) و هم Backend را (وظیفه‌ی ۱). بدون وظیفه‌ی ۱، دکمه‌ی «جداسازی پزشک» برای مالک کلینیک ۴۰۳ می‌دهد.
- **SOLID:** منطق مدیریت پزشکان فقط در `ClinicDoctorsManager` باشد؛ نه در `ClinicDetailPage` کپی بماند نه در `ClinicDoctorsPage` دوباره نوشته شود.
- **بعد از اتمام:** `graphify update .` برای به‌روز نگه‌داشتن گراف (طبق قانون پروژه).