Files
nuzlocke-tracker/frontend/src/pages/admin/AdminRuns.tsx
Julian Tabel 42b66ee9a2
All checks were successful
CI / backend-lint (push) Successful in 10s
CI / actions-lint (push) Successful in 16s
CI / frontend-lint (push) Successful in 21s
Implement dark-first design system with Geist typography (#28)
Co-authored-by: Julian Tabel <juliantabel.jt@gmail.com>
Co-committed-by: Julian Tabel <juliantabel.jt@gmail.com>
2026-02-17 20:48:42 +01:00

141 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-status-active'
: r.status === 'completed'
? 'text-text-link'
: 'text-status-failed'
}
>
{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 bg-surface-2 border-border-default"
>
<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 bg-surface-2 border-border-default"
>
<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-text-tertiary hover:text-text-primary"
>
Clear filters
</button>
)}
<span className="text-sm text-text-tertiary 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>
)
}