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:
Denozordec
2026-05-20 14:52:44 +07:00
co-authored by Cursor
parent e06ee880b2
commit e68f034966
8 changed files with 162 additions and 306 deletions
@@ -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>