- 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.
221 lines
8.9 KiB
Markdown
221 lines
8.9 KiB
Markdown
---
|
|
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.
|