- Add Owner column to AdminRuns.tsx and AdminGenlockes.tsx - Add owner filter dropdown to both admin pages - Add owner field to GenlockeListItem schema (resolved from first leg's run) - Update frontend types for GenlockeListItem Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
186 lines
5.8 KiB
TypeScript
186 lines
5.8 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 [ownerFilter, setOwnerFilter] = 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))
|
|
if (ownerFilter) {
|
|
if (ownerFilter === '__none__') {
|
|
result = result.filter((r) => !r.owner)
|
|
} else {
|
|
result = result.filter((r) => r.owner?.id === ownerFilter)
|
|
}
|
|
}
|
|
return result
|
|
}, [runs, statusFilter, gameFilter, ownerFilter])
|
|
|
|
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 runOwners = useMemo(() => {
|
|
const owners = new Map<string, string>()
|
|
let hasUnowned = false
|
|
for (const r of runs) {
|
|
if (r.owner) {
|
|
owners.set(r.owner.id, r.owner.displayName ?? r.owner.id)
|
|
} else {
|
|
hasUnowned = true
|
|
}
|
|
}
|
|
const sorted = [...owners.entries()].sort((a, b) => a[1].localeCompare(b[1]))
|
|
return { owners: sorted, hasUnowned }
|
|
}, [runs])
|
|
|
|
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: 'Owner',
|
|
accessor: (r) => (
|
|
<span className={r.owner ? '' : 'text-text-tertiary'}>
|
|
{r.owner?.displayName ?? r.owner?.id ?? 'No owner'}
|
|
</span>
|
|
),
|
|
sortKey: (r) => r.owner?.displayName ?? r.owner?.id ?? '',
|
|
},
|
|
{
|
|
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>
|
|
<select
|
|
value={ownerFilter}
|
|
onChange={(e) => setOwnerFilter(e.target.value)}
|
|
className="px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
|
>
|
|
<option value="">All owners</option>
|
|
{runOwners.hasUnowned && <option value="__none__">No owner</option>}
|
|
{runOwners.owners.map(([id, name]) => (
|
|
<option key={id} value={id}>
|
|
{name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{(statusFilter || gameFilter || ownerFilter) && (
|
|
<button
|
|
onClick={() => {
|
|
setStatusFilter('')
|
|
setGameFilter('')
|
|
setOwnerFilter('')
|
|
}}
|
|
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>
|
|
)
|
|
}
|