300 lines
9.9 KiB
TypeScript
300 lines
9.9 KiB
TypeScript
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;
|