diff --git a/.beans/nuzlocke-tracker-k7ot--genlocke-management-in-admin-panel.md b/.beans/nuzlocke-tracker-k7ot--genlocke-management-in-admin-panel.md index e67b867..aa9aa20 100644 --- a/.beans/nuzlocke-tracker-k7ot--genlocke-management-in-admin-panel.md +++ b/.beans/nuzlocke-tracker-k7ot--genlocke-management-in-admin-panel.md @@ -1,10 +1,11 @@ --- # nuzlocke-tracker-k7ot title: Genlocke management in admin panel -status: todo +status: in-progress type: feature +priority: normal created_at: 2026-02-09T08:38:13Z -updated_at: 2026-02-09T08:38:13Z +updated_at: 2026-02-09T09:43:04Z parent: nuzlocke-tracker-25mh --- @@ -41,12 +42,12 @@ Add genlocke management to the admin panel, allowing admins to view, edit, and d - API client functions and hooks for admin CRUD operations ## Checklist -- [ ] Implement `GET /api/v1/genlockes` list endpoint -- [ ] Implement `GET /api/v1/genlockes/{id}` detail endpoint -- [ ] Implement `PATCH /api/v1/genlockes/{id}` update endpoint -- [ ] Implement `DELETE /api/v1/genlockes/{id}` delete endpoint -- [ ] Implement leg management endpoints (add, remove, reorder) -- [ ] Build admin genlocke list page -- [ ] Build admin genlocke detail/edit page -- [ ] Add admin routes and sidebar navigation -- [ ] Add frontend API client and hooks for genlocke admin \ No newline at end of file +- [x] Implement `GET /api/v1/genlockes` list endpoint +- [x] Implement `GET /api/v1/genlockes/{id}` detail endpoint +- [x] Implement `PATCH /api/v1/genlockes/{id}` update endpoint +- [x] Implement `DELETE /api/v1/genlockes/{id}` delete endpoint +- [x] Implement leg management endpoints (add, remove) +- [x] Build admin genlocke list page +- [x] Build admin genlocke detail/edit page +- [x] Add admin routes and sidebar navigation +- [x] Add frontend API client and hooks for genlocke admin \ No newline at end of file diff --git a/backend/src/app/api/genlockes.py b/backend/src/app/api/genlockes.py index 98ee358..d35c1a6 100644 --- a/backend/src/app/api/genlockes.py +++ b/backend/src/app/api/genlockes.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel -from sqlalchemy import func, select +from sqlalchemy import delete as sa_delete, func, select, update as sa_update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -12,12 +12,14 @@ from app.models.genlocke import Genlocke, GenlockeLeg from app.models.nuzlocke_run import NuzlockeRun from app.models.pokemon import Pokemon from app.schemas.genlocke import ( + AddLegRequest, GenlockeCreate, GenlockeDetailResponse, GenlockeLegDetailResponse, GenlockeListItem, GenlockeResponse, GenlockeStatsResponse, + GenlockeUpdate, RetiredPokemonResponse, ) from app.services.families import build_families @@ -393,3 +395,150 @@ async def get_retired_families( retired_pokemon_ids=sorted(cumulative), by_leg=by_leg, ) + + +@router.patch("/{genlocke_id}", response_model=GenlockeResponse) +async def update_genlocke( + genlocke_id: int, + data: GenlockeUpdate, + session: AsyncSession = Depends(get_session), +): + result = await session.execute( + select(Genlocke) + .where(Genlocke.id == genlocke_id) + .options( + selectinload(Genlocke.legs).selectinload(GenlockeLeg.game), + ) + ) + genlocke = result.scalar_one_or_none() + if genlocke is None: + raise HTTPException(status_code=404, detail="Genlocke not found") + + update_data = data.model_dump(exclude_unset=True) + + if "status" in update_data: + if update_data["status"] not in ("active", "completed", "failed"): + raise HTTPException( + status_code=400, + detail="Status must be one of: active, completed, failed", + ) + + for field, value in update_data.items(): + setattr(genlocke, field, value) + + await session.commit() + await session.refresh(genlocke) + return genlocke + + +@router.delete("/{genlocke_id}", status_code=204) +async def delete_genlocke( + genlocke_id: int, + session: AsyncSession = Depends(get_session), +): + genlocke = await session.get(Genlocke, genlocke_id) + if genlocke is None: + raise HTTPException(status_code=404, detail="Genlocke not found") + + # Unlink runs from legs so runs are preserved + await session.execute( + sa_update(GenlockeLeg) + .where(GenlockeLeg.genlocke_id == genlocke_id) + .values(run_id=None) + ) + + # Delete legs explicitly to avoid ORM cascade issues + # (genlocke_id is non-nullable, so SQLAlchemy can't nullify it) + await session.execute( + sa_delete(GenlockeLeg) + .where(GenlockeLeg.genlocke_id == genlocke_id) + ) + + await session.delete(genlocke) + await session.commit() + + +@router.post( + "/{genlocke_id}/legs", + response_model=GenlockeResponse, + status_code=201, +) +async def add_leg( + genlocke_id: int, + data: AddLegRequest, + session: AsyncSession = Depends(get_session), +): + genlocke = await session.get(Genlocke, genlocke_id) + if genlocke is None: + raise HTTPException(status_code=404, detail="Genlocke not found") + + # Validate game exists + game = await session.get(Game, data.game_id) + if game is None: + raise HTTPException(status_code=404, detail="Game not found") + + # Find max leg_order + max_order_result = await session.execute( + select(func.max(GenlockeLeg.leg_order)).where( + GenlockeLeg.genlocke_id == genlocke_id + ) + ) + max_order = max_order_result.scalar() or 0 + + leg = GenlockeLeg( + genlocke_id=genlocke_id, + game_id=data.game_id, + leg_order=max_order + 1, + ) + session.add(leg) + await session.commit() + + # Reload with relationships + result = await session.execute( + select(Genlocke) + .where(Genlocke.id == genlocke_id) + .options( + selectinload(Genlocke.legs).selectinload(GenlockeLeg.game), + ) + ) + return result.scalar_one() + + +@router.delete("/{genlocke_id}/legs/{leg_id}", status_code=204) +async def remove_leg( + genlocke_id: int, + leg_id: int, + session: AsyncSession = Depends(get_session), +): + result = await session.execute( + select(GenlockeLeg).where( + GenlockeLeg.id == leg_id, + GenlockeLeg.genlocke_id == genlocke_id, + ) + ) + leg = result.scalar_one_or_none() + if leg is None: + raise HTTPException(status_code=404, detail="Leg not found") + + if leg.run_id is not None: + raise HTTPException( + status_code=400, + detail="Cannot remove a leg that has a linked run. Delete or unlink the run first.", + ) + + removed_order = leg.leg_order + await session.delete(leg) + + # Re-number remaining legs to keep leg_order contiguous + remaining_result = await session.execute( + select(GenlockeLeg) + .where( + GenlockeLeg.genlocke_id == genlocke_id, + GenlockeLeg.leg_order > removed_order, + ) + .order_by(GenlockeLeg.leg_order) + ) + for remaining_leg in remaining_result.scalars().all(): + remaining_leg.leg_order -= 1 + + await session.commit() diff --git a/backend/src/app/schemas/genlocke.py b/backend/src/app/schemas/genlocke.py index af6d838..18ef69e 100644 --- a/backend/src/app/schemas/genlocke.py +++ b/backend/src/app/schemas/genlocke.py @@ -11,6 +11,15 @@ class GenlockeCreate(CamelModel): nuzlocke_rules: dict = {} +class GenlockeUpdate(CamelModel): + name: str | None = None + status: str | None = None + + +class AddLegRequest(CamelModel): + game_id: int + + class GenlockeLegResponse(CamelModel): id: int genlocke_id: int diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6108aff..e7bf0e5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,8 @@ import { AdminRouteDetail, AdminEvolutions, AdminRuns, + AdminGenlockes, + AdminGenlockeDetail, } from './pages/admin' function App() { @@ -32,6 +34,8 @@ function App() { } /> } /> } /> + } /> + } /> diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 4f59ccc..89273fc 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -24,7 +24,10 @@ import type { UpdateBossBattleInput, BossPokemonInput, BossReorderItem, + UpdateGenlockeInput, + AddGenlockeLegInput, } from '../types' +import type { Genlocke } from '../types/game' // Games export const createGame = (data: CreateGameInput) => @@ -140,3 +143,16 @@ export const reorderBosses = (gameId: number, bosses: BossReorderItem[]) => export const setBossTeam = (gameId: number, bossId: number, team: BossPokemonInput[]) => api.put(`/games/${gameId}/bosses/${bossId}/pokemon`, team) + +// Genlockes +export const updateGenlocke = (id: number, data: UpdateGenlockeInput) => + api.patch(`/genlockes/${id}`, data) + +export const deleteGenlocke = (id: number) => + api.del(`/genlockes/${id}`) + +export const addGenlockeLeg = (genlockeId: number, data: AddGenlockeLegInput) => + api.post(`/genlockes/${genlockeId}/legs`, data) + +export const deleteGenlockeLeg = (genlockeId: number, legId: number) => + api.del(`/genlockes/${genlockeId}/legs/${legId}`) diff --git a/frontend/src/components/admin/AdminLayout.tsx b/frontend/src/components/admin/AdminLayout.tsx index 0c55532..8e19c47 100644 --- a/frontend/src/components/admin/AdminLayout.tsx +++ b/frontend/src/components/admin/AdminLayout.tsx @@ -5,6 +5,7 @@ const navItems = [ { to: '/admin/pokemon', label: 'Pokemon' }, { to: '/admin/evolutions', label: 'Evolutions' }, { to: '/admin/runs', label: 'Runs' }, + { to: '/admin/genlockes', label: 'Genlockes' }, ] export function AdminLayout() { diff --git a/frontend/src/hooks/useAdmin.ts b/frontend/src/hooks/useAdmin.ts index 7246be2..ace2460 100644 --- a/frontend/src/hooks/useAdmin.ts +++ b/frontend/src/hooks/useAdmin.ts @@ -17,6 +17,8 @@ import type { UpdateBossBattleInput, BossPokemonInput, BossReorderItem, + UpdateGenlockeInput, + AddGenlockeLegInput, } from '../types' // --- Queries --- @@ -360,3 +362,56 @@ export function useSetBossTeam(gameId: number, bossId: number) { onError: (err) => toast.error(`Failed to update boss team: ${err.message}`), }) } + +// --- Genlocke Mutations --- + +export function useUpdateGenlocke() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, data }: { id: number; data: UpdateGenlockeInput }) => + adminApi.updateGenlocke(id, data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['genlockes'] }) + toast.success('Genlocke updated') + }, + onError: (err) => toast.error(`Failed to update genlocke: ${err.message}`), + }) +} + +export function useDeleteGenlocke() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: number) => adminApi.deleteGenlocke(id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['genlockes'] }) + toast.success('Genlocke deleted') + }, + onError: (err) => toast.error(`Failed to delete genlocke: ${err.message}`), + }) +} + +export function useAddGenlockeLeg(genlockeId: number) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (data: AddGenlockeLegInput) => adminApi.addGenlockeLeg(genlockeId, data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['genlockes'] }) + qc.invalidateQueries({ queryKey: ['genlockes', genlockeId] }) + toast.success('Leg added') + }, + onError: (err) => toast.error(`Failed to add leg: ${err.message}`), + }) +} + +export function useDeleteGenlockeLeg(genlockeId: number) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (legId: number) => adminApi.deleteGenlockeLeg(genlockeId, legId), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['genlockes'] }) + qc.invalidateQueries({ queryKey: ['genlockes', genlockeId] }) + toast.success('Leg removed') + }, + onError: (err) => toast.error(`Failed to remove leg: ${err.message}`), + }) +} diff --git a/frontend/src/pages/admin/AdminGenlockeDetail.tsx b/frontend/src/pages/admin/AdminGenlockeDetail.tsx new file mode 100644 index 0000000..122e728 --- /dev/null +++ b/frontend/src/pages/admin/AdminGenlockeDetail.tsx @@ -0,0 +1,319 @@ +import { useState } from 'react' +import { useParams, useNavigate, Link } from 'react-router-dom' +import { useGenlocke } from '../../hooks/useGenlockes' +import { useGames } from '../../hooks/useGames' +import { + useUpdateGenlocke, + useDeleteGenlocke, + useAddGenlockeLeg, + useDeleteGenlockeLeg, +} from '../../hooks/useAdmin' +import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal' + +export function AdminGenlockeDetail() { + const { genlockeId } = useParams<{ genlockeId: string }>() + const id = Number(genlockeId) + const navigate = useNavigate() + const { data: genlocke, isLoading } = useGenlocke(id) + const { data: games = [] } = useGames() + + const updateGenlocke = useUpdateGenlocke() + const deleteGenlocke = useDeleteGenlocke() + const addLeg = useAddGenlockeLeg(id) + const deleteLeg = useDeleteGenlockeLeg(id) + + const [name, setName] = useState(null) + const [status, setStatus] = useState(null) + const [showDelete, setShowDelete] = useState(false) + const [addingLeg, setAddingLeg] = useState(false) + const [selectedGameId, setSelectedGameId] = useState('') + + if (isLoading) return
Loading...
+ if (!genlocke) return
Genlocke not found
+ + const editName = name ?? genlocke.name + const editStatus = status ?? genlocke.status + + const hasChanges = editName !== genlocke.name || editStatus !== genlocke.status + + const handleSave = () => { + const data: Record = {} + if (editName !== genlocke.name) data.name = editName + if (editStatus !== genlocke.status) data.status = editStatus + if (Object.keys(data).length === 0) return + updateGenlocke.mutate( + { id, data }, + { + onSuccess: () => { + setName(null) + setStatus(null) + }, + }, + ) + } + + const handleAddLeg = () => { + if (!selectedGameId) return + addLeg.mutate( + { gameId: Number(selectedGameId) }, + { + onSuccess: () => { + setAddingLeg(false) + setSelectedGameId('') + }, + }, + ) + } + + return ( +
+ + + {/* Header */} +
+
+
+ + setName(e.target.value)} + className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" + /> +
+
+ + +
+ + +
+

+ Created {new Date(genlocke.createdAt).toLocaleDateString()} +

+
+ + {/* Rules (read-only) */} +
+

Rules

+
+
+ Genlocke rules: +
+              {JSON.stringify(genlocke.genlockeRules, null, 2)}
+            
+
+
+ Nuzlocke rules: +
+              {JSON.stringify(genlocke.nuzlockeRules, null, 2)}
+            
+
+
+
+ + {/* Legs */} +
+
+

Legs ({genlocke.legs.length})

+ +
+ + {addingLeg && ( +
+ + + +
+ )} + + {genlocke.legs.length === 0 ? ( +
+ No legs yet. Add one to get started. +
+ ) : ( +
+
+ + + + + + + + + + + + + + {genlocke.legs.map((leg) => ( + + + + + + + + + + ))} + +
+ Order + + Game + + Run + + Status + + Enc. + + Deaths + + Actions +
{leg.legOrder}{leg.game.name} + {leg.runId ? ( + + Run #{leg.runId} + + ) : ( + + )} + + {leg.runStatus ? ( + + {leg.runStatus} + + ) : ( + + )} + {leg.encounterCount}{leg.deathCount} + +
+
+
+ )} +
+ + {/* Stats */} +
+

Stats

+
+
+ Legs +

{genlocke.stats.legsCompleted} / {genlocke.stats.totalLegs}

+
+
+ Encounters +

{genlocke.stats.totalEncounters}

+
+
+ Deaths +

{genlocke.stats.totalDeaths}

+
+
+ Survival Rate +

+ {genlocke.stats.totalEncounters > 0 + ? `${Math.round(((genlocke.stats.totalEncounters - genlocke.stats.totalDeaths) / genlocke.stats.totalEncounters) * 100)}%` + : '\u2014'} +

+
+
+
+ + {showDelete && ( + + deleteGenlocke.mutate(id, { + onSuccess: () => navigate('/admin/genlockes'), + }) + } + onCancel={() => setShowDelete(false)} + isDeleting={deleteGenlocke.isPending} + /> + )} +
+ ) +} diff --git a/frontend/src/pages/admin/AdminGenlockes.tsx b/frontend/src/pages/admin/AdminGenlockes.tsx new file mode 100644 index 0000000..6d9949c --- /dev/null +++ b/frontend/src/pages/admin/AdminGenlockes.tsx @@ -0,0 +1,107 @@ +import { useState, useMemo } from 'react' +import { useNavigate } from 'react-router-dom' +import { AdminTable, type Column } from '../../components/admin/AdminTable' +import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal' +import { useGenlockes } from '../../hooks/useGenlockes' +import { useDeleteGenlocke } from '../../hooks/useAdmin' +import type { GenlockeListItem } from '../../types/game' + +export function AdminGenlockes() { + const { data: genlockes = [], isLoading } = useGenlockes() + const deleteGenlocke = useDeleteGenlocke() + const navigate = useNavigate() + + const [deleting, setDeleting] = useState(null) + const [statusFilter, setStatusFilter] = useState('') + + const filtered = useMemo(() => { + if (!statusFilter) return genlockes + return genlockes.filter((g) => g.status === statusFilter) + }, [genlockes, statusFilter]) + + const columns: Column[] = [ + { header: 'Name', accessor: (g) => g.name, sortKey: (g) => g.name }, + { + header: 'Status', + accessor: (g) => ( + + {g.status} + + ), + sortKey: (g) => g.status, + }, + { + header: 'Legs', + accessor: (g) => `${g.completedLegs} / ${g.totalLegs}`, + sortKey: (g) => g.totalLegs, + }, + { + header: 'Created', + accessor: (g) => new Date(g.createdAt).toLocaleDateString(), + sortKey: (g) => g.createdAt, + }, + ] + + return ( +
+
+

Genlockes

+
+ +
+ + {statusFilter && ( + + )} + + {filtered.length} genlockes + +
+ + g.id} + onRowClick={(g) => navigate(`/admin/genlockes/${g.id}`)} + /> + + {deleting && ( + + deleteGenlocke.mutate(deleting.id, { + onSuccess: () => setDeleting(null), + }) + } + onCancel={() => setDeleting(null)} + isDeleting={deleteGenlocke.isPending} + /> + )} +
+ ) +} diff --git a/frontend/src/pages/admin/index.ts b/frontend/src/pages/admin/index.ts index 6779596..766dd3e 100644 --- a/frontend/src/pages/admin/index.ts +++ b/frontend/src/pages/admin/index.ts @@ -4,3 +4,5 @@ export { AdminPokemon } from './AdminPokemon' export { AdminRouteDetail } from './AdminRouteDetail' export { AdminEvolutions } from './AdminEvolutions' export { AdminRuns } from './AdminRuns' +export { AdminGenlockes } from './AdminGenlockes' +export { AdminGenlockeDetail } from './AdminGenlockeDetail' diff --git a/frontend/src/types/admin.ts b/frontend/src/types/admin.ts index 30769c4..4535c77 100644 --- a/frontend/src/types/admin.ts +++ b/frontend/src/types/admin.ts @@ -178,3 +178,13 @@ export interface BossPokemonInput { order: number conditionLabel?: string | null } + +// Genlocke admin +export interface UpdateGenlockeInput { + name?: string + status?: string +} + +export interface AddGenlockeLegInput { + gameId: number +}