- 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.
8.9 KiB
8.9 KiB
name, description
| name | description |
|---|---|
| add-admin-page | 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:
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:
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:
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?.dataunless the endpoint is a$this->success(['data' => ...])double-nest. Standard$this->success($array)→ extract withdata?.data.$this->paginated()→ items atdata?.data, total atdata?.meta?.totalRecords. - Stats from
$this->success($stats)may still be double-nested in older endpoints — use(statsQ.data?.data as any)?.data ?? statsQ.data?.datato handle both. - Category API (
/api/v1/categorys/{bundle}) is always triple-nested: extract withdata?.data?.data ?? []. - Search debounce is always 350ms via
setTimeoutin auseEffect. - Query keys follow the format
['admin-entity-name', page, limit, search, ...filters]. - After creating the page, run
ddev exec yarn devto check for TypeScript errors.