import React, { useCallback, 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, updateCycle } = 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 = useCallback( async (cycleId: number, isBackground = false) => { if (!isBackground) { setLoading(true); } setError(null); try { try { await api.weights.sync({ cycle_id: cycleId }); } catch { // Best-effort edge sync: don't fail page load if edge vision API is temporarily unreachable } const [list, freshCycle] = await Promise.all([ api.weights.list({ cycle_id: cycleId }), api.cycles?.retrieve?.(cycleId)?.catch(() => null) ?? Promise.resolve(null), ]); if (freshCycle) { updateCycle(cycleId, freshCycle); } 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) { if (!isBackground) { setError(errorMessage(err)); } } finally { if (!isBackground) { setLoading(false); } } }, [updateCycle] ); useEffect(() => { if (!selectedCycle) { setRows([]); setLatest(null); setSelectedDate(''); return; } const cycleId = selectedCycle.id; void reload(cycleId, false); const interval = setInterval(() => { if (document.visibilityState === 'visible') { void reload(cycleId, true); } }, 60000); const handleFocusOrVisibility = () => { if (document.visibilityState === 'visible') { void reload(cycleId, true); } }; document.addEventListener('visibilitychange', handleFocusOrVisibility); window.addEventListener('focus', handleFocusOrVisibility); return () => { clearInterval(interval); document.removeEventListener('visibilitychange', handleFocusOrVisibility); window.removeEventListener('focus', handleFocusOrVisibility); }; }, [selectedCycle?.id, reload]); // 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, chicken_area: fallback.chicken_area, 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', 'Luas Ayam (IOT)', ].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)} {row.chicken_area && row.chicken_area > 0 ? `${formatNumber(row.chicken_area, 2, 0, true)} cm²` : '-'}
{/* 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;