feat(web): bulk-операции в таблице агентов и подтверждения опасных действий
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user