472 lines
17 KiB
TypeScript
472 lines
17 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 { AIInsight, 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';
|
|
|
|
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 } = useFarm();
|
|
const [rows, setRows] = useState<KPI[]>([]);
|
|
const [insights, setInsights] = useState<AIInsight[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [refreshing, setRefreshing] = 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, insightRows] = await Promise.all([
|
|
api.kpis.list({ cycle_id: selectedCycle.id }),
|
|
api.insights.list({ cycle_id: selectedCycle.id }),
|
|
]);
|
|
setRows(kpiRows);
|
|
setInsights(insightRows);
|
|
setLastUpdated(new Date());
|
|
} catch (err) {
|
|
setError(errorMessage(err));
|
|
}
|
|
}, [selectedCycle]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedCycle) {
|
|
setRows([]);
|
|
setInsights([]);
|
|
setLastUpdated(null);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
(async () => {
|
|
setLoading(true);
|
|
try {
|
|
await loadData();
|
|
} finally {
|
|
if (!cancelled) setLoading(false);
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [selectedCycle, loadData]);
|
|
|
|
const handleRefresh = async () => {
|
|
setRefreshing(true);
|
|
try {
|
|
await loadData();
|
|
} finally {
|
|
setRefreshing(false);
|
|
}
|
|
};
|
|
|
|
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 eefInsights = useMemo(() => {
|
|
const matched = insights.filter((item) => {
|
|
const text =
|
|
`${item.section ?? ''} ${item.alert ?? ''} ${item.insight_text ?? ''}`.toLowerCase();
|
|
return (
|
|
text.includes('eef') ||
|
|
text.includes('fcr') ||
|
|
text.includes('hidup') ||
|
|
text.includes('live') ||
|
|
text.includes('panen') ||
|
|
text.includes('bw')
|
|
);
|
|
});
|
|
return (matched.length > 0 ? matched : insights).slice(0, 4);
|
|
}, [insights]);
|
|
|
|
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" />}
|
|
/>
|
|
<button
|
|
onClick={handleRefresh}
|
|
disabled={refreshing || loading}
|
|
className="inline-flex shrink-0 items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-gray-400"
|
|
>
|
|
<i className={`fa-solid fa-rotate-right ${refreshing || loading ? 'fa-spin' : ''}`} />
|
|
<span>{refreshing || loading ? 'Memuat...' : 'Refresh'}</span>
|
|
</button>
|
|
</div>
|
|
|
|
{error && (
|
|
<div>
|
|
<Banner kind="error">{error}</Banner>
|
|
</div>
|
|
)}
|
|
|
|
<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>
|
|
{loading ? (
|
|
<Spinner />
|
|
) : eefInsights.length === 0 ? (
|
|
<Banner>Belum ada insight untuk siklus ini.</Banner>
|
|
) : (
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
{eefInsights.map((item) => (
|
|
<article
|
|
key={item.id}
|
|
className="bg-white rounded-xl shadow-sm p-5 border border-gray-100"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wide text-gray-400">{item.section}</p>
|
|
<h3 className="font-semibold text-gray-800 mt-1">{item.alert || 'Insight'}</h3>
|
|
</div>
|
|
<span className="text-xs text-gray-400">{item.session}</span>
|
|
</div>
|
|
<p className="text-sm text-gray-600 mt-3 whitespace-pre-wrap">
|
|
{item.insight_text}
|
|
</p>
|
|
</article>
|
|
))}
|
|
</div>
|
|
)}
|
|
</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;
|