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

17 KiB
Raw Blame History

انتقال مدیریت کلینیک و پزشکان کلینیک به یک تب مجزا در تنظیمات (نقش‌محور)

پروژه

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

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 با 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 رندر کن — نه ری‌دایرکت:
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 . برای به‌روز نگه‌داشتن گراف (طبق قانون پروژه).