Add genlocke admin panel with CRUD endpoints and UI
Backend: PATCH/DELETE genlocke, POST/DELETE legs with order re-numbering. Frontend: admin list page with status filter, detail page with inline editing, legs table, and stats display. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,11 @@
|
|||||||
---
|
---
|
||||||
# nuzlocke-tracker-k7ot
|
# nuzlocke-tracker-k7ot
|
||||||
title: Genlocke management in admin panel
|
title: Genlocke management in admin panel
|
||||||
status: todo
|
status: in-progress
|
||||||
type: feature
|
type: feature
|
||||||
|
priority: normal
|
||||||
created_at: 2026-02-09T08:38:13Z
|
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
|
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
|
- API client functions and hooks for admin CRUD operations
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
- [ ] Implement `GET /api/v1/genlockes` list endpoint
|
- [x] Implement `GET /api/v1/genlockes` list endpoint
|
||||||
- [ ] Implement `GET /api/v1/genlockes/{id}` detail endpoint
|
- [x] Implement `GET /api/v1/genlockes/{id}` detail endpoint
|
||||||
- [ ] Implement `PATCH /api/v1/genlockes/{id}` update endpoint
|
- [x] Implement `PATCH /api/v1/genlockes/{id}` update endpoint
|
||||||
- [ ] Implement `DELETE /api/v1/genlockes/{id}` delete endpoint
|
- [x] Implement `DELETE /api/v1/genlockes/{id}` delete endpoint
|
||||||
- [ ] Implement leg management endpoints (add, remove, reorder)
|
- [x] Implement leg management endpoints (add, remove)
|
||||||
- [ ] Build admin genlocke list page
|
- [x] Build admin genlocke list page
|
||||||
- [ ] Build admin genlocke detail/edit page
|
- [x] Build admin genlocke detail/edit page
|
||||||
- [ ] Add admin routes and sidebar navigation
|
- [x] Add admin routes and sidebar navigation
|
||||||
- [ ] Add frontend API client and hooks for genlocke admin
|
- [x] Add frontend API client and hooks for genlocke admin
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
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.nuzlocke_run import NuzlockeRun
|
||||||
from app.models.pokemon import Pokemon
|
from app.models.pokemon import Pokemon
|
||||||
from app.schemas.genlocke import (
|
from app.schemas.genlocke import (
|
||||||
|
AddLegRequest,
|
||||||
GenlockeCreate,
|
GenlockeCreate,
|
||||||
GenlockeDetailResponse,
|
GenlockeDetailResponse,
|
||||||
GenlockeLegDetailResponse,
|
GenlockeLegDetailResponse,
|
||||||
GenlockeListItem,
|
GenlockeListItem,
|
||||||
GenlockeResponse,
|
GenlockeResponse,
|
||||||
GenlockeStatsResponse,
|
GenlockeStatsResponse,
|
||||||
|
GenlockeUpdate,
|
||||||
RetiredPokemonResponse,
|
RetiredPokemonResponse,
|
||||||
)
|
)
|
||||||
from app.services.families import build_families
|
from app.services.families import build_families
|
||||||
@@ -393,3 +395,150 @@ async def get_retired_families(
|
|||||||
retired_pokemon_ids=sorted(cumulative),
|
retired_pokemon_ids=sorted(cumulative),
|
||||||
by_leg=by_leg,
|
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()
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ class GenlockeCreate(CamelModel):
|
|||||||
nuzlocke_rules: dict = {}
|
nuzlocke_rules: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
class GenlockeUpdate(CamelModel):
|
||||||
|
name: str | None = None
|
||||||
|
status: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AddLegRequest(CamelModel):
|
||||||
|
game_id: int
|
||||||
|
|
||||||
|
|
||||||
class GenlockeLegResponse(CamelModel):
|
class GenlockeLegResponse(CamelModel):
|
||||||
id: int
|
id: int
|
||||||
genlocke_id: int
|
genlocke_id: int
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
AdminRouteDetail,
|
AdminRouteDetail,
|
||||||
AdminEvolutions,
|
AdminEvolutions,
|
||||||
AdminRuns,
|
AdminRuns,
|
||||||
|
AdminGenlockes,
|
||||||
|
AdminGenlockeDetail,
|
||||||
} from './pages/admin'
|
} from './pages/admin'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -32,6 +34,8 @@ function App() {
|
|||||||
<Route path="pokemon" element={<AdminPokemon />} />
|
<Route path="pokemon" element={<AdminPokemon />} />
|
||||||
<Route path="evolutions" element={<AdminEvolutions />} />
|
<Route path="evolutions" element={<AdminEvolutions />} />
|
||||||
<Route path="runs" element={<AdminRuns />} />
|
<Route path="runs" element={<AdminRuns />} />
|
||||||
|
<Route path="genlockes" element={<AdminGenlockes />} />
|
||||||
|
<Route path="genlockes/:genlockeId" element={<AdminGenlockeDetail />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ import type {
|
|||||||
UpdateBossBattleInput,
|
UpdateBossBattleInput,
|
||||||
BossPokemonInput,
|
BossPokemonInput,
|
||||||
BossReorderItem,
|
BossReorderItem,
|
||||||
|
UpdateGenlockeInput,
|
||||||
|
AddGenlockeLegInput,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
|
import type { Genlocke } from '../types/game'
|
||||||
|
|
||||||
// Games
|
// Games
|
||||||
export const createGame = (data: CreateGameInput) =>
|
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[]) =>
|
export const setBossTeam = (gameId: number, bossId: number, team: BossPokemonInput[]) =>
|
||||||
api.put<BossBattle>(`/games/${gameId}/bosses/${bossId}/pokemon`, team)
|
api.put<BossBattle>(`/games/${gameId}/bosses/${bossId}/pokemon`, team)
|
||||||
|
|
||||||
|
// Genlockes
|
||||||
|
export const updateGenlocke = (id: number, data: UpdateGenlockeInput) =>
|
||||||
|
api.patch<Genlocke>(`/genlockes/${id}`, data)
|
||||||
|
|
||||||
|
export const deleteGenlocke = (id: number) =>
|
||||||
|
api.del(`/genlockes/${id}`)
|
||||||
|
|
||||||
|
export const addGenlockeLeg = (genlockeId: number, data: AddGenlockeLegInput) =>
|
||||||
|
api.post<Genlocke>(`/genlockes/${genlockeId}/legs`, data)
|
||||||
|
|
||||||
|
export const deleteGenlockeLeg = (genlockeId: number, legId: number) =>
|
||||||
|
api.del(`/genlockes/${genlockeId}/legs/${legId}`)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const navItems = [
|
|||||||
{ to: '/admin/pokemon', label: 'Pokemon' },
|
{ to: '/admin/pokemon', label: 'Pokemon' },
|
||||||
{ to: '/admin/evolutions', label: 'Evolutions' },
|
{ to: '/admin/evolutions', label: 'Evolutions' },
|
||||||
{ to: '/admin/runs', label: 'Runs' },
|
{ to: '/admin/runs', label: 'Runs' },
|
||||||
|
{ to: '/admin/genlockes', label: 'Genlockes' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function AdminLayout() {
|
export function AdminLayout() {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import type {
|
|||||||
UpdateBossBattleInput,
|
UpdateBossBattleInput,
|
||||||
BossPokemonInput,
|
BossPokemonInput,
|
||||||
BossReorderItem,
|
BossReorderItem,
|
||||||
|
UpdateGenlockeInput,
|
||||||
|
AddGenlockeLegInput,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
|
|
||||||
// --- Queries ---
|
// --- Queries ---
|
||||||
@@ -360,3 +362,56 @@ export function useSetBossTeam(gameId: number, bossId: number) {
|
|||||||
onError: (err) => toast.error(`Failed to update boss team: ${err.message}`),
|
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}`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
319
frontend/src/pages/admin/AdminGenlockeDetail.tsx
Normal file
319
frontend/src/pages/admin/AdminGenlockeDetail.tsx
Normal file
@@ -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<string | null>(null)
|
||||||
|
const [status, setStatus] = useState<string | null>(null)
|
||||||
|
const [showDelete, setShowDelete] = useState(false)
|
||||||
|
const [addingLeg, setAddingLeg] = useState(false)
|
||||||
|
const [selectedGameId, setSelectedGameId] = useState<number | ''>('')
|
||||||
|
|
||||||
|
if (isLoading) return <div className="py-8 text-center text-gray-500">Loading...</div>
|
||||||
|
if (!genlocke) return <div className="py-8 text-center text-gray-500">Genlocke not found</div>
|
||||||
|
|
||||||
|
const editName = name ?? genlocke.name
|
||||||
|
const editStatus = status ?? genlocke.status
|
||||||
|
|
||||||
|
const hasChanges = editName !== genlocke.name || editStatus !== genlocke.status
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const data: Record<string, string> = {}
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<nav className="text-sm mb-4 text-gray-500 dark:text-gray-400">
|
||||||
|
<Link to="/admin/genlockes" className="hover:underline">
|
||||||
|
Genlockes
|
||||||
|
</Link>
|
||||||
|
{' / '}
|
||||||
|
<span className="text-gray-900 dark:text-gray-100">{genlocke.name}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6 space-y-4">
|
||||||
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
Status
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={editStatus}
|
||||||
|
onChange={(e) => setStatus(e.target.value)}
|
||||||
|
className="px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
<option value="failed">Failed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!hasChanges || updateGenlocke.isPending}
|
||||||
|
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{updateGenlocke.isPending ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDelete(true)}
|
||||||
|
className="px-4 py-2 text-sm font-medium rounded-md border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Created {new Date(genlocke.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rules (read-only) */}
|
||||||
|
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||||
|
<h3 className="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">Rules</h3>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Genlocke rules:</span>
|
||||||
|
<pre className="mt-1 text-xs bg-white dark:bg-gray-900 p-2 rounded border dark:border-gray-700 overflow-x-auto">
|
||||||
|
{JSON.stringify(genlocke.genlockeRules, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Nuzlocke rules:</span>
|
||||||
|
<pre className="mt-1 text-xs bg-white dark:bg-gray-900 p-2 rounded border dark:border-gray-700 overflow-x-auto">
|
||||||
|
{JSON.stringify(genlocke.nuzlockeRules, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legs */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-lg font-semibold">Legs ({genlocke.legs.length})</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setAddingLeg(!addingLeg)}
|
||||||
|
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Add Leg
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{addingLeg && (
|
||||||
|
<div className="mb-4 flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||||
|
<select
|
||||||
|
value={selectedGameId}
|
||||||
|
onChange={(e) => setSelectedGameId(e.target.value ? Number(e.target.value) : '')}
|
||||||
|
className="flex-1 px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
>
|
||||||
|
<option value="">Select a game...</option>
|
||||||
|
{games.map((g) => (
|
||||||
|
<option key={g.id} value={g.id}>
|
||||||
|
{g.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={handleAddLeg}
|
||||||
|
disabled={!selectedGameId || addLeg.isPending}
|
||||||
|
className="px-4 py-2 text-sm font-medium rounded-md bg-green-600 text-white hover:bg-green-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{addLeg.isPending ? 'Adding...' : 'Add'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setAddingLeg(false)
|
||||||
|
setSelectedGameId('')
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{genlocke.legs.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">
|
||||||
|
No legs yet. Add one to get started.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-16">
|
||||||
|
Order
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Game
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Run
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-20">
|
||||||
|
Enc.
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-20">
|
||||||
|
Deaths
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-20">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{genlocke.legs.map((leg) => (
|
||||||
|
<tr key={leg.id}>
|
||||||
|
<td className="px-4 py-3 text-sm whitespace-nowrap">{leg.legOrder}</td>
|
||||||
|
<td className="px-4 py-3 text-sm whitespace-nowrap">{leg.game.name}</td>
|
||||||
|
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
||||||
|
{leg.runId ? (
|
||||||
|
<Link
|
||||||
|
to={`/runs/${leg.runId}`}
|
||||||
|
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
|
>
|
||||||
|
Run #{leg.runId}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
||||||
|
{leg.runStatus ? (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
leg.runStatus === 'active'
|
||||||
|
? 'text-green-600 dark:text-green-400'
|
||||||
|
: leg.runStatus === 'completed'
|
||||||
|
? 'text-blue-600 dark:text-blue-400'
|
||||||
|
: 'text-red-600 dark:text-red-400'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{leg.runStatus}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm whitespace-nowrap">{leg.encounterCount}</td>
|
||||||
|
<td className="px-4 py-3 text-sm whitespace-nowrap">{leg.deathCount}</td>
|
||||||
|
<td className="px-4 py-3 text-sm whitespace-nowrap text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => deleteLeg.mutate(leg.id)}
|
||||||
|
disabled={leg.runId !== null || deleteLeg.isPending}
|
||||||
|
title={leg.runId !== null ? 'Cannot remove a leg with a linked run' : 'Remove leg'}
|
||||||
|
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||||
|
<h3 className="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">Stats</h3>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Legs</span>
|
||||||
|
<p className="text-lg font-semibold">{genlocke.stats.legsCompleted} / {genlocke.stats.totalLegs}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Encounters</span>
|
||||||
|
<p className="text-lg font-semibold">{genlocke.stats.totalEncounters}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Deaths</span>
|
||||||
|
<p className="text-lg font-semibold">{genlocke.stats.totalDeaths}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Survival Rate</span>
|
||||||
|
<p className="text-lg font-semibold">
|
||||||
|
{genlocke.stats.totalEncounters > 0
|
||||||
|
? `${Math.round(((genlocke.stats.totalEncounters - genlocke.stats.totalDeaths) / genlocke.stats.totalEncounters) * 100)}%`
|
||||||
|
: '\u2014'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showDelete && (
|
||||||
|
<DeleteConfirmModal
|
||||||
|
title={`Delete "${genlocke.name}"?`}
|
||||||
|
message="This will permanently delete the genlocke. Linked runs will be preserved but detached."
|
||||||
|
onConfirm={() =>
|
||||||
|
deleteGenlocke.mutate(id, {
|
||||||
|
onSuccess: () => navigate('/admin/genlockes'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onCancel={() => setShowDelete(false)}
|
||||||
|
isDeleting={deleteGenlocke.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
107
frontend/src/pages/admin/AdminGenlockes.tsx
Normal file
107
frontend/src/pages/admin/AdminGenlockes.tsx
Normal file
@@ -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<GenlockeListItem | null>(null)
|
||||||
|
const [statusFilter, setStatusFilter] = useState('')
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!statusFilter) return genlockes
|
||||||
|
return genlockes.filter((g) => g.status === statusFilter)
|
||||||
|
}, [genlockes, statusFilter])
|
||||||
|
|
||||||
|
const columns: Column<GenlockeListItem>[] = [
|
||||||
|
{ header: 'Name', accessor: (g) => g.name, sortKey: (g) => g.name },
|
||||||
|
{
|
||||||
|
header: 'Status',
|
||||||
|
accessor: (g) => (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
g.status === 'active'
|
||||||
|
? 'text-green-600 dark:text-green-400'
|
||||||
|
: g.status === 'completed'
|
||||||
|
? 'text-blue-600 dark:text-blue-400'
|
||||||
|
: 'text-red-600 dark:text-red-400'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{g.status}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-xl font-semibold">Genlockes</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 flex items-center gap-4">
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
>
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
<option value="failed">Failed</option>
|
||||||
|
</select>
|
||||||
|
{statusFilter && (
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('')}
|
||||||
|
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||||
|
{filtered.length} genlockes
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminTable
|
||||||
|
columns={columns}
|
||||||
|
data={filtered}
|
||||||
|
isLoading={isLoading}
|
||||||
|
emptyMessage="No genlockes found."
|
||||||
|
keyFn={(g) => g.id}
|
||||||
|
onRowClick={(g) => navigate(`/admin/genlockes/${g.id}`)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{deleting && (
|
||||||
|
<DeleteConfirmModal
|
||||||
|
title={`Delete "${deleting.name}"?`}
|
||||||
|
message="This will permanently delete the genlocke. Linked runs will be preserved but detached."
|
||||||
|
onConfirm={() =>
|
||||||
|
deleteGenlocke.mutate(deleting.id, {
|
||||||
|
onSuccess: () => setDeleting(null),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onCancel={() => setDeleting(null)}
|
||||||
|
isDeleting={deleteGenlocke.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,3 +4,5 @@ export { AdminPokemon } from './AdminPokemon'
|
|||||||
export { AdminRouteDetail } from './AdminRouteDetail'
|
export { AdminRouteDetail } from './AdminRouteDetail'
|
||||||
export { AdminEvolutions } from './AdminEvolutions'
|
export { AdminEvolutions } from './AdminEvolutions'
|
||||||
export { AdminRuns } from './AdminRuns'
|
export { AdminRuns } from './AdminRuns'
|
||||||
|
export { AdminGenlockes } from './AdminGenlockes'
|
||||||
|
export { AdminGenlockeDetail } from './AdminGenlockeDetail'
|
||||||
|
|||||||
@@ -178,3 +178,13 @@ export interface BossPokemonInput {
|
|||||||
order: number
|
order: number
|
||||||
conditionLabel?: string | null
|
conditionLabel?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Genlocke admin
|
||||||
|
export interface UpdateGenlockeInput {
|
||||||
|
name?: string
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddGenlockeLegInput {
|
||||||
|
gameId: number
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user