adjust panel iot page to match dashboard-cpsp page

This commit is contained in:
Alberto-Audrix committed 2026-10-05 15:10:53 +07:00
1 parent b5ee6b87f0
commit 04678ef773
2 files changed
+319 -209

No files matched your search

+291 -209
View File
@@ -12,7 +12,14 @@ import { api, errorMessage } from '../services/apiClient.ts';
import type { Flock, PanelIoT } from '../types/api.ts'; import type { Flock, PanelIoT } from '../types/api.ts';
import { useFarm } from '../context/FarmContext.tsx'; import { useFarm } from '../context/FarmContext.tsx';
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx'; import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
import { cycleDayForDate, formatDateId, formatDateRange, formatNumber } from '../utils/format.ts'; import {
cycleDayForDate,
formatDateId,
formatDateRange,
formatNumber,
resolveCycleBoundDate,
todayIso,
} from '../utils/format.ts';
import { import {
getTempStandardByDay, getTempStandardByDay,
getWaterStandardByDay, getWaterStandardByDay,
@@ -53,7 +60,11 @@ type DaySummary = {
minExp: number; minExp: number;
maxExp: number; maxExp: number;
humidity: number; humidity: number;
minHumidity: number;
maxHumidity: number;
windSpeed: number; windSpeed: number;
minWind: number;
maxWind: number;
waterTotal: number; waterTotal: number;
}; };
@@ -126,11 +137,19 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
if (cancelled) return; if (cancelled) return;
const sortedDates = [...dateList].sort(); const sortedDates = [...dateList].sort();
setDates(sortedDates); setDates(sortedDates);
setSelectedDate((previous) => const cycleMin = selectedCycle.start_date;
previous && sortedDates.includes(previous) const cycleMax = resolveCycleBoundDate(selectedCycle);
? previous const fallbackDate =
: (sortedDates[sortedDates.length - 1] ?? '') 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) { } catch (err) {
if (!cancelled) setError(errorMessage(err)); if (!cancelled) setError(errorMessage(err));
} finally { } finally {
@@ -191,7 +210,11 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
minExp: min('experience_temperature'), minExp: min('experience_temperature'),
maxExp: max('experience_temperature'), maxExp: max('experience_temperature'),
humidity: last.humidity, humidity: last.humidity,
minHumidity: min('humidity'),
maxHumidity: max('humidity'),
windSpeed: last.wind_speed, windSpeed: last.wind_speed,
minWind: min('wind_speed'),
maxWind: max('wind_speed'),
waterTotal: last.water_total, waterTotal: last.water_total,
}; };
}, [dayRows]); }, [dayRows]);
@@ -243,8 +266,19 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
suhu_max_C: summary?.maxTemp ?? null, suhu_max_C: summary?.maxTemp ?? null,
experience_suhu_min_C: summary?.minExp ?? null, experience_suhu_min_C: summary?.minExp ?? null,
experience_suhu_max_C: summary?.maxExp ?? null, experience_suhu_max_C: summary?.maxExp ?? null,
kelembapan_min_persen: summary?.minHumidity ?? null,
kelembapan_max_persen: summary?.maxHumidity ?? null,
kecepatan_angin_min_mPerDetik: summary?.minWind ?? null,
kecepatan_angin_max_mPerDetik: summary?.maxWind ?? null,
waktu_data: selectedDate || null,
}); });
}, [selectedKandang, selectedCycle, dayAge, summary]); }, [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} />; if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
@@ -261,9 +295,9 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
setSelectedFlockId(event.target.value ? Number(event.target.value) : null) 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" className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700"
aria-label="Pilih flock" aria-label="Pilih lantai"
> >
{flocks.length === 0 && <option value="">Tidak ada flock</option>} {flocks.length === 0 && <option value="">Tidak ada lantai</option>}
{flocks.map((flock) => ( {flocks.map((flock) => (
<option key={flock.id} value={flock.id}> <option key={flock.id} value={flock.id}>
{flock.flock_name} {flock.flock_name}
@@ -275,6 +309,32 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
{error && <Banner kind="error">{error}</Banner>} {error && <Banner kind="error">{error}</Banner>}
{selectedFlockId == null ? (
<div className={cardClass}>
<p className="py-8 text-center text-slate-400">
Pilih lantai 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 ? ( {selectedKandang && insightContext ? (
<PageAiInsight <PageAiInsight
topic="iot_panel" topic="iot_panel"
@@ -318,221 +378,243 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
/> />
) : ( ) : (
<section> <section>
<h2 className="text-lg font-bold text-gray-800 mb-3"> <h2 className="mb-3 text-lg font-bold text-gray-800">
<i className="fa-solid fa-brain text-red-600 mr-2" /> <i className="fa-solid fa-brain mr-2 text-red-600" />
AI Insight Panel IoT AI Insight Panel IoT
</h2> </h2>
<Banner>Pilih kandang untuk melihat AI Insight.</Banner> <Banner>Pilih kandang untuk melihat AI Insight.</Banner>
</section> </section>
)} )}
{loading && dayRows.length === 0 ? ( {selectedFlockId != null &&
<Spinner /> (loading && dayRows.length === 0 ? (
) : summary ? ( <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"> <section className="grid gap-3 xl:grid-cols-[1.25fr_2.35fr]">
<span className="inline-flex items-center gap-2"> <div className="space-y-3">
<i className="fa-regular fa-calendar" /> <article className={cardClass}>
<label htmlFor="iot-date-picker" className="text-slate-500"> <div className="grid gap-3 sm:grid-cols-2">
Tanggal: <div className="rounded-lg bg-red-50/70 p-4">
</label> <h2 className="text-sm font-semibold text-slate-700">
<input <i className="fa-solid fa-temperature-half mr-2 text-red-500" />
id="iot-date-picker" Suhu Rata-Rata
type="date" </h2>
value={selectedDate} <p className="mt-2 text-3xl font-bold text-slate-700">
min={dates[0] ?? undefined} {display(summary.temp, ' °C')}
max={dates[dates.length - 1] ?? undefined} </p>
onChange={(event) => setSelectedDate(event.target.value)} <p className="mt-1 text-xs text-slate-500">
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" {display(summary.minTemp, ' °C')} – {display(summary.maxTemp, ' °C')}
/> </p>
</span> </div>
<span className="text-xs">{summary.count} pembacaan</span> <div className="rounded-lg bg-sky-50/80 p-4">
</div> <h2 className="text-sm font-semibold text-slate-700">
<i className="fa-solid fa-temperature-half mr-2 text-sky-600" />
<section className="grid gap-3 xl:grid-cols-[1.25fr_2.35fr]"> Experience Suhu
<div className="space-y-3"> </h2>
<article className={cardClass}> <p className="mt-2 text-3xl font-bold text-sky-600">
<div className="grid gap-3 sm:grid-cols-2"> {display(summary.experienceTemp, ' °C')}
<div className="rounded-lg bg-red-50/70 p-4"> </p>
<h2 className="text-sm font-semibold text-slate-700"> <p className="mt-1 text-xs text-slate-500">
<i className="fa-solid fa-temperature-half mr-2 text-red-500" /> {display(summary.minExp, ' °C')} – {display(summary.maxExp, ' °C')}
Suhu Rata-Rata </p>
</h2> </div>
<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>
<div className="rounded-lg bg-sky-50/80 p-4"> </article>
<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}> <article className={cardClass}>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{[ {[
{ {
label: 'Kelembapan', label: 'Kelembapan',
value: display(summary.humidity, ' %'), value: display(summary.humidity, ' %'),
icon: 'fa-droplet', icon: 'fa-droplet',
color: 'text-blue-500', color: 'text-blue-500',
chartKey: 'humidity', chartKey: 'humidity',
}, },
{ {
label: 'Angin', label: 'Angin',
value: display(summary.windSpeed, ' m/s'), value: display(summary.windSpeed, ' m/s'),
icon: 'fa-wind', icon: 'fa-wind',
color: 'text-indigo-500', color: 'text-indigo-500',
chartKey: 'wind', chartKey: 'wind',
}, },
{ {
label: 'Air total', label: 'Air total',
value: display(summary.waterTotal, ' L'), value: display(summary.waterTotal, ' L'),
icon: 'fa-water', icon: 'fa-water',
color: 'text-cyan-500', color: 'text-cyan-500',
chartKey: 'water', chartKey: 'water',
}, },
{ {
label: 'Umur', label: 'Umur',
value: dayAge == null ? 'N/A' : `${dayAge} hari`, value: dayAge == null ? 'N/A' : `${dayAge} hari`,
icon: 'fa-dove', icon: 'fa-dove',
color: 'text-amber-600', color: 'text-amber-600',
}, },
{ {
label: 'Tanggal', label: 'Tanggal',
value: formatDateId(selectedDate), value: formatDateId(selectedDate),
icon: 'fa-calendar-day', icon: 'fa-calendar-day',
color: 'text-slate-500', color: 'text-slate-500',
}, },
{ {
label: 'Flock', label: 'Lantai',
value: value:
flocks.find((flock) => flock.id === selectedFlockId)?.flock_name ?? 'N/A', flocks.find((flock) => flock.id === selectedFlockId)?.flock_name ?? 'N/A',
icon: 'fa-warehouse', icon: 'fa-warehouse',
color: 'text-slate-500', color: 'text-slate-500',
}, },
].map((tile) => { ].map((tile) => {
const inner = ( const inner = (
<> <>
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500">
<i className={`fa-solid ${tile.icon} mr-2 ${tile.color}`} /> <i className={`fa-solid ${tile.icon} mr-2 ${tile.color}`} />
{tile.label} {tile.label}
</p> </p>
<p className="mt-2 text-xl font-semibold text-slate-700">{tile.value}</p> <p className="mt-2 text-xl font-semibold text-slate-700">{tile.value}</p>
</> </>
); );
if (!tile.chartKey) { 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 ( return (
<div <button
key={tile.label} key={tile.label}
className="min-h-[104px] rounded-xl border border-slate-200 bg-white p-3" 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} {inner}
</div> </button>
); );
} })}
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> </div>
<span className="rounded-full bg-sky-50 px-3 py-1 text-xs font-semibold text-sky-700"> </article>
{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} /> <IotPanelExperienceTempCard
</div> calc={experienceCalc}
</section> apiExperienceTemp={summary.experienceTemp}
</> />
) : ( </div>
<div className={cardClass}>
<p className="py-8 text-center text-slate-400"> <div className="space-y-3">
Belum ada data panel IoT untuk flock ini. <article className={`${cardClass} min-h-[390px]`}>
</p> <div className="mb-3 flex items-start justify-between gap-3">
</div> <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 lantai ini. Refresh halaman 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 && {selectedChartKey &&
+28
View File
@@ -57,6 +57,34 @@ export function inclusiveDayCount(startIso: string, endIso: string): number {
return Math.max(1, cycleDayForDate(startIso, endIso) + 1); return Math.max(1, cycleDayForDate(startIso, endIso) + 1);
} }
/** Inclusive upper date for open cycles (null end_date) without producing NaN day math. */
export function resolveCycleBoundDate(cycle: {
start_date: string;
end_date?: string | null;
soft_end_date?: string | null;
visible_through_date?: string | null;
total_days?: number;
}): string {
if (cycle.end_date) return cycle.end_date;
if (cycle.soft_end_date) return cycle.soft_end_date;
if (cycle.visible_through_date) return cycle.visible_through_date;
if (cycle.total_days != null && cycle.total_days > 0) {
return addDaysIso(cycle.start_date, Math.max(cycle.total_days - 1, 0));
}
return todayIso();
}
/** 0-based last day index from start → bound date (safe when end_date is null). */
export function cycleLastDayIndex(cycle: {
start_date: string;
end_date?: string | null;
soft_end_date?: string | null;
visible_through_date?: string | null;
total_days?: number;
}): number {
return cycleDayForDate(cycle.start_date, resolveCycleBoundDate(cycle));
}
export type CycleStatusUi = 'active' | 'pending_close' | 'closed'; export type CycleStatusUi = 'active' | 'pending_close' | 'closed';
export function resolveCycleStatus(cycle: { export function resolveCycleStatus(cycle: {