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
@@ -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.