assets-ai/src/app/(app)/assets/page.tsx

616 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import Pagination from '@/components/ui/Pagination'
import Badge from '@/components/ui/Badge'
import Button from '@/components/ui/Button'
import Modal from '@/components/ui/Modal'
import type { Asset, PaginatedResult } from '@/types'
import {
Plus, Upload, Download, Search, ChevronUp, ChevronDown, ChevronsUpDown,
Eye, Edit, Trash2, Filter, ArrowUpDown, Check,
} from 'lucide-react'
const statusColor: Record<string, 'blue' | 'green' | 'yellow' | 'red' | 'gray'> = {
'腾讯使用': 'green', '图灵使用': 'blue', '闲置': 'yellow', '备用': 'yellow', '维修中': 'red', '已下线': 'gray',
}
const COLUMNS = [
{ key: 'node_name', label: '节点名称', sortable: true },
{ key: 'business_ip', label: '业务IP', sortable: true },
{ key: 'hdm_ip', label: 'HDM IP', sortable: true },
{ key: 'device_type', label: '设备类型', sortable: true },
{ key: 'manufacturer', label: '厂商', sortable: true },
{ key: 'device_model', label: '设备型号', sortable: true },
{ key: 'serial_number', label: '序列号', sortable: true },
{ key: 'status', label: '状态', sortable: true },
]
export default function AssetsPage() {
const router = useRouter()
const [result, setResult] = useState<PaginatedResult<Asset>>({ data: [], total: 0, page: 1, pageSize: 20, totalPages: 0 })
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [sortKey, setSortKey] = useState('node_name')
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc')
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [deleteTarget, setDeleteTarget] = useState<number | null>(null)
const [deleting, setDeleting] = useState(false)
const [exportModalOpen, setExportModalOpen] = useState(false)
const [exportMode, setExportMode] = useState<'all' | 'selected'>('all')
const [permissions, setPermissions] = useState<string[]>([])
// 列头筛选相关状态
const [tableMode, setTableMode] = useState<'sort' | 'filter'>('sort')
const [columnFilterValues, setColumnFilterValues] = useState<Record<string, string[]>>({})
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null)
const [filterOptions, setFilterOptions] = useState<Record<string, string[]>>({})
const [filterLoading, setFilterLoading] = useState<Record<string, boolean>>({})
const [filterSearch, setFilterSearch] = useState<Record<string, string>>({})
const filterDropRef = useRef<HTMLDivElement>(null)
// 列宽拖拽调整
const [colWidths, setColWidths] = useState<Record<string, number>>({})
const [resizingCol, setResizingCol] = useState<string | null>(null)
const resizeRef = useRef<{ col: string; startX: number; startWidth: number } | null>(null)
const [hasResized, setHasResized] = useState(false)
function snapAllColumns() {
if (hasResized) return
setHasResized(true)
const widths: Record<string, number> = {}
document.querySelectorAll('th[data-col-key]').forEach(th => {
const key = th.getAttribute('data-col-key')
if (key) widths[key] = th.getBoundingClientRect().width
})
setColWidths(widths)
}
function onResizeStart(colKey: string, e: React.MouseEvent) {
e.preventDefault()
e.stopPropagation()
snapAllColumns()
const th = (e.target as HTMLElement).closest('th')
const startWidth = th?.getBoundingClientRect().width || 150
resizeRef.current = { col: colKey, startX: e.clientX, startWidth }
setResizingCol(colKey)
document.body.style.userSelect = 'none'
document.body.style.cursor = 'col-resize'
}
useEffect(() => {
if (!resizingCol) return
function onMouseMove(e: MouseEvent) {
if (!resizeRef.current) return
const { col, startX, startWidth } = resizeRef.current
setColWidths(prev => ({ ...prev, [col]: Math.max(60, startWidth + (e.clientX - startX)) }))
}
function onMouseUp() {
setResizingCol(null)
resizeRef.current = null
document.body.style.userSelect = ''
document.body.style.cursor = ''
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
return () => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
}, [resizingCol])
// 关闭筛选下拉
useEffect(() => {
function handleClick(e: MouseEvent) {
if (filterDropRef.current && !filterDropRef.current.contains(e.target as Node)) {
setOpenFilterColumn(null)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [])
// 加载某列的唯一值
const loadColumnOptions = useCallback((field: string) => {
if (filterOptions[field]) return
setFilterLoading(prev => ({ ...prev, [field]: true }))
fetch(`/api/assets/field-values?field=${field}&q=`)
.then(r => r.json())
.then(d => {
if (d.values) setFilterOptions(prev => ({ ...prev, [field]: d.values }))
})
.catch(() => {})
.finally(() => setFilterLoading(prev => ({ ...prev, [field]: false })))
}, [filterOptions])
// 列头点击:排序或打开筛选
function handleColumnClick(key: string, sortable: boolean) {
if (tableMode === 'sort') {
if (!sortable) return
if (sortKey === key) setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')
else { setSortKey(key); setSortOrder('asc') }
setPage(1)
} else {
if (openFilterColumn === key) {
setOpenFilterColumn(null)
} else {
setOpenFilterColumn(key)
loadColumnOptions(key)
}
}
}
// 切换某列的筛选值
function toggleFilterValue(column: string, value: string) {
setColumnFilterValues(prev => {
const current = prev[column] || []
const next = current.includes(value)
? current.filter(v => v !== value)
: [...current, value]
const updated = { ...prev, [column]: next }
if (next.length === 0) delete updated[column]
return updated
})
setPage(1)
}
// 清空某列筛选
function clearColumnFilter(column: string) {
setColumnFilterValues(prev => {
const next = { ...prev }
delete next[column]
return next
})
setPage(1)
}
// 排序图标(可点击触发排序/筛选)
function SortIcon({ colKey, sortable }: { colKey: string; sortable: boolean }) {
if (!sortable) return null
const icon = tableMode === 'filter'
? <Filter size={12} />
: sortKey !== colKey
? <ChevronsUpDown size={13} />
: sortOrder === 'asc'
? <ChevronUp size={13} className="text-blue-500" />
: <ChevronDown size={13} className="text-blue-500" />
return (
<span
onClick={(e) => { e.stopPropagation(); handleColumnClick(colKey, sortable) }}
className={`ml-1 flex-shrink-0 rounded transition-colors cursor-pointer ${tableMode === 'sort' && sortKey === colKey ? 'text-blue-500' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-300'}`}
title={tableMode === 'sort' ? '点击排序' : '点击筛选'}
>
{icon}
</span>
)
}
// 列头是否显示筛选图标(已选值时)
function FilterIndicator({ colKey }: { colKey: string }) {
const vals = columnFilterValues[colKey]
if (!vals || vals.length === 0) return null
return (
<span className="ml-0.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-blue-600 text-white text-[10px] font-bold">
{vals.length}
</span>
)
}
// 筛选下拉内容
function FilterDropdown({ column }: { column: string }) {
const options = filterOptions[column] || []
const selected = columnFilterValues[column] || []
const searchVal = filterSearch[column] || ''
const loading = filterLoading[column]
const filtered = searchVal
? options.filter(o => o.toLowerCase().includes(searchVal.toLowerCase()))
: options
const hasMore = options.length >= 50
return (
<div className="absolute top-full left-0 z-50 mt-1 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shadow-xl w-fit min-w-48 max-w-80">
{/* 搜索框 */}
<div className="p-2 border-b border-slate-100 dark:border-slate-700">
<input
type="text"
value={searchVal}
onChange={e => setFilterSearch(prev => ({ ...prev, [column]: e.target.value }))}
placeholder="搜索..."
className="w-full px-2 py-1 rounded border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-xs text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
{/* 选项列表 */}
<div className="max-h-56 overflow-y-auto">
{loading ? (
<div className="px-3 py-4 text-xs text-slate-400 text-center">...</div>
) : filtered.length === 0 ? (
<div className="px-3 py-4 text-xs text-slate-400 text-center"></div>
) : (
filtered.map(opt => {
const checked = selected.includes(opt)
return (
<button
key={opt}
onClick={() => toggleFilterValue(column, opt)}
className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left transition-colors ${
checked
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
: 'text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700'
}`}
>
<span className={`w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 ${
checked
? 'bg-blue-600 border-blue-600'
: 'border-slate-300 dark:border-slate-600'
}`}>
{checked && <Check size={10} className="text-white" />}
</span>
<span className="truncate">{opt || '(空)'}</span>
</button>
)
})
)}
</div>
{/* 底部操作 */}
<div className="px-2 py-1.5 border-t border-slate-100 dark:border-slate-700 flex items-center justify-between">
<span className="text-[10px] text-slate-400">
{selected.length > 0 ? `已选 ${selected.length}${hasMore ? '最多50项' : ''}` : `${options.length} 个值`}
</span>
{selected.length > 0 && (
<button
onClick={() => { clearColumnFilter(column); setOpenFilterColumn(null) }}
className="text-[10px] text-blue-600 dark:text-blue-400 hover:underline"
>
</button>
)}
</div>
</div>
)
}
// 切换模式:清除列筛选
function switchMode(mode: 'sort' | 'filter') {
setTableMode(mode)
setOpenFilterColumn(null)
if (mode === 'sort') {
// 排序模式不清除筛选状态,保留 columnFilterValues
}
}
useEffect(() => {
fetch('/api/auth/me').then(r => r.json()).then(d => {
if (d.user?.permissions) setPermissions(d.user.permissions)
}).catch(() => {})
}, [])
const fetchAssets = useCallback(async () => {
setLoading(true)
try {
const params = new URLSearchParams({
page: String(page), pageSize: String(pageSize), sortKey, sortOrder,
})
if (search) params.set('search', search)
for (const [field, values] of Object.entries(columnFilterValues)) {
for (const v of values) {
params.append(`filter_${field}`, v)
}
}
const res = await fetch(`/api/assets?${params}`)
if (res.status === 401) { router.push('/login'); return }
const data = await res.json()
setResult(data)
} catch { setResult({ data: [], total: 0, page: 1, pageSize: 20, totalPages: 0 }) }
finally { setLoading(false) }
}, [page, pageSize, sortKey, sortOrder, search, columnFilterValues, router])
useEffect(() => { fetchAssets() }, [fetchAssets])
function handleSearch(q: string) {
setSearch(q); setPage(1)
}
function handlePageChange(p: number) { setPage(p); setSelectedIds(new Set()) }
function handlePageSizeChange(s: number) { setPageSize(s); setPage(1) }
function toggleSelect(id: number) {
setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function toggleAll() {
const allIds = result.data.map(a => a.id)
if (selectedIds.size === allIds.length) setSelectedIds(new Set())
else setSelectedIds(new Set(allIds))
}
async function handleDelete(id: number) {
setDeleting(true)
try {
const res = await fetch(`/api/assets/${id}`, { method: 'DELETE' })
if (res.ok) { setDeleteTarget(null); fetchAssets() }
} finally { setDeleting(false) }
}
function handleExportConfirm() {
const params = new URLSearchParams()
if (exportMode === 'selected' && selectedIds.size > 0) {
params.set('ids', [...selectedIds].join(','))
} else {
if (search) params.set('search', search)
for (const [field, values] of Object.entries(columnFilterValues)) {
for (const v of values) {
params.append(`filter_${field}`, v)
}
}
}
setExportModalOpen(false)
window.open(`/api/assets/export?${params}`, '_blank')
}
const { data, total, totalPages } = result
const allSelected = data.length > 0 && data.every(a => selectedIds.has(a.id))
// 是否有任何列筛选激活
const hasActiveColumnFilters = Object.values(columnFilterValues).some(v => v.length > 0)
const canExportAll = permissions.includes('*') || permissions.includes('assets:export:all')
const canExportSelected = permissions.includes('*') || permissions.includes('assets:export:selected')
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white"></h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
<span className="font-medium">{total}</span>
{selectedIds.size > 0 && <span className="ml-2"> <span className="font-medium text-blue-600">{selectedIds.size}</span> </span>}
</p>
</div>
<div className="flex items-center gap-2">
{selectedIds.size > 0 && permissions.includes('assets:update') && (
<Link href={`/assets/batch-edit?ids=${[...selectedIds].join(',')}`}>
<Button variant="secondary" size="sm"> {selectedIds.size} </Button>
</Link>
)}
{permissions.includes('assets:import') && (
<Link href="/assets/import"><Button variant="secondary" size="sm"><Upload size={14} /></Button></Link>
)}
{permissions.includes('assets:import') && (
<a href="/api/assets/template" download><Button variant="secondary" size="sm"><Download size={14} /></Button></a>
)}
{canExportSelected && selectedIds.size > 0 && (
<Button variant="secondary" size="sm" onClick={() => { setExportMode('selected'); setExportModalOpen(true) }}><Download size={14} />({selectedIds.size})</Button>
)}
{canExportAll && (
<Button variant="secondary" size="sm" onClick={() => { setExportMode('all'); setExportModalOpen(true) }}><Download size={14} />{selectedIds.size > 0 ? '导出全部' : '导出'}</Button>
)}
<Link href="/assets/new"><Button size="sm"><Plus size={14} /></Button></Link>
</div>
</div>
{/* 搜索 + 高级筛选 */}
<div className="flex items-start gap-3 mb-4">
<div className="flex-1 flex items-center gap-2">
<div className="relative flex-1 max-w-sm">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch(search)}
placeholder="快速搜索..."
className="w-full pl-9 pr-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<Button variant="secondary" size="sm" onClick={() => handleSearch(search)}></Button>
</div>
<Link href="/assets/advanced-search">
<Button variant="secondary" size="sm">
<Search size={14} />
</Button>
</Link>
</div>
{/* 活跃列筛选标签 */}
{hasActiveColumnFilters && (
<div className="flex flex-wrap items-center gap-2 mb-4">
<span className="text-xs text-slate-500 dark:text-slate-400">:</span>
{Object.entries(columnFilterValues).filter(([, v]) => v.length > 0).map(([col, vals]) => {
const colDef = COLUMNS.find(c => c.key === col)
return (
<span key={col} className="inline-flex items-center gap-1 px-2 py-1 rounded bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 text-xs">
<span className="font-medium">{colDef?.label || col}</span>
<span>: {vals.join(', ')}</span>
<button onClick={() => clearColumnFilter(col)} className="ml-0.5 hover:text-blue-900 dark:hover:text-blue-200">×</button>
</span>
)
})}
<button onClick={() => { setColumnFilterValues({}); setPage(1) }}
className="text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"></button>
</div>
)}
{/* 工具栏:每页行数 */}
<div className="flex items-center justify-end mb-2">
<div className="flex items-center gap-1.5">
<span className="text-xs text-slate-500 dark:text-slate-400"></span>
<div className="flex rounded-lg border border-slate-300 dark:border-slate-600 overflow-hidden">
{[20, 50, 100].map(s => (
<button
key={s}
onClick={() => handlePageSizeChange(s)}
className={`px-3 py-1 text-xs font-medium transition-colors ${
pageSize === s
? 'bg-blue-600 text-white'
: 'bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-700'
}`}
>
{s}
</button>
))}
</div>
<span className="text-xs text-slate-500 dark:text-slate-400"></span>
</div>
</div>
{/* 表格 */}
<div className="overflow-x-auto rounded-lg border border-slate-200 dark:border-slate-700">
<table className="w-full text-sm" style={{ tableLayout: hasResized ? 'fixed' : 'auto' }}>
<thead className="bg-slate-50 dark:bg-slate-800">
<tr>
{/* 模式切换 + 全选 */}
<th className="px-4 py-3 w-10 text-center">
<input type="checkbox" checked={allSelected} onChange={toggleAll}
className="rounded border-slate-300 dark:border-slate-600" />
</th>
{COLUMNS.map(col => (
<th key={col.key} data-col-key={col.key} className="px-4 py-3 text-center relative" style={colWidths[col.key] ? { width: colWidths[col.key] } : undefined}>
<div className="relative flex flex-col">
<div className="flex items-center justify-center gap-0.5">
{/* 左侧:模式切换按钮 */}
<button
onClick={() => switchMode(tableMode === 'sort' ? 'filter' : 'sort')}
className={`p-0.5 rounded transition-colors flex-shrink-0 ${
tableMode === 'sort' && col.sortable
? 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-300'
: tableMode === 'filter'
? 'text-blue-500 bg-blue-50 dark:bg-blue-900/30'
: 'text-slate-300'
}`}
title={tableMode === 'sort' ? '切换到筛选模式' : '切换到排序模式'}
>
<ArrowUpDown size={12} />
</button>
{/* 中间:列标题 */}
<button
className={`font-medium min-w-0 break-all ${
col.sortable
? 'cursor-pointer hover:text-slate-900 dark:hover:text-slate-100'
: 'cursor-default'
} ${
openFilterColumn === col.key
? 'text-blue-600 dark:text-blue-400'
: tableMode === 'filter'
? 'text-blue-600 dark:text-blue-400 cursor-pointer'
: 'text-slate-600 dark:text-slate-300'
}`}
onClick={() => handleColumnClick(col.key, col.sortable)}
>
{col.label}
</button>
{/* 右侧:排序/筛选图标 */}
<SortIcon colKey={col.key} sortable={col.sortable} />
<FilterIndicator colKey={col.key} />
</div>
{openFilterColumn === col.key && (
<div ref={filterDropRef}>
<FilterDropdown column={col.key} />
</div>
)}
</div>
{/* 右侧:拖拽手柄(宽点击区 + 细可见线) */}
<div
className="absolute top-1/2 right-0 -translate-y-1/2 h-4 w-2 cursor-col-resize z-10 flex items-center justify-center group"
onMouseDown={(e) => onResizeStart(col.key, e)}
>
<div className="w-px h-full bg-slate-300 dark:bg-slate-600 group-hover:bg-blue-400 transition-colors" />
</div>
</th>
))}
<th className="px-4 py-3 text-center font-medium text-slate-600 dark:text-slate-300" style={{ width: 120 }}></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-slate-700">
{loading ? (
<tr><td colSpan={COLUMNS.length + 2} className="px-4 py-12 text-center text-slate-500 dark:text-slate-400">...</td></tr>
) : data.length === 0 ? (
<tr><td colSpan={COLUMNS.length + 2} className="px-4 py-12 text-center text-slate-500 dark:text-slate-400"></td></tr>
) : data.map(row => (
<tr key={row.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
<td className="px-4 py-3 text-center">
<input type="checkbox" checked={selectedIds.has(row.id)} onChange={() => toggleSelect(row.id)}
className="rounded border-slate-300 dark:border-slate-600" />
</td>
<td className="px-4 py-3 font-medium text-center break-all">
<Link href={`/assets/${row.id}`} className="text-blue-600 dark:text-blue-400 hover:underline">{row.node_name || '-'}</Link>
</td>
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.business_ip || '-'}</td>
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.hdm_ip ? <a href={`http://${row.hdm_ip}`} target="_blank" rel="noopener noreferrer" className="text-blue-600 dark:text-blue-400 hover:underline">{row.hdm_ip}</a> : '-'}</td>
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.device_type}</td>
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.manufacturer || '-'}</td>
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.device_model || '-'}</td>
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.serial_number || '-'}</td>
<td className="px-4 py-3 text-center"><Badge color={statusColor[String(row.status ?? '')] || 'gray'}>{row.status ?? '-'}</Badge></td>
<td className="px-4 py-3 text-center">
<div className="flex items-center gap-1">
<Link href={`/assets/${row.id}`}
className="p-1.5 rounded-lg text-slate-500 hover:text-blue-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
<Eye size={16} />
</Link>
<Link href={`/assets/${row.id}/edit`}
className="p-1.5 rounded-lg text-slate-500 hover:text-green-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
<Edit size={16} />
</Link>
<button onClick={() => setDeleteTarget(row.id)}
className="p-1.5 rounded-lg text-slate-500 hover:text-red-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
<Trash2 size={16} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 分页 */}
<div className="mt-4 flex justify-center">
<Pagination page={page} totalPages={totalPages} onPageChange={handlePageChange} />
</div>
{/* 删除确认 */}
<Modal open={deleteTarget !== null} onClose={() => setDeleteTarget(null)} title="确认删除">
<p className="text-slate-700 dark:text-slate-300"></p>
<div className="flex justify-end gap-3 mt-4">
<Button variant="secondary" onClick={() => setDeleteTarget(null)}></Button>
<Button variant="danger" loading={deleting} onClick={() => deleteTarget && handleDelete(deleteTarget)}></Button>
</div>
</Modal>
{/* 导出确认 */}
<Modal open={exportModalOpen} onClose={() => setExportModalOpen(false)} title={exportMode === 'selected' ? '导出选中设备' : '导出全部设备'}>
{exportMode === 'selected' ? (
<p className="text-slate-700 dark:text-slate-300 mb-3">
<span className="font-medium">{selectedIds.size}</span>
</p>
) : (
<>
<p className="text-slate-700 dark:text-slate-300 mb-3">
<span className="font-medium">{total}</span>
</p>
{(Object.keys(columnFilterValues).length > 0 || search) && (
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
)}
</>
)}
<div className="flex justify-end gap-3 mt-4">
<Button variant="secondary" onClick={() => setExportModalOpen(false)}></Button>
<Button onClick={handleExportConfirm}><Download size={14} /></Button>
</div>
</Modal>
</div>
)
}