167 lines
5.5 KiB
TypeScript
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>
|
|
);
|
|
}
|