import React, { useEffect, useMemo, useState } from 'react'; import { api, ApiError, errorMessage } from '../services/apiClient.ts'; import type { ChickenWeight, LatestAvg } from '../types/api.ts'; import { useFarm } from '../context/FarmContext.tsx'; import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx'; import { formatDateId, formatDateRange, formatNumber, resolveCycleStatus, } from '../utils/format.ts'; import { formatWeight } from '../utils/weight.ts'; import { buildWeightInsightContext } from '../utils/insightContextBuilders.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'; import { PageAiInsight } from './shared/PageAiInsight.tsx'; const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { const { selectedCycle, selectedSite, selectedKandang } = useFarm(); const [rows, setRows] = useState([]); const [latest, setLatest] = useState(null); const [loading, setLoading] = useState(false); const [syncing, setSyncing] = useState(false); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [selectedDate, setSelectedDate] = useState(''); const isClosedCycle = !selectedCycle || resolveCycleStatus(selectedCycle) !== 'active'; const reload = async (cycleId: number) => { setError(null); try { const list = await api.weights.list({ cycle_id: cycleId }); setRows(list); 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); setSelectedDate(''); return; } let cancelled = false; (async () => { setLoading(true); 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 handleSync = async () => { if (!selectedCycle || isClosedCycle) return; setSyncing(true); setError(null); setNotice(null); try { await api.weights.sync({ cycle_id: selectedCycle.id }); setNotice('Sinkron berat edge berhasil.'); await reload(selectedCycle.id); } catch (err) { setError(errorMessage(err)); } finally { setSyncing(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 ?? '') : ''; // 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]); const insightContext = useMemo(() => { if (!selectedKandang) return null; return buildWeightInsightContext({ kandangId: selectedKandang.id, kandangName: selectedKandang.kandang_name, cycleId: selectedCycle?.id, totalDays: selectedCycle?.total_days ?? null, docWeight: docWeight, weightRows: chartRows, averageWeight: kpiLatest?.average_weight ?? null, uniformity: kpiLatest?.uniformity ?? null, adg: kpiLatest?.average_daily_gain ?? null, }); }, [selectedKandang, selectedCycle, docWeight, chartRows, kpiLatest]); if (!selectedCycle) return ; return (
} subtitle={`Analisis pertumbuhan bobot ayam secara real-time · ${selectedSite?.site_name ?? ''} · ${selectedKandang?.kandang_name ?? ''} · Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)}`} actions={ } /> {error && (
{error}
)} {notice && (
{notice}
)} {loading && rows.length === 0 ? ( ) : (
{selectedKandang && insightContext ? ( ) : (

AI Insight Berat Ayam

Pilih kandang untuk melihat dan generate AI Insight.
)} {/* 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 "Sync" untuk menarik prediksi berat dari edge vision
  • • Hari 0 memakai berat DOC dan uniformity 0% (bukan data edge)
  • • Target berat menggunakan standar performa berdasarkan DOC
)}
); }; export default WeightMonitoringPage;