- 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.
17 KiB
انتقال مدیریت کلینیک و پزشکان کلینیک به یک تب مجزا در تنظیمات (نقشمحور)
پروژه
clinicpro (پنل ادمین React + یک اصلاح کوچک permission در Backend Symfony — همان ریپو، cross-repo نیست).
زمینه
کاربری که بهعنوان مدیر/مالک کلینیک ثبتنام میکند primaryRole === 'clinic' میگیرد (برچسب «مالک کلینیک»). امروز در منوی تنظیمات یک تب به نام «مدیریت مطب» وجود دارد (key: 'clinic') که به /admin/my-clinic میرود؛ آن صفحه بلافاصله به /admin/clinics/{dbUuid} = ClinicDetailPage ریدایرکت میکند. یعنی با کلیک روی تب تنظیمات، کاربر از پوستهی تنظیمات (SettingsLayout) خارج میشود و به یک صفحهی جنریک ادمین (همان صفحهای که مدیرکل برای هر کلینیک میبیند) پرتاب میشود. این صفحه هم مدیریت اطلاعات کلینیک و هم مدیریت پزشکانِ کلینیک (لیست/دعوت/تعلیق/حذف دعوتنامه/جداسازی پزشک) را در خود دارد.
دو مشکل:
- مدیریت پزشکانِ کلینیک بهجای اینکه یک تب مستقل و تمیز داخل تنظیمات باشد، داخل یک صفحهی بزرگ ادمین قاطی شده و کاربر را از تنظیمات بیرون میبرد.
- تب «مدیریت مطب» در sidebar دسکتاپِ تنظیمات (
PurchaseSubscriptionSidebar) اصلاً نقشمحور نیست و برای همه (از جمله پزشک مهمان) نمایش داده میشود — این درSettingsLayout.test.tsxهم بهعنوان رفتار فعلی ثبت شده.
هدف
- یک تب مجزا در تنظیمات به نام «پزشکان کلینیک» ساخته شود که کل مدیریت کلینیک و پزشکانِ کلینیک را داخل پوستهی تنظیمات (
SettingsLayout) در دسترس بگذارد. - تب فعلی «مدیریت مطب» بهطور کامل از هر دو منوی تنظیمات حذف شود (موبایل:
SETTINGS_MENU؛ دسکتاپ:PurchaseSubscriptionSidebar). - تمام امکانات مدیریت کلینیک + پزشکانِ کلینیک از همان تب جدید در دسترس باشد.
- کنترل دسترسی نقشمحور:
- مدیر کلینیک (
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
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 (بدون نقشگِیت!)
const NAV_ITEMS: NavItem[] = [
// ...
{ key: 'clinic', label: 'مدیریت مطب', to: '/admin/my-clinic' }, // برای همهی نقشها دیده میشود
// ...
];
export default function PurchaseSubscriptionSidebar({ active }: { active: string }) { /* هیچ نقشی نمیگیرد */ }
تب فعلی — MyClinicPage.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
<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
#[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با propclinicUuid: 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رندر کن — نه ریدایرکت:
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) با گارد نقش:
<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:'مدیریت مطب', ... }را حذف و جایگزین کن با:
{ 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 .برای بهروز نگهداشتن گراف (طبق قانون پروژه).