Files
dashboard-cpsp-executive/components/EefDashboardPage.tsx
T

472 lines
16 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { api, errorMessage } from '../services/apiClient.ts';
import type { KPI } from '../types/api.ts';
import { useFarm } from '../context/FarmContext.tsx';
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
import { formatNumber, formatRatio } from '../utils/format.ts';
import { buildEefInsightContext } from '../utils/insightContextBuilders.ts';
import { PageAiInsight } from './shared/PageAiInsight.tsx';
type EefRow = {
day: number;
chickenLifePercentage: number | null;
averageHarvestBw: number | null;
averageHarvestDay: number | null;
fcr: number | null;
eef: number | null;
};
type TrendPoint = { day: number; eef: number | null };
type SupportPoint = {
day: number;
chickenLifePercentage: number | null;
averageHarvestBw: number | null;
averageHarvestDay: number | null;
fcr: number | null;
};
const formatThreeDecimal = (value: number | null): string =>
value == null ? 'N/A' : formatRatio(value, 3);
const formatNum = (value: number | null | undefined, maxDecimals = 0): string =>
value == null || !Number.isFinite(Number(value))
? 'N/A'
: formatNumber(Number(value), maxDecimals, 0, true);
const StatCard: React.FC<{ title: string; value: string; unit: string }> = ({
title,
value,
unit,
}) => (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<p className="text-sm font-semibold text-gray-500">{title}</p>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-2xl font-bold text-gray-800">{value}</span>
<span className="text-sm font-semibold text-gray-500">{unit}</span>
</div>
</div>
);
const EefTrendChart: React.FC<{ data: TrendPoint[] }> = ({ data }) => (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
<h2 className="text-xl font-bold text-gray-800 mb-4">Grafik EEF</h2>
<div className="h-80">
{data.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500">
Tidak ada data EEF tersedia.
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="day" fontSize={12} tick={{ fill: '#6b7280' }} />
<YAxis
fontSize={12}
tick={{ fill: '#6b7280' }}
tickFormatter={(value) => formatNum(Number(value), 0)}
width={60}
domain={[0, 'dataMax + 20']}
label={{ value: 'EEF', angle: -90, position: 'insideLeft' }}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: '1px solid #d1d5db',
borderRadius: '8px',
}}
formatter={(value) => [formatThreeDecimal(Number(value)), 'EEF']}
labelFormatter={(label) => `Hari ${label}`}
/>
<Line
type="monotone"
dataKey="eef"
name="EEF"
stroke="#dc2626"
strokeWidth={2.5}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
connectNulls
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
</div>
);
const SupportingMetricChart: React.FC<{
title: string;
data: SupportPoint[];
dataKey: keyof Omit<SupportPoint, 'day'>;
unit: string;
stroke: string;
maximumFractionDigits?: number;
valueFormatter?: (value: number) => string;
}> = ({ title, data, dataKey, unit, stroke, maximumFractionDigits = 0, valueFormatter }) => (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<h3 className="text-lg font-bold text-gray-800 mb-4">{title}</h3>
<div className="h-64">
{data.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500">
Tidak ada data tersedia.
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="day" fontSize={12} tick={{ fill: '#6b7280' }} />
<YAxis
fontSize={12}
tick={{ fill: '#6b7280' }}
tickFormatter={(value) =>
valueFormatter
? valueFormatter(Number(value))
: formatNum(Number(value), maximumFractionDigits)
}
width={70}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: '1px solid #d1d5db',
borderRadius: '8px',
}}
formatter={(value) => {
const formatted = valueFormatter
? valueFormatter(Number(value))
: formatNum(Number(value), maximumFractionDigits);
return [`${formatted}${unit ? ` ${unit}` : ''}`, title];
}}
labelFormatter={(label) => `Hari ${label}`}
/>
<Line
type="monotone"
dataKey={dataKey}
name={title}
stroke={stroke}
strokeWidth={2.5}
dot={{ r: 3 }}
activeDot={{ r: 5 }}
connectNulls
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
</div>
);
const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
const { selectedCycle, selectedKandang } = useFarm();
const [rows, setRows] = useState<KPI[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const loadData = useCallback(async () => {
if (!selectedCycle) return;
setError(null);
try {
const kpiRows = await api.kpis.list({ cycle_id: selectedCycle.id });
setRows(kpiRows);
setLastUpdated(new Date());
} catch (err) {
setError(errorMessage(err));
}
}, [selectedCycle]);
useEffect(() => {
if (!selectedCycle) {
setRows([]);
setLastUpdated(null);
return;
}
let cancelled = false;
(async () => {
setLoading(true);
try {
await loadData();
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [selectedCycle, loadData]);
const eefRows = useMemo<EefRow[]>(
() =>
[...rows]
.sort((a, b) => a.age - b.age || a.date.localeCompare(b.date))
.map((row) => ({
day: row.age,
chickenLifePercentage: row.chicken_life_percentage,
averageHarvestBw: row.average_harvest_weight > 0 ? row.average_harvest_weight : null,
averageHarvestDay: row.average_harvest_day > 0 ? row.average_harvest_day : null,
fcr: row.fcr > 0 ? row.fcr : null,
eef: row.eef > 0 ? row.eef : null,
})),
[rows]
);
const trendData = useMemo<TrendPoint[]>(
() => eefRows.map((row) => ({ day: row.day, eef: row.eef })),
[eefRows]
);
const supportData = useMemo<SupportPoint[]>(
() =>
eefRows.map((row) => ({
day: row.day,
chickenLifePercentage: row.chickenLifePercentage,
averageHarvestBw: row.averageHarvestBw,
averageHarvestDay: row.averageHarvestDay,
fcr: row.fcr,
})),
[eefRows]
);
const getLatestValid = <K extends keyof EefRow>(key: K): EefRow[K] | null => {
for (let i = eefRows.length - 1; i >= 0; i--) {
const val = eefRows[i]?.[key];
if (val !== null && val !== undefined && val !== 0 && String(val) !== '') {
return val;
}
}
return null;
};
const latestEef = getLatestValid('eef');
const insightContext = useMemo(() => {
if (!selectedKandang) return null;
return buildEefInsightContext({
kandangId: selectedKandang.id,
kandangName: selectedKandang.kandang_name,
cycleId: selectedCycle?.id,
totalDays: selectedCycle?.total_days ?? null,
eefRows,
});
}, [selectedKandang, selectedCycle, eefRows]);
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
return (
<main className="mx-auto max-w-[1240px] space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<PageHeader
title="EEF"
subtitle="Dashboard European Efficiency Factor berdasarkan data performa produksi IoT."
icon={<i className="fa-solid fa-gauge-high" />}
/>
</div>
{error && (
<div>
<Banner kind="error">{error}</Banner>
</div>
)}
{selectedKandang && insightContext ? (
<PageAiInsight
topic="eef"
cacheKey={`eef-k${selectedKandang.id}-c${selectedCycle.id}-rows${eefRows.length}-eef${String(latestEef ?? 'null').replace('.', '_')}`}
contextData={insightContext}
chartSpec={{
type: 'trend',
historyKey: 'tren_eef_harian',
title: 'Tren EEF',
series: [{ field: 'eef', label: 'EEF', color: '#dc2626' }],
}}
scalarScope={{
historyKey: 'tren_eef_harian',
derive: {
eefTerakhir_indeksTanpaSatuan: {
field: 'eef',
agg: 'lastValid',
replaces: 'eef_terakhir',
},
fcrTerakhir_rasio: {
field: 'fcr',
agg: 'lastValid',
replaces: 'fcr_terakhir',
},
persenHidupTerakhir_persen: {
field: 'persen_hidup',
agg: 'lastValid',
replaces: 'persen_hidup',
},
bobotPanenTerakhir_gram: {
field: 'bw_panen',
agg: 'lastValid',
replaces: 'bw_panen_gram',
},
umurPanenTerakhir_hari: {
field: 'umur_panen_hari',
agg: 'lastValid',
replaces: 'umur_panen_rata_hari',
},
},
drop: [
'eef_terakhir',
'fcr_terakhir',
'persen_hidup',
'bw_panen_gram',
'umur_panen_rata_hari',
],
}}
/>
) : (
<section>
<h2 className="text-lg font-bold text-gray-800 mb-3">
<i className="fa-solid fa-brain text-red-600 mr-2" />
AI Insight EEF
</h2>
<Banner>Pilih kandang untuk melihat AI Insight.</Banner>
</section>
)}
{loading ? (
<Spinner />
) : (
<div className="space-y-8">
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-6">
<StatCard title="EEF" value={formatThreeDecimal(latestEef)} unit="" />
<StatCard
title="Persentase Ayam Hidup"
value={formatNum(getLatestValid('chickenLifePercentage'), 3)}
unit="%"
/>
<StatCard
title="BW Rata-rata Panen"
value={formatNum(getLatestValid('averageHarvestBw'), 2)}
unit="g"
/>
<StatCard
title="Umur Rata-rata Panen"
value={formatNum(getLatestValid('averageHarvestDay'), 2)}
unit="hari"
/>
<StatCard title="FCR" value={formatThreeDecimal(getLatestValid('fcr'))} unit="" />
</div>
<EefTrendChart data={trendData} />
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-6 py-4 border-b border-gray-100">
<h2 className="text-xl font-bold text-gray-800">Data Pendukung EEF</h2>
{lastUpdated && (
<p className="text-sm text-gray-500">
Update:{' '}
{lastUpdated.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' })}
</p>
)}
</div>
<div className="p-6">
<h2 className="text-xl font-bold text-gray-800 mb-4">Grafik Data Pendukung</h2>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<SupportingMetricChart
title="Persentase Ayam Hidup"
data={supportData}
dataKey="chickenLifePercentage"
unit="%"
stroke="#2563eb"
maximumFractionDigits={3}
/>
<SupportingMetricChart
title="BW Rata-rata Panen"
data={supportData}
dataKey="averageHarvestBw"
unit="g"
stroke="#16a34a"
maximumFractionDigits={2}
/>
<SupportingMetricChart
title="Umur Rata-rata Panen"
data={supportData}
dataKey="averageHarvestDay"
unit=""
stroke="#9333ea"
maximumFractionDigits={2}
/>
<SupportingMetricChart
title="FCR"
data={supportData}
dataKey="fcr"
unit=""
stroke="#dc2626"
valueFormatter={(value) => formatThreeDecimal(value)}
/>
</div>
</div>
<div className="border-t border-gray-100">
<div className="px-6 py-4">
<h2 className="text-xl font-bold text-gray-800">Tabel Data Pendukung EEF</h2>
</div>
<div className="overflow-auto max-h-[420px] border-t border-gray-100">
<table className="min-w-full text-sm">
<thead className="bg-gray-700 text-white sticky top-0 z-10">
<tr>
<th className="p-3 text-left font-semibold">Umur</th>
<th className="p-3 text-right font-semibold">Persentase Ayam Hidup</th>
<th className="p-3 text-right font-semibold">BW Rata-rata Panen</th>
<th className="p-3 text-right font-semibold">Umur Rata-rata Panen</th>
<th className="p-3 text-right font-semibold">FCR</th>
<th className="p-3 text-right font-semibold">EEF</th>
</tr>
</thead>
<tbody>
{eefRows.map((row) => (
<tr key={row.day} className="even:bg-gray-50 hover:bg-red-50">
<td className="p-3 font-semibold text-gray-800">Hari {row.day}</td>
<td className="p-3 text-right text-gray-700">
{formatNum(row.chickenLifePercentage, 3)}
{row.chickenLifePercentage != null ? '%' : ''}
</td>
<td className="p-3 text-right text-gray-700">
{formatNum(row.averageHarvestBw, 2)}
{row.averageHarvestBw != null ? ' g' : ''}
</td>
<td className="p-3 text-right text-gray-700">
{formatNum(row.averageHarvestDay, 2)}
</td>
<td className="p-3 text-right font-mono text-gray-700">
{formatThreeDecimal(row.fcr)}
</td>
<td className="p-3 text-right font-mono text-gray-700">
{formatThreeDecimal(row.eef)}
</td>
</tr>
))}
{eefRows.length === 0 && (
<tr>
<td colSpan={6} className="p-8 text-center text-gray-500">
Tidak ada data EEF tersedia.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
</main>
);
};
export default EefDashboardPage;