Files
dashboard/components/IotPanelPage.tsx
T

300 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useAppContext } from '../context/AppContext.tsx';
import { getEstimasiPanenEndMs } from '../utils/dateFilter.ts';
import { baseCard } from './iotPanel/constants.tsx';
import { computeDashboard } from './iotPanel/computeDashboard.ts';
import IotSensorHistoryModal from './iotPanel/IotSensorHistoryModal.tsx';
import { isDeviceHistoryMetric } from './iotPanel/deviceHistoryUtils.ts';
import { loadDeviceHistory24h, loadIotSensorHistory24h } from './iotPanel/iotSensorHistoryApi.ts';
import IotPanelDashboard from './iotPanel/IotPanelDashboard.tsx';
import { PageAiInsight } from './shared/PageAiInsight.tsx';
import {
getTempStandardByDay,
getWaterStandardByDay,
HUMIDITY_RANGE_PCT,
parseReading,
} from '../utils/cp707EnvStandards.ts';
import { apiResponseCounts, latestResultRow, loadDashboardData } from './iotPanel/iotApi.ts';
import { preprocessFlockPayloadForPanel } from './iotPanel/preprocessFlockPayload.ts';
import type {
IotDeviceHistoryPoint,
IotFlocksRaw,
IotHistoryMetric,
IotSensorHistoryPoint,
PayloadData,
} from './iotPanel/types.ts';
const IotPanelPage: React.FC = () => {
const { cycleData } = useAppContext();
const iotMaxAtMs = useMemo(
() => getEstimasiPanenEndMs(cycleData),
[cycleData.endDate, cycleData.startDate, cycleData.totalDays]
);
const [data, setData] = useState<PayloadData | null>(null);
const [error, setError] = useState<string | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [lastFleetRaw, setLastFleetRaw] = useState<IotFlocksRaw | null>(null);
const [lastRefreshTimeLabel, setLastRefreshTimeLabel] = useState('--');
const [apiOnline, setApiOnline] = useState(true);
const [historyOpen, setHistoryOpen] = useState(false);
const [historyMetric, setHistoryMetric] = useState<IotHistoryMetric | null>(null);
const [historyPoints, setHistoryPoints] = useState<IotSensorHistoryPoint[]>([]);
const [deviceHistoryPoints, setDeviceHistoryPoints] = useState<IotDeviceHistoryPoint[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyError, setHistoryError] = useState<string | null>(null);
const refreshDashboardData = useCallback(async () => {
setIsRefreshing(true);
try {
const { raw, data: next } = await loadDashboardData({ maxAtMs: iotMaxAtMs });
setLastFleetRaw(raw);
setLastRefreshTimeLabel(new Date().toLocaleTimeString('id-ID'));
setApiOnline(true);
setData(next);
setError(null);
} catch (err) {
setApiOnline(false);
setError(err instanceof Error ? err.message : 'Gagal memuat data');
} finally {
setIsRefreshing(false);
}
}, [iotMaxAtMs]);
useEffect(() => {
void refreshDashboardData();
}, [refreshDashboardData]);
const dataForCompute = useMemo((): PayloadData | null => {
if (!data) return null;
return preprocessFlockPayloadForPanel(data);
}, [data]);
const computed = useMemo(
() => (dataForCompute ? computeDashboard(dataForCompute) : null),
[dataForCompute]
);
const apiStatusLine = useMemo(() => {
if (!lastFleetRaw) return null;
const row = latestResultRow(lastFleetRaw);
const latestId = row?.id ?? '--';
const counts = apiResponseCounts(lastFleetRaw);
const stateText = apiOnline ? 'Online' : 'Offline';
return `API: ${stateText} | id=${latestId} | counts=${counts} | last refresh=${lastRefreshTimeLabel}`;
}, [lastFleetRaw, apiOnline, lastRefreshTimeLabel]);
const openSensorHistory = useCallback(
async (metric: IotHistoryMetric) => {
setHistoryMetric(metric);
setHistoryOpen(true);
setHistoryPoints([]);
setDeviceHistoryPoints([]);
setHistoryLoading(true);
setHistoryError(null);
try {
const row = latestResultRow(lastFleetRaw);
const flockId = row?.flock_id || row?.flock || null;
if (isDeviceHistoryMetric(metric)) {
setDeviceHistoryPoints(await loadDeviceHistory24h(metric, flockId, iotMaxAtMs));
} else {
setHistoryPoints(await loadIotSensorHistory24h(metric, flockId, iotMaxAtMs));
}
} catch (e) {
setHistoryError(e instanceof Error ? e.message : 'Gagal memuat riwayat');
setHistoryPoints([]);
setDeviceHistoryPoints([]);
} finally {
setHistoryLoading(false);
}
},
[lastFleetRaw, iotMaxAtMs]
);
const closeSensorHistory = useCallback(() => {
setHistoryOpen(false);
setHistoryMetric(null);
}, []);
useEffect(() => {
if (!historyOpen) return;
const onKey = (ev: KeyboardEvent) => {
if (ev.key === 'Escape') closeSensorHistory();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [historyOpen, closeSensorHistory]);
return (
<main className="px-4 pb-8 sm:px-6 lg:px-8">
<div className="mb-6 flex items-center">
<i className="fa-solid fa-table-columns mr-4 text-3xl text-red-600"></i>
<div>
<h1 className="text-3xl font-bold text-gray-800">Panel IoT</h1>
<p className="mt-1 text-gray-600">
Pantau ringkasan kondisi lingkungan, perangkat kipas, dan HVAC kandang secara real-time.
</p>
</div>
</div>
{!computed && !error && <div className={baseCard}>Memuat panel IoT...</div>}
{error && !computed && (
<div className={`${baseCard} text-red-600`}>Dashboard gagal dimuat: {error}</div>
)}
{computed && (
<>
<PageAiInsight
topic="iot_panel"
cacheKey={`iot-${computed.datetime}-${computed.avgTemp}-${computed.humidity}`}
// No per-day history exists for the panel — it is a live snapshot — so
// the chart compares each reading with its CP 707 standard instead of
// drawing a trend.
chartSpec={{
type: 'condition',
title: 'Kondisi kandang vs standar CP 707',
bars: [
{
label: 'Suhu rata-rata',
actual: parseReading(computed.avgTemp),
standard: getTempStandardByDay(computed.dayAgeValue),
unit: '°C',
note:
getTempStandardByDay(computed.dayAgeValue) === null
? 'Umur tidak diketahui, standar suhu tidak dapat ditentukan.'
: undefined,
},
{
label: 'Kelembapan',
actual: parseReading(computed.humidity),
standard: HUMIDITY_RANGE_PCT.max,
unit: '%',
note: `Rentang ideal buku ${HUMIDITY_RANGE_PCT.min}–${HUMIDITY_RANGE_PCT.max}%; batang biru menandai batas atas.`,
},
{
label: 'Experience Suhu',
actual: parseReading(computed.experienceTemp),
standard: getTempStandardByDay(computed.dayAgeValue),
unit: '°C',
note:
getTempStandardByDay(computed.dayAgeValue) === null
? 'Umur tidak diketahui, standar suhu tidak dapat ditentukan.'
: 'Suhu yang dirasakan ayam memperhitungkan kecepatan angin dan kelembapan.',
},
{
label: 'Konsumsi Air',
actual: parseReading(computed.water),
standard: getWaterStandardByDay(computed.dayAgeValue)?.max ?? null,
unit: 'L',
note: (() => {
const std = getWaterStandardByDay(computed.dayAgeValue);
return std
? `Standar CP 707 hari ke-${computed.dayAgeValue}: ${std.min}–${std.max} L / 1.000 ekor / hari (suhu 21°C).`
: 'Standar tidak tersedia untuk umur ini.';
})(),
},
],
}}
contextData={{
waktu_data: computed.datetime,
hari_ke: computed.dayAgeValue,
kandang: computed.coopName,
// Numbers, not the formatted display strings ("24,2 °C"): the
// unit already lives in the field name, so passing the string
// rendered "24,2 °C °C" in the summary and handed the model a
// comma-decimal value it had to parse for itself.
suhu_rata_rata_C: parseReading(computed.avgTemp),
experience_suhu_C: parseReading(computed.experienceTemp),
kelembapan_persen: parseReading(computed.humidity),
kecepatan_angin_mPerDetik: parseReading(computed.wind),
konsumsi_air_L: parseReading(computed.water),
lux: parseReading(computed.lux),
suhu1_C: parseReading(computed.temp1),
suhu2_C: parseReading(computed.temp2),
suhu3_C: parseReading(computed.temp3),
alarm_suhu_tinggi: computed.avgTempAlarmHighlight,
alarm_kelembapan_tinggi: computed.humidityAlarmHighlight,
jumlah_fan_aktif: computed.experienceActiveFanCount,
inverter: computed.inverterLabel,
}}
/>
<IotPanelDashboard
computed={computed}
apiStatusLine={apiStatusLine}
apiOnline={apiOnline}
error={error}
isRefreshing={isRefreshing}
onRefresh={refreshDashboardData}
onSensorHistoryClick={openSensorHistory}
sensorHistoryLoading={historyLoading}
/>
</>
)}
<IotSensorHistoryModal
open={historyOpen}
metric={historyMetric}
onClose={closeSensorHistory}
points={historyPoints}
deviceHistoryPoints={deviceHistoryPoints}
loading={historyLoading}
error={historyError}
/>
</main>
);
};
export default IotPanelPage;