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