feat(web): bulk-операции в таблице агентов и подтверждения опасных действий

This commit is contained in:
Denozordec
2026-09-25 01:07:05 +07:00
parent 0f7daf77e3
commit 98e4dcdefb
5 changed files with 252 additions and 19 deletions
+166 -10
View File
@@ -1,5 +1,6 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { RowSelectionState } from '@tanstack/react-table'
import { toast } from 'sonner'
import { useCallback, useMemo, useState } from 'react'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
@@ -13,6 +14,7 @@ import {
Plus,
ShieldIcon,
TableIcon,
Trash2,
UserPlus,
WifiOff,
} from 'lucide-react'
@@ -29,6 +31,7 @@ import {
FrameHeader,
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import {
Alert,
AlertDescription,
@@ -85,12 +88,18 @@ export const Route = createFileRoute('/_auth/agents/')({
const AGENT_TABS = [
{ id: 'all', label: 'Все' },
{ id: 'invited', label: 'Invited' },
{ id: 'pending', label: 'Pending' },
{ id: 'approved', label: 'Approved' },
{ id: 'revoked', label: 'Revoked' },
{ id: 'invited', label: 'Приглашённые' },
{ id: 'pending', label: 'Ожидают' },
{ id: 'approved', label: 'Одобренные' },
{ id: 'revoked', label: 'Отозванные' },
] as const
/** Список имён для подтверждений: первые 5, дальше «и ещё N». */
function formatNames(names: string[]): string {
if (names.length <= 5) return names.join(', ')
return `${names.slice(0, 5).join(', ')} и ещё ${names.length - 5}`
}
function AgentsPage() {
const navigate = useNavigate({ from: Route.fullPath })
const { agent: detailAgentId, view } = Route.useSearch()
@@ -101,6 +110,9 @@ function AgentsPage() {
const canWrite = useCan()('fw:agents:write')
const [createOpen, setCreateOpen] = useState(false)
const [deleteAgentId, setDeleteAgentId] = useState<string | null>(null)
const [approveAllOpen, setApproveAllOpen] = useState(false)
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[] | null>(null)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [filters, setFilters] = useState<Filter[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState('all')
@@ -156,7 +168,7 @@ function AgentsPage() {
},
})
const approveAllPending = useMutation({
const approveBulk = useMutation({
mutationFn: (ids: string[]) =>
apiFetch('/api/v1/agents/approve-bulk', {
method: 'POST',
@@ -170,9 +182,39 @@ function AgentsPage() {
),
)
},
onSuccess: () => {
toast.success('Все pending одобрены')
onSuccess: (_data, ids) => {
toast.success(`Одобрено агентов: ${ids.length}`)
setRowSelection({})
void qc.invalidateQueries({ queryKey: ['agents'] })
void qc.invalidateQueries({ queryKey: ['dashboard'] })
},
onError: (e: Error, _ids, rollback) => {
rollback?.()
toast.error(e.message)
},
})
const deleteBulk = useMutation({
mutationFn: (ids: string[]) =>
apiFetch('/api/v1/agents/delete-bulk', {
method: 'POST',
body: JSON.stringify({ agent_ids: ids }),
}),
onMutate: (ids) => {
const target = new Set(ids)
return patchAgentsCache((items) =>
items.filter((a) => !target.has(a.id)),
)
},
onSuccess: (_data, ids) => {
toast.success(`Удалено агентов: ${ids.length}`)
setBulkDeleteIds(null)
setRowSelection({})
if (detailAgentId && ids.includes(detailAgentId)) {
setSearch({ agent: '' })
}
void qc.invalidateQueries({ queryKey: ['agents'] })
void qc.invalidateQueries({ queryKey: ['dashboard'] })
},
onError: (e: Error, _ids, rollback) => {
rollback?.()
@@ -205,6 +247,77 @@ function AgentsPage() {
() => items.filter((a) => a.status === 'pending').map((a) => a.id),
[items],
)
const pendingNames = useMemo(
() =>
items.filter((a) => a.status === 'pending').map((a) => a.name),
[items],
)
const selectedIds = useMemo(
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
[rowSelection],
)
const selectedAgents = useMemo(
() => items.filter((a) => selectedIds.includes(a.id)),
[items, selectedIds],
)
const approvableSelected = useMemo(
() =>
selectedAgents.filter(
(a) => a.status === 'pending' || a.status === 'invited',
),
[selectedAgents],
)
const clearSelection = useCallback(() => setRowSelection({}), [])
const selectionToolbar = useCallback(
(ctx: { selectedCount: number }) => {
if (ctx.selectedCount === 0 || !canWrite) return null
return (
<div className="flex flex-wrap items-center justify-between gap-2">
<Badge variant="secondary" size="sm">
{ctx.selectedCount} выбрано
</Badge>
<div className="flex flex-wrap items-center gap-2">
{approvableSelected.length > 0 ? (
<Button
size="sm"
variant="outline"
disabled={approveBulk.isPending}
onClick={() =>
approveBulk.mutate(approvableSelected.map((a) => a.id))
}
>
<Check data-icon="inline-start" />
Утвердить ({approvableSelected.length})
</Button>
) : null}
<Button
size="sm"
variant="outline"
className="text-destructive"
disabled={deleteBulk.isPending}
onClick={() => setBulkDeleteIds(selectedIds)}
>
<Trash2 data-icon="inline-start" />
Удалить
</Button>
<Button size="sm" variant="ghost" onClick={clearSelection}>
Снять выделение
</Button>
</div>
</div>
)
},
[
canWrite,
approvableSelected,
selectedIds,
approveBulk.isPending,
deleteBulk.isPending,
clearSelection,
],
)
const kpiCards = useMemo(
() =>
@@ -460,12 +573,12 @@ function AgentsPage() {
<Button
size="sm"
disabled={
approveAllPending.isPending || pendingIds.length === 0
approveBulk.isPending || pendingIds.length === 0
}
onClick={() => approveAllPending.mutate(pendingIds)}
onClick={() => setApproveAllOpen(true)}
>
<Check data-icon="inline-start" />
Approve all
Утвердить всех
</Button>
) : null}
</div>
@@ -505,6 +618,10 @@ function AgentsPage() {
onRetry={() => void agentsQ.refetch()}
emptyAction={addButton}
toolbarExtra={viewToggle}
enableRowSelection={canWrite}
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
selectionToolbar={selectionToolbar}
onSelect={handleSelectAgent}
onApprove={(id) => approve.mutate(id)}
approvePending={approve.isPending}
@@ -578,6 +695,45 @@ function AgentsPage() {
}}
disabled={removeAgent.isPending}
/>
<ConfirmDialog
open={approveAllOpen}
onOpenChange={setApproveAllOpen}
title="Утвердить всех ожидающих?"
confirmLabel="Утвердить"
confirmVariant="default"
description={
pendingIds.length > 0
? `Будут одобрены ${pendingIds.length} агент(ов): ${formatNames(pendingNames)}. Агентам будет назначен общий набор правил по умолчанию.`
: 'Нет агентов, ожидающих одобрения.'
}
onConfirm={() => {
setApproveAllOpen(false)
approveBulk.mutate(pendingIds)
}}
disabled={approveBulk.isPending || pendingIds.length === 0}
/>
<ConfirmDialog
open={bulkDeleteIds !== null}
onOpenChange={(open) => {
if (!open) setBulkDeleteIds(null)
}}
title="Удалить выбранных агентов?"
description={
bulkDeleteIds && bulkDeleteIds.length > 0
? `Будут удалены ${bulkDeleteIds.length} агент(ов): ${formatNames(
selectedAgents
.filter((a) => bulkDeleteIds.includes(a.id))
.map((a) => a.name),
)}. Вместе с overrides, install-ссылками и статистикой. Действие нельзя отменить.`
: 'Вместе с overrides, install-ссылками и статистикой. Действие нельзя отменить.'
}
onConfirm={() => {
if (bulkDeleteIds) deleteBulk.mutate(bulkDeleteIds)
}}
disabled={deleteBulk.isPending}
/>
</PageShell>
)
}