refactor(web): extract shared kpi metrics grid pattern
WEB-18: KpiMetricsGrid в ui/patterns; рефакторинг overview, network, modules, directories, operations, schedule, monitoring. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from 'svelte';
|
||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
|
||||
export type KpiAccent = {
|
||||
border: string;
|
||||
bg: string;
|
||||
iconBg: string;
|
||||
iconText: string;
|
||||
};
|
||||
|
||||
export type KpiCardItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
description: string;
|
||||
icon: Component;
|
||||
accent: KpiAccent;
|
||||
badge: string;
|
||||
badgeClass?: string;
|
||||
badgeVariant?: 'default' | 'secondary' | 'destructive' | 'outline';
|
||||
valueClass?: string;
|
||||
error?: string | null;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
cards: KpiCardItem[];
|
||||
loading?: boolean;
|
||||
skeletonCount?: number;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
let { cards, loading = false, skeletonCount = 3, class: className }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={cn('grid gap-4', className)}>
|
||||
{#if loading}
|
||||
{#each Array(skeletonCount) as _, i (i)}
|
||||
<CardSkeleton />
|
||||
{/each}
|
||||
{:else}
|
||||
{#each cards as card (card.id)}
|
||||
{@const Icon = card.icon}
|
||||
{@const a = card.accent}
|
||||
<Card
|
||||
class={cn(
|
||||
'overflow-hidden border-l-4 shadow-sm',
|
||||
card.href ? 'transition-colors hover:border-primary/35' : '',
|
||||
a.border,
|
||||
a.bg
|
||||
)}
|
||||
>
|
||||
<CardHeader class="pb-2">
|
||||
{#if card.href}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<CardDescription class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
class={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-lg',
|
||||
a.iconBg
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon class={cn('size-4', a.iconText)} />
|
||||
</span>
|
||||
<span class="truncate">{card.label}</span>
|
||||
</CardDescription>
|
||||
<Button variant="ghost" size="icon-sm" href={card.href}>
|
||||
<ArrowRight class="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<CardDescription class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
class={cn('flex size-9 shrink-0 items-center justify-center rounded-lg', a.iconBg)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon class={cn('size-4', a.iconText)} />
|
||||
</span>
|
||||
<span class="truncate">{card.label}</span>
|
||||
</CardDescription>
|
||||
{/if}
|
||||
<CardTitle class={cn('font-bold tabular-nums', card.valueClass ?? 'text-3xl')}
|
||||
>{card.value}</CardTitle
|
||||
>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<Badge variant={card.badgeVariant ?? 'outline'} class={card.badgeClass}
|
||||
>{card.badge}</Badge
|
||||
>
|
||||
{#if card.error}
|
||||
<p class="text-xs text-destructive">{card.error}</p>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">{card.description}</p>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -26,6 +26,7 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||
import { Skeleton } from '$lib/ui/core/skeleton/index.js';
|
||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import { notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import OverviewRecentJobsCard from '$lib/components/overview/OverviewRecentJobsCard.svelte';
|
||||
@@ -38,7 +39,6 @@
|
||||
import Radio from '@lucide/svelte/icons/radio';
|
||||
import Activity from '@lucide/svelte/icons/activity';
|
||||
import Clock from '@lucide/svelte/icons/clock';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
|
||||
import Info from '@lucide/svelte/icons/info';
|
||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||
@@ -251,50 +251,12 @@
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#if initialLoading}
|
||||
{#each Array(5) as _, i (i)}
|
||||
<CardSkeleton />
|
||||
{/each}
|
||||
{:else}
|
||||
{#each kpiCards as card (card.id)}
|
||||
{@const Icon = card.icon}
|
||||
{@const a = card.accent}
|
||||
<Card
|
||||
class={cn(
|
||||
'overflow-hidden border-l-4 shadow-sm transition-colors hover:border-primary/35',
|
||||
a.border,
|
||||
a.bg
|
||||
)}
|
||||
>
|
||||
<CardHeader class="pb-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<CardDescription class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
class={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-lg',
|
||||
a.iconBg
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon class={cn('size-4', a.iconText)} />
|
||||
</span>
|
||||
<span class="truncate">{card.label}</span>
|
||||
</CardDescription>
|
||||
<Button variant="ghost" size="icon-sm" href={resolve(card.href)}>
|
||||
<ArrowRight class="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<CardTitle class="text-3xl font-bold tabular-nums">{card.value}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<Badge variant="outline">{card.badge}</Badge>
|
||||
<p class="text-xs text-muted-foreground">{card.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<KpiMetricsGrid
|
||||
cards={kpiCards}
|
||||
loading={initialLoading}
|
||||
skeletonCount={5}
|
||||
class="sm:grid-cols-2 lg:grid-cols-3"
|
||||
/>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<OverviewRecentJobsCard
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
|
||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import { notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import DirectoriesCommunitiesCard from '$lib/components/directories/DirectoriesCommunitiesCard.svelte';
|
||||
@@ -169,53 +170,12 @@
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
{#if initialLoading}
|
||||
{#each Array(3) as _, i (i)}
|
||||
<CardSkeleton />
|
||||
{/each}
|
||||
{:else}
|
||||
{#each kpiCards as card (card.id)}
|
||||
{@const Icon = card.icon}
|
||||
{@const a = card.accent}
|
||||
<Card
|
||||
class={cn(
|
||||
'overflow-hidden border-l-4 shadow-sm transition-colors',
|
||||
a.border,
|
||||
a.bg,
|
||||
card.href ? 'hover:border-primary/35' : ''
|
||||
)}
|
||||
>
|
||||
<CardHeader class="pb-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<CardDescription class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
class={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-lg',
|
||||
a.iconBg
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon class={cn('size-4', a.iconText)} />
|
||||
</span>
|
||||
<span class="truncate">{card.label}</span>
|
||||
</CardDescription>
|
||||
{#if card.href}
|
||||
<Button variant="ghost" size="icon-sm" href={resolve(card.href)}>
|
||||
<ArrowRight class="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<CardTitle class="text-3xl font-bold tabular-nums">{card.value}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<Badge variant="outline">{card.badge}</Badge>
|
||||
<p class="text-xs text-muted-foreground">{card.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<KpiMetricsGrid
|
||||
cards={kpiCards}
|
||||
loading={initialLoading}
|
||||
skeletonCount={3}
|
||||
class="sm:grid-cols-3"
|
||||
/>
|
||||
|
||||
<Tabs value="communities">
|
||||
<TabsList>
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
|
||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||
@@ -217,36 +218,12 @@
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
{#if initialLoading}
|
||||
{#each Array(3) as _, i (i)}
|
||||
<CardSkeleton />
|
||||
{/each}
|
||||
{:else}
|
||||
{#each kpiCards as card (card.id)}
|
||||
{@const Icon = card.icon}
|
||||
{@const a = card.accent}
|
||||
<Card class={cn('overflow-hidden border-l-4 shadow-sm', a.border, a.bg)}>
|
||||
<CardHeader class="pb-2">
|
||||
<CardDescription class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
class={cn('flex size-9 shrink-0 items-center justify-center rounded-lg', a.iconBg)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon class={cn('size-4', a.iconText)} />
|
||||
</span>
|
||||
<span class="truncate">{card.label}</span>
|
||||
</CardDescription>
|
||||
<CardTitle class="text-3xl font-bold tabular-nums">{card.value}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<Badge variant="outline">{card.badge}</Badge>
|
||||
<p class="text-xs text-muted-foreground">{card.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<KpiMetricsGrid
|
||||
cards={kpiCards}
|
||||
loading={initialLoading}
|
||||
skeletonCount={3}
|
||||
class="sm:grid-cols-3"
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="flex flex-wrap items-center justify-between gap-2 border-b py-3">
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
TableRow
|
||||
} from '$lib/ui/core/table/index.js';
|
||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
|
||||
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import { cn } from '$lib/utils.js';
|
||||
@@ -223,6 +224,7 @@
|
||||
badge: version ? 'Загружена' : '—',
|
||||
badgeVariant: version ? ('outline' as const) : ('secondary' as const),
|
||||
badgeClass: undefined,
|
||||
valueClass: 'font-mono text-xl',
|
||||
error: versionError,
|
||||
href: undefined
|
||||
}
|
||||
@@ -342,66 +344,12 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{#if initialLoading}
|
||||
{#each Array(4) as _, i (i)}
|
||||
<CardSkeleton />
|
||||
{/each}
|
||||
{:else}
|
||||
{#each kpiCards as card (card.id)}
|
||||
{@const Icon = card.icon}
|
||||
{@const a = card.accent}
|
||||
<Card
|
||||
class={cn(
|
||||
'overflow-hidden border-l-4 shadow-sm transition-colors',
|
||||
a.border,
|
||||
a.bg,
|
||||
card.href ? 'hover:border-primary/35' : ''
|
||||
)}
|
||||
>
|
||||
<CardHeader class="pb-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<CardDescription class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
class={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-lg',
|
||||
a.iconBg
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon class={cn('size-4', a.iconText)} />
|
||||
</span>
|
||||
<span class="truncate">{card.label}</span>
|
||||
</CardDescription>
|
||||
{#if card.href}
|
||||
<Button variant="ghost" size="icon-sm" href={resolve(card.href)}>
|
||||
<ArrowRight class="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<CardTitle
|
||||
class={cn(
|
||||
'font-bold tabular-nums',
|
||||
card.id === 'version' ? 'font-mono text-xl' : 'text-3xl'
|
||||
)}
|
||||
>
|
||||
{card.value}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Badge variant={card.badgeVariant} class={card.badgeClass}>{card.badge}</Badge>
|
||||
</div>
|
||||
{#if card.error}
|
||||
<p class="text-xs text-destructive">{card.error}</p>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">{card.description}</p>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<KpiMetricsGrid
|
||||
cards={kpiCards}
|
||||
loading={initialLoading}
|
||||
skeletonCount={4}
|
||||
class="sm:grid-cols-2 xl:grid-cols-4"
|
||||
/>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
{#if initialLoading}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
|
||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import { notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import NetworkPeersCard from '$lib/components/network/NetworkPeersCard.svelte';
|
||||
@@ -179,36 +180,12 @@
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
{#if initialLoading}
|
||||
{#each Array(3) as _, i (i)}
|
||||
<CardSkeleton />
|
||||
{/each}
|
||||
{:else}
|
||||
{#each kpiCards as card (card.id)}
|
||||
{@const Icon = card.icon}
|
||||
{@const a = card.accent}
|
||||
<Card class={cn('overflow-hidden border-l-4 shadow-sm', a.border, a.bg)}>
|
||||
<CardHeader class="pb-2">
|
||||
<CardDescription class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
class={cn('flex size-9 shrink-0 items-center justify-center rounded-lg', a.iconBg)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon class={cn('size-4', a.iconText)} />
|
||||
</span>
|
||||
<span class="truncate">{card.label}</span>
|
||||
</CardDescription>
|
||||
<CardTitle class="text-3xl font-bold tabular-nums">{card.value}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<Badge variant="outline" class={card.badgeClass}>{card.badge}</Badge>
|
||||
<p class="text-xs text-muted-foreground">{card.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<KpiMetricsGrid
|
||||
cards={kpiCards}
|
||||
loading={initialLoading}
|
||||
skeletonCount={3}
|
||||
class="sm:grid-cols-3"
|
||||
/>
|
||||
|
||||
<Tabs value="peers">
|
||||
<TabsList>
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
} from '$lib/components/operations/types.js';
|
||||
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
|
||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
|
||||
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||
@@ -891,53 +892,12 @@
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
{#if initialLoading}
|
||||
{#each Array(3) as _, i (i)}
|
||||
<CardSkeleton />
|
||||
{/each}
|
||||
{:else}
|
||||
{#each kpiCards as card (card.id)}
|
||||
{@const Icon = card.icon}
|
||||
{@const a = card.accent}
|
||||
<Card
|
||||
class={cn(
|
||||
'overflow-hidden border-l-4 shadow-sm transition-colors',
|
||||
a.border,
|
||||
a.bg,
|
||||
card.href ? 'hover:border-primary/35' : ''
|
||||
)}
|
||||
>
|
||||
<CardHeader class="pb-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<CardDescription class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
class={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-lg',
|
||||
a.iconBg
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon class={cn('size-4', a.iconText)} />
|
||||
</span>
|
||||
<span class="truncate">{card.label}</span>
|
||||
</CardDescription>
|
||||
{#if card.href}
|
||||
<Button variant="ghost" size="icon-sm" href={resolve(card.href)}>
|
||||
<ArrowRight class="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<CardTitle class="text-3xl font-bold tabular-nums">{card.value}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<Badge variant="outline" class={card.badgeClass}>{card.badge}</Badge>
|
||||
<p class="text-xs text-muted-foreground">{card.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<KpiMetricsGrid
|
||||
cards={kpiCards}
|
||||
loading={initialLoading}
|
||||
skeletonCount={3}
|
||||
class="sm:grid-cols-3"
|
||||
/>
|
||||
|
||||
<OperationsQuickActions
|
||||
{applying}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
|
||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
@@ -234,53 +235,12 @@
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
{#if initialLoading}
|
||||
{#each Array(3) as _, i (i)}
|
||||
<CardSkeleton />
|
||||
{/each}
|
||||
{:else}
|
||||
{#each kpiCards as card (card.id)}
|
||||
{@const Icon = card.icon}
|
||||
{@const a = card.accent}
|
||||
<Card
|
||||
class={cn(
|
||||
'overflow-hidden border-l-4 shadow-sm transition-colors',
|
||||
a.border,
|
||||
a.bg,
|
||||
card.href ? 'hover:border-primary/35' : ''
|
||||
)}
|
||||
>
|
||||
<CardHeader class="pb-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<CardDescription class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
class={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-lg',
|
||||
a.iconBg
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon class={cn('size-4', a.iconText)} />
|
||||
</span>
|
||||
<span class="truncate">{card.label}</span>
|
||||
</CardDescription>
|
||||
{#if card.href}
|
||||
<Button variant="ghost" size="icon-sm" href={resolve(card.href)}>
|
||||
<ArrowRight class="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<CardTitle class="text-3xl font-bold tabular-nums">{card.value}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<Badge variant="outline" class={card.badgeClass}>{card.badge}</Badge>
|
||||
<p class="text-xs text-muted-foreground">{card.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<KpiMetricsGrid
|
||||
cards={kpiCards}
|
||||
loading={initialLoading}
|
||||
skeletonCount={3}
|
||||
class="sm:grid-cols-3"
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
Reference in New Issue
Block a user