ai insight rework #2
This commit is contained in:
1 parent
9d76f54d33
commit
e1987d6999
39 files changed
+2311
-830
No files matched your search
+196
-183
@@ -12,7 +12,14 @@ import { api, errorMessage } from '../services/apiClient.ts';
|
||||
import type { 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, resolveCycleBoundDate, todayIso } from '../utils/format.ts';
|
||||
import {
|
||||
cycleDayForDate,
|
||||
formatDateId,
|
||||
formatDateRange,
|
||||
formatNumber,
|
||||
resolveCycleBoundDate,
|
||||
todayIso,
|
||||
} from '../utils/format.ts';
|
||||
import {
|
||||
getTempStandardByDay,
|
||||
getWaterStandardByDay,
|
||||
@@ -132,7 +139,8 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
setDates(sortedDates);
|
||||
const cycleMin = selectedCycle.start_date;
|
||||
const cycleMax = resolveCycleBoundDate(selectedCycle);
|
||||
const fallbackDate = todayIso() > cycleMax ? cycleMax : todayIso() < cycleMin ? cycleMin : todayIso();
|
||||
const fallbackDate =
|
||||
todayIso() > cycleMax ? cycleMax : todayIso() < cycleMin ? cycleMin : todayIso();
|
||||
setSelectedDate((previous) => {
|
||||
if (sortedDates.length > 0) {
|
||||
return previous && sortedDates.includes(previous)
|
||||
@@ -303,7 +311,9 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
|
||||
{selectedFlockId == null ? (
|
||||
<div className={cardClass}>
|
||||
<p className="py-8 text-center text-slate-400">Pilih lantai untuk melihat data panel IoT.</p>
|
||||
<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">
|
||||
@@ -322,7 +332,6 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -382,192 +391,192 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
<Spinner />
|
||||
) : summary ? (
|
||||
<>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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: 'Lantai',
|
||||
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) {
|
||||
<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: 'Lantai',
|
||||
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 (
|
||||
<div
|
||||
<button
|
||||
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}
|
||||
</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>
|
||||
<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>
|
||||
</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>
|
||||
))}
|
||||
<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">
|
||||
{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}>
|
||||
@@ -591,8 +600,12 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
{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.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>
|
||||
|
||||
Reference in new issue
Block a user