-
- {item.value}
-
-
- {item.label}
+
+
{item.label}
+
+
+ {item.value}
+
+ {footer}
-
- {footer}
)
}
-function panelClassName(item: KpiStatItem, className?: string) {
+function panelClassName(item: KpiStatItem, className?: string, flush = false) {
const onActivate = resolveActivate(item)
const clickable = Boolean(item.to || onActivate)
const selected = isSelected(item)
return cn(
- 'relative isolate flex h-full min-w-0 flex-col',
- clickable &&
- 'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
- selected && 'ring-primary/30 bg-muted/30 ring-1',
+ flush
+ ? 'bg-card flex h-full min-w-0 items-center'
+ : 'relative isolate flex h-full min-w-0 flex-col',
+ clickable && 'hover:bg-muted/40 cursor-pointer transition-colors',
+ !flush && clickable && 'focus-within:ring-ring focus-within:ring-2',
+ selected && (flush ? 'bg-accent/70' : 'ring-primary/30 bg-muted/30 ring-1'),
className,
)
}
@@ -131,28 +182,31 @@ function panelClassName(item: KpiStatItem, className?: string) {
export function KpiStatCardTile({
item,
embedded = false,
+ flush = false,
className,
}: {
item: KpiStatItem
embedded?: boolean
+ flush?: boolean
className?: string
}) {
const onActivate = resolveActivate(item)
- const panelClass = panelClassName(item, className)
+ const panelClass = panelClassName(item, className, flush)
+ const Shell = flush ? 'div' : FramePanel
let panel: ReactNode
if (item.to) {
panel = (
-
-
+
+
-
+
)
} else if (onActivate) {
panel = (
-
handleCardKeyDown(onActivate, e)}
>
-
+
)
} else {
panel = (
-
+
-
+
)
}
+ if (flush) return panel
+
if (embedded) {
return
{panel}
}
@@ -191,27 +247,26 @@ export function KpiStatCard({
}
function statGridClass(count: number) {
- if (count >= 4) return 'grid-cols-1 gap-5 @xl:grid-cols-2 @5xl:grid-cols-4'
- if (count === 3) return 'grid-cols-1 gap-5 @3xl:grid-cols-3'
- return 'grid-cols-1 gap-5 @3xl:grid-cols-2'
+ return COLS[Math.min(Math.max(count, 1), 4)] ?? COLS[4]
}
function KpiStatGridSkeleton({ count }: { count: number }) {
return (
-
- {Array.from({ length: count }).map((_, index) => (
-
-
-
-
-
-
+
+
+
+ {Array.from({ length: count }).map((_, index) => (
+
-
-
-
- ))}
-
+ ))}
+
+
+
)
}
@@ -243,7 +298,6 @@ export function KpiStatGrid({
emptyIcon,
className,
skeletonCount = 4,
- embedded = false,
'aria-label': ariaLabel,
}: KpiStatGridProps) {
if (isLoading) {
@@ -266,17 +320,21 @@ export function KpiStatGrid({
}
return (
-
-
- {list.map((item, index) => (
-
- ))}
-
-
+
+
+
+ {list.map((item, index) => (
+
+
+
+ ))}
+
+
+
)
}
diff --git a/apps/web/src/components/reui-kit/resource-page.tsx b/apps/web/src/components/reui-kit/resource-page.tsx
index b7d836f..4dd7341 100644
--- a/apps/web/src/components/reui-kit/resource-page.tsx
+++ b/apps/web/src/components/reui-kit/resource-page.tsx
@@ -8,17 +8,21 @@ import {
type PaginationState,
type RowSelectionState,
type SortingState,
+ type VisibilityState,
} from '@tanstack/react-table'
import {
CircleAlertIcon,
+ Columns3Icon,
FilterIcon,
FunnelXIcon,
+ SearchIcon,
type LucideIcon,
} from 'lucide-react'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { Badge } from '@/components/reui/badge'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
+import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
@@ -36,6 +40,7 @@ import {
FrameTitle,
} from '@/components/reui/frame'
import { Button } from '@cfdm/ui/components/button'
+import { Input } from '@cfdm/ui/components/input'
import { Separator } from '@cfdm/ui/components/separator'
import { Skeleton } from '@cfdm/ui/components/skeleton'
import {
@@ -157,6 +162,8 @@ export function ResourcePage
({
const activeTab = controlledTab ?? internalTab
const [sorting, setSorting] = useState([])
+ const [columnVisibility, setColumnVisibility] = useState({})
+ const [query, setQuery] = useState('')
const [rowSelection, setRowSelection] = useState({})
const [pagination, setPagination] = useState({
pageIndex: 0,
@@ -171,11 +178,15 @@ export function ResourcePage({
const filteredData = useMemo(() => {
let result = applyFiltersToData(data, filters, getFilterFieldValue)
+ const needle = query.trim().toLowerCase()
+ if (needle) {
+ result = result.filter((item) => JSON.stringify(item).toLowerCase().includes(needle))
+ }
if (tabs && tabs.length > 0 && tabFilter && activeTab !== 'all') {
result = result.filter((item) => tabFilter(item, activeTab))
}
return result
- }, [data, filters, getFilterFieldValue, tabs, tabFilter, activeTab])
+ }, [data, filters, getFilterFieldValue, query, tabs, tabFilter, activeTab])
const tabCounts = useMemo(() => {
if (!tabs?.length || !tabFilter) return {}
@@ -205,9 +216,10 @@ export function ResourcePage({
data: filteredData,
columns,
getRowId,
- state: { sorting, rowSelection, pagination },
+ state: { sorting, columnVisibility, rowSelection, pagination },
enableRowSelection,
onSortingChange: setSorting,
+ onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
@@ -409,19 +421,46 @@ export function ResourcePage({
) : null}
-
-
- Фильтры
-
- }
- />
+
+
+
+ {
+ setQuery(event.target.value)
+ resetPagination()
+ }}
+ aria-label="Поиск"
+ placeholder="Поиск"
+ className="pl-8"
+ />
+
+
+
+ Фильтры
+
+ }
+ />
+
+
+
+ Колонки
+
+ }
+ />
{toolbarExtra}
{selectedCount > 0 ? (
diff --git a/apps/web/src/components/reui/data-grid/data-grid-column-visibility.tsx b/apps/web/src/components/reui/data-grid/data-grid-column-visibility.tsx
index 625f5f2..b5dcd38 100644
--- a/apps/web/src/components/reui/data-grid/data-grid-column-visibility.tsx
+++ b/apps/web/src/components/reui/data-grid/data-grid-column-visibility.tsx
@@ -26,7 +26,7 @@ function DataGridColumnVisibility({
- Toggle Columns
+ Колонки
{table
.getAllColumns()
diff --git a/apps/web/src/routes/_auth/domains/index.tsx b/apps/web/src/routes/_auth/domains/index.tsx
index c9e7604..1d61fc3 100644
--- a/apps/web/src/routes/_auth/domains/index.tsx
+++ b/apps/web/src/routes/_auth/domains/index.tsx
@@ -44,6 +44,7 @@ import {
SelectValue,
} from '@cfdm/ui/components/select'
import { FieldGroup } from '@cfdm/ui/components/field'
+import { Input } from '@cfdm/ui/components/input'
export const Route = createFileRoute('/_auth/domains/')({
loader: ({ context: { queryClient } }) =>
@@ -55,10 +56,9 @@ export const Route = createFileRoute('/_auth/domains/')({
})
function DomainsPage() {
- const [groupOpen, setGroupOpen] = useState(false)
- const [groupValue, setGroupValue] = useState('none')
const selectedIdsRef = useRef([])
const [syncOpen, setSyncOpen] = useState(false)
+ const [importOpen, setImportOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState(null)
const [filters, setFilters] = useState(createDefaultDomainFilters)
const [activeTab, setActiveTab] = useState('all')
@@ -84,7 +84,8 @@ function DomainsPage() {
const filterFields = useDomainFilterFields(groupItems)
- const form = useForm({ defaultValues: { group_id: '' } })
+ const [importGroup, setImportGroup] = useState('none')
+ const importForm = useForm({ defaultValues: { zone_name: '' } })
const deleteMutation = useMutation({
mutationFn: (id: number) => api.delete(`/api/v1/domains/${id}`),
@@ -99,6 +100,21 @@ function DomainsPage() {
},
})
+ const importMutation = useMutation({
+ mutationFn: (body: { zone_name: string; group_id: number | null }) =>
+ api.post('/api/v1/domains', body),
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({ queryKey: domainKeys.all })
+ setImportOpen(false)
+ setImportGroup('none')
+ importForm.reset({ zone_name: '' })
+ toast.success('Домен импортирован')
+ },
+ onError: (err) => {
+ toast.error(err instanceof Error ? err.message : 'Не удалось импортировать домен')
+ },
+ })
+
const syncMutation = useMutation({
mutationFn: () => api.post<{ job_id: string }>('/api/v1/sync'),
onSuccess: async () => {
@@ -134,18 +150,10 @@ function DomainsPage() {
const freshness = domainFreshnessLine(domains)
- function openGroupSheet() {
- if (selectedIdsRef.current.length === 0) {
- toast.message('Выберите домены в таблице')
- return
- }
- setGroupOpen(true)
- }
-
const primaryAction = (
<>
-