Files
dashboard-cpsp/components/IotPanelPage.tsx
T

659 lines
25 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, { 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;
windSpeed: number;
waterTotal: number;
};
const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
const { selectedCycle, selectedKandang, selectedSite } = useFarm();
const [flocks, setFlocks] = useState<Flock[]>([]);
const [selectedFlockId, setSelectedFlockId] = useState<number | null>(null);
const [dates, setDates] = useState<string[]>([]);
const [selectedDate, setSelectedDate] = useState('');
const [dayRows, setDayRows] = useState<PanelIoT[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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<DaySummary | null>(() => {
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,
windSpeed: last.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,
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 <EmptyFarm onSettings={onSettings} />;
return (
<main className="mx-auto max-w-[1240px] space-y-4">
<PageHeader
title="Panel IoT"
subtitle={`${selectedSite?.site_name ?? ''} · ${selectedKandang?.kandang_name ?? ''} · Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)}`}
icon={<i className="fa-solid fa-table-columns" />}
actions={
<select
value={selectedFlockId ?? ''}
onChange={(event) =>
setSelectedFlockId(event.target.value ? Number(event.target.value) : null)
}
className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700"
aria-label="Pilih flock"
>
{flocks.length === 0 && <option value="">Tidak ada flock</option>}
{flocks.map((flock) => (
<option key={flock.id} value={flock.id}>
{flock.flock_name}
</option>
))}
</select>
}
/>
{error && <Banner kind="error">{error}</Banner>}
{selectedFlockId == null ? (
<div className={cardClass}>
<p className="py-8 text-center text-slate-400">Pilih flock untuk melihat data panel IoT.</p>
</div>
) : (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<span className="inline-flex items-center gap-2">
<i className="fa-regular fa-calendar" />
<label htmlFor="iot-date-picker" className="text-slate-500">
Tanggal:
</label>
<input
id="iot-date-picker"
type="date"
value={selectedDate}
min={datePickerMin}
max={datePickerMax}
onChange={(event) => 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"
/>
</span>
</div>
)}
{selectedKandang && insightContext ? (
<PageAiInsight
topic="iot_panel"
cacheKey={`iot-k${selectedKandang.id}-c${selectedCycle.id}-d${selectedDate}-t${summary?.temp ?? 'na'}`}
contextData={insightContext}
chartSpec={{
type: 'condition',
title: 'Kondisi kandang vs standar CP 707',
bars: [
{
label: 'Suhu rata-rata',
actual: summary?.temp ?? null,
standard: getTempStandardByDay(dayAge),
unit: '°C',
note:
getTempStandardByDay(dayAge) === null
? 'Umur tidak diketahui, standar suhu tidak dapat ditentukan.'
: undefined,
},
{
label: 'Kelembapan',
actual: summary?.humidity ?? null,
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: summary?.experienceTemp ?? null,
standard: getTempStandardByDay(dayAge),
unit: '°C',
},
{
label: 'Konsumsi Air',
actual: summary?.waterTotal ?? null,
standard: getWaterStandardByDay(dayAge)?.max ?? null,
unit: 'L',
},
],
}}
/>
) : (
<section>
<h2 className="mb-3 text-lg font-bold text-gray-800">
<i className="fa-solid fa-brain mr-2 text-red-600" />
AI Insight Panel IoT
</h2>
<Banner>Pilih kandang untuk melihat dan generate AI Insight.</Banner>
</section>
)}
{selectedFlockId != null &&
(loading && dayRows.length === 0 ? (
<Spinner />
) : summary ? (
<>
<section className="grid gap-3 xl:grid-cols-[1.25fr_2.35fr]">
<div className="space-y-3">
<article className={cardClass}>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-lg bg-red-50/70 p-4">
<h2 className="text-sm font-semibold text-slate-700">
<i className="fa-solid fa-temperature-half mr-2 text-red-500" />
Suhu Rata-Rata
</h2>
<p className="mt-2 text-3xl font-bold text-slate-700">
{display(summary.temp, ' °C')}
</p>
<p className="mt-1 text-xs text-slate-500">
{display(summary.minTemp, ' °C')} – {display(summary.maxTemp, ' °C')}
</p>
</div>
<div className="rounded-lg bg-sky-50/80 p-4">
<h2 className="text-sm font-semibold text-slate-700">
<i className="fa-solid fa-temperature-half mr-2 text-sky-600" />
Experience Suhu
</h2>
<p className="mt-2 text-3xl font-bold text-sky-600">
{display(summary.experienceTemp, ' °C')}
</p>
<p className="mt-1 text-xs text-slate-500">
{display(summary.minExp, ' °C')} – {display(summary.maxExp, ' °C')}
</p>
</div>
</div>
</article>
<article className={cardClass}>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{[
{
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: 'Flock',
value:
flocks.find((flock) => flock.id === selectedFlockId)?.flock_name ?? 'N/A',
icon: 'fa-warehouse',
color: 'text-slate-500',
},
].map((tile) => {
const inner = (
<>
<p className="text-sm text-slate-500">
<i className={`fa-solid ${tile.icon} mr-2 ${tile.color}`} />
{tile.label}
</p>
<p className="mt-2 text-xl font-semibold text-slate-700">{tile.value}</p>
</>
);
if (!tile.chartKey) {
return (
<div
key={tile.label}
className="min-h-[104px] rounded-xl border border-slate-200 bg-white p-3"
>
{inner}
</div>
);
}
return (
<button
key={tile.label}
type="button"
onClick={() =>
setSelectedChartKey(tile.chartKey as 'humidity' | 'wind' | 'water')
}
className="min-h-[104px] cursor-pointer rounded-xl border border-slate-200 bg-white p-3 text-left transition hover:bg-slate-50"
aria-label={`Lihat grafik ${tile.label}`}
>
{inner}
</button>
);
})}
</div>
</article>
<IotPanelExperienceTempCard
calc={experienceCalc}
apiExperienceTemp={summary.experienceTemp}
/>
</div>
<div className="space-y-3">
<article className={`${cardClass} min-h-[390px]`}>
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<h2 className="font-semibold text-slate-800">Histori Suhu Harian</h2>
<p className="mt-1 text-xs text-slate-500">
<span className="inline-flex items-center gap-1 mr-3">
<span className="inline-block h-2 w-2 rounded-full bg-red-500" />
Garis merah = suhu rata-rata
</span>
<span className="inline-flex items-center gap-1">
<span className="inline-block h-2 w-2 rounded-full bg-sky-600" />
Garis biru = experience suhu
</span>
</p>
</div>
<span className="rounded-full bg-sky-50 px-3 py-1 text-xs font-semibold text-sky-700">
{formatDateId(selectedDate)}
</span>
</div>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={chartRows}
margin={{ top: 8, right: 12, left: -18, bottom: 4 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} stroke="#64748b" />
<YAxis tick={{ fontSize: 11 }} stroke="#64748b" />
<Tooltip
formatter={(value, name) => [
`${formatNumber(Number(value))} °C`,
name as string,
]}
/>
<Line
type="monotone"
dataKey="average"
name="Suhu rata-rata"
stroke="#ef4444"
strokeWidth={2}
dot={false}
/>
<Line
type="monotone"
dataKey="experience"
name="Experience suhu"
stroke="#0284c7"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</article>
<IotPanelChillFactorCard dayAge={dayAge} chillFactor={experienceCalc.chillFactor} />
</div>
</section>
</>
) : (
<div className={cardClass}>
<p className="py-8 text-center text-slate-400">
{dates.length === 0
? 'Belum ada data panel IoT untuk flock ini. Tanggal tetap bisa dipilih dalam rentang siklus; generate ulang setelah data tersedia.'
: `Belum ada data panel IoT untuk ${selectedDate ? formatDateId(selectedDate) : 'tanggal ini'}.`}
</p>
</div>
))}
{selectedFlockId != null && dayRows.length > 0 && (
<article className={cardClass}>
<h2 className="mb-3 font-semibold text-slate-800">
<i className="fa-solid fa-table mr-2 text-slate-400" />
Data Histori Panel IoT · {formatDateId(selectedDate)}
</h2>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="px-4 py-3 text-left font-semibold">Waktu</th>
<th className="px-4 py-3 text-right font-semibold">Suhu Rata-rata</th>
<th className="px-4 py-3 text-right font-semibold">Experience Suhu</th>
<th className="px-4 py-3 text-right font-semibold">Kelembapan</th>
<th className="px-4 py-3 text-right font-semibold">Angin</th>
<th className="px-4 py-3 text-right font-semibold">Air Total</th>
</tr>
</thead>
<tbody>
{dayRows.map((row) => (
<tr key={row.id} className="border-t border-slate-100">
<td className="px-4 py-2">{formatTime(row.timestamp)}</td>
<td className="px-4 py-2 text-right">{formatNumber(row.average_temperature)} °C</td>
<td className="px-4 py-2 text-right">{formatNumber(row.experience_temperature)} °C</td>
<td className="px-4 py-2 text-right">{formatNumber(row.humidity)} %</td>
<td className="px-4 py-2 text-right">{formatNumber(row.wind_speed)} m/s</td>
<td className="px-4 py-2 text-right">{formatNumber(row.water_total)} L</td>
</tr>
))}
</tbody>
</table>
</div>
</article>
)}
{selectedChartKey &&
(() => {
const chart = SECONDARY_CHARTS.find((item) => item.key === selectedChartKey)!;
const latestPoint = chartRows[chartRows.length - 1];
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={() => setSelectedChartKey(null)}
>
<div
className="w-full max-w-2xl rounded-xl bg-white p-5 shadow-xl"
onClick={(event) => event.stopPropagation()}
>
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<h3 className="text-lg font-semibold text-slate-800">{chart.label}</h3>
<p className="text-xs text-slate-500">
Pembacaan sepanjang hari · {formatDateId(selectedDate)}
</p>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-slate-50 px-2 py-1 text-xs font-semibold text-slate-600">
Terakhir: {display(latestPoint?.[chart.key], chart.unit)}
</span>
<button
type="button"
onClick={() => setSelectedChartKey(null)}
className="rounded-lg border border-slate-200 p-2 text-slate-600 hover:bg-slate-50"
aria-label="Tutup grafik"
>
<i className="fa-solid fa-xmark" />
</button>
</div>
</div>
<div className="h-[320px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartRows} margin={{ top: 4, right: 8, left: -14, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} stroke="#64748b" />
<YAxis tick={{ fontSize: 11 }} stroke="#64748b" width={38} />
<Tooltip
formatter={(value, name) => [
`${formatNumber(Number(value))}${chart.unit}`,
name as string,
]}
/>
<Line
type="monotone"
dataKey={chart.key}
name={chart.label}
stroke={chart.color}
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
})()}
</main>
);
};
export default IotPanelPage;