Files
dashboard-cpsp/components/shared/PageAiInsight.tsx
T
2026-09-29 15:19:10 +07:00

1024 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Per-page / per-topic AI Insight shell (FCR, weight, counting, IoT, …).
*
* Builds/scopes context, then loads the insight from the DB on mount
* (`GET /ai-insights/cached/`). LLM generate runs only when the user clicks
* Generate (force=true). No client memory/sessionStorage for insight text —
* the DB row is the source of truth.
* Distinct from `AiInsightCard` (Dashboard report modes).
*/
import React, { useState, useEffect, useRef } from 'react';
import { useFarm } from '../../context/FarmContext.tsx';
import { api, errorMessage } from '../../services/apiClient.ts';
import type {
InsightApiResponse,
InsightCitation,
InsightContext,
InsightJobStage,
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';
import InsightProgressSteps from './InsightProgressSteps.tsx';
const CLIENT_TIMEOUT_MS = 1_200_000; // 20 minutes — NUC LLM can take several minutes
/** Phase 4: poll async generate job until done / error / timeout / cancel. */
async function pollInsightJob(
jobId: string,
isCurrent: () => boolean,
setStage: (s: InsightJobStage | null) => void
): Promise<InsightApiResponse | null> {
const deadline = Date.now() + CLIENT_TIMEOUT_MS;
for (;;) {
if (!isCurrent()) return null;
const job = await api.insights.jobStatus(jobId);
if (!isCurrent()) return null;
setStage(job.stage);
if (job.status === 'done') return job.result;
if (job.status === 'error') throw new Error(job.error || 'Gagal menghasilkan AI Insight.');
if (Date.now() > deadline) {
throw new Error('TIMEOUT_LIMIT: generate insight melebihi batas waktu. Coba lagi nanti.');
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
type PageInsightResult = AiResult & {
citations?: InsightCitation[];
};
/** Dedupes concurrent soft DB loads for the same scope (not a content cache). */
const _insightInFlight = new Map<string, Promise<PageInsightResult | null>>();
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 renderValue = (val: unknown, suffix: string = ''): string => {
if (val === undefined || val === null || val === '') return 'N/A';
if (val === UNAVAILABLE) return UNAVAILABLE;
return `${val} ${suffix}`.trim();
};
/** Format a day's min–max range for IoT panel insight metrics (no rounding). */
const renderDayRange = (minVal: unknown, maxVal: unknown, suffix: string): string => {
const lo = typeof minVal === 'number' && Number.isFinite(minVal) ? minVal : null;
const hi = typeof maxVal === 'number' && Number.isFinite(maxVal) ? maxVal : null;
// Keep full precision — do not toFixed / cap fraction digits.
const fmt = (n: number) =>
n.toLocaleString('id-ID', { maximumFractionDigits: 20, useGrouping: false });
if (lo === null && hi === null) return 'N/A';
if (lo === null) return `${fmt(hi!)} ${suffix}`.trim();
if (hi === null) return `${fmt(lo)} ${suffix}`.trim();
if (lo === hi) return `${fmt(lo)} ${suffix}`.trim();
return `${fmt(lo)} – ${fmt(hi)} ${suffix}`.trim();
};
/** Match FeedSaldoCard: masuk IOT + saldo awal. */
const feedMasukTotal = (ctx: Record<string, unknown>): number | null => {
const masuk =
typeof ctx.total_karung_masuk_iot === 'number' && Number.isFinite(ctx.total_karung_masuk_iot)
? ctx.total_karung_masuk_iot
: null;
const saldo =
typeof ctx.saldo_awal_karung === 'number' && Number.isFinite(ctx.saldo_awal_karung)
? ctx.saldo_awal_karung
: null;
if (masuk === null && saldo === null) return null;
return (masuk ?? 0) + (saldo ?? 0);
};
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 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<PageInsightResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [stage, setStage] = useState<InsightJobStage | 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 = (next: number | null) => {
setSelectedDayState(next);
try {
if (next === null) sessionStorage.removeItem(selectedDayKey);
else sessionStorage.setItem(selectedDayKey, String(next));
} catch {
/* ignore */
}
};
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 minTemp = n(displayContext.suhu_min_C);
const maxTemp = n(displayContext.suhu_max_C);
const lastTemp = n(displayContext.suhu_rata_rata_C);
const setpoint = n(displayContext.setpoint_C);
if (minTemp === null && maxTemp === null && lastTemp === null) return null;
const lo = minTemp ?? lastTemp!;
const hi = maxTemp ?? lastTemp!;
// Grade against the farther extreme from setpoint so a bad stretch that day surfaces.
const worstDiff =
setpoint === null ? null : Math.max(Math.abs(lo - setpoint), Math.abs(hi - setpoint));
const signedWorst =
setpoint === null
? null
: Math.abs(lo - setpoint) >= Math.abs(hi - setpoint)
? lo - setpoint
: hi - setpoint;
return {
label: 'Suhu Rata-rata Kandang',
value: renderDayRange(lo, hi, ''),
unit: '°C',
context: setpoint === null ? null : `Setpoint: ${setpoint.toLocaleString('id-ID')} °C`,
status: (worstDiff === null
? 'unknown'
: worstDiff <= 1
? 'ok'
: worstDiff <= 2.5
? 'warning'
: 'critical') as InsightStatus,
deltaLabel:
signedWorst === null
? null
: `${signedWorst > 0 ? '+' : ''}${signedWorst} °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 masukTotal = feedMasukTotal(displayContext);
const parts = [
masukTotal === null ? null : `Masuk: ${masukTotal.toLocaleString('id-ID')} karung`,
manual === null ? null : `Manual dituang: ${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,
};
}
if (topic === 'hitung_ayam') {
const actual = n(displayContext.populasi_kini_ekor);
if (actual === null) return null;
const awal = n(displayContext.populasi_awal_ekor);
const mortPct = n(displayContext.mortalitas_persen);
return {
label: 'Populasi Kini',
value: Math.round(actual).toLocaleString('id-ID'),
unit: 'ekor',
context:
awal === null ? null : `Populasi awal: ${Math.round(awal).toLocaleString('id-ID')} ekor`,
status: (mortPct === null
? 'unknown'
: mortPct < 5
? 'ok'
: mortPct <= 7
? 'warning'
: 'critical') as InsightStatus,
deltaLabel:
mortPct === null
? null
: `Mortalitas ${mortPct.toLocaleString('id-ID', { maximumFractionDigits: 2 })}%`,
};
}
return null;
}, [topic, displayContext, currentDay, effectiveDay]);
const meta = TOPIC_META[topic] ?? TOPIC_META.dashboard;
const generate = async (force = false) => {
const storageKey = `page-insight-v1-${scopeKey}`;
const requestId = ++requestIdRef.current;
const isCurrent = () => requestIdRef.current === requestId;
// Soft load: share one in-flight DB GET for the same scope (no client content cache).
if (!force && _insightInFlight.has(storageKey)) {
const shared = await _insightInFlight.get(storageKey);
if (!isCurrent()) return;
setResult(shared ?? null);
setLoading(false);
lastCacheKeyRef.current = scopeKey;
return;
}
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 (): Promise<PageInsightResult | null> => {
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 null;
setResult(null);
setLoading(false);
lastCacheKeyRef.current = scopeKey;
return null;
}
} else {
setStage(null);
const job = await api.insights.generateAsync({
cycle_id: selectedCycle.id,
kandang_id: kandangId,
topic,
report_type: 'page',
report_period: reportPeriod,
context: contextPayload,
force_refresh: true,
});
apiResult = await pollInsightJob(job.job_id, isCurrent, setStage);
}
if (!isCurrent()) return null;
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: PageInsightResult = {
summary: parsed.summary,
insight: parsed.insight,
citations: apiResult.citations,
};
if (!isCurrent()) return wrapped;
setResult(wrapped);
lastCacheKeyRef.current = scopeKey;
return wrapped;
} catch (err) {
if (!isCurrent()) return null;
console.error(`[PageAiInsight:${topic}] request failed:`, err);
// Soft DB misses/errors should not look like a failed generate.
if (force) {
setError(errorMessage(err));
setResult(null);
} else {
setResult(null);
}
return null;
} finally {
if (isCurrent()) {
setLoading(false);
inFlightRef.current = false;
}
_insightInFlight.delete(storageKey);
}
})();
if (!force) {
_insightInFlight.set(storageKey, fetchPromise);
}
await fetchPromise;
};
useEffect(() => {
desiredKeyRef.current = scopeKey;
generateRef.current = generate;
});
// Day / scope change: clear UI and soft-load from DB (no generate).
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]);
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>
)}
<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" />
<InsightProgressSteps stage={stage} />
<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">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">
{typeof displayContext.persen_hidup === 'number'
? `${displayContext.persen_hidup.toLocaleString('id-ID', {
maximumFractionDigits: 2,
})} %`
: renderValue(displayContext.persen_hidup, '%')}
</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">BW Rata-rata Panen</span>
<span className="text-sm font-bold text-gray-900">
{typeof displayContext.bw_panen_gram === 'number'
? `${displayContext.bw_panen_gram.toLocaleString('id-ID', {
maximumFractionDigits: 2,
})} g`
: renderValue(displayContext.bw_panen_gram, '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">
Umur Rata-rata Panen
</span>
<span className="text-sm font-bold text-gray-900">
{typeof displayContext.umur_panen_rata_hari === 'number'
? `${displayContext.umur_panen_rata_hari.toLocaleString('id-ID', {
maximumFractionDigits: 2,
})} hari`
: renderValue(displayContext.umur_panen_rata_hari, 'hari')}
</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">Experience Suhu</span>
<span className="text-sm font-bold text-gray-900">
{renderDayRange(
displayContext.experience_suhu_min_C ?? displayContext.experience_suhu_C,
displayContext.experience_suhu_max_C ?? displayContext.experience_suhu_C,
'°C'
)}
</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">Kelembapan</span>
<span className="text-sm font-bold text-gray-900">
{renderDayRange(
displayContext.kelembapan_min_persen ?? displayContext.kelembapan_persen,
displayContext.kelembapan_max_persen ?? 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">
{renderDayRange(
displayContext.kecepatan_angin_min_mPerDetik ??
displayContext.kecepatan_angin_mPerDetik,
displayContext.kecepatan_angin_max_mPerDetik ??
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">Total Karung Masuk</span>
<span className="text-sm font-bold text-gray-900">
{renderValue(feedMasukTotal(displayContext), 'Karung')}
</span>
</div>
</>
)}
{topic === 'hitung_ayam' && (
<>
<div className="flex justify-between items-center py-1 border-b border-gray-50">
<span className="text-sm text-gray-600 font-medium">Populasi Awal</span>
<span className="text-sm font-bold text-gray-900">
{renderValue(displayContext.populasi_awal_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">Populasi Kini</span>
<span className="text-sm font-bold text-gray-900">
{renderValue(displayContext.populasi_kini_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">
Mortalitas Kumulatif
</span>
<span className="text-sm font-bold text-gray-900">
{renderValue(displayContext.mortalitas_kumulatif_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">Mortalitas</span>
<span className="text-sm font-bold text-gray-900">
{typeof displayContext.mortalitas_persen === 'number'
? `${displayContext.mortalitas_persen.toLocaleString('id-ID', {
maximumFractionDigits: 2,
})} %`
: renderValue(displayContext.mortalitas_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">Panen Kumulatif</span>
<span className="text-sm font-bold text-gray-900">
{renderValue(displayContext.panen_kumulatif_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">Berat Panen</span>
<span className="text-sm font-bold text-gray-900">
{renderValue(displayContext.berat_panen_kumulatif_kg, 'kg')}
</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;