feat(web): enhance UI components and improve documentation
Build, Test, and Push CFDM Docker Image / test (push) Failing after 37s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
Build, Test, and Push CFDM Docker Image / test (push) Failing after 37s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
- Updated frontend UI patterns documentation to reflect new components and their usage. - Enhanced CertKpiCards for better KPI visualization and streamlined data handling. - Refactored OpsDashboard and ResourcePage for improved layout consistency and loading states. - Introduced CatalogBoardToggle for better navigation between views in the Groups and Services pages. - Improved Tabs component styles for better responsiveness and accessibility. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import { Frame } from "@/components/reui/frame"
|
||||
|
||||
import { CardItem } from "./card-item"
|
||||
import { CARDS } from "./data"
|
||||
|
||||
export function CardGrid() {
|
||||
return (
|
||||
<Frame className="@container w-full">
|
||||
{/* Grid */}
|
||||
<div className="grid gap-1 @2xl:grid-cols-2 @4xl:grid-cols-4">
|
||||
{CARDS.map((card) => (
|
||||
<CardItem key={card.title} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useId } from "react"
|
||||
import { FramePanel } from "@/components/reui/frame"
|
||||
|
||||
import { ICard } from "./data"
|
||||
|
||||
function CardDotPatternRtl() {
|
||||
const id = useId().replace(/:/g, "")
|
||||
const width = 240
|
||||
const height = 136
|
||||
const patternId = `${id}-pattern`
|
||||
const maskId = `${id}-mask`
|
||||
const gradientId = `${id}-gradient`
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 z-0 [--card-dot-pattern-fill:var(--color-foreground)] [--card-dot-pattern-opacity:0.12] dark:[--card-dot-pattern-opacity:0.2]"
|
||||
>
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="size-full"
|
||||
fill="none"
|
||||
preserveAspectRatio="none"
|
||||
shapeRendering="crispEdges"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ opacity: "var(--card-dot-pattern-opacity)" }}
|
||||
>
|
||||
<defs>
|
||||
<mask
|
||||
id={maskId}
|
||||
style={{ maskType: "alpha" }}
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width={width}
|
||||
height={height}
|
||||
>
|
||||
<rect width={width} height={height} fill={`url(#${gradientId})`} />
|
||||
</mask>
|
||||
|
||||
<linearGradient
|
||||
id={gradientId}
|
||||
x1={width}
|
||||
y1={0}
|
||||
x2={width - 83.8613}
|
||||
y2={95.7483}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="white" />
|
||||
<stop offset="0.538873" stopColor="white" />
|
||||
<stop offset="1" stopColor="white" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
|
||||
<pattern
|
||||
id={patternId}
|
||||
patternUnits="userSpaceOnUse"
|
||||
patternTransform="matrix(48 0 0 48 0 0)"
|
||||
preserveAspectRatio="none"
|
||||
viewBox="0 0 48 48"
|
||||
width="1"
|
||||
height="1"
|
||||
>
|
||||
<g fill="var(--card-dot-pattern-fill)">
|
||||
<rect x="0" y="0" width="2" height="2" fillOpacity="0.216" />
|
||||
<rect x="4" y="0" width="2" height="2" fillOpacity="0.489" />
|
||||
<rect x="8" y="0" width="2" height="2" fillOpacity="0.366" />
|
||||
<rect x="12" y="0" width="2" height="2" fillOpacity="0.305" />
|
||||
<rect x="16" y="0" width="2" height="2" fillOpacity="0.514" />
|
||||
<rect x="20" y="0" width="2" height="2" fillOpacity="0.129" />
|
||||
<rect x="24" y="0" width="2" height="2" fillOpacity="0.431" />
|
||||
<rect x="28" y="0" width="2" height="2" fillOpacity="0.142" />
|
||||
<rect x="32" y="0" width="2" height="2" fillOpacity="0.288" />
|
||||
<rect x="36" y="0" width="2" height="2" fillOpacity="0.144" />
|
||||
<rect x="40" y="0" width="2" height="2" fillOpacity="0.313" />
|
||||
<rect x="44" y="0" width="2" height="2" fillOpacity="0.214" />
|
||||
<rect x="0" y="4" width="2" height="2" fillOpacity="0.4" />
|
||||
<rect x="4" y="4" width="2" height="2" fillOpacity="0.13" />
|
||||
<rect x="8" y="4" width="2" height="2" fillOpacity="0.498" />
|
||||
<rect x="12" y="4" width="2" height="2" fillOpacity="0.473" />
|
||||
<rect x="16" y="4" width="2" height="2" fillOpacity="0.386" />
|
||||
<rect x="20" y="4" width="2" height="2" fillOpacity="0.248" />
|
||||
<rect x="24" y="4" width="2" height="2" fillOpacity="0.168" />
|
||||
<rect x="28" y="4" width="2" height="2" fillOpacity="0.193" />
|
||||
<rect x="32" y="4" width="2" height="2" fillOpacity="0.528" />
|
||||
<rect x="36" y="4" width="2" height="2" fillOpacity="0.307" />
|
||||
<rect x="40" y="4" width="2" height="2" fillOpacity="0.314" />
|
||||
<rect x="44" y="4" width="2" height="2" fillOpacity="0.444" />
|
||||
<rect x="0" y="8" width="2" height="2" fillOpacity="0.33" />
|
||||
<rect x="4" y="8" width="2" height="2" fillOpacity="0.438" />
|
||||
<rect x="8" y="8" width="2" height="2" fillOpacity="0.477" />
|
||||
<rect x="12" y="8" width="2" height="2" fillOpacity="0.434" />
|
||||
<rect x="16" y="8" width="2" height="2" fillOpacity="0.47" />
|
||||
<rect x="20" y="8" width="2" height="2" fillOpacity="0.383" />
|
||||
<rect x="24" y="8" width="2" height="2" fillOpacity="0.514" />
|
||||
<rect x="28" y="8" width="2" height="2" fillOpacity="0.287" />
|
||||
<rect x="32" y="8" width="2" height="2" fillOpacity="0.346" />
|
||||
<rect x="36" y="8" width="2" height="2" fillOpacity="0.52" />
|
||||
<rect x="40" y="8" width="2" height="2" fillOpacity="0.524" />
|
||||
<rect x="44" y="8" width="2" height="2" fillOpacity="0.126" />
|
||||
<rect x="0" y="12" width="2" height="2" fillOpacity="0.259" />
|
||||
<rect x="4" y="12" width="2" height="2" fillOpacity="0.281" />
|
||||
<rect x="8" y="12" width="2" height="2" fillOpacity="0.311" />
|
||||
<rect x="12" y="12" width="2" height="2" fillOpacity="0.212" />
|
||||
<rect x="16" y="12" width="2" height="2" fillOpacity="0.524" />
|
||||
<rect x="20" y="12" width="2" height="2" fillOpacity="0.353" />
|
||||
<rect x="24" y="12" width="2" height="2" fillOpacity="0.34" />
|
||||
<rect x="28" y="12" width="2" height="2" fillOpacity="0.417" />
|
||||
<rect x="32" y="12" width="2" height="2" fillOpacity="0.258" />
|
||||
<rect x="36" y="12" width="2" height="2" fillOpacity="0.158" />
|
||||
<rect x="40" y="12" width="2" height="2" fillOpacity="0.388" />
|
||||
<rect x="44" y="12" width="2" height="2" fillOpacity="0.184" />
|
||||
<rect x="0" y="16" width="2" height="2" fillOpacity="0.491" />
|
||||
<rect x="4" y="16" width="2" height="2" fillOpacity="0.417" />
|
||||
<rect x="8" y="16" width="2" height="2" fillOpacity="0.231" />
|
||||
<rect x="12" y="16" width="2" height="2" fillOpacity="0.209" />
|
||||
<rect x="16" y="16" width="2" height="2" fillOpacity="0.305" />
|
||||
<rect x="20" y="16" width="2" height="2" fillOpacity="0.287" />
|
||||
<rect x="24" y="16" width="2" height="2" fillOpacity="0.353" />
|
||||
<rect x="28" y="16" width="2" height="2" fillOpacity="0.513" />
|
||||
<rect x="32" y="16" width="2" height="2" fillOpacity="0.483" />
|
||||
<rect x="36" y="16" width="2" height="2" fillOpacity="0.455" />
|
||||
<rect x="40" y="16" width="2" height="2" fillOpacity="0.483" />
|
||||
<rect x="44" y="16" width="2" height="2" fillOpacity="0.189" />
|
||||
<rect x="0" y="20" width="2" height="2" fillOpacity="0.264" />
|
||||
<rect x="4" y="20" width="2" height="2" fillOpacity="0.295" />
|
||||
<rect x="8" y="20" width="2" height="2" fillOpacity="0.51" />
|
||||
<rect x="12" y="20" width="2" height="2" fillOpacity="0.305" />
|
||||
<rect x="16" y="20" width="2" height="2" fillOpacity="0.438" />
|
||||
<rect x="20" y="20" width="2" height="2" fillOpacity="0.289" />
|
||||
<rect x="24" y="20" width="2" height="2" fillOpacity="0.322" />
|
||||
<rect x="28" y="20" width="2" height="2" fillOpacity="0.196" />
|
||||
<rect x="32" y="20" width="2" height="2" fillOpacity="0.486" />
|
||||
<rect x="36" y="20" width="2" height="2" fillOpacity="0.388" />
|
||||
<rect x="40" y="20" width="2" height="2" fillOpacity="0.489" />
|
||||
<rect x="44" y="20" width="2" height="2" fillOpacity="0.208" />
|
||||
<rect x="0" y="24" width="2" height="2" fillOpacity="0.239" />
|
||||
<rect x="4" y="24" width="2" height="2" fillOpacity="0.463" />
|
||||
<rect x="8" y="24" width="2" height="2" fillOpacity="0.539" />
|
||||
<rect x="12" y="24" width="2" height="2" fillOpacity="0.408" />
|
||||
<rect x="16" y="24" width="2" height="2" fillOpacity="0.527" />
|
||||
<rect x="20" y="24" width="2" height="2" fillOpacity="0.357" />
|
||||
<rect x="24" y="24" width="2" height="2" fillOpacity="0.362" />
|
||||
<rect x="28" y="24" width="2" height="2" fillOpacity="0.151" />
|
||||
<rect x="32" y="24" width="2" height="2" fillOpacity="0.324" />
|
||||
<rect x="36" y="24" width="2" height="2" fillOpacity="0.347" />
|
||||
<rect x="40" y="24" width="2" height="2" fillOpacity="0.299" />
|
||||
<rect x="44" y="24" width="2" height="2" fillOpacity="0.127" />
|
||||
<rect x="0" y="28" width="2" height="2" fillOpacity="0.476" />
|
||||
<rect x="4" y="28" width="2" height="2" fillOpacity="0.461" />
|
||||
<rect x="8" y="28" width="2" height="2" fillOpacity="0.377" />
|
||||
<rect x="12" y="28" width="2" height="2" fillOpacity="0.415" />
|
||||
<rect x="16" y="28" width="2" height="2" fillOpacity="0.217" />
|
||||
<rect x="20" y="28" width="2" height="2" fillOpacity="0.297" />
|
||||
<rect x="24" y="28" width="2" height="2" fillOpacity="0.258" />
|
||||
<rect x="28" y="28" width="2" height="2" fillOpacity="0.296" />
|
||||
<rect x="32" y="28" width="2" height="2" fillOpacity="0.276" />
|
||||
<rect x="36" y="28" width="2" height="2" fillOpacity="0.182" />
|
||||
<rect x="40" y="28" width="2" height="2" fillOpacity="0.422" />
|
||||
<rect x="44" y="28" width="2" height="2" fillOpacity="0.392" />
|
||||
<rect x="0" y="32" width="2" height="2" fillOpacity="0.273" />
|
||||
<rect x="4" y="32" width="2" height="2" fillOpacity="0.143" />
|
||||
<rect x="8" y="32" width="2" height="2" fillOpacity="0.338" />
|
||||
<rect x="12" y="32" width="2" height="2" fillOpacity="0.264" />
|
||||
<rect x="16" y="32" width="2" height="2" fillOpacity="0.218" />
|
||||
<rect x="20" y="32" width="2" height="2" fillOpacity="0.29" />
|
||||
<rect x="24" y="32" width="2" height="2" fillOpacity="0.336" />
|
||||
<rect x="28" y="32" width="2" height="2" fillOpacity="0.313" />
|
||||
<rect x="32" y="32" width="2" height="2" fillOpacity="0.514" />
|
||||
<rect x="36" y="32" width="2" height="2" fillOpacity="0.289" />
|
||||
<rect x="40" y="32" width="2" height="2" fillOpacity="0.25" />
|
||||
<rect x="44" y="32" width="2" height="2" fillOpacity="0.507" />
|
||||
<rect x="0" y="36" width="2" height="2" fillOpacity="0.346" />
|
||||
<rect x="4" y="36" width="2" height="2" fillOpacity="0.208" />
|
||||
<rect x="8" y="36" width="2" height="2" fillOpacity="0.313" />
|
||||
<rect x="12" y="36" width="2" height="2" fillOpacity="0.365" />
|
||||
<rect x="16" y="36" width="2" height="2" fillOpacity="0.491" />
|
||||
<rect x="20" y="36" width="2" height="2" fillOpacity="0.121" />
|
||||
<rect x="24" y="36" width="2" height="2" fillOpacity="0.472" />
|
||||
<rect x="28" y="36" width="2" height="2" fillOpacity="0.281" />
|
||||
<rect x="32" y="36" width="2" height="2" fillOpacity="0.297" />
|
||||
<rect x="36" y="36" width="2" height="2" fillOpacity="0.33" />
|
||||
<rect x="40" y="36" width="2" height="2" fillOpacity="0.463" />
|
||||
<rect x="44" y="36" width="2" height="2" fillOpacity="0.447" />
|
||||
<rect x="0" y="40" width="2" height="2" fillOpacity="0.217" />
|
||||
<rect x="4" y="40" width="2" height="2" fillOpacity="0.467" />
|
||||
<rect x="8" y="40" width="2" height="2" fillOpacity="0.297" />
|
||||
<rect x="12" y="40" width="2" height="2" fillOpacity="0.303" />
|
||||
<rect x="16" y="40" width="2" height="2" fillOpacity="0.497" />
|
||||
<rect x="20" y="40" width="2" height="2" fillOpacity="0.201" />
|
||||
<rect x="24" y="40" width="2" height="2" fillOpacity="0.5" />
|
||||
<rect x="28" y="40" width="2" height="2" fillOpacity="0.458" />
|
||||
<rect x="32" y="40" width="2" height="2" fillOpacity="0.165" />
|
||||
<rect x="36" y="40" width="2" height="2" fillOpacity="0.36" />
|
||||
<rect x="40" y="40" width="2" height="2" fillOpacity="0.329" />
|
||||
<rect x="44" y="40" width="2" height="2" fillOpacity="0.419" />
|
||||
<rect x="0" y="44" width="2" height="2" fillOpacity="0.47" />
|
||||
<rect x="4" y="44" width="2" height="2" fillOpacity="0.161" />
|
||||
<rect x="8" y="44" width="2" height="2" fillOpacity="0.191" />
|
||||
<rect x="12" y="44" width="2" height="2" fillOpacity="0.341" />
|
||||
<rect x="16" y="44" width="2" height="2" fillOpacity="0.279" />
|
||||
<rect x="20" y="44" width="2" height="2" fillOpacity="0.387" />
|
||||
<rect x="24" y="44" width="2" height="2" fillOpacity="0.173" />
|
||||
<rect x="28" y="44" width="2" height="2" fillOpacity="0.537" />
|
||||
<rect x="32" y="44" width="2" height="2" fillOpacity="0.218" />
|
||||
<rect x="36" y="44" width="2" height="2" fillOpacity="0.368" />
|
||||
<rect x="40" y="44" width="2" height="2" fillOpacity="0.5" />
|
||||
<rect x="44" y="44" width="2" height="2" fillOpacity="0.414" />
|
||||
</g>
|
||||
</pattern>
|
||||
</defs>
|
||||
<g mask={`url(#${maskId})`}>
|
||||
<rect width={width} height={height} fill={`url(#${patternId})`} />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export function CardItem({ card }: { card: ICard }) {
|
||||
return (
|
||||
<FramePanel className="relative isolate flex flex-col gap-3">
|
||||
{/* Card */}
|
||||
<CardDotPatternRtl />
|
||||
<div className="relative z-10 flex flex-col gap-3">
|
||||
<span className="text-muted-foreground text-relaxed text-sm">
|
||||
{card.title}
|
||||
</span>
|
||||
<span className="text-foreground text-xl leading-tight font-semibold">
|
||||
{card.total}
|
||||
</span>
|
||||
</div>
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface ICard {
|
||||
title: string
|
||||
total: string
|
||||
}
|
||||
|
||||
export const CARDS: ICard[] = [
|
||||
{
|
||||
title: "Avg. Deal Size",
|
||||
total: "$14,9M",
|
||||
},
|
||||
{
|
||||
title: "Due Deals",
|
||||
total: "16",
|
||||
},
|
||||
{
|
||||
title: "Pending Invoices",
|
||||
total: "$2.7M",
|
||||
},
|
||||
{
|
||||
title: "Active Clients",
|
||||
total: "36",
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
import { CardGrid } from "./components/card-grid"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full max-w-5xl items-center justify-center p-10">
|
||||
<CardGrid />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
|
||||
interface CatalogBoardToggleProps {
|
||||
view: 'catalog' | 'board'
|
||||
onViewChange: (view: 'catalog' | 'board') => void
|
||||
}
|
||||
|
||||
export function CatalogBoardToggle({
|
||||
view,
|
||||
onViewChange,
|
||||
}: CatalogBoardToggleProps) {
|
||||
if (view === 'board') {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onViewChange('catalog')}
|
||||
>
|
||||
К каталогу
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onViewChange('board')}
|
||||
>
|
||||
Доска
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,7 @@
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
CheckCircle2Icon,
|
||||
ShieldAlertIcon,
|
||||
ShieldCheckIcon,
|
||||
} from 'lucide-react'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { ShieldCheckIcon } from 'lucide-react'
|
||||
import { KpiStatGrid, type KpiStatCard } from '@/components/reui-kit/kpi-stat-grid'
|
||||
|
||||
const KPI_ICON_BG = ['bg-chart-1', 'bg-chart-2', 'bg-chart-3', 'bg-chart-4'] as const
|
||||
|
||||
const ACTIVE_STATUSES = new Set(['active', 'ok', 'synced'])
|
||||
const PROBLEM_STATUSES = new Set(['expired', 'error', 'conflict'])
|
||||
|
||||
@@ -52,49 +44,33 @@ export function CertKpiCards({
|
||||
return [
|
||||
{
|
||||
id: 'all',
|
||||
typeLabel: 'Мониторинг',
|
||||
title: 'Всего',
|
||||
metricLabel: 'Проверенные хосты',
|
||||
label: 'Всего',
|
||||
value: allCount,
|
||||
icon: <ShieldCheckIcon aria-hidden="true" />,
|
||||
iconBg: KPI_ICON_BG[0],
|
||||
badge: <Badge variant="info-light">Все статусы</Badge>,
|
||||
hint: 'Проверенные хосты',
|
||||
onSelect: () => onSelectTab('all'),
|
||||
selected: activeTab === 'all',
|
||||
},
|
||||
{
|
||||
id: 'active',
|
||||
typeLabel: 'Безопасность',
|
||||
title: 'OK',
|
||||
metricLabel: 'Активные сертификаты',
|
||||
label: 'OK',
|
||||
value: okCount,
|
||||
icon: <CheckCircle2Icon aria-hidden="true" />,
|
||||
iconBg: KPI_ICON_BG[1],
|
||||
badge: <Badge variant="success-light">В норме</Badge>,
|
||||
hint: 'Активные сертификаты',
|
||||
onSelect: () => onSelectTab('active'),
|
||||
selected: activeTab === 'active',
|
||||
},
|
||||
{
|
||||
id: 'warning',
|
||||
typeLabel: 'Срок действия',
|
||||
title: 'Предупреждение',
|
||||
metricLabel: 'Истекают скоро',
|
||||
label: 'Предупреждение',
|
||||
value: warningCount,
|
||||
icon: <AlertTriangleIcon aria-hidden="true" />,
|
||||
iconBg: KPI_ICON_BG[2],
|
||||
badge: <Badge variant="warning-light">Требуют внимания</Badge>,
|
||||
hint: 'Истекают скоро',
|
||||
onSelect: () => onSelectTab('warning'),
|
||||
selected: activeTab === 'warning',
|
||||
},
|
||||
{
|
||||
id: 'expired',
|
||||
typeLabel: 'Риски',
|
||||
title: 'Проблемы',
|
||||
metricLabel: 'Ошибка или истёк',
|
||||
label: 'Проблемы',
|
||||
value: problemCount,
|
||||
icon: <ShieldAlertIcon aria-hidden="true" />,
|
||||
iconBg: KPI_ICON_BG[3],
|
||||
badge: <Badge variant="destructive-light">Критично</Badge>,
|
||||
hint: 'Ошибка или истёк',
|
||||
onSelect: () => onSelectTab('expired'),
|
||||
selected: activeTab === 'expired',
|
||||
},
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { MoreHorizontalIcon, SearchIcon } from 'lucide-react'
|
||||
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { createFilter, type FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
|
||||
export interface GroupCatalogRow {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
domainCount: number
|
||||
}
|
||||
|
||||
export const GROUP_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'with_domains', label: 'С доменами' },
|
||||
{ id: 'empty', label: 'Пустые' },
|
||||
] as const
|
||||
|
||||
export function groupTabFilter(item: GroupCatalogRow, tabId: string) {
|
||||
if (tabId === 'with_domains') return item.domainCount > 0
|
||||
if (tabId === 'empty') return item.domainCount === 0
|
||||
return true
|
||||
}
|
||||
|
||||
export function createDefaultGroupFilters() {
|
||||
return [createFilter('name', 'contains', [''])]
|
||||
}
|
||||
|
||||
export function useGroupFilterFields() {
|
||||
return useMemo<FilterFieldConfig[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Название',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск…',
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
}
|
||||
|
||||
export function groupFilterFieldValue(item: GroupCatalogRow, field: string) {
|
||||
switch (field) {
|
||||
case 'name':
|
||||
return `${item.name} ${item.slug}`.toLowerCase()
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function useGroupColumns({
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
onEdit: (group: GroupCatalogRow) => void
|
||||
onDelete: (group: GroupCatalogRow) => void
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<GroupCatalogRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'name',
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Название" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto max-w-64 truncate p-0 font-medium"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
to="/groups/$groupId"
|
||||
params={{ groupId: String(row.original.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{row.original.name}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'slug',
|
||||
accessorKey: 'slug',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Slug" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{row.original.slug}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'domainCount',
|
||||
accessorKey: 'domainCount',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Домены" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">{row.original.domainCount}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
aria-label="Действия"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link
|
||||
to="/groups/$groupId"
|
||||
params={{ groupId: String(row.original.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Открыть
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onEdit(row.original)}>
|
||||
Изменить
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => onDelete(row.original)}
|
||||
>
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
],
|
||||
[onEdit, onDelete],
|
||||
)
|
||||
|
||||
return { columns }
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { MoreHorizontalIcon, SearchIcon } from 'lucide-react'
|
||||
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { createFilter, type FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import type { ServiceView } from '@/lib/schemas'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
|
||||
export interface ServiceCatalogRow {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
enabled: boolean
|
||||
groupId: number | null
|
||||
groupName: string | null
|
||||
domainIds: number[]
|
||||
service: ServiceView
|
||||
}
|
||||
|
||||
export const SERVICE_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'enabled', label: 'Включены' },
|
||||
{ id: 'disabled', label: 'Выключены' },
|
||||
{ id: 'ungrouped', label: 'Без группы' },
|
||||
] as const
|
||||
|
||||
export function serviceTabFilter(item: ServiceCatalogRow, tabId: string) {
|
||||
if (tabId === 'enabled') return item.enabled
|
||||
if (tabId === 'disabled') return !item.enabled
|
||||
if (tabId === 'ungrouped') return item.groupId == null
|
||||
return true
|
||||
}
|
||||
|
||||
export function createDefaultServiceFilters() {
|
||||
return [createFilter('name', 'contains', [''])]
|
||||
}
|
||||
|
||||
export function useServiceFilterFields() {
|
||||
return useMemo<FilterFieldConfig[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Название',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск…',
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
}
|
||||
|
||||
export function serviceFilterFieldValue(item: ServiceCatalogRow, field: string) {
|
||||
switch (field) {
|
||||
case 'name':
|
||||
return `${item.name} ${item.slug} ${item.groupName ?? ''}`.toLowerCase()
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function useServiceColumns({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggle,
|
||||
togglingId,
|
||||
}: {
|
||||
onEdit: (service: ServiceView) => void
|
||||
onDelete: (service: ServiceView) => void
|
||||
onToggle: (serviceId: number, enabled: boolean) => void
|
||||
togglingId: number | null
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<ServiceCatalogRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'name',
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Название" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<button
|
||||
type="button"
|
||||
className="text-foreground max-w-64 truncate text-left font-medium hover:underline"
|
||||
onClick={() => onEdit(row.original.service)}
|
||||
>
|
||||
{row.original.name}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'group',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Группа" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{row.original.groupName ?? 'Без группы'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'enabled',
|
||||
accessorKey: 'enabled',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Статус" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={row.original.enabled}
|
||||
disabled={togglingId === row.original.id}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggle(row.original.id, Boolean(checked))
|
||||
}
|
||||
aria-label={
|
||||
row.original.enabled ? 'Выключить сервис' : 'Включить сервис'
|
||||
}
|
||||
/>
|
||||
<StatusBadge
|
||||
status={row.original.enabled ? 'active' : 'disabled'}
|
||||
label={row.original.enabled ? 'Вкл' : 'Выкл'}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
aria-label="Действия"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit(row.original.service)}>
|
||||
Изменить
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => onDelete(row.original.service)}
|
||||
>
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
],
|
||||
[onEdit, onDelete, onToggle, togglingId],
|
||||
)
|
||||
|
||||
return { columns }
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@cfdm/ui/components/card"
|
||||
import { Input } from "@cfdm/ui/components/input"
|
||||
import { Label } from "@cfdm/ui/components/label"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@cfdm/ui/components/tabs"
|
||||
|
||||
export function Pattern() {
|
||||
return (
|
||||
<div className="flex w-full max-w-xs flex-col gap-6">
|
||||
<Tabs defaultValue="account">
|
||||
<TabsList variant="line" className="mb-3.5 w-full">
|
||||
<TabsTrigger value="account">Account</TabsTrigger>
|
||||
<TabsTrigger value="password">Password</TabsTrigger>
|
||||
<TabsTrigger value="settings">Settings</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="account">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Account</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
Update your account information.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="underline-name" className="text-sm">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="underline-name"
|
||||
defaultValue="Alex Chen"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="underline-email" className="text-sm">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id="underline-email"
|
||||
type="email"
|
||||
defaultValue="alex.chen@example.com"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="pt-3">
|
||||
<Button size="sm">Save changes</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="password">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Password</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
Change your password here.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="underline-current" className="text-sm">
|
||||
Current password
|
||||
</Label>
|
||||
<Input id="underline-current" type="password" className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="underline-new" className="text-sm">
|
||||
New password
|
||||
</Label>
|
||||
<Input id="underline-new" type="password" className="h-9" />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="pt-3">
|
||||
<Button size="sm">Update password</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="settings">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Settings</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
Manage your preferences.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="underline-theme" className="text-sm">
|
||||
Theme
|
||||
</Label>
|
||||
<Input
|
||||
id="underline-theme"
|
||||
defaultValue="Light"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="underline-language" className="text-sm">
|
||||
Language
|
||||
</Label>
|
||||
<Input
|
||||
id="underline-language"
|
||||
defaultValue="English"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="pt-3">
|
||||
<Button size="sm">Save settings</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from
|
||||
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
|
||||
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
|
||||
export { KpiStatGrid, type KpiStatCard, type OpsKpiCard } from './kpi-stat-grid'
|
||||
export { OpsDashboard, OpsDashboardHintLink } from './ops-dashboard'
|
||||
export { OpsDashboard } from './ops-dashboard'
|
||||
export { KanbanBoard, KanbanBoardSkeleton, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board'
|
||||
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import { Item, ItemMedia } from '@cfdm/ui/components/item'
|
||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||
|
||||
export interface KpiStatCard {
|
||||
id: string
|
||||
typeLabel: string
|
||||
title: string
|
||||
metricLabel: string
|
||||
label: string
|
||||
value: string | number
|
||||
icon: ReactNode
|
||||
iconBg?: string
|
||||
hint?: ReactNode
|
||||
detail?: ReactNode
|
||||
badge?: ReactNode
|
||||
hint?: string
|
||||
to?: string
|
||||
search?: Record<string, unknown>
|
||||
onSelect?: () => void
|
||||
selected?: boolean
|
||||
}
|
||||
@@ -31,75 +27,70 @@ interface KpiStatGridProps {
|
||||
skeletonCount?: number
|
||||
}
|
||||
|
||||
const ICON_TILE_CLASS =
|
||||
'border-background flex size-10.5 items-center justify-center border-2 p-0 [background-image:radial-gradient(48.05%_48.05%_at_50%_5.95%,rgba(255,255,255,0.4)_0%,rgba(255,255,255,0)_100%)] shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5 [&_svg]:text-white'
|
||||
function KpiStatCardBody({ card }: { card: KpiStatCard }) {
|
||||
return (
|
||||
<div className="relative z-10 flex flex-col gap-3">
|
||||
<span className="text-muted-foreground text-sm">{card.label}</span>
|
||||
<span className="text-foreground text-xl leading-tight font-semibold tabular-nums">
|
||||
{card.value}
|
||||
</span>
|
||||
{card.hint ? (
|
||||
<span className="text-muted-foreground text-xs leading-snug">{card.hint}</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KpiStatCardItem({ card }: { card: KpiStatCard }) {
|
||||
const interactive = Boolean(card.onSelect)
|
||||
const Panel = interactive ? 'button' : 'div'
|
||||
const panelClass = cn(
|
||||
'relative isolate flex h-full flex-col gap-3',
|
||||
card.selected && 'ring-primary/30 bg-muted/30 ring-1',
|
||||
)
|
||||
|
||||
if (card.to) {
|
||||
return (
|
||||
<Link
|
||||
to={card.to}
|
||||
search={card.search}
|
||||
className="hover:bg-muted/40 focus-visible:ring-ring rounded-[calc(var(--frame-radius)-2px)] transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<FramePanel className={panelClass}>
|
||||
<KpiStatCardBody card={card} />
|
||||
</FramePanel>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
if (card.onSelect) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={card.onSelect}
|
||||
className="hover:bg-muted/40 focus-visible:ring-ring rounded-[calc(var(--frame-radius)-2px)] text-start transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<FramePanel className={panelClass}>
|
||||
<KpiStatCardBody card={card} />
|
||||
</FramePanel>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel
|
||||
type={interactive ? 'button' : undefined}
|
||||
onClick={card.onSelect}
|
||||
className={cn(
|
||||
'text-start',
|
||||
interactive &&
|
||||
'hover:bg-muted/40 focus-visible:ring-ring rounded-[calc(var(--frame-radius)-2px)] transition-colors focus-visible:ring-2 focus-visible:outline-none',
|
||||
card.selected && 'bg-muted/30 ring-primary/30 ring-1',
|
||||
)}
|
||||
>
|
||||
<FramePanel className="flex h-full flex-col items-start gap-4">
|
||||
<div className="flex w-full items-center gap-2.5">
|
||||
<Item className={cn('p-0', ICON_TILE_CLASS, card.iconBg ?? 'bg-chart-1')}>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{card.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<p className="text-muted-foreground min-w-0 flex-1 text-xs leading-tight">
|
||||
{card.typeLabel}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-foreground text-2xl leading-none font-bold tabular-nums">
|
||||
{card.value}
|
||||
</div>
|
||||
<div className="text-sm leading-tight font-medium">{card.title}</div>
|
||||
<div className="text-muted-foreground text-xs leading-snug">{card.metricLabel}</div>
|
||||
</div>
|
||||
|
||||
{card.badge || card.hint ? (
|
||||
<div className="mt-auto flex flex-wrap items-center gap-2">
|
||||
{card.badge}
|
||||
{card.hint}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{card.detail ? (
|
||||
<p className="text-muted-foreground text-xs leading-snug">{card.detail}</p>
|
||||
) : null}
|
||||
</FramePanel>
|
||||
</Panel>
|
||||
<FramePanel className={panelClass}>
|
||||
<KpiStatCardBody card={card} />
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
function KpiStatGridSkeleton({ count }: { count: number }) {
|
||||
return (
|
||||
<Frame className="@container w-full">
|
||||
<div className="grid gap-1 @2xl:grid-cols-2 @5xl:grid-cols-4">
|
||||
<div className="grid gap-1 @2xl:grid-cols-2 @4xl:grid-cols-4">
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<FramePanel key={index} className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Skeleton className="size-10.5 rounded-lg" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-28" />
|
||||
<FramePanel key={index} className="flex flex-col gap-3">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-7 w-14" />
|
||||
<Skeleton className="h-3 w-28" />
|
||||
</FramePanel>
|
||||
))}
|
||||
</div>
|
||||
@@ -134,7 +125,7 @@ export function KpiStatGrid({
|
||||
|
||||
return (
|
||||
<Frame className={cn('@container w-full', className)}>
|
||||
<div className="grid gap-1 @2xl:grid-cols-2 @5xl:grid-cols-4">
|
||||
<div className="grid gap-1 @2xl:grid-cols-2 @4xl:grid-cols-4">
|
||||
{cards.map((card) => (
|
||||
<KpiStatCardItem key={card.id} card={card} />
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
@@ -7,6 +6,7 @@ import {
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||
import { debugAgentLog } from '@/lib/debug-agent-log'
|
||||
import { KpiStatGrid, type KpiStatCard } from './kpi-stat-grid'
|
||||
|
||||
@@ -18,6 +18,24 @@ interface OpsDashboardProps {
|
||||
kpiCards: OpsKpiCard[]
|
||||
charts: ReactNode
|
||||
queue: ReactNode
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
function OpsDashboardSkeleton() {
|
||||
return (
|
||||
<div className="text-foreground @container mx-auto flex w-full max-w-7xl flex-col gap-2 md:gap-3">
|
||||
<header className="px-1">
|
||||
<Skeleton className="h-7 w-56" />
|
||||
<Skeleton className="mt-2 h-4 w-80 max-w-full" />
|
||||
</header>
|
||||
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
|
||||
<div className="grid gap-2 @3xl:grid-cols-2">
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
</div>
|
||||
<Skeleton className="h-48 w-full rounded-xl" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function OpsDashboard({
|
||||
@@ -26,10 +44,12 @@ export function OpsDashboard({
|
||||
kpiCards,
|
||||
charts,
|
||||
queue,
|
||||
isLoading = false,
|
||||
}: OpsDashboardProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return
|
||||
const el = rootRef.current
|
||||
if (!el) return
|
||||
debugAgentLog(
|
||||
@@ -42,12 +62,16 @@ export function OpsDashboard({
|
||||
},
|
||||
'A',
|
||||
)
|
||||
}, [])
|
||||
}, [isLoading])
|
||||
|
||||
if (isLoading) {
|
||||
return <OpsDashboardSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="text-foreground @container mx-auto flex w-full max-w-7xl flex-col gap-4 md:gap-6"
|
||||
className="text-foreground @container mx-auto flex w-full max-w-7xl flex-col gap-2 md:gap-3"
|
||||
>
|
||||
<header className="px-1">
|
||||
<div className="flex flex-col gap-px">
|
||||
@@ -64,7 +88,7 @@ export function OpsDashboard({
|
||||
|
||||
<section
|
||||
aria-label="Аналитика"
|
||||
className="grid min-w-0 auto-rows-fr items-start gap-4 @3xl:grid-cols-2"
|
||||
className="grid min-w-0 auto-rows-fr items-start gap-2 @3xl:grid-cols-2"
|
||||
>
|
||||
{charts}
|
||||
</section>
|
||||
@@ -83,23 +107,3 @@ export function OpsDashboard({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function OpsDashboardHintLink({
|
||||
to,
|
||||
search,
|
||||
children,
|
||||
}: {
|
||||
to: string
|
||||
search?: Record<string, unknown>
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
search={search}
|
||||
className="text-primary text-sm font-medium hover:underline"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ import { Separator } from '@cfdm/ui/components/separator'
|
||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { applyFiltersToData } from './filter-utils'
|
||||
import { debugAgentLog } from '@/lib/debug-agent-log'
|
||||
@@ -290,24 +289,15 @@ export function ResourcePage<T extends object>({
|
||||
<FramePanel className="p-0 shadow-none!">
|
||||
{tabs && tabs.length > 0 ? (
|
||||
<>
|
||||
{/* Line tabs как @reui/data-grid-filtering-2: muted count + underline */}
|
||||
{/* Line tabs: c-tabs-2 + filtering-2 (muted count + underline) */}
|
||||
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||
<TabsList
|
||||
variant="line"
|
||||
className="h-auto gap-5 bg-transparent p-0"
|
||||
>
|
||||
<TabsList variant="line" className="gap-5">
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
className={cn(
|
||||
'h-auto flex-none gap-2 rounded-none bg-transparent px-0 pb-3 text-sm shadow-none',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'data-active:bg-transparent data-active:text-foreground data-active:shadow-none',
|
||||
'dark:data-active:border-transparent dark:data-active:bg-transparent',
|
||||
'after:bottom-0 after:h-0.5',
|
||||
)}
|
||||
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3"
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
|
||||
|
||||
@@ -404,13 +404,15 @@ export function ServiceEditSheet({
|
||||
orientation="horizontal"
|
||||
className="flex w-full flex-col gap-4"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="general">Основное</TabsTrigger>
|
||||
<TabsTrigger value="bindings">
|
||||
<TabsList variant="line" className="w-full gap-5">
|
||||
<TabsTrigger value="general" className="px-0 pb-2">
|
||||
Основное
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="bindings" className="gap-2 px-0 pb-2">
|
||||
Привязки
|
||||
{bindings.length > 0 ? (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
({bindings.length})
|
||||
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
|
||||
{bindings.length}
|
||||
</span>
|
||||
) : null}
|
||||
</TabsTrigger>
|
||||
|
||||
Reference in New Issue
Block a user