Files
clinicpro/.claude/skills/add-admin-page/SKILL.md
T
hamed e88ae9bf9c 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.
2026-06-11 09:28:51 +03:30

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