From c6521dd20646b73c1b8b3479fc86a7758a8791f6 Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Sun, 8 Feb 2026 20:29:55 +0100 Subject: [PATCH] Add filter controls to admin tables Pokemon (type), Evolutions (trigger), Games (region/generation), and Runs (status/game) now have dropdown filters alongside search. Server-side filtering for paginated tables, client-side for small datasets. Co-Authored-By: Claude Opus 4.6 --- ...ker-em40--add-filtering-to-admin-tables.md | 4 +- backend/src/app/api/evolutions.py | 5 ++ backend/src/app/api/pokemon.py | 3 + frontend/src/api/admin.ts | 6 +- frontend/src/hooks/useAdmin.ts | 12 ++-- frontend/src/pages/admin/AdminEvolutions.tsx | 34 ++++++++++- frontend/src/pages/admin/AdminGames.tsx | 58 ++++++++++++++++++- frontend/src/pages/admin/AdminPokemon.tsx | 34 ++++++++++- frontend/src/pages/admin/AdminRuns.tsx | 52 ++++++++++++++++- 9 files changed, 188 insertions(+), 20 deletions(-) diff --git a/.beans/nuzlocke-tracker-em40--add-filtering-to-admin-tables.md b/.beans/nuzlocke-tracker-em40--add-filtering-to-admin-tables.md index d8632f8..3a2e367 100644 --- a/.beans/nuzlocke-tracker-em40--add-filtering-to-admin-tables.md +++ b/.beans/nuzlocke-tracker-em40--add-filtering-to-admin-tables.md @@ -1,11 +1,11 @@ --- # nuzlocke-tracker-em40 title: Add filtering to admin tables -status: todo +status: completed type: feature priority: low created_at: 2026-02-08T12:33:46Z -updated_at: 2026-02-08T12:33:46Z +updated_at: 2026-02-08T19:28:04Z parent: nuzlocke-tracker-iu5b --- diff --git a/backend/src/app/api/evolutions.py b/backend/src/app/api/evolutions.py index 8229b35..95efaea 100644 --- a/backend/src/app/api/evolutions.py +++ b/backend/src/app/api/evolutions.py @@ -21,6 +21,7 @@ router = APIRouter() @router.get("/evolutions", response_model=PaginatedEvolutionResponse) async def list_evolutions( search: str | None = Query(None), + trigger: str | None = Query(None), limit: int = Query(50, ge=1, le=500), offset: int = Query(0, ge=0), session: AsyncSession = Depends(get_session), @@ -44,6 +45,8 @@ async def list_evolutions( func.lower(Evolution.item).contains(search_lower), ) ) + if trigger: + base_query = base_query.where(Evolution.trigger == trigger) # Count total (without eager loads) count_base = select(Evolution) @@ -60,6 +63,8 @@ async def list_evolutions( func.lower(Evolution.item).contains(search_lower), ) ) + if trigger: + count_base = count_base.where(Evolution.trigger == trigger) count_query = select(func.count()).select_from(count_base.subquery()) total = (await session.execute(count_query)).scalar() or 0 diff --git a/backend/src/app/api/pokemon.py b/backend/src/app/api/pokemon.py index 4a2112b..c52d370 100644 --- a/backend/src/app/api/pokemon.py +++ b/backend/src/app/api/pokemon.py @@ -32,6 +32,7 @@ router = APIRouter() @router.get("/pokemon", response_model=PaginatedPokemonResponse) async def list_pokemon( search: str | None = Query(None), + type: str | None = Query(None), limit: int = Query(50, ge=1, le=500), offset: int = Query(0, ge=0), session: AsyncSession = Depends(get_session), @@ -42,6 +43,8 @@ async def list_pokemon( base_query = base_query.where( func.lower(Pokemon.name).contains(search.lower()) ) + if type: + base_query = base_query.where(Pokemon.types.any(type)) # Get total count count_query = select(func.count()).select_from(base_query.subquery()) diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 1006e41..4f59ccc 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -50,9 +50,10 @@ export const reorderRoutes = (gameId: number, routes: RouteReorderItem[]) => api.put(`/games/${gameId}/routes/reorder`, { routes }) // Pokemon -export const listPokemon = (search?: string, limit = 50, offset = 0) => { +export const listPokemon = (search?: string, limit = 50, offset = 0, type?: string) => { const params = new URLSearchParams() if (search) params.set('search', search) + if (type) params.set('type', type) params.set('limit', String(limit)) params.set('offset', String(offset)) return api.get(`/pokemon?${params}`) @@ -80,9 +81,10 @@ export const bulkImportBosses = (gameId: number, items: unknown[]) => api.post(`/games/${gameId}/bosses/bulk-import`, items) // Evolutions -export const listEvolutions = (search?: string, limit = 50, offset = 0) => { +export const listEvolutions = (search?: string, limit = 50, offset = 0, trigger?: string) => { const params = new URLSearchParams() if (search) params.set('search', search) + if (trigger) params.set('trigger', trigger) params.set('limit', String(limit)) params.set('offset', String(offset)) return api.get(`/evolutions?${params}`) diff --git a/frontend/src/hooks/useAdmin.ts b/frontend/src/hooks/useAdmin.ts index ac29ac2..7246be2 100644 --- a/frontend/src/hooks/useAdmin.ts +++ b/frontend/src/hooks/useAdmin.ts @@ -21,10 +21,10 @@ import type { // --- Queries --- -export function usePokemonList(search?: string, limit = 50, offset = 0) { +export function usePokemonList(search?: string, limit = 50, offset = 0, type?: string) { return useQuery({ - queryKey: ['pokemon', { search, limit, offset }], - queryFn: () => adminApi.listPokemon(search, limit, offset), + queryKey: ['pokemon', { search, limit, offset, type }], + queryFn: () => adminApi.listPokemon(search, limit, offset, type), }) } @@ -213,10 +213,10 @@ export function useBulkImportBosses(gameId: number) { // --- Evolution Queries & Mutations --- -export function useEvolutionList(search?: string, limit = 50, offset = 0) { +export function useEvolutionList(search?: string, limit = 50, offset = 0, trigger?: string) { return useQuery({ - queryKey: ['evolutions', { search, limit, offset }], - queryFn: () => adminApi.listEvolutions(search, limit, offset), + queryKey: ['evolutions', { search, limit, offset, trigger }], + queryFn: () => adminApi.listEvolutions(search, limit, offset, trigger), }) } diff --git a/frontend/src/pages/admin/AdminEvolutions.tsx b/frontend/src/pages/admin/AdminEvolutions.tsx index b7612ad..4ca8252 100644 --- a/frontend/src/pages/admin/AdminEvolutions.tsx +++ b/frontend/src/pages/admin/AdminEvolutions.tsx @@ -15,11 +15,20 @@ import type { EvolutionAdmin, CreateEvolutionInput, UpdateEvolutionInput } from const PAGE_SIZE = 50 +const EVOLUTION_TRIGGERS = [ + { value: 'level-up', label: 'Level Up' }, + { value: 'trade', label: 'Trade' }, + { value: 'use-item', label: 'Use Item' }, + { value: 'shed', label: 'Shed' }, + { value: 'other', label: 'Other' }, +] + export function AdminEvolutions() { const [search, setSearch] = useState('') + const [triggerFilter, setTriggerFilter] = useState('') const [page, setPage] = useState(0) const offset = page * PAGE_SIZE - const { data, isLoading } = useEvolutionList(search || undefined, PAGE_SIZE, offset) + const { data, isLoading } = useEvolutionList(search || undefined, PAGE_SIZE, offset, triggerFilter || undefined) const evolutions = data?.items ?? [] const total = data?.total ?? 0 const totalPages = Math.ceil(total / PAGE_SIZE) @@ -101,7 +110,28 @@ export function AdminEvolutions() { placeholder="Search by pokemon name, trigger, or item..." className="w-full max-w-sm px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" /> - + + {(search || triggerFilter) && ( + + )} + {total} evolutions diff --git a/frontend/src/pages/admin/AdminGames.tsx b/frontend/src/pages/admin/AdminGames.tsx index c1e6298..9a2ebfe 100644 --- a/frontend/src/pages/admin/AdminGames.tsx +++ b/frontend/src/pages/admin/AdminGames.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useMemo } from 'react' import { AdminTable, type Column } from '../../components/admin/AdminTable' import { GameFormModal } from '../../components/admin/GameFormModal' import { useGames } from '../../hooks/useGames' @@ -15,6 +15,24 @@ export function AdminGames() { const [showCreate, setShowCreate] = useState(false) const [editing, setEditing] = useState(null) + const [regionFilter, setRegionFilter] = useState('') + const [genFilter, setGenFilter] = useState('') + + const regions = useMemo( + () => [...new Set(games.map((g) => g.region))].sort(), + [games], + ) + const generations = useMemo( + () => [...new Set(games.map((g) => g.generation))].sort((a, b) => a - b), + [games], + ) + + const filteredGames = useMemo(() => { + let result = games + if (regionFilter) result = result.filter((g) => g.region === regionFilter) + if (genFilter) result = result.filter((g) => g.generation === Number(genFilter)) + return result + }, [games, regionFilter, genFilter]) const columns: Column[] = [ { header: 'Name', accessor: (g) => g.name }, @@ -47,11 +65,45 @@ export function AdminGames() { +
+ + + {(regionFilter || genFilter) && ( + + )} + + {filteredGames.length} games + +
+ setEditing(g)} keyFn={(g) => g.id} /> diff --git a/frontend/src/pages/admin/AdminPokemon.tsx b/frontend/src/pages/admin/AdminPokemon.tsx index c8a7e0b..c972f42 100644 --- a/frontend/src/pages/admin/AdminPokemon.tsx +++ b/frontend/src/pages/admin/AdminPokemon.tsx @@ -15,11 +15,18 @@ import type { Pokemon, CreatePokemonInput, UpdatePokemonInput } from '../../type const PAGE_SIZE = 50 +const POKEMON_TYPES = [ + 'bug', 'dark', 'dragon', 'electric', 'fairy', 'fighting', 'fire', 'flying', + 'ghost', 'grass', 'ground', 'ice', 'normal', 'poison', 'psychic', 'rock', + 'steel', 'water', +] + export function AdminPokemon() { const [search, setSearch] = useState('') + const [typeFilter, setTypeFilter] = useState('') const [page, setPage] = useState(0) const offset = page * PAGE_SIZE - const { data, isLoading } = usePokemonList(search || undefined, PAGE_SIZE, offset) + const { data, isLoading } = usePokemonList(search || undefined, PAGE_SIZE, offset, typeFilter || undefined) const pokemon = data?.items ?? [] const total = data?.total ?? 0 const totalPages = Math.ceil(total / PAGE_SIZE) @@ -83,12 +90,33 @@ export function AdminPokemon() { value={search} onChange={(e) => { setSearch(e.target.value) - setPage(0) // Reset to first page on search + setPage(0) }} placeholder="Search by name..." className="w-full max-w-sm px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" /> - + + {(search || typeFilter) && ( + + )} + {total} pokemon diff --git a/frontend/src/pages/admin/AdminRuns.tsx b/frontend/src/pages/admin/AdminRuns.tsx index 50512f3..0c75eeb 100644 --- a/frontend/src/pages/admin/AdminRuns.tsx +++ b/frontend/src/pages/admin/AdminRuns.tsx @@ -11,12 +11,26 @@ export function AdminRuns() { const deleteRun = useDeleteRun() const [deleting, setDeleting] = useState(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[] = [ { header: 'Run Name', accessor: (r) => r.name, sortKey: (r) => r.name }, { @@ -54,11 +68,45 @@ export function AdminRuns() {

Runs

+
+ + + {(statusFilter || gameFilter) && ( + + )} + + {filteredRuns.length} runs + +
+ r.id} onRowClick={(r) => setDeleting(r)} />