Files
dashboard-cpsp-executive/components/IotPanelPage.tsx
T

604 lines
23 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 } 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);
setSelectedDate((previous) =>
previous && sortedDates.includes(previous)
? previous
: (sortedDates[sortedDates.length - 1] ?? '')
);
} 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,
experience_suhu_min_C: summary?.minExp ?? null,
experience_suhu_max_C: summary?.maxExp ?? null,
});
}, [selectedKandang, selectedCycle, dayAge, summary]);
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>}
{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="text-lg font-bold text-gray-800 mb-3">
<i className="fa-solid fa-brain text-red-600 mr-2" />
AI Insight Panel IoT
</h2>
<Banner>Pilih kandang untuk melihat AI Insight.</Banner>
</section>
)}
{loading && dayRows.length === 0 ? (
<Spinner />
) : summary ? (
<>
<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={dates[0] ?? undefined}
max={dates[dates.length - 1] ?? undefined}
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>
<span className="text-xs">{summary.count} pembacaan</span>
</div>
<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">
Belum ada data panel IoT untuk flock ini.
</p>
</div>
)}
{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;