diff --git a/.beans/nuzlocke-tracker-dyzh--click-to-edit-pattern-across-admin-tables.md b/.beans/nuzlocke-tracker-dyzh--click-to-edit-pattern-across-admin-tables.md index b3e1814..b954377 100644 --- a/.beans/nuzlocke-tracker-dyzh--click-to-edit-pattern-across-admin-tables.md +++ b/.beans/nuzlocke-tracker-dyzh--click-to-edit-pattern-across-admin-tables.md @@ -1,11 +1,11 @@ --- # nuzlocke-tracker-dyzh title: Click-to-edit pattern across admin tables -status: todo +status: completed type: feature priority: high created_at: 2026-02-08T12:32:53Z -updated_at: 2026-02-08T12:35:03Z +updated_at: 2026-02-08T12:45:17Z parent: nuzlocke-tracker-iu5b blocking: - nuzlocke-tracker-fxi7 diff --git a/.beans/nuzlocke-tracker-fxi7--pokemon-detail-card-with-encounters-and-evolution.md b/.beans/nuzlocke-tracker-fxi7--pokemon-detail-card-with-encounters-and-evolution.md index e5d3562..e87901b 100644 --- a/.beans/nuzlocke-tracker-fxi7--pokemon-detail-card-with-encounters-and-evolution.md +++ b/.beans/nuzlocke-tracker-fxi7--pokemon-detail-card-with-encounters-and-evolution.md @@ -1,11 +1,11 @@ --- # nuzlocke-tracker-fxi7 title: Pokemon detail card with encounters and evolution chain -status: todo +status: completed type: feature priority: high created_at: 2026-02-08T12:33:05Z -updated_at: 2026-02-08T12:33:05Z +updated_at: 2026-02-08T12:53:13Z parent: nuzlocke-tracker-iu5b --- @@ -16,12 +16,28 @@ When viewing/editing a Pokemon in the admin panel, show contextual information a 1. Editable fields (name, types, dex number, sprite, etc.) 2. **Encounter locations**: A list of routes/games where this pokemon appears as a route encounter. Grouped by game, showing route name + encounter method + levels. 3. **Evolution chain**: Visual display of the pokemon's evolution family — predecessors and successors with triggers (level, item, trade, etc.) -- Encounter locations and evolution chain are read-only informational sections - Encounter locations link to the route detail page in admin for quick navigation +- Evolution chain entries are clickable to open the EvolutionFormModal for direct editing -## Backend support -- Encounters by pokemon: May need a new endpoint or can query route_encounters filtered by pokemon_id -- Evolution chain: Can reuse existing /pokemon/{id}/evolutions endpoint, but may need a 'full chain' variant that shows the complete family tree (not just direct evolutions from this pokemon) +## Implementation + +### Tabbed modal (edit mode) +In edit mode, the PokemonFormModal uses three tabs instead of a single scrolling view: +- **Details** — the form fields (PokeAPI ID, name, types, etc.) with Save/Delete/Cancel footer +- **Evolutions** — clickable evolution chain rows that open a stacked EvolutionFormModal for direct editing +- **Encounters** — encounter locations grouped by game, with route names linking to admin route detail pages + +In create mode, no tabs are shown (just the form fields). + +### Backend endpoints +- `GET /pokemon/{id}/encounter-locations` — returns encounters grouped by game with route/game names eagerly loaded +- `GET /pokemon/{id}/evolution-chain` — BFS to find full evolution family, returns all edges with from/to Pokemon + +### Frontend +- New types: `PokemonEncounterLocationItem`, `PokemonEncounterLocation` +- New API functions: `fetchPokemonEncounterLocations`, `fetchPokemonEvolutionChain` +- New hooks: `usePokemonEncounterLocations`, `usePokemonEvolutionChain` +- Extracted `formatEvolutionMethod` to shared `utils/formatEvolution.ts` ## Notes - This helps the admin quickly verify data completeness — 'is this pokemon assigned to the right routes?' and 'are its evolutions set up correctly?' \ No newline at end of file diff --git a/backend/src/app/api/pokemon.py b/backend/src/app/api/pokemon.py index b9df80f..4a2112b 100644 --- a/backend/src/app/api/pokemon.py +++ b/backend/src/app/api/pokemon.py @@ -8,13 +8,17 @@ from app.models.evolution import Evolution from app.models.pokemon import Pokemon from app.models.route import Route from app.models.route_encounter import RouteEncounter +from app.models.game import Game from app.schemas.pokemon import ( BulkImportItem, BulkImportResult, + EvolutionAdminResponse, EvolutionResponse, FamiliesResponse, PaginatedPokemonResponse, PokemonCreate, + PokemonEncounterLocationItem, + PokemonEncounterLocationResponse, PokemonResponse, PokemonUpdate, RouteEncounterCreate, @@ -174,6 +178,103 @@ async def get_pokemon_forms( return result.scalars().all() +@router.get( + "/pokemon/{pokemon_id}/encounter-locations", + response_model=list[PokemonEncounterLocationResponse], +) +async def get_pokemon_encounter_locations( + pokemon_id: int, session: AsyncSession = Depends(get_session) +): + pokemon = await session.get(Pokemon, pokemon_id) + if pokemon is None: + raise HTTPException(status_code=404, detail="Pokemon not found") + + result = await session.execute( + select(RouteEncounter) + .where(RouteEncounter.pokemon_id == pokemon_id) + .options(joinedload(RouteEncounter.route), joinedload(RouteEncounter.game)) + .order_by(RouteEncounter.game_id, RouteEncounter.route_id) + ) + encounters = result.scalars().unique().all() + + grouped: dict[int, PokemonEncounterLocationResponse] = {} + for enc in encounters: + if enc.game_id not in grouped: + grouped[enc.game_id] = PokemonEncounterLocationResponse( + game_id=enc.game_id, + game_name=enc.game.name, + encounters=[], + ) + grouped[enc.game_id].encounters.append( + PokemonEncounterLocationItem( + route_id=enc.route_id, + route_name=enc.route.name, + encounter_method=enc.encounter_method, + encounter_rate=enc.encounter_rate, + min_level=enc.min_level, + max_level=enc.max_level, + ) + ) + + return list(grouped.values()) + + +@router.get( + "/pokemon/{pokemon_id}/evolution-chain", + response_model=list[EvolutionAdminResponse], +) +async def get_pokemon_evolution_chain( + pokemon_id: int, session: AsyncSession = Depends(get_session) +): + from collections import deque + + pokemon = await session.get(Pokemon, pokemon_id) + if pokemon is None: + raise HTTPException(status_code=404, detail="Pokemon not found") + + # Load all evolutions to build adjacency + result = await session.execute(select(Evolution)) + evolutions = result.scalars().all() + + adj: dict[int, set[int]] = {} + for evo in evolutions: + adj.setdefault(evo.from_pokemon_id, set()).add(evo.to_pokemon_id) + adj.setdefault(evo.to_pokemon_id, set()).add(evo.from_pokemon_id) + + # BFS from pokemon_id to find family members + family: set[int] = set() + queue = deque([pokemon_id]) + while queue: + current = queue.popleft() + if current in family: + continue + family.add(current) + for neighbor in adj.get(current, set()): + if neighbor not in family: + queue.append(neighbor) + + # Filter evolutions to only those in the family + family_evo_ids = [ + evo.id for evo in evolutions + if evo.from_pokemon_id in family and evo.to_pokemon_id in family + ] + + if not family_evo_ids: + return [] + + # Reload with eager-loaded relationships + result = await session.execute( + select(Evolution) + .where(Evolution.id.in_(family_evo_ids)) + .options( + joinedload(Evolution.from_pokemon), + joinedload(Evolution.to_pokemon), + ) + .order_by(Evolution.from_pokemon_id, Evolution.to_pokemon_id) + ) + return result.scalars().unique().all() + + @router.get("/pokemon/{pokemon_id}/evolutions", response_model=list[EvolutionResponse]) async def get_pokemon_evolutions( pokemon_id: int, diff --git a/backend/src/app/schemas/pokemon.py b/backend/src/app/schemas/pokemon.py index bd33186..6181b03 100644 --- a/backend/src/app/schemas/pokemon.py +++ b/backend/src/app/schemas/pokemon.py @@ -50,6 +50,21 @@ class RouteEncounterDetailResponse(RouteEncounterResponse): pokemon: PokemonResponse +class PokemonEncounterLocationItem(CamelModel): + route_id: int + route_name: str + encounter_method: str + encounter_rate: int + min_level: int + max_level: int + + +class PokemonEncounterLocationResponse(CamelModel): + game_id: int + game_name: str + encounters: list[PokemonEncounterLocationItem] + + # --- Admin schemas --- diff --git a/frontend/src/api/pokemon.ts b/frontend/src/api/pokemon.ts index f64e129..07b35ea 100644 --- a/frontend/src/api/pokemon.ts +++ b/frontend/src/api/pokemon.ts @@ -1,5 +1,6 @@ import { api } from './client' import type { Pokemon } from '../types/game' +import type { EvolutionAdmin, PokemonEncounterLocation } from '../types/admin' export function getPokemon(id: number): Promise { return api.get(`/pokemon/${id}`) @@ -8,3 +9,11 @@ export function getPokemon(id: number): Promise { export function fetchPokemonFamilies(): Promise<{ families: number[][] }> { return api.get('/pokemon/families') } + +export function fetchPokemonEncounterLocations(pokemonId: number): Promise { + return api.get(`/pokemon/${pokemonId}/encounter-locations`) +} + +export function fetchPokemonEvolutionChain(pokemonId: number): Promise { + return api.get(`/pokemon/${pokemonId}/evolution-chain`) +} diff --git a/frontend/src/components/StatusChangeModal.tsx b/frontend/src/components/StatusChangeModal.tsx index c10da00..8fa1a98 100644 --- a/frontend/src/components/StatusChangeModal.tsx +++ b/frontend/src/components/StatusChangeModal.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import type { EncounterDetail, UpdateEncounterInput } from '../types' import { useEvolutions, useForms } from '../hooks/useEncounters' import { TypeBadge } from './TypeBadge' +import { formatEvolutionMethod } from '../utils/formatEvolution' interface StatusChangeModalProps { encounter: EncounterDetail @@ -14,28 +15,6 @@ interface StatusChangeModalProps { region?: string } -function formatEvolutionMethod(evo: { trigger: string; minLevel: number | null; item: string | null; heldItem: string | null; condition: string | null }): string { - const parts: string[] = [] - if (evo.trigger === 'level-up' && evo.minLevel) { - parts.push(`Level ${evo.minLevel}`) - } else if (evo.trigger === 'level-up') { - parts.push('Level up') - } else if (evo.trigger === 'use-item' && evo.item) { - parts.push(evo.item.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())) - } else if (evo.trigger === 'trade') { - parts.push('Trade') - } else { - parts.push(evo.trigger.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())) - } - if (evo.heldItem) { - parts.push(`holding ${evo.heldItem.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}`) - } - if (evo.condition) { - parts.push(evo.condition) - } - return parts.join(', ') -} - export function StatusChangeModal({ encounter, onUpdate, diff --git a/frontend/src/components/admin/PokemonFormModal.tsx b/frontend/src/components/admin/PokemonFormModal.tsx index 744785d..e165957 100644 --- a/frontend/src/components/admin/PokemonFormModal.tsx +++ b/frontend/src/components/admin/PokemonFormModal.tsx @@ -1,6 +1,11 @@ -import { type FormEvent, useState } from 'react' -import { FormModal } from './FormModal' -import type { Pokemon, CreatePokemonInput, UpdatePokemonInput } from '../../types' +import { type FormEvent, useState, useEffect } from 'react' +import { Link } from 'react-router-dom' +import { useQueryClient } from '@tanstack/react-query' +import { EvolutionFormModal } from './EvolutionFormModal' +import type { Pokemon, CreatePokemonInput, UpdatePokemonInput, EvolutionAdmin, UpdateEvolutionInput } from '../../types' +import { usePokemonEncounterLocations, usePokemonEvolutionChain } from '../../hooks/usePokemon' +import { useUpdateEvolution, useDeleteEvolution } from '../../hooks/useAdmin' +import { formatEvolutionMethod } from '../../utils/formatEvolution' interface PokemonFormModalProps { pokemon?: Pokemon @@ -11,12 +16,34 @@ interface PokemonFormModalProps { isDeleting?: boolean } +type Tab = 'details' | 'evolutions' | 'encounters' + export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onDelete, isDeleting }: PokemonFormModalProps) { const [pokeapiId, setPokeapiId] = useState(String(pokemon?.pokeapiId ?? '')) const [nationalDex, setNationalDex] = useState(String(pokemon?.nationalDex ?? '')) const [name, setName] = useState(pokemon?.name ?? '') const [types, setTypes] = useState(pokemon?.types.join(', ') ?? '') const [spriteUrl, setSpriteUrl] = useState(pokemon?.spriteUrl ?? '') + const [activeTab, setActiveTab] = useState('details') + const [editingEvolution, setEditingEvolution] = useState(null) + const [confirmingDelete, setConfirmingDelete] = useState(false) + + const isEdit = !!pokemon + const pokemonId = pokemon?.id ?? null + const { data: encounterLocations, isLoading: encountersLoading } = usePokemonEncounterLocations(pokemonId) + const { data: evolutionChain, isLoading: evolutionsLoading } = usePokemonEvolutionChain(pokemonId) + + const queryClient = useQueryClient() + const updateEvolution = useUpdateEvolution() + const deleteEvolution = useDeleteEvolution() + + useEffect(() => { + setConfirmingDelete(false) + }, [onDelete]) + + const invalidateChain = () => { + queryClient.invalidateQueries({ queryKey: ['pokemon', pokemonId, 'evolution-chain'] }) + } const handleSubmit = (e: FormEvent) => { e.preventDefault() @@ -33,68 +60,256 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD }) } + const tabs: { key: Tab; label: string }[] = [ + { key: 'details', label: 'Details' }, + { key: 'evolutions', label: 'Evolutions' }, + { key: 'encounters', label: 'Encounters' }, + ] + + const tabClass = (tab: Tab) => + `px-3 py-1.5 text-sm font-medium rounded-t-md border-b-2 transition-colors ${ + activeTab === tab + ? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400' + : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300' + }` + return ( - -
- - setPokeapiId(e.target.value)} - className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" - /> + <> +
+
+
+ {/* Header */} +
+

