Files
dashboard-cpsp/components/IotPanelPage.tsx
T

596 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 { 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;