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.
This commit is contained in:
@@ -0,0 +1,447 @@
|
||||
# پرامپت: داشبورد چند-نقشه (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]
|
||||
```
|
||||
|
||||
پاسخ:
|
||||
```json
|
||||
{
|
||||
"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`**:
|
||||
```php
|
||||
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` (فرانتاند)
|
||||
|
||||
```typescript
|
||||
// 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` اضافه کن:
|
||||
|
||||
```tsx
|
||||
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` میگیرد:
|
||||
|
||||
```tsx
|
||||
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]`
|
||||
|
||||
پاسخ:
|
||||
```json
|
||||
{
|
||||
"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]`
|
||||
|
||||
پاسخ:
|
||||
```json
|
||||
{
|
||||
"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]`
|
||||
|
||||
پاسخ:
|
||||
```json
|
||||
{
|
||||
"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` را به این شکل بازنویسی کن:
|
||||
|
||||
```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`
|
||||
|
||||
```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`
|
||||
|
||||
```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`
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: add-admin-endpoint
|
||||
description: Add a new paginated admin API endpoint to AdminApiController. Use when the user wants to add a backend admin list, stats, or action endpoint — things like "add an endpoint for X", "create an admin API for Y", "I need a route that lists Z".
|
||||
---
|
||||
|
||||
## Target file
|
||||
`src/Admin/Controller/AdminApiController.php`
|
||||
|
||||
All admin endpoints live here. The class already has `#[IsGranted('ROLE_ADMIN')]` and injects `EntityManagerInterface $em`.
|
||||
|
||||
## Checklist
|
||||
|
||||
1. **Stats endpoint** (optional but standard): a separate `#[Route('/api/v1/admin/{entity}/stats')]` method that returns counts via raw SQL (`$this->em->getConnection()->fetchOne()`). Return with `$this->success([...])`.
|
||||
|
||||
2. **List endpoint**: use QueryBuilder with `->getArrayResult()` — never load full entities for list queries (entity getters may not exist for all fields). Pattern:
|
||||
|
||||
```php
|
||||
#[Route('/api/v1/admin/{entities}', methods: ['GET'])]
|
||||
public function list{Entity}(Request $request): JsonResponse
|
||||
{
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
|
||||
$search = trim((string) $request->query->get('search', ''));
|
||||
|
||||
$qb = $this->em->createQueryBuilder()
|
||||
->select('e.id, e.uuid, e.someField, e.createdAt')
|
||||
->from(SomeEntity::class, 'e');
|
||||
|
||||
if ($search !== '') {
|
||||
$qb->andWhere('e.name LIKE :s')->setParameter('s', "%$search%");
|
||||
}
|
||||
|
||||
$total = (clone $qb)->select('COUNT(e.id)')->getQuery()->getSingleScalarResult();
|
||||
|
||||
$items = $qb
|
||||
->orderBy('e.id', 'DESC')
|
||||
->setFirstResult(($page - 1) * $limit)
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
return $this->paginated($items, (int) $total, $page, $limit);
|
||||
}
|
||||
```
|
||||
|
||||
3. **Action endpoints** (toggle status, etc.) follow this shape:
|
||||
|
||||
```php
|
||||
#[Route('/api/v1/admin/{entities}/{uuid}/status', methods: ['POST'])]
|
||||
public function toggle{Entity}Status(string $uuid): JsonResponse
|
||||
{
|
||||
$entity = $this->em->getRepository(SomeEntity::class)->findOneBy(['uuid' => $uuid]);
|
||||
if (!$entity) return $this->error('NOT_FOUND', 'Entity not found', 404);
|
||||
|
||||
$entity->setIsActive(!$entity->getIsActive());
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success(['is_active' => $entity->getIsActive()]);
|
||||
}
|
||||
```
|
||||
|
||||
## Critical rules
|
||||
|
||||
- **Always use `getArrayResult()`** for list queries. Never call entity getters inside admin list methods.
|
||||
- `createdAt` and `updatedAt` are Unix integer timestamps — do not format them in PHP, let the frontend handle it.
|
||||
- For JOINs to categories (city, state, specialty), use LEFT JOIN in DQL and select the name field directly into the array result.
|
||||
- Response shape for lists: `$this->paginated($items, $total, $page, $limit)` — frontend reads `data?.data` for items and `data?.meta?.totalRecords` for count.
|
||||
- Add `use` imports for any new entity class at the top of the file.
|
||||
- Run `/sync-db` only if a new entity or column was added as part of this change.
|
||||
@@ -0,0 +1,220 @@
|
||||
---
|
||||
name: add-admin-page
|
||||
description: Add a new admin React page to the frontend. Use when the user wants a new admin panel page — list views, detail pages, management UIs. Triggers on "add a page for X", "create an admin page", "I need a UI for managing Y", "build the frontend for Z".
|
||||
---
|
||||
|
||||
## Files to create/modify
|
||||
|
||||
| Action | Path |
|
||||
|--------|------|
|
||||
| Create | `assets/admin/pages/{Name}Page.tsx` |
|
||||
| Edit | `assets/admin/types/index.ts` — add the TypeScript interface |
|
||||
| Edit | `assets/admin/App.tsx` — add the route |
|
||||
|
||||
## Step 1 — Add the TypeScript type
|
||||
|
||||
Add to `assets/admin/types/index.ts`:
|
||||
|
||||
```ts
|
||||
export interface SomeName {
|
||||
uuid: string;
|
||||
// ... fields matching the backend array result
|
||||
}
|
||||
```
|
||||
|
||||
## Step 2 — Register the route
|
||||
|
||||
In `assets/admin/App.tsx`, add inside the `<AdminLayout>` routes block:
|
||||
|
||||
```tsx
|
||||
import SomeNamePage from './pages/SomeNamePage';
|
||||
// ...
|
||||
<Route path="some-names" element={<SomeNamePage />} />
|
||||
<Route path="some-names/:uuid" element={<SomeNameDetailPage />} /> {/* if detail page needed */}
|
||||
```
|
||||
|
||||
## Step 3 — Create the page
|
||||
|
||||
Standard list page pattern:
|
||||
|
||||
```tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { MagnifyingGlassIcon, PlusIcon, EyeIcon, TrashIcon, ArrowPathIcon, CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
import { formatDate } from '../lib/utils';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import type { SomeName } from '../types';
|
||||
|
||||
export default function SomeNamePage() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
const [limit] = useState(15);
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [deleteTarget, setDeleteTarget] = useState<SomeName | null>(null);
|
||||
|
||||
// Debounced search — always 350 ms
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => { setSearch(searchInput); setPage(1); }, 350);
|
||||
return () => clearTimeout(t);
|
||||
}, [searchInput]);
|
||||
|
||||
// List query
|
||||
const listQ = useQuery({
|
||||
queryKey: ['admin-some-names', page, limit, search],
|
||||
queryFn: () => {
|
||||
const p = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
if (search) p.set('search', search);
|
||||
return api.get<PaginatedResponse<SomeName>>(`/api/v1/admin/some-names?${p}`);
|
||||
},
|
||||
});
|
||||
|
||||
const items = listQ.data?.data ?? [];
|
||||
const total = listQ.data?.meta?.totalRecords ?? 0;
|
||||
|
||||
// Stats query (if stats endpoint exists)
|
||||
const statsQ = useQuery({
|
||||
queryKey: ['admin-some-names-stats'],
|
||||
queryFn: () => api.get<ApiResponse<{ total: number; active: number }>>('/api/v1/admin/some-names/stats'),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
// IMPORTANT: stats data may be double-nested depending on backend shape.
|
||||
// Use this pattern to handle both cases:
|
||||
const stats = (statsQ.data?.data as any)?.data ?? statsQ.data?.data;
|
||||
|
||||
// Delete mutation
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/some-names/${uuid}`),
|
||||
onSuccess: () => {
|
||||
toast.success('حذف شد');
|
||||
setDeleteTarget(null);
|
||||
qc.invalidateQueries({ queryKey: ['admin-some-names'] });
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
// Toggle status mutation
|
||||
const toggleMut = useMutation({
|
||||
mutationFn: (uuid: string) => api.post<ApiResponse<{ is_active: boolean }>>(`/api/v1/admin/some-names/${uuid}/status`, {}),
|
||||
onSuccess: () => {
|
||||
toast.success('وضعیت تغییر کرد');
|
||||
qc.invalidateQueries({ queryKey: ['admin-some-names'] });
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
{/* Header */}
|
||||
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||
<div>
|
||||
<h1 className="section-title">عنوان صفحه</h1>
|
||||
<div className="muted" style={{ fontSize: 13, marginTop: 3 }}>توضیح کوتاه</div>
|
||||
</div>
|
||||
<button className="btn primary sm" onClick={() => navigate('/admin/some-names/new')}>
|
||||
<PlusIcon style={{ width: 15, height: 15 }} /> افزودن
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* KPI cards — only if stats endpoint exists */}
|
||||
{/* <div className="stat-grid"> ... </div> */}
|
||||
|
||||
{/* Main card */}
|
||||
<div className="card">
|
||||
{/* Toolbar */}
|
||||
<div className="card-pad" style={{ paddingBottom: 0 }}>
|
||||
<div className="toolbar">
|
||||
<div className="field" style={{ minWidth: 240 }}>
|
||||
<MagnifyingGlassIcon style={{ width: 17, height: 17 }} />
|
||||
<input value={searchInput} onChange={(e) => setSearchInput(e.target.value)} placeholder="جستجو..." />
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
<button className="btn ghost sm" onClick={() => listQ.refetch()} disabled={listQ.isFetching}>
|
||||
<ArrowPathIcon style={{ width: 15, height: 15 }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="table-wrap">
|
||||
<table className="t">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>نام</th>
|
||||
<th>وضعیت</th>
|
||||
<th>تاریخ</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{listQ.isLoading && Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={i}>{Array.from({ length: 4 }).map((_, j) => (
|
||||
<td key={j}><div className="skeleton" style={{ height: 14, borderRadius: 6, width: '60%' }} /></td>
|
||||
))}</tr>
|
||||
))}
|
||||
{!listQ.isLoading && items.length === 0 && (
|
||||
<tr><td colSpan={4}><div className="empty">موردی یافت نشد</div></td></tr>
|
||||
)}
|
||||
{!listQ.isLoading && items.map((item) => (
|
||||
<tr key={item.uuid} style={{ cursor: 'pointer' }} onClick={() => navigate(`/admin/some-names/${item.uuid}`)}>
|
||||
<td>{/* render fields */}</td>
|
||||
<td>
|
||||
<span className={`badge ${(item as any).is_active ? 'green' : 'gray'}`}>
|
||||
<span className="bdot" />
|
||||
{(item as any).is_active ? 'فعال' : 'غیرفعال'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="muted">{formatDate((item as any).created_at)}</td>
|
||||
<td onClick={(e) => e.stopPropagation()}>
|
||||
<div className="row-actions">
|
||||
<button className="mini-btn" onClick={() => navigate(`/admin/some-names/${item.uuid}`)}>
|
||||
<EyeIcon style={{ width: 16, height: 16 }} />
|
||||
</button>
|
||||
<button className="mini-btn" onClick={() => toggleMut.mutate(item.uuid)} disabled={toggleMut.isPending}>
|
||||
{(item as any).is_active
|
||||
? <XCircleIcon style={{ width: 16, height: 16 }} />
|
||||
: <CheckCircleIcon style={{ width: 16, height: 16 }} />}
|
||||
</button>
|
||||
<button className="mini-btn danger" onClick={() => setDeleteTarget(item)}>
|
||||
<TrashIcon style={{ width: 16, height: 16 }} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="حذف"
|
||||
message={`آیا از حذف این مورد اطمینان دارید؟`}
|
||||
confirmLabel="حذف"
|
||||
danger
|
||||
loading={deleteMut.isPending}
|
||||
onConfirm={() => deleteTarget && deleteMut.mutate(deleteTarget.uuid)}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Critical rules
|
||||
|
||||
- **Never use `data?.data?.data`** unless the endpoint is a `$this->success(['data' => ...])` double-nest. Standard `$this->success($array)` → extract with `data?.data`. `$this->paginated()` → items at `data?.data`, total at `data?.meta?.totalRecords`.
|
||||
- Stats from `$this->success($stats)` may still be double-nested in older endpoints — use `(statsQ.data?.data as any)?.data ?? statsQ.data?.data` to handle both.
|
||||
- Category API (`/api/v1/categorys/{bundle}`) is always triple-nested: extract with `data?.data?.data ?? []`.
|
||||
- Search debounce is always 350ms via `setTimeout` in a `useEffect`.
|
||||
- Query keys follow the format `['admin-entity-name', page, limit, search, ...filters]`.
|
||||
- After creating the page, run `ddev exec yarn dev` to check for TypeScript errors.
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: sync-db
|
||||
description: Run the standard Doctrine migration cycle after any entity change. Use whenever an entity is added or modified, a new column/relation is needed, or the user says "migrate", "sync the database", "generate migration", or asks to apply schema changes.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
Run these three commands in sequence and report the output of each step:
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console doctrine:migrations:diff --no-interaction
|
||||
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
|
||||
ddev exec php bin/console cache:clear
|
||||
```
|
||||
|
||||
If `migrations:diff` reports "No changes detected", skip `migrations:migrate` and say so. If any step fails, stop and show the full error output.
|
||||
@@ -258,8 +258,9 @@ async function geocodeCity(name: string): Promise<[number, number] | null> {
|
||||
|
||||
// ── Edit Modal ─────────────────────────────────────────────────────────────
|
||||
|
||||
function EditModal({ clinic, onClose, onSaved }: {
|
||||
function EditModal({ clinic, onClose, onSaved, initialTab = 'basic' }: {
|
||||
clinic: ClinicDetail; onClose: () => void; onSaved: () => void;
|
||||
initialTab?: 'basic' | 'location' | 'tags';
|
||||
}) {
|
||||
const [mapFlyTarget, setMapFlyTarget] = useState<[number, number] | null>(null);
|
||||
|
||||
@@ -347,7 +348,7 @@ function EditModal({ clinic, onClose, onSaved }: {
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'basic' | 'location' | 'tags'>('basic');
|
||||
const [activeTab, setActiveTab] = useState<'basic' | 'location' | 'tags'>(initialTab);
|
||||
|
||||
return (
|
||||
<div className="overlay" onClick={onClose}>
|
||||
@@ -567,9 +568,10 @@ export default function ClinicDetailPage() {
|
||||
const { uuid } = useParams<{ uuid: string }>();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editInitialTab, setEditInitialTab] = useState<'basic' | 'location' | 'tags'>('basic');
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
const [doctorsTab, setDoctorsTab] = useState<'doctors' | 'invitations'>('doctors');
|
||||
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||
const galleryInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -606,6 +608,11 @@ export default function ClinicDetailPage() {
|
||||
|
||||
const invitationList: ClinicInvitation[] = invitationsQ.data?.data ?? [];
|
||||
|
||||
const openEdit = (tab: 'basic' | 'location' | 'tags' = 'basic') => {
|
||||
setEditInitialTab(tab);
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const toggleMut = useMutation({
|
||||
mutationFn: () => api.patch<ApiResponse<any>>(`/api/v1/admin/clinic/${uuid}/status`, {}),
|
||||
onSuccess: () => {
|
||||
@@ -679,8 +686,8 @@ export default function ClinicDetailPage() {
|
||||
const json = await res.json();
|
||||
const url = json?.data?.url;
|
||||
if (url && clinic) {
|
||||
const existing = (clinic.images_clinic ?? []).filter(img => img?.url).map(img => img.url);
|
||||
await api.patch(`/api/v1/clinic/${uuid}`, { image_clinic: [...existing, url] });
|
||||
const existing = (clinic.images_clinic ?? []).filter(img => img?.url);
|
||||
await api.patch(`/api/v1/clinic/${uuid}`, { image_clinic: [...existing, { url }] });
|
||||
toast.success('تصویر اضافه شد');
|
||||
qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] });
|
||||
}
|
||||
@@ -735,7 +742,7 @@ export default function ClinicDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn ghost sm" onClick={() => setEditOpen(true)}>
|
||||
<button className="btn ghost sm" onClick={() => openEdit('basic')}>
|
||||
<PencilIcon style={{ width: 15, height: 15 }} /> ویرایش
|
||||
</button>
|
||||
<button className={`btn sm ${clinic.is_active ? 'soft' : 'primary'}`}
|
||||
@@ -970,67 +977,82 @@ export default function ClinicDetailPage() {
|
||||
{/* Right sidebar */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
|
||||
|
||||
{/* Specialties */}
|
||||
{/* Specialties + Insurances + Services combined */}
|
||||
<div className="card card-pad">
|
||||
<b style={{ fontSize: 13, display: 'block', marginBottom: 10 }}>
|
||||
تخصصها ({formatNumber((clinic.specialties ?? []).length)})
|
||||
</b>
|
||||
{(clinic.specialties ?? []).length === 0
|
||||
? <p className="muted" style={{ fontSize: 12 }}>تخصصی ثبت نشده</p>
|
||||
: <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{clinic.specialties.map(s => (
|
||||
<span key={s.id} className="badge violet"><span className="bdot" />{s.name}</span>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<b style={{ fontSize: 13 }}>تخصصها، بیمه و خدمات</b>
|
||||
<button className="btn ghost sm" style={{ fontSize: 12 }} onClick={() => openEdit('tags')}>
|
||||
<PencilIcon style={{ width: 13, height: 13 }} /> ویرایش
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Insurances */}
|
||||
<div className="card card-pad">
|
||||
<b style={{ fontSize: 13, display: 'block', marginBottom: 10 }}>
|
||||
بیمهها ({formatNumber((clinic.list_bime ?? []).length)})
|
||||
</b>
|
||||
{(clinic.list_bime ?? []).length === 0
|
||||
? <p className="muted" style={{ fontSize: 12 }}>بیمهای ثبت نشده</p>
|
||||
: <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{clinic.list_bime.map(ins => (
|
||||
<span key={ins.id} className="badge blue"><span className="bdot" />{ins.name}</span>
|
||||
))}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<div className="muted" style={{ fontSize: 11, marginBottom: 6 }}>
|
||||
تخصصها ({formatNumber((clinic.specialties ?? []).length)})
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
{(clinic.specialties ?? []).length === 0
|
||||
? <p className="muted" style={{ fontSize: 12 }}>ثبت نشده</p>
|
||||
: <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{clinic.specialties.map(s => (
|
||||
<span key={s.id} className="badge violet"><span className="bdot" />{s.name}</span>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Services */}
|
||||
<div className="card card-pad">
|
||||
<b style={{ fontSize: 13, display: 'block', marginBottom: 10 }}>
|
||||
خدمات ({formatNumber((clinic.services ?? []).length)})
|
||||
</b>
|
||||
{(clinic.services ?? []).length === 0
|
||||
? <p className="muted" style={{ fontSize: 12 }}>خدمتی ثبت نشده</p>
|
||||
: <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{clinic.services.map(s => (
|
||||
<span key={s.id} className="badge green"><span className="bdot" />{s.name}</span>
|
||||
))}
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
||||
<div className="muted" style={{ fontSize: 11, marginBottom: 6 }}>
|
||||
بیمهها ({formatNumber((clinic.list_bime ?? []).length)})
|
||||
</div>
|
||||
}
|
||||
{(clinic.list_bime ?? []).length === 0
|
||||
? <p className="muted" style={{ fontSize: 12 }}>ثبت نشده</p>
|
||||
: <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{clinic.list_bime.map(ins => (
|
||||
<span key={ins.id} className="badge blue"><span className="bdot" />{ins.name}</span>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
||||
<div className="muted" style={{ fontSize: 11, marginBottom: 6 }}>
|
||||
خدمات ({formatNumber((clinic.services ?? []).length)})
|
||||
</div>
|
||||
{(clinic.services ?? []).length === 0
|
||||
? <p className="muted" style={{ fontSize: 12 }}>ثبت نشده</p>
|
||||
: <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{clinic.services.map(s => (
|
||||
<span key={s.id} className="badge green"><span className="bdot" />{s.name}</span>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit modal */}
|
||||
{editOpen && (
|
||||
<EditModal clinic={clinic} onClose={() => setEditOpen(false)}
|
||||
onSaved={() => { qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] }); qc.invalidateQueries({ queryKey: ['admin-clinics'] }); }} />
|
||||
{/* Edit modal — portal to escape Leaflet transform context */}
|
||||
{editOpen && createPortal(
|
||||
<EditModal
|
||||
clinic={clinic}
|
||||
initialTab={editInitialTab}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onSaved={() => { qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] }); qc.invalidateQueries({ queryKey: ['admin-clinics'] }); }}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{/* Invite doctor modal */}
|
||||
{inviteOpen && uuid && (
|
||||
{inviteOpen && uuid && createPortal(
|
||||
<InviteModal
|
||||
clinicUuid={uuid}
|
||||
onClose={() => setInviteOpen(false)}
|
||||
onInvited={() => { qc.invalidateQueries({ queryKey: ['clinic-invitations', uuid] }); setDoctorsTab('invitations'); }}
|
||||
/>
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{/* Delete confirm */}
|
||||
|
||||
@@ -523,7 +523,7 @@ table.t tbody tr:hover .row-actions { opacity: 1; }
|
||||
|
||||
/* ── Modal ───────────────────────────────────────────────────── */
|
||||
.overlay {
|
||||
position: fixed; inset: 0; z-index: 80; display: grid; place-items: center; padding: 20px;
|
||||
position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; padding: 20px;
|
||||
background: rgba(8,13,22,.5); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
|
||||
animation: fade-in .2s var(--ease);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user