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
|
||||
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(),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user