refactor: integrate PanelCard and enhance dashboard components for improved layout
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 52s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m7s
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 52s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m7s
Refactored multiple dashboard components to utilize the new PanelCard for better organization and presentation. Updated the DashboardFramePanel, DashboardQuickLinks, and DashboardOperationsBreakdown components to streamline layouts and enhance user experience. Removed deprecated components and improved loading states in various sections, ensuring a cohesive interface throughout the application.
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import { Cell, Pie, PieChart } from 'recharts'
|
||||
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
ChartContainer,
|
||||
type ChartConfig,
|
||||
} from '@evobgp/ui/components/chart'
|
||||
import type { BreakdownSlice } from '@/lib/metrics'
|
||||
|
||||
/** chart-27 / chart-13 inspired donut in PanelCard. */
|
||||
export function DonutBreakdownCard({
|
||||
title,
|
||||
description,
|
||||
slices,
|
||||
centerLabel,
|
||||
badge,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
slices: BreakdownSlice[]
|
||||
centerLabel: string
|
||||
badge?: string
|
||||
}) {
|
||||
const total = slices.reduce((sum, s) => sum + s.count, 0)
|
||||
const chartConfig = slices.reduce<ChartConfig>((acc, slice) => {
|
||||
acc[slice.key] = { label: slice.label, color: slice.color }
|
||||
return acc
|
||||
}, {})
|
||||
const data = slices.map((slice) => ({ ...slice, fill: slice.color, share: slice.count }))
|
||||
|
||||
return (
|
||||
<PanelCard title={title} description={description} className="h-full">
|
||||
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center">
|
||||
{total === 0 ? (
|
||||
<p className="text-muted-foreground w-full py-8 text-center text-sm">Нет данных</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative mx-auto size-36 shrink-0">
|
||||
<ChartContainer config={chartConfig} className="aspect-square size-36">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="share"
|
||||
nameKey="label"
|
||||
innerRadius={48}
|
||||
outerRadius={64}
|
||||
strokeWidth={2}
|
||||
stroke="var(--color-card)"
|
||||
>
|
||||
{data.map((entry) => (
|
||||
<Cell key={entry.key} fill={entry.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-muted-foreground text-xs">{centerLabel}</span>
|
||||
<span className="text-lg font-semibold tabular-nums">{total}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="min-w-0 flex-1 space-y-2">
|
||||
{slices.map((slice) => {
|
||||
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
|
||||
return (
|
||||
<li key={slice.key} className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className="size-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: slice.color }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="truncate">{slice.label}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 tabular-nums">
|
||||
{slice.count} ({pct}%)
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{badge ? (
|
||||
<Badge variant="success-light" className="absolute top-4 right-4 hidden sm:flex">
|
||||
{badge}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</PanelCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
import { IconStack } from '@/components/reui/icon-stack'
|
||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@evobgp/ui/components/empty'
|
||||
|
||||
/** empty-state-11 pattern adapted to Card surface. */
|
||||
export function IllustratedEmptyState({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
action,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
icon: LucideIcon
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Card className="bg-muted min-h-[240px] p-0 shadow-none">
|
||||
<CardContent className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10">
|
||||
{action ? <div className="w-full self-end">{action}</div> : null}
|
||||
<Empty className="max-w-md gap-5 bg-transparent p-0">
|
||||
<EmptyHeader className="items-center gap-5 text-center">
|
||||
<EmptyMedia className="mb-0">
|
||||
<IconStack aria-hidden>
|
||||
<Icon strokeWidth={1.9} aria-hidden />
|
||||
</IconStack>
|
||||
</EmptyMedia>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<EmptyTitle className="text-base font-semibold tracking-tight">{title}</EmptyTitle>
|
||||
<EmptyDescription className="max-w-sm text-sm/relaxed">{description}</EmptyDescription>
|
||||
</div>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { DonutBreakdownCard } from './donut-breakdown-card'
|
||||
export { IllustratedEmptyState } from './illustrated-empty-state'
|
||||
export { KpiSparklineCard, type KpiSparklineMetric } from './kpi-sparkline-card'
|
||||
export { PanelCorners } from './panel-corners'
|
||||
export { ProjectsEmptyState } from './projects-empty-state'
|
||||
export { SegmentedProgressCard, type SegmentStat } from './segmented-progress-card'
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { CardDotField } from '@/components/dashboard/card-dot-field'
|
||||
import { PanelCorners } from '@/components/patterns/panel-corners'
|
||||
import { toneStyles, type MetricTone } from '@/components/patterns/metric-tone-styles'
|
||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
||||
|
||||
export type KpiSparklineMetric = {
|
||||
id: string
|
||||
title: string
|
||||
label: string
|
||||
value: string
|
||||
delta: string
|
||||
deltaVariant: ComponentProps<typeof Badge>['variant']
|
||||
detail: string
|
||||
tone: MetricTone
|
||||
sparkline: readonly number[]
|
||||
}
|
||||
|
||||
function Sparkline({ values, color }: { values: readonly number[]; color: string }) {
|
||||
const min = Math.min(...values)
|
||||
const max = Math.max(...values)
|
||||
const spread = Math.max(1, max - min)
|
||||
const points = values
|
||||
.map((value, index) => {
|
||||
const x = (index / (values.length - 1)) * 72
|
||||
const y = 28 - ((value - min) / spread) * 22
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||||
})
|
||||
.join(' ')
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 72 32" className="h-9 w-24 shrink-0 opacity-95" role="img" aria-label="Тренд">
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="square"
|
||||
strokeLinejoin="miter"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** KPI card with sparkline (dashboard-5 / chart-15 pattern, Card surface). */
|
||||
export function KpiSparklineCard({ metric }: { metric: KpiSparklineMetric }) {
|
||||
const tone = toneStyles[metric.tone]
|
||||
|
||||
return (
|
||||
<Card className="relative overflow-hidden p-0">
|
||||
<CardDotField className="text-muted-foreground [mask-image:radial-gradient(72%_64%_at_50%_44%,black,transparent)] opacity-70" />
|
||||
<PanelCorners />
|
||||
<CardContent className="relative z-10 flex min-h-[7.25rem] flex-col justify-between gap-5 p-4">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-foreground truncate text-sm leading-4 font-semibold">{metric.title}</span>
|
||||
<span className="text-muted-foreground truncate text-xs leading-4">{metric.label}</span>
|
||||
</div>
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div className="min-w-0 space-y-2.5">
|
||||
<div className="text-foreground text-2xl leading-none font-semibold tracking-tight tabular-nums">
|
||||
{metric.value}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={metric.deltaVariant} radius="full" size="sm">
|
||||
{metric.delta}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">{metric.detail}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Sparkline values={metric.sparkline} color={tone.stroke} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type MetricTone = 'danger' | 'success' | 'warning' | 'info'
|
||||
|
||||
export const toneStyles: Record<MetricTone, { stroke: string }> = {
|
||||
danger: { stroke: 'var(--color-destructive)' },
|
||||
success: { stroke: 'var(--color-success)' },
|
||||
warning: { stroke: 'var(--color-warning)' },
|
||||
info: { stroke: 'var(--color-info)' },
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Corner accents from ReUI dashboard-5 block. */
|
||||
export function PanelCorners() {
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
aria-hidden
|
||||
className="border-foreground/65 absolute top-0 left-0 size-2 border-t border-l"
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className="border-foreground/65 absolute right-0 bottom-0 size-2 border-r border-b"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Boxes, Plus } from 'lucide-react'
|
||||
|
||||
import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-state'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
/** empty-state-3 pattern for first module. */
|
||||
export function ProjectsEmptyState() {
|
||||
return (
|
||||
<IllustratedEmptyState
|
||||
icon={Boxes}
|
||||
title="Создайте первый модуль"
|
||||
description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны."
|
||||
action={
|
||||
<Button size="sm" render={<Link to="/modules/new" />}>
|
||||
<Plus />
|
||||
Новый модуль
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { Progress } from '@evobgp/ui/components/progress'
|
||||
|
||||
export type SegmentStat = {
|
||||
value: string | number
|
||||
label: string
|
||||
percent: number
|
||||
badge?: ReactNode
|
||||
}
|
||||
|
||||
/** stats-4 pattern: dual metrics + progress + segmented meter (Card surface). */
|
||||
export function SegmentedProgressCard({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
primary,
|
||||
secondary,
|
||||
segments = 30,
|
||||
footer,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
actions?: ReactNode
|
||||
primary: SegmentStat
|
||||
secondary: SegmentStat
|
||||
segments?: number
|
||||
footer?: ReactNode
|
||||
}) {
|
||||
const filled = Math.round((secondary.percent / 100) * segments)
|
||||
|
||||
return (
|
||||
<PanelCard title={title} description={description} actions={actions} className="h-full">
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<div className="flex items-stretch gap-x-6">
|
||||
<div className="flex flex-1 flex-col items-start gap-1">
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<span className="text-foreground text-2xl font-bold tabular-nums">{primary.value}</span>
|
||||
{primary.badge}
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm font-medium">{primary.label}</span>
|
||||
<div className="mt-1 w-full">
|
||||
<Progress value={primary.percent} className="**:data-[slot=progress-track]:h-2.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-muted-foreground/10 flex flex-1 flex-col items-start gap-1 border-s ps-6">
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<span className="text-foreground text-2xl font-bold tabular-nums">{secondary.value}</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm font-medium">{secondary.label}</span>
|
||||
<div className="mt-1 flex w-full gap-0.5">
|
||||
{Array.from({ length: segments }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'h-2.5 w-0.5 flex-1 rounded-md',
|
||||
i < filled ? 'bg-success' : 'bg-muted',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{footer ? <div className="text-muted-foreground text-xs">{footer}</div> : null}
|
||||
</div>
|
||||
</PanelCard>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user