update ai insight migration for all page
This commit is contained in:
1 parent
07d36081ce
commit
18790ab8f6
69 files changed
+9457
-399
No files matched your search
+144
-92
@@ -9,13 +9,20 @@ import {
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { api, errorMessage } from '../services/apiClient.ts';
|
||||
import type { AIInsight, Flock, PanelIoT } from '../types/api.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 } from '../utils/format.ts';
|
||||
import { cycleDayForDate, formatDateId, formatDateRange, formatNumber, resolveCycleBoundDate, todayIso } from '../utils/format.ts';
|
||||
import {
|
||||
getTempStandardByDay,
|
||||
getWaterStandardByDay,
|
||||
HUMIDITY_RANGE_PCT,
|
||||
} from '../utils/cp707EnvStandards.ts';
|
||||
import { buildIotInsightContext } from '../utils/insightContextBuilders.ts';
|
||||
import { computeExperienceCalc } from './iotPanel/chillFactor.ts';
|
||||
import IotPanelChillFactorCard from './iotPanel/IotPanelChillFactorCard.tsx';
|
||||
import IotPanelExperienceTempCard from './iotPanel/IotPanelExperienceTempCard.tsx';
|
||||
import { PageAiInsight } from './shared/PageAiInsight.tsx';
|
||||
|
||||
const cardClass = 'rounded-xl border border-slate-200 bg-white p-4 shadow-sm';
|
||||
|
||||
@@ -57,7 +64,6 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
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);
|
||||
@@ -82,7 +88,6 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
setDates([]);
|
||||
setSelectedDate('');
|
||||
setDayRows([]);
|
||||
setInsights([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
@@ -109,7 +114,6 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
setDates([]);
|
||||
setSelectedDate('');
|
||||
setDayRows([]);
|
||||
setInsights([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
@@ -117,19 +121,26 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
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 }),
|
||||
]);
|
||||
const dateList = await api.panelIot.dates({
|
||||
cycle_id: selectedCycle.id,
|
||||
flock_id: selectedFlockId,
|
||||
});
|
||||
if (cancelled) return;
|
||||
const sortedDates = [...dateList].sort();
|
||||
setDates(sortedDates);
|
||||
setInsights(insightRows);
|
||||
setSelectedDate((previous) =>
|
||||
previous && sortedDates.includes(previous)
|
||||
? previous
|
||||
: (sortedDates[sortedDates.length - 1] ?? '')
|
||||
);
|
||||
const cycleMin = selectedCycle.start_date;
|
||||
const cycleMax = resolveCycleBoundDate(selectedCycle);
|
||||
const fallbackDate = todayIso() > cycleMax ? cycleMax : todayIso() < cycleMin ? cycleMin : todayIso();
|
||||
setSelectedDate((previous) => {
|
||||
if (sortedDates.length > 0) {
|
||||
return previous && sortedDates.includes(previous)
|
||||
? previous
|
||||
: (sortedDates[sortedDates.length - 1] ?? fallbackDate);
|
||||
}
|
||||
// No IoT dates yet — keep a navigable date inside the cycle window.
|
||||
if (previous && previous >= cycleMin && previous <= cycleMax) return previous;
|
||||
return fallbackDate;
|
||||
});
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(errorMessage(err));
|
||||
} finally {
|
||||
@@ -224,21 +235,29 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
[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')
|
||||
);
|
||||
const insightContext = useMemo(() => {
|
||||
if (!selectedKandang) return null;
|
||||
return buildIotInsightContext({
|
||||
kandangId: selectedKandang.id,
|
||||
kandangName: selectedKandang.kandang_name,
|
||||
cycleId: selectedCycle?.id,
|
||||
hari_ke: dayAge,
|
||||
totalDays: selectedCycle?.total_days ?? null,
|
||||
suhu_rata_rata_C: summary?.temp ?? null,
|
||||
experience_suhu_C: summary?.experienceTemp ?? null,
|
||||
kelembapan_persen: summary?.humidity ?? null,
|
||||
kecepatan_angin_mPerDetik: summary?.windSpeed ?? null,
|
||||
konsumsi_air_L: summary?.waterTotal ?? null,
|
||||
setpoint_C: getTempStandardByDay(dayAge),
|
||||
suhu_min_C: summary?.minTemp ?? null,
|
||||
suhu_max_C: summary?.maxTemp ?? null,
|
||||
waktu_data: selectedDate || null,
|
||||
});
|
||||
return (matched.length > 0 ? matched : insights).slice(0, 4);
|
||||
}, [insights]);
|
||||
}, [selectedKandang, selectedCycle, summary, dayAge, selectedDate]);
|
||||
|
||||
// Always allow navigating the cycle window — never hide empty days behind missing IoT dates.
|
||||
const datePickerMin = selectedCycle?.start_date ?? undefined;
|
||||
const datePickerMax = selectedCycle ? resolveCycleBoundDate(selectedCycle) : undefined;
|
||||
|
||||
const refresh = () => {
|
||||
setRefreshing(true);
|
||||
@@ -285,63 +304,94 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
/>
|
||||
|
||||
{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>
|
||||
{selectedFlockId == null ? (
|
||||
<div className={cardClass}>
|
||||
<p className="py-8 text-center text-slate-400">Pilih flock 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>
|
||||
<span className="text-xs">
|
||||
{summary
|
||||
? `${summary.count} pembacaan`
|
||||
: dates.length === 0
|
||||
? 'Belum ada tanggal dengan data IoT'
|
||||
: 'Tidak ada pembacaan pada tanggal ini'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedKandang && insightContext ? (
|
||||
<PageAiInsight
|
||||
topic="iot_panel"
|
||||
cacheKey={`iot-k${selectedKandang.id}-c${selectedCycle.id}-d${selectedDate}-t${summary?.temp ?? 'na'}`}
|
||||
contextData={insightContext}
|
||||
chartSpec={{
|
||||
type: 'condition',
|
||||
title: 'Kondisi kandang vs standar CP 707',
|
||||
bars: [
|
||||
{
|
||||
label: 'Suhu rata-rata',
|
||||
actual: summary?.temp ?? null,
|
||||
standard: getTempStandardByDay(dayAge),
|
||||
unit: '°C',
|
||||
note:
|
||||
getTempStandardByDay(dayAge) === null
|
||||
? 'Umur tidak diketahui, standar suhu tidak dapat ditentukan.'
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
label: 'Kelembapan',
|
||||
actual: summary?.humidity ?? null,
|
||||
standard: HUMIDITY_RANGE_PCT.max,
|
||||
unit: '%',
|
||||
note: `Rentang ideal buku ${HUMIDITY_RANGE_PCT.min}–${HUMIDITY_RANGE_PCT.max}%; batang biru menandai batas atas.`,
|
||||
},
|
||||
{
|
||||
label: 'Experience Suhu',
|
||||
actual: summary?.experienceTemp ?? null,
|
||||
standard: getTempStandardByDay(dayAge),
|
||||
unit: '°C',
|
||||
},
|
||||
{
|
||||
label: 'Konsumsi Air',
|
||||
actual: summary?.waterTotal ?? null,
|
||||
standard: getWaterStandardByDay(dayAge)?.max ?? null,
|
||||
unit: 'L',
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
<Banner>Pilih kandang untuk melihat dan generate AI Insight.</Banner>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{selectedFlockId != null &&
|
||||
(loading && dayRows.length === 0 ? (
|
||||
<Spinner />
|
||||
) : summary ? (
|
||||
<>
|
||||
<section className="grid gap-3 xl:grid-cols-[1.25fr_2.35fr]">
|
||||
<div className="space-y-3">
|
||||
<article className={cardClass}>
|
||||
@@ -518,14 +568,16 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className={cardClass}>
|
||||
<p className="py-8 text-center text-slate-400">
|
||||
{dates.length === 0
|
||||
? 'Belum ada data panel IoT untuk flock ini. Tanggal tetap bisa dipilih dalam rentang siklus; generate ulang setelah data tersedia.'
|
||||
: `Belum ada data panel IoT untuk ${selectedDate ? formatDateId(selectedDate) : 'tanggal ini'}.`}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{selectedChartKey &&
|
||||
(() => {
|
||||
|
||||
Reference in new issue
Block a user