update applying current ai insight from dashboard-cpsp

This commit is contained in:
Alberto-Audrix committed 2026-10-05 14:25:22 +07:00
1 parent 31272035e9
commit b87f20e3b3
27 files changed
+5300 -343

No files matched your search

+105
View File
@@ -0,0 +1,105 @@
import React from 'react';
export interface ConditionBar {
label: string;
actual: number | null;
standard: number | null;
unit: string;
/** Optional note shown under the bar, e.g. why a standard is absent. */
note?: string;
}
export interface InsightConditionChartProps {
bars: ConditionBar[];
title?: string;
emptyLabel?: string;
}
const AKTUAL = '#dc2626';
const STANDAR = '#3b82f6';
const fmt = (value: number, unit: string) =>
`${Number(value).toLocaleString('id-ID', { maximumFractionDigits: 1 })}${unit ? ` ${unit}` : ''}`;
/**
* Current readings against their CP 707 standard, one row per metric.
* Used when there is no per-day history (e.g. live IoT snapshot).
*/
const InsightConditionChart: React.FC<InsightConditionChartProps> = ({
bars,
title,
emptyLabel = 'Belum ada pembacaan sensor untuk digambarkan.',
}) => {
const usable = bars.filter((bar) => bar.actual !== null || bar.standard !== null);
if (usable.length === 0) {
return (
<div className="rounded-xl border border-gray-100 bg-gray-50/60 p-4 text-center text-sm text-gray-500">
{emptyLabel}
</div>
);
}
return (
<div className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
{title && <h4 className="mb-1 text-sm font-bold text-gray-800">{title}</h4>}
<div className="mb-3 flex items-center gap-4 text-xs text-gray-500">
<span className="flex items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: AKTUAL }} />
Aktual
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: STANDAR }} />
Standar CP 707
</span>
</div>
<div className="space-y-3">
{usable.map((bar) => {
const max = Math.max(bar.actual ?? 0, bar.standard ?? 0, 1);
const pct = (value: number | null) =>
value === null ? 0 : Math.max(2, Math.round((value / max) * 100));
return (
<div key={bar.label}>
<div className="mb-1 flex items-baseline justify-between">
<span className="text-xs font-medium text-gray-600">{bar.label}</span>
<span className="text-xs text-gray-500">
{bar.actual === null ? 'data tidak tersedia' : fmt(bar.actual, bar.unit)}
{bar.standard !== null && (
<span className="text-gray-400"> · standar {fmt(bar.standard, bar.unit)}</span>
)}
</span>
</div>
<div className="space-y-1">
<div className="h-2.5 w-full rounded-sm bg-gray-100">
{bar.actual !== null && (
<div
className="h-full rounded-sm"
style={{ width: `${pct(bar.actual)}%`, background: AKTUAL }}
title={`Aktual ${fmt(bar.actual, bar.unit)}`}
/>
)}
</div>
<div className="h-2.5 w-full rounded-sm bg-gray-100">
{bar.standard !== null && (
<div
className="h-full rounded-sm"
style={{ width: `${pct(bar.standard)}%`, background: STANDAR }}
title={`Standar ${fmt(bar.standard, bar.unit)}`}
/>
)}
</div>
</div>
{bar.note && <p className="mt-1 text-[11px] text-gray-400">{bar.note}</p>}
</div>
);
})}
</div>
</div>
);
};
export default InsightConditionChart;
@@ -0,0 +1,49 @@
import type { InsightJobStage } from '../../types/api.ts';
/** Phase 4 pipeline step indicator for async AI Insight generation. */
const STEPS: { stage: InsightJobStage; label: string; icon: string }[] = [
{ stage: 'grading', label: 'Evaluating CP 707 rules...', icon: 'fa-solid fa-scale-balanced' },
{ stage: 'retrieving', label: 'Fetching SOP guidelines...', icon: 'fa-solid fa-book-open' },
{
stage: 'synthesizing',
label: 'Synthesizing recommendations...',
icon: 'fa-solid fa-wand-magic-sparkles',
},
{ stage: 'saving', label: 'Saving insight...', icon: 'fa-solid fa-floppy-disk' },
];
const ORDER: InsightJobStage[] = ['queued', 'starting', ...STEPS.map((s) => s.stage), 'done'];
function stageIndex(stage: InsightJobStage | null): number {
if (!stage) return 0;
const i = ORDER.indexOf(stage);
return i < 0 ? 0 : i;
}
export default function InsightProgressSteps({ stage }: { stage: InsightJobStage | null }) {
const current = stageIndex(stage);
return (
<ol className="space-y-1.5 text-xs text-gray-600" aria-live="polite">
{STEPS.map((step, i) => {
const state = current > i ? 'done' : current === i ? 'active' : 'pending';
return (
<li
key={step.stage}
className={`flex items-center gap-2 ${state === 'pending' ? 'opacity-40' : ''}`}
>
<i
className={`${step.icon} ${
state === 'done' ? 'text-green-600' : 'text-blue-600'
} ${state === 'active' ? 'fa-spin' : ''}`}
aria-hidden="true"
/>
<span>{step.label}</span>
{state === 'done' && (
<i className="fa-solid fa-check text-green-600" aria-hidden="true" />
)}
</li>
);
})}
</ol>
);
}
+169
View File
@@ -0,0 +1,169 @@
import React from 'react';
/**
* Headline figures for AI Insight cards.
* Matches NEW dashboard gray card patterns (white + border-gray-100).
*/
export type InsightStatus = 'ok' | 'warning' | 'critical' | 'unknown';
const STATUS_CHIP: Record<InsightStatus, { cls: string; glyph: string }> = {
ok: { cls: 'bg-green-50 text-green-800 border-green-200', glyph: '✓' },
warning: { cls: 'bg-amber-50 text-amber-800 border-amber-200', glyph: '!' },
critical: { cls: 'bg-red-50 text-red-800 border-red-200', glyph: '⚠' },
unknown: { cls: 'bg-gray-50 text-gray-500 border-gray-200', glyph: '–' },
};
interface DeltaChipProps {
status: InsightStatus;
label: string;
}
export const DeltaChip: React.FC<DeltaChipProps> = ({ status, label }) => {
const chip = STATUS_CHIP[status];
return (
<span
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-semibold ${chip.cls}`}
>
<span aria-hidden="true">{chip.glyph}</span>
{label}
</span>
);
};
/** Axis-less sparkline — shape only; the tile number carries the value. */
export const Sparkline: React.FC<{ values: number[]; className?: string }> = ({
values,
className,
}) => {
const clean = values.filter((v) => typeof v === 'number' && Number.isFinite(v));
if (clean.length < 2) return null;
const width = 88;
const height = 28;
const min = Math.min(...clean);
const max = Math.max(...clean);
const span = max - min || 1;
const step = width / (clean.length - 1);
const points = clean
.map((value, index) => {
const x = index * step;
const y = height - 2 - ((value - min) / span) * (height - 4);
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(' ');
return (
<svg
viewBox={`0 0 ${width} ${height}`}
className={className}
width={width}
height={height}
role="presentation"
aria-hidden="true"
preserveAspectRatio="none"
>
<polyline
points={points}
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};
interface HeroFigureProps {
label: string;
value: string;
unit?: string;
context?: string | null;
status?: InsightStatus;
deltaLabel?: string | null;
sparkline?: number[];
}
export const HeroFigure: React.FC<HeroFigureProps> = ({
label,
value,
unit,
context,
status = 'unknown',
deltaLabel,
sparkline,
}) => (
<div className="flex items-end justify-between gap-4 rounded-xl border border-gray-100 bg-white px-5 py-4 shadow-sm">
<div className="min-w-0">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500">{label}</div>
<div className="mt-1 flex items-baseline gap-1.5 pb-0.5">
<span className="text-5xl font-bold leading-[1.15] tracking-tight text-gray-900">
{value}
</span>
{unit ? <span className="text-sm font-medium text-gray-500">{unit}</span> : null}
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
{deltaLabel ? <DeltaChip status={status} label={deltaLabel} /> : null}
{context ? <span className="text-xs text-gray-500">{context}</span> : null}
</div>
</div>
{sparkline && sparkline.length > 1 ? (
<Sparkline values={sparkline} className="shrink-0 text-red-400" />
) : null}
</div>
);
interface StatTileProps {
label: string;
value: string;
unit?: string;
hint?: string | null;
status?: InsightStatus;
deltaLabel?: string | null;
sparkline?: number[];
}
export const StatTile: React.FC<StatTileProps> = ({
label,
value,
unit,
hint,
status = 'unknown',
deltaLabel,
sparkline,
}) => (
<div className="rounded-xl border border-gray-100 bg-white px-4 py-3 shadow-sm">
<div className="text-xs font-medium text-gray-500">
{label}
{hint ? <span className="ml-1 text-[11px] text-gray-400">{hint}</span> : null}
</div>
<div className="mt-1 flex items-baseline gap-1">
<span className="text-xl font-bold leading-[1.3] text-gray-900">{value}</span>
{unit ? <span className="text-xs font-medium text-gray-500">{unit}</span> : null}
</div>
{deltaLabel ? (
<div className="mt-1.5">
<DeltaChip status={status} label={deltaLabel} />
</div>
) : null}
{sparkline && sparkline.length > 1 ? (
<Sparkline values={sparkline} className="mt-2 text-gray-300" />
) : null}
</div>
);
/** Secondary figures: subordinate to hero / tiles. */
export const MutedStatRow: React.FC<{ label: string; hint?: string | null; value: string }> = ({
label,
hint,
value,
}) => (
<div className="flex items-center justify-between border-b border-gray-50 py-1.5">
<span className="text-sm text-gray-600">
{label}
{hint ? <span className="ml-1 text-xs text-gray-400">{hint}</span> : null}
</span>
<span className="text-sm font-semibold text-gray-900">{value}</span>
</div>
);
+230
View File
@@ -0,0 +1,230 @@
import React from 'react';
import {
Area,
CartesianGrid,
ComposedChart,
Legend,
Line,
ReferenceArea,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
/** One plotted line: which field in the row, how to name it, what colour. */
export interface InsightSeries {
field: string;
label: string;
color: string;
/** Draw as a dashed reference line — for a target or standard, not a measurement. */
dashed?: boolean;
}
/**
* Shade the distance between a measurement and the standard it is judged against.
*
* Recharts has no between-two-series area, so the band is two stacked areas: a
* transparent base up to the lower of the pair, then the gap itself.
*/
export interface InsightGapSpec {
actualField: string;
targetField: string;
}
export interface InsightTrendChartProps {
/** FULL history — the analysed window is highlighted, not isolated. */
rows: Array<Record<string, unknown>>;
series: InsightSeries[];
/** Inclusive day range the insight text actually covers. */
highlight?: { firstDay: number; lastDay: number } | null;
unit?: string;
title?: string;
emptyLabel?: string;
gap?: InsightGapSpec | null;
}
const dayOf = (row: Record<string, unknown>): number | null => {
for (const key of ['hari', 'day']) {
const value = row[key];
if (typeof value === 'number' && Number.isFinite(value)) return value;
}
return null;
};
const numOrNull = (value: unknown) =>
typeof value === 'number' && Number.isFinite(value) ? value : null;
/**
* Trend behind an insight, with the analysed period marked.
*/
const InsightTrendChart: React.FC<InsightTrendChartProps> = ({
rows,
series,
highlight,
unit,
title,
emptyLabel = 'Belum ada data untuk digambarkan.',
gap = null,
}) => {
const data = (Array.isArray(rows) ? rows : [])
.map((row) => {
const day = dayOf(row);
if (day === null) return null;
const point: Record<string, number | null> = { hari: day };
for (const s of series) point[s.field] = numOrNull(row[s.field]);
if (gap) {
const actual = numOrNull(row[gap.actualField]);
const target = numOrNull(row[gap.targetField]);
point.bandBase = actual !== null && target !== null ? Math.min(actual, target) : null;
point.bandSize = actual !== null && target !== null ? Math.abs(actual - target) : null;
}
return point;
})
.filter((point): point is Record<string, number | null> & { hari: number } => point !== null)
.sort((a, b) => a.hari - b.hari);
const drawn = series.filter((s) => data.some((point) => point[s.field] !== null));
if (data.length === 0 || drawn.length === 0) {
return (
<div className="rounded-xl border border-gray-100 bg-gray-50/60 p-4 text-center text-sm text-gray-500">
{emptyLabel}
</div>
);
}
const days = data.map((point) => point.hari);
const step = Math.max(1, Math.ceil(days.length / 10));
const ticks = days.filter((_, i) => i % step === 0 || i === days.length - 1);
const band =
highlight && Number.isFinite(highlight.firstDay) && Number.isFinite(highlight.lastDay)
? highlight
: null;
const bandFrom = band
? band.firstDay === band.lastDay
? band.firstDay - 0.4
: band.firstDay
: 0;
const bandTo = band ? (band.firstDay === band.lastDay ? band.lastDay + 0.4 : band.lastDay) : 0;
const gapFill = (() => {
if (!gap) return null;
const last = [...data]
.reverse()
.find((point) => point[gap.actualField] !== null && point[gap.targetField] !== null);
const actual = last?.[gap.actualField];
const target = last?.[gap.targetField];
if (typeof actual !== 'number' || typeof target !== 'number' || target === 0) {
return 'rgba(107, 114, 128, 0.12)';
}
const deviation = ((actual - target) / target) * 100;
if (deviation >= -5) return 'rgba(12, 163, 12, 0.12)';
if (deviation >= -15) return 'rgba(250, 178, 25, 0.18)';
return 'rgba(208, 59, 59, 0.14)';
})();
return (
<div className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
{title && <h4 className="mb-1 text-sm font-bold text-gray-800">{title}</h4>}
{band && (
<p className="mb-3 text-xs text-gray-500">
Area berbayang = periode yang dibahas insight
{band.firstDay === band.lastDay
? ` (hari ke-${band.lastDay})`
: ` (hari ke-${band.firstDay} s/d ke-${band.lastDay})`}
</p>
)}
<ResponsiveContainer width="100%" height={230}>
<ComposedChart data={data} margin={{ top: 8, right: 12, bottom: 4, left: 0 }}>
<CartesianGrid stroke="#e5e7eb" vertical={false} />
{band && (
<ReferenceArea
x1={bandFrom}
x2={bandTo}
fill="#6b7280"
fillOpacity={0.12}
stroke="#6b7280"
strokeOpacity={0.25}
/>
)}
<XAxis
dataKey="hari"
type="number"
domain={[(days[0] ?? 0) - 0.5, (days[days.length - 1] ?? 0) + 0.5]}
ticks={ticks}
allowDecimals={false}
tick={{ fontSize: 11, fill: '#6b7280' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
tickFormatter={(value) => `H${value}`}
/>
<YAxis
tick={{ fontSize: 11, fill: '#6b7280' }}
tickLine={false}
axisLine={false}
width={52}
tickFormatter={(value) => Number(value).toLocaleString('id-ID')}
/>
<Tooltip
contentStyle={{
fontSize: 12,
borderRadius: 8,
border: '1px solid #d1d5db',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
}}
labelFormatter={(value) => `Hari ke-${value}`}
formatter={(value, name) => {
const label = String(name ?? '');
if (label === 'bandBase' || label === 'bandSize') return null;
if (value == null || Array.isArray(value)) return null;
return [`${Number(value).toLocaleString('id-ID')}${unit ? ` ${unit}` : ''}`, label];
}}
/>
{drawn.length > 1 && <Legend wrapperStyle={{ fontSize: 12 }} iconType="plainline" />}
{gap && gapFill && (
<>
<Area
dataKey="bandBase"
stackId="gap"
stroke="none"
fill="none"
legendType="none"
isAnimationActive={false}
connectNulls
/>
<Area
dataKey="bandSize"
stackId="gap"
stroke="none"
fill={gapFill}
fillOpacity={1}
legendType="none"
isAnimationActive={false}
connectNulls
/>
</>
)}
{drawn.map((s) => (
<Line
key={s.field}
type="monotone"
dataKey={s.field}
name={s.label}
stroke={s.color}
strokeWidth={2}
strokeDasharray={s.dashed ? '5 4' : undefined}
dot={!s.dashed && data.length <= 20 ? { r: 3, strokeWidth: 0, fill: s.color } : false}
activeDot={{ r: 5, strokeWidth: 2, stroke: '#ffffff' }}
connectNulls
isAnimationActive={false}
/>
))}
</ComposedChart>
</ResponsiveContainer>
</div>
);
};
export default InsightTrendChart;
+960
View File
@@ -0,0 +1,960 @@
/**
* 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 {
InsightCitation,
InsightContext,
InsightTopic,
} from '../../types/api.ts';
import { formatNumber, formatRatio } from '../../utils/format.ts';
import { getCobbStandardForDay } from '../../utils/insightHelpers.ts';
import { parseAiResult, type AiResult } from '../../utils/insightParse.ts';
import {
scopeContextToTimeframe,
scopeScalarsToTimeframe,
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';
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 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 contextDay: number | null = React.useMemo(() => {
const raw = contextData as Record<string, unknown> | null | undefined;
if (!raw) return null;
for (const key of ['hari_ke', 'hari', 'day', 'hari_terakhir']) {
const val = raw[key];
if (typeof val === 'number' && Number.isFinite(val)) return val;
}
return null;
}, [contextData]);
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 ?? contextDay) : 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: ${formatNumber(setpoint, 2, 0)} °C`,
status: (worstDiff === null
? 'unknown'
: worstDiff <= 1
? 'ok'
: worstDiff <= 2.5
? 'warning'
: 'critical') as InsightStatus,
deltaLabel:
signedWorst === null
? null
: `${signedWorst > 0 ? '+' : ''}${formatNumber(signedWorst, 2, 0)} °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 reportPeriod =
timeframe === 'harian'
? effectiveDay !== null
? `Hari ${effectiveDay}`
: currentDayVal
? `Hari ${currentDayVal}`
: null
: null;
let apiResult = null as Awaited<ReturnType<typeof api.insights.generate>> | null;
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;
}
if (!apiResult) {
if (!isCurrent()) return null;
setResult(null);
setLoading(false);
lastCacheKeyRef.current = scopeKey;
return null;
}
if (!isCurrent()) return null;
if (!apiResult?.insight_text && !apiResult?.summary && !apiResult?.insight) {
throw new Error(apiResult?.message || 'Gagal memuat AI Insight.');
}
const text = apiResult.insight_text ?? '';
let parsed: { summary: string; insight: string } | null = null;
if (apiResult.summary || apiResult.insight) {
parsed = {
summary: apiResult.summary || '',
insight: apiResult.insight || apiResult.insight_text || '',
};
} else {
parsed = 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);
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>
)}
</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 melihat AI Insight.
</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">
Memuat data AI Insight…
</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">Insight akan tampil setelah disinkronkan dari farm site.</p>
</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">
<div>
<span className="text-sm text-gray-600 font-medium block">
Total Karung Masuk
</span>
{(typeof displayContext.saldo_awal_karung === 'number' ||
typeof displayContext.total_karung_masuk_iot === 'number') && (
<span className="text-xs text-gray-400">
{[
typeof displayContext.saldo_awal_karung === 'number'
? `Saldo Awal: ${displayContext.saldo_awal_karung.toLocaleString('id-ID')}`
: null,
typeof displayContext.total_karung_masuk_iot === 'number'
? `Masuk IoT: ${displayContext.total_karung_masuk_iot.toLocaleString('id-ID')}`
: null,
]
.filter(Boolean)
.join(' + ')}
</span>
)}
</div>
<span className="text-sm font-bold text-gray-900">
{renderValue(feedMasukTotal(displayContext), '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">
Total Karung Pakan Dituang
</span>
<span className="text-sm font-bold text-gray-900">
{renderValue(displayContext.total_karung_dituang_iot, '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;