Add click-to-edit pattern across all admin tables
Replace Actions columns with clickable rows that open edit modals directly. Delete is now an inline two-step confirm button in the edit modal footer. Games modal links to routes/bosses detail, route modal links to encounters, and boss modal has an Edit Team button. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { AdminTable, type Column } from '../../components/admin/AdminTable'
|
||||
import { EvolutionFormModal } from '../../components/admin/EvolutionFormModal'
|
||||
import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
|
||||
import {
|
||||
useEvolutionList,
|
||||
useCreateEvolution,
|
||||
@@ -29,7 +28,6 @@ export function AdminEvolutions() {
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [editing, setEditing] = useState<EvolutionAdmin | null>(null)
|
||||
const [deleting, setDeleting] = useState<EvolutionAdmin | null>(null)
|
||||
|
||||
const columns: Column<EvolutionAdmin>[] = [
|
||||
{
|
||||
@@ -57,25 +55,6 @@ export function AdminEvolutions() {
|
||||
{ header: 'Trigger', accessor: (e) => e.trigger },
|
||||
{ header: 'Level', accessor: (e) => e.minLevel ?? '-' },
|
||||
{ header: 'Item', accessor: (e) => e.item ?? '-' },
|
||||
{
|
||||
header: 'Actions',
|
||||
accessor: (e) => (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setEditing(e)}
|
||||
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 text-sm"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleting(e)}
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-400 text-sm"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -123,6 +102,7 @@ export function AdminEvolutions() {
|
||||
isLoading={isLoading}
|
||||
emptyMessage="No evolutions found."
|
||||
keyFn={(e) => e.id}
|
||||
onRowClick={(e) => setEditing(e)}
|
||||
/>
|
||||
|
||||
{totalPages > 1 && (
|
||||
@@ -189,19 +169,11 @@ export function AdminEvolutions() {
|
||||
}
|
||||
onClose={() => setEditing(null)}
|
||||
isSubmitting={updateEvolution.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleting && (
|
||||
<DeleteConfirmModal
|
||||
title={`Delete evolution?`}
|
||||
message={`This will permanently delete the evolution from ${deleting.fromPokemon.name} to ${deleting.toPokemon.name}.`}
|
||||
onConfirm={() =>
|
||||
deleteEvolution.mutate(deleting.id, {
|
||||
onSuccess: () => setDeleting(null),
|
||||
onDelete={() =>
|
||||
deleteEvolution.mutate(editing.id, {
|
||||
onSuccess: () => setEditing(null),
|
||||
})
|
||||
}
|
||||
onCancel={() => setDeleting(null)}
|
||||
isDeleting={deleteEvolution.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { RouteFormModal } from '../../components/admin/RouteFormModal'
|
||||
import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
|
||||
import { BossBattleFormModal } from '../../components/admin/BossBattleFormModal'
|
||||
import { BossTeamEditor } from '../../components/admin/BossTeamEditor'
|
||||
import { useGame } from '../../hooks/useGames'
|
||||
@@ -39,13 +38,9 @@ import type { CreateBossBattleInput, UpdateBossBattleInput } from '../../types/a
|
||||
|
||||
function SortableRouteRow({
|
||||
route,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onClick,
|
||||
}: {
|
||||
route: GameRoute
|
||||
onEdit: (r: GameRoute) => void
|
||||
onDelete: (r: GameRoute) => void
|
||||
onClick: (r: GameRoute) => void
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||
@@ -83,29 +78,12 @@ function SortableRouteRow({
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap w-16">{route.order}</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap">{route.name}</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap w-32">
|
||||
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => onEdit(route)}
|
||||
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 text-sm"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(route)}
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-400 text-sm"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdminGameDetail() {
|
||||
const { gameId } = useParams<{ gameId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const id = Number(gameId)
|
||||
const { data: game, isLoading } = useGame(id)
|
||||
|
||||
@@ -121,10 +99,8 @@ export function AdminGameDetail() {
|
||||
const [tab, setTab] = useState<'routes' | 'bosses'>('routes')
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [editing, setEditing] = useState<GameRoute | null>(null)
|
||||
const [deleting, setDeleting] = useState<GameRoute | null>(null)
|
||||
const [showCreateBoss, setShowCreateBoss] = useState(false)
|
||||
const [editingBoss, setEditingBoss] = useState<BossBattle | null>(null)
|
||||
const [deletingBoss, setDeletingBoss] = useState<BossBattle | null>(null)
|
||||
const [editingTeam, setEditingTeam] = useState<BossBattle | null>(null)
|
||||
|
||||
const sensors = useSensors(
|
||||
@@ -235,9 +211,6 @@ export function AdminGameDetail() {
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-32">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<DndContext
|
||||
@@ -254,9 +227,7 @@ export function AdminGameDetail() {
|
||||
<SortableRouteRow
|
||||
key={route.id}
|
||||
route={route}
|
||||
onEdit={setEditing}
|
||||
onDelete={setDeleting}
|
||||
onClick={(r) => navigate(`/admin/games/${id}/routes/${r.id}`)}
|
||||
onClick={(r) => setEditing(r)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -291,20 +262,13 @@ export function AdminGameDetail() {
|
||||
}
|
||||
onClose={() => setEditing(null)}
|
||||
isSubmitting={updateRoute.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleting && (
|
||||
<DeleteConfirmModal
|
||||
title={`Delete ${deleting.name}?`}
|
||||
message="This will permanently delete the route. Routes with existing encounters cannot be deleted."
|
||||
onConfirm={() =>
|
||||
deleteRoute.mutate(deleting.id, {
|
||||
onSuccess: () => setDeleting(null),
|
||||
onDelete={() =>
|
||||
deleteRoute.mutate(editing.id, {
|
||||
onSuccess: () => setEditing(null),
|
||||
})
|
||||
}
|
||||
onCancel={() => setDeleting(null)}
|
||||
isDeleting={deleteRoute.isPending}
|
||||
detailUrl={`/admin/games/${id}/routes/${editing.id}`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -358,14 +322,15 @@ export function AdminGameDetail() {
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-16">
|
||||
Team
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-40">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{bosses.map((boss) => (
|
||||
<tr key={boss.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<tr
|
||||
key={boss.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer"
|
||||
onClick={() => setEditingBoss(boss)}
|
||||
>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.order}</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap font-medium">{boss.name}</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap capitalize">
|
||||
@@ -374,28 +339,6 @@ export function AdminGameDetail() {
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.location}</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.levelCap}</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.pokemon.length}</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setEditingTeam(boss)}
|
||||
className="text-green-600 hover:text-green-800 dark:text-green-400 text-sm"
|
||||
>
|
||||
Team
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingBoss(boss)}
|
||||
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 text-sm"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletingBoss(boss)}
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-400 text-sm"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -434,20 +377,16 @@ export function AdminGameDetail() {
|
||||
}
|
||||
onClose={() => setEditingBoss(null)}
|
||||
isSubmitting={updateBoss.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deletingBoss && (
|
||||
<DeleteConfirmModal
|
||||
title={`Delete ${deletingBoss.name}?`}
|
||||
message="This will permanently delete this boss battle and its pokemon team."
|
||||
onConfirm={() =>
|
||||
deleteBoss.mutate(deletingBoss.id, {
|
||||
onSuccess: () => setDeletingBoss(null),
|
||||
onDelete={() =>
|
||||
deleteBoss.mutate(editingBoss.id, {
|
||||
onSuccess: () => setEditingBoss(null),
|
||||
})
|
||||
}
|
||||
onCancel={() => setDeletingBoss(null)}
|
||||
isDeleting={deleteBoss.isPending}
|
||||
onEditTeam={() => {
|
||||
setEditingTeam(editingBoss)
|
||||
setEditingBoss(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { AdminTable, type Column } from '../../components/admin/AdminTable'
|
||||
import { GameFormModal } from '../../components/admin/GameFormModal'
|
||||
import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
|
||||
import { useGames } from '../../hooks/useGames'
|
||||
import { useCreateGame, useUpdateGame, useDeleteGame } from '../../hooks/useAdmin'
|
||||
import { exportGames } from '../../api/admin'
|
||||
@@ -10,7 +8,6 @@ import { downloadJson } from '../../utils/download'
|
||||
import type { Game, CreateGameInput, UpdateGameInput } from '../../types'
|
||||
|
||||
export function AdminGames() {
|
||||
const navigate = useNavigate()
|
||||
const { data: games = [], isLoading } = useGames()
|
||||
const createGame = useCreateGame()
|
||||
const updateGame = useUpdateGame()
|
||||
@@ -18,7 +15,6 @@ export function AdminGames() {
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [editing, setEditing] = useState<Game | null>(null)
|
||||
const [deleting, setDeleting] = useState<Game | null>(null)
|
||||
|
||||
const columns: Column<Game>[] = [
|
||||
{ header: 'Name', accessor: (g) => g.name },
|
||||
@@ -26,25 +22,6 @@ export function AdminGames() {
|
||||
{ header: 'Region', accessor: (g) => g.region, sortKey: (g) => g.region },
|
||||
{ header: 'Gen', accessor: (g) => g.generation, sortKey: (g) => g.generation },
|
||||
{ header: 'Year', accessor: (g) => g.releaseYear ?? '-', sortKey: (g) => g.releaseYear ?? 0 },
|
||||
{
|
||||
header: 'Actions',
|
||||
accessor: (g) => (
|
||||
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => setEditing(g)}
|
||||
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 text-sm"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleting(g)}
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-400 text-sm"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -75,7 +52,7 @@ export function AdminGames() {
|
||||
data={games}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="No games yet. Add one to get started."
|
||||
onRowClick={(g) => navigate(`/admin/games/${g.id}`)}
|
||||
onRowClick={(g) => setEditing(g)}
|
||||
keyFn={(g) => g.id}
|
||||
/>
|
||||
|
||||
@@ -102,20 +79,13 @@ export function AdminGames() {
|
||||
}
|
||||
onClose={() => setEditing(null)}
|
||||
isSubmitting={updateGame.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleting && (
|
||||
<DeleteConfirmModal
|
||||
title={`Delete ${deleting.name}?`}
|
||||
message="This will permanently delete the game and all its routes. Games with existing runs cannot be deleted."
|
||||
onConfirm={() =>
|
||||
deleteGame.mutate(deleting.id, {
|
||||
onSuccess: () => setDeleting(null),
|
||||
onDelete={() =>
|
||||
deleteGame.mutate(editing.id, {
|
||||
onSuccess: () => setEditing(null),
|
||||
})
|
||||
}
|
||||
onCancel={() => setDeleting(null)}
|
||||
isDeleting={deleteGame.isPending}
|
||||
detailUrl={`/admin/games/${editing.id}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState } from 'react'
|
||||
import { AdminTable, type Column } from '../../components/admin/AdminTable'
|
||||
import { PokemonFormModal } from '../../components/admin/PokemonFormModal'
|
||||
import { BulkImportModal } from '../../components/admin/BulkImportModal'
|
||||
import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
|
||||
import {
|
||||
usePokemonList,
|
||||
useCreatePokemon,
|
||||
@@ -32,7 +31,6 @@ export function AdminPokemon() {
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [showBulkImport, setShowBulkImport] = useState(false)
|
||||
const [editing, setEditing] = useState<Pokemon | null>(null)
|
||||
const [deleting, setDeleting] = useState<Pokemon | null>(null)
|
||||
|
||||
const columns: Column<Pokemon>[] = [
|
||||
{ header: 'Dex #', accessor: (p) => p.nationalDex, className: 'w-16' },
|
||||
@@ -48,25 +46,6 @@ export function AdminPokemon() {
|
||||
},
|
||||
{ header: 'Name', accessor: (p) => p.name },
|
||||
{ header: 'Types', accessor: (p) => p.types.join(', ') },
|
||||
{
|
||||
header: 'Actions',
|
||||
accessor: (p) => (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setEditing(p)}
|
||||
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 text-sm"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleting(p)}
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-400 text-sm"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -120,6 +99,7 @@ export function AdminPokemon() {
|
||||
isLoading={isLoading}
|
||||
emptyMessage="No pokemon found."
|
||||
keyFn={(p) => p.id}
|
||||
onRowClick={(p) => setEditing(p)}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
@@ -194,19 +174,11 @@ export function AdminPokemon() {
|
||||
}
|
||||
onClose={() => setEditing(null)}
|
||||
isSubmitting={updatePokemon.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleting && (
|
||||
<DeleteConfirmModal
|
||||
title={`Delete ${deleting.name}?`}
|
||||
message="This will permanently delete the pokemon. Pokemon with existing encounters cannot be deleted."
|
||||
onConfirm={() =>
|
||||
deletePokemon.mutate(deleting.id, {
|
||||
onSuccess: () => setDeleting(null),
|
||||
onDelete={() =>
|
||||
deletePokemon.mutate(editing.id, {
|
||||
onSuccess: () => setEditing(null),
|
||||
})
|
||||
}
|
||||
onCancel={() => setDeleting(null)}
|
||||
isDeleting={deletePokemon.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { AdminTable, type Column } from '../../components/admin/AdminTable'
|
||||
import { RouteEncounterFormModal } from '../../components/admin/RouteEncounterFormModal'
|
||||
import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
|
||||
import { useGame, useRoutePokemon } from '../../hooks/useGames'
|
||||
import {
|
||||
useAddRouteEncounter,
|
||||
@@ -29,7 +28,6 @@ export function AdminRouteDetail() {
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [editing, setEditing] = useState<RouteEncounterDetail | null>(null)
|
||||
const [deleting, setDeleting] = useState<RouteEncounterDetail | null>(null)
|
||||
|
||||
const route = game?.routes?.find((r) => r.id === rId)
|
||||
|
||||
@@ -54,25 +52,6 @@ export function AdminRouteDetail() {
|
||||
accessor: (e) =>
|
||||
e.minLevel === e.maxLevel ? `Lv ${e.minLevel}` : `Lv ${e.minLevel}-${e.maxLevel}`,
|
||||
},
|
||||
{
|
||||
header: 'Actions',
|
||||
accessor: (e) => (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setEditing(e)}
|
||||
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 text-sm"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleting(e)}
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-400 text-sm"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -109,6 +88,7 @@ export function AdminRouteDetail() {
|
||||
isLoading={isLoading}
|
||||
emptyMessage="No pokemon assigned to this route yet."
|
||||
keyFn={(e) => e.id}
|
||||
onRowClick={(e) => setEditing(e)}
|
||||
/>
|
||||
|
||||
{showCreate && (
|
||||
@@ -134,19 +114,11 @@ export function AdminRouteDetail() {
|
||||
}
|
||||
onClose={() => setEditing(null)}
|
||||
isSubmitting={updateEncounter.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleting && (
|
||||
<DeleteConfirmModal
|
||||
title={`Remove ${deleting.pokemon.name}?`}
|
||||
message="This will remove this pokemon from the route's encounter table."
|
||||
onConfirm={() =>
|
||||
removeEncounter.mutate(deleting.id, {
|
||||
onSuccess: () => setDeleting(null),
|
||||
onDelete={() =>
|
||||
removeEncounter.mutate(editing.id, {
|
||||
onSuccess: () => setEditing(null),
|
||||
})
|
||||
}
|
||||
onCancel={() => setDeleting(null)}
|
||||
isDeleting={removeEncounter.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -46,19 +46,6 @@ export function AdminRuns() {
|
||||
accessor: (r) => new Date(r.startedAt).toLocaleDateString(),
|
||||
sortKey: (r) => r.startedAt,
|
||||
},
|
||||
{
|
||||
header: 'Actions',
|
||||
accessor: (r) => (
|
||||
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => setDeleting(r)}
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-400 text-sm"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -73,6 +60,7 @@ export function AdminRuns() {
|
||||
isLoading={runsLoading || gamesLoading}
|
||||
emptyMessage="No runs yet."
|
||||
keyFn={(r) => r.id}
|
||||
onRowClick={(r) => setDeleting(r)}
|
||||
/>
|
||||
|
||||
{deleting && (
|
||||
|
||||
Reference in New Issue
Block a user