From 5d54c00af0c614a7428e89028a72b89cc486c8c3 Mon Sep 17 00:00:00 2001
From: Julian Tabel
Date: Sun, 8 Feb 2026 11:52:18 +0100
Subject: [PATCH] Add tabbed UI for routes/bosses and boss export endpoint
Refactors AdminGameDetail to use tabs instead of stacked sections,
adds GET /export/games/{game_id}/bosses endpoint, and adds Export
button to the Boss Battles tab.
Co-Authored-By: Claude Opus 4.6
---
...panel-tabs-for-routesbosses-boss-export.md | 11 +
backend/src/app/api/export.py | 59 ++-
frontend/src/api/admin.ts | 3 +
frontend/src/pages/admin/AdminGameDetail.tsx | 418 ++++++++++--------
4 files changed, 296 insertions(+), 195 deletions(-)
create mode 100644 .beans/nuzlocke-tracker-6kux--admin-panel-tabs-for-routesbosses-boss-export.md
diff --git a/.beans/nuzlocke-tracker-6kux--admin-panel-tabs-for-routesbosses-boss-export.md b/.beans/nuzlocke-tracker-6kux--admin-panel-tabs-for-routesbosses-boss-export.md
new file mode 100644
index 0000000..c9a6d8d
--- /dev/null
+++ b/.beans/nuzlocke-tracker-6kux--admin-panel-tabs-for-routesbosses-boss-export.md
@@ -0,0 +1,11 @@
+---
+# nuzlocke-tracker-6kux
+title: 'Admin Panel: Tabs for Routes/Bosses + Boss Export'
+status: completed
+type: feature
+priority: normal
+created_at: 2026-02-08T10:49:47Z
+updated_at: 2026-02-08T10:51:25Z
+---
+
+Add tabbed UI to AdminGameDetail (Routes/Bosses tabs) and boss battle export endpoint
\ No newline at end of file
diff --git a/backend/src/app/api/export.py b/backend/src/app/api/export.py
index fb06a00..cdce3aa 100644
--- a/backend/src/app/api/export.py
+++ b/backend/src/app/api/export.py
@@ -6,6 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.database import get_session
+from app.models.boss_battle import BossBattle
+from app.models.boss_pokemon import BossPokemon
from app.models.evolution import Evolution
from app.models.game import Game
from app.models.pokemon import Pokemon
@@ -46,10 +48,10 @@ async def export_game_routes(
if not game:
raise HTTPException(status_code=404, detail="Game not found")
- # Load all routes for this game with encounters and pokemon
+ # Load all routes for this game's version group with encounters and pokemon
result = await session.execute(
select(Route)
- .where(Route.game_id == game_id)
+ .where(Route.version_group_id == game.version_group_id)
.options(
selectinload(Route.route_encounters).selectinload(RouteEncounter.pokemon),
)
@@ -65,6 +67,10 @@ async def export_game_routes(
children_by_parent.setdefault(r.parent_route_id, []).append(r)
def format_encounters(route: Route) -> list[dict]:
+ # Filter route_encounters to this specific game
+ game_encounters = [
+ enc for enc in route.route_encounters if enc.game_id == game_id
+ ]
return [
{
"pokeapi_id": enc.pokemon.pokeapi_id,
@@ -74,7 +80,7 @@ async def export_game_routes(
"min_level": enc.min_level,
"max_level": enc.max_level,
}
- for enc in sorted(route.route_encounters, key=lambda e: -e.encounter_rate)
+ for enc in sorted(game_encounters, key=lambda e: -e.encounter_rate)
]
def format_route(route: Route) -> dict:
@@ -106,6 +112,53 @@ async def export_game_routes(
}
+@router.get("/games/{game_id}/bosses")
+async def export_game_bosses(
+ game_id: int,
+ session: AsyncSession = Depends(get_session),
+):
+ """Export boss battles for a game in seed JSON format."""
+ game = await session.get(Game, game_id)
+ if not game:
+ raise HTTPException(status_code=404, detail="Game not found")
+
+ result = await session.execute(
+ select(BossBattle)
+ .where(BossBattle.version_group_id == game.version_group_id)
+ .options(
+ selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon),
+ )
+ .order_by(BossBattle.order)
+ )
+ bosses = result.scalars().all()
+
+ return {
+ "filename": f"{game.slug}-bosses.json",
+ "data": [
+ {
+ "name": b.name,
+ "boss_type": b.boss_type,
+ "badge_name": b.badge_name,
+ "badge_image_url": b.badge_image_url,
+ "level_cap": b.level_cap,
+ "order": b.order,
+ "location": b.location,
+ "sprite_url": b.sprite_url,
+ "pokemon": [
+ {
+ "pokeapi_id": bp.pokemon.pokeapi_id,
+ "pokemon_name": bp.pokemon.name,
+ "level": bp.level,
+ "order": bp.order,
+ }
+ for bp in sorted(b.pokemon, key=lambda p: p.order)
+ ],
+ }
+ for b in bosses
+ ],
+ }
+
+
@router.get("/pokemon")
async def export_pokemon(session: AsyncSession = Depends(get_session)):
"""Export all pokemon in seed JSON format."""
diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts
index 0ed7570..82f7291 100644
--- a/frontend/src/api/admin.ts
+++ b/frontend/src/api/admin.ts
@@ -94,6 +94,9 @@ export const exportGames = () =>
export const exportGameRoutes = (gameId: number) =>
api.get<{ filename: string; data: unknown }>(`/export/games/${gameId}/routes`)
+export const exportGameBosses = (gameId: number) =>
+ api.get<{ filename: string; data: unknown }>(`/export/games/${gameId}/bosses`)
+
export const exportPokemon = () =>
api.get[]>('/export/pokemon')
diff --git a/frontend/src/pages/admin/AdminGameDetail.tsx b/frontend/src/pages/admin/AdminGameDetail.tsx
index a939da2..6cd115f 100644
--- a/frontend/src/pages/admin/AdminGameDetail.tsx
+++ b/frontend/src/pages/admin/AdminGameDetail.tsx
@@ -32,7 +32,7 @@ import {
useDeleteBossBattle,
useSetBossTeam,
} from '../../hooks/useAdmin'
-import { exportGameRoutes } from '../../api/admin'
+import { exportGameRoutes, exportGameBosses } from '../../api/admin'
import { downloadJson } from '../../utils/download'
import type { Route as GameRoute, CreateRouteInput, UpdateRouteInput, BossBattle } from '../../types'
import type { CreateBossBattleInput, UpdateBossBattleInput } from '../../types/admin'
@@ -118,6 +118,7 @@ export function AdminGameDetail() {
const updateBoss = useUpdateBossBattle(id)
const deleteBoss = useDeleteBossBattle(id)
+ const [tab, setTab] = useState<'routes' | 'bosses'>('routes')
const [showCreate, setShowCreate] = useState(false)
const [editing, setEditing] = useState(null)
const [deleting, setDeleting] = useState(null)
@@ -174,203 +175,236 @@ export function AdminGameDetail() {
-
-
Routes ({routes.length})
-
-
-
-
+
+
+
- {routes.length === 0 ? (
-
- No routes yet. Add one to get started.
-
- ) : (
-
-
-
-
-
- |
-
- Order
- |
-
- Name
- |
-
- Actions
- |
-
-
-
- r.id)}
- strategy={verticalListSortingStrategy}
- >
+ {tab === 'routes' && (
+ <>
+
+
+
+
+
+ {routes.length === 0 ? (
+
+ No routes yet. Add one to get started.
+
+ ) : (
+
+
+
+
+
+ |
+
+ Order
+ |
+
+ Name
+ |
+
+ Actions
+ |
+
+
+
+ r.id)}
+ strategy={verticalListSortingStrategy}
+ >
+
+ {routes.map((route) => (
+ navigate(`/admin/games/${id}/routes/${r.id}`)}
+ />
+ ))}
+
+
+
+
+
+
+ )}
+
+ {showCreate && (
+ 0 ? Math.max(...routes.map((r) => r.order)) + 1 : 1}
+ onSubmit={(data) =>
+ createRoute.mutate(data as CreateRouteInput, {
+ onSuccess: () => setShowCreate(false),
+ })
+ }
+ onClose={() => setShowCreate(false)}
+ isSubmitting={createRoute.isPending}
+ />
+ )}
+
+ {editing && (
+
+ updateRoute.mutate(
+ { routeId: editing.id, data: data as UpdateRouteInput },
+ { onSuccess: () => setEditing(null) },
+ )
+ }
+ onClose={() => setEditing(null)}
+ isSubmitting={updateRoute.isPending}
+ />
+ )}
+
+ {deleting && (
+
+ deleteRoute.mutate(deleting.id, {
+ onSuccess: () => setDeleting(null),
+ })
+ }
+ onCancel={() => setDeleting(null)}
+ isDeleting={deleteRoute.isPending}
+ />
+ )}
+ >
+ )}
+
+ {tab === 'bosses' && (
+ <>
+
+
+
+
+
+ {!bosses || bosses.length === 0 ? (
+
+ No boss battles yet. Add one to get started.
+
+ ) : (
+
+
+
+
+
+ |
+ Order
+ |
+
+ Name
+ |
+
+ Type
+ |
+
+ Location
+ |
+
+ Lv Cap
+ |
+
+ Team
+ |
+
+ Actions
+ |
+
+
- {routes.map((route) => (
- navigate(`/admin/games/${id}/routes/${r.id}`)}
- />
+ {bosses.map((boss) => (
+
+ | {boss.order} |
+ {boss.name} |
+
+ {boss.bossType.replace('_', ' ')}
+ |
+ {boss.location} |
+ {boss.levelCap} |
+ {boss.pokemon.length} |
+
+
+
+
+
+
+ |
+
))}
-
-
-
-
-
- )}
-
- {showCreate && (
- 0 ? Math.max(...routes.map((r) => r.order)) + 1 : 1}
- onSubmit={(data) =>
- createRoute.mutate(data as CreateRouteInput, {
- onSuccess: () => setShowCreate(false),
- })
- }
- onClose={() => setShowCreate(false)}
- isSubmitting={createRoute.isPending}
- />
- )}
-
- {editing && (
-
- updateRoute.mutate(
- { routeId: editing.id, data: data as UpdateRouteInput },
- { onSuccess: () => setEditing(null) },
- )
- }
- onClose={() => setEditing(null)}
- isSubmitting={updateRoute.isPending}
- />
- )}
-
- {deleting && (
-
- deleteRoute.mutate(deleting.id, {
- onSuccess: () => setDeleting(null),
- })
- }
- onCancel={() => setDeleting(null)}
- isDeleting={deleteRoute.isPending}
- />
- )}
-
- {/* Boss Battles Section */}
-
-
-
Boss Battles ({bosses?.length ?? 0})
-
-
-
- {!bosses || bosses.length === 0 ? (
-
- No boss battles yet. Add one to get started.
-
- ) : (
-
-
-
-
-
- |
- Order
- |
-
- Name
- |
-
- Type
- |
-
- Location
- |
-
- Lv Cap
- |
-
- Team
- |
-
- Actions
- |
-
-
-
- {bosses.map((boss) => (
-
- | {boss.order} |
- {boss.name} |
-
- {boss.bossType.replace('_', ' ')}
- |
- {boss.location} |
- {boss.levelCap} |
- {boss.pokemon.length} |
-
-
-
-
-
-
- |
-
- ))}
-
-
+
+
-
- )}
-
+ )}
+ >
+ )}
{/* Boss Battle Modals */}
{showCreateBoss && (