ifixkart-admin/components/ui/StatsSparklineCard.tsx

167 lines
5.5 KiB
TypeScript

'use client';
import { useId } from 'react';
import { ArrowDownRight, ArrowUpRight, type LucideIcon } from 'lucide-react';
export type StatsSparklineTone = 'green' | 'violet' | 'orange' | 'blue';
const TONES: Record<
StatsSparklineTone,
{ stroke: string; iconBox: string; value: string }
> = {
green: {
stroke: 'var(--success)',
iconBox: 'bg-success/10 border-success/25 text-success',
value: 'text-success',
},
violet: {
stroke: 'var(--info)',
iconBox: 'bg-info/10 border-info/25 text-info',
value: 'text-info',
},
orange: {
stroke: 'var(--warning)',
iconBox: 'bg-warning/10 border-warning/30 text-warning',
value: 'text-warning',
},
blue: {
stroke: '#3b82f6',
iconBox: 'bg-[#3b82f6]/10 border-[#3b82f6]/25 text-[#3b82f6]',
value: 'text-[#3b82f6]',
},
};
export function datesToSparkline(
dates: Array<string | Date | null | undefined>,
days = 7
): number[] {
const buckets = Array.from({ length: days }, () => 0);
const today = new Date();
today.setHours(0, 0, 0, 0);
for (const raw of dates) {
if (!raw) continue;
const d = new Date(raw);
if (Number.isNaN(d.getTime())) continue;
d.setHours(0, 0, 0, 0);
const diff = Math.round((today.getTime() - d.getTime()) / 86400000);
if (diff >= 0 && diff < days) buckets[days - 1 - diff] += 1;
}
return buckets;
}
export function weekOverWeekChange(dates: Array<string | Date | null | undefined>): number {
const now = Date.now();
const week = 7 * 86400000;
let thisWeek = 0;
let lastWeek = 0;
for (const raw of dates) {
if (!raw) continue;
const t = new Date(raw).getTime();
if (Number.isNaN(t)) continue;
const age = now - t;
if (age >= 0 && age < week) thisWeek += 1;
else if (age >= week && age < week * 2) lastWeek += 1;
}
if (lastWeek === 0) return thisWeek === 0 ? 0 : 100;
return ((thisWeek - lastWeek) / lastWeek) * 100;
}
function MiniSparkline({ data, color }: { data: number[]; color: string }) {
const gradientId = useId();
const width = 118;
const height = 52;
const padX = 2;
const padY = 6;
const values = data.length > 1 ? data : [0, 0];
const max = Math.max(...values, 1);
const points = values.map((value, index) => {
const x = padX + (index / Math.max(values.length - 1, 1)) * (width - padX * 2);
const y = height - padY - (value / max) * (height - padY * 2);
return { x, y };
});
const line = points.map((point, index) => `${index === 0 ? 'M' : 'L'}${point.x},${point.y}`).join(' ');
const area = `${line} L${points[points.length - 1].x},${height - 2} L${points[0].x},${height - 2} Z`;
return (
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className="overflow-visible shrink-0">
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity="0.32" />
<stop offset="100%" stopColor={color} stopOpacity="0.02" />
</linearGradient>
</defs>
<line
x1={padX}
x2={width - padX}
y1={height - 4}
y2={height - 4}
stroke="currentColor"
strokeDasharray="3 4"
className="text-border"
strokeWidth="1"
/>
<path d={area} fill={`url(#${gradientId})`} />
<path d={line} fill="none" stroke={color} strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
interface StatsSparklineCardProps {
title: string;
value: string | number;
icon: LucideIcon;
tone: StatsSparklineTone;
series: number[];
deltaPercent?: number;
deltaLabel?: string;
active?: boolean;
onClick?: () => void;
}
export function StatsSparklineCard({
title,
value,
icon: Icon,
tone,
series,
deltaPercent = 0,
deltaLabel = 'vs Last Week',
active = false,
onClick,
}: StatsSparklineCardProps) {
const theme = TONES[tone];
const positive = deltaPercent >= 0;
const TrendIcon = positive ? ArrowUpRight : ArrowDownRight;
const deltaText = `${positive ? '+' : ''}${deltaPercent.toFixed(1)}%`;
return (
<button
type="button"
onClick={onClick}
className={`bg-card border text-left cursor-pointer transition-colors crm-radius-section p-4 shadow-[0_1px_3px_rgba(16,24,40,0.06)] min-w-0 ${
active ? 'border-[#b8c0d4]' : 'border-border'
}`}
>
<div className="flex items-center gap-3 min-w-0">
<div className={`w-10 h-10 crm-radius-section border flex items-center justify-center shrink-0 ${theme.iconBox}`}>
<Icon className="w-[18px] h-[18px]" strokeWidth={1.75} />
</div>
<p className="text-[14px] font-medium text-foreground truncate">{title}</p>
</div>
<div className="mt-4 border border-border crm-radius-section px-3.5 py-3 flex items-end justify-between gap-2 min-w-0">
<div className="min-w-0">
<p className={`text-[26px] font-bold tracking-tight leading-none ${theme.value}`}>{value}</p>
<p className="text-[12px] mt-2 leading-none truncate">
<span className={`font-semibold ${theme.value}`}>{deltaText}</span>
<span className="text-muted-foreground"> {deltaLabel}</span>
</p>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<TrendIcon className={`w-4 h-4 mb-3 ${theme.value}`} strokeWidth={2.25} />
<MiniSparkline data={series} color={theme.stroke} />
</div>
</div>
</button>
);
}