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
@@ -0,0 +1,942 @@
|
||||
/**
|
||||
* Per-page / per-topic AI Insight shell (FCR, weight, counting, IoT, …).
|
||||
*
|
||||
* Builds/scopes context, then loads cached insight on mount.
|
||||
* LLM generate runs only when the user clicks Generate (force=true).
|
||||
* Distinct from
|
||||
* `AiInsightCard` (Dashboard report modes). See docs/ai-insight/README.md.
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useFarm } from '../../context/FarmContext.tsx';
|
||||
import { api, errorMessage } from '../../services/apiClient.ts';
|
||||
import type {
|
||||
InsightCitation,
|
||||
InsightContext,
|
||||
InsightSource,
|
||||
InsightTopic,
|
||||
} from '../../types/api.ts';
|
||||
import { formatRatio } from '../../utils/format.ts';
|
||||
import { getCobbStandardForDay } from '../../utils/insightHelpers.ts';
|
||||
import { parseAiResult, type AiResult } from '../../utils/insightParse.ts';
|
||||
import {
|
||||
scopeContextToTimeframe,
|
||||
scopeScalarsToTimeframe,
|
||||
describeTimeframe,
|
||||
UNAVAILABLE,
|
||||
type ScalarScopeSpec,
|
||||
} from '../../utils/insightScope.ts';
|
||||
import InsightTrendChart, { type InsightSeries } from './InsightTrendChart.tsx';
|
||||
import InsightConditionChart, { type ConditionBar } from './InsightConditionChart.tsx';
|
||||
import { HeroFigure, type InsightStatus } from './InsightStatTiles.tsx';
|
||||
|
||||
const CLIENT_TIMEOUT_MS = 1_200_000; // 20 minutes — NUC LLM can take several minutes
|
||||
|
||||
const _insightMemoryCache = new Map<string, CachedPageInsight>();
|
||||
const _insightInFlight = new Map<string, Promise<void>>();
|
||||
|
||||
type CachedPageInsight = AiResult & {
|
||||
source: InsightSource;
|
||||
citations?: InsightCitation[];
|
||||
};
|
||||
|
||||
export type { InsightTopic };
|
||||
|
||||
export interface PageAiInsightProps {
|
||||
topic: InsightTopic;
|
||||
/** Curated context — must include kandangId (never null "all kandang"). */
|
||||
contextData: InsightContext | Record<string, unknown>;
|
||||
cacheKey: string;
|
||||
scalarScope?: ScalarScopeSpec;
|
||||
chartSpec?:
|
||||
| {
|
||||
type: 'trend';
|
||||
historyKey: string;
|
||||
series: InsightSeries[];
|
||||
unit?: string;
|
||||
title?: string;
|
||||
gap?: { actualField: string; targetField: string };
|
||||
}
|
||||
| { type: 'condition'; bars: ConditionBar[]; title?: string };
|
||||
}
|
||||
|
||||
const TOPIC_META: Record<InsightTopic, { title: string; icon: string }> = {
|
||||
hitung_ayam: { title: 'AI Insight — Hitung Ayam', icon: 'fa-kiwi-bird' },
|
||||
berat_ayam: { title: 'AI Insight — Berat Ayam', icon: 'fa-weight-scale' },
|
||||
fcr: { title: 'AI Insight — FCR', icon: 'fa-wheat-awn' },
|
||||
eef: { title: 'AI Insight — EEF', icon: 'fa-chart-line' },
|
||||
iot_panel: { title: 'AI Insight — Panel IoT', icon: 'fa-temperature-half' },
|
||||
hitung_karung: { title: 'AI Insight — Hitung Karung', icon: 'fa-boxes-stacked' },
|
||||
dashboard: { title: 'AI Insight — Dashboard', icon: 'fa-brain' },
|
||||
};
|
||||
|
||||
const SOURCE_LABEL: Record<InsightSource, { text: string; cls: string }> = {
|
||||
generated: { text: 'Baru dibuat', cls: 'bg-green-50 text-green-700 border-green-200' },
|
||||
cache: { text: 'Dari cache', cls: 'bg-blue-50 text-blue-700 border-blue-200' },
|
||||
local_fallback: {
|
||||
text: 'Fallback lokal',
|
||||
cls: 'bg-amber-50 text-amber-800 border-amber-200',
|
||||
},
|
||||
};
|
||||
|
||||
const renderValue = (val: unknown, suffix: string = ''): string => {
|
||||
if (val === undefined || val === null || val === '') return 'N/A';
|
||||
if (val === UNAVAILABLE) return UNAVAILABLE;
|
||||
return `${val} ${suffix}`.trim();
|
||||
};
|
||||
|
||||
const requireKandangId = (ctx: Record<string, unknown>): number => {
|
||||
const raw = ctx.kandangId ?? ctx.kandang_id;
|
||||
const id = typeof raw === 'number' ? raw : Number(raw);
|
||||
if (!Number.isFinite(id) || id <= 0) {
|
||||
throw new Error('kandangId wajib — AI Insight tidak boleh digenerate untuk semua kandang');
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
const _saveToCache = (storageKey: string, result: CachedPageInsight): void => {
|
||||
_insightMemoryCache.set(storageKey, result);
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(result));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const CitationsBlock: React.FC<{ citations?: InsightCitation[] }> = ({ citations }) => {
|
||||
if (!citations || citations.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-4 rounded-lg border border-gray-100 bg-gray-50 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-2">
|
||||
Sumber referensi
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{citations.map((c, i) => (
|
||||
<li key={c.id ?? `${c.title ?? 'cite'}-${i}`} className="text-xs text-gray-600">
|
||||
<span className="font-medium text-gray-800">
|
||||
{c.chapter || c.title || c.source || `Referensi ${i + 1}`}
|
||||
</span>
|
||||
{c.excerpt ? <span className="text-gray-500"> — {c.excerpt}</span> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
topic,
|
||||
contextData,
|
||||
cacheKey,
|
||||
scalarScope,
|
||||
chartSpec,
|
||||
}) => {
|
||||
const { selectedCycle, selectedKandang, selectedSite, selectedKandangId } = useFarm();
|
||||
|
||||
const [result, setResult] = useState<CachedPageInsight | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const supportsTimeframe = Boolean(scalarScope);
|
||||
const timeframe: 'harian' | 'mingguan' = 'harian';
|
||||
|
||||
const selectedDayKey = `insight-day-${topic}`;
|
||||
const [selectedDay, setSelectedDayState] = useState<number | null>(() => {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(selectedDayKey);
|
||||
const parsed = raw === null ? NaN : parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const setSelectedDay = useCallback(
|
||||
(next: number | null) => {
|
||||
setSelectedDayState(next);
|
||||
try {
|
||||
if (next === null) sessionStorage.removeItem(selectedDayKey);
|
||||
else sessionStorage.setItem(selectedDayKey, String(next));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
[selectedDayKey]
|
||||
);
|
||||
|
||||
const availableDays = React.useMemo(() => {
|
||||
const key =
|
||||
scalarScope?.historyKey ?? (chartSpec?.type === 'trend' ? chartSpec.historyKey : null);
|
||||
if (!key) return [];
|
||||
const rows = (contextData as Record<string, unknown>)[key];
|
||||
if (!Array.isArray(rows)) return [];
|
||||
const days = new Set<number>();
|
||||
for (const row of rows) {
|
||||
if (typeof row !== 'object' || row === null) continue;
|
||||
const r = row as Record<string, unknown>;
|
||||
for (const field of ['hari', 'day']) {
|
||||
const value = r[field];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) days.add(value);
|
||||
}
|
||||
}
|
||||
return [...days].sort((a, b) => a - b);
|
||||
}, [contextData, scalarScope, chartSpec]);
|
||||
|
||||
const lastAvailableDay: number | null =
|
||||
availableDays.length > 0 ? availableDays[availableDays.length - 1]! : null;
|
||||
|
||||
// Dropdown shows last available day when nothing is selected — generate/cache
|
||||
// must use that same day, not cycle.current_day (calendar can be ahead of data).
|
||||
const activeDay: number | null =
|
||||
timeframe === 'harian' && selectedDay !== null && availableDays.includes(selectedDay)
|
||||
? selectedDay
|
||||
: null;
|
||||
const effectiveDay: number | null =
|
||||
timeframe === 'harian' ? (activeDay ?? lastAvailableDay) : null;
|
||||
|
||||
const scopeKey = `${cacheKey}-${timeframe}${effectiveDay === null ? '' : `-h${effectiveDay}`}`;
|
||||
|
||||
const inFlightRef = useRef(false);
|
||||
const lastCacheKeyRef = useRef<string | null>(null);
|
||||
const desiredKeyRef = useRef<string>('');
|
||||
const requestIdRef = useRef(0);
|
||||
const generateRef = useRef<(force?: boolean) => Promise<void>>(async () => {});
|
||||
|
||||
const chartHighlight = React.useMemo(() => {
|
||||
if (!chartSpec || chartSpec.type !== 'trend') return null;
|
||||
const rows = (contextData as Record<string, unknown>)[chartSpec.historyKey];
|
||||
if (!Array.isArray(rows)) return null;
|
||||
const days = rows
|
||||
.map((row) => {
|
||||
const r = row as Record<string, unknown>;
|
||||
for (const key of ['hari', 'day']) {
|
||||
const value = r[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((day): day is number => day !== null);
|
||||
if (days.length === 0) return null;
|
||||
if (timeframe === 'harian' && effectiveDay !== null) {
|
||||
return { firstDay: 0, lastDay: effectiveDay };
|
||||
}
|
||||
const lastDay = Math.max(...days);
|
||||
const firstDay = timeframe === 'harian' ? lastDay : Math.max(Math.min(...days), lastDay - 6);
|
||||
return { firstDay, lastDay };
|
||||
}, [chartSpec, contextData, timeframe, effectiveDay]);
|
||||
|
||||
const displayContext = React.useMemo(() => {
|
||||
const raw = contextData as Record<string, unknown>;
|
||||
if (!scalarScope) return raw;
|
||||
const sliced = scopeContextToTimeframe(raw, timeframe, effectiveDay);
|
||||
const scoped = scopeScalarsToTimeframe(
|
||||
sliced as Record<string, unknown>,
|
||||
timeframe,
|
||||
scalarScope,
|
||||
effectiveDay
|
||||
);
|
||||
const out: Record<string, unknown> = { ...raw };
|
||||
for (const [outKey, rule] of Object.entries(scalarScope.derive)) {
|
||||
if (rule.replaces) out[rule.replaces] = scoped[outKey];
|
||||
}
|
||||
for (const key of scalarScope.unavailable ?? []) out[key] = UNAVAILABLE;
|
||||
out.periode = scoped.periode;
|
||||
return out;
|
||||
}, [contextData, timeframe, effectiveDay, scalarScope]);
|
||||
|
||||
const periodeLabel = typeof displayContext.periode === 'string' ? displayContext.periode : null;
|
||||
|
||||
const trendChartRows = React.useMemo(() => {
|
||||
if (!chartSpec || chartSpec.type !== 'trend') return [];
|
||||
const rows = (contextData as Record<string, unknown>)[chartSpec.historyKey];
|
||||
if (!Array.isArray(rows)) return [];
|
||||
if (timeframe !== 'harian' || effectiveDay === null) {
|
||||
return rows as Array<Record<string, unknown>>;
|
||||
}
|
||||
return (rows as Array<Record<string, unknown>>).filter((row) => {
|
||||
for (const field of ['hari', 'day']) {
|
||||
const value = row[field];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value <= effectiveDay;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [chartSpec, contextData, timeframe, effectiveDay]);
|
||||
|
||||
const currentDay = selectedCycle?.current_day ?? null;
|
||||
|
||||
const heroSpec = React.useMemo(() => {
|
||||
const n = (value: unknown): number | null =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
const standardDay = effectiveDay ?? currentDay ?? null;
|
||||
const standard = standardDay ? getCobbStandardForDay(standardDay) : null;
|
||||
const dayLabel = standardDay ? `hari ke-${standardDay}` : null;
|
||||
|
||||
if (topic === 'berat_ayam') {
|
||||
const actual = n(displayContext.averageWeight);
|
||||
const target = n(displayContext.targetWeight) ?? standard?.weight ?? null;
|
||||
if (actual === null) return null;
|
||||
const dev = target ? ((actual - target) / target) * 100 : null;
|
||||
return {
|
||||
label: 'Rata-rata Bobot Aktual',
|
||||
value: Math.round(actual).toLocaleString('id-ID'),
|
||||
unit: 'g',
|
||||
context: target
|
||||
? `Target CP 707${dayLabel ? ` ${dayLabel}` : ''}: ${Math.round(target).toLocaleString('id-ID')} g`
|
||||
: null,
|
||||
status: (dev === null
|
||||
? 'unknown'
|
||||
: dev >= -5
|
||||
? 'ok'
|
||||
: dev >= -15
|
||||
? 'warning'
|
||||
: 'critical') as InsightStatus,
|
||||
deltaLabel: dev === null ? null : `${dev.toFixed(0)}% vs target`,
|
||||
};
|
||||
}
|
||||
|
||||
if (topic === 'fcr') {
|
||||
const actual = n(displayContext.fcr_terakhir);
|
||||
if (actual === null) return null;
|
||||
const std = standard?.fcr ?? null;
|
||||
return {
|
||||
label: 'FCR Aktual',
|
||||
value: formatRatio(actual, 3),
|
||||
unit: undefined,
|
||||
context: std ? `Standar CP 707${dayLabel ? ` ${dayLabel}` : ''}: ${formatRatio(std, 3)}` : null,
|
||||
status: (std === null
|
||||
? 'unknown'
|
||||
: actual <= std
|
||||
? 'ok'
|
||||
: actual <= std * 1.1
|
||||
? 'warning'
|
||||
: 'critical') as InsightStatus,
|
||||
deltaLabel:
|
||||
std === null
|
||||
? null
|
||||
: `${actual > std ? '+' : ''}${formatRatio(actual - std, 3)} vs standar`,
|
||||
};
|
||||
}
|
||||
|
||||
if (topic === 'eef') {
|
||||
const actual = n(displayContext.eef_terakhir);
|
||||
if (actual === null) return null;
|
||||
const atHarvest = (currentDay ?? 0) >= 35;
|
||||
return {
|
||||
label: 'EEF',
|
||||
value: formatRatio(actual, 3),
|
||||
unit: undefined,
|
||||
context: atHarvest ? 'Standar umur panen: >300' : 'Belum umur panen — wajar masih rendah',
|
||||
status: (!atHarvest
|
||||
? 'unknown'
|
||||
: actual >= 300
|
||||
? 'ok'
|
||||
: actual >= 250
|
||||
? 'warning'
|
||||
: 'critical') as InsightStatus,
|
||||
deltaLabel: atHarvest ? (actual >= 300 ? 'di atas standar' : 'di bawah standar') : null,
|
||||
};
|
||||
}
|
||||
|
||||
if (topic === 'iot_panel') {
|
||||
const actual = n(displayContext.suhu_rata_rata_C);
|
||||
const setpoint = n(displayContext.setpoint_C);
|
||||
if (actual === null) return null;
|
||||
const diff = setpoint === null ? null : actual - setpoint;
|
||||
return {
|
||||
label: 'Suhu Rata-rata Kandang',
|
||||
value: actual.toLocaleString('id-ID'),
|
||||
unit: '°C',
|
||||
context: setpoint === null ? null : `Setpoint: ${setpoint.toLocaleString('id-ID')} °C`,
|
||||
status: (diff === null
|
||||
? 'unknown'
|
||||
: Math.abs(diff) <= 1
|
||||
? 'ok'
|
||||
: Math.abs(diff) <= 2.5
|
||||
? 'warning'
|
||||
: 'critical') as InsightStatus,
|
||||
deltaLabel:
|
||||
diff === null ? null : `${diff > 0 ? '+' : ''}${diff.toFixed(1)} °C vs setpoint`,
|
||||
};
|
||||
}
|
||||
|
||||
if (topic === 'hitung_karung') {
|
||||
const actual = n(displayContext.total_karung_dituang_iot);
|
||||
if (actual === null) return null;
|
||||
const manual = n(displayContext.total_karung_dituang_manual);
|
||||
const perDay = n(displayContext.rata_karung_per_hari);
|
||||
const parts = [
|
||||
manual === null ? null : `Manual: ${manual.toLocaleString('id-ID')} karung`,
|
||||
perDay === null ? null : `${perDay.toLocaleString('id-ID')} karung/hari`,
|
||||
].filter(Boolean);
|
||||
return {
|
||||
label: 'Pakan Dituang (IOT)',
|
||||
value: actual.toLocaleString('id-ID'),
|
||||
unit: 'karung',
|
||||
context: parts.length > 0 ? parts.join(' · ') : null,
|
||||
status: 'unknown' as InsightStatus,
|
||||
deltaLabel: null,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [topic, displayContext, currentDay, effectiveDay]);
|
||||
|
||||
const meta = TOPIC_META[topic] ?? TOPIC_META.dashboard;
|
||||
|
||||
const generate = useCallback(
|
||||
async (force = false) => {
|
||||
const storageKey = `page-insight-v1-${scopeKey}`;
|
||||
const requestId = ++requestIdRef.current;
|
||||
const isCurrent = () => requestIdRef.current === requestId;
|
||||
|
||||
if (force) {
|
||||
_insightMemoryCache.delete(storageKey);
|
||||
try {
|
||||
sessionStorage.removeItem(storageKey);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
if (!force) {
|
||||
const memCached = _insightMemoryCache.get(storageKey);
|
||||
if (memCached) {
|
||||
if (!isCurrent()) return;
|
||||
setResult(memCached);
|
||||
setLoading(false);
|
||||
lastCacheKeyRef.current = scopeKey;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!force && _insightInFlight.has(storageKey)) {
|
||||
await _insightInFlight.get(storageKey);
|
||||
if (!isCurrent()) return;
|
||||
const memCached = _insightMemoryCache.get(storageKey);
|
||||
if (memCached) {
|
||||
setResult(memCached);
|
||||
setLoading(false);
|
||||
lastCacheKeyRef.current = scopeKey;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!force) {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(storageKey);
|
||||
if (raw) {
|
||||
const parsedJson = JSON.parse(raw) as CachedPageInsight;
|
||||
if (parsedJson?.summary || parsedJson?.insight) {
|
||||
_insightMemoryCache.set(storageKey, parsedJson);
|
||||
if (!isCurrent()) return;
|
||||
setResult(parsedJson);
|
||||
setLoading(false);
|
||||
lastCacheKeyRef.current = scopeKey;
|
||||
return;
|
||||
}
|
||||
const parsed = parseAiResult(raw);
|
||||
if (parsed) {
|
||||
const wrapped: CachedPageInsight = { ...parsed, source: 'cache' };
|
||||
_insightMemoryCache.set(storageKey, wrapped);
|
||||
if (!isCurrent()) return;
|
||||
setResult(wrapped);
|
||||
setLoading(false);
|
||||
lastCacheKeyRef.current = scopeKey;
|
||||
return;
|
||||
}
|
||||
sessionStorage.removeItem(storageKey);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
if (inFlightRef.current && !force) return;
|
||||
inFlightRef.current = true;
|
||||
// Only show the LLM loading UI when the user clicks Generate.
|
||||
if (force) {
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
const fetchPromise = (async () => {
|
||||
try {
|
||||
if (selectedKandangId == null && !(contextData as Record<string, unknown>).kandangId) {
|
||||
throw new Error(
|
||||
'Pilih kandang terlebih dahulu. AI Insight digenerate per kandang, bukan semua kandang.'
|
||||
);
|
||||
}
|
||||
if (!selectedCycle?.id) {
|
||||
throw new Error('Data siklus tidak tersedia. Pastikan ada siklus aktif.');
|
||||
}
|
||||
|
||||
const rawCtx = contextData as Record<string, unknown>;
|
||||
const kandangId = requireKandangId({
|
||||
...rawCtx,
|
||||
kandangId: rawCtx.kandangId ?? selectedKandangId,
|
||||
});
|
||||
|
||||
const rawCtxDay =
|
||||
effectiveDay ??
|
||||
rawCtx.hari_ke ??
|
||||
rawCtx.hari_terakhir ??
|
||||
rawCtx.currentDay ??
|
||||
currentDay;
|
||||
const maxDays = (rawCtx.totalDays as number | undefined) ?? selectedCycle.total_days;
|
||||
const currentDayVal =
|
||||
typeof rawCtxDay === 'number' && maxDays && rawCtxDay > maxDays ? maxDays : rawCtxDay;
|
||||
|
||||
const slicedContextData = scopeContextToTimeframe(contextData, timeframe, effectiveDay);
|
||||
const scopedContextData = scalarScope
|
||||
? scopeScalarsToTimeframe(
|
||||
slicedContextData as Record<string, unknown>,
|
||||
timeframe,
|
||||
scalarScope,
|
||||
effectiveDay
|
||||
)
|
||||
: slicedContextData;
|
||||
|
||||
const dataPeriode = (scopedContextData as Record<string, unknown>)?.periode;
|
||||
// Prefer explicit day-0..N wording when a day is selected (matches dashboard reports).
|
||||
const timeframeLabel =
|
||||
effectiveDay !== null
|
||||
? describeTimeframe(timeframe, currentDayVal as number | null, effectiveDay)
|
||||
: !supportsTimeframe
|
||||
? 'Kondisi terkini (pembacaan sesaat, bukan rentang waktu)'
|
||||
: typeof dataPeriode === 'string' && dataPeriode.trim().length > 0
|
||||
? dataPeriode
|
||||
: describeTimeframe(timeframe, currentDayVal as number | null, effectiveDay);
|
||||
|
||||
const reportPeriod =
|
||||
timeframe === 'harian'
|
||||
? effectiveDay !== null
|
||||
? `Hari ${effectiveDay}`
|
||||
: currentDayVal
|
||||
? `Hari ${currentDayVal}`
|
||||
: null
|
||||
: null;
|
||||
|
||||
const contextPayload: InsightContext = {
|
||||
...(scopedContextData as InsightContext),
|
||||
kandangId,
|
||||
kandangName:
|
||||
(rawCtx.kandangName as string | undefined) ??
|
||||
selectedKandang?.kandang_name ??
|
||||
undefined,
|
||||
cycleId: selectedCycle.id,
|
||||
hari_ke: typeof effectiveDay === 'number' ? effectiveDay : typeof currentDayVal === 'number' ? currentDayVal : null,
|
||||
totalDays: maxDays ?? null,
|
||||
periode: timeframeLabel,
|
||||
};
|
||||
|
||||
let apiResult = null as Awaited<ReturnType<typeof api.insights.generate>> | null;
|
||||
|
||||
if (!force) {
|
||||
try {
|
||||
apiResult = await api.insights.cached({
|
||||
cycle_id: selectedCycle.id,
|
||||
kandang_id: kandangId,
|
||||
topic,
|
||||
report_type: 'page',
|
||||
report_period: reportPeriod,
|
||||
});
|
||||
if (!apiResult?.insight_text && !apiResult?.summary) apiResult = null;
|
||||
} catch {
|
||||
apiResult = null;
|
||||
}
|
||||
// Manual-generate only: never POST generate on mount / soft refresh.
|
||||
if (!apiResult) {
|
||||
if (!isCurrent()) return;
|
||||
setResult(null);
|
||||
setLoading(false);
|
||||
lastCacheKeyRef.current = scopeKey;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const generateCall = api.insights.generate({
|
||||
cycle_id: selectedCycle.id,
|
||||
kandang_id: kandangId,
|
||||
topic,
|
||||
report_type: 'page',
|
||||
report_period: reportPeriod,
|
||||
context: contextPayload,
|
||||
force_refresh: true,
|
||||
});
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
'TIMEOUT_LIMIT: generate insight melebihi batas waktu. Coba lagi nanti.'
|
||||
)
|
||||
),
|
||||
CLIENT_TIMEOUT_MS
|
||||
)
|
||||
);
|
||||
apiResult = await Promise.race([generateCall, timeoutPromise]);
|
||||
}
|
||||
|
||||
if (!isCurrent()) return;
|
||||
|
||||
if (!apiResult?.insight_text && !apiResult?.summary && !apiResult?.insight) {
|
||||
throw new Error(apiResult?.message || 'Gagal menghasilkan AI Insight.');
|
||||
}
|
||||
|
||||
const text = apiResult.insight_text ?? '';
|
||||
const parsed =
|
||||
apiResult.summary || apiResult.insight
|
||||
? {
|
||||
summary: apiResult.summary || '',
|
||||
insight: apiResult.insight || '',
|
||||
}
|
||||
: parseAiResult(text);
|
||||
|
||||
if (!parsed || (!parsed.summary && !parsed.insight)) {
|
||||
throw new Error('Respons AI tidak dapat diproses.');
|
||||
}
|
||||
|
||||
const wrapped: CachedPageInsight = {
|
||||
summary: parsed.summary,
|
||||
insight: parsed.insight,
|
||||
source: apiResult.source ?? (force ? 'generated' : 'cache'),
|
||||
citations: apiResult.citations,
|
||||
};
|
||||
|
||||
_saveToCache(storageKey, wrapped);
|
||||
if (!isCurrent()) return;
|
||||
setResult(wrapped);
|
||||
lastCacheKeyRef.current = scopeKey;
|
||||
} catch (err) {
|
||||
if (!isCurrent()) return;
|
||||
console.error(`[PageAiInsight:${topic}] request failed:`, err);
|
||||
// Soft cache misses/errors should not look like a failed generate.
|
||||
if (force) {
|
||||
setError(errorMessage(err));
|
||||
setResult(null);
|
||||
} else {
|
||||
setResult(null);
|
||||
}
|
||||
} finally {
|
||||
if (isCurrent()) {
|
||||
setLoading(false);
|
||||
inFlightRef.current = false;
|
||||
}
|
||||
_insightInFlight.delete(storageKey);
|
||||
}
|
||||
})();
|
||||
|
||||
_insightInFlight.set(storageKey, fetchPromise);
|
||||
await fetchPromise;
|
||||
},
|
||||
[
|
||||
topic,
|
||||
contextData,
|
||||
selectedCycle,
|
||||
selectedKandang,
|
||||
selectedKandangId,
|
||||
currentDay,
|
||||
timeframe,
|
||||
effectiveDay,
|
||||
scalarScope,
|
||||
supportsTimeframe,
|
||||
scopeKey,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
desiredKeyRef.current = scopeKey;
|
||||
generateRef.current = generate;
|
||||
});
|
||||
|
||||
// Day / scope change: drop previous insight immediately and only soft-load cache (no generate UI).
|
||||
useEffect(() => {
|
||||
if (lastCacheKeyRef.current === scopeKey) return;
|
||||
requestIdRef.current += 1;
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
inFlightRef.current = false;
|
||||
const timer = setTimeout(() => generateRef.current(false), 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, [scopeKey]);
|
||||
|
||||
const sourceMeta = result ? SOURCE_LABEL[result.source] : null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-100 bg-white shadow-sm overflow-hidden mb-6">
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-gray-100 bg-gray-50/80">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="p-1.5 bg-red-50 rounded-lg">
|
||||
<i className={`fa-solid ${meta.icon} text-red-600 text-sm`} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-bold text-gray-900">{meta.title}</span>
|
||||
<p className="text-xs text-gray-400 truncate">
|
||||
{selectedKandang?.kandang_name ?? 'Kandang'}
|
||||
{selectedSite?.site_name ? ` · ${selectedSite.site_name}` : ''}
|
||||
{effectiveDay != null ? ` · Hari ke-${effectiveDay}` : currentDay != null ? ` · Hari ke-${currentDay}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{supportsTimeframe && timeframe === 'harian' && availableDays.length > 1 && (
|
||||
<select
|
||||
value={effectiveDay ?? availableDays[availableDays.length - 1]}
|
||||
onChange={(e) => setSelectedDay(parseInt(e.target.value, 10))}
|
||||
className="text-[11px] font-semibold border border-gray-200 bg-white text-gray-700 rounded-lg px-2 py-1 shadow-sm"
|
||||
title="Pilih hari siklus"
|
||||
>
|
||||
{availableDays.map((d) => (
|
||||
<option key={d} value={d}>
|
||||
Hari {d}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{sourceMeta && (
|
||||
<span
|
||||
className={`hidden sm:inline-flex rounded-full border px-2 py-0.5 text-[10px] font-semibold ${sourceMeta.cls}`}
|
||||
>
|
||||
{sourceMeta.text}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => generate(true)}
|
||||
disabled={loading || selectedKandangId == null}
|
||||
title="Refresh insight"
|
||||
className="p-1.5 rounded-lg border border-gray-200 bg-white hover:bg-gray-50 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<i
|
||||
className={`fa-solid fa-rotate-right text-gray-600 text-sm ${loading ? 'fa-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{selectedKandangId == null && (
|
||||
<div className="flex items-start gap-2 bg-amber-50 border border-amber-100 rounded-xl p-3">
|
||||
<i className="fa-solid fa-triangle-exclamation text-amber-500 mt-0.5" />
|
||||
<p className="text-xs text-amber-800">
|
||||
Pilih satu kandang untuk menghasilkan AI Insight. Generate untuk semua kandang tidak
|
||||
diizinkan.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && !result && (
|
||||
<div className="flex flex-col items-center gap-3 py-8 justify-center">
|
||||
<div className="w-8 h-8 rounded-full border-4 border-gray-100 border-t-red-600 animate-spin" />
|
||||
<p className="text-sm text-gray-500 text-center max-w-sm">
|
||||
Menganalisis data dengan model AI… proses ini bisa memakan waktu beberapa menit.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !result && (
|
||||
<div className="flex items-start gap-2 bg-red-50 border border-red-100 rounded-xl p-3">
|
||||
<i className="fa-solid fa-triangle-exclamation text-red-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-red-600">Gagal memuat insight</p>
|
||||
<p className="text-xs text-red-500 mt-0.5">{error}</p>
|
||||
<button
|
||||
onClick={() => generate(true)}
|
||||
className="text-xs text-red-600 underline mt-1 hover:text-red-800"
|
||||
>
|
||||
Coba lagi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && !result && selectedKandangId != null && (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center">
|
||||
<i className="fa-solid fa-brain text-gray-300 text-2xl" />
|
||||
<p className="text-sm text-gray-500">Belum ada AI Insight untuk periode ini.</p>
|
||||
<p className="text-xs text-gray-400">Klik tombol refresh untuk generate insight.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => generate(true)}
|
||||
className="mt-2 inline-flex items-center gap-2 rounded-lg bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-700"
|
||||
>
|
||||
<i className="fa-solid fa-wand-magic-sparkles" />
|
||||
Generate Insight
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="space-y-4">
|
||||
{heroSpec && (
|
||||
<HeroFigure
|
||||
label={heroSpec.label}
|
||||
value={heroSpec.value}
|
||||
unit={heroSpec.unit}
|
||||
context={heroSpec.context}
|
||||
status={heroSpec.status}
|
||||
deltaLabel={heroSpec.deltaLabel}
|
||||
/>
|
||||
)}
|
||||
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5">
|
||||
{periodeLabel && (
|
||||
<p className="mb-3 text-xs font-medium text-gray-500">
|
||||
Angka di bawah dihitung dari periode{' '}
|
||||
<span className="text-gray-700">{periodeLabel}</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-3 gap-x-6">
|
||||
{topic === 'fcr' && (
|
||||
<>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">
|
||||
Konsumsi Pakan Kumulatif
|
||||
</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.pakan_karung_terakhir, 'Karung')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Rata-rata Bobot</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.bobot_iot_gram_terakhir, 'g')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Populasi Hidup</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.ayam_hidup_ekor, 'Ekor')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Total Bobot Panen</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.beratPanen_kg, 'kg')}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{topic === 'berat_ayam' && (
|
||||
<>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Target Bobot</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.targetWeight, 'g')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Uniformity</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.uniformity, '%')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">
|
||||
Average Daily Gain (ADG)
|
||||
</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.adg, 'g/hari')}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{topic === 'eef' && (
|
||||
<>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">EEF Aktual</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{typeof displayContext.eef_terakhir === 'number'
|
||||
? formatRatio(displayContext.eef_terakhir, 3)
|
||||
: renderValue(displayContext.eef_terakhir)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">FCR Aktual</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{typeof displayContext.fcr_terakhir === 'number'
|
||||
? formatRatio(displayContext.fcr_terakhir, 3)
|
||||
: renderValue(displayContext.fcr_terakhir)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Persentase Hidup</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.persen_hidup, '%')}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{topic === 'iot_panel' && (
|
||||
<>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Kelembapan</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.kelembapan_persen, '%')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Kecepatan Angin</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.kecepatan_angin_mPerDetik, 'm/s')}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{topic === 'hitung_karung' && (
|
||||
<>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Saldo Awal Pakan</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.saldo_awal_karung, 'Karung')}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{chartSpec && (
|
||||
<div className="mt-6 pt-4 border-t border-gray-100">
|
||||
{chartSpec.type === 'trend' ? (
|
||||
<InsightTrendChart
|
||||
rows={trendChartRows}
|
||||
series={chartSpec.series}
|
||||
unit={chartSpec.unit}
|
||||
title={chartSpec.title}
|
||||
highlight={chartHighlight}
|
||||
gap={chartSpec.gap ?? null}
|
||||
/>
|
||||
) : (
|
||||
<InsightConditionChart bars={chartSpec.bars} title={chartSpec.title} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-gray-100">
|
||||
<h4 className="text-sm font-bold text-gray-800 mb-2 flex items-center gap-2">
|
||||
<i className="fa-solid fa-circle-check text-green-600" /> Kesimpulan
|
||||
</h4>
|
||||
<p className="text-sm text-gray-700 bg-gray-50 p-3 rounded-lg border border-gray-100">
|
||||
{result.summary}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-bold text-gray-800 mb-2 flex items-center gap-2">
|
||||
<i className="fa-solid fa-brain text-red-600" /> AI Insight
|
||||
</h4>
|
||||
<p className="text-sm text-gray-700 bg-gray-50 p-3 rounded-lg border border-gray-100 whitespace-pre-wrap">
|
||||
{result.insight}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CitationsBlock citations={result.citations} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageAiInsight;
|
||||
Reference in new issue
Block a user