- 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.
207 lines
17 KiB
Markdown
207 lines
17 KiB
Markdown
# انتقال مدیریت کلینیک و پزشکان کلینیک به یک تب مجزا در تنظیمات (نقشمحور)
|
||
|
||
## پروژه
|
||
|
||
`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 .` برای بهروز نگهداشتن گراف (طبق قانون پروژه).
|