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,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.
|
||||
Reference in New Issue
Block a user