Files
cloudflare-domain-manager/apps/web/src/components/domains-data-grid.tsx
T
DenozordecandCursor 72a4b3403b
Build, Test, and Push CFDM Docker Image / test (push) Successful in 3m14s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 2m5s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
Выравнивание UI с vps-tracker: тема b2fA, ReUI DataGrid и shell
Синхронизированы тема и layout (ModeToggle, sticky header), списки переведены на DataGridCard, дашборд на компактные SectionCards.

Co-authored-by: Cursor <[email protected]>
2026-06-30 14:48:24 +07:00

277 lines
8.0 KiB
TypeScript

import { useMemo } from 'react'
import { Link } from '@tanstack/react-router'
import { GlobeIcon, MoreHorizontalIcon } from 'lucide-react'
import { Badge } from '@cfdm/ui/components/badge'
import { Button } from '@cfdm/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { ConfirmDialog } from '@/components/confirm-dialog'
import {
DataGridCard,
columnDefFromDataGrid,
} from '@/components/data-grid-card'
import type { DataGridColumn } from '@/components/data-grid-types'
import { DomainsFiltersToolbar } from '@/components/domains-filters-toolbar'
import { StatusBadge } from '@/components/status-badge'
import type { DomainListItem } from '@/lib/schemas'
import { useState } from 'react'
export type DomainTableRow = DomainListItem
interface GroupFilterItem {
label: string
value: string
}
export interface DomainsDataGridProps {
data: DomainTableRow[]
totalCount: number
groupFilterItems: GroupFilterItem[]
groupFilterValue: string
onGroupFilterChange: (value: string | null) => void
search: string
onSearchChange: (value: string) => void
onDelete: (domain: DomainTableRow) => void
isDeleting?: boolean
emptyAction?: React.ReactNode
}
export function DomainsDataGrid({
data,
totalCount,
groupFilterItems,
groupFilterValue,
onGroupFilterChange,
search,
onSearchChange,
onDelete,
isDeleting = false,
emptyAction,
}: DomainsDataGridProps) {
const [deleteTarget, setDeleteTarget] = useState<DomainTableRow | null>(null)
const filteredData = useMemo(() => {
const query = search.trim().toLowerCase()
if (!query) return data
return data.filter(
(d) =>
d.zone_name.toLowerCase().includes(query) ||
(d.group_name ?? '').toLowerCase().includes(query),
)
}, [data, search])
const columns = useMemo<DataGridColumn<DomainTableRow>[]>(
() => [
{
key: 'zone_name',
header: 'Зона',
icon: GlobeIcon,
sortable: true,
sortValue: (row) => row.zone_name,
cell: (row) => (
<Button
variant="link"
className="h-auto p-0 font-medium"
nativeButton={false}
render={
<Link to="/domains/$domainId" params={{ domainId: String(row.id) }} />
}
>
{row.zone_name}
</Button>
),
},
{
key: 'group',
header: 'Группа',
sortable: true,
sortValue: (row) => row.group_name ?? 'Без группы',
cell: (row) => {
if (row.group_id && row.group_name) {
return (
<Button
variant="link"
className="h-auto p-0"
nativeButton={false}
render={
<Link
to="/groups/$groupId"
params={{ groupId: String(row.group_id) }}
/>
}
>
{row.group_name}
</Button>
)
}
return <Badge variant="outline">Без группы</Badge>
},
},
{
key: 'service_count',
header: 'Сервисы',
sortable: true,
sortValue: (row) => row.service_count,
className: 'tabular-nums',
cell: (row) => (
<Button
variant="link"
className="h-auto p-0 tabular-nums"
nativeButton={false}
render={<Link to="/services" search={{ domainId: row.id }} />}
>
{row.service_count}
</Button>
),
},
{
key: 'status',
header: 'Статус',
cell: (row) => <StatusBadge status={row.status} />,
},
{
key: 'last_synced_at',
header: 'Синхронизация',
sortable: true,
sortValue: (row) => row.last_synced_at ?? '',
className: 'text-muted-foreground tabular-nums',
cell: (row) => row.last_synced_at ?? '—',
},
{
key: 'actions',
header: '',
enableHiding: false,
className: 'text-right',
cell: (row) => (
<div className="text-right">
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon" className="size-8" />}
>
<MoreHorizontalIcon />
<span className="sr-only">Действия</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
render={
<Link to="/domains/$domainId" params={{ domainId: String(row.id) }} />
}
>
Обзор
</DropdownMenuItem>
<DropdownMenuItem
render={
<Link
to="/domains/$domainId/dns"
params={{ domainId: String(row.id) }}
search={{ host: undefined }}
/>
}
>
DNS
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
disabled={isDeleting}
onClick={() => setDeleteTarget(row)}
>
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
},
],
[isDeleting],
)
const groupSelect = (
<Select
items={[{ label: 'Все группы', value: 'all' }, ...groupFilterItems]}
value={groupFilterValue || 'all'}
onValueChange={(value) => {
if (!value || value === 'all') {
onGroupFilterChange(null)
return
}
onGroupFilterChange(value)
}}
>
<SelectTrigger className="w-full sm:w-56">
<SelectValue placeholder="Все группы" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Все группы</SelectItem>
{groupFilterItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
const hasActiveFilters = Boolean(groupFilterValue) || Boolean(search.trim())
return (
<>
<DataGridCard
title="Список доменов"
description="Импортированные зоны Cloudflare"
columns={columnDefFromDataGrid(columns)}
data={filteredData}
rowId={(row) => String(row.id)}
emptyTitle="Домены не найдены"
emptyDescription="Импортируйте зону из Cloudflare или измените фильтры"
emptyAction={emptyAction}
pinLastColumn
actions={
<DomainsFiltersToolbar
search={search}
onSearchChange={onSearchChange}
groupSelect={groupSelect}
shownCount={filteredData.length}
totalCount={totalCount}
hasActiveFilters={hasActiveFilters}
onReset={() => {
onSearchChange('')
onGroupFilterChange(null)
}}
/>
}
/>
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null)
}}
title="Удалить зону?"
description={
deleteTarget
? `Зона «${deleteTarget.zone_name}» будет удалена из менеджера вместе с DNS-записями и привязками. Зона в Cloudflare не затрагивается.`
: ''
}
onConfirm={() => {
if (!deleteTarget) return
onDelete(deleteTarget)
setDeleteTarget(null)
}}
disabled={isDeleting}
/>
</>
)
}