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>
554 lines
21 KiB
TypeScript
554 lines
21 KiB
TypeScript
import { useState } from 'react'
|
|
import { useParams, Link } from 'react-router-dom'
|
|
import {
|
|
DndContext,
|
|
closestCenter,
|
|
KeyboardSensor,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
type DragEndEvent,
|
|
} from '@dnd-kit/core'
|
|
import {
|
|
SortableContext,
|
|
sortableKeyboardCoordinates,
|
|
useSortable,
|
|
verticalListSortingStrategy,
|
|
} from '@dnd-kit/sortable'
|
|
import { CSS } from '@dnd-kit/utilities'
|
|
import { BulkImportModal } from '../../components/admin/BulkImportModal'
|
|
import { RouteFormModal } from '../../components/admin/RouteFormModal'
|
|
import { BossBattleFormModal } from '../../components/admin/BossBattleFormModal'
|
|
import { BossTeamEditor } from '../../components/admin/BossTeamEditor'
|
|
import { TypeBadge } from '../../components/TypeBadge'
|
|
import { useGame } from '../../hooks/useGames'
|
|
import { useGameBosses } from '../../hooks/useBosses'
|
|
import {
|
|
useCreateRoute,
|
|
useUpdateRoute,
|
|
useDeleteRoute,
|
|
useReorderRoutes,
|
|
useBulkImportRoutes,
|
|
useReorderBosses,
|
|
useCreateBossBattle,
|
|
useUpdateBossBattle,
|
|
useDeleteBossBattle,
|
|
useSetBossTeam,
|
|
useBulkImportBosses,
|
|
} from '../../hooks/useAdmin'
|
|
import { exportGameRoutes, exportGameBosses } from '../../api/admin'
|
|
import { downloadJson } from '../../utils/download'
|
|
import type { Route as GameRoute, CreateRouteInput, UpdateRouteInput, BossBattle } from '../../types'
|
|
import type { CreateBossBattleInput, UpdateBossBattleInput } from '../../types/admin'
|
|
|
|
function SortableRouteRow({
|
|
route,
|
|
onClick,
|
|
}: {
|
|
route: GameRoute
|
|
onClick: (r: GameRoute) => void
|
|
}) {
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
|
useSortable({ id: route.id })
|
|
|
|
const style = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
}
|
|
|
|
return (
|
|
<tr
|
|
ref={setNodeRef}
|
|
style={style}
|
|
className={`${isDragging ? 'opacity-50 bg-blue-50 dark:bg-blue-900/20' : ''} hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer`}
|
|
onClick={() => onClick(route)}
|
|
>
|
|
<td className="px-4 py-3 text-sm w-12">
|
|
<button
|
|
{...attributes}
|
|
{...listeners}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 touch-none"
|
|
title="Drag to reorder"
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
<circle cx="5" cy="3" r="1.5" />
|
|
<circle cx="11" cy="3" r="1.5" />
|
|
<circle cx="5" cy="8" r="1.5" />
|
|
<circle cx="11" cy="8" r="1.5" />
|
|
<circle cx="5" cy="13" r="1.5" />
|
|
<circle cx="11" cy="13" r="1.5" />
|
|
</svg>
|
|
</button>
|
|
</td>
|
|
<td className="px-4 py-3 text-sm whitespace-nowrap w-16">{route.order}</td>
|
|
<td className="px-4 py-3 text-sm whitespace-nowrap">{route.name}</td>
|
|
</tr>
|
|
)
|
|
}
|
|
|
|
function SortableBossRow({
|
|
boss,
|
|
onClick,
|
|
}: {
|
|
boss: BossBattle
|
|
onClick: (b: BossBattle) => void
|
|
}) {
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
|
useSortable({ id: boss.id })
|
|
|
|
const style = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
}
|
|
|
|
return (
|
|
<tr
|
|
ref={setNodeRef}
|
|
style={style}
|
|
className={`${isDragging ? 'opacity-50 bg-blue-50 dark:bg-blue-900/20' : ''} hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer`}
|
|
onClick={() => onClick(boss)}
|
|
>
|
|
<td className="px-4 py-3 text-sm w-12">
|
|
<button
|
|
{...attributes}
|
|
{...listeners}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 touch-none"
|
|
title="Drag to reorder"
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
<circle cx="5" cy="3" r="1.5" />
|
|
<circle cx="11" cy="3" r="1.5" />
|
|
<circle cx="5" cy="8" r="1.5" />
|
|
<circle cx="11" cy="8" r="1.5" />
|
|
<circle cx="5" cy="13" r="1.5" />
|
|
<circle cx="11" cy="13" r="1.5" />
|
|
</svg>
|
|
</button>
|
|
</td>
|
|
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.order}</td>
|
|
<td className="px-4 py-3 text-sm whitespace-nowrap font-medium">{boss.name}</td>
|
|
<td className="px-4 py-3 text-sm whitespace-nowrap capitalize">
|
|
{boss.bossType.replace('_', ' ')}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
|
{boss.specialtyType ? <TypeBadge type={boss.specialtyType} /> : '\u2014'}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.section ?? '\u2014'}</td>
|
|
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.location}</td>
|
|
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.levelCap}</td>
|
|
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.pokemon.length}</td>
|
|
</tr>
|
|
)
|
|
}
|
|
|
|
export function AdminGameDetail() {
|
|
const { gameId } = useParams<{ gameId: string }>()
|
|
const id = Number(gameId)
|
|
const { data: game, isLoading } = useGame(id)
|
|
|
|
const createRoute = useCreateRoute(id)
|
|
const updateRoute = useUpdateRoute(id)
|
|
const deleteRoute = useDeleteRoute(id)
|
|
const reorderRoutes = useReorderRoutes(id)
|
|
const bulkImportRoutes = useBulkImportRoutes(id)
|
|
const { data: bosses } = useGameBosses(id)
|
|
const createBoss = useCreateBossBattle(id)
|
|
const updateBoss = useUpdateBossBattle(id)
|
|
const deleteBoss = useDeleteBossBattle(id)
|
|
const reorderBosses = useReorderBosses(id)
|
|
const bulkImportBosses = useBulkImportBosses(id)
|
|
|
|
const [tab, setTab] = useState<'routes' | 'bosses'>('routes')
|
|
const [showCreate, setShowCreate] = useState(false)
|
|
const [showBulkImportRoutes, setShowBulkImportRoutes] = useState(false)
|
|
const [editing, setEditing] = useState<GameRoute | null>(null)
|
|
const [showCreateBoss, setShowCreateBoss] = useState(false)
|
|
const [showBulkImportBosses, setShowBulkImportBosses] = useState(false)
|
|
const [editingBoss, setEditingBoss] = useState<BossBattle | null>(null)
|
|
const [editingTeam, setEditingTeam] = useState<BossBattle | null>(null)
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
)
|
|
|
|
if (isLoading) return <div className="py-8 text-center text-gray-500">Loading...</div>
|
|
if (!game) return <div className="py-8 text-center text-gray-500">Game not found</div>
|
|
|
|
const routes = game.routes ?? []
|
|
|
|
const handleDragEnd = (event: DragEndEvent) => {
|
|
const { active, over } = event
|
|
if (!over || active.id === over.id) return
|
|
|
|
const oldIndex = routes.findIndex((r) => r.id === active.id)
|
|
const newIndex = routes.findIndex((r) => r.id === over.id)
|
|
if (oldIndex === -1 || newIndex === -1) return
|
|
|
|
// Build new order assignments based on rearranged positions
|
|
const reordered = [...routes]
|
|
const [moved] = reordered.splice(oldIndex, 1)
|
|
reordered.splice(newIndex, 0, moved)
|
|
|
|
const newOrders = reordered.map((r, i) => ({
|
|
id: r.id,
|
|
order: i + 1,
|
|
}))
|
|
reorderRoutes.mutate(newOrders)
|
|
}
|
|
|
|
const handleBossDragEnd = (event: DragEndEvent) => {
|
|
if (!bosses) return
|
|
const { active, over } = event
|
|
if (!over || active.id === over.id) return
|
|
|
|
const oldIndex = bosses.findIndex((b) => b.id === active.id)
|
|
const newIndex = bosses.findIndex((b) => b.id === over.id)
|
|
if (oldIndex === -1 || newIndex === -1) return
|
|
|
|
const reordered = [...bosses]
|
|
const [moved] = reordered.splice(oldIndex, 1)
|
|
reordered.splice(newIndex, 0, moved)
|
|
|
|
const newOrders = reordered.map((b, i) => ({
|
|
id: b.id,
|
|
order: i + 1,
|
|
}))
|
|
reorderBosses.mutate(newOrders)
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<nav className="text-sm mb-4 text-gray-500 dark:text-gray-400">
|
|
<Link to="/admin/games" className="hover:underline">
|
|
Games
|
|
</Link>
|
|
{' / '}
|
|
<span className="text-gray-900 dark:text-gray-100">{game.name}</span>
|
|
</nav>
|
|
|
|
<div className="mb-6">
|
|
<h2 className="text-xl font-semibold">{game.name}</h2>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
{game.region.charAt(0).toUpperCase() + game.region.slice(1)} · Gen {game.generation}
|
|
{game.releaseYear ? ` \u00b7 ${game.releaseYear}` : ''}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex border-b border-gray-200 dark:border-gray-700 mb-4">
|
|
<button
|
|
onClick={() => setTab('routes')}
|
|
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
|
|
tab === 'routes'
|
|
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400'
|
|
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
|
}`}
|
|
>
|
|
Routes ({routes.length})
|
|
</button>
|
|
<button
|
|
onClick={() => setTab('bosses')}
|
|
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
|
|
tab === 'bosses'
|
|
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400'
|
|
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
|
}`}
|
|
>
|
|
Boss Battles ({bosses?.length ?? 0})
|
|
</button>
|
|
</div>
|
|
|
|
{tab === 'routes' && (
|
|
<>
|
|
<div className="flex justify-end gap-2 mb-4">
|
|
<button
|
|
onClick={async () => {
|
|
const result = await exportGameRoutes(id)
|
|
downloadJson(result.data, result.filename)
|
|
}}
|
|
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"
|
|
>
|
|
Export
|
|
</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
|
|
onClick={() => setShowCreate(true)}
|
|
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
|
|
>
|
|
Add Route
|
|
</button>
|
|
</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 ? (
|
|
<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.
|
|
</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-12" />
|
|
<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">
|
|
Name
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext
|
|
items={routes.map((r) => r.id)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
|
{routes.map((route) => (
|
|
<SortableRouteRow
|
|
key={route.id}
|
|
route={route}
|
|
onClick={(r) => setEditing(r)}
|
|
/>
|
|
))}
|
|
</tbody>
|
|
</SortableContext>
|
|
</DndContext>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showCreate && (
|
|
<RouteFormModal
|
|
nextOrder={routes.length > 0 ? Math.max(...routes.map((r) => r.order)) + 1 : 1}
|
|
onSubmit={(data) =>
|
|
createRoute.mutate(data as CreateRouteInput, {
|
|
onSuccess: () => setShowCreate(false),
|
|
})
|
|
}
|
|
onClose={() => setShowCreate(false)}
|
|
isSubmitting={createRoute.isPending}
|
|
/>
|
|
)}
|
|
|
|
{editing && (
|
|
<RouteFormModal
|
|
route={editing}
|
|
onSubmit={(data) =>
|
|
updateRoute.mutate(
|
|
{ routeId: editing.id, data: data as UpdateRouteInput },
|
|
{ onSuccess: () => setEditing(null) },
|
|
)
|
|
}
|
|
onClose={() => setEditing(null)}
|
|
isSubmitting={updateRoute.isPending}
|
|
onDelete={() =>
|
|
deleteRoute.mutate(editing.id, {
|
|
onSuccess: () => setEditing(null),
|
|
})
|
|
}
|
|
isDeleting={deleteRoute.isPending}
|
|
detailUrl={`/admin/games/${id}/routes/${editing.id}`}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{tab === 'bosses' && (
|
|
<>
|
|
<div className="flex justify-end gap-2 mb-4">
|
|
<button
|
|
onClick={async () => {
|
|
const result = await exportGameBosses(id)
|
|
downloadJson(result.data, result.filename)
|
|
}}
|
|
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"
|
|
>
|
|
Export
|
|
</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
|
|
onClick={() => setShowCreateBoss(true)}
|
|
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
|
|
>
|
|
Add Boss Battle
|
|
</button>
|
|
</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 ? (
|
|
<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.
|
|
</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-12" />
|
|
<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">
|
|
Name
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Type
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Specialty
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Section
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Location
|
|
</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">
|
|
Lv Cap
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-16">
|
|
Team
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={handleBossDragEnd}
|
|
>
|
|
<SortableContext
|
|
items={bosses.map((b) => b.id)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
|
{bosses.map((boss) => (
|
|
<SortableBossRow
|
|
key={boss.id}
|
|
boss={boss}
|
|
onClick={(b) => setEditingBoss(b)}
|
|
/>
|
|
))}
|
|
</tbody>
|
|
</SortableContext>
|
|
</DndContext>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Boss Battle Modals */}
|
|
{showCreateBoss && (
|
|
<BossBattleFormModal
|
|
routes={routes}
|
|
nextOrder={bosses ? Math.max(0, ...bosses.map((b) => b.order)) + 1 : 1}
|
|
onSubmit={(data) =>
|
|
createBoss.mutate(data as CreateBossBattleInput, {
|
|
onSuccess: () => setShowCreateBoss(false),
|
|
})
|
|
}
|
|
onClose={() => setShowCreateBoss(false)}
|
|
isSubmitting={createBoss.isPending}
|
|
/>
|
|
)}
|
|
|
|
{editingBoss && (
|
|
<BossBattleFormModal
|
|
boss={editingBoss}
|
|
routes={routes}
|
|
nextOrder={editingBoss.order}
|
|
onSubmit={(data) =>
|
|
updateBoss.mutate(
|
|
{ bossId: editingBoss.id, data: data as UpdateBossBattleInput },
|
|
{ onSuccess: () => setEditingBoss(null) },
|
|
)
|
|
}
|
|
onClose={() => setEditingBoss(null)}
|
|
isSubmitting={updateBoss.isPending}
|
|
onDelete={() =>
|
|
deleteBoss.mutate(editingBoss.id, {
|
|
onSuccess: () => setEditingBoss(null),
|
|
})
|
|
}
|
|
isDeleting={deleteBoss.isPending}
|
|
onEditTeam={() => {
|
|
setEditingTeam(editingBoss)
|
|
setEditingBoss(null)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{editingTeam && (
|
|
<BossTeamEditorWrapper
|
|
gameId={id}
|
|
boss={editingTeam}
|
|
onClose={() => setEditingTeam(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function BossTeamEditorWrapper({
|
|
gameId,
|
|
boss,
|
|
onClose,
|
|
}: {
|
|
gameId: number
|
|
boss: BossBattle
|
|
onClose: () => void
|
|
}) {
|
|
const setBossTeam = useSetBossTeam(gameId, boss.id)
|
|
return (
|
|
<BossTeamEditor
|
|
boss={boss}
|
|
onSave={(team) =>
|
|
setBossTeam.mutate(team, { onSuccess: onClose })
|
|
}
|
|
onClose={onClose}
|
|
isSaving={setBossTeam.isPending}
|
|
/>
|
|
)
|
|
}
|