スキル一覧に戻る

admin-crud

Mavrick91 / tanstack-start-app

0🍴 0📅 2026年1月10日

Generate admin dashboard pages with data tables, filters, bulk actions, dialogs, and forms. Use when building admin interfaces, management pages, or dashboard components.

SKILL.md

---
name: admin-crud
description: Generate admin dashboard pages with data tables, filters, bulk actions, dialogs, and forms. Use when building admin interfaces, management pages, or dashboard components.
---

# Admin CRUD Generator

Create admin dashboard pages following this project's established patterns.

## Admin Page Structure

```
src/
├── routes/admin/
│   └── resources/
│       ├── index.tsx          # List page
│       └── $resourceId.tsx    # Detail/edit page
└── components/admin/
    └── resources/
        ├── ResourcesList.tsx        # List container
        ├── ResourceForm.tsx         # Create/edit form
        └── components/
            ├── ResourceTable.tsx    # Data table
            ├── StatusBadge.tsx      # Status indicator
            ├── BulkActionsBar.tsx   # Bulk operations
            └── ResourceActions.tsx  # Row actions
```

## List Page Template

```typescript
// src/routes/admin/resources/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { ResourcesList } from '@/components/admin/resources/ResourcesList'

export const Route = createFileRoute('/admin/resources/')({
  component: ResourcesPage,
})

function ResourcesPage() {
  return <ResourcesList />
}
```

## List Component with Data Table

```typescript
// src/components/admin/resources/ResourcesList.tsx
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Plus } from 'lucide-react'
import { Link } from '@tanstack/react-router'

import { Button } from '@/components/ui/button'
import { ResourceTable } from './components/ResourceTable'

export function ResourcesList() {
  const { t } = useTranslation()

  const { data, isLoading, error } = useQuery({
    queryKey: ['resources'],
    queryFn: async () => {
      const res = await fetch('/api/resources', { credentials: 'include' })
      const json = await res.json()
      if (!json.success) throw new Error(json.error)
      return json
    },
  })

  if (isLoading) {
    return (
      <div className="flex items-center justify-center h-64">
        <div
          className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-pink-500"
          role="status"
          aria-label="Loading"
        />
      </div>
    )
  }

  if (error) {
    return (
      <div className="text-center py-12">
        <p className="text-red-500">{t('Failed to load')}</p>
      </div>
    )
  }

  const { items, total } = data

  if (items.length === 0) {
    return (
      <div className="text-center py-12">
        <Package className="mx-auto h-12 w-12 text-muted-foreground" />
        <h3 className="mt-2 text-sm font-semibold">{t('No resources')}</h3>
        <p className="mt-1 text-sm text-muted-foreground">
          {t('Get started by creating a new resource.')}
        </p>
        <div className="mt-6">
          <Button asChild>
            <Link to="/admin/resources/new">
              <Plus className="mr-2 h-4 w-4" />
              {t('Add Resource')}
            </Link>
          </Button>
        </div>
      </div>
    )
  }

  return (
    <div className="space-y-4">
      <div className="flex items-center justify-between">
        <h1 className="text-2xl font-bold">{t('Resources')}</h1>
        <Button asChild>
          <Link to="/admin/resources/new">
            <Plus className="mr-2 h-4 w-4" />
            {t('Add Resource')}
          </Link>
        </Button>
      </div>

      <ResourceTable resources={items} />

      <div className="text-sm text-muted-foreground">
        {t('{{count}} total', { count: total })}
      </div>
    </div>
  )
}
```

## Data Table Component

