Add bulk import for evolutions, routes, and bosses
Add three new bulk import endpoints that accept the same JSON format as
their corresponding export endpoints, enabling round-trip compatibility:
- POST /evolutions/bulk-import (upsert by from/to pokemon pair)
- POST /games/{id}/routes/bulk-import (reuses seed loader for hierarchy)
- POST /games/{id}/bosses/bulk-import (reuses seed loader with team data)
Generalize BulkImportModal to support all entity types with configurable
title, example, and result labels. Wire up Bulk Import buttons on
AdminEvolutions, and AdminGameDetail routes/bosses tabs.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
# nuzlocke-tracker-5o1v
|
||||||
|
title: Improve encounter method input in route encounter form
|
||||||
|
status: todo
|
||||||
|
type: feature
|
||||||
|
created_at: 2026-02-08T19:06:10Z
|
||||||
|
updated_at: 2026-02-08T19:06:10Z
|
||||||
|
parent: nuzlocke-tracker-iu5b
|
||||||
|
---
|
||||||
|
|
||||||
|
Replace the free-text encounter method input in the route encounter form (RouteEncounterFormModal) with a smarter selector that leverages the known encounter methods already defined in the codebase.
|
||||||
|
|
||||||
|
## Current behavior
|
||||||
|
- The encounter method field in RouteEncounterFormModal is a plain `<input type="text">` with a placeholder "e.g. Walking, Surfing, Fishing"
|
||||||
|
- Easy to introduce typos or inconsistent naming (e.g. "walking" vs "walk" vs "Grass")
|
||||||
|
- The app already has a well-defined set of encounter methods in `EncounterMethodBadge.tsx` with METHOD_CONFIG and METHOD_ORDER (starter, gift, fossil, trade, walk, headbutt, surf, rock-smash, old-rod, good-rod, super-rod)
|
||||||
|
- The backend stores this as a `String(30)` column, so it's not strictly enum-constrained
|
||||||
|
|
||||||
|
## Desired behavior
|
||||||
|
- Replace the free-text input with a dropdown/select that lists the known encounter methods from METHOD_ORDER, using the human-readable labels from getMethodLabel()
|
||||||
|
- Include an "Other" option that reveals a text input for custom methods not in the predefined list
|
||||||
|
- When editing an existing encounter, pre-select the correct method
|
||||||
|
- Consider showing the colored badge preview next to each option for visual consistency with how methods appear elsewhere in the app
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- `frontend/src/components/admin/RouteEncounterFormModal.tsx` — replace the text input with new selector
|
||||||
|
- `frontend/src/components/EncounterMethodBadge.tsx` — export METHOD_CONFIG or add a helper to get the list of known methods
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
---
|
---
|
||||||
# nuzlocke-tracker-ok1t
|
# nuzlocke-tracker-ok1t
|
||||||
title: Bulk import for evolutions, routes, and bosses
|
title: Bulk import for evolutions, routes, and bosses
|
||||||
status: todo
|
status: completed
|
||||||
type: feature
|
type: feature
|
||||||
|
priority: normal
|
||||||
created_at: 2026-02-08T12:33:39Z
|
created_at: 2026-02-08T12:33:39Z
|
||||||
updated_at: 2026-02-08T12:33:39Z
|
updated_at: 2026-02-08T19:13:27Z
|
||||||
parent: nuzlocke-tracker-iu5b
|
parent: nuzlocke-tracker-iu5b
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ from app.models.boss_pokemon import BossPokemon
|
|||||||
from app.models.boss_result import BossResult
|
from app.models.boss_result import BossResult
|
||||||
from app.models.game import Game
|
from app.models.game import Game
|
||||||
from app.models.nuzlocke_run import NuzlockeRun
|
from app.models.nuzlocke_run import NuzlockeRun
|
||||||
|
from app.models.pokemon import Pokemon
|
||||||
|
from app.models.route import Route
|
||||||
from app.schemas.boss import (
|
from app.schemas.boss import (
|
||||||
BossBattleCreate,
|
BossBattleCreate,
|
||||||
BossBattleResponse,
|
BossBattleResponse,
|
||||||
@@ -20,6 +22,8 @@ from app.schemas.boss import (
|
|||||||
BossResultCreate,
|
BossResultCreate,
|
||||||
BossResultResponse,
|
BossResultResponse,
|
||||||
)
|
)
|
||||||
|
from app.schemas.pokemon import BulkBossItem, BulkImportResult
|
||||||
|
from app.seeds.loader import upsert_bosses
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -164,6 +168,34 @@ async def delete_boss(
|
|||||||
return Response(status_code=204)
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/games/{game_id}/bosses/bulk-import", response_model=BulkImportResult)
|
||||||
|
async def bulk_import_bosses(
|
||||||
|
game_id: int,
|
||||||
|
items: list[BulkBossItem],
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
vg_id = await _get_version_group_id(session, game_id)
|
||||||
|
|
||||||
|
# Build pokeapi_id -> id mapping
|
||||||
|
result = await session.execute(select(Pokemon.pokeapi_id, Pokemon.id))
|
||||||
|
dex_to_id = {row.pokeapi_id: row.id for row in result}
|
||||||
|
|
||||||
|
# Build route name -> id mapping for after_route_name resolution
|
||||||
|
result = await session.execute(
|
||||||
|
select(Route.name, Route.id).where(Route.version_group_id == vg_id)
|
||||||
|
)
|
||||||
|
route_name_to_id = {row.name: row.id for row in result}
|
||||||
|
|
||||||
|
bosses_data = [item.model_dump() for item in items]
|
||||||
|
try:
|
||||||
|
count = await upsert_bosses(session, vg_id, bosses_data, dex_to_id, route_name_to_id)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Failed to import bosses: {e}")
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return BulkImportResult(created=count, updated=0, errors=[])
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
"/games/{game_id}/bosses/{boss_id}/pokemon",
|
"/games/{game_id}/bosses/{boss_id}/pokemon",
|
||||||
response_model=BossBattleResponse,
|
response_model=BossBattleResponse,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ from app.core.database import get_session
|
|||||||
from app.models.evolution import Evolution
|
from app.models.evolution import Evolution
|
||||||
from app.models.pokemon import Pokemon
|
from app.models.pokemon import Pokemon
|
||||||
from app.schemas.pokemon import (
|
from app.schemas.pokemon import (
|
||||||
|
BulkEvolutionItem,
|
||||||
|
BulkImportResult,
|
||||||
EvolutionAdminResponse,
|
EvolutionAdminResponse,
|
||||||
EvolutionCreate,
|
EvolutionCreate,
|
||||||
EvolutionUpdate,
|
EvolutionUpdate,
|
||||||
@@ -144,3 +146,65 @@ async def delete_evolution(
|
|||||||
|
|
||||||
await session.delete(evolution)
|
await session.delete(evolution)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/evolutions/bulk-import", response_model=BulkImportResult)
|
||||||
|
async def bulk_import_evolutions(
|
||||||
|
items: list[BulkEvolutionItem],
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
# Build pokeapi_id -> id mapping
|
||||||
|
result = await session.execute(select(Pokemon.pokeapi_id, Pokemon.id))
|
||||||
|
dex_to_id = {row.pokeapi_id: row.id for row in result}
|
||||||
|
|
||||||
|
created = 0
|
||||||
|
updated = 0
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
from_id = dex_to_id.get(item.from_pokeapi_id)
|
||||||
|
to_id = dex_to_id.get(item.to_pokeapi_id)
|
||||||
|
|
||||||
|
if from_id is None:
|
||||||
|
errors.append(f"Pokemon with pokeapi_id {item.from_pokeapi_id} not found")
|
||||||
|
continue
|
||||||
|
if to_id is None:
|
||||||
|
errors.append(f"Pokemon with pokeapi_id {item.to_pokeapi_id} not found")
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if evolution already exists
|
||||||
|
existing = await session.execute(
|
||||||
|
select(Evolution).where(
|
||||||
|
Evolution.from_pokemon_id == from_id,
|
||||||
|
Evolution.to_pokemon_id == to_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
evolution = existing.scalar_one_or_none()
|
||||||
|
|
||||||
|
if evolution is not None:
|
||||||
|
evolution.trigger = item.trigger
|
||||||
|
evolution.min_level = item.min_level
|
||||||
|
evolution.item = item.item
|
||||||
|
evolution.held_item = item.held_item
|
||||||
|
evolution.condition = item.condition
|
||||||
|
evolution.region = item.region
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
evolution = Evolution(
|
||||||
|
from_pokemon_id=from_id,
|
||||||
|
to_pokemon_id=to_id,
|
||||||
|
trigger=item.trigger,
|
||||||
|
min_level=item.min_level,
|
||||||
|
item=item.item,
|
||||||
|
held_item=item.held_item,
|
||||||
|
condition=item.condition,
|
||||||
|
region=item.region,
|
||||||
|
)
|
||||||
|
session.add(evolution)
|
||||||
|
created += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Evolution {item.from_pokeapi_id} -> {item.to_pokeapi_id}: {e}")
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return BulkImportResult(created=created, updated=updated, errors=errors)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from sqlalchemy.orm import selectinload
|
|||||||
from app.core.database import get_session
|
from app.core.database import get_session
|
||||||
from app.models.boss_battle import BossBattle
|
from app.models.boss_battle import BossBattle
|
||||||
from app.models.game import Game
|
from app.models.game import Game
|
||||||
|
from app.models.pokemon import Pokemon
|
||||||
from app.models.route import Route
|
from app.models.route import Route
|
||||||
from app.models.route_encounter import RouteEncounter
|
from app.models.route_encounter import RouteEncounter
|
||||||
from app.schemas.game import (
|
from app.schemas.game import (
|
||||||
@@ -19,6 +20,8 @@ from app.schemas.game import (
|
|||||||
RouteUpdate,
|
RouteUpdate,
|
||||||
RouteWithChildrenResponse,
|
RouteWithChildrenResponse,
|
||||||
)
|
)
|
||||||
|
from app.schemas.pokemon import BulkImportResult, BulkRouteItem
|
||||||
|
from app.seeds.loader import upsert_route_encounters, upsert_routes
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -332,3 +335,68 @@ async def delete_route(
|
|||||||
|
|
||||||
await session.delete(route)
|
await session.delete(route)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{game_id}/routes/bulk-import", response_model=BulkImportResult)
|
||||||
|
async def bulk_import_routes(
|
||||||
|
game_id: int,
|
||||||
|
items: list[BulkRouteItem],
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
vg_id = await _get_version_group_id(session, game_id)
|
||||||
|
|
||||||
|
# Build pokeapi_id -> id mapping for encounter resolution
|
||||||
|
result = await session.execute(select(Pokemon.pokeapi_id, Pokemon.id))
|
||||||
|
dex_to_id = {row.pokeapi_id: row.id for row in result}
|
||||||
|
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
# Upsert routes using the seed loader (handles parent/child hierarchy)
|
||||||
|
routes_data = [item.model_dump() for item in items]
|
||||||
|
try:
|
||||||
|
route_name_to_id = await upsert_routes(session, vg_id, routes_data)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Failed to import routes: {e}")
|
||||||
|
|
||||||
|
# Upsert encounters for each route
|
||||||
|
encounter_count = 0
|
||||||
|
for item in items:
|
||||||
|
route_id = route_name_to_id.get(item.name)
|
||||||
|
if route_id is None:
|
||||||
|
errors.append(f"Route '{item.name}' not found after upsert")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if item.encounters:
|
||||||
|
try:
|
||||||
|
count = await upsert_route_encounters(
|
||||||
|
session, route_id, [e.model_dump() for e in item.encounters],
|
||||||
|
dex_to_id, game_id,
|
||||||
|
)
|
||||||
|
encounter_count += count
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Encounters for '{item.name}': {e}")
|
||||||
|
|
||||||
|
for child in item.children:
|
||||||
|
child_id = route_name_to_id.get(child.name)
|
||||||
|
if child_id is None:
|
||||||
|
errors.append(f"Child route '{child.name}' not found after upsert")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if child.encounters:
|
||||||
|
try:
|
||||||
|
count = await upsert_route_encounters(
|
||||||
|
session, child_id, [e.model_dump() for e in child.encounters],
|
||||||
|
dex_to_id, game_id,
|
||||||
|
)
|
||||||
|
encounter_count += count
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Encounters for '{child.name}': {e}")
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
route_count = len(route_name_to_id)
|
||||||
|
return BulkImportResult(
|
||||||
|
created=route_count,
|
||||||
|
updated=encounter_count,
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|||||||
@@ -158,3 +158,60 @@ class EvolutionUpdate(CamelModel):
|
|||||||
held_item: str | None = None
|
held_item: str | None = None
|
||||||
condition: str | None = None
|
condition: str | None = None
|
||||||
region: str | None = None
|
region: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Bulk import schemas (match export format, snake_case) ---
|
||||||
|
|
||||||
|
|
||||||
|
class BulkEvolutionItem(BaseModel):
|
||||||
|
from_pokeapi_id: int
|
||||||
|
to_pokeapi_id: int
|
||||||
|
trigger: str
|
||||||
|
min_level: int | None = None
|
||||||
|
item: str | None = None
|
||||||
|
held_item: str | None = None
|
||||||
|
condition: str | None = None
|
||||||
|
region: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BulkRouteEncounterItem(BaseModel):
|
||||||
|
pokeapi_id: int
|
||||||
|
method: str
|
||||||
|
encounter_rate: int
|
||||||
|
min_level: int
|
||||||
|
max_level: int
|
||||||
|
|
||||||
|
|
||||||
|
class BulkRouteChildItem(BaseModel):
|
||||||
|
name: str
|
||||||
|
order: int
|
||||||
|
pinwheel_zone: int | None = None
|
||||||
|
encounters: list[BulkRouteEncounterItem] = []
|
||||||
|
|
||||||
|
|
||||||
|
class BulkRouteItem(BaseModel):
|
||||||
|
name: str
|
||||||
|
order: int
|
||||||
|
encounters: list[BulkRouteEncounterItem] = []
|
||||||
|
children: list[BulkRouteChildItem] = []
|
||||||
|
|
||||||
|
|
||||||
|
class BulkBossPokemonItem(BaseModel):
|
||||||
|
pokeapi_id: int
|
||||||
|
level: int
|
||||||
|
order: int
|
||||||
|
|
||||||
|
|
||||||
|
class BulkBossItem(BaseModel):
|
||||||
|
name: str
|
||||||
|
boss_type: str
|
||||||
|
specialty_type: str | None = None
|
||||||
|
badge_name: str | None = None
|
||||||
|
badge_image_url: str | None = None
|
||||||
|
level_cap: int
|
||||||
|
order: int
|
||||||
|
after_route_name: str | None = None
|
||||||
|
location: str
|
||||||
|
section: str | None = None
|
||||||
|
sprite_url: str | None = None
|
||||||
|
pokemon: list[BulkBossPokemonItem] = []
|
||||||
|
|||||||
@@ -70,6 +70,15 @@ export const deletePokemon = (id: number) =>
|
|||||||
export const bulkImportPokemon = (items: Array<{ pokeapiId: number; nationalDex: number; name: string; types: string[]; spriteUrl?: string | null }>) =>
|
export const bulkImportPokemon = (items: Array<{ pokeapiId: number; nationalDex: number; name: string; types: string[]; spriteUrl?: string | null }>) =>
|
||||||
api.post<BulkImportResult>('/pokemon/bulk-import', items)
|
api.post<BulkImportResult>('/pokemon/bulk-import', items)
|
||||||
|
|
||||||
|
export const bulkImportEvolutions = (items: unknown[]) =>
|
||||||
|
api.post<BulkImportResult>('/evolutions/bulk-import', items)
|
||||||
|
|
||||||
|
export const bulkImportRoutes = (gameId: number, items: unknown[]) =>
|
||||||
|
api.post<BulkImportResult>(`/games/${gameId}/routes/bulk-import`, items)
|
||||||
|
|
||||||
|
export const bulkImportBosses = (gameId: number, items: unknown[]) =>
|
||||||
|
api.post<BulkImportResult>(`/games/${gameId}/bosses/bulk-import`, items)
|
||||||
|
|
||||||
// Evolutions
|
// Evolutions
|
||||||
export const listEvolutions = (search?: string, limit = 50, offset = 0) => {
|
export const listEvolutions = (search?: string, limit = 50, offset = 0) => {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
|
|||||||
@@ -2,16 +2,17 @@ import { type FormEvent, useState } from 'react'
|
|||||||
import type { BulkImportResult } from '../../types'
|
import type { BulkImportResult } from '../../types'
|
||||||
|
|
||||||
interface BulkImportModalProps {
|
interface BulkImportModalProps {
|
||||||
onSubmit: (items: Array<{ pokeapiId: number; nationalDex: number; name: string; types: string[]; spriteUrl?: string | null }>) => Promise<BulkImportResult>
|
title: string
|
||||||
|
example?: string
|
||||||
|
onSubmit: (items: unknown[]) => Promise<BulkImportResult>
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
/** Label for the "created" count in the result summary */
|
||||||
|
createdLabel?: string
|
||||||
|
/** Label for the "updated" count in the result summary */
|
||||||
|
updatedLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const EXAMPLE = `[
|
export function BulkImportModal({ title, example, onSubmit, onClose, createdLabel = 'Created', updatedLabel = 'Updated' }: BulkImportModalProps) {
|
||||||
{ "pokeapiId": 1, "nationalDex": 1, "name": "Bulbasaur", "types": ["Grass", "Poison"] },
|
|
||||||
{ "pokeapiId": 4, "nationalDex": 4, "name": "Charmander", "types": ["Fire"] }
|
|
||||||
]`
|
|
||||||
|
|
||||||
export function BulkImportModal({ onSubmit, onClose }: BulkImportModalProps) {
|
|
||||||
const [json, setJson] = useState('')
|
const [json, setJson] = useState('')
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [result, setResult] = useState<BulkImportResult | null>(null)
|
const [result, setResult] = useState<BulkImportResult | null>(null)
|
||||||
@@ -27,13 +28,13 @@ export function BulkImportModal({ onSubmit, onClose }: BulkImportModalProps) {
|
|||||||
items = JSON.parse(json)
|
items = JSON.parse(json)
|
||||||
if (!Array.isArray(items)) throw new Error('Must be an array')
|
if (!Array.isArray(items)) throw new Error('Must be an array')
|
||||||
} catch {
|
} catch {
|
||||||
setError('Invalid JSON. Must be an array of pokemon objects.')
|
setError('Invalid JSON. Must be an array of objects.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const res = await onSubmit(items as Array<{ pokeapiId: number; nationalDex: number; name: string; types: string[] }>)
|
const res = await onSubmit(items)
|
||||||
setResult(res)
|
setResult(res)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Import failed')
|
setError(err instanceof Error ? err.message : 'Import failed')
|
||||||
@@ -47,7 +48,7 @@ export function BulkImportModal({ onSubmit, onClose }: BulkImportModalProps) {
|
|||||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||||
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 className="text-lg font-semibold">Bulk Import Pokemon</h2>
|
<h2 className="text-lg font-semibold">{title}</h2>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="px-6 py-4 space-y-4">
|
<div className="px-6 py-4 space-y-4">
|
||||||
@@ -59,7 +60,7 @@ export function BulkImportModal({ onSubmit, onClose }: BulkImportModalProps) {
|
|||||||
rows={12}
|
rows={12}
|
||||||
value={json}
|
value={json}
|
||||||
onChange={(e) => setJson(e.target.value)}
|
onChange={(e) => setJson(e.target.value)}
|
||||||
placeholder={EXAMPLE}
|
placeholder={example}
|
||||||
className="w-full px-3 py-2 border rounded-md font-mono text-sm dark:bg-gray-700 dark:border-gray-600"
|
className="w-full px-3 py-2 border rounded-md font-mono text-sm dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,7 +73,7 @@ export function BulkImportModal({ onSubmit, onClose }: BulkImportModalProps) {
|
|||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<div className="p-3 bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-md text-sm">
|
<div className="p-3 bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-md text-sm">
|
||||||
<p>Created: {result.created}, Updated: {result.updated}</p>
|
<p>{createdLabel}: {result.created}, {updatedLabel}: {result.updated}</p>
|
||||||
{result.errors.length > 0 && (
|
{result.errors.length > 0 && (
|
||||||
<ul className="mt-2 list-disc list-inside text-red-600 dark:text-red-400">
|
<ul className="mt-2 list-disc list-inside text-red-600 dark:text-red-400">
|
||||||
{result.errors.map((err, i) => (
|
{result.errors.map((err, i) => (
|
||||||
|
|||||||
@@ -174,6 +174,43 @@ export function useBulkImportPokemon() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useBulkImportEvolutions() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (items: unknown[]) => adminApi.bulkImportEvolutions(items),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['evolutions'] })
|
||||||
|
toast.success(`Import complete: ${result.created} created, ${result.updated} updated`)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(`Import failed: ${err.message}`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBulkImportRoutes(gameId: number) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (items: unknown[]) => adminApi.bulkImportRoutes(gameId, items),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['games', gameId] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['games', gameId, 'routes'] })
|
||||||
|
toast.success(`Import complete: ${result.created} routes, ${result.updated} encounters`)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(`Import failed: ${err.message}`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBulkImportBosses(gameId: number) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (items: unknown[]) => adminApi.bulkImportBosses(gameId, items),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['games', gameId, 'bosses'] })
|
||||||
|
toast.success(`Import complete: ${result.created} bosses imported`)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(`Import failed: ${err.message}`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// --- Evolution Queries & Mutations ---
|
// --- Evolution Queries & Mutations ---
|
||||||
|
|
||||||
export function useEvolutionList(search?: string, limit = 50, offset = 0) {
|
export function useEvolutionList(search?: string, limit = 50, offset = 0) {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { AdminTable, type Column } from '../../components/admin/AdminTable'
|
import { AdminTable, type Column } from '../../components/admin/AdminTable'
|
||||||
|
import { BulkImportModal } from '../../components/admin/BulkImportModal'
|
||||||
import { EvolutionFormModal } from '../../components/admin/EvolutionFormModal'
|
import { EvolutionFormModal } from '../../components/admin/EvolutionFormModal'
|
||||||
import {
|
import {
|
||||||
useEvolutionList,
|
useEvolutionList,
|
||||||
useCreateEvolution,
|
useCreateEvolution,
|
||||||
useUpdateEvolution,
|
useUpdateEvolution,
|
||||||
useDeleteEvolution,
|
useDeleteEvolution,
|
||||||
|
useBulkImportEvolutions,
|
||||||
} from '../../hooks/useAdmin'
|
} from '../../hooks/useAdmin'
|
||||||
import { exportEvolutions } from '../../api/admin'
|
import { exportEvolutions } from '../../api/admin'
|
||||||
import { downloadJson } from '../../utils/download'
|
import { downloadJson } from '../../utils/download'
|
||||||
@@ -25,8 +27,10 @@ export function AdminEvolutions() {
|
|||||||
const createEvolution = useCreateEvolution()
|
const createEvolution = useCreateEvolution()
|
||||||
const updateEvolution = useUpdateEvolution()
|
const updateEvolution = useUpdateEvolution()
|
||||||
const deleteEvolution = useDeleteEvolution()
|
const deleteEvolution = useDeleteEvolution()
|
||||||
|
const bulkImport = useBulkImportEvolutions()
|
||||||
|
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [showBulkImport, setShowBulkImport] = useState(false)
|
||||||
const [editing, setEditing] = useState<EvolutionAdmin | null>(null)
|
const [editing, setEditing] = useState<EvolutionAdmin | null>(null)
|
||||||
|
|
||||||
const columns: Column<EvolutionAdmin>[] = [
|
const columns: Column<EvolutionAdmin>[] = [
|
||||||
@@ -71,6 +75,12 @@ export function AdminEvolutions() {
|
|||||||
>
|
>
|
||||||
Export
|
Export
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBulkImport(true)}
|
||||||
|
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
Bulk Import
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreate(true)}
|
onClick={() => setShowCreate(true)}
|
||||||
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
|
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
|
||||||
@@ -146,6 +156,15 @@ export function AdminEvolutions() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showBulkImport && (
|
||||||
|
<BulkImportModal
|
||||||
|
title="Bulk Import Evolutions"
|
||||||
|
example={`[\n { "from_pokeapi_id": 1, "to_pokeapi_id": 2, "trigger": "level-up", "min_level": 16 }\n]`}
|
||||||
|
onSubmit={(items) => bulkImport.mutateAsync(items)}
|
||||||
|
onClose={() => setShowBulkImport(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{showCreate && (
|
{showCreate && (
|
||||||
<EvolutionFormModal
|
<EvolutionFormModal
|
||||||
onSubmit={(data) =>
|
onSubmit={(data) =>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from '@dnd-kit/sortable'
|
} from '@dnd-kit/sortable'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
|
import { BulkImportModal } from '../../components/admin/BulkImportModal'
|
||||||
import { RouteFormModal } from '../../components/admin/RouteFormModal'
|
import { RouteFormModal } from '../../components/admin/RouteFormModal'
|
||||||
import { BossBattleFormModal } from '../../components/admin/BossBattleFormModal'
|
import { BossBattleFormModal } from '../../components/admin/BossBattleFormModal'
|
||||||
import { BossTeamEditor } from '../../components/admin/BossTeamEditor'
|
import { BossTeamEditor } from '../../components/admin/BossTeamEditor'
|
||||||
@@ -27,11 +28,13 @@ import {
|
|||||||
useUpdateRoute,
|
useUpdateRoute,
|
||||||
useDeleteRoute,
|
useDeleteRoute,
|
||||||
useReorderRoutes,
|
useReorderRoutes,
|
||||||
|
useBulkImportRoutes,
|
||||||
useReorderBosses,
|
useReorderBosses,
|
||||||
useCreateBossBattle,
|
useCreateBossBattle,
|
||||||
useUpdateBossBattle,
|
useUpdateBossBattle,
|
||||||
useDeleteBossBattle,
|
useDeleteBossBattle,
|
||||||
useSetBossTeam,
|
useSetBossTeam,
|
||||||
|
useBulkImportBosses,
|
||||||
} from '../../hooks/useAdmin'
|
} from '../../hooks/useAdmin'
|
||||||
import { exportGameRoutes, exportGameBosses } from '../../api/admin'
|
import { exportGameRoutes, exportGameBosses } from '../../api/admin'
|
||||||
import { downloadJson } from '../../utils/download'
|
import { downloadJson } from '../../utils/download'
|
||||||
@@ -149,16 +152,20 @@ export function AdminGameDetail() {
|
|||||||
const updateRoute = useUpdateRoute(id)
|
const updateRoute = useUpdateRoute(id)
|
||||||
const deleteRoute = useDeleteRoute(id)
|
const deleteRoute = useDeleteRoute(id)
|
||||||
const reorderRoutes = useReorderRoutes(id)
|
const reorderRoutes = useReorderRoutes(id)
|
||||||
|
const bulkImportRoutes = useBulkImportRoutes(id)
|
||||||
const { data: bosses } = useGameBosses(id)
|
const { data: bosses } = useGameBosses(id)
|
||||||
const createBoss = useCreateBossBattle(id)
|
const createBoss = useCreateBossBattle(id)
|
||||||
const updateBoss = useUpdateBossBattle(id)
|
const updateBoss = useUpdateBossBattle(id)
|
||||||
const deleteBoss = useDeleteBossBattle(id)
|
const deleteBoss = useDeleteBossBattle(id)
|
||||||
const reorderBosses = useReorderBosses(id)
|
const reorderBosses = useReorderBosses(id)
|
||||||
|
const bulkImportBosses = useBulkImportBosses(id)
|
||||||
|
|
||||||
const [tab, setTab] = useState<'routes' | 'bosses'>('routes')
|
const [tab, setTab] = useState<'routes' | 'bosses'>('routes')
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [showBulkImportRoutes, setShowBulkImportRoutes] = useState(false)
|
||||||
const [editing, setEditing] = useState<GameRoute | null>(null)
|
const [editing, setEditing] = useState<GameRoute | null>(null)
|
||||||
const [showCreateBoss, setShowCreateBoss] = useState(false)
|
const [showCreateBoss, setShowCreateBoss] = useState(false)
|
||||||
|
const [showBulkImportBosses, setShowBulkImportBosses] = useState(false)
|
||||||
const [editingBoss, setEditingBoss] = useState<BossBattle | null>(null)
|
const [editingBoss, setEditingBoss] = useState<BossBattle | null>(null)
|
||||||
const [editingTeam, setEditingTeam] = useState<BossBattle | null>(null)
|
const [editingTeam, setEditingTeam] = useState<BossBattle | null>(null)
|
||||||
|
|
||||||
@@ -265,6 +272,12 @@ export function AdminGameDetail() {
|
|||||||
>
|
>
|
||||||
Export
|
Export
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBulkImportRoutes(true)}
|
||||||
|
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
Bulk Import
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreate(true)}
|
onClick={() => setShowCreate(true)}
|
||||||
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
|
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
|
||||||
@@ -273,6 +286,17 @@ export function AdminGameDetail() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showBulkImportRoutes && (
|
||||||
|
<BulkImportModal
|
||||||
|
title="Bulk Import Routes"
|
||||||
|
example={`[\n { "name": "Route 1", "order": 1, "encounters": [...], "children": [...] }\n]`}
|
||||||
|
createdLabel="Routes"
|
||||||
|
updatedLabel="Encounters"
|
||||||
|
onSubmit={(items) => bulkImportRoutes.mutateAsync(items)}
|
||||||
|
onClose={() => setShowBulkImportRoutes(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{routes.length === 0 ? (
|
{routes.length === 0 ? (
|
||||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700 rounded-lg">
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||||
No routes yet. Add one to get started.
|
No routes yet. Add one to get started.
|
||||||
@@ -365,6 +389,12 @@ export function AdminGameDetail() {
|
|||||||
>
|
>
|
||||||
Export
|
Export
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBulkImportBosses(true)}
|
||||||
|
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
Bulk Import
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreateBoss(true)}
|
onClick={() => setShowCreateBoss(true)}
|
||||||
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
|
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
|
||||||
@@ -373,6 +403,17 @@ export function AdminGameDetail() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showBulkImportBosses && (
|
||||||
|
<BulkImportModal
|
||||||
|
title="Bulk Import Boss Battles"
|
||||||
|
example={`[\n { "name": "Brock", "boss_type": "gym_leader", "level_cap": 15, "order": 1, "location": "Pewter City", "pokemon": [...] }\n]`}
|
||||||
|
createdLabel="Bosses"
|
||||||
|
updatedLabel="Updated"
|
||||||
|
onSubmit={(items) => bulkImportBosses.mutateAsync(items)}
|
||||||
|
onClose={() => setShowBulkImportBosses(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{!bosses || bosses.length === 0 ? (
|
{!bosses || bosses.length === 0 ? (
|
||||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700 rounded-lg">
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||||
No boss battles yet. Add one to get started.
|
No boss battles yet. Add one to get started.
|
||||||
|
|||||||
@@ -158,7 +158,9 @@ export function AdminPokemon() {
|
|||||||
|
|
||||||
{showBulkImport && (
|
{showBulkImport && (
|
||||||
<BulkImportModal
|
<BulkImportModal
|
||||||
onSubmit={(items) => bulkImport.mutateAsync(items)}
|
title="Bulk Import Pokemon"
|
||||||
|
example={`[\n { "pokeapi_id": 1, "national_dex": 1, "name": "Bulbasaur", "types": ["Grass", "Poison"] }\n]`}
|
||||||
|
onSubmit={(items) => bulkImport.mutateAsync(items as Parameters<typeof bulkImport.mutateAsync>[0])}
|
||||||
onClose={() => setShowBulkImport(false)}
|
onClose={() => setShowBulkImport(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user