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:
2026-02-08 13:44:38 +01:00
parent 76d69dfaf1
commit f09b8213fd
14 changed files with 145 additions and 228 deletions

View File

@@ -0,0 +1,11 @@
---
# nuzlocke-tracker-z511
title: Click-to-edit pattern across admin tables
status: completed
type: feature
priority: normal
created_at: 2026-02-08T12:39:48Z
updated_at: 2026-02-08T12:44:09Z
---
Remove Actions columns from all admin tables. Row click opens edit modal. Delete moves into edit modal footer with inline two-step confirm. See plan for full details.

View File

@@ -10,6 +10,9 @@ interface BossBattleFormModalProps {
onSubmit: (data: CreateBossBattleInput | UpdateBossBattleInput) => void onSubmit: (data: CreateBossBattleInput | UpdateBossBattleInput) => void
onClose: () => void onClose: () => void
isSubmitting?: boolean isSubmitting?: boolean
onDelete?: () => void
isDeleting?: boolean
onEditTeam?: () => void
} }
const BOSS_TYPES = [ const BOSS_TYPES = [
@@ -28,6 +31,9 @@ export function BossBattleFormModal({
onSubmit, onSubmit,
onClose, onClose,
isSubmitting, isSubmitting,
onDelete,
isDeleting,
onEditTeam,
}: BossBattleFormModalProps) { }: BossBattleFormModalProps) {
const [name, setName] = useState(boss?.name ?? '') const [name, setName] = useState(boss?.name ?? '')
const [bossType, setBossType] = useState(boss?.bossType ?? 'gym_leader') const [bossType, setBossType] = useState(boss?.bossType ?? 'gym_leader')
@@ -63,6 +69,17 @@ export function BossBattleFormModal({
onClose={onClose} onClose={onClose}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
onDelete={onDelete}
isDeleting={isDeleting}
headerExtra={onEditTeam ? (
<button
type="button"
onClick={onEditTeam}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
Edit Team ({boss?.pokemon.length ?? 0})
</button>
) : undefined}
> >
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>

View File

@@ -8,6 +8,8 @@ interface EvolutionFormModalProps {
onSubmit: (data: CreateEvolutionInput | UpdateEvolutionInput) => void onSubmit: (data: CreateEvolutionInput | UpdateEvolutionInput) => void
onClose: () => void onClose: () => void
isSubmitting?: boolean isSubmitting?: boolean
onDelete?: () => void
isDeleting?: boolean
} }
const TRIGGER_OPTIONS = ['level-up', 'trade', 'use-item', 'shed', 'other'] const TRIGGER_OPTIONS = ['level-up', 'trade', 'use-item', 'shed', 'other']
@@ -17,6 +19,8 @@ export function EvolutionFormModal({
onSubmit, onSubmit,
onClose, onClose,
isSubmitting, isSubmitting,
onDelete,
isDeleting,
}: EvolutionFormModalProps) { }: EvolutionFormModalProps) {
const [fromPokemonId, setFromPokemonId] = useState<number | null>( const [fromPokemonId, setFromPokemonId] = useState<number | null>(
evolution?.fromPokemonId ?? null, evolution?.fromPokemonId ?? null,
@@ -52,6 +56,8 @@ export function EvolutionFormModal({
onClose={onClose} onClose={onClose}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
onDelete={onDelete}
isDeleting={isDeleting}
> >
<PokemonSelector <PokemonSelector
label="From Pokemon" label="From Pokemon"

View File

@@ -1,4 +1,4 @@
import { type FormEvent, type ReactNode } from 'react' import { type FormEvent, type ReactNode, useState, useEffect } from 'react'
interface FormModalProps { interface FormModalProps {
title: string title: string
@@ -7,6 +7,9 @@ interface FormModalProps {
children: ReactNode children: ReactNode
submitLabel?: string submitLabel?: string
isSubmitting?: boolean isSubmitting?: boolean
onDelete?: () => void
isDeleting?: boolean
headerExtra?: ReactNode
} }
export function FormModal({ export function FormModal({
@@ -16,17 +19,46 @@ export function FormModal({
children, children,
submitLabel = 'Save', submitLabel = 'Save',
isSubmitting, isSubmitting,
onDelete,
isDeleting,
headerExtra,
}: FormModalProps) { }: FormModalProps) {
const [confirmingDelete, setConfirmingDelete] = useState(false)
// Reset confirm state when modal closes/reopens
useEffect(() => {
setConfirmingDelete(false)
}, [onDelete])
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} /> <div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto"> <div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700"> <div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold">{title}</h2> <h2 className="text-lg font-semibold">{title}</h2>
{headerExtra}
</div> </div>
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
<div className="px-6 py-4 space-y-4">{children}</div> <div className="px-6 py-4 space-y-4">{children}</div>
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3"> <div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center gap-3">
{onDelete && (
<button
type="button"
disabled={isDeleting}
onClick={() => {
if (confirmingDelete) {
onDelete()
} else {
setConfirmingDelete(true)
}
}}
onBlur={() => setConfirmingDelete(false)}
className="px-4 py-2 text-sm font-medium rounded-md text-red-600 dark:text-red-400 border border-red-300 dark:border-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50"
>
{isDeleting ? 'Deleting...' : confirmingDelete ? 'Confirm?' : 'Delete'}
</button>
)}
<div className="flex-1" />
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}

View File

@@ -1,4 +1,5 @@
import { type FormEvent, useState, useEffect } from 'react' import { type FormEvent, useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { FormModal } from './FormModal' import { FormModal } from './FormModal'
import type { Game, CreateGameInput, UpdateGameInput } from '../../types' import type { Game, CreateGameInput, UpdateGameInput } from '../../types'
@@ -7,6 +8,9 @@ interface GameFormModalProps {
onSubmit: (data: CreateGameInput | UpdateGameInput) => void onSubmit: (data: CreateGameInput | UpdateGameInput) => void
onClose: () => void onClose: () => void
isSubmitting?: boolean isSubmitting?: boolean
onDelete?: () => void
isDeleting?: boolean
detailUrl?: string
} }
function slugify(name: string) { function slugify(name: string) {
@@ -16,7 +20,7 @@ function slugify(name: string) {
.replace(/^-|-$/g, '') .replace(/^-|-$/g, '')
} }
export function GameFormModal({ game, onSubmit, onClose, isSubmitting }: GameFormModalProps) { export function GameFormModal({ game, onSubmit, onClose, isSubmitting, onDelete, isDeleting, detailUrl }: GameFormModalProps) {
const [name, setName] = useState(game?.name ?? '') const [name, setName] = useState(game?.name ?? '')
const [slug, setSlug] = useState(game?.slug ?? '') const [slug, setSlug] = useState(game?.slug ?? '')
const [generation, setGeneration] = useState(String(game?.generation ?? '')) const [generation, setGeneration] = useState(String(game?.generation ?? ''))
@@ -47,6 +51,16 @@ export function GameFormModal({ game, onSubmit, onClose, isSubmitting }: GameFor
onClose={onClose} onClose={onClose}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
onDelete={onDelete}
isDeleting={isDeleting}
headerExtra={detailUrl ? (
<Link
to={detailUrl}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
View Routes & Bosses
</Link>
) : undefined}
> >
<div> <div>
<label className="block text-sm font-medium mb-1">Name</label> <label className="block text-sm font-medium mb-1">Name</label>

View File

@@ -7,9 +7,11 @@ interface PokemonFormModalProps {
onSubmit: (data: CreatePokemonInput | UpdatePokemonInput) => void onSubmit: (data: CreatePokemonInput | UpdatePokemonInput) => void
onClose: () => void onClose: () => void
isSubmitting?: boolean isSubmitting?: boolean
onDelete?: () => void
isDeleting?: boolean
} }
export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting }: PokemonFormModalProps) { export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onDelete, isDeleting }: PokemonFormModalProps) {
const [pokeapiId, setPokeapiId] = useState(String(pokemon?.pokeapiId ?? '')) const [pokeapiId, setPokeapiId] = useState(String(pokemon?.pokeapiId ?? ''))
const [nationalDex, setNationalDex] = useState(String(pokemon?.nationalDex ?? '')) const [nationalDex, setNationalDex] = useState(String(pokemon?.nationalDex ?? ''))
const [name, setName] = useState(pokemon?.name ?? '') const [name, setName] = useState(pokemon?.name ?? '')
@@ -37,6 +39,8 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting }: P
onClose={onClose} onClose={onClose}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
onDelete={onDelete}
isDeleting={isDeleting}
> >
<div> <div>
<label className="block text-sm font-medium mb-1">PokeAPI ID</label> <label className="block text-sm font-medium mb-1">PokeAPI ID</label>

View File

@@ -8,6 +8,8 @@ interface RouteEncounterFormModalProps {
onSubmit: (data: CreateRouteEncounterInput | UpdateRouteEncounterInput) => void onSubmit: (data: CreateRouteEncounterInput | UpdateRouteEncounterInput) => void
onClose: () => void onClose: () => void
isSubmitting?: boolean isSubmitting?: boolean
onDelete?: () => void
isDeleting?: boolean
} }
export function RouteEncounterFormModal({ export function RouteEncounterFormModal({
@@ -15,6 +17,8 @@ export function RouteEncounterFormModal({
onSubmit, onSubmit,
onClose, onClose,
isSubmitting, isSubmitting,
onDelete,
isDeleting,
}: RouteEncounterFormModalProps) { }: RouteEncounterFormModalProps) {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [pokemonId, setPokemonId] = useState(encounter?.pokemonId ?? 0) const [pokemonId, setPokemonId] = useState(encounter?.pokemonId ?? 0)
@@ -52,6 +56,8 @@ export function RouteEncounterFormModal({
onClose={onClose} onClose={onClose}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
onDelete={onDelete}
isDeleting={isDeleting}
> >
{!encounter && ( {!encounter && (
<div> <div>

View File

@@ -1,4 +1,5 @@
import { type FormEvent, useState } from 'react' import { type FormEvent, useState } from 'react'
import { Link } from 'react-router-dom'
import { FormModal } from './FormModal' import { FormModal } from './FormModal'
import type { Route, CreateRouteInput, UpdateRouteInput } from '../../types' import type { Route, CreateRouteInput, UpdateRouteInput } from '../../types'
@@ -8,9 +9,12 @@ interface RouteFormModalProps {
onSubmit: (data: CreateRouteInput | UpdateRouteInput) => void onSubmit: (data: CreateRouteInput | UpdateRouteInput) => void
onClose: () => void onClose: () => void
isSubmitting?: boolean isSubmitting?: boolean
onDelete?: () => void
isDeleting?: boolean
detailUrl?: string
} }
export function RouteFormModal({ route, nextOrder, onSubmit, onClose, isSubmitting }: RouteFormModalProps) { export function RouteFormModal({ route, nextOrder, onSubmit, onClose, isSubmitting, onDelete, isDeleting, detailUrl }: RouteFormModalProps) {
const [name, setName] = useState(route?.name ?? '') const [name, setName] = useState(route?.name ?? '')
const [order, setOrder] = useState(String(route?.order ?? nextOrder ?? 0)) const [order, setOrder] = useState(String(route?.order ?? nextOrder ?? 0))
const [pinwheelZone, setPinwheelZone] = useState( const [pinwheelZone, setPinwheelZone] = useState(
@@ -32,6 +36,16 @@ export function RouteFormModal({ route, nextOrder, onSubmit, onClose, isSubmitti
onClose={onClose} onClose={onClose}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
onDelete={onDelete}
isDeleting={isDeleting}
headerExtra={detailUrl ? (
<Link
to={detailUrl}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
View Encounters
</Link>
) : undefined}
> >
<div> <div>
<label className="block text-sm font-medium mb-1">Name</label> <label className="block text-sm font-medium mb-1">Name</label>

View File

@@ -1,7 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { AdminTable, type Column } from '../../components/admin/AdminTable' import { AdminTable, type Column } from '../../components/admin/AdminTable'
import { EvolutionFormModal } from '../../components/admin/EvolutionFormModal' import { EvolutionFormModal } from '../../components/admin/EvolutionFormModal'
import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
import { import {
useEvolutionList, useEvolutionList,
useCreateEvolution, useCreateEvolution,
@@ -29,7 +28,6 @@ export function AdminEvolutions() {
const [showCreate, setShowCreate] = useState(false) const [showCreate, setShowCreate] = useState(false)
const [editing, setEditing] = useState<EvolutionAdmin | null>(null) const [editing, setEditing] = useState<EvolutionAdmin | null>(null)
const [deleting, setDeleting] = useState<EvolutionAdmin | null>(null)
const columns: Column<EvolutionAdmin>[] = [ const columns: Column<EvolutionAdmin>[] = [
{ {
@@ -57,25 +55,6 @@ export function AdminEvolutions() {
{ header: 'Trigger', accessor: (e) => e.trigger }, { header: 'Trigger', accessor: (e) => e.trigger },
{ header: 'Level', accessor: (e) => e.minLevel ?? '-' }, { header: 'Level', accessor: (e) => e.minLevel ?? '-' },
{ header: 'Item', accessor: (e) => e.item ?? '-' }, { 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 ( return (
@@ -123,6 +102,7 @@ export function AdminEvolutions() {
isLoading={isLoading} isLoading={isLoading}
emptyMessage="No evolutions found." emptyMessage="No evolutions found."
keyFn={(e) => e.id} keyFn={(e) => e.id}
onRowClick={(e) => setEditing(e)}
/> />
{totalPages > 1 && ( {totalPages > 1 && (
@@ -189,19 +169,11 @@ export function AdminEvolutions() {
} }
onClose={() => setEditing(null)} onClose={() => setEditing(null)}
isSubmitting={updateEvolution.isPending} isSubmitting={updateEvolution.isPending}
/> onDelete={() =>
)} deleteEvolution.mutate(editing.id, {
onSuccess: () => setEditing(null),
{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),
}) })
} }
onCancel={() => setDeleting(null)}
isDeleting={deleteEvolution.isPending} isDeleting={deleteEvolution.isPending}
/> />
)} )}

View File

@@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { import {
DndContext, DndContext,
closestCenter, closestCenter,
@@ -17,7 +17,6 @@ import {
} from '@dnd-kit/sortable' } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities' import { CSS } from '@dnd-kit/utilities'
import { RouteFormModal } from '../../components/admin/RouteFormModal' import { RouteFormModal } from '../../components/admin/RouteFormModal'
import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
import { BossBattleFormModal } from '../../components/admin/BossBattleFormModal' import { BossBattleFormModal } from '../../components/admin/BossBattleFormModal'
import { BossTeamEditor } from '../../components/admin/BossTeamEditor' import { BossTeamEditor } from '../../components/admin/BossTeamEditor'
import { useGame } from '../../hooks/useGames' import { useGame } from '../../hooks/useGames'
@@ -39,13 +38,9 @@ import type { CreateBossBattleInput, UpdateBossBattleInput } from '../../types/a
function SortableRouteRow({ function SortableRouteRow({
route, route,
onEdit,
onDelete,
onClick, onClick,
}: { }: {
route: GameRoute route: GameRoute
onEdit: (r: GameRoute) => void
onDelete: (r: GameRoute) => void
onClick: (r: GameRoute) => void onClick: (r: GameRoute) => void
}) { }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
@@ -83,29 +78,12 @@ function SortableRouteRow({
</td> </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 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">{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> </tr>
) )
} }
export function AdminGameDetail() { export function AdminGameDetail() {
const { gameId } = useParams<{ gameId: string }>() const { gameId } = useParams<{ gameId: string }>()
const navigate = useNavigate()
const id = Number(gameId) const id = Number(gameId)
const { data: game, isLoading } = useGame(id) const { data: game, isLoading } = useGame(id)
@@ -121,10 +99,8 @@ export function AdminGameDetail() {
const [tab, setTab] = useState<'routes' | 'bosses'>('routes') const [tab, setTab] = useState<'routes' | 'bosses'>('routes')
const [showCreate, setShowCreate] = useState(false) const [showCreate, setShowCreate] = useState(false)
const [editing, setEditing] = useState<GameRoute | null>(null) const [editing, setEditing] = useState<GameRoute | null>(null)
const [deleting, setDeleting] = useState<GameRoute | null>(null)
const [showCreateBoss, setShowCreateBoss] = useState(false) const [showCreateBoss, setShowCreateBoss] = useState(false)
const [editingBoss, setEditingBoss] = useState<BossBattle | null>(null) const [editingBoss, setEditingBoss] = useState<BossBattle | null>(null)
const [deletingBoss, setDeletingBoss] = useState<BossBattle | null>(null)
const [editingTeam, setEditingTeam] = useState<BossBattle | null>(null) const [editingTeam, setEditingTeam] = useState<BossBattle | null>(null)
const sensors = useSensors( 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"> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Name Name
</th> </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> </tr>
</thead> </thead>
<DndContext <DndContext
@@ -254,9 +227,7 @@ export function AdminGameDetail() {
<SortableRouteRow <SortableRouteRow
key={route.id} key={route.id}
route={route} route={route}
onEdit={setEditing} onClick={(r) => setEditing(r)}
onDelete={setDeleting}
onClick={(r) => navigate(`/admin/games/${id}/routes/${r.id}`)}
/> />
))} ))}
</tbody> </tbody>
@@ -291,20 +262,13 @@ export function AdminGameDetail() {
} }
onClose={() => setEditing(null)} onClose={() => setEditing(null)}
isSubmitting={updateRoute.isPending} isSubmitting={updateRoute.isPending}
/> onDelete={() =>
)} deleteRoute.mutate(editing.id, {
onSuccess: () => setEditing(null),
{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),
}) })
} }
onCancel={() => setDeleting(null)}
isDeleting={deleteRoute.isPending} 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"> <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 Team
</th> </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> </tr>
</thead> </thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700"> <tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{bosses.map((boss) => ( {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">{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 font-medium">{boss.name}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap capitalize"> <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.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.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">{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> </tr>
))} ))}
</tbody> </tbody>
@@ -434,20 +377,16 @@ export function AdminGameDetail() {
} }
onClose={() => setEditingBoss(null)} onClose={() => setEditingBoss(null)}
isSubmitting={updateBoss.isPending} isSubmitting={updateBoss.isPending}
/> onDelete={() =>
)} deleteBoss.mutate(editingBoss.id, {
onSuccess: () => setEditingBoss(null),
{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),
}) })
} }
onCancel={() => setDeletingBoss(null)}
isDeleting={deleteBoss.isPending} isDeleting={deleteBoss.isPending}
onEditTeam={() => {
setEditingTeam(editingBoss)
setEditingBoss(null)
}}
/> />
)} )}

View File

@@ -1,8 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { AdminTable, type Column } from '../../components/admin/AdminTable' import { AdminTable, type Column } from '../../components/admin/AdminTable'
import { GameFormModal } from '../../components/admin/GameFormModal' import { GameFormModal } from '../../components/admin/GameFormModal'
import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
import { useGames } from '../../hooks/useGames' import { useGames } from '../../hooks/useGames'
import { useCreateGame, useUpdateGame, useDeleteGame } from '../../hooks/useAdmin' import { useCreateGame, useUpdateGame, useDeleteGame } from '../../hooks/useAdmin'
import { exportGames } from '../../api/admin' import { exportGames } from '../../api/admin'
@@ -10,7 +8,6 @@ import { downloadJson } from '../../utils/download'
import type { Game, CreateGameInput, UpdateGameInput } from '../../types' import type { Game, CreateGameInput, UpdateGameInput } from '../../types'
export function AdminGames() { export function AdminGames() {
const navigate = useNavigate()
const { data: games = [], isLoading } = useGames() const { data: games = [], isLoading } = useGames()
const createGame = useCreateGame() const createGame = useCreateGame()
const updateGame = useUpdateGame() const updateGame = useUpdateGame()
@@ -18,7 +15,6 @@ export function AdminGames() {
const [showCreate, setShowCreate] = useState(false) const [showCreate, setShowCreate] = useState(false)
const [editing, setEditing] = useState<Game | null>(null) const [editing, setEditing] = useState<Game | null>(null)
const [deleting, setDeleting] = useState<Game | null>(null)
const columns: Column<Game>[] = [ const columns: Column<Game>[] = [
{ header: 'Name', accessor: (g) => g.name }, { header: 'Name', accessor: (g) => g.name },
@@ -26,25 +22,6 @@ export function AdminGames() {
{ header: 'Region', accessor: (g) => g.region, sortKey: (g) => g.region }, { header: 'Region', accessor: (g) => g.region, sortKey: (g) => g.region },
{ header: 'Gen', accessor: (g) => g.generation, sortKey: (g) => g.generation }, { header: 'Gen', accessor: (g) => g.generation, sortKey: (g) => g.generation },
{ header: 'Year', accessor: (g) => g.releaseYear ?? '-', sortKey: (g) => g.releaseYear ?? 0 }, { 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 ( return (
@@ -75,7 +52,7 @@ export function AdminGames() {
data={games} data={games}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="No games yet. Add one to get started." emptyMessage="No games yet. Add one to get started."
onRowClick={(g) => navigate(`/admin/games/${g.id}`)} onRowClick={(g) => setEditing(g)}
keyFn={(g) => g.id} keyFn={(g) => g.id}
/> />
@@ -102,20 +79,13 @@ export function AdminGames() {
} }
onClose={() => setEditing(null)} onClose={() => setEditing(null)}
isSubmitting={updateGame.isPending} isSubmitting={updateGame.isPending}
/> onDelete={() =>
)} deleteGame.mutate(editing.id, {
onSuccess: () => setEditing(null),
{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),
}) })
} }
onCancel={() => setDeleting(null)}
isDeleting={deleteGame.isPending} isDeleting={deleteGame.isPending}
detailUrl={`/admin/games/${editing.id}`}
/> />
)} )}
</div> </div>

View File

@@ -2,7 +2,6 @@ import { useState } from 'react'
import { AdminTable, type Column } from '../../components/admin/AdminTable' import { AdminTable, type Column } from '../../components/admin/AdminTable'
import { PokemonFormModal } from '../../components/admin/PokemonFormModal' import { PokemonFormModal } from '../../components/admin/PokemonFormModal'
import { BulkImportModal } from '../../components/admin/BulkImportModal' import { BulkImportModal } from '../../components/admin/BulkImportModal'
import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
import { import {
usePokemonList, usePokemonList,
useCreatePokemon, useCreatePokemon,
@@ -32,7 +31,6 @@ export function AdminPokemon() {
const [showCreate, setShowCreate] = useState(false) const [showCreate, setShowCreate] = useState(false)
const [showBulkImport, setShowBulkImport] = useState(false) const [showBulkImport, setShowBulkImport] = useState(false)
const [editing, setEditing] = useState<Pokemon | null>(null) const [editing, setEditing] = useState<Pokemon | null>(null)
const [deleting, setDeleting] = useState<Pokemon | null>(null)
const columns: Column<Pokemon>[] = [ const columns: Column<Pokemon>[] = [
{ header: 'Dex #', accessor: (p) => p.nationalDex, className: 'w-16' }, { header: 'Dex #', accessor: (p) => p.nationalDex, className: 'w-16' },
@@ -48,25 +46,6 @@ export function AdminPokemon() {
}, },
{ header: 'Name', accessor: (p) => p.name }, { header: 'Name', accessor: (p) => p.name },
{ header: 'Types', accessor: (p) => p.types.join(', ') }, { 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 ( return (
@@ -120,6 +99,7 @@ export function AdminPokemon() {
isLoading={isLoading} isLoading={isLoading}
emptyMessage="No pokemon found." emptyMessage="No pokemon found."
keyFn={(p) => p.id} keyFn={(p) => p.id}
onRowClick={(p) => setEditing(p)}
/> />
{/* Pagination */} {/* Pagination */}
@@ -194,19 +174,11 @@ export function AdminPokemon() {
} }
onClose={() => setEditing(null)} onClose={() => setEditing(null)}
isSubmitting={updatePokemon.isPending} isSubmitting={updatePokemon.isPending}
/> onDelete={() =>
)} deletePokemon.mutate(editing.id, {
onSuccess: () => setEditing(null),
{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),
}) })
} }
onCancel={() => setDeleting(null)}
isDeleting={deletePokemon.isPending} isDeleting={deletePokemon.isPending}
/> />
)} )}

View File

@@ -2,7 +2,6 @@ import { useState } from 'react'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { AdminTable, type Column } from '../../components/admin/AdminTable' import { AdminTable, type Column } from '../../components/admin/AdminTable'
import { RouteEncounterFormModal } from '../../components/admin/RouteEncounterFormModal' import { RouteEncounterFormModal } from '../../components/admin/RouteEncounterFormModal'
import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
import { useGame, useRoutePokemon } from '../../hooks/useGames' import { useGame, useRoutePokemon } from '../../hooks/useGames'
import { import {
useAddRouteEncounter, useAddRouteEncounter,
@@ -29,7 +28,6 @@ export function AdminRouteDetail() {
const [showCreate, setShowCreate] = useState(false) const [showCreate, setShowCreate] = useState(false)
const [editing, setEditing] = useState<RouteEncounterDetail | null>(null) const [editing, setEditing] = useState<RouteEncounterDetail | null>(null)
const [deleting, setDeleting] = useState<RouteEncounterDetail | null>(null)
const route = game?.routes?.find((r) => r.id === rId) const route = game?.routes?.find((r) => r.id === rId)
@@ -54,25 +52,6 @@ export function AdminRouteDetail() {
accessor: (e) => accessor: (e) =>
e.minLevel === e.maxLevel ? `Lv ${e.minLevel}` : `Lv ${e.minLevel}-${e.maxLevel}`, 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 ( return (
@@ -109,6 +88,7 @@ export function AdminRouteDetail() {
isLoading={isLoading} isLoading={isLoading}
emptyMessage="No pokemon assigned to this route yet." emptyMessage="No pokemon assigned to this route yet."
keyFn={(e) => e.id} keyFn={(e) => e.id}
onRowClick={(e) => setEditing(e)}
/> />
{showCreate && ( {showCreate && (
@@ -134,19 +114,11 @@ export function AdminRouteDetail() {
} }
onClose={() => setEditing(null)} onClose={() => setEditing(null)}
isSubmitting={updateEncounter.isPending} isSubmitting={updateEncounter.isPending}
/> onDelete={() =>
)} removeEncounter.mutate(editing.id, {
onSuccess: () => setEditing(null),
{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),
}) })
} }
onCancel={() => setDeleting(null)}
isDeleting={removeEncounter.isPending} isDeleting={removeEncounter.isPending}
/> />
)} )}

View File

@@ -46,19 +46,6 @@ export function AdminRuns() {
accessor: (r) => new Date(r.startedAt).toLocaleDateString(), accessor: (r) => new Date(r.startedAt).toLocaleDateString(),
sortKey: (r) => r.startedAt, 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 ( return (
@@ -73,6 +60,7 @@ export function AdminRuns() {
isLoading={runsLoading || gamesLoading} isLoading={runsLoading || gamesLoading}
emptyMessage="No runs yet." emptyMessage="No runs yet."
keyFn={(r) => r.id} keyFn={(r) => r.id}
onRowClick={(r) => setDeleting(r)}
/> />
{deleting && ( {deleting && (