Files
nuzlocke-tracker/frontend/src/pages/admin/AdminRuns.tsx
Julian Tabel 924efa9073 Show error feedback when run deletion is blocked
Add optional error prop to DeleteConfirmModal and wire it into AdminRuns
so the backend's rejection message is displayed to the user.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 12:07:09 +01:00

131 lines
4.3 KiB
TypeScript

import { useState, useMemo } from 'react'
import { AdminTable, type Column } from '../../components/admin/AdminTable'
import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
import { useRuns, useDeleteRun } from '../../hooks/useRuns'
import { useGames } from '../../hooks/useGames'
import type { NuzlockeRun } from '../../types/game'
export function AdminRuns() {
const { data: runs = [], isLoading: runsLoading } = useRuns()
const { data: games = [], isLoading: gamesLoading } = useGames()
const deleteRun = useDeleteRun()
const [deleting, setDeleting] = useState<NuzlockeRun | null>(null)
const [statusFilter, setStatusFilter] = useState('')
const [gameFilter, setGameFilter] = useState('')
const gameMap = useMemo(
() => new Map(games.map((g) => [g.id, g.name])),
[games],
)
const filteredRuns = useMemo(() => {
let result = runs
if (statusFilter) result = result.filter((r) => r.status === statusFilter)
if (gameFilter) result = result.filter((r) => r.gameId === Number(gameFilter))
return result
}, [runs, statusFilter, gameFilter])
const runGames = useMemo(
() => [...new Map(runs.map((r) => [r.gameId, gameMap.get(r.gameId) ?? `Game #${r.gameId}`])).entries()].sort((a, b) => a[1].localeCompare(b[1])),
[runs, gameMap],
)
const columns: Column<NuzlockeRun>[] = [
{ header: 'Run Name', accessor: (r) => r.name, sortKey: (r) => r.name },
{
header: 'Game',
accessor: (r) => gameMap.get(r.gameId) ?? `Game #${r.gameId}`,
sortKey: (r) => gameMap.get(r.gameId) ?? '',
},
{
header: 'Status',
accessor: (r) => (
<span
className={
r.status === 'active'
? 'text-green-600 dark:text-green-400'
: r.status === 'completed'
? 'text-blue-600 dark:text-blue-400'
: 'text-red-600 dark:text-red-400'
}
>
{r.status}
</span>
),
sortKey: (r) => r.status,
},
{
header: 'Started',
accessor: (r) => new Date(r.startedAt).toLocaleDateString(),
sortKey: (r) => r.startedAt,
},
]
return (
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold">Runs</h2>
</div>
<div className="mb-4 flex items-center gap-4">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
>
<option value="">All statuses</option>
<option value="active">Active</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
</select>
<select
value={gameFilter}
onChange={(e) => setGameFilter(e.target.value)}
className="px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
>
<option value="">All games</option>
{runGames.map(([id, name]) => (
<option key={id} value={id}>{name}</option>
))}
</select>
{(statusFilter || gameFilter) && (
<button
onClick={() => { setStatusFilter(''); setGameFilter('') }}
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Clear filters
</button>
)}
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
{filteredRuns.length} runs
</span>
</div>
<AdminTable
columns={columns}
data={filteredRuns}
isLoading={runsLoading || gamesLoading}
emptyMessage="No runs found."
keyFn={(r) => r.id}
onRowClick={(r) => setDeleting(r)}
/>
{deleting && (
<DeleteConfirmModal
title={`Delete "${deleting.name}"?`}
message="This will permanently delete the run and all its encounters."
onConfirm={() =>
deleteRun.mutate(deleting.id, {
onSuccess: () => setDeleting(null),
})
}
onCancel={() => { setDeleting(null); deleteRun.reset() }}
isDeleting={deleteRun.isPending}
error={deleteRun.error?.message ?? null}
/>
)}
</div>
)
}