130 lines
4.1 KiB
TypeScript
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;
|