diff --git a/apps/web/src/components/domain-kpi-cards.tsx b/apps/web/src/components/domain-kpi-cards.tsx new file mode 100644 index 0000000..4c9e483 --- /dev/null +++ b/apps/web/src/components/domain-kpi-cards.tsx @@ -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(() => { + 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 ( + } + emptyMessage="Нет доменов — импортируйте зону Cloudflare" + /> + ) +} diff --git a/apps/web/src/components/group-kpi-cards.tsx b/apps/web/src/components/group-kpi-cards.tsx new file mode 100644 index 0000000..c29ad79 --- /dev/null +++ b/apps/web/src/components/group-kpi-cards.tsx @@ -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(() => { + 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 ( + } + emptyMessage="Нет групп — создайте первую" + /> + ) +} diff --git a/apps/web/src/components/service-kpi-cards.tsx b/apps/web/src/components/service-kpi-cards.tsx new file mode 100644 index 0000000..493e42b --- /dev/null +++ b/apps/web/src/components/service-kpi-cards.tsx @@ -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(() => { + 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 ( + } + emptyMessage="Нет сервисов — создайте первый" + /> + ) +} diff --git a/apps/web/src/components/services/services-grouped-catalog.tsx b/apps/web/src/components/services/services-grouped-catalog.tsx index 4caa9dc..1630423 100644 --- a/apps/web/src/components/services/services-grouped-catalog.tsx +++ b/apps/web/src/components/services/services-grouped-catalog.tsx @@ -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(() => createDefaultServiceFilters(), ) diff --git a/apps/web/src/routes/_auth/domains/index.tsx b/apps/web/src/routes/_auth/domains/index.tsx index 79126d7..3266b9f 100644 --- a/apps/web/src/routes/_auth/domains/index.tsx +++ b/apps/web/src/routes/_auth/domains/index.tsx @@ -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(null) const [filters, setFilters] = useState(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 ( + ({ ...tab }))} + activeTab={activeTab} + onTabChange={setActiveTab} tabFilter={domainTabFilter} filterFields={filterFields} filters={filters} diff --git a/apps/web/src/routes/_auth/groups.tsx b/apps/web/src/routes/_auth/groups.tsx index 64b197f..2879957 100644 --- a/apps/web/src/routes/_auth/groups.tsx +++ b/apps/web/src/routes/_auth/groups.tsx @@ -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(null) const [deletingGroup, setDeletingGroup] = useState(null) const [filters, setFilters] = useState(() => 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() { /> ) ) : ( + <> + ({ ...tab }))} + activeTab={activeTab} + onTabChange={setActiveTab} tabFilter={groupTabFilter} filterFields={filterFields} filters={filters} @@ -301,6 +313,7 @@ function GroupsPage() { action: createButton, }} /> + )} (null) const [togglingServiceId, setTogglingServiceId] = useState(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() { )} ) : ( + <> + } /> + )} {sheets}