```typescript
// src/components/admin/resources/components/ResourceTable.tsx
import { useState } from 'react'
import { Link } from '@tanstack/react-router'
import { MoreHorizontal, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'
import { useTranslation } from 'react-i18next'

import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { StatusBadge } from './StatusBadge'

interface Resource {
  id: string
  name: { en: string }
  status: 'active' | 'draft' | 'archived'
  createdAt: string
}

interface Props {
  resources: Resource[]
}

export function ResourceTable({ resources }: Props) {
  const { t } = useTranslation()
  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
  const [sortKey, setSortKey] = useState<string>('createdAt')
  const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc')

  const toggleSelect = (id: string) => {
    const next = new Set(selectedIds)
    if (next.has(id)) {
      next.delete(id)
    } else {
      next.add(id)
    }
    setSelectedIds(next)
  }

  const toggleSelectAll = () => {
    if (selectedIds.size === resources.length) {
      setSelectedIds(new Set())
    } else {
      setSelectedIds(new Set(resources.map((r) => r.id)))
    }
  }

  const isAllSelected = selectedIds.size === resources.length
  const isSomeSelected = selectedIds.size > 0 && selectedIds.size < resources.length

  const handleSort = (key: string) => {
    if (sortKey === key) {
      setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
    } else {
      setSortKey(key)
      setSortOrder('desc')
    }
  }

  const SortIcon = ({ columnKey }: { columnKey: string }) => {
    if (sortKey !== columnKey) return <ArrowUpDown className="ml-2 h-4 w-4" />
    return sortOrder === 'asc'
      ? <ArrowUp className="ml-2 h-4 w-4" />
      : <ArrowDown className="ml-2 h-4 w-4" />
  }

  return (
    <>
      {selectedIds.size > 0 && (
        <BulkActionsBar
          selectedCount={selectedIds.size}
          onClearSelection={() => setSelectedIds(new Set())}
        />
      )}

      <div className="rounded-md border">
        <table className="w-full">
          <thead>
            <tr className="border-b bg-muted/50">
              <th className="w-12 p-4">
                <Checkbox
                  checked={isAllSelected}
                  indeterminate={isSomeSelected}
                  onCheckedChange={toggleSelectAll}
                />
              </th>
              <th className="p-4 text-left">
                <button
                  className="flex items-center font-medium"
                  onClick={() => handleSort('name')}
                >
                  {t('Name')}
                  <SortIcon columnKey="name" />
                </button>
              </th>
              <th className="p-4 text-left">{t('Status')}</th>
              <th className="p-4 text-left">
                <button
                  className="flex items-center font-medium"
                  onClick={() => handleSort('createdAt')}
                >
                  {t('Created')}
                  <SortIcon columnKey="createdAt" />
                </button>
              </th>
              <th className="w-12 p-4"></th>
            </tr>
          </thead>
          <tbody>
            {resources.map((resource) => (
              <tr
                key={resource.id}
                className={`border-b hover:bg-muted/50 group ${
                  selectedIds.has(resource.id) ? 'bg-pink-500/5' : ''
                }`}
              >
                <td className="p-4">
                  <Checkbox
                    checked={selectedIds.has(resource.id)}
                    onCheckedChange={() => toggleSelect(resource.id)}
                  />
                </td>
                <td className="p-4">
                  <Link
                    to="/admin/resources/$resourceId"
                    params={{ resourceId: resource.id }}
                    className="font-medium hover:underline"
                  >
                    {resource.name.en}
                  </Link>
                </td>
                <td className="p-4">
                  <StatusBadge status={resource.status} />
                </td>
                <td className="p-4 text-muted-foreground">
                  {new Date(resource.createdAt).toLocaleDateString()}
                </td>
                <td className="p-4">
                  <DropdownMenu>
                    <DropdownMenuTrigger asChild>
                      <Button variant="ghost" size="icon">
                        <MoreHorizontal className="h-4 w-4" />
                      </Button>
                    </DropdownMenuTrigger>
                    <DropdownMenuContent align="end">
                      <DropdownMenuItem asChild>
                        <Link
                          to="/admin/resources/$resourceId"
                          params={{ resourceId: resource.id }}
                        >
                          {t('Edit')}
                        </Link>
                      </DropdownMenuItem>
                      <DropdownMenuItem className="text-destructive">
                        {t('Delete')}
                      </DropdownMenuItem>
                    </DropdownMenuContent>
                  </DropdownMenu>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </>
  )
}
```

## Status Badge

```typescript
// src/components/admin/resources/components/StatusBadge.tsx
import { cn } from '@/lib/utils'

const statusStyles = {
  active: 'bg-emerald-500/10 text-emerald-500',
  draft: 'bg-amber-500/10 text-amber-500',
  archived: 'bg-muted text-muted-foreground',
  pending: 'bg-blue-500/10 text-blue-500',
  processing: 'bg-purple-500/10 text-purple-500',
  shipped: 'bg-cyan-500/10 text-cyan-500',
  delivered: 'bg-emerald-500/10 text-emerald-500',
  cancelled: 'bg-red-500/10 text-red-500',
  paid: 'bg-emerald-500/10 text-emerald-500',
  failed: 'bg-red-500/10 text-red-500',
  refunded: 'bg-amber-500/10 text-amber-500',
}

interface Props {
  status: keyof typeof statusStyles
}

export function StatusBadge({ status }: Props) {
  return (
    <span
      className={cn(
        'inline-flex items-center gap-1.5 rounded-full px-2 py-1 text-xs font-medium uppercase',
        statusStyles[status] || statusStyles.draft
      )}
    >
      <span className="h-1.5 w-1.5 rounded-full bg-current" />
      {status}
    </span>
  )
}
```

## Bulk Actions Bar

