import React, { useEffect, useMemo, useState } from 'react'; import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis, } from 'recharts'; import { api, errorMessage } from '../services/apiClient.ts'; import type { Flock, PanelIoT } from '../types/api.ts'; import { useFarm } from '../context/FarmContext.tsx'; import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx'; import { cycleDayForDate, formatDateId, formatDateRange, formatNumber, resolveCycleBoundDate, todayIso } from '../utils/format.ts'; import { getTempStandardByDay, getWaterStandardByDay, HUMIDITY_RANGE_PCT, } from '../utils/cp707EnvStandards.ts'; import { buildIotInsightContext } from '../utils/insightContextBuilders.ts'; import { computeExperienceCalc } from './iotPanel/chillFactor.ts'; import IotPanelChillFactorCard from './iotPanel/IotPanelChillFactorCard.tsx'; import IotPanelExperienceTempCard from './iotPanel/IotPanelExperienceTempCard.tsx'; import { PageAiInsight } from './shared/PageAiInsight.tsx'; const cardClass = 'rounded-xl border border-slate-200 bg-white p-4 shadow-sm'; const SECONDARY_CHARTS = [ { key: 'humidity', label: 'Kelembapan', unit: ' %', color: '#3b82f6' }, { key: 'wind', label: 'Angin', unit: ' m/s', color: '#6366f1' }, { key: 'water', label: 'Air', unit: ' L', color: '#06b6d4' }, ] as const; const display = (value: number | null | undefined, suffix = '') => value == null || !Number.isFinite(Number(value)) ? 'N/A' : `${formatNumber(Number(value))}${suffix}`; const formatTime = (iso: string | null | undefined): string => { if (!iso) return '--'; const date = new Date(iso); if (Number.isNaN(date.getTime())) return iso; return date.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' }); }; type DaySummary = { count: number; temp: number; minTemp: number; maxTemp: number; experienceTemp: number; minExp: number; maxExp: number; humidity: number; minHumidity: number; maxHumidity: number; windSpeed: number; minWind: number; maxWind: number; waterTotal: number; }; const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { const { selectedCycle, selectedKandang, selectedSite } = useFarm(); const [flocks, setFlocks] = useState([]); const [selectedFlockId, setSelectedFlockId] = useState(null); const [dates, setDates] = useState([]); const [selectedDate, setSelectedDate] = useState(''); const [dayRows, setDayRows] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [selectedChartKey, setSelectedChartKey] = useState<'humidity' | 'wind' | 'water' | null>( null ); useEffect(() => { if (!selectedChartKey) return; const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') setSelectedChartKey(null); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [selectedChartKey]); useEffect(() => { if (!selectedKandang) { setFlocks([]); setSelectedFlockId(null); setDates([]); setSelectedDate(''); setDayRows([]); return; } let cancelled = false; (async () => { setError(null); try { const data = await api.flocks.list({ kandang_id: selectedKandang.id }); if (cancelled) return; setFlocks(data); setSelectedFlockId((previous) => previous && data.some((flock) => flock.id === previous) ? previous : (data[0]?.id ?? null) ); } catch (err) { if (!cancelled) setError(errorMessage(err)); } })(); return () => { cancelled = true; }; }, [selectedKandang]); useEffect(() => { if (!selectedCycle || selectedFlockId == null) { setDates([]); setSelectedDate(''); setDayRows([]); return; } let cancelled = false; (async () => { setLoading(true); setError(null); try { const dateList = await api.panelIot.dates({ cycle_id: selectedCycle.id, flock_id: selectedFlockId, }); if (cancelled) return; const sortedDates = [...dateList].sort(); setDates(sortedDates); const cycleMin = selectedCycle.start_date; const cycleMax = resolveCycleBoundDate(selectedCycle); const fallbackDate = todayIso() > cycleMax ? cycleMax : todayIso() < cycleMin ? cycleMin : todayIso(); setSelectedDate((previous) => { if (sortedDates.length > 0) { return previous && sortedDates.includes(previous) ? previous : (sortedDates[sortedDates.length - 1] ?? fallbackDate); } if (previous && previous >= cycleMin && previous <= cycleMax) return previous; return fallbackDate; }); } catch (err) { if (!cancelled) setError(errorMessage(err)); } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; }, [selectedCycle, selectedFlockId]); useEffect(() => { if (!selectedCycle || selectedFlockId == null || !selectedDate) { setDayRows([]); return; } let cancelled = false; (async () => { setLoading(true); setError(null); try { const data = await api.panelIot.list({ cycle_id: selectedCycle.id, flock_id: selectedFlockId, date: selectedDate, }); if (!cancelled) { const sorted = [...data].sort((a, b) => (a.timestamp ?? a.date).localeCompare(b.timestamp ?? b.date) ); setDayRows(sorted); } } catch (err) { if (!cancelled) setError(errorMessage(err)); } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; }, [selectedCycle, selectedFlockId, selectedDate]); const summary = useMemo(() => { if (dayRows.length === 0) return null; const sorted = [...dayRows].sort((a, b) => (a.timestamp ?? a.date).localeCompare(b.timestamp ?? b.date) ); const min = (key: keyof PanelIoT) => Math.min(...sorted.map((row) => Number(row[key]))); const max = (key: keyof PanelIoT) => Math.max(...sorted.map((row) => Number(row[key]))); const last = sorted[sorted.length - 1]!; return { count: sorted.length, temp: last.average_temperature, minTemp: min('average_temperature'), maxTemp: max('average_temperature'), experienceTemp: last.experience_temperature, minExp: min('experience_temperature'), maxExp: max('experience_temperature'), humidity: last.humidity, minHumidity: min('humidity'), maxHumidity: max('humidity'), windSpeed: last.wind_speed, minWind: min('wind_speed'), maxWind: max('wind_speed'), waterTotal: last.water_total, }; }, [dayRows]); const chartRows = useMemo( () => [...dayRows] .sort((a, b) => (a.timestamp ?? a.date).localeCompare(b.timestamp ?? b.date)) .map((row) => ({ time: formatTime(row.timestamp), average: row.average_temperature, experience: row.experience_temperature, humidity: row.humidity, wind: row.wind_speed, water: row.water_total, })), [dayRows] ); const dayAge = selectedDate ? cycleDayForDate(selectedCycle?.start_date ?? '', selectedDate) : null; const experienceCalc = useMemo( () => computeExperienceCalc({ averageTemp: summary?.temp, windSpeed: summary?.windSpeed, humidity: summary?.humidity, dayAge, }), [summary, dayAge] ); const insightContext = useMemo(() => { if (!selectedKandang) return null; return buildIotInsightContext({ kandangId: selectedKandang.id, kandangName: selectedKandang.kandang_name, cycleId: selectedCycle?.id, hari_ke: dayAge, totalDays: selectedCycle?.total_days ?? null, suhu_rata_rata_C: summary?.temp ?? null, experience_suhu_C: summary?.experienceTemp ?? null, kelembapan_persen: summary?.humidity ?? null, kecepatan_angin_mPerDetik: summary?.windSpeed ?? null, konsumsi_air_L: summary?.waterTotal ?? null, setpoint_C: getTempStandardByDay(dayAge), suhu_min_C: summary?.minTemp ?? null, suhu_max_C: summary?.maxTemp ?? null, experience_suhu_min_C: summary?.minExp ?? null, experience_suhu_max_C: summary?.maxExp ?? null, kelembapan_min_persen: summary?.minHumidity ?? null, kelembapan_max_persen: summary?.maxHumidity ?? null, kecepatan_angin_min_mPerDetik: summary?.minWind ?? null, kecepatan_angin_max_mPerDetik: summary?.maxWind ?? null, waktu_data: selectedDate || null, }); }, [selectedKandang, selectedCycle, summary, dayAge, selectedDate]); const datePickerMin = selectedCycle?.start_date ?? undefined; // IoT panel is real-time — not subject to the publish cutoff that // resolveCycleBoundDate() applies via soft_end_date → visible_through_date(). // Closed cycles use end_date; open cycles go up to today. const datePickerMax = selectedCycle?.end_date ?? todayIso(); if (!selectedCycle) return ; return (
} actions={ } /> {error && {error}} {selectedFlockId == null ? (

Pilih lantai untuk melihat data panel IoT.

) : (
setSelectedDate(event.target.value)} className="rounded-lg border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-700 focus:ring-2 focus:ring-red-500 focus:border-red-500" />
)} {selectedKandang && insightContext ? ( ) : (

AI Insight Panel IoT

Pilih kandang untuk melihat dan generate AI Insight.
)} {selectedFlockId != null && (loading && dayRows.length === 0 ? ( ) : summary ? ( <>

Suhu Rata-Rata

{display(summary.temp, ' °C')}

{display(summary.minTemp, ' °C')} – {display(summary.maxTemp, ' °C')}

Experience Suhu

{display(summary.experienceTemp, ' °C')}

{display(summary.minExp, ' °C')} – {display(summary.maxExp, ' °C')}

{[ { label: 'Kelembapan', value: display(summary.humidity, ' %'), icon: 'fa-droplet', color: 'text-blue-500', chartKey: 'humidity', }, { label: 'Angin', value: display(summary.windSpeed, ' m/s'), icon: 'fa-wind', color: 'text-indigo-500', chartKey: 'wind', }, { label: 'Air total', value: display(summary.waterTotal, ' L'), icon: 'fa-water', color: 'text-cyan-500', chartKey: 'water', }, { label: 'Umur', value: dayAge == null ? 'N/A' : `${dayAge} hari`, icon: 'fa-dove', color: 'text-amber-600', }, { label: 'Tanggal', value: formatDateId(selectedDate), icon: 'fa-calendar-day', color: 'text-slate-500', }, { label: 'Lantai', value: flocks.find((flock) => flock.id === selectedFlockId)?.flock_name ?? 'N/A', icon: 'fa-warehouse', color: 'text-slate-500', }, ].map((tile) => { const inner = ( <>

{tile.label}

{tile.value}

); if (!tile.chartKey) { return (
{inner}
); } return ( ); })}

Histori Suhu Harian

Garis merah = suhu rata-rata Garis biru = experience suhu

{formatDateId(selectedDate)}
[ `${formatNumber(Number(value))} °C`, name as string, ]} />
) : (

{dates.length === 0 ? 'Belum ada data panel IoT untuk lantai ini. Refresh halaman setelah data tersedia.' : `Belum ada data panel IoT untuk ${selectedDate ? formatDateId(selectedDate) : 'tanggal ini'}.`}

))} {selectedFlockId != null && dayRows.length > 0 && (

Data Histori Panel IoT · {formatDateId(selectedDate)}

{dayRows.map((row) => ( ))}
Waktu Suhu Rata-rata Experience Suhu Kelembapan Angin Air Total
{formatTime(row.timestamp)} {formatNumber(row.average_temperature)} °C {formatNumber(row.experience_temperature)} °C {formatNumber(row.humidity)} % {formatNumber(row.wind_speed)} m/s {formatNumber(row.water_total)} L
)} {selectedChartKey && (() => { const chart = SECONDARY_CHARTS.find((item) => item.key === selectedChartKey)!; const latestPoint = chartRows[chartRows.length - 1]; return (
setSelectedChartKey(null)} >
event.stopPropagation()} >

{chart.label}

Pembacaan sepanjang hari · {formatDateId(selectedDate)}

Terakhir: {display(latestPoint?.[chart.key], chart.unit)}
[ `${formatNumber(Number(value))}${chart.unit}`, name as string, ]} />
); })()}
); }; export default IotPanelPage;