Files
dashboard-cpsp/components/AiInsightCard.tsx
T

812 lines
31 KiB
TypeScript

/**
* Dashboard AI Insight card (page / daily / weekly / end_cycle report modes).
*
* Parallel client to `PageAiInsight` (topic pages) — same API, different UX.
* Architecture: docs/ai-insight/README.md
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useFarm } from '../context/FarmContext.tsx';
import { api, errorMessage } from '../services/apiClient.ts';
import type {
InsightCitation,
InsightContext,
InsightReportType,
InsightStructuredReport,
InsightStructuredSection,
InsightStatusLabel,
} from '../types/api.ts';
import {
type DashboardInsightMetrics,
scopeDashboardInsightMetrics,
} from '../utils/insightContextBuilders.ts';
import { getCobbStandardForDay } from '../utils/insightHelpers.ts';
import { formatRatio } from '../utils/format.ts';
import { HeroFigure, MutedStatRow, StatTile, type InsightStatus } from './shared/InsightStatTiles.tsx';
import InsightTrendChart from './shared/InsightTrendChart.tsx';
const CLIENT_TIMEOUT_MS = 1_200_000; // 20 minutes
const sectionBadgeClasses: Record<InsightStatusLabel, string> = {
ok: 'border-emerald-200 bg-emerald-50 text-emerald-700',
warning: 'border-amber-200 bg-amber-50 text-amber-700',
critical: 'border-red-200 bg-red-50 text-red-700',
unknown: 'border-slate-200 bg-slate-50 text-slate-600',
};
export interface AiInsightCardProps {
/** Required — insight is always per kandang. */
kandangId: number;
/** Optional curated context from a page builder (e.g. buildFcrInsightContext). */
context?: InsightContext;
/** Display-only KPIs + weight series (not sent to the LLM). */
dashboardMetrics?: DashboardInsightMetrics | null;
page?: 'hitung_ayam' | 'berat_ayam' | 'fcr' | 'eef' | 'panel_iot' | 'data_quality' | 'dashboard';
}
const parseDayFromPeriod = (period: string | null): number | null => {
if (!period) return null;
const match = String(period).match(/\d+/);
if (!match) return null;
const day = parseInt(match[0], 10);
return Number.isFinite(day) && day > 0 ? day : null;
};
const stripJsonFences = (text: string) =>
text
.trim()
.replace(/^```json\s*/i, '')
.replace(/^```\s*/i, '')
.replace(/```$/i, '')
.trim();
const extractJsonObject = (text: string): string | null => {
const stripped = stripJsonFences(text);
if (!stripped) return null;
if (stripped.startsWith('{') && stripped.endsWith('}')) return stripped;
const first = stripped.indexOf('{');
const last = stripped.lastIndexOf('}');
if (first === -1 || last === -1 || last <= first) return null;
return stripped.slice(first, last + 1);
};
const asNonEmptyString = (value: unknown): string | null => {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
const asStringArray = (value: unknown, limit = 3): string[] => {
if (!Array.isArray(value)) return [];
return value
.map((item) => asNonEmptyString(item))
.filter((item): item is string => item !== null)
.slice(0, limit);
};
const asInsightStatus = (value: unknown): InsightStatusLabel => {
if (value === 'ok' || value === 'warning' || value === 'critical' || value === 'unknown') {
return value;
}
return 'unknown';
};
const normalizeInsightSection = (value: unknown): InsightStructuredSection => {
const section = (value && typeof value === 'object' ? value : {}) as Record<string, unknown>;
return {
status: asInsightStatus(section.status),
bullets: asStringArray(section.bullets, 3),
evidence: asStringArray(section.evidence, 4),
actions: asStringArray(section.actions, 3),
};
};
const normalizeStructuredInsight = (rawText: string): InsightStructuredReport | null => {
try {
const jsonText = extractJsonObject(rawText);
if (!jsonText) return null;
const parsed = JSON.parse(jsonText) as Record<string, unknown>;
const summary = (
parsed.summary && typeof parsed.summary === 'object' ? parsed.summary : {}
) as Record<string, unknown>;
const detailed = (
parsed.detailed && typeof parsed.detailed === 'object' ? parsed.detailed : {}
) as Record<string, unknown>;
return {
summary: {
headline: asNonEmptyString(summary.headline) || 'Ringkasan belum tersedia',
bullets: asStringArray(summary.bullets, 3),
risks: asStringArray(summary.risks, 3),
actions: asStringArray(summary.actions, 3),
},
detailed: {
hitung_ayam: normalizeInsightSection(detailed.hitung_ayam),
berat_ayam: normalizeInsightSection(detailed.berat_ayam),
fcr: normalizeInsightSection(detailed.fcr),
eef: normalizeInsightSection(detailed.eef),
panel_iot: normalizeInsightSection(detailed.panel_iot),
data_quality: normalizeInsightSection(detailed.data_quality),
},
};
} catch {
return null;
}
};
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>
);
};
/**
* Dashboard AI Insight report card (shell).
* Requires a kandangId — never generates for "all kandang".
* Wire page-specific KPIs/charts via `context` from insightContextBuilders.
*/
export const AiInsightCard: React.FC<AiInsightCardProps> = ({
kandangId,
context: contextProp,
dashboardMetrics = null,
page = 'dashboard',
}) => {
const { selectedCycle, selectedKandang, selectedSite, selectedKandangId } = useFarm();
const [insightText, setInsightText] = useState('');
const [structuredInsight, setStructuredInsight] = useState<InsightStructuredReport | null>(null);
const [citations, setCitations] = useState<InsightCitation[]>([]);
const [loading, setLoading] = useState(false);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [error, setError] = useState<string | null>(null);
const [detailsOpen, setDetailsOpen] = useState(false);
const [reportType, setReportType] = useState<InsightReportType>('daily');
const [dayPeriod, setDayPeriod] = useState<string | null>(null);
const insightInFlightRef = useRef(false);
const effectiveKandangId = kandangId ?? selectedKandangId;
const contextDay =
typeof contextProp?.hari_ke === 'number' && Number.isFinite(contextProp.hari_ke)
? contextProp.hari_ke
: null;
// Prefer context/data day over cycle calendar day (can be ahead of KPI/weight data).
const currentDayVal =
contextDay ?? dashboardMetrics?.hari_ke ?? selectedCycle?.current_day ?? 0;
const reportPeriod =
reportType === 'daily'
? (dayPeriod ?? (currentDayVal > 0 ? `Hari ${currentDayVal}` : null))
: reportType === 'weekly'
? dayPeriod
: null;
const scopedMetrics = useMemo(() => {
if (!dashboardMetrics) return null;
if (reportType === 'end_cycle') return dashboardMetrics;
const selectedDay = parseDayFromPeriod(reportPeriod);
return scopeDashboardInsightMetrics(dashboardMetrics, selectedDay);
}, [dashboardMetrics, reportType, reportPeriod]);
const topic = page === 'dashboard' ? 'dashboard' : page === 'panel_iot' ? 'iot_panel' : page;
const applyInsight = useCallback(
(text: string, nextCitations?: InsightCitation[]) => {
setInsightText(text);
setStructuredInsight(normalizeStructuredInsight(text));
setCitations(nextCitations ?? []);
},
[]
);
const generateInsight = useCallback(
async (forceRefresh = false) => {
if (insightInFlightRef.current) return;
if (effectiveKandangId == null) {
setError('Pilih kandang terlebih dahulu. Insight digenerate per kandang.');
return;
}
if (!selectedCycle?.id) {
setError('Data siklus tidak tersedia. Pastikan ada siklus aktif.');
return;
}
insightInFlightRef.current = true;
if (forceRefresh) setLoading(true);
setError(null);
try {
const contextPayload: InsightContext = {
kandangId: effectiveKandangId,
kandangName: selectedKandang?.kandang_name,
cycleId: selectedCycle.id,
hari_ke: currentDayVal > 0 ? currentDayVal : selectedCycle.current_day,
totalDays: selectedCycle.total_days,
...(contextProp ?? {}),
};
// Always stamp required identity after spread so caller cannot clear it.
contextPayload.kandangId = effectiveKandangId;
if (currentDayVal > 0) contextPayload.hari_ke = currentDayVal;
// Prefer period-scoped display metrics so the model analyzes the same
// numbers the muted rows / tiles show for the selected day.
if (scopedMetrics) {
contextPayload.hari_ke = scopedMetrics.hari_ke ?? contextPayload.hari_ke;
contextPayload.fcr_terakhir = scopedMetrics.fcr;
contextPayload.eef_terakhir = scopedMetrics.eef;
contextPayload.bobot_avg_gram = scopedMetrics.bobot_avg_gram;
contextPayload.ayam_hidup_ekor = scopedMetrics.ayam_hidup_ekor;
contextPayload.mortalitas_kumulatif_persen =
scopedMetrics.mortalitas_kumulatif_persen ?? null;
contextPayload.persen_hidup =
scopedMetrics.mortalitas_kumulatif_persen == null
? null
: Math.round((100 - scopedMetrics.mortalitas_kumulatif_persen) * 100) / 100;
contextPayload.doc_in_ekor = scopedMetrics.doc_in_ekor;
contextPayload.kematian_kumulatif_ekor = scopedMetrics.kematian_kumulatif_ekor;
contextPayload.panen_kumulatif_ekor = scopedMetrics.panen_kumulatif_ekor;
contextPayload.tonase_panen_kg = scopedMetrics.tonase_panen_kg;
contextPayload.uniformity_persen = scopedMetrics.uniformity_persen;
contextPayload.pakan_kumulatif_karung = scopedMetrics.pakan_kumulatif_karung;
}
let apiResult = null as Awaited<ReturnType<typeof api.insights.generate>> | null;
if (!forceRefresh) {
try {
apiResult = await api.insights.cached({
cycle_id: selectedCycle.id,
kandang_id: effectiveKandangId,
topic,
report_type: reportType,
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) {
applyInsight('', 'cache');
setLastUpdated(null);
setError(null);
return;
}
} else {
const generateCall = api.insights.generate({
cycle_id: selectedCycle.id,
kandang_id: effectiveKandangId,
topic,
report_type: reportType,
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 (!apiResult?.insight_text && !apiResult?.summary) {
throw new Error(apiResult?.message || 'Gagal mengambil insight AI dari server');
}
applyInsight(apiResult.insight_text, apiResult.citations);
setLastUpdated(new Date());
setDetailsOpen(false);
} catch (err) {
console.error('Error generating insight:', err);
if (forceRefresh) {
applyInsight('', 'local_fallback');
setLastUpdated(null);
setError(errorMessage(err));
setDetailsOpen(false);
} else {
applyInsight('', 'cache');
setLastUpdated(null);
setError(null);
}
} finally {
setLoading(false);
insightInFlightRef.current = false;
}
},
[
effectiveKandangId,
selectedCycle,
selectedKandang,
contextProp,
currentDayVal,
topic,
reportType,
reportPeriod,
scopedMetrics,
applyInsight,
]
);
useEffect(() => {
if (effectiveKandangId == null || !selectedCycle?.id) return;
// Soft cache load only — never auto-generate.
const timer = setTimeout(() => generateInsight(false), 50);
return () => clearTimeout(timer);
}, [effectiveKandangId, selectedCycle?.id, reportType, reportPeriod, generateInsight]);
// NOTE: no interval auto-refresh / auto-generate (manual Generate only).
const formatLastUpdated = () => {
if (!lastUpdated) return '';
const diff = Date.now() - lastUpdated.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
if (hours > 0) return `${hours} jam yang lalu`;
if (minutes > 0) return `${minutes} menit yang lalu`;
return 'Baru saja';
};
const renderBulletList = (items: string[], emptyLabel = 'data belum tersedia') => {
if (!items.length) return <p className="text-sm text-gray-500">{emptyLabel}</p>;
return (
<ul className="space-y-2">
{items.map((item, index) => (
<li key={`${item}-${index}`} className="flex gap-2 text-sm text-gray-700">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
<span>{item}</span>
</li>
))}
</ul>
);
};
const renderDetailSection = (
title: string,
section: InsightStructuredSection,
accentClass: string
) => (
<section className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500">{title}</p>
<span
className={`rounded-full border px-2.5 py-1 text-xs font-semibold uppercase tracking-wide ${accentClass}`}
>
{section.status}
</span>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500">Poin</p>
<div className="mt-2">{renderBulletList(section.bullets)}</div>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500">Bukti</p>
<div className="mt-2">{renderBulletList(section.evidence)}</div>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500">Aksi</p>
<div className="mt-2">{renderBulletList(section.actions)}</div>
</div>
</div>
</section>
);
const summary = structuredInsight?.summary;
const detailed = structuredInsight?.detailed;
const gradeDay = scopedMetrics?.hari_ke ?? (currentDayVal > 0 ? currentDayVal : null);
const fcrStandard = gradeDay ? getCobbStandardForDay(gradeDay)?.fcr ?? null : null;
const fcrStatus = ((): InsightStatus => {
const actual = scopedMetrics?.fcr ?? null;
if (actual === null || fcrStandard === null) return 'unknown';
if (actual <= fcrStandard) return 'ok';
if (actual <= fcrStandard * 1.1) return 'warning';
return 'critical';
})();
const eefStatus = ((): InsightStatus => {
const actual = scopedMetrics?.eef ?? null;
if (actual === null) return 'unknown';
if ((gradeDay ?? 0) < 35) return 'unknown';
if (actual >= 300) return 'ok';
if (actual >= 250) return 'warning';
return 'critical';
})();
const mortalityStatus = ((): InsightStatus => {
const pct = scopedMetrics?.mortalitas_kumulatif_persen ?? null;
if (pct === null) return 'unknown';
if (pct < 5) return 'ok';
if (pct <= 7) return 'warning';
return 'critical';
})();
const weightTarget =
scopedMetrics?.weightSeries.find((row) => row.hari === gradeDay)?.target ??
[...(scopedMetrics?.weightSeries ?? [])].reverse().find((row) => row.target != null)
?.target ??
null;
const weightStatus = ((): InsightStatus => {
const actual = scopedMetrics?.bobot_avg_gram ?? null;
if (actual === null || weightTarget === null || weightTarget <= 0) return 'unknown';
const dev = ((actual - weightTarget) / weightTarget) * 100;
if (dev >= -5) return 'ok';
if (dev >= -15) return 'warning';
return 'critical';
})();
const weightGapPct =
scopedMetrics?.bobot_avg_gram != null && weightTarget != null && weightTarget > 0
? ((scopedMetrics.bobot_avg_gram - weightTarget) / weightTarget) * 100
: null;
const chartHighlight =
reportType === 'daily' && gradeDay != null
? { firstDay: gradeDay, lastDay: gradeDay }
: gradeDay != null
? { firstDay: 1, lastDay: gradeDay }
: null;
const renderVisualBlock = () => {
if (!scopedMetrics || page !== 'dashboard') return null;
const fcrVal = scopedMetrics.fcr;
const eefVal = scopedMetrics.eef;
const popVal = scopedMetrics.ayam_hidup_ekor;
const mortVal = scopedMetrics.mortalitas_kumulatif_persen;
const bobotVal = scopedMetrics.bobot_avg_gram;
return (
<div className="mb-5 space-y-3 rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<HeroFigure
label="FCR Aktual"
value={fcrVal == null ? 'N/A' : formatRatio(fcrVal, 3)}
context={
fcrStandard != null && gradeDay != null
? `Standar CP 707 hari ke-${gradeDay}: ${formatRatio(fcrStandard, 3)}`
: null
}
status={fcrStatus}
deltaLabel={
fcrVal != null && fcrStandard != null
? `${fcrVal > fcrStandard ? '+' : ''}${formatRatio(fcrVal - fcrStandard, 3)} vs standar`
: null
}
/>
<HeroFigure
label="EEF"
value={eefVal == null ? 'N/A' : formatRatio(eefVal, 3)}
context={
(gradeDay ?? 0) >= 35
? 'Standar umur panen: >300'
: 'Belum umur panen — wajar masih rendah'
}
status={eefStatus}
deltaLabel={
eefVal == null || (gradeDay ?? 0) < 35
? null
: eefVal >= 300
? 'di atas standar'
: 'di bawah standar'
}
/>
</div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3">
<StatTile
label="Populasi Hidup"
value={popVal == null ? 'N/A' : popVal.toLocaleString('id-ID')}
unit={popVal == null ? undefined : 'ekor'}
hint="s/d kini"
/>
<StatTile
label="Mortalitas Kumulatif"
value={mortVal == null ? 'N/A' : `${mortVal.toFixed(2)}%`}
status={mortalityStatus}
deltaLabel={
mortVal == null
? null
: mortalityStatus === 'ok'
? '✓ normal <5%'
: mortalityStatus === 'warning'
? '↑ melebihi standar <5%'
: '↑ tinggi, standar <5%'
}
/>
<StatTile
label="Bobot Rata-rata"
value={bobotVal == null ? 'N/A' : Math.round(bobotVal).toLocaleString('id-ID')}
unit={bobotVal == null ? undefined : 'g'}
status={weightStatus}
deltaLabel={
weightGapPct == null ? null : `${weightGapPct.toFixed(0)}% vs target`
}
/>
</div>
<div className="mt-1 grid grid-cols-1 gap-x-6 md:grid-cols-2">
<MutedStatRow
label="Jumlah Populasi (Jumlah DOC)"
value={
scopedMetrics.doc_in_ekor == null
? 'N/A'
: scopedMetrics.doc_in_ekor.toLocaleString('id-ID')
}
/>
<MutedStatRow
label="Jumlah Kematian (Kematian + Afkir)"
hint={gradeDay != null ? `— s/d hari ke-${gradeDay}` : null}
value={
scopedMetrics.kematian_kumulatif_ekor == null
? 'N/A'
: scopedMetrics.kematian_kumulatif_ekor.toLocaleString('id-ID')
}
/>
<MutedStatRow
label="Jumlah terpanen"
hint={gradeDay != null ? `— s/d hari ke-${gradeDay}` : null}
value={
scopedMetrics.panen_kumulatif_ekor == null
? 'N/A'
: scopedMetrics.panen_kumulatif_ekor.toLocaleString('id-ID')
}
/>
<MutedStatRow
label="Jumlah tonase panen"
hint={gradeDay != null ? `— s/d hari ke-${gradeDay}` : null}
value={
scopedMetrics.tonase_panen_kg == null
? 'N/A'
: `${scopedMetrics.tonase_panen_kg.toLocaleString('id-ID')} kg`
}
/>
<MutedStatRow
label="Uniformity (AI Scale)"
value={
scopedMetrics.uniformity_persen == null
? 'N/A'
: `${scopedMetrics.uniformity_persen.toFixed(1)}%`
}
/>
<MutedStatRow
label="Konsumsi pakan (Sack Counting)"
value={
scopedMetrics.pakan_kumulatif_karung == null
? 'N/A'
: `${scopedMetrics.pakan_kumulatif_karung.toLocaleString('id-ID')} karung`
}
/>
</div>
<div className="pt-2">
<InsightTrendChart
rows={scopedMetrics.weightSeries as Array<Record<string, unknown>>}
series={[
{ field: 'actual', label: 'Bobot aktual', color: '#dc2626' },
{ field: 'target', label: 'Target CP 707', color: '#9ca3af', dashed: true },
]}
unit="g"
title="Bobot Aktual vs Target CP 707"
highlight={chartHighlight}
gap={{ actualField: 'actual', targetField: 'target' }}
emptyLabel="Belum ada data bobot untuk digambarkan."
/>
</div>
</div>
);
};
return (
<div className="bg-white border border-gray-100 rounded-xl shadow-sm overflow-hidden">
<div className="p-5 sm:p-6">
<div className="flex flex-col md:flex-row md:items-center justify-between mb-4 gap-4">
<div className="flex items-center gap-2 min-w-0">
<i className="fa-solid fa-brain text-red-600 text-xl" />
<div className="min-w-0">
<h2 className="text-lg font-bold text-gray-900">AI Insight</h2>
<p className="text-xs text-gray-400 truncate">
{selectedKandang?.kandang_name ?? `Kandang ${effectiveKandangId}`}
{selectedSite?.site_name ? ` · ${selectedSite.site_name}` : ''}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{(page === 'dashboard' || !page) && (
<>
<select
value={reportType}
onChange={(e) => setReportType(e.target.value as InsightReportType)}
className="text-sm border border-gray-200 rounded-lg shadow-sm bg-white px-2 py-1.5"
>
<option value="daily">Harian</option>
{(currentDayVal >= 35 || selectedCycle?.status === 'closed') && (
<option value="end_cycle">Akhir Siklus</option>
)}
</select>
{reportType === 'daily' && currentDayVal > 0 && (
<select
value={reportPeriod ?? `Hari ${currentDayVal}`}
onChange={(e) => setDayPeriod(e.target.value)}
className="text-sm border border-gray-200 rounded-lg shadow-sm bg-white px-2 py-1.5"
>
{Array.from({ length: currentDayVal }, (_, i) => i + 1).map((d) => (
<option key={d} value={`Hari ${d}`}>
Hari {d}
</option>
))}
</select>
)}
</>
)}
<button
onClick={() => generateInsight(true)}
disabled={loading || effectiveKandangId == null}
className={`p-2 rounded-lg transition-all shadow-sm border border-gray-200 ${
loading ? 'bg-gray-100 cursor-not-allowed' : 'bg-white hover:bg-gray-50'
}`}
title="Refresh insight"
>
<i className={`fa-solid fa-rotate-right text-gray-700 ${loading ? 'fa-spin' : ''}`} />
</button>
</div>
</div>
{effectiveKandangId != null ? renderVisualBlock() : null}
<div className="min-h-[120px]">
{effectiveKandangId == null ? (
<div className="bg-amber-50 border border-amber-100 rounded-lg p-4 text-sm text-amber-800">
Pilih satu kandang untuk menghasilkan AI Insight. Generate untuk semua kandang tidak
diizinkan.
</div>
) : loading && !insightText ? (
<div className="flex items-center justify-center py-8">
<div className="flex flex-col items-center gap-3">
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-red-600" />
<p className="text-sm text-gray-600 text-center max-w-sm">
Menganalisis data peternakan… proses ini bisa memakan waktu beberapa menit.
</p>
</div>
</div>
) : error && !insightText ? (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-red-700 text-sm font-semibold mb-2">Error</p>
<p className="text-red-600 text-sm">{error}</p>
<button
onClick={() => generateInsight(true)}
className="text-xs text-red-600 underline mt-2"
>
Coba lagi
</button>
</div>
) : structuredInsight && summary ? (
<div className="space-y-4">
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<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">
{summary.headline}
</p>
<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>
<div className="text-sm text-gray-700 space-y-2 leading-relaxed">
{renderBulletList([
...summary.bullets,
...summary.risks,
...summary.actions,
])}
</div>
</div>
<CitationsBlock citations={citations} />
</div>
{page === 'dashboard' && detailed && (
<>
<button
onClick={() => setDetailsOpen((v) => !v)}
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50"
>
{detailsOpen ? 'Sembunyikan detail' : 'Lihat detail per metrik'}
</button>
{detailsOpen && (
<div className="space-y-3">
{detailed.hitung_ayam &&
renderDetailSection(
'Hitung Ayam',
detailed.hitung_ayam,
sectionBadgeClasses[detailed.hitung_ayam.status]
)}
{detailed.berat_ayam &&
renderDetailSection(
'Berat Ayam',
detailed.berat_ayam,
sectionBadgeClasses[detailed.berat_ayam.status]
)}
{detailed.fcr &&
renderDetailSection(
'FCR',
detailed.fcr,
sectionBadgeClasses[detailed.fcr.status]
)}
{detailed.eef &&
renderDetailSection(
'EEF',
detailed.eef,
sectionBadgeClasses[detailed.eef.status]
)}
{detailed.panel_iot &&
renderDetailSection(
'Panel IoT',
detailed.panel_iot,
sectionBadgeClasses[detailed.panel_iot.status]
)}
{detailed.data_quality &&
renderDetailSection(
'Kualitas Data',
detailed.data_quality,
sectionBadgeClasses[detailed.data_quality.status]
)}
</div>
)}
</>
)}
</div>
) : insightText ? (
<div className="text-sm text-gray-800 leading-relaxed whitespace-pre-wrap">
{insightText}
<CitationsBlock citations={citations} />
</div>
) : (
<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={() => generateInsight(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>
)}
</div>
{lastUpdated && (
<div className="mt-4 pt-4 border-t border-gray-100">
<p className="text-xs text-gray-500">Terakhir diperbarui: {formatLastUpdated()}</p>
</div>
)}
</div>
</div>
);
};
export default AiInsightCard;