feat: show owner info in admin pages
All checks were successful
CI / backend-tests (pull_request) Successful in 29s
CI / frontend-tests (pull_request) Successful in 29s

- 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>
This commit is contained in:
2026-03-21 13:38:08 +01:00
parent 3bd24fcdb0
commit a3f332f82b
6 changed files with 171 additions and 8 deletions

View File

@@ -0,0 +1,43 @@
---
# nuzlocke-tracker-2fp1
title: Show owner info in admin pages
status: in-progress
type: feature
priority: normal
created_at: 2026-03-21T12:18:51Z
updated_at: 2026-03-21T12:37:36Z
parent: nuzlocke-tracker-wwnu
---
## Problem
Admin pages (`AdminRuns.tsx`, `AdminGenlockes.tsx`) don't show which user owns each run or genlocke. This makes it hard for admins to manage content.
## Approach
### Backend
- The `/api/runs` list endpoint already returns run data — verify it includes `owner` (id + email). If not, add it to the response schema.
- For genlockes, ownership is inferred from the first leg's run owner. Add an `owner` field to the genlocke list response that resolves from the first leg's run.
### Frontend
- `AdminRuns.tsx`: Add an "Owner" column showing the owner's email (or "No owner" for legacy runs)
- `AdminGenlockes.tsx`: Add an "Owner" column showing the inferred owner from the first leg's run
- Add owner filter dropdown to both pages
## Files to modify
- `backend/src/app/api/runs.py` — verify owner is included in list response
- `backend/src/app/api/genlockes.py` — add owner resolution to list endpoint
- `backend/src/app/schemas/genlocke.py` — add owner field to `GenlockeListItem`
- `frontend/src/pages/admin/AdminRuns.tsx` — add Owner column + filter
- `frontend/src/pages/admin/AdminGenlockes.tsx` — add Owner column + filter
- `frontend/src/types/game.ts` — update types if needed
## Checklist
- [x] Verify runs list API includes owner info; add if missing
- [x] Add owner resolution to genlocke list endpoint (from first leg's run)
- [x] Update `GenlockeListItem` schema to include owner
- [x] Add Owner column to `AdminRuns.tsx`
- [x] Add Owner column to `AdminGenlockes.tsx`
- [x] Add owner filter to both admin pages

View File

@@ -28,6 +28,7 @@ from app.schemas.genlocke import (
GenlockeLegDetailResponse,
GenlockeLineageResponse,
GenlockeListItem,
GenlockeOwnerResponse,
GenlockeResponse,
GenlockeStatsResponse,
GenlockeUpdate,
@@ -81,7 +82,9 @@ async def list_genlockes(session: AsyncSession = Depends(get_session)):
result = await session.execute(
select(Genlocke)
.options(
selectinload(Genlocke.legs).selectinload(GenlockeLeg.run),
selectinload(Genlocke.legs)
.selectinload(GenlockeLeg.run)
.selectinload(NuzlockeRun.owner),
)
.order_by(Genlocke.created_at.desc())
)
@@ -91,6 +94,16 @@ async def list_genlockes(session: AsyncSession = Depends(get_session)):
for g in genlockes:
completed_legs = 0
current_leg_order = None
owner = None
# Find first leg (leg_order == 1) to get owner
first_leg = next((leg for leg in g.legs if leg.leg_order == 1), None)
if first_leg and first_leg.run and first_leg.run.owner:
owner = GenlockeOwnerResponse(
id=first_leg.run.owner.id,
display_name=first_leg.run.owner.display_name,
)
for leg in g.legs:
if leg.run and leg.run.status == "completed":
completed_legs += 1
@@ -106,6 +119,7 @@ async def list_genlockes(session: AsyncSession = Depends(get_session)):
total_legs=len(g.legs),
completed_legs=completed_legs,
current_leg_order=current_leg_order,
owner=owner,
)
)
return items

View File

