Files

130 lines
4.1 KiB
TypeScript

import React, { useMemo } from 'react';
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis } from 'recharts';
import { formatNumber, formatRatio } from '../utils/format.ts';
import type { View } from '../types/common.ts';
export type KpiStatus = 'ok' | 'warning' | 'critical' | 'na';
export type KpiTrendPoint = {
value: number;
/** Tooltip label, e.g. ``Hari 0 · 2 Sep 2026``. */
label: string;
};
export type KpiCardModel = {
key: string;
title: string;
value: number | null;
unit: string;
icon: string;
trend?: KpiTrendPoint[];
status: KpiStatus;
targetView?: View;
};
const statusStyles: Record<KpiStatus, { bg: string; text: string; icon: string; line: string }> = {
ok: { bg: 'bg-green-100', text: 'text-green-800', icon: 'text-green-600', line: '#16a34a' },
warning: {
bg: 'bg-yellow-100',
text: 'text-yellow-800',
icon: 'text-yellow-600',
line: '#f59e0b',
},
critical: { bg: 'bg-red-100', text: 'text-red-800', icon: 'text-red-600', line: '#dc2626' },
na: { bg: 'bg-blue-50', text: 'text-blue-900', icon: 'text-blue-600', line: '#3b82f6' },
};
type Props = Omit<KpiCardModel, 'key' | 'targetView'> & {
onClick?: () => void;
isLoading?: boolean;
};
const KpiCard: React.FC<Props> = ({
title,
value,
unit,
icon,
trend,
status,
onClick,
isLoading = false,
}) => {
const isNA = value === null;
const styles = isNA ? statusStyles.na : statusStyles[status];
const trendData = useMemo(
() => (trend ?? []).map((point) => ({ name: point.label, value: point.value })),
[trend]
);
const displayValue = () => {
if (value === null) return 'N/A';
if (title.toUpperCase().includes('FCR') || title.toUpperCase().includes('EEF')) {
return formatRatio(value, 3);
}
if (unit === 'gram' && value > 999) return formatNumber(value / 1000, 2);
return formatNumber(value, 2);
};
const displayUnit = () => {
if (value !== null && unit === 'gram' && value > 999) return 'kg';
return unit;
};
return (
<div
className={`p-4 sm:p-5 rounded-xl shadow-sm transition-all duration-300 hover:shadow-lg ${styles.bg} ${
onClick ? 'cursor-pointer hover:scale-105' : ''
} flex flex-col h-full`}
onClick={onClick}
>
<div className="flex justify-between items-start mb-2">
<div className="flex flex-col flex-1 min-w-0">
<div className={`text-2xl sm:text-3xl font-bold ${styles.text} truncate`}>
{isLoading ? (
<span className="text-base sm:text-xl">
<i className="fa-solid fa-spinner fa-spin mr-2" />
Memuat...
</span>
) : (
<>
{displayValue()}{' '}
{!isNA && <span className="text-lg sm:text-xl font-medium">{displayUnit()}</span>}
</>
)}
</div>
<p className={`text-xs sm:text-sm font-semibold mt-1 ${styles.text}`}>{title}</p>
</div>
<div className={`text-2xl sm:text-3xl ${styles.icon} flex-shrink-0 ml-2`}>{icon}</div>
</div>
<div className="h-12 sm:h-14 lg:h-16 mt-auto">
{!isNA && trendData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={trendData} margin={{ top: 2, right: 2, bottom: 2, left: 2 }}>
<XAxis dataKey="name" hide />
<Line
type="monotone"
dataKey="value"
stroke={styles.line}
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
<Tooltip
formatter={(val) => [formatNumber(Number(val ?? 0), 2), title]}
labelFormatter={(label) => String(label ?? '')}
contentStyle={{ fontSize: 12, borderRadius: 8 }}
/>
</LineChart>
</ResponsiveContainer>
) : (
<div className="flex items-center justify-center h-full text-xs text-gray-400">
{isNA ? 'Data tidak tersedia' : ''}
</div>
)}
</div>
</div>
);
};
export default KpiCard;