update ai insight migration for all page
This commit is contained in:
1 parent
07d36081ce
commit
18790ab8f6
69 files changed
+9457
-399
No files matched your search
@@ -0,0 +1,833 @@
|
||||
/**
|
||||
* 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,
|
||||
InsightSource,
|
||||
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 SOURCE_LABEL: Record<InsightSource, { text: string; cls: string }> = {
|
||||
generated: { text: 'Baru dibuat', cls: 'bg-green-50 text-green-700 border-green-200' },
|
||||
cache: { text: 'Dari cache', cls: 'bg-blue-50 text-blue-700 border-blue-200' },
|
||||
local_fallback: {
|
||||
text: 'Fallback lokal',
|
||||
cls: 'bg-amber-50 text-amber-800 border-amber-200',
|
||||
},
|
||||
};
|
||||
|
||||
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 [source, setSource] = useState<InsightSource | 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, nextSource: InsightSource, nextCitations?: InsightCitation[]) => {
|
||||
setInsightText(text);
|
||||
setStructuredInsight(normalizeStructuredInsight(text));
|
||||
setSource(nextSource);
|
||||
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.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.source ?? (forceRefresh ? 'generated' : 'cache'),
|
||||
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 sourceMeta = source ? SOURCE_LABEL[source] : null;
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{sourceMeta && (
|
||||
<span
|
||||
className={`inline-flex rounded-full border px-2 py-0.5 text-[10px] font-semibold ${sourceMeta.cls}`}
|
||||
>
|
||||
{sourceMeta.text}
|
||||
</span>
|
||||
)}
|
||||
<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;
|
||||
Reference in new issue
Block a user