@@ -1,10 +1,16 @@
from datetime import datetime
from uuid import UUID
from app.schemas.base import CamelModel
from app.schemas.game import GameResponse
from app.schemas.pokemon import PokemonResponse
class GenlockeOwnerResponse(CamelModel):
id: UUID
display_name: str | None = None
class GenlockeCreate(CamelModel):
name: str
game_ids: list[int]
@@ -92,6 +98,7 @@ class GenlockeListItem(CamelModel):
total_legs: int
completed_legs: int
current_leg_order: int | None = None
owner: GenlockeOwnerResponse | None = None
class GenlockeDetailResponse(CamelModel):

View File

@@ -13,14 +13,46 @@ export function AdminGenlockes() {
const [deleting, setDeleting] = useState<GenlockeListItem | null>(null)
const [statusFilter, setStatusFilter] = useState('')
const [ownerFilter, setOwnerFilter] = useState('')
const filtered = useMemo(() => {
if (!statusFilter) return genlockes
return genlockes.filter((g) => g.status === statusFilter)
}, [genlockes, statusFilter])
let result = genlockes
if (statusFilter) result = result.filter((g) => g.status === statusFilter)
if (ownerFilter) {
if (ownerFilter === '__none__') {
result = result.filter((g) => !g.owner)
} else {
result = result.filter((g) => g.owner?.id === ownerFilter)
}
}
return result
}, [genlockes, statusFilter, ownerFilter])
const genlockeOwners = useMemo(() => {
const owners = new Map<string, string>()
let hasUnowned = false
for (const g of genlockes) {
if (g.owner) {
owners.set(g.owner.id, g.owner.displayName ?? g.owner.id)
} else {
hasUnowned = true
}
}
const sorted = [...owners.entries()].sort((a, b) => a[1].localeCompare(b[1]))
return { owners: sorted, hasUnowned }
}, [genlockes])
const columns: Column<GenlockeListItem>[] = [
{ header: 'Name', accessor: (g) => g.name, sortKey: (g) => g.name },
{
header: 'Owner',
accessor: (g) => (
<span className={g.owner ? '' : 'text-text-tertiary'}>
{g.owner?.displayName ?? g.owner?.id ?? 'No owner'}
</span>
),
sortKey: (g) => g.owner?.displayName ?? g.owner?.id ?? '',
},
{
header: 'Status',
accessor: (g) => (
@@ -67,9 +99,25 @@ export function AdminGenlockes() {
<option value="completed">Completed</option>
<option value="failed">Failed</option>
</select>
{statusFilter && (
<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>
{genlockeOwners.hasUnowned && <option value="__none__">No owner</option>}
{genlockeOwners.owners.map(([id, name]) => (
<option key={id} value={id}>
{name}
</option>
))}
</select>
{(statusFilter || ownerFilter) && (
<button
onClick={() => setStatusFilter('')}
onClick={() => {
setStatusFilter('')
setOwnerFilter('')
}}
className="text-sm text-text-tertiary hover:text-text-primary"
>
Clear filters

View File

@@ -13,6 +13,7 @@ export function AdminRuns() {
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])
@@ -20,8 +21,15 @@ export function AdminRuns() {
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])
}, [runs, statusFilter, gameFilter, ownerFilter])
const runGames = useMemo(
() =>
@@ -33,6 +41,20 @@ export function AdminRuns() {
[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 },
{
@@ -40,6 +62,15 @@ export function AdminRuns() {
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) => (
@@ -93,11 +124,25 @@ export function AdminRuns() {
</option>
))}
</select>
{(statusFilter || gameFilter) && (
<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"
>

View File

@@ -320,6 +320,11 @@ export interface GenlockeStats {
totalLegs: number
}
export interface GenlockeOwner {
id: string
displayName: string | null
}
export interface GenlockeListItem {
id: number
name: string
@@ -328,6 +333,7 @@ export interface GenlockeListItem {
totalLegs: number
completedLegs: number
currentLegOrder: number | null
owner: GenlockeOwner | null
}
export interface RetiredPokemon {