{pokemon ? 'Edit Pokemon' : 'Add Pokemon'}

+ {isEdit && ( +
+ {tabs.map((tab) => ( + + ))} +
+ )} +
+ + {/* Details tab (form) */} + {activeTab === 'details' && ( +
+
+
+ + setPokeapiId(e.target.value)} + className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" + /> +
+
+ + setNationalDex(e.target.value)} + className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" + /> +
+
+ + setName(e.target.value)} + className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" + /> +
+
+ + setTypes(e.target.value)} + placeholder="Fire, Flying" + className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" + /> +
+
+ + setSpriteUrl(e.target.value)} + placeholder="Optional" + className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" + /> +
+
+
+ {onDelete && ( + + )} +
+ + +
+ + )} + + {/* Evolutions tab */} + {activeTab === 'evolutions' && ( +
+
+ {evolutionsLoading && ( +

Loading...

+ )} + {!evolutionsLoading && (!evolutionChain || evolutionChain.length === 0) && ( +

No evolutions

+ )} + {!evolutionsLoading && evolutionChain && evolutionChain.length > 0 && ( +
+ {evolutionChain.map((evo) => ( + + ))} +
+ )} +
+
+ +
+
+ )} + + {/* Encounters tab */} + {activeTab === 'encounters' && ( +
+
+ {encountersLoading && ( +

Loading...

+ )} + {!encountersLoading && (!encounterLocations || encounterLocations.length === 0) && ( +

No encounters

+ )} + {!encountersLoading && encounterLocations && encounterLocations.length > 0 && ( +
+ {encounterLocations.map((game) => ( +
+
+ {game.gameName} +
+
+ {game.encounters.map((enc, i) => ( +
+ + {enc.routeName} + + + — {enc.encounterMethod}, Lv. {enc.minLevel}–{enc.maxLevel} + +
+ ))} +
+
+ ))} +
+ )} +
+
+ +
+
+ )} +
-
- - setNationalDex(e.target.value)} - className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" + + {editingEvolution && ( + + updateEvolution.mutate( + { id: editingEvolution.id, data: data as UpdateEvolutionInput }, + { + onSuccess: () => { + setEditingEvolution(null) + invalidateChain() + }, + }, + ) + } + onClose={() => setEditingEvolution(null)} + isSubmitting={updateEvolution.isPending} + onDelete={() => + deleteEvolution.mutate(editingEvolution.id, { + onSuccess: () => { + setEditingEvolution(null) + invalidateChain() + }, + }) + } + isDeleting={deleteEvolution.isPending} /> -
-
- - setName(e.target.value)} - className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" - /> -
-
- - setTypes(e.target.value)} - placeholder="Fire, Flying" - className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" - /> -
-
- - setSpriteUrl(e.target.value)} - placeholder="Optional" - className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" - /> -
- + )} + ) } diff --git a/frontend/src/hooks/usePokemon.ts b/frontend/src/hooks/usePokemon.ts index 01794f3..2feb83a 100644 --- a/frontend/src/hooks/usePokemon.ts +++ b/frontend/src/hooks/usePokemon.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import { getPokemon, fetchPokemonFamilies } from '../api/pokemon' +import { getPokemon, fetchPokemonFamilies, fetchPokemonEncounterLocations, fetchPokemonEvolutionChain } from '../api/pokemon' export function usePokemon(id: number | null) { return useQuery({ @@ -16,3 +16,19 @@ export function usePokemonFamilies() { staleTime: Infinity, }) } + +export function usePokemonEncounterLocations(pokemonId: number | null) { + return useQuery({ + queryKey: ['pokemon', pokemonId, 'encounter-locations'], + queryFn: () => fetchPokemonEncounterLocations(pokemonId!), + enabled: pokemonId !== null, + }) +} + +export function usePokemonEvolutionChain(pokemonId: number | null) { + return useQuery({ + queryKey: ['pokemon', pokemonId, 'evolution-chain'], + queryFn: () => fetchPokemonEvolutionChain(pokemonId!), + enabled: pokemonId !== null, + }) +} diff --git a/frontend/src/types/admin.ts b/frontend/src/types/admin.ts index de269d8..cecd8ea 100644 --- a/frontend/src/types/admin.ts +++ b/frontend/src/types/admin.ts @@ -121,6 +121,22 @@ export interface UpdateEvolutionInput { region?: string | null } +// Pokemon encounter locations (detail card) +export interface PokemonEncounterLocationItem { + routeId: number + routeName: string + encounterMethod: string + encounterRate: number + minLevel: number + maxLevel: number +} + +export interface PokemonEncounterLocation { + gameId: number + gameName: string + encounters: PokemonEncounterLocationItem[] +} + // Boss battles admin export interface CreateBossBattleInput { name: string diff --git a/frontend/src/utils/formatEvolution.ts b/frontend/src/utils/formatEvolution.ts new file mode 100644 index 0000000..c7c92b1 --- /dev/null +++ b/frontend/src/utils/formatEvolution.ts @@ -0,0 +1,21 @@ +export function formatEvolutionMethod(evo: { trigger: string; minLevel: number | null; item: string | null; heldItem: string | null; condition: string | null }): string { + const parts: string[] = [] + if (evo.trigger === 'level-up' && evo.minLevel) { + parts.push(`Level ${evo.minLevel}`) + } else if (evo.trigger === 'level-up') { + parts.push('Level up') + } else if (evo.trigger === 'use-item' && evo.item) { + parts.push(evo.item.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())) + } else if (evo.trigger === 'trade') { + parts.push('Trade') + } else { + parts.push(evo.trigger.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())) + } + if (evo.heldItem) { + parts.push(`holding ${evo.heldItem.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}`) + } + if (evo.condition) { + parts.push(evo.condition) + } + return parts.join(', ') +}