Files
clinicpro/.claude/prompt/multi-role-dashboard.md
T
hamed e88ae9bf9c feat: enhance ClinicDetailPage with dynamic tab management in EditModal
- Added initialTab prop to EditModal for setting the active tab on open.
- Updated state management in ClinicDetailPage to handle initial tab for editing.
- Refactored openEdit function to set the initial tab before opening the edit modal.
- Combined specialties, insurances, and services sections in the sidebar for better organization.
- Improved modal rendering using createPortal for better context handling.

style: increase z-index for modal overlay

- Updated the z-index of the overlay class in styles.css to ensure modals appear above other elements.

feat: implement multi-role dashboard functionality

- Created a new prompt for multi-role dashboard implementation.
- Defined roles and their access levels in the admin panel.
- Updated backend to support user role identification and context retrieval.
- Enhanced frontend to dynamically render components based on user roles.
- Added new routes and components for role-specific dashboards.

chore: add skills for admin endpoint and page creation

- Created SKILL.md files for adding admin endpoints and pages.
- Provided templates and guidelines for implementing new admin features.

chore: sync database after entity changes

- Added a new skill for syncing the database after any entity modifications.
2026-06-11 09:28:51 +03:30

16 KiB

پرامپت: داشبورد چند-نقشه (Multi-Role Dashboard)

هدف کلی

پنل /admin/ باید علاوه بر ادمین، برای نقش‌های زیر نیز کار کند — هر نقش فقط بخش‌هایی می‌بیند که به آن دسترسی دارد:

نقش نام فارسی ROLE در Symfony
ادمین سیستم مدیر کل ROLE_ADMIN
صاحب کلینیک مالک کلینیک ROLE_CLINIC
دکتر عضو کلینیک پزشک ROLE_DOCTOR
منشی منشی ROLE_SECRETARY

وضعیت فعلی (مهم — قبل از تغییر بخوان)

بک‌اند

  • کلاس AdminApiController با #[IsGranted('ROLE_ADMIN')] روی کل کلاس — تمام endpoint های داشبورد فعلی فقط برای ادمین
  • موجودیت‌ها:
    • User: فیلد roles: array — مقادیر ممکن: ROLE_USER, ROLE_ADMIN, ROLE_DOCTOR, ROLE_CLINIC, ROLE_SECRETARY
    • Clinic: فیلد user (ManyToOne به User) — صاحب کلینیک. رابطه ManyToMany با Doctor از طریق جدول clinic_doctors
    • Doctor: فیلد user (OneToOne به User). دارای mobileNumber و رابطه با Specialty
    • DoctorSecretary: فیلد doctor (ManyToOne)، secretary (ManyToOne به User)، permissions (JSON):
      { version:1, resources: {
          appointments: { view, create, cancel, update_status },
          addresses:    { view, create, update, delete },
          clinic_info:  { view, update },
          insurances:   { view, create, update, delete }
      }}
      
  • JWT: از LexikJWTBundle — payload شامل: username (mobile_number)، roles (آرایه)، iat، exp
  • endpoint های فعلی داشبورد:
    • GET /api/v1/admin/dashboard/stats → ۱۲ KPI عمومی
    • GET /api/v1/admin/dashboard/charts → نمودار ۳۰ روزه
    • GET /api/v1/admin/dashboard/recent → آخرین نوبت‌ها، پرداخت‌ها، کاربران
    • همه با ROLE_ADMIN

فرانت‌اند

  • authStore.ts (Zustand + persist در clinicpro-auth): فقط token، refreshToken، isAuthenticated
  • Sidebar.tsx: لیست ثابت — بدون هیچ فیلتر نقشی
  • App.tsx: همه routes با PrivateRoute (فقط isAuthenticated بررسی می‌شود)
  • DashboardPage.tsx: سه query + نمودار Recharts + mini lists فعلی

مرحله ۱ — endpoint شناسایی کاربر (بک‌اند)

فایل جدید: src/Auth/Controller/MeController.php

GET /api/v1/me   [IS_AUTHENTICATED_FULLY]

پاسخ:

{
  "success": true,
  "data": {
    "uuid": "...",
    "mobile": "09...",
    "name": "دکتر علی ...",
    "roles": ["ROLE_USER", "ROLE_DOCTOR"],
    "primary_role": "doctor",
    "context": {
      "doctor_uuid": "...",
      "doctor_name": "دکتر علی احمدی"
    }
  }
}

قانون primary_role (اولویت‌بندی):

  • ROLE_ADMIN"admin"
  • ROLE_CLINIC"clinic"
  • ROLE_DOCTOR"doctor"
  • ROLE_SECRETARY"secretary"
  • بقیه → "user"

پر کردن context:

  • ROLE_DOCTOR: از DoctorRepository::findByUser($user){doctor_uuid, doctor_name}
  • ROLE_CLINIC: از ClinicRepository::findOneBy(['user' => $user]){clinic_uuid, clinic_name, clinic_logo}
  • ROLE_SECRETARY: از DoctorSecretaryRepository::findActiveBySecretary($user) (متد جدید) → {doctor_uuid, doctor_name, secretary_uuid, permissions}
  • اگر موجودیت پیدا نشد: context: null

متد جدید در DoctorSecretaryRepository:

public function findActiveBySecretary(User $user): ?DoctorSecretary
{
    return $this->findOneBy(['secretary' => $user, 'active' => true]);
}

security.yaml — endpoint /api/v1/me را به firewall api اضافه کن (نه public_endpoints، چون نیاز به احراز هویت دارد — فایروال api آن را پوشش می‌دهد).


مرحله ۲ — به‌روز کردن authStore.ts (فرانت‌اند)

// assets/admin/stores/authStore.ts

interface AuthState {
  token: string | null;
  refreshToken: string | null;
  isAuthenticated: boolean;
  // فیلدهای جدید:
  userUuid: string | null;
  userName: string | null;
  primaryRole: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'user' | null;
  context: Record<string, any> | null;
}
  • متد login(token, refreshToken): بعد از ذخیره token، یک GET /api/v1/me بزند و نتیجه را ذخیره کند
  • متد logout(): همه فیلدها را پاک کند
  • متد جدید fetchMe(): GET /api/v1/me و update store — در App.tsx هنگام mount فراخوانی شود (اگر token موجود بود اما primaryRole خالی بود، تا بعد از reload صفحه role بازیابی شود)

مرحله ۳ — محافظت route ها (فرانت‌اند)

در App.tsx، کامپوننت RoleRoute اضافه کن:

function RoleRoute({ roles, children }: { roles: string[]; children: ReactNode }) {
  const primaryRole = useAuthStore(s => s.primaryRole);
  if (!primaryRole) return <div style={{padding:40, textAlign:'center'}}>در حال بارگذاری...</div>;
  if (!roles.includes(primaryRole)) return <Navigate to="/admin/dashboard" replace />;
  return <>{children}</>;
}

Route های فقط ادمین (با <RoleRoute roles={['admin']}> بپوشان):

  • /admin/users, /admin/users/:uuid
  • /admin/payments
  • /admin/settlements
  • /admin/representations, /admin/representations/:uuid
  • /admin/comments
  • /admin/ratings
  • /admin/sms
  • /admin/categories
  • /admin/blogs, /admin/blogs/new, /admin/blogs/:uuid/edit
  • /admin/secretaries
  • /admin/clinics (لیست کل کلینیک‌ها)

Route های admin + clinic:

  • /admin/clinics/:uuid — ادمین همه را می‌بیند، clinic فقط کلینیک خودش را
  • /admin/doctors — ادمین همه، clinic فقط پزشکان کلینیکش

Route های مشترک همه نقش‌ها:

  • /admin/dashboard
  • /admin/appointments, /admin/appointments/:uuid

Route های جدید:

  • /admin/my-clinic<MyClinicPage /> — فقط ROLE_CLINIC

مرحله ۴ — Sidebar پویا (فرانت‌اند)

فایل assets/admin/components/layout/Sidebar.tsx

ساختار sections را به یک تابع تبدیل کن که primaryRole و context می‌گیرد:

function buildSections(
  primaryRole: string | null,
  context: Record<string, any> | null
): Section[]

ادمین — همه لینک‌های فعلی (بدون تغییر)

صاحب کلینیک (clinic):

عمومی:
  • داشبورد         /admin/dashboard
  • کلینیک من       /admin/my-clinic
  • پزشکان          /admin/doctors

مدیریت:
  • نوبت‌ها          /admin/appointments

دکتر (doctor):

عمومی:
  • داشبورد         /admin/dashboard

مدیریت:
  • نوبت‌های من     /admin/appointments

منشی (secretary) — بر اساس context.permissions.resources:

عمومی:
  • داشبورد         /admin/dashboard

مدیریت (شرطی):
  • نوبت‌ها          /admin/appointments    ← اگر appointments.view = true

در Sidebar، permissions را از useAuthStore(s => s.context) بخوان.


مرحله ۵ — endpoint های داشبورد جدید (بک‌اند)

فایل جدید: src/Dashboard/Controller/DashboardController.php

سه endpoint جداگانه — هر سه از BaseController extend می‌کنند:


GET /api/v1/dashboard/clinic [ROLE_CLINIC]

پاسخ:

{
  "clinic": { "uuid":"...", "name":"...", "is_active": true, "logo":"..." },
  "stats": {
    "total_doctors": 5,
    "today_appointments": 12,
    "this_month_appointments": 87,
    "pending_invitations": 2
  },
  "today_appointments": [
    { "uuid":"...", "patient_name":"...", "doctor_name":"...", "slot_start": 1234567890, "status":"reserved" }
  ],
  "doctors": [
    { "uuid":"...", "name":"دکتر ...", "specialty":"...", "today_count": 3 }
  ]
}

پیاده‌سازی:

  • کلینیک را از ClinicRepository::findOneBy(['user' => $user]) بگیر
  • اگر نبود: throw new AppException('ERR_NOT_FOUND_001', 'کلینیک یافت نشد', 404)
  • today_appointments و this_month_appointments: از جدول appointments با JOIN به clinic_doctors فیلتر کن
  • pending_invitations: از clinic_doctor_invitations با status='pending' بشمار
  • today_appointments لیست: ۵ نوبت اخیر امروز این کلینیک (از طریق JOIN clinic_doctors)
  • doctors: لیست پزشکان کلینیک با شمارش نوبت امروز آن‌ها

GET /api/v1/dashboard/doctor [ROLE_DOCTOR]

پاسخ:

{
  "doctor": { "uuid":"...", "name":"...", "degree":"...", "profile_image":"..." },
  "stats": {
    "today_appointments": 5,
    "tomorrow_appointments": 3,
    "this_month_appointments": 42,
    "avg_rating": 4.7,
    "total_ratings": 18
  },
  "today_appointments": [
    { "uuid":"...", "patient_name":"...", "patient_mobile":"...", "slot_start": 1234567890, "status":"reserved" }
  ],
  "clinics": [
    { "uuid":"...", "name":"کلینیک ...", "logo":"..." }
  ]
}

پیاده‌سازی:

  • دکتر از DoctorRepository::findByUser($user) — اگر نبود 404
  • today_appointments: نوبت‌های این دکتر با slot_start در بازه ابتدا تا انتهای امروز
  • tomorrow_appointments: همان برای فردا
  • avg_rating: AVG(overall) از جدول ratings برای این دکتر
  • clinics: کلینیک‌هایی که این دکتر در clinic_doctors آن‌هاست

GET /api/v1/dashboard/secretary [ROLE_SECRETARY]

پاسخ:

{
  "doctor": { "uuid":"...", "name":"...", "degree":"..." },
  "permissions": { ... },
  "stats": {
    "today_appointments": 4,
    "tomorrow_appointments": 2
  },
  "today_appointments": [
    { "uuid":"...", "patient_name":"...", "patient_mobile":"...", "slot_start": 1234567890, "status":"reserved" }
  ]
}

پیاده‌سازی:

  • از DoctorSecretaryRepository::findActiveBySecretary($user) اولین رابطه فعال بگیر
  • اگر نبود: throw new AppException('ERR_FORBIDDEN_001', 'دسترسی منشی تنظیم نشده', 403)
  • بررسی appointments.view = true در permissions — اگر false بود، today_appointments آرایه خالی برگردان
  • نوبت‌های دکتر مربوطه را برگردان

مرحله ۶ — DashboardPage.tsx چند-نقشه (فرانت‌اند)

فایل assets/admin/pages/DashboardPage.tsx را به این شکل بازنویسی کن:

export default function DashboardPage() {
  const primaryRole = useAuthStore(s => s.primaryRole);

  if (!primaryRole) return <LoadingSkeleton />;
  if (primaryRole === 'admin')     return <AdminDashboard />;
  if (primaryRole === 'clinic')    return <ClinicDashboard />;
  if (primaryRole === 'doctor')    return <DoctorDashboard />;
  if (primaryRole === 'secretary') return <SecretaryDashboard />;
  return <div className="card card-pad"><p className="muted">نقش شما برای داشبورد تعریف نشده</p></div>;
}

AdminDashboard: کد فعلی DashboardPage عیناً — فقط در یک تابع بپیچ

ClinicDashboard:

  • یک query به /api/v1/dashboard/clinic
  • ۴ کارت KPI: تعداد پزشکان / نوبت امروز / نوبت این ماه / دعوتنامه در انتظار
  • جدول نوبت‌های امروز (ستون: بیمار، پزشک، زمان، وضعیت)
  • لیست پزشکان با تعداد نوبت امروز
  • دکمه "مدیریت کلینیک" → navigate به /admin/my-clinic

DoctorDashboard:

  • یک query به /api/v1/dashboard/doctor
  • ۴ کارت KPI: نوبت امروز / فردا / این ماه / میانگین امتیاز (با ستاره)
  • جدول نوبت‌های امروز (ستون: بیمار، موبایل dir="ltr", زمان، وضعیت)
  • لیست کلینیک‌های عضو به شکل badge

SecretaryDashboard:

  • یک query به /api/v1/dashboard/secretary
  • نام دکتر مربوطه در header کارت
  • ۲ کارت KPI: نوبت امروز / فردا
  • جدول نوبت‌های امروز
  • لیست مجوزهای فعال با آیکون ✓

مرحله ۷ — صفحه "کلینیک من" (فرانت‌اند)

فایل جدید: assets/admin/pages/MyClinicPage.tsx

export default function MyClinicPage() {
  const context = useAuthStore(s => s.context);
  const clinicUuid = context?.clinic_uuid;
  
  if (!clinicUuid) return (
    <div className="card card-pad">
      <p>کلینیک شما هنوز ثبت نشده است.</p>
    </div>
  );
  
  // همان محتوای ClinicDetailPage — اما uuid از context
  // دکمه "حذف کلینیک" نشان داده نشود
  // بقیه همه فعال: ویرایش، تغییر وضعیت، آپلود لوگو، گالری، دعوت پزشک
}

بهترین رویکرد: کد مشترک را از ClinicDetailPage.tsx در یک کامپوننت ClinicDetailView جدا کن که uuid و showDeleteButton را به عنوان prop می‌گیرد. هر دو صفحه از آن استفاده کنند.


مرحله ۸ — نوبت‌های فیلترشده (بک‌اند + فرانت‌اند)

بک‌اند — endpoint جدید: GET /api/v1/my/appointments [IS_AUTHENTICATED_FULLY]

در یک Controller جدید یا در AppointmentController:

GET /api/v1/my/appointments?page=1&limit=15&status=...&search=...

بر اساس نقش فیلتر:

  • ROLE_ADMIN: همه نوبت‌ها (redirect به /api/v1/admin/appointments)
  • ROLE_CLINIC: نوبت‌هایی که doctor آن در clinic_doctors این کلینیک است
  • ROLE_DOCTOR: نوبت‌های این دکتر
  • ROLE_SECRETARY: نوبت‌های دکتری که این منشی به آن وصل است (اگر appointments.view = true)

پاسخ: همان فرمت paginated() موجود.

فرانت‌اند — AppointmentsPage.tsx

const primaryRole = useAuthStore(s => s.primaryRole);
const endpoint = primaryRole === 'admin'
  ? `/api/v1/admin/appointments?...`
  : `/api/v1/my/appointments?...`;

نکات مهم پیاده‌سازی

CSS / UI — فقط template CSS

  • .card, .card-pad, .badge.green/.blue/.amber/.violet/.gray
  • .btn.primary/.ghost/.soft/.sm
  • .skeleton برای loading
  • .empty برای حالت خالی
  • گرادیان آواتار: HUES_LIST = [256, 205, 162, 295, 272] با OKLCH: background: \linear-gradient(145deg, oklch(0.62 0.15 ${hue}), oklch(0.48 0.16 ${hue}))``
  • هیچ Tailwind نیست

پاسخ‌های API

  • $this->success($data){ success, data: $data } — برای single resource
  • $this->paginated($items, $total, $page, $limit){ success, data: $items[], meta: {...} }
  • $this->error(...){ success:false, errors:[...] }

ترتیب اجرا (پیشنهادی)

  1. MeController + بک‌اند test با curl
  2. authStore.ts — اضافه کردن fetchMe + فیلدهای جدید
  3. App.tsx — fetchMe در mount
  4. DashboardController — هر سه endpoint
  5. DashboardPage.tsx — sub-dashboardها
  6. Sidebar.tsx — پویا
  7. App.tsx — RoleRoute
  8. MyClinicPage.tsx
  9. AppointmentsPage.tsx — فیلتر endpoint

بعد از هر مرحله: ddev exec php bin/console cache:clear و ddev exec yarn dev


خلاصه فایل‌های جدید/تغییریافته

بک‌اند (جدید)

  • src/Auth/Controller/MeController.php
  • src/Dashboard/Controller/DashboardController.php

بک‌اند (تغییر)

  • src/Secretary/Repository/DoctorSecretaryRepository.php — اضافه: findActiveBySecretary()

فرانت‌اند (تغییر)

  • assets/admin/stores/authStore.ts — اضافه: primaryRole، context، fetchMe()
  • assets/admin/App.tsx — اضافه: RoleRoute، fetchMe در mount، route های جدید
  • assets/admin/components/layout/Sidebar.tsx — تبدیل به پویا
  • assets/admin/pages/DashboardPage.tsx — multi-role
  • assets/admin/pages/AppointmentsPage.tsx — endpoint پویا

فرانت‌اند (جدید)

  • assets/admin/pages/MyClinicPage.tsx