feat(ui): добавить KPI card-35 на domains, groups и services
ReUI PRO card-35 через KpiStatGrid; клик по KPI синхронизирует tabs. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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
|
isLoading?: boolean
|
||||||
primaryAction?: ReactNode
|
primaryAction?: ReactNode
|
||||||
togglingId: number | null
|
togglingId: number | null
|
||||||
|
activeTab?: string
|
||||||
|
onTabChange?: (tabId: string) => void
|
||||||
onEditService: (service: ServiceView) => void
|
onEditService: (service: ServiceView) => void
|
||||||
onDeleteService: (service: ServiceView) => void
|
onDeleteService: (service: ServiceView) => void
|
||||||
onToggleService: (serviceId: number, enabled: boolean) => void
|
onToggleService: (serviceId: number, enabled: boolean) => void
|
||||||
@@ -209,6 +211,8 @@ export function ServicesGroupedCatalog({
|
|||||||
isLoading = false,
|
isLoading = false,
|
||||||
primaryAction,
|
primaryAction,
|
||||||
togglingId,
|
togglingId,
|
||||||
|
activeTab: controlledTab,
|
||||||
|
onTabChange,
|
||||||
onEditService,
|
onEditService,
|
||||||
onDeleteService,
|
onDeleteService,
|
||||||
onToggleService,
|
onToggleService,
|
||||||
@@ -217,7 +221,9 @@ export function ServicesGroupedCatalog({
|
|||||||
onAddServiceToGroup,
|
onAddServiceToGroup,
|
||||||
emptyAction,
|
emptyAction,
|
||||||
}: ServicesGroupedCatalogProps) {
|
}: ServicesGroupedCatalogProps) {
|
||||||
const [tab, setTab] = useState('all')
|
const [internalTab, setInternalTab] = useState('all')
|
||||||
|
const tab = controlledTab ?? internalTab
|
||||||
|
const setTab = onTabChange ?? setInternalTab
|
||||||
const [filters, setFilters] = useState<Filter[]>(() =>
|
const [filters, setFilters] = useState<Filter[]>(() =>
|
||||||
createDefaultServiceFilters(),
|
createDefaultServiceFilters(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
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 { useForm } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
serviceBindingKeys,
|
serviceBindingKeys,
|
||||||
} from '@/queries'
|
} from '@/queries'
|
||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
|
import { DomainKpiCards } from '@/components/domain-kpi-cards'
|
||||||
import { ResourcePage } from '@/components/reui-kit'
|
import { ResourcePage } from '@/components/reui-kit'
|
||||||
import {
|
import {
|
||||||
DOMAIN_TABS,
|
DOMAIN_TABS,
|
||||||
@@ -55,7 +56,9 @@ function DomainsPage() {
|
|||||||
const [importGroupId, setImportGroupId] = useState('')
|
const [importGroupId, setImportGroupId] = useState('')
|
||||||
const [deleteTarget, setDeleteTarget] = useState<DomainListItem | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<DomainListItem | null>(null)
|
||||||
const [filters, setFilters] = useState<Filter[]>(createDefaultDomainFilters)
|
const [filters, setFilters] = useState<Filter[]>(createDefaultDomainFilters)
|
||||||
|
const [activeTab, setActiveTab] = useState('all')
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const handleSelectTab = useCallback((tabId: string) => setActiveTab(tabId), [])
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: domains,
|
data: domains,
|
||||||
@@ -146,10 +149,18 @@ function DomainsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
|
<DomainKpiCards
|
||||||
|
domains={domains}
|
||||||
|
activeTab={activeTab}
|
||||||
|
isLoading={isLoading && !domains}
|
||||||
|
onSelectTab={handleSelectTab}
|
||||||
|
/>
|
||||||
<ResourcePage
|
<ResourcePage
|
||||||
title="Домены"
|
title="Домены"
|
||||||
description="Импортированные зоны Cloudflare"
|
description="Импортированные зоны Cloudflare"
|
||||||
tabs={DOMAIN_TABS.map((tab) => ({ ...tab }))}
|
tabs={DOMAIN_TABS.map((tab) => ({ ...tab }))}
|
||||||
|
activeTab={activeTab}
|
||||||
|
onTabChange={setActiveTab}
|
||||||
tabFilter={domainTabFilter}
|
tabFilter={domainTabFilter}
|
||||||
filterFields={filterFields}
|
filterFields={filterFields}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
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 { FolderTreeIcon } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { api } from '@/lib/api-client'
|
import { api } from '@/lib/api-client'
|
||||||
import type { CreateGroupInput, Group } from '@/lib/schemas'
|
import type { CreateGroupInput, Group } from '@/lib/schemas'
|
||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
|
import { GroupKpiCards } from '@/components/group-kpi-cards'
|
||||||
import { EmptyState } from '@/components/empty-state'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { DomainGroupEditSheet } from '@/components/domain-group-edit-sheet'
|
import { DomainGroupEditSheet } from '@/components/domain-group-edit-sheet'
|
||||||
@@ -66,7 +67,9 @@ function GroupsPage() {
|
|||||||
const [editingGroup, setEditingGroup] = useState<Group | null>(null)
|
const [editingGroup, setEditingGroup] = useState<Group | null>(null)
|
||||||
const [deletingGroup, setDeletingGroup] = useState<Group | null>(null)
|
const [deletingGroup, setDeletingGroup] = useState<Group | null>(null)
|
||||||
const [filters, setFilters] = useState<Filter[]>(() => createDefaultGroupFilters())
|
const [filters, setFilters] = useState<Filter[]>(() => createDefaultGroupFilters())
|
||||||
|
const [activeTab, setActiveTab] = useState('all')
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const handleSelectTab = useCallback((tabId: string) => setActiveTab(tabId), [])
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: groups,
|
data: groups,
|
||||||
@@ -276,10 +279,19 @@ function GroupsPage() {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
<GroupKpiCards
|
||||||
|
rows={catalogRows}
|
||||||
|
activeTab={activeTab}
|
||||||
|
isLoading={false}
|
||||||
|
onSelectTab={handleSelectTab}
|
||||||
|
/>
|
||||||
<ResourcePage
|
<ResourcePage
|
||||||
title="Группы"
|
title="Группы"
|
||||||
description="Каталог групп доменов"
|
description="Каталог групп доменов"
|
||||||
tabs={GROUP_TABS.map((tab) => ({ ...tab }))}
|
tabs={GROUP_TABS.map((tab) => ({ ...tab }))}
|
||||||
|
activeTab={activeTab}
|
||||||
|
onTabChange={setActiveTab}
|
||||||
tabFilter={groupTabFilter}
|
tabFilter={groupTabFilter}
|
||||||
filterFields={filterFields}
|
filterFields={filterFields}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
@@ -301,6 +313,7 @@ function GroupsPage() {
|
|||||||
action: createButton,
|
action: createButton,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DomainGroupEditSheet
|
<DomainGroupEditSheet
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
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 { ServerIcon } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
UpdateServiceConfigInput,
|
UpdateServiceConfigInput,
|
||||||
} from '@/lib/schemas'
|
} from '@/lib/schemas'
|
||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
|
import { ServiceKpiCards } from '@/components/service-kpi-cards'
|
||||||
import { EmptyState } from '@/components/empty-state'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { ServiceEditSheet } from '@/components/service-edit-sheet'
|
import { ServiceEditSheet } from '@/components/service-edit-sheet'
|
||||||
@@ -146,7 +147,9 @@ function ServicesPage() {
|
|||||||
const [deletingGroupId, setDeletingGroupId] = useState<number | null>(null)
|
const [deletingGroupId, setDeletingGroupId] = useState<number | null>(null)
|
||||||
const [togglingServiceId, setTogglingServiceId] = useState<number | null>(null)
|
const [togglingServiceId, setTogglingServiceId] = useState<number | null>(null)
|
||||||
const [bulkToggling, setBulkToggling] = useState(false)
|
const [bulkToggling, setBulkToggling] = useState(false)
|
||||||
|
const [activeTab, setActiveTab] = useState('all')
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const handleSelectTab = useCallback((tabId: string) => setActiveTab(tabId), [])
|
||||||
const selection = useServicesSelection()
|
const selection = useServicesSelection()
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@@ -621,11 +624,20 @@ function ServicesPage() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
<ServiceKpiCards
|
||||||
|
rows={catalogRows}
|
||||||
|
activeTab={activeTab}
|
||||||
|
isLoading={false}
|
||||||
|
onSelectTab={handleSelectTab}
|
||||||
|
/>
|
||||||
<ServicesGroupedCatalog
|
<ServicesGroupedCatalog
|
||||||
data={data ?? { groups: [], ungrouped: [] }}
|
data={data ?? { groups: [], ungrouped: [] }}
|
||||||
domainId={domainId}
|
domainId={domainId}
|
||||||
domainLabel={filteredDomain?.zone_name}
|
domainLabel={filteredDomain?.zone_name}
|
||||||
togglingId={togglingServiceId}
|
togglingId={togglingServiceId}
|
||||||
|
activeTab={activeTab}
|
||||||
|
onTabChange={setActiveTab}
|
||||||
onEditService={setEditingService}
|
onEditService={setEditingService}
|
||||||
onDeleteService={setDeletingService}
|
onDeleteService={setDeletingService}
|
||||||
onToggleService={handleServiceToggle}
|
onToggleService={handleServiceToggle}
|
||||||
@@ -644,6 +656,7 @@ function ServicesPage() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{sheets}
|
{sheets}
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|||||||
Reference in New Issue
Block a user