import React, { useEffect, useMemo, useState } from 'react'; import { api, ApiError, errorMessage } from '../services/apiClient.ts'; import type { AIInsight, ChickenWeight, LatestAvg } from '../types/api.ts'; import { useFarm } from '../context/FarmContext.tsx'; import { Banner, EmptyFarm, Spinner } from './ui/Feedback.tsx'; import { formatDateId, formatNumber } from '../utils/format.ts'; import { formatWeight } from '../utils/weight.ts'; import WeightStats from './weight/WeightStats.tsx'; import DailyWeightProgressionChart from './weight/DailyWeightProgressionChart.tsx'; import DailyWeightGainChart from './weight/DailyWeightGainChart.tsx'; import DailyDetailPanel from './weight/DailyDetailPanel.tsx'; const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { const { selectedCycle } = useFarm(); const [rows, setRows] = useState([]); const [latest, setLatest] = useState(null); const [insights, setInsights] = useState([]); const [loading, setLoading] = useState(false); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); const [selectedDate, setSelectedDate] = useState(''); const reload = async (cycleId: number) => { setError(null); try { const [list, insightRows] = await Promise.all([ api.weights.list({ cycle_id: cycleId }), api.insights.list({ cycle_id: cycleId }), ]); setRows(list); setInsights(insightRows); try { setLatest(await api.weights.latestAvg({ cycle_id: cycleId })); } catch (err) { if (err instanceof ApiError && err.status === 404) setLatest(null); else throw err; } } catch (err) { setError(errorMessage(err)); } }; useEffect(() => { if (!selectedCycle) { setRows([]); setLatest(null); setInsights([]); setSelectedDate(''); return; } let cancelled = false; (async () => { setLoading(true); setRefreshing(false); try { await reload(selectedCycle.id); } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedCycle]); // Days without a weighing are stored as 0 g. Charts skip those days so the // line does not drop to zero at the end of a cycle, mirroring the old // dashboard which only plotted days with actual timbangan data. const chartRows = useMemo(() => rows.filter((row) => row.average_weight > 0), [rows]); // Default the date picker to the newest row with a real weighing once data // loads, so the detail panel opens with meaningful values. useEffect(() => { if (!selectedDate) { const newestMeaningful = chartRows[0] ?? rows[0]; if (newestMeaningful) setSelectedDate(newestMeaningful.date); } }, [rows, chartRows, selectedDate]); const handleRefresh = async () => { if (!selectedCycle) return; setRefreshing(true); try { await reload(selectedCycle.id); } catch { // error state is set inside reload } finally { setRefreshing(false); } }; const docWeight = selectedCycle?.chick_in_weight ?? 35; const selectedRow = useMemo( () => rows.find((row) => row.date === selectedDate) ?? null, [rows, selectedDate] ); const minDate = rows.length > 0 ? (rows[rows.length - 1]?.date ?? '') : ''; const maxDate = rows.length > 0 ? (rows[0]?.date ?? '') : ''; const weightInsights = useMemo(() => { const matched = insights.filter((item) => { const section = `${item.section ?? ''} ${item.alert ?? ''}`.toLowerCase(); return section.includes('berat') || section.includes('weight') || section.includes('timbang'); }); return (matched.length > 0 ? matched : insights).slice(0, 4); }, [insights]); // Prefer the latest non-zero row for the KPI card so a closed cycle with // trailing empty days does not read as "0 g". const kpiLatest = useMemo(() => { if (!latest) return null; if (latest.average_weight > 0) return latest; const fallback = rows.find((row) => row.average_weight > 0); if (!fallback) return null; return { average_weight: fallback.average_weight, date: fallback.date, age: fallback.age, uniformity: fallback.uniformity, average_daily_gain: fallback.average_daily_gain, cycle: fallback.cycle, kandang: fallback.kandang, }; }, [latest, rows]); if (!selectedCycle) return ; return (
{/* Header */}

Dashboard Berat Ayam

Analisis pertumbuhan dan kesejahteraan bobot ayam secara real-time.

{!loading && latest?.date && (

Terakhir diperbarui: {formatDateId(latest.date)}

)}
{error && (
{error}
)} {loading && rows.length === 0 ? ( ) : (
{/* AI Insight */}

AI Insight

{weightInsights.length === 0 ? ( Belum ada insight untuk siklus ini. ) : (
{weightInsights.map((item) => (

{item.section}

{item.alert || 'Insight'}

{item.session}

{item.insight_text}

))}
)}
{/* TOP SECTION: DATA SELURUH SIKLUS */}
{/* ===== SEPARATOR: DATA PER HARI ===== */}

Data Per Hari

Pilih tanggal untuk melihat detail data harian

setSelectedDate(e.target.value)} className="px-2 py-1 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-red-500 focus:border-red-500" />
{/* Riwayat lengkap */}

Riwayat Berat Harian

{['Tanggal', 'Umur', 'DOC', 'AVG', 'Uniformity', 'ADG'].map((h) => ( ))} {rows.length === 0 ? ( ) : ( rows.map((row) => ( )) )}
{h}
Belum ada data berat untuk siklus ini.
{formatDateId(row.date)} {row.age} {formatWeight(row.doc_weight, { decimals: 0 })} {formatWeight(row.average_weight, { stripTrailingZeros: true })} {formatNumber(row.uniformity, 2, 2, true)}% {formatNumber(row.average_daily_gain, 2, 2, true)}
{/* Info Box */}

Informasi Pembaruan Data

  • • Klik tombol "Refresh" untuk memperbarui data secara manual
  • • Grafik menampilkan data dari awal siklus hingga hari ini
  • • Target berat menggunakan standar performa Ross 308
)}
); }; export default WeightMonitoringPage;