```typescript
// src/components/admin/resources/components/BulkActionsBar.tsx
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'

import { Button } from '@/components/ui/button'

interface Props {
  selectedCount: number
  selectedIds: string[]
  onClearSelection: () => void
}

export function BulkActionsBar({ selectedCount, selectedIds, onClearSelection }: Props) {
  const { t } = useTranslation()
  const queryClient = useQueryClient()

  const bulkUpdate = useMutation({
    mutationFn: async (action: 'activate' | 'archive' | 'delete') => {
      const res = await fetch('/api/resources/bulk', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'include',
        body: JSON.stringify({ ids: selectedIds, action }),
      })
      const json = await res.json()
      if (!json.success) throw new Error(json.error)
      return json
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['resources'] })
      onClearSelection()
      toast.success(t('Updated successfully'))
    },
    onError: (error) => {
      toast.error(error.message)
    },
  })

  return (
    <div className="flex items-center gap-4 rounded-lg border bg-muted/50 p-4">
      <span className="text-sm font-medium">
        {t('{{count}} selected', { count: selectedCount })}
      </span>
      <div className="flex gap-2">
        <Button
          size="sm"
          variant="outline"
          onClick={() => bulkUpdate.mutate('activate')}
          disabled={bulkUpdate.isPending}
        >
          {t('Activate')}
        </Button>
        <Button
          size="sm"
          variant="outline"
          onClick={() => bulkUpdate.mutate('archive')}
          disabled={bulkUpdate.isPending}
        >
          {t('Archive')}
        </Button>
        <Button
          size="sm"
          variant="destructive"
          onClick={() => bulkUpdate.mutate('delete')}
          disabled={bulkUpdate.isPending}
        >
          {t('Delete')}
        </Button>
      </div>
      <Button
        size="sm"
        variant="ghost"
        onClick={onClearSelection}
        className="ml-auto"
      >
        {t('Clear selection')}
      </Button>
    </div>
  )
}
```

## Card-Based Form Layout

```typescript
// Product form pattern with gradient accent cards
<div className="grid lg:grid-cols-3 gap-6">
  {/* Main content - 2 columns */}
  <div className="lg:col-span-2 space-y-6">
    {/* Details Card */}
    <Card className="border-border/50 shadow-xl shadow-foreground/5 bg-card/50 backdrop-blur-sm overflow-hidden">
      <div className="h-1 bg-gradient-to-r from-pink-500 to-purple-500" />
      <CardHeader>
        <CardTitle>{t('Details')}</CardTitle>
      </CardHeader>
      <CardContent>
        {/* Form fields */}
      </CardContent>
    </Card>

    {/* Media Card */}
    <Card className="overflow-hidden">
      <div className="h-1 bg-gradient-to-r from-violet-500 to-fuchsia-500" />
      <CardHeader>
        <CardTitle>{t('Media')}</CardTitle>
      </CardHeader>
      <CardContent>
        {/* Image uploader */}
      </CardContent>
    </Card>
  </div>

  {/* Sidebar - 1 column, sticky */}
  <div className="space-y-6">
    <div className="lg:sticky lg:top-4">
      {/* Status Card */}
      <Card>
        <div className="h-1 bg-gradient-to-r from-emerald-500 to-teal-500" />
        <CardHeader>
          <CardTitle>{t('Status')}</CardTitle>
        </CardHeader>
        <CardContent>
          {/* Status select */}
        </CardContent>
      </Card>
    </div>
  </div>
</div>
```

## Gradient Accent Colors

| Section  | Gradient                         |
| -------- | -------------------------------- |
| Details  | `from-pink-500 to-purple-500`    |
| Media    | `from-violet-500 to-fuchsia-500` |
| Options  | `from-blue-500 to-cyan-500`      |
| Variants | `from-emerald-500 to-teal-500`   |
| SEO      | `from-amber-500 to-orange-500`   |

## Confirmation Dialog

```typescript
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from '@/components/ui/alert-dialog'

function DeleteConfirmDialog({ open, onOpenChange, onConfirm, resourceName }) {
  const { t } = useTranslation()

  return (
    <AlertDialog open={open} onOpenChange={onOpenChange}>
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>{t('Delete Resource')}</AlertDialogTitle>
          <AlertDialogDescription>
            {t('Are you sure you want to delete "{{name}}"? This action cannot be undone.', {
              name: resourceName,
            })}
          </AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
          <AlertDialogAction
            onClick={onConfirm}
            className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
          >
            {t('Delete')}
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  )
}
```

## See Also

- `src/components/admin/products/` - Full product CRUD example
- `src/components/admin/orders/` - Order management
- `src/hooks/useDataTable.ts` - Table state management
- `forms` skill - Form patterns with FNForm