veza/apps/web/src/components/dashboard/StatCard.tsx
senke 7c69474cf9 aesthetic-improvements: align spacing to 8px grid (Action 11.2.1.3)
- Created automated script (scripts/align-8px-grid.py) to align all spacing to 8px grid
- Replaced non-8px-aligned spacing: gap-3/p-3/m-3 (12px) → gap-4/p-4/m-4 (16px), gap-5/p-5/m-5 (20px) → gap-6/p-6/m-6 (24px), gap-10/p-10/m-10 (40px) → gap-12/p-12/m-12 (48px), gap-20/p-20/m-20 (80px) → gap-24/p-24/m-24 (96px)
- Preserved: 4px values (gap-1, p-1, m-1) as they may be intentional fine-tuning, responsive breakpoints (sm:, md:, lg:), test files, documentation
- Modified files across all components to ensure consistent 8px grid alignment
- Action 11.2.1.3: Align all elements to 8px grid - COMPLETE
2026-01-16 11:50:46 +01:00

120 lines
3.2 KiB
TypeScript

import React from 'react';
import { Card } from '../ui/card';
import { ArrowUp, ArrowDown } from 'lucide-react';
interface StatCardProps {
label: string;
value: string | number;
icon: React.ReactNode;
trend?: string | number; // String like "+12%" or raw number
color?: 'cyan' | 'magenta' | 'lime' | 'gold' | 'red';
sparklineData?: number[];
}
export const StatCard: React.FC<StatCardProps> = ({
label,
value,
icon,
trend,
color = 'cyan',
sparklineData,
}) => {
const colorMap = {
cyan: 'text-kodo-cyan',
magenta: 'text-kodo-magenta',
lime: 'text-kodo-lime',
gold: 'text-kodo-gold',
red: 'text-kodo-red',
};
const bgMap = {
cyan: 'bg-kodo-steel/10',
magenta: 'bg-kodo-magenta/10',
lime: 'bg-kodo-lime/10',
gold: 'bg-kodo-gold/10',
red: 'bg-kodo-red/10',
};
const renderSparkline = (data: number[]) => {
if (!data || data.length < 2) return null;
const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
const width = 100;
const height = 40;
const points = data
.map((d, i) => {
const x = (i / (data.length - 1)) * width;
const y = height - ((d - min) / range) * height;
return `${x},${y}`;
})
.join(' ');
return (
<svg
width="100%"
height={height}
viewBox={`0 0 ${width} ${height}`}
className="opacity-50 overflow-visible"
>
<polyline
points={points}
fill="none"
stroke="currentColor"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
/>
</svg>
);
};
const isPositive =
typeof trend === 'string' ? !trend.startsWith('-') : (trend || 0) >= 0;
const trendValue = typeof trend === 'number' ? `${Math.abs(trend)}%` : trend;
return (
<Card
variant="default"
className="flex flex-col justify-between h-full p-6 hover:border-opacity-100 transition-all relative overflow-hidden"
>
<div className="flex justify-between items-start mb-2 relative z-10">
<div>
<p className="text-xs font-mono text-kodo-content-dim uppercase tracking-widest mb-1">
{label}
</p>
<h3 className="text-2xl font-display font-bold text-white">
{value}
</h3>
</div>
<div className={`p-2 rounded-lg ${bgMap[color]} ${colorMap[color]}`}>
{icon}
</div>
</div>
<div className="relative z-10 flex items-end justify-between mt-2">
{trend && (
<div
className={`flex items-center gap-1 text-xs font-bold ${isPositive ? 'text-kodo-lime' : 'text-kodo-red'}`}
>
{isPositive ? (
<ArrowUp className="w-3 h-3" />
) : (
<ArrowDown className="w-3 h-3" />
)}
{trendValue}{' '}
<span className="text-kodo-content-dim font-normal">vs last period</span>
</div>
)}
</div>
{sparklineData && (
<div
className={`absolute bottom-0 left-0 right-0 h-12 ${colorMap[color]} opacity-20 pointer-events-none`}
>
{renderSparkline(sparklineData)}
</div>
)}
</Card>
);
};