adjust panel iot page to match dashboard-cpsp page
This commit is contained in:
1 parent
b5ee6b87f0
commit
04678ef773
2 files changed
+319
-209
No files matched your search
+291
-209
@@ -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 &&
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in new issue
Block a user