- 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.
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_SECRETARYClinic: فیلدuser(ManyToOne به User) — صاحب کلینیک. رابطه ManyToMany باDoctorاز طریق جدولclinic_doctorsDoctor: فیلدuser(OneToOne به User). دارایmobileNumberو رابطه باSpecialtyDoctorSecretary: فیلد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،isAuthenticatedSidebar.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لیست: ۵ نوبت اخیر امروز این کلینیک (از طریق JOINclinic_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:[...] }
ترتیب اجرا (پیشنهادی)
MeController+ بکاند test با curlauthStore.ts— اضافه کردن fetchMe + فیلدهای جدیدApp.tsx— fetchMe در mountDashboardController— هر سه endpointDashboardPage.tsx— sub-dashboardهاSidebar.tsx— پویاApp.tsx— RoleRouteMyClinicPage.tsxAppointmentsPage.tsx— فیلتر endpoint
بعد از هر مرحله: ddev exec php bin/console cache:clear و ddev exec yarn dev
خلاصه فایلهای جدید/تغییریافته
بکاند (جدید)
src/Auth/Controller/MeController.phpsrc/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-roleassets/admin/pages/AppointmentsPage.tsx— endpoint پویا
فرانتاند (جدید)
assets/admin/pages/MyClinicPage.tsx