feat(ui): добавить KPI card-35 на domains, groups и services

ReUI PRO card-35 через KpiStatGrid; клик по KPI синхронизирует tabs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-07-17 13:44:06 +07:00
co-authored by Cursor
parent af191dcd5b
commit 1e8236298d
7 changed files with 250 additions and 4 deletions
@@ -0,0 +1,71 @@
import { useMemo } from 'react'
import { GlobeIcon } from 'lucide-react'
import { KpiStatGrid, type KpiStatCard } from '@/components/reui-kit/kpi-stat-grid'
import type { DomainListItem } from '@/lib/schemas'
interface DomainKpiCardsProps {
domains: DomainListItem[] | undefined
activeTab: string
isLoading?: boolean
onSelectTab: (tabId: string) => void
}
/** KPI strip — ReUI PRO [card-35](https://reui.io/preview/base/card-35) */
export function DomainKpiCards({
domains,
activeTab,
isLoading,
onSelectTab,
}: DomainKpiCardsProps) {
const cards = useMemo<KpiStatCard[]>(() => {
const list = domains ?? []
const withoutGroup = list.filter((d) => d.group_id == null).length
const withServices = list.filter((d) => d.service_count > 0).length
const active = list.filter((d) => d.status === 'active').length
return [
{
id: 'all',
label: 'Всего',
value: list.length,
hint: 'Импортированные зоны',
onSelect: () => onSelectTab('all'),
selected: activeTab === 'all',
},
{
id: 'without_group',
label: 'Без группы',
value: withoutGroup,
hint: 'Не назначена группа',
onSelect: () => onSelectTab('without_group'),
selected: activeTab === 'without_group',
},
{
id: 'active',
label: 'Active',
value: active,
hint: 'Статус Cloudflare',
onSelect: () => onSelectTab('all'),
selected: false,
},
{
id: 'with_services',
label: 'С сервисами',
value: withServices,
hint: 'Есть привязки',
onSelect: () => onSelectTab('all'),
selected: false,
},
]
}, [domains, activeTab, onSelectTab])
return (
<KpiStatGrid
cards={cards}
isLoading={isLoading}
skeletonCount={4}
emptyIcon={<GlobeIcon className="text-muted-foreground size-5" aria-hidden />}
emptyMessage="Нет доменов — импортируйте зону Cloudflare"
/>
)
}
@@ -0,0 +1,61 @@
import { useMemo } from 'react'
import { FolderTreeIcon } from 'lucide-react'
import { KpiStatGrid, type KpiStatCard } from '@/components/reui-kit/kpi-stat-grid'
import type { GroupCatalogRow } from '@/components/columns/groups-columns'
interface GroupKpiCardsProps {
rows: GroupCatalogRow[]
activeTab: string
isLoading?: boolean
onSelectTab: (tabId: string) => void
}
/** KPI strip — ReUI PRO [card-35](https://reui.io/preview/base/card-35) */
export function GroupKpiCards({
rows,
activeTab,
isLoading,
onSelectTab,
}: GroupKpiCardsProps) {
const cards = useMemo<KpiStatCard[]>(() => {
const withDomains = rows.filter((r) => r.domainCount > 0).length
const empty = rows.filter((r) => r.domainCount === 0).length
return [
{
id: 'all',
label: 'Всего',
value: rows.length,
hint: 'Группы доменов',
onSelect: () => onSelectTab('all'),
selected: activeTab === 'all',
},
{
id: 'with_domains',
label: 'С доменами',
value: withDomains,
hint: 'Непустые группы',
onSelect: () => onSelectTab('with_domains'),
selected: activeTab === 'with_domains',
},
{
id: 'empty',
label: 'Пустые',
value: empty,
hint: 'Без доменов',
onSelect: () => onSelectTab('empty'),
selected: activeTab === 'empty',
},
]
}, [rows, activeTab, onSelectTab])
return (
<KpiStatGrid
cards={cards}
isLoading={isLoading}
skeletonCount={3}
emptyIcon={<FolderTreeIcon className="text-muted-foreground size-5" aria-hidden />}
emptyMessage="Нет групп — создайте первую"
/>
)
}
@@ -0,0 +1,71 @@
import { useMemo } from 'react'
import { ServerIcon } from 'lucide-react'
import { KpiStatGrid, type KpiStatCard } from '@/components/reui-kit/kpi-stat-grid'
import type { ServiceCatalogRow } from '@/components/columns/services-columns'
interface ServiceKpiCardsProps {
rows: ServiceCatalogRow[]
activeTab: string
isLoading?: boolean
onSelectTab: (tabId: string) => void
}
/** KPI strip — ReUI PRO [card-35](https://reui.io/preview/base/card-35) */
export function ServiceKpiCards({
rows,
activeTab,
isLoading,
onSelectTab,
}: ServiceKpiCardsProps) {
const cards = useMemo<KpiStatCard[]>(() => {
const enabled = rows.filter((r) => r.enabled).length
const disabled = rows.filter((r) => !r.enabled).length
const ungrouped = rows.filter((r) => r.groupId == null).length
const down = rows.filter((r) => r.service.health_status === 'down').length
return [
{
id: 'all',
label: 'Всего',
value: rows.length,
hint: 'Сервисы',
onSelect: () => onSelectTab('all'),
selected: activeTab === 'all',
},
{
id: 'enabled',
label: 'Включены',
value: enabled,
hint: 'Активные',
onSelect: () => onSelectTab('enabled'),
selected: activeTab === 'enabled',
},
{
id: 'disabled',
label: 'Выключены',
value: disabled,
hint: 'Отключены',
onSelect: () => onSelectTab('disabled'),
selected: activeTab === 'disabled',
},
{
id: 'ungrouped',
label: 'Без группы',
value: ungrouped,
hint: down > 0 ? `Down: ${down}` : 'Не в группе',
onSelect: () => onSelectTab('ungrouped'),
selected: activeTab === 'ungrouped',
},
]
}, [rows, activeTab, onSelectTab])
return (
<KpiStatGrid
cards={cards}
isLoading={isLoading}
skeletonCount={4}
emptyIcon={<ServerIcon className="text-muted-foreground size-5" aria-hidden />}
emptyMessage="Нет сервисов — создайте первый"
/>
)
}
@@ -193,6 +193,8 @@ interface ServicesGroupedCatalogProps {
isLoading?: boolean
primaryAction?: ReactNode
togglingId: number | null
activeTab?: string
onTabChange?: (tabId: string) => void
onEditService: (service: ServiceView) => void
onDeleteService: (service: ServiceView) => void
onToggleService: (serviceId: number, enabled: boolean) => void
@@ -209,6 +211,8 @@ export function ServicesGroupedCatalog({
isLoading = false,
primaryAction,
togglingId,
activeTab: controlledTab,
onTabChange,
onEditService,
onDeleteService,
onToggleService,
@@ -217,7 +221,9 @@ export function ServicesGroupedCatalog({
onAddServiceToGroup,
emptyAction,
}: ServicesGroupedCatalogProps) {
const [tab, setTab] = useState('all')
const [internalTab, setInternalTab] = useState('all')
const tab = controlledTab ?? internalTab
const setTab = onTabChange ?? setInternalTab
const [filters, setFilters] = useState<Filter[]>(() =>
createDefaultServiceFilters(),
)
+12 -1
View File
@@ -1,6 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { toast } from 'sonner'
@@ -15,6 +15,7 @@ import {
serviceBindingKeys,
} from '@/queries'
import { PageShell } from '@/components/page-shell'
import { DomainKpiCards } from '@/components/domain-kpi-cards'
import { ResourcePage } from '@/components/reui-kit'
import {
DOMAIN_TABS,
@@ -55,7 +56,9 @@ function DomainsPage() {
const [importGroupId, setImportGroupId] = useState('')
const [deleteTarget, setDeleteTarget] = useState<DomainListItem | null>(null)
const [filters, setFilters] = useState<Filter[]>(createDefaultDomainFilters)
const [activeTab, setActiveTab] = useState('all')
const queryClient = useQueryClient()
const handleSelectTab = useCallback((tabId: string) => setActiveTab(tabId), [])
const {
data: domains,
@@ -146,10 +149,18 @@ function DomainsPage() {
return (
<PageShell>
<DomainKpiCards
domains={domains}
activeTab={activeTab}
isLoading={isLoading && !domains}
onSelectTab={handleSelectTab}
/>
<ResourcePage
title="Домены"
description="Импортированные зоны Cloudflare"
tabs={DOMAIN_TABS.map((tab) => ({ ...tab }))}
activeTab={activeTab}
onTabChange={setActiveTab}
tabFilter={domainTabFilter}
filterFields={filterFields}
filters={filters}
+14 -1
View File
@@ -1,6 +1,6 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { FolderTreeIcon } from 'lucide-react'
import { toast } from 'sonner'
import {
@@ -14,6 +14,7 @@ import {
import { api } from '@/lib/api-client'
import type { CreateGroupInput, Group } from '@/lib/schemas'
import { PageShell } from '@/components/page-shell'
import { GroupKpiCards } from '@/components/group-kpi-cards'
import { EmptyState } from '@/components/empty-state'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { DomainGroupEditSheet } from '@/components/domain-group-edit-sheet'
@@ -66,7 +67,9 @@ function GroupsPage() {
const [editingGroup, setEditingGroup] = useState<Group | null>(null)
const [deletingGroup, setDeletingGroup] = useState<Group | null>(null)
const [filters, setFilters] = useState<Filter[]>(() => createDefaultGroupFilters())
const [activeTab, setActiveTab] = useState('all')
const queryClient = useQueryClient()
const handleSelectTab = useCallback((tabId: string) => setActiveTab(tabId), [])
const {
data: groups,
@@ -276,10 +279,19 @@ function GroupsPage() {
/>
)
) : (
<>
<GroupKpiCards
rows={catalogRows}
activeTab={activeTab}
isLoading={false}
onSelectTab={handleSelectTab}
/>
<ResourcePage
title="Группы"
description="Каталог групп доменов"
tabs={GROUP_TABS.map((tab) => ({ ...tab }))}
activeTab={activeTab}
onTabChange={setActiveTab}
tabFilter={groupTabFilter}
filterFields={filterFields}
filters={filters}
@@ -301,6 +313,7 @@ function GroupsPage() {
action: createButton,
}}
/>
</>
)}
<DomainGroupEditSheet
+14 -1
View File
@@ -1,6 +1,6 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { ServerIcon } from 'lucide-react'
import { toast } from 'sonner'
import {
@@ -21,6 +21,7 @@ import type {
UpdateServiceConfigInput,
} from '@/lib/schemas'
import { PageShell } from '@/components/page-shell'
import { ServiceKpiCards } from '@/components/service-kpi-cards'
import { EmptyState } from '@/components/empty-state'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { ServiceEditSheet } from '@/components/service-edit-sheet'
@@ -146,7 +147,9 @@ function ServicesPage() {
const [deletingGroupId, setDeletingGroupId] = useState<number | null>(null)
const [togglingServiceId, setTogglingServiceId] = useState<number | null>(null)
const [bulkToggling, setBulkToggling] = useState(false)
const [activeTab, setActiveTab] = useState('all')
const queryClient = useQueryClient()
const handleSelectTab = useCallback((tabId: string) => setActiveTab(tabId), [])
const selection = useServicesSelection()
const {
data,
@@ -621,11 +624,20 @@ function ServicesPage() {
)}
</>
) : (
<>
<ServiceKpiCards
rows={catalogRows}
activeTab={activeTab}
isLoading={false}
onSelectTab={handleSelectTab}
/>
<ServicesGroupedCatalog
data={data ?? { groups: [], ungrouped: [] }}
domainId={domainId}
domainLabel={filteredDomain?.zone_name}
togglingId={togglingServiceId}
activeTab={activeTab}
onTabChange={setActiveTab}
onEditService={setEditingService}
onDeleteService={setDeletingService}
onToggleService={handleServiceToggle}
@@ -644,6 +656,7 @@ function ServicesPage() {
</div>
}
/>
</>
)}
{sheets}
</PageShell>