1035 lines
41 KiB
TypeScript
1035 lines
41 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 {
|
|
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 { 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 = '<!--end_cycle_json-->';
|
|
|
|
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',
|
|
};
|
|
|
|
const confidenceBadge: Record<string, string> = {
|
|
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<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 normalizeEndCycleInsight = (
|
|
raw: unknown,
|
|
fallbackSummary?: string | null
|
|
): EndCycleStructuredInsight | null => {
|
|
if (!raw || typeof raw !== 'object') return null;
|
|
const parsed = raw as Record<string, unknown>;
|
|
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<string, unknown> => !!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<string, unknown> => !!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 (
|
|
<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,
|
|
iotDayAggregates = [],
|
|
page = 'dashboard',
|
|
}) => {
|
|
const { selectedCycle, selectedKandang, selectedSite, selectedKandangId } = useFarm();
|
|
|
|
const [insightText, setInsightText] = useState('');
|
|
const [structuredInsight, setStructuredInsight] = useState<InsightStructuredReport | null>(null);
|
|
const [endCycleInsight, setEndCycleInsight] = useState<EndCycleStructuredInsight | 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[] | 'cache' | 'local_fallback',
|
|
structuredEndCycle?: EndCycleStructuredInsight | null,
|
|
summary?: 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);
|
|
},
|
|
[]
|
|
);
|
|
|
|
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;
|
|
|
|
// 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<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,
|
|
apiResult.structured_end_cycle ?? null,
|
|
apiResult.summary
|
|
);
|
|
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 <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 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 (
|
|
<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>
|
|
) : endCycleInsight ? (
|
|
<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">
|
|
{endCycleInsight.kesimpulan}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
|
<h4 className="text-sm font-bold text-gray-800 mb-3 flex items-center gap-2">
|
|
<i className="fa-solid fa-triangle-exclamation text-amber-600" /> Masalah
|
|
</h4>
|
|
{renderBulletList(
|
|
endCycleInsight.masalah,
|
|
'Tidak ada masalah kritis dari graded facts'
|
|
)}
|
|
</div>
|
|
|
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
|
<h4 className="text-sm font-bold text-gray-800 mb-3 flex items-center gap-2">
|
|
<i className="fa-solid fa-magnifying-glass-chart text-red-600" /> Akar penyebab
|
|
</h4>
|
|
{endCycleInsight.akar_penyebab.length === 0 ? (
|
|
<p className="text-sm text-gray-500">
|
|
Belum ada kandidat akar penyebab berbukti.
|
|
</p>
|
|
) : (
|
|
<ul className="space-y-3">
|
|
{endCycleInsight.akar_penyebab.map((item, index) => (
|
|
<li
|
|
key={`${item.id ?? item.hipotesis}-${index}`}
|
|
className="rounded-lg border border-gray-100 bg-gray-50 p-3"
|
|
>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<p className="text-sm font-semibold text-gray-800">{item.hipotesis}</p>
|
|
{item.confidence ? (
|
|
<span
|
|
className={`rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${
|
|
confidenceBadge[item.confidence] ?? confidenceBadge.low
|
|
}`}
|
|
>
|
|
{item.confidence}
|
|
</span>
|
|
) : null}
|
|
{item.fase ? (
|
|
<span className="text-[10px] uppercase tracking-wide text-gray-500">
|
|
{item.fase}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
{item.bukti.length > 0 ? (
|
|
<ul className="mt-2 space-y-1">
|
|
{item.bukti.map((bukti, buktiIdx) => (
|
|
<li key={`${bukti}-${buktiIdx}`} className="text-xs text-gray-600">
|
|
— {bukti}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
|
<h4 className="text-sm font-bold text-gray-800 mb-3 flex items-center gap-2">
|
|
<i className="fa-solid fa-clipboard-check text-blue-600" /> Perbaikan siklus
|
|
berikutnya
|
|
</h4>
|
|
{endCycleInsight.perbaikan_siklus_berikutnya.length === 0 ? (
|
|
<p className="text-sm text-gray-500">Belum ada checklist perbaikan.</p>
|
|
) : (
|
|
<ul className="space-y-3">
|
|
{endCycleInsight.perbaikan_siklus_berikutnya.map((item, index) => (
|
|
<li
|
|
key={`${item.id ?? item.aksi}-${index}`}
|
|
className="rounded-lg border border-blue-100 bg-blue-50/40 p-3"
|
|
>
|
|
<p className="text-[10px] font-semibold uppercase tracking-wide text-blue-700">
|
|
{item.fase}
|
|
</p>
|
|
<p className="mt-1 text-sm text-gray-800">{item.aksi}</p>
|
|
{item.metrik_pantau ? (
|
|
<p className="mt-1 text-xs text-gray-500">Pantau: {item.metrik_pantau}</p>
|
|
) : null}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
{endCycleInsight.insight ? (
|
|
<p className="mt-3 text-sm text-gray-600">{endCycleInsight.insight}</p>
|
|
) : null}
|
|
<CitationsBlock citations={citations} />
|
|
</div>
|
|
</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.includes(END_CYCLE_JSON_MARKER)
|
|
? insightText.split(END_CYCLE_JSON_MARKER)[0]?.trim()
|
|
: 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;
|