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:
Julian Tabel
2026-02-09 10:51:47 +01:00
parent 08f6857451
commit a81a17c485
11 changed files with 685 additions and 12 deletions

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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>

View File

@@ -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}`)

View File

@@ -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() {

View File

@@ -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}`),
})
}

View 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">&mdash;</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">&mdash;</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>
)
}

View 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>
)
}

View File

@@ -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'

View File

@@ -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
}