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:
hamed
2026-06-11 09:28:51 +03:30
parent af0ae51987
commit e88ae9bf9c
7 changed files with 1475 additions and 955 deletions
+447
View File
@@ -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.
+220
View File
@@ -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.
+15
View File
@@ -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.
+649 -902
View File
File diff suppressed because it is too large Load Diff
+74 -52
View File
@@ -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 */}
+1 -1
View File
@@ -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);
}