/** * Dashboard AI Insight card (daily / 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 { EndCycleStructuredInsight, InsightCitation, InsightContext, InsightReportType, InsightStructuredReport, InsightStructuredSection, InsightStatusLabel, } from '../types/api.ts'; import { buildEndCycleRollups, type DashboardInsightMetrics, type EndCycleIotDayAggregate, scopeDashboardInsightMetrics, } from '../utils/insightContextBuilders.ts'; import { getCobbStandardForDay } from '../utils/insightHelpers.ts'; import { parseAiResult, type AiResult } from '../utils/insightParse.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 END_CYCLE_JSON_MARKER = ''; const sectionBadgeClasses: Record = { 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', }; const confidenceBadge: Record = { high: 'border-red-200 bg-red-50 text-red-700', med: 'border-amber-200 bg-amber-50 text-amber-700', low: '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; /** Optional IoT day aggregates for end-cycle root-cause enrichment. */ iotDayAggregates?: EndCycleIotDayAggregate[]; 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; 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; const summary = ( parsed.summary && typeof parsed.summary === 'object' ? parsed.summary : {} ) as Record; const detailed = ( parsed.detailed && typeof parsed.detailed === 'object' ? parsed.detailed : {} ) as Record; 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 normalizeEndCycleInsight = ( raw: unknown, fallbackSummary?: string | null ): EndCycleStructuredInsight | null => { if (!raw || typeof raw !== 'object') return null; const parsed = raw as Record; const hasShape = Array.isArray(parsed.masalah) || Array.isArray(parsed.akar_penyebab) || Array.isArray(parsed.perbaikan_siklus_berikutnya); if (!hasShape) return null; const akarRaw = Array.isArray(parsed.akar_penyebab) ? parsed.akar_penyebab : []; const fixRaw = Array.isArray(parsed.perbaikan_siklus_berikutnya) ? parsed.perbaikan_siklus_berikutnya : []; return { kesimpulan: asNonEmptyString(parsed.kesimpulan) || asNonEmptyString(fallbackSummary) || 'Ringkasan akhir siklus', masalah: asStringArray(parsed.masalah, 8), akar_penyebab: akarRaw .filter((item): item is Record => !!item && typeof item === 'object') .map((item) => ({ id: asNonEmptyString(item.id) ?? undefined, hipotesis: asNonEmptyString(item.hipotesis) || 'Hipotesis', bukti: asStringArray(item.bukti, 6), confidence: asNonEmptyString(item.confidence) ?? undefined, fase: asNonEmptyString(item.fase) ?? undefined, })), perbaikan_siklus_berikutnya: fixRaw .filter((item): item is Record => !!item && typeof item === 'object') .map((item) => ({ id: asNonEmptyString(item.id) ?? undefined, fase: asNonEmptyString(item.fase) || 'growth', aksi: asNonEmptyString(item.aksi) || '', metrik_pantau: asNonEmptyString(item.metrik_pantau) ?? undefined, })) .filter((item) => item.aksi.length > 0), insight: asNonEmptyString(parsed.insight) ?? undefined, }; }; const parseEndCycleFromText = ( text: string, fallbackSummary?: string | null ): EndCycleStructuredInsight | null => { if (!text) return null; if (text.includes(END_CYCLE_JSON_MARKER)) { const raw = text.split(END_CYCLE_JSON_MARKER, 2)[1]?.trim() ?? ''; try { return normalizeEndCycleInsight(JSON.parse(raw), fallbackSummary); } catch { /* fall through */ } } const jsonText = extractJsonObject(text); if (!jsonText) return null; try { return normalizeEndCycleInsight(JSON.parse(jsonText), fallbackSummary); } catch { return null; } }; const CitationsBlock: React.FC<{ citations?: InsightCitation[] }> = ({ citations }) => { if (!citations || citations.length === 0) return null; return (

Sumber referensi

    {citations.map((c, i) => (
  • {c.chapter || c.title || c.source || `Referensi ${i + 1}`} {c.excerpt ? — {c.excerpt} : null}
  • ))}
); }; /** * 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 = ({ kandangId, context: contextProp, dashboardMetrics = null, iotDayAggregates = [], page = 'dashboard', }) => { const { selectedCycle, selectedKandang, selectedSite, selectedKandangId } = useFarm(); const [insightText, setInsightText] = useState(''); const [dailyInsight, setDailyInsight] = useState(null); const [structuredInsight, setStructuredInsight] = useState(null); const [endCycleInsight, setEndCycleInsight] = useState(null); const [citations, setCitations] = useState([]); const [loading, setLoading] = useState(false); const [lastUpdated, setLastUpdated] = useState(null); const [error, setError] = useState(null); const [detailsOpen, setDetailsOpen] = useState(false); const [reportType, setReportType] = useState('daily'); const [dayPeriod, setDayPeriod] = useState(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 ?? (typeof currentDayVal === 'number' && currentDayVal >= 0 ? `Hari ${currentDayVal}` : null)) : 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[] | 'cache' | 'local_fallback', structuredEndCycle?: EndCycleStructuredInsight | null, summary?: string | null, akarMasalah?: string | null, insightBody?: string | null ) => { const citationsArg = Array.isArray(nextCitations) ? nextCitations : []; setInsightText(text); const endCycle = structuredEndCycle ?? parseEndCycleFromText(text, summary) ?? (typeof nextCitations === 'string' ? null : null); setEndCycleInsight(endCycle); // Prefer end-cycle structured UI over legacy multi-topic structured dump. setStructuredInsight(endCycle ? null : normalizeStructuredInsight(text)); setCitations(citationsArg); if (!endCycle) { if (summary || akarMasalah || insightBody) { setDailyInsight({ summary: summary || '', akar_masalah: akarMasalah || undefined, insight: insightBody || text || '', }); } else if (text) { setDailyInsight(parseAiResult(text)); } else { setDailyInsight(null); } } else { setDailyInsight(null); } }, [] ); 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 effectiveDay = typeof currentDayVal === 'number' && currentDayVal >= 0 ? currentDayVal : selectedCycle.current_day; const contextPayload: InsightContext = { kandangId: effectiveKandangId, kandangName: selectedKandang?.kandang_name, cycleId: selectedCycle.id, hari_ke: effectiveDay, totalDays: selectedCycle.total_days, ...(contextProp ?? {}), }; // Always stamp required identity after spread so caller cannot clear it. contextPayload.kandangId = effectiveKandangId; if (typeof currentDayVal === 'number' && 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; const currentTarget = scopedMetrics.weightSeries.find((row) => row.hari === contextPayload.hari_ke)?.target ?? [...scopedMetrics.weightSeries].reverse().find((row) => row.target != null)?.target ?? null; if (currentTarget != null && currentTarget > 0) { contextPayload.target_bw_gram = currentTarget; } // EEF series for trend computation (eef, dashboard, end_cycle topics) if (topic === 'eef' || topic === 'dashboard' || reportType === 'end_cycle') { contextPayload.eef_seri = scopedMetrics.kpiSeries .filter((row) => row.eef != null && row.hari > 0) .map((row) => ({ hari: row.hari, eef: row.eef })); } } if (reportType === 'end_cycle' && scopedMetrics) { const rollups = buildEndCycleRollups(scopedMetrics, iotDayAggregates); contextPayload.weekly_summaries = rollups.weekly_summaries; contextPayload.phase_summaries = rollups.phase_summaries; contextPayload.tren_kpi_harian = rollups.tren_kpi_harian; contextPayload.mati_ekor = rollups.mati_ekor; contextPayload.panen_ekor = rollups.panen_ekor; contextPayload.saldo_ekor = rollups.saldo_ekor; if (iotDayAggregates.length > 0) { const latestIot = iotDayAggregates[iotDayAggregates.length - 1]!; contextPayload.suhu_rata_rata_C = latestIot.avg_temp_C; contextPayload.kelembapan_persen = latestIot.avg_humidity_pct; contextPayload.iot_day_aggregates = iotDayAggregates; } } let apiResult = null as Awaited> | 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((_, 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, apiResult.structured_end_cycle ?? null, apiResult.summary, apiResult.akar_masalah, apiResult.insight ); 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, iotDayAggregates, 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

{emptyLabel}

; return (
    {items.map((item, index) => (
  • {item}
  • ))}
); }; const renderDetailSection = ( title: string, section: InsightStructuredSection, accentClass: string ) => (

{title}

{section.status}

Poin

{renderBulletList(section.bullets)}

Bukti

{renderBulletList(section.evidence)}

Aksi

{renderBulletList(section.actions)}
); const summary = structuredInsight?.summary; const detailed = structuredInsight?.detailed; const gradeDay = scopedMetrics?.hari_ke ?? (typeof currentDayVal === 'number' && currentDayVal >= 0 ? currentDayVal : null); const fcrStandard = gradeDay != null ? (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 hasInsight = Boolean(insightText.trim()) || structuredInsight != null || endCycleInsight != null; const renderVisualBlock = () => { if (!hasInsight || !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 (
fcrStandard ? '+' : ''}${formatRatio(fcrVal - fcrStandard, 3)} vs standar` : null } /> = 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' } />
>} 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." />
); }; return (

AI Insight

{selectedKandang?.kandang_name ?? `Kandang ${effectiveKandangId}`} {selectedSite?.site_name ? ` · ${selectedSite.site_name}` : ''}

{(page === 'dashboard' || !page) && ( <> {reportType === 'daily' && currentDayVal >= 0 && ( )} )}
{effectiveKandangId != null ? renderVisualBlock() : null}
{effectiveKandangId == null ? (
Pilih satu kandang untuk menghasilkan AI Insight. Generate untuk semua kandang tidak diizinkan.
) : loading && !insightText ? (

Menganalisis data peternakan… proses ini bisa memakan waktu beberapa menit.

) : error && !insightText ? (

Error

{error}

) : endCycleInsight ? (

Kesimpulan

{endCycleInsight.kesimpulan}

Masalah

{renderBulletList( endCycleInsight.masalah, 'Tidak ada masalah kritis dari graded facts' )}

Akar penyebab

{endCycleInsight.akar_penyebab.length === 0 ? (

Belum ada kandidat akar penyebab berbukti.

) : (
    {endCycleInsight.akar_penyebab.map((item, index) => (
  • {item.hipotesis}

    {item.confidence ? ( {item.confidence} ) : null} {item.fase ? ( {item.fase} ) : null}
    {item.bukti.length > 0 ? (
      {item.bukti.map((bukti, buktiIdx) => (
    • — {bukti}
    • ))}
    ) : null}
  • ))}
)}

Perbaikan siklus berikutnya

{endCycleInsight.perbaikan_siklus_berikutnya.length === 0 ? (

Belum ada checklist perbaikan.

) : (
    {endCycleInsight.perbaikan_siklus_berikutnya.map((item, index) => (
  • {item.fase}

    {item.aksi}

    {item.metrik_pantau ? (

    Pantau: {item.metrik_pantau}

    ) : null}
  • ))}
)} {endCycleInsight.insight ? (

{endCycleInsight.insight}

) : null}
) : structuredInsight && summary ? (

Kesimpulan

{summary.headline}

AI Insight

{renderBulletList([...summary.bullets, ...summary.risks, ...summary.actions])}
{page === 'dashboard' && detailed && ( <> {detailsOpen && (
{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] )}
)} )}
) : dailyInsight ? (

Kesimpulan

{dailyInsight.summary}

{dailyInsight.akar_masalah && (

Akar Masalah

{dailyInsight.akar_masalah}

)}

AI Insight & Rekomendasi

{dailyInsight.insight}

) : insightText ? (
{insightText.includes(END_CYCLE_JSON_MARKER) ? insightText.split(END_CYCLE_JSON_MARKER)[0]?.trim() : insightText.includes('') ? insightText.split('')[0]?.trim() : insightText}
) : (

Belum ada AI Insight untuk periode ini.

Klik tombol refresh untuk generate insight.

)}
{lastUpdated && (

Terakhir diperbarui: {formatLastUpdated()}

)}
); }; export default AiInsightCard;