596 lines
23 KiB
TypeScript
596 lines
23 KiB
TypeScript
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 { AIInsight, 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 { computeExperienceCalc } from './iotPanel/chillFactor.ts';
|
||
import IotPanelChillFactorCard from './iotPanel/IotPanelChillFactorCard.tsx';
|
||
import IotPanelExperienceTempCard from './iotPanel/IotPanelExperienceTempCard.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 [insights, setInsights] = useState<AIInsight[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [refreshing, setRefreshing] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [refreshToken, setRefreshToken] = useState(0);
|
||
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([]);
|
||
setInsights([]);
|
||
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, refreshToken]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedCycle || selectedFlockId == null) {
|
||
setDates([]);
|
||
setSelectedDate('');
|
||
setDayRows([]);
|
||
setInsights([]);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
(async () => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const [dateList, insightRows] = await Promise.all([
|
||
api.panelIot.dates({ cycle_id: selectedCycle.id, flock_id: selectedFlockId }),
|
||
api.insights.list({ cycle_id: selectedCycle.id }),
|
||
]);
|
||
if (cancelled) return;
|
||
const sortedDates = [...dateList].sort();
|
||
setDates(sortedDates);
|
||
setInsights(insightRows);
|
||
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, refreshToken]);
|
||
|
||
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, refreshToken]);
|
||
|
||
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 iotInsights = useMemo(() => {
|
||
const matched = insights.filter((item) => {
|
||
const text =
|
||
`${item.section ?? ''} ${item.alert ?? ''} ${item.insight_text ?? ''}`.toLowerCase();
|
||
return (
|
||
text.includes('iot') ||
|
||
text.includes('suhu') ||
|
||
text.includes('kelembapan') ||
|
||
text.includes('humidity') ||
|
||
text.includes('temperature') ||
|
||
text.includes('lingkungan')
|
||
);
|
||
});
|
||
return (matched.length > 0 ? matched : insights).slice(0, 4);
|
||
}, [insights]);
|
||
|
||
const refresh = () => {
|
||
setRefreshing(true);
|
||
setRefreshToken((token) => token + 1);
|
||
window.setTimeout(() => setRefreshing(false), 500);
|
||
};
|
||
|
||
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={
|
||
<div className="flex items-center gap-2">
|
||
<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>
|
||
<button
|
||
type="button"
|
||
onClick={refresh}
|
||
disabled={refreshing}
|
||
className="rounded-lg border border-slate-300 bg-slate-50 px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-100 disabled:opacity-60"
|
||
>
|
||
<i className={`fa-solid fa-rotate-right mr-1 ${refreshing ? 'fa-spin' : ''}`} />
|
||
Refresh
|
||
</button>
|
||
</div>
|
||
}
|
||
/>
|
||
|
||
{error && <Banner kind="error">{error}</Banner>}
|
||
{loading && dayRows.length === 0 ? (
|
||
<Spinner />
|
||
) : summary ? (
|
||
<>
|
||
<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>
|
||
{iotInsights.length === 0 ? (
|
||
<Banner>Belum ada insight untuk siklus ini.</Banner>
|
||
) : (
|
||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||
{iotInsights.map((item) => (
|
||
<article
|
||
key={item.id}
|
||
className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm"
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<p className="text-xs uppercase tracking-wide text-gray-400">
|
||
{item.section}
|
||
</p>
|
||
<h3 className="mt-1 font-semibold text-gray-800">
|
||
{item.alert || 'Insight'}
|
||
</h3>
|
||
</div>
|
||
<span className="text-xs text-gray-400">{item.session}</span>
|
||
</div>
|
||
<p className="mt-3 whitespace-pre-wrap text-sm text-gray-600">
|
||
{item.insight_text}
|
||
</p>
|
||
</article>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<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;
|