first commit

This commit is contained in:
Alberto-Audrix committed 2026-09-08 15:21:23 +07:00
commit b54624be96
226 files changed
+108840

No files matched your search

+595
View File
@@ -0,0 +1,595 @@
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;