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;
|
||||
+84
-32
@@ -1,7 +1,6 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { api, ApiError, errorMessage } from '../services/apiClient.ts';
|
||||
import type {
|
||||
AIInsight,
|
||||
ChickenCounting,
|
||||
ChickenWeight,
|
||||
Karung,
|
||||
@@ -21,12 +20,17 @@ import {
|
||||
} from '../utils/format.ts';
|
||||
import { throughVisibleDate } from '../utils/visibility.ts';
|
||||
import { getCountingAccuracyWarning } from '../utils/countingAccuracyWarning.ts';
|
||||
import {
|
||||
buildDashboardInsightContext,
|
||||
buildDashboardInsightMetrics,
|
||||
} from '../utils/insightContextBuilders.ts';
|
||||
import DashboardReportCtas from './dashboard/DashboardReportCtas.tsx';
|
||||
import DashboardCloseCycleControls from './dashboard/DashboardCloseCycleControls.tsx';
|
||||
import DashboardManualInputForm from './dashboard/DashboardManualInputForm.tsx';
|
||||
import ReportPreviewOverlay from './dashboard/ReportPreviewOverlay.tsx';
|
||||
import CountingAccuracyWarningBanner from './counting/CountingAccuracyWarningBanner.tsx';
|
||||
import type { DashboardReportType } from '../types/dashboardReport.ts';
|
||||
import { AiInsightCard } from './AiInsightCard.tsx';
|
||||
|
||||
type Props = { setView: (view: View) => void };
|
||||
|
||||
@@ -35,7 +39,7 @@ function chronological<T>(rows: T[]): T[] {
|
||||
}
|
||||
|
||||
const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
const { selectedCycle, selectedKandang, selectedSite } = useFarm();
|
||||
const { selectedCycle, selectedKandang, selectedSite, selectedKandangId } = useFarm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [kpis, setKpis] = useState<KPI[]>([]);
|
||||
@@ -43,7 +47,6 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
const [manualInputs, setManualInputs] = useState<ManualInput[]>([]);
|
||||
const [weights, setWeights] = useState<ChickenWeight[]>([]);
|
||||
const [karung, setKarung] = useState<Karung | null>(null);
|
||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||
const [reportOpen, setReportOpen] = useState(false);
|
||||
const [reportType, setReportType] = useState<DashboardReportType>('daily');
|
||||
|
||||
@@ -54,7 +57,6 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
setManualInputs([]);
|
||||
setWeights([]);
|
||||
setKarung(null);
|
||||
setInsights([]);
|
||||
return;
|
||||
}
|
||||
const cycleId = selectedCycle.id;
|
||||
@@ -63,12 +65,11 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [kpiRows, countingRows, manualRows, weightRows, insightRows] = await Promise.all([
|
||||
const [kpiRows, countingRows, manualRows, weightRows] = await Promise.all([
|
||||
api.kpis.list({ cycle_id: cycleId }),
|
||||
api.countings.list({ cycle_id: cycleId }),
|
||||
api.manualInputs.list({ cycle_id: cycleId }),
|
||||
api.weights.list({ cycle_id: cycleId }),
|
||||
api.insights.list({ cycle_id: cycleId }),
|
||||
]);
|
||||
let latestKarung: Karung | null = null;
|
||||
try {
|
||||
@@ -82,7 +83,6 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
setManualInputs(manualRows);
|
||||
setWeights(weightRows);
|
||||
setKarung(latestKarung);
|
||||
setInsights(insightRows);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(errorMessage(err));
|
||||
} finally {
|
||||
@@ -211,6 +211,73 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
});
|
||||
}, [selectedCycle, countings, kpis, manualInputs]);
|
||||
|
||||
const insightContext = useMemo(() => {
|
||||
if (selectedKandangId == null) return null;
|
||||
const visibleThrough = selectedCycle?.visible_through_date;
|
||||
const visibleKpis = throughVisibleDate(kpis, visibleThrough);
|
||||
const latest = visibleKpis[0] ?? null;
|
||||
const anchorDate = latest?.date ?? null;
|
||||
const weightsThroughAnchor = anchorDate
|
||||
? throughVisibleDate(weights, visibleThrough).filter((row) => row.date <= anchorDate)
|
||||
: [];
|
||||
const weightOnAnchor = anchorDate
|
||||
? (weightsThroughAnchor.find((row) => row.date === anchorDate) ?? weightsThroughAnchor[0])
|
||||
: null;
|
||||
const bobotAvg =
|
||||
latest && latest.iot_weight > 0
|
||||
? latest.iot_weight
|
||||
: (weightOnAnchor?.average_weight ?? null);
|
||||
|
||||
return buildDashboardInsightContext({
|
||||
kandangId: selectedKandangId,
|
||||
kandangName: selectedKandang?.kandang_name,
|
||||
cycleId: selectedCycle?.id,
|
||||
// Prefer latest KPI age — cycle.current_day can be ahead of available data.
|
||||
hari_ke: latest?.age ?? selectedCycle?.current_day ?? null,
|
||||
totalDays: selectedCycle?.total_days ?? null,
|
||||
fcr_terakhir: latest?.fcr ?? null,
|
||||
eef_terakhir: latest?.eef ?? null,
|
||||
bobot_avg_gram: bobotAvg,
|
||||
mortalitas_hari_ini_ekor: latest?.mortality ?? null,
|
||||
karung_dituang_hari_ini:
|
||||
latest != null ? latest.feed : (karung?.feed_use_today ?? karung?.out_today ?? null),
|
||||
pakan_kumulatif_karung: karung?.feed_use_total ?? latest?.feed_total ?? null,
|
||||
ayam_hidup_ekor: latest?.chicken_life ?? null,
|
||||
persen_hidup: latest?.chicken_life_percentage ?? null,
|
||||
doc_in_ekor: selectedCycle?.doc_in_count ?? null,
|
||||
kematian_kumulatif_ekor: latest?.mortality_total ?? null,
|
||||
panen_kumulatif_ekor: latest?.harvest_total ?? null,
|
||||
tonase_panen_kg: latest?.harvest_weight_total ?? null,
|
||||
uniformity_persen: weightOnAnchor?.uniformity ?? null,
|
||||
});
|
||||
}, [
|
||||
selectedKandangId,
|
||||
selectedKandang,
|
||||
selectedCycle,
|
||||
kpis,
|
||||
weights,
|
||||
karung,
|
||||
]);
|
||||
|
||||
const insightMetrics = useMemo(() => {
|
||||
if (selectedKandangId == null) return null;
|
||||
const visibleThrough = selectedCycle?.visible_through_date;
|
||||
const visibleKpis = throughVisibleDate(kpis, visibleThrough);
|
||||
const latest = visibleKpis[0] ?? null;
|
||||
const anchorDate = latest?.date ?? null;
|
||||
const weightsThroughAnchor = anchorDate
|
||||
? throughVisibleDate(weights, visibleThrough).filter((row) => row.date <= anchorDate)
|
||||
: throughVisibleDate(weights, visibleThrough);
|
||||
|
||||
return buildDashboardInsightMetrics({
|
||||
kpis: visibleKpis,
|
||||
weights: weightsThroughAnchor,
|
||||
currentDay: latest?.age ?? selectedCycle?.current_day ?? null,
|
||||
docInCount: selectedCycle?.doc_in_count ?? null,
|
||||
pakanKumulatifFallback: karung?.feed_use_total ?? latest?.feed_total ?? null,
|
||||
});
|
||||
}, [selectedKandangId, selectedCycle, kpis, weights, karung]);
|
||||
|
||||
if (!selectedCycle) return <EmptyFarm onSettings={() => setView('kandangSettings')} />;
|
||||
|
||||
const feedTotal = karung?.feed_use_total ?? kpis[0]?.feed_total ?? 0;
|
||||
@@ -344,32 +411,17 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
</div>
|
||||
</div>
|
||||
<section className="mt-8">
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-3">AI Insight</h2>
|
||||
{insights.length === 0 ? (
|
||||
<Banner>Belum ada insight untuk siklus ini.</Banner>
|
||||
{selectedKandangId != null ? (
|
||||
<AiInsightCard
|
||||
kandangId={selectedKandangId}
|
||||
context={insightContext ?? undefined}
|
||||
dashboardMetrics={insightMetrics}
|
||||
page="dashboard"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{insights.slice(0, 4).map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className="bg-white rounded-xl shadow-sm p-5 border border-gray-100"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">
|
||||
{item.section}
|
||||
</p>
|
||||
<h3 className="font-semibold text-gray-800 mt-1">
|
||||
{item.alert || 'Insight'}
|
||||
</h3>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{item.session}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-3 whitespace-pre-wrap">
|
||||
{item.insight_text}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
<div className="rounded-xl border border-amber-100 bg-amber-50 p-4 text-sm text-amber-800">
|
||||
Pilih satu kandang untuk menghasilkan AI Insight. Generate untuk semua kandang tidak
|
||||
didukung.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -9,10 +9,12 @@ import {
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { api, errorMessage } from '../services/apiClient.ts';
|
||||
import type { AIInsight, KPI } from '../types/api.ts';
|
||||
import type { KPI } from '../types/api.ts';
|
||||
import { useFarm } from '../context/FarmContext.tsx';
|
||||
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
|
||||
import { formatNumber, formatRatio } from '../utils/format.ts';
|
||||
import { buildEefInsightContext } from '../utils/insightContextBuilders.ts';
|
||||
import { PageAiInsight } from './shared/PageAiInsight.tsx';
|
||||
|
||||
type EefRow = {
|
||||
day: number;
|
||||
@@ -164,9 +166,8 @@ const SupportingMetricChart: React.FC<{
|
||||
);
|
||||
|
||||
const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
const { selectedCycle } = useFarm();
|
||||
const { selectedCycle, selectedKandang } = useFarm();
|
||||
const [rows, setRows] = useState<KPI[]>([]);
|
||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -176,12 +177,8 @@ const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
|
||||
if (!selectedCycle) return;
|
||||
setError(null);
|
||||
try {
|
||||
const [kpiRows, insightRows] = await Promise.all([
|
||||
api.kpis.list({ cycle_id: selectedCycle.id }),
|
||||
api.insights.list({ cycle_id: selectedCycle.id }),
|
||||
]);
|
||||
const kpiRows = await api.kpis.list({ cycle_id: selectedCycle.id });
|
||||
setRows(kpiRows);
|
||||
setInsights(insightRows);
|
||||
setLastUpdated(new Date());
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
@@ -191,7 +188,6 @@ const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
|
||||
useEffect(() => {
|
||||
if (!selectedCycle) {
|
||||
setRows([]);
|
||||
setInsights([]);
|
||||
setLastUpdated(null);
|
||||
return;
|
||||
}
|
||||
@@ -262,21 +258,16 @@ const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
|
||||
|
||||
const latestEef = getLatestValid('eef');
|
||||
|
||||
const eefInsights = useMemo(() => {
|
||||
const matched = insights.filter((item) => {
|
||||
const text =
|
||||
`${item.section ?? ''} ${item.alert ?? ''} ${item.insight_text ?? ''}`.toLowerCase();
|
||||
return (
|
||||
text.includes('eef') ||
|
||||
text.includes('fcr') ||
|
||||
text.includes('hidup') ||
|
||||
text.includes('live') ||
|
||||
text.includes('panen') ||
|
||||
text.includes('bw')
|
||||
);
|
||||
const insightContext = useMemo(() => {
|
||||
if (!selectedKandang) return null;
|
||||
return buildEefInsightContext({
|
||||
kandangId: selectedKandang.id,
|
||||
kandangName: selectedKandang.kandang_name,
|
||||
cycleId: selectedCycle?.id,
|
||||
totalDays: selectedCycle?.total_days ?? null,
|
||||
eefRows,
|
||||
});
|
||||
return (matched.length > 0 ? matched : insights).slice(0, 4);
|
||||
}, [insights]);
|
||||
}, [selectedKandang, selectedCycle, eefRows]);
|
||||
|
||||
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
|
||||
|
||||
@@ -304,37 +295,53 @@ const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
||||
<i className="fa-solid fa-brain text-red-600 mr-2" />
|
||||
AI Insight EEF
|
||||
</h2>
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : eefInsights.length === 0 ? (
|
||||
<Banner>Belum ada insight untuk siklus ini.</Banner>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{eefInsights.map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className="bg-white rounded-xl shadow-sm p-5 border border-gray-100"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">{item.section}</p>
|
||||
<h3 className="font-semibold text-gray-800 mt-1">{item.alert || 'Insight'}</h3>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{item.session}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-3 whitespace-pre-wrap">
|
||||
{item.insight_text}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{selectedKandang && insightContext ? (
|
||||
<PageAiInsight
|
||||
topic="eef"
|
||||
cacheKey={`eef-k${selectedKandang.id}-c${selectedCycle.id}-rows${eefRows.length}-eef${String(latestEef ?? 'null').replace('.', '_')}`}
|
||||
contextData={insightContext}
|
||||
chartSpec={{
|
||||
type: 'trend',
|
||||
historyKey: 'tren_eef_harian',
|
||||
title: 'Tren EEF',
|
||||
series: [{ field: 'eef', label: 'EEF', color: '#dc2626' }],
|
||||
}}
|
||||
scalarScope={{
|
||||
historyKey: 'tren_eef_harian',
|
||||
derive: {
|
||||
// Match EEF page StatCards: latest valid value, never a window mean.
|
||||
eefTerakhir_indeksTanpaSatuan: {
|
||||
field: 'eef',
|
||||
agg: 'lastValid',
|
||||
replaces: 'eef_terakhir',
|
||||
},
|
||||
fcrTerakhir_rasio: {
|
||||
field: 'fcr',
|
||||
agg: 'lastValid',
|
||||
replaces: 'fcr_terakhir',
|
||||
},
|
||||
persenHidupTerakhir_persen: {
|
||||
field: 'persen_hidup',
|
||||
agg: 'lastValid',
|
||||
replaces: 'persen_hidup',
|
||||
},
|
||||
bobotPanenTerakhir_gram: {
|
||||
field: 'bw_panen',
|
||||
agg: 'lastValid',
|
||||
replaces: 'bw_panen_gram',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<section>
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
||||
<i className="fa-solid fa-brain text-red-600 mr-2" />
|
||||
AI Insight EEF
|
||||
</h2>
|
||||
<Banner>Pilih kandang untuk melihat dan generate AI Insight.</Banner>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
|
||||
@@ -9,10 +9,12 @@ import {
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { api, errorMessage } from '../services/apiClient.ts';
|
||||
import type { AIInsight, ChickenWeight, KPI } from '../types/api.ts';
|
||||
import type { ChickenWeight, KPI } from '../types/api.ts';
|
||||
import { useFarm } from '../context/FarmContext.tsx';
|
||||
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
|
||||
import { formatNumber, formatRatio } from '../utils/format.ts';
|
||||
import { buildFcrInsightContext } from '../utils/insightContextBuilders.ts';
|
||||
import { PageAiInsight } from './shared/PageAiInsight.tsx';
|
||||
|
||||
type FcrRow = {
|
||||
day: number;
|
||||
@@ -155,10 +157,9 @@ const SupportingMetricChart: React.FC<{
|
||||
);
|
||||
|
||||
const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
const { selectedCycle } = useFarm();
|
||||
const { selectedCycle, selectedKandang } = useFarm();
|
||||
const [rows, setRows] = useState<KPI[]>([]);
|
||||
const [weightRows, setWeightRows] = useState<ChickenWeight[]>([]);
|
||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -168,13 +169,11 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
|
||||
if (!selectedCycle) return;
|
||||
setError(null);
|
||||
try {
|
||||
const [kpiRows, insightRows, weightList] = await Promise.all([
|
||||
const [kpiRows, weightList] = await Promise.all([
|
||||
api.kpis.list({ cycle_id: selectedCycle.id }),
|
||||
api.insights.list({ cycle_id: selectedCycle.id }),
|
||||
api.weights.list({ cycle_id: selectedCycle.id }),
|
||||
]);
|
||||
setRows(kpiRows);
|
||||
setInsights(insightRows);
|
||||
setWeightRows(weightList);
|
||||
setLastUpdated(new Date());
|
||||
} catch (err) {
|
||||
@@ -186,7 +185,6 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
|
||||
if (!selectedCycle) {
|
||||
setRows([]);
|
||||
setWeightRows([]);
|
||||
setInsights([]);
|
||||
setLastUpdated(null);
|
||||
return;
|
||||
}
|
||||
@@ -268,20 +266,16 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
|
||||
const latestFcr = getLatestValid('fcr');
|
||||
const latestIotWeight = getLatestValid('iotWeight');
|
||||
|
||||
const fcrInsights = useMemo(() => {
|
||||
const matched = insights.filter((item) => {
|
||||
const text =
|
||||
`${item.section ?? ''} ${item.alert ?? ''} ${item.insight_text ?? ''}`.toLowerCase();
|
||||
return (
|
||||
text.includes('fcr') ||
|
||||
text.includes('pakan') ||
|
||||
text.includes('feed') ||
|
||||
text.includes('konversi') ||
|
||||
text.includes('panen')
|
||||
);
|
||||
const insightContext = useMemo(() => {
|
||||
if (!selectedKandang) return null;
|
||||
return buildFcrInsightContext({
|
||||
kandangId: selectedKandang.id,
|
||||
kandangName: selectedKandang.kandang_name,
|
||||
cycleId: selectedCycle?.id,
|
||||
totalDays: selectedCycle?.total_days ?? null,
|
||||
fcrRows,
|
||||
});
|
||||
return (matched.length > 0 ? matched : insights).slice(0, 4);
|
||||
}, [insights]);
|
||||
}, [selectedKandang, selectedCycle, fcrRows]);
|
||||
|
||||
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
|
||||
|
||||
@@ -309,37 +303,53 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
||||
<i className="fa-solid fa-brain text-red-600 mr-2" />
|
||||
AI Insight FCR
|
||||
</h2>
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : fcrInsights.length === 0 ? (
|
||||
<Banner>Belum ada insight untuk siklus ini.</Banner>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{fcrInsights.map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className="bg-white rounded-xl shadow-sm p-5 border border-gray-100"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">{item.section}</p>
|
||||
<h3 className="font-semibold text-gray-800 mt-1">{item.alert || 'Insight'}</h3>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{item.session}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-3 whitespace-pre-wrap">
|
||||
{item.insight_text}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{selectedKandang && insightContext ? (
|
||||
<PageAiInsight
|
||||
topic="fcr"
|
||||
cacheKey={`fcr-k${selectedKandang.id}-c${selectedCycle.id}-rows${fcrRows.length}-fcr${String(latestFcr ?? 'null').replace('.', '_')}`}
|
||||
contextData={insightContext}
|
||||
chartSpec={{
|
||||
type: 'trend',
|
||||
historyKey: 'tren_fcr_harian',
|
||||
title: 'Tren FCR',
|
||||
series: [{ field: 'fcr_aktual', label: 'FCR aktual', color: '#16a34a' }],
|
||||
}}
|
||||
scalarScope={{
|
||||
historyKey: 'tren_fcr_harian',
|
||||
derive: {
|
||||
// Match FCR page StatCards: latest valid value, never a window mean.
|
||||
fcrTerakhir_rasio: {
|
||||
field: 'fcr_aktual',
|
||||
agg: 'lastValid',
|
||||
replaces: 'fcr_terakhir',
|
||||
},
|
||||
pakanTerakhir_karung: {
|
||||
field: 'pakan_karung',
|
||||
agg: 'lastValid',
|
||||
replaces: 'pakan_karung_terakhir',
|
||||
},
|
||||
bobotIotTerakhir_gram: {
|
||||
field: 'bobot_iot_gram',
|
||||
agg: 'lastValid',
|
||||
replaces: 'bobot_iot_gram_terakhir',
|
||||
},
|
||||
ayamHidupTerakhir_ekor: {
|
||||
field: 'ayam_hidup_ekor',
|
||||
agg: 'lastValid',
|
||||
replaces: 'ayam_hidup_ekor',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<section>
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
||||
<i className="fa-solid fa-brain text-red-600 mr-2" />
|
||||
AI Insight FCR
|
||||
</h2>
|
||||
<Banner>Pilih kandang untuk melihat dan generate AI Insight.</Banner>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { api, errorMessage } from '../services/apiClient.ts';
|
||||
import type { AIInsight, Cycle, Karung, ManualInput } from '../types/api.ts';
|
||||
import type { Cycle, Karung, ManualInput } from '../types/api.ts';
|
||||
import { useFarm } from '../context/FarmContext.tsx';
|
||||
import { Banner, EmptyFarm, Spinner } from './ui/Feedback.tsx';
|
||||
import { addDaysIso, cycleDayForDate, formatDateId, resolveCycleStatus, todayIso } from '../utils/format.ts';
|
||||
import { buildFeedSackInsightContext } from '../utils/insightContextBuilders.ts';
|
||||
import FeedStats from './feedSack/FeedStats.tsx';
|
||||
import FeedSaldoCard from './feedSack/FeedSaldoCard.tsx';
|
||||
import FeedTrendChart from './feedSack/FeedTrendChart.tsx';
|
||||
import FeedDailySummaryTable from './feedSack/FeedDailySummaryTable.tsx';
|
||||
import InitialBalanceCard from './feedSack/InitialBalanceCard.tsx';
|
||||
import { PageAiInsight } from './shared/PageAiInsight.tsx';
|
||||
|
||||
const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
const { selectedCycle, selectedKandang, selectedSite, cycles, setSelectedCycleId, updateCycle } =
|
||||
useFarm();
|
||||
const [rows, setRows] = useState<Karung[]>([]);
|
||||
const [manualRows, setManualRows] = useState<ManualInput[]>([]);
|
||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -26,13 +27,11 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [karungRows, insightRows, manualList] = await Promise.all([
|
||||
const [karungRows, manualList] = await Promise.all([
|
||||
api.karungs.list({ cycle_id: cycleId }),
|
||||
api.insights.list({ cycle_id: cycleId }),
|
||||
api.manualInputs.list({ cycle_id: cycleId }),
|
||||
]);
|
||||
setRows(karungRows);
|
||||
setInsights(insightRows);
|
||||
setManualRows(manualList);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
@@ -44,7 +43,6 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
useEffect(() => {
|
||||
if (!selectedCycle) {
|
||||
setRows([]);
|
||||
setInsights([]);
|
||||
setManualRows([]);
|
||||
return;
|
||||
}
|
||||
@@ -110,6 +108,43 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
})} ${date.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' })}`;
|
||||
}, [latest]);
|
||||
|
||||
const insightContext = useMemo(() => {
|
||||
if (!selectedKandang || !selectedCycle) return null;
|
||||
const manualByDate = new Map(sortedManualRows.map((row) => [row.date, row]));
|
||||
const dailyRows = sortedRows.map((row) => {
|
||||
const hari = cycleDayForDate(selectedCycle.start_date, row.date);
|
||||
const manual = manualByDate.get(row.date);
|
||||
return {
|
||||
day: hari,
|
||||
masuk: row.in_today,
|
||||
dituang: row.feed_use_today,
|
||||
keluar: row.out_today,
|
||||
masukManual: manual?.feed_in_manual ?? null,
|
||||
dituangManual: manual?.feed_use_manual ?? null,
|
||||
};
|
||||
});
|
||||
return buildFeedSackInsightContext({
|
||||
kandangId: selectedKandang.id,
|
||||
kandangName: selectedKandang.kandang_name,
|
||||
cycleId: selectedCycle.id,
|
||||
totalDays: selectedCycle.total_days,
|
||||
dailyRows,
|
||||
saldo_awal_karung: selectedCycle.feed_initial_balance ?? null,
|
||||
total_karung_masuk_iot: latest?.in_total ?? null,
|
||||
total_karung_dituang_iot: latest?.feed_use_total ?? null,
|
||||
total_karung_keluar_iot: latest?.out_total ?? null,
|
||||
total_karung_masuk_manual: latestManual?.feed_in_manual_total ?? null,
|
||||
total_karung_dituang_manual: latestManual?.feed_use_manual_total ?? null,
|
||||
});
|
||||
}, [
|
||||
selectedKandang,
|
||||
selectedCycle,
|
||||
sortedRows,
|
||||
sortedManualRows,
|
||||
latest,
|
||||
latestManual,
|
||||
]);
|
||||
|
||||
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
|
||||
|
||||
const cycleStatus = resolveCycleStatus(selectedCycle);
|
||||
@@ -249,40 +284,70 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
<Spinner />
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{/* AI Insight */}
|
||||
<section>
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
||||
<i className="fa-solid fa-brain text-red-600 mr-2"></i>
|
||||
AI Insight Hitung Karung
|
||||
</h2>
|
||||
{insights.length === 0 ? (
|
||||
<Banner>Belum ada insight untuk siklus ini.</Banner>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{insights.slice(0, 4).map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className="bg-white rounded-xl shadow-sm p-5 border border-gray-100"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">
|
||||
{item.section}
|
||||
</p>
|
||||
<h3 className="font-semibold text-gray-800 mt-1">
|
||||
{item.alert || 'Insight'}
|
||||
</h3>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{item.session}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-3 whitespace-pre-wrap">
|
||||
{item.insight_text}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{selectedKandang && insightContext ? (
|
||||
<PageAiInsight
|
||||
topic="hitung_karung"
|
||||
cacheKey={`pakan-k${selectedKandang.id}-c${selectedCycle.id}-day${currentDay}-rows${sortedRows.length}`}
|
||||
contextData={insightContext}
|
||||
chartSpec={{
|
||||
type: 'trend',
|
||||
historyKey: 'tren_harian',
|
||||
title: 'Karung dituang vs masuk per hari',
|
||||
unit: 'karung',
|
||||
series: [
|
||||
{ field: 'dituang', label: 'Dituang (IOT)', color: '#dc2626' },
|
||||
{ field: 'masuk', label: 'Masuk (IOT)', color: '#3b82f6' },
|
||||
{ field: 'masukManual', label: 'Masuk (Manual)', color: '#0ea5e9' },
|
||||
],
|
||||
}}
|
||||
scalarScope={{
|
||||
historyKey: 'tren_harian',
|
||||
derive: {
|
||||
karungMasuk_karung: {
|
||||
field: 'masuk',
|
||||
agg: 'sum',
|
||||
replaces: 'total_karung_masuk_iot',
|
||||
},
|
||||
karungDituang_karung: {
|
||||
field: 'dituang',
|
||||
agg: 'sum',
|
||||
replaces: 'total_karung_dituang_iot',
|
||||
},
|
||||
karungKeluar_karung: { field: 'keluar', agg: 'sum' },
|
||||
karungMasukManual_karung: {
|
||||
field: 'masukManual',
|
||||
agg: 'sum',
|
||||
replaces: 'total_karung_masuk_manual',
|
||||
},
|
||||
karungDituangManual_karung: {
|
||||
field: 'dituangManual',
|
||||
agg: 'sum',
|
||||
replaces: 'total_karung_dituang_manual',
|
||||
},
|
||||
rataKarungDituangPerHari_karung: {
|
||||
field: 'dituang',
|
||||
agg: 'mean',
|
||||
replaces: 'rata_karung_per_hari',
|
||||
},
|
||||
},
|
||||
drop: [
|
||||
'total_karung_masuk_iot',
|
||||
'total_karung_dituang_iot',
|
||||
'total_karung_masuk_manual',
|
||||
'total_karung_dituang_manual',
|
||||
'rata_karung_per_hari',
|
||||
],
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<section>
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
||||
<i className="fa-solid fa-brain text-red-600 mr-2"></i>
|
||||
AI Insight Hitung Karung
|
||||
</h2>
|
||||
<Banner>Pilih kandang untuk melihat dan generate AI Insight.</Banner>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Saldo Awal */}
|
||||
<InitialBalanceCard
|
||||
|
||||
+144
-92
@@ -9,13 +9,20 @@ import {
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { api, errorMessage } from '../services/apiClient.ts';
|
||||
import type { AIInsight, Flock, PanelIoT } from '../types/api.ts';
|
||||
import type { Flock, PanelIoT } from '../types/api.ts';
|
||||
import { useFarm } from '../context/FarmContext.tsx';
|
||||
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
|
||||
import { cycleDayForDate, formatDateId, formatDateRange, formatNumber } from '../utils/format.ts';
|
||||
import { cycleDayForDate, formatDateId, formatDateRange, formatNumber, resolveCycleBoundDate, todayIso } from '../utils/format.ts';
|
||||
import {
|
||||
getTempStandardByDay,
|
||||
getWaterStandardByDay,
|
||||
HUMIDITY_RANGE_PCT,
|
||||
} from '../utils/cp707EnvStandards.ts';
|
||||
import { buildIotInsightContext } from '../utils/insightContextBuilders.ts';
|
||||
import { computeExperienceCalc } from './iotPanel/chillFactor.ts';
|
||||
import IotPanelChillFactorCard from './iotPanel/IotPanelChillFactorCard.tsx';
|
||||
import IotPanelExperienceTempCard from './iotPanel/IotPanelExperienceTempCard.tsx';
|
||||
import { PageAiInsight } from './shared/PageAiInsight.tsx';
|
||||
|
||||
const cardClass = 'rounded-xl border border-slate-200 bg-white p-4 shadow-sm';
|
||||
|
||||
@@ -57,7 +64,6 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
const [dates, setDates] = useState<string[]>([]);
|
||||
const [selectedDate, setSelectedDate] = useState('');
|
||||
const [dayRows, setDayRows] = useState<PanelIoT[]>([]);
|
||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -82,7 +88,6 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
setDates([]);
|
||||
setSelectedDate('');
|
||||
setDayRows([]);
|
||||
setInsights([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
@@ -109,7 +114,6 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
setDates([]);
|
||||
setSelectedDate('');
|
||||
setDayRows([]);
|
||||
setInsights([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
@@ -117,19 +121,26 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [dateList, insightRows] = await Promise.all([
|
||||
api.panelIot.dates({ cycle_id: selectedCycle.id, flock_id: selectedFlockId }),
|
||||
api.insights.list({ cycle_id: selectedCycle.id }),
|
||||
]);
|
||||
const dateList = await api.panelIot.dates({
|
||||
cycle_id: selectedCycle.id,
|
||||
flock_id: selectedFlockId,
|
||||
});
|
||||
if (cancelled) return;
|
||||
const sortedDates = [...dateList].sort();
|
||||
setDates(sortedDates);
|
||||
setInsights(insightRows);
|
||||
setSelectedDate((previous) =>
|
||||
previous && sortedDates.includes(previous)
|
||||
? previous
|
||||
: (sortedDates[sortedDates.length - 1] ?? '')
|
||||
);
|
||||
const cycleMin = selectedCycle.start_date;
|
||||
const cycleMax = resolveCycleBoundDate(selectedCycle);
|
||||
const fallbackDate = todayIso() > cycleMax ? cycleMax : todayIso() < cycleMin ? cycleMin : todayIso();
|
||||
setSelectedDate((previous) => {
|
||||
if (sortedDates.length > 0) {
|
||||
return previous && sortedDates.includes(previous)
|
||||
? previous
|
||||
: (sortedDates[sortedDates.length - 1] ?? fallbackDate);
|
||||
}
|
||||
// No IoT dates yet — keep a navigable date inside the cycle window.
|
||||
if (previous && previous >= cycleMin && previous <= cycleMax) return previous;
|
||||
return fallbackDate;
|
||||
});
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(errorMessage(err));
|
||||
} finally {
|
||||
@@ -224,21 +235,29 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
[summary, dayAge]
|
||||
);
|
||||
|
||||
const iotInsights = useMemo(() => {
|
||||
const matched = insights.filter((item) => {
|
||||
const text =
|
||||
`${item.section ?? ''} ${item.alert ?? ''} ${item.insight_text ?? ''}`.toLowerCase();
|
||||
return (
|
||||
text.includes('iot') ||
|
||||
text.includes('suhu') ||
|
||||
text.includes('kelembapan') ||
|
||||
text.includes('humidity') ||
|
||||
text.includes('temperature') ||
|
||||
text.includes('lingkungan')
|
||||
);
|
||||
const insightContext = useMemo(() => {
|
||||
if (!selectedKandang) return null;
|
||||
return buildIotInsightContext({
|
||||
kandangId: selectedKandang.id,
|
||||
kandangName: selectedKandang.kandang_name,
|
||||
cycleId: selectedCycle?.id,
|
||||
hari_ke: dayAge,
|
||||
totalDays: selectedCycle?.total_days ?? null,
|
||||
suhu_rata_rata_C: summary?.temp ?? null,
|
||||
experience_suhu_C: summary?.experienceTemp ?? null,
|
||||
kelembapan_persen: summary?.humidity ?? null,
|
||||
kecepatan_angin_mPerDetik: summary?.windSpeed ?? null,
|
||||
konsumsi_air_L: summary?.waterTotal ?? null,
|
||||
setpoint_C: getTempStandardByDay(dayAge),
|
||||
suhu_min_C: summary?.minTemp ?? null,
|
||||
suhu_max_C: summary?.maxTemp ?? null,
|
||||
waktu_data: selectedDate || null,
|
||||
});
|
||||
return (matched.length > 0 ? matched : insights).slice(0, 4);
|
||||
}, [insights]);
|
||||
}, [selectedKandang, selectedCycle, summary, dayAge, selectedDate]);
|
||||
|
||||
// Always allow navigating the cycle window — never hide empty days behind missing IoT dates.
|
||||
const datePickerMin = selectedCycle?.start_date ?? undefined;
|
||||
const datePickerMax = selectedCycle ? resolveCycleBoundDate(selectedCycle) : undefined;
|
||||
|
||||
const refresh = () => {
|
||||
setRefreshing(true);
|
||||
@@ -285,63 +304,94 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
/>
|
||||
|
||||
{error && <Banner kind="error">{error}</Banner>}
|
||||
{loading && dayRows.length === 0 ? (
|
||||
<Spinner />
|
||||
) : summary ? (
|
||||
<>
|
||||
<section>
|
||||
<h2 className="mb-3 text-lg font-bold text-gray-800">
|
||||
<i className="fa-solid fa-brain mr-2 text-red-600" />
|
||||
AI Insight Panel IoT
|
||||
</h2>
|
||||
{iotInsights.length === 0 ? (
|
||||
<Banner>Belum ada insight untuk siklus ini.</Banner>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{iotInsights.map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">
|
||||
{item.section}
|
||||
</p>
|
||||
<h3 className="mt-1 font-semibold text-gray-800">
|
||||
{item.alert || 'Insight'}
|
||||
</h3>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{item.session}</span>
|
||||
</div>
|
||||
<p className="mt-3 whitespace-pre-wrap text-sm text-gray-600">
|
||||
{item.insight_text}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<i className="fa-regular fa-calendar" />
|
||||
<label htmlFor="iot-date-picker" className="text-slate-500">
|
||||
Tanggal:
|
||||
</label>
|
||||
<input
|
||||
id="iot-date-picker"
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
min={dates[0] ?? undefined}
|
||||
max={dates[dates.length - 1] ?? undefined}
|
||||
onChange={(event) => setSelectedDate(event.target.value)}
|
||||
className="rounded-lg border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-700 focus:ring-2 focus:ring-red-500 focus:border-red-500"
|
||||
/>
|
||||
</span>
|
||||
<span className="text-xs">{summary.count} pembacaan</span>
|
||||
</div>
|
||||
{selectedFlockId == null ? (
|
||||
<div className={cardClass}>
|
||||
<p className="py-8 text-center text-slate-400">Pilih flock untuk melihat data panel IoT.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<i className="fa-regular fa-calendar" />
|
||||
<label htmlFor="iot-date-picker" className="text-slate-500">
|
||||
Tanggal:
|
||||
</label>
|
||||
<input
|
||||
id="iot-date-picker"
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
min={datePickerMin}
|
||||
max={datePickerMax}
|
||||
onChange={(event) => setSelectedDate(event.target.value)}
|
||||
className="rounded-lg border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-700 focus:ring-2 focus:ring-red-500 focus:border-red-500"
|
||||
/>
|
||||
</span>
|
||||
<span className="text-xs">
|
||||
{summary
|
||||
? `${summary.count} pembacaan`
|
||||
: dates.length === 0
|
||||
? 'Belum ada tanggal dengan data IoT'
|
||||
: 'Tidak ada pembacaan pada tanggal ini'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedKandang && insightContext ? (
|
||||
<PageAiInsight
|
||||
topic="iot_panel"
|
||||
cacheKey={`iot-k${selectedKandang.id}-c${selectedCycle.id}-d${selectedDate}-t${summary?.temp ?? 'na'}`}
|
||||
contextData={insightContext}
|
||||
chartSpec={{
|
||||
type: 'condition',
|
||||
title: 'Kondisi kandang vs standar CP 707',
|
||||
bars: [
|
||||
{
|
||||
label: 'Suhu rata-rata',
|
||||
actual: summary?.temp ?? null,
|
||||
standard: getTempStandardByDay(dayAge),
|
||||
unit: '°C',
|
||||
note:
|
||||
getTempStandardByDay(dayAge) === null
|
||||
? 'Umur tidak diketahui, standar suhu tidak dapat ditentukan.'
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
label: 'Kelembapan',
|
||||
actual: summary?.humidity ?? null,
|
||||
standard: HUMIDITY_RANGE_PCT.max,
|
||||
unit: '%',
|
||||
note: `Rentang ideal buku ${HUMIDITY_RANGE_PCT.min}–${HUMIDITY_RANGE_PCT.max}%; batang biru menandai batas atas.`,
|
||||
},
|
||||
{
|
||||
label: 'Experience Suhu',
|
||||
actual: summary?.experienceTemp ?? null,
|
||||
standard: getTempStandardByDay(dayAge),
|
||||
unit: '°C',
|
||||
},
|
||||
{
|
||||
label: 'Konsumsi Air',
|
||||
actual: summary?.waterTotal ?? null,
|
||||
standard: getWaterStandardByDay(dayAge)?.max ?? null,
|
||||
unit: 'L',
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<section>
|
||||
<h2 className="mb-3 text-lg font-bold text-gray-800">
|
||||
<i className="fa-solid fa-brain mr-2 text-red-600" />
|
||||
AI Insight Panel IoT
|
||||
</h2>
|
||||
<Banner>Pilih kandang untuk melihat dan generate AI Insight.</Banner>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{selectedFlockId != null &&
|
||||
(loading && dayRows.length === 0 ? (
|
||||
<Spinner />
|
||||
) : summary ? (
|
||||
<>
|
||||
<section className="grid gap-3 xl:grid-cols-[1.25fr_2.35fr]">
|
||||
<div className="space-y-3">
|
||||
<article className={cardClass}>
|
||||
@@ -518,14 +568,16 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
<IotPanelChillFactorCard dayAge={dayAge} chillFactor={experienceCalc.chillFactor} />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<div className={cardClass}>
|
||||
<p className="py-8 text-center text-slate-400">
|
||||
Belum ada data panel IoT untuk flock ini.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className={cardClass}>
|
||||
<p className="py-8 text-center text-slate-400">
|
||||
{dates.length === 0
|
||||
? 'Belum ada data panel IoT untuk flock ini. Tanggal tetap bisa dipilih dalam rentang siklus; generate ulang setelah data tersedia.'
|
||||
: `Belum ada data panel IoT untuk ${selectedDate ? formatDateId(selectedDate) : 'tanggal ini'}.`}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{selectedChartKey &&
|
||||
(() => {
|
||||
|
||||
@@ -468,7 +468,7 @@ const SettingsPage: React.FC = () => {
|
||||
<section className="bg-white rounded-xl shadow-sm p-5">
|
||||
<h2 className="font-semibold text-gray-800 mb-3">Flock</h2>
|
||||
<p className="mb-3 text-xs text-gray-500">
|
||||
Flock tetap untuk kandang ini (dipakai Panel IoT). Tidak terikat ke siklus.
|
||||
Lantai tetap untuk kandang ini (dipakai Panel IoT). Tidak terikat ke siklus.
|
||||
</p>
|
||||
{isSuperAdmin ? (
|
||||
<form
|
||||
@@ -501,7 +501,7 @@ const SettingsPage: React.FC = () => {
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="mb-4 text-xs text-gray-500">Hanya admin yang dapat membuat flock.</p>
|
||||
<p className="mb-4 text-xs text-gray-500">Hanya admin yang dapat membuat lantai.</p>
|
||||
)}
|
||||
{!selectedKandangId ? (
|
||||
<p className="text-xs text-gray-500">Pilih kandang terlebih dahulu.</p>
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { api, ApiError, errorMessage } from '../services/apiClient.ts';
|
||||
import type { AIInsight, ChickenWeight, LatestAvg } from '../types/api.ts';
|
||||
import type { ChickenWeight, LatestAvg } from '../types/api.ts';
|
||||
import { useFarm } from '../context/FarmContext.tsx';
|
||||
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
|
||||
import { formatDateId, formatDateRange, formatNumber, resolveCycleStatus } from '../utils/format.ts';
|
||||
import { formatWeight } from '../utils/weight.ts';
|
||||
import { buildWeightInsightContext } from '../utils/insightContextBuilders.ts';
|
||||
import WeightStats from './weight/WeightStats.tsx';
|
||||
import DailyWeightProgressionChart from './weight/DailyWeightProgressionChart.tsx';
|
||||
import DailyWeightGainChart from './weight/DailyWeightGainChart.tsx';
|
||||
import DailyDetailPanel from './weight/DailyDetailPanel.tsx';
|
||||
import { PageAiInsight } from './shared/PageAiInsight.tsx';
|
||||
|
||||
const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
const { selectedCycle, selectedSite, selectedKandang } = useFarm();
|
||||
const [rows, setRows] = useState<ChickenWeight[]>([]);
|
||||
const [latest, setLatest] = useState<LatestAvg | null>(null);
|
||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -26,12 +27,8 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
const reload = async (cycleId: number) => {
|
||||
setError(null);
|
||||
try {
|
||||
const [list, insightRows] = await Promise.all([
|
||||
api.weights.list({ cycle_id: cycleId }),
|
||||
api.insights.list({ cycle_id: cycleId }),
|
||||
]);
|
||||
const list = await api.weights.list({ cycle_id: cycleId });
|
||||
setRows(list);
|
||||
setInsights(insightRows);
|
||||
try {
|
||||
setLatest(await api.weights.latestAvg({ cycle_id: cycleId }));
|
||||
} catch (err) {
|
||||
@@ -47,7 +44,6 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
if (!selectedCycle) {
|
||||
setRows([]);
|
||||
setLatest(null);
|
||||
setInsights([]);
|
||||
setSelectedDate('');
|
||||
return;
|
||||
}
|
||||
@@ -106,14 +102,6 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
const minDate = rows.length > 0 ? (rows[rows.length - 1]?.date ?? '') : '';
|
||||
const maxDate = rows.length > 0 ? (rows[0]?.date ?? '') : '';
|
||||
|
||||
const weightInsights = useMemo(() => {
|
||||
const matched = insights.filter((item) => {
|
||||
const section = `${item.section ?? ''} ${item.alert ?? ''}`.toLowerCase();
|
||||
return section.includes('berat') || section.includes('weight') || section.includes('timbang');
|
||||
});
|
||||
return (matched.length > 0 ? matched : insights).slice(0, 4);
|
||||
}, [insights]);
|
||||
|
||||
// Prefer the latest non-zero row for the KPI card so a closed cycle with
|
||||
// trailing empty days does not read as "0 g".
|
||||
const kpiLatest = useMemo<LatestAvg | null>(() => {
|
||||
@@ -132,6 +120,20 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
};
|
||||
}, [latest, rows]);
|
||||
|
||||
const insightContext = useMemo(() => {
|
||||
if (!selectedKandang) return null;
|
||||
return buildWeightInsightContext({
|
||||
kandangId: selectedKandang.id,
|
||||
kandangName: selectedKandang.kandang_name,
|
||||
cycleId: selectedCycle?.id,
|
||||
totalDays: selectedCycle?.total_days ?? null,
|
||||
weightRows: chartRows,
|
||||
averageWeight: kpiLatest?.average_weight ?? null,
|
||||
uniformity: kpiLatest?.uniformity ?? null,
|
||||
adg: kpiLatest?.average_daily_gain ?? null,
|
||||
});
|
||||
}, [selectedKandang, selectedCycle, chartRows, kpiLatest]);
|
||||
|
||||
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
|
||||
|
||||
return (
|
||||
@@ -177,37 +179,53 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
<Spinner />
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{/* AI Insight */}
|
||||
<section>
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-3">AI Insight</h2>
|
||||
{weightInsights.length === 0 ? (
|
||||
<Banner>Belum ada insight untuk siklus ini.</Banner>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{weightInsights.map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className="bg-white rounded-xl shadow-sm p-5 border border-gray-100"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">
|
||||
{item.section}
|
||||
</p>
|
||||
<h3 className="font-semibold text-gray-800 mt-1">
|
||||
{item.alert || 'Insight'}
|
||||
</h3>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{item.session}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-3 whitespace-pre-wrap">
|
||||
{item.insight_text}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{selectedKandang && insightContext ? (
|
||||
<PageAiInsight
|
||||
topic="berat_ayam"
|
||||
cacheKey={`wm-k${selectedKandang.id}-c${selectedCycle.id}-day${selectedCycle.current_day}-rows${chartRows.length}`}
|
||||
contextData={insightContext}
|
||||
chartSpec={{
|
||||
type: 'trend',
|
||||
historyKey: 'tren_harian',
|
||||
title: 'Bobot aktual vs target',
|
||||
unit: 'gram',
|
||||
gap: { actualField: 'bobot_gram', targetField: 'target_gram' },
|
||||
series: [
|
||||
{ field: 'bobot_gram', label: 'Bobot aktual', color: '#7c3aed' },
|
||||
{ field: 'target_gram', label: 'Target', color: '#9ca3af', dashed: true },
|
||||
],
|
||||
}}
|
||||
scalarScope={{
|
||||
historyKey: 'tren_harian',
|
||||
derive: {
|
||||
// Match Weight page StatCards: latest valid value, never a window mean.
|
||||
bobotTerakhir_gram: {
|
||||
field: 'bobot_gram',
|
||||
agg: 'lastValid',
|
||||
replaces: 'averageWeight',
|
||||
},
|
||||
targetBobot_gram: {
|
||||
field: 'target_gram',
|
||||
agg: 'lastValid',
|
||||
replaces: 'targetWeight',
|
||||
},
|
||||
pertambahanHarian_gramPerHari: {
|
||||
field: 'adg_gram_per_hari',
|
||||
agg: 'lastValid',
|
||||
replaces: 'adg',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<section>
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
||||
<i className="fa-solid fa-brain text-red-600 mr-2" />
|
||||
AI Insight Berat Ayam
|
||||
</h2>
|
||||
<Banner>Pilih kandang untuk melihat dan generate AI Insight.</Banner>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* TOP SECTION: DATA SELURUH SIKLUS */}
|
||||
<div className="space-y-8">
|
||||
|
||||
@@ -179,9 +179,6 @@ vi.mock('../../services/apiClient.ts', () => ({
|
||||
list: vi.fn().mockResolvedValue(karungRows),
|
||||
request: vi.fn().mockResolvedValue({ karung: {}, upstream: {} }),
|
||||
},
|
||||
insights: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
manualInputs: {
|
||||
list: vi.fn().mockResolvedValue(manualRows),
|
||||
},
|
||||
@@ -189,6 +186,10 @@ vi.mock('../../services/apiClient.ts', () => ({
|
||||
errorMessage: (err: unknown) => (err instanceof Error ? err.message : 'Terjadi kesalahan'),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/PageAiInsight.tsx', () => ({
|
||||
PageAiInsight: () => <div data-testid="page-ai-insight" />,
|
||||
}));
|
||||
|
||||
vi.mock('../../context/FarmContext.tsx', () => ({
|
||||
useFarm: () => ({
|
||||
selectedSite: { id: 1, site_name: 'Sukawarna', user: 1, created_at: '', updated_at: '' },
|
||||
@@ -199,6 +200,7 @@ vi.mock('../../context/FarmContext.tsx', () => ({
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
selectedKandangId: 1,
|
||||
selectedCycle: cycle,
|
||||
cycles: [cycle],
|
||||
setSelectedCycleId: vi.fn(),
|
||||
@@ -216,7 +218,6 @@ describe('FeedSackCountingPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.karungs.list).mockResolvedValue(karungRows);
|
||||
vi.mocked(api.manualInputs.list).mockResolvedValue(manualRows);
|
||||
vi.mocked(api.insights.list).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('renders the recreated hitung karung view with stats, saldo, and daily table', async () => {
|
||||
@@ -229,7 +230,7 @@ describe('FeedSackCountingPage', () => {
|
||||
screen.getByText('Monitoring karung masuk dan karung dituang per hari')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/10 Agu 2026 - 13 Sep 2026/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Sync hari ini/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Sync/i })).toBeInTheDocument();
|
||||
|
||||
// Ringkasan stats
|
||||
expect(screen.getAllByText('Karung Masuk').length).toBeGreaterThan(0);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { api, errorMessage } from '../../services/apiClient.ts';
|
||||
import type { AIInsight, ChickenCounting, KPI, ManualInput } from '../../types/api.ts';
|
||||
import type { ChickenCounting, KPI, ManualInput } from '../../types/api.ts';
|
||||
import { useFarm } from '../../context/FarmContext.tsx';
|
||||
import { Banner, EmptyFarm, PageHeader, Spinner } from '../ui/Feedback.tsx';
|
||||
import {
|
||||
@@ -13,9 +13,11 @@ import {
|
||||
resolveCycleStatus,
|
||||
todayIso,
|
||||
} from '../../utils/format.ts';
|
||||
import { buildCountingInsightContext } from '../../utils/insightContextBuilders.ts';
|
||||
import PopulationTrendChart from './PopulationTrendChart.tsx';
|
||||
import CountingAccuracyWarningBanner from './CountingAccuracyWarningBanner.tsx';
|
||||
import { getCountingAccuracyWarning } from '../../utils/countingAccuracyWarning.ts';
|
||||
import { PageAiInsight } from '../shared/PageAiInsight.tsx';
|
||||
|
||||
type Row = {
|
||||
day: number;
|
||||
@@ -50,7 +52,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
const [kpis, setKpis] = useState<KPI[]>([]);
|
||||
const [countings, setCountings] = useState<ChickenCounting[]>([]);
|
||||
const [manualRows, setManualRows] = useState<ManualInput[]>([]);
|
||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -64,16 +65,14 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [k, c, m, i] = await Promise.all([
|
||||
const [k, c, m] = await Promise.all([
|
||||
api.kpis.list({ cycle_id: cycleId }),
|
||||
api.countings.list({ cycle_id: cycleId }),
|
||||
api.manualInputs.list({ cycle_id: cycleId }),
|
||||
api.insights.list({ cycle_id: cycleId }),
|
||||
]);
|
||||
setKpis(k);
|
||||
setCountings(c);
|
||||
setManualRows(m);
|
||||
setInsights(i);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
@@ -86,7 +85,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
setKpis([]);
|
||||
setCountings([]);
|
||||
setManualRows([]);
|
||||
setInsights([]);
|
||||
return;
|
||||
}
|
||||
void loadData(selectedCycle.id);
|
||||
@@ -192,9 +190,8 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
const totalPanen = latest?.harvestTotal ?? 0;
|
||||
const totalBeratPanen = latest?.harvestWeightTotal ?? 0;
|
||||
const currentPopulation = latest?.stockAkhir ?? initialPopulation;
|
||||
const mortalityRate = initialPopulation
|
||||
? ((totalMortality / initialPopulation) * 100).toFixed(2)
|
||||
: '0.00';
|
||||
const mortalityRate =
|
||||
initialPopulation > 0 ? (totalMortality / initialPopulation) * 100 : null;
|
||||
|
||||
return {
|
||||
initialPopulation,
|
||||
@@ -206,6 +203,38 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
};
|
||||
}, [rows, selectedCycle]);
|
||||
|
||||
const insightContext = useMemo(() => {
|
||||
if (!selectedKandang || !selectedCycle) return null;
|
||||
// Cap insight history to last day with KPI (or counting) data — calendar
|
||||
// padding can extend past days that actually have operational figures.
|
||||
const lastKpiAge = kpis.length > 0 ? Math.max(...kpis.map((k) => k.age)) : null;
|
||||
const lastCountingDay =
|
||||
countings.length > 0
|
||||
? Math.max(
|
||||
...countings.map((c) => cycleDayForDate(selectedCycle.start_date, c.date))
|
||||
)
|
||||
: null;
|
||||
const lastDataDay =
|
||||
lastKpiAge != null || lastCountingDay != null
|
||||
? Math.max(lastKpiAge ?? 0, lastCountingDay ?? 0)
|
||||
: null;
|
||||
const insightRows =
|
||||
lastDataDay != null ? rows.filter((row) => row.day <= lastDataDay) : rows;
|
||||
return buildCountingInsightContext({
|
||||
kandangId: selectedKandang.id,
|
||||
kandangName: selectedKandang.kandang_name,
|
||||
cycleId: selectedCycle.id,
|
||||
totalDays: selectedCycle.total_days ?? null,
|
||||
dailyRows: insightRows,
|
||||
populasi_awal_ekor: stats.initialPopulation > 0 ? stats.initialPopulation : null,
|
||||
populasi_kini_ekor: stats.currentPopulation,
|
||||
mortalitas_kumulatif_ekor: stats.totalMortality,
|
||||
mortalitas_persen: stats.mortalityRate,
|
||||
panen_kumulatif_ekor: stats.totalPanen,
|
||||
berat_panen_kumulatif_kg: stats.totalBeratPanen,
|
||||
});
|
||||
}, [selectedKandang, selectedCycle, rows, stats, kpis, countings]);
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
rows.map((r) => ({
|
||||
@@ -306,36 +335,41 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
<CountingAccuracyWarningBanner warning={countingAccuracyWarning} />
|
||||
)}
|
||||
|
||||
{/* AI Insight */}
|
||||
<section className="mb-6">
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
||||
<i className="fa-solid fa-wand-magic-sparkles text-purple-500 mr-2"></i>
|
||||
AI Insight
|
||||
</h2>
|
||||
{insights.length === 0 ? (
|
||||
<Banner>Belum ada insight untuk siklus ini.</Banner>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{insights.slice(0, 4).map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className="bg-white rounded-xl shadow-sm p-5 border border-gray-100"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">{item.section}</p>
|
||||
<h3 className="font-semibold text-gray-800 mt-1">{item.alert || 'Insight'}</h3>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{item.session}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-3 whitespace-pre-wrap">
|
||||
{item.insight_text}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{selectedKandang && insightContext ? (
|
||||
<div className="mb-6">
|
||||
<PageAiInsight
|
||||
topic="hitung_ayam"
|
||||
cacheKey={`hitung-k${selectedKandang.id}-c${selectedCycle.id}-day${selectedCycle.current_day}-rows${rows.length}`}
|
||||
contextData={insightContext}
|
||||
chartSpec={{
|
||||
type: 'trend',
|
||||
historyKey: 'tren_populasi_harian',
|
||||
title: 'Tren populasi',
|
||||
unit: 'ekor',
|
||||
series: [{ field: 'populasi', label: 'Populasi', color: '#7c3aed' }],
|
||||
}}
|
||||
scalarScope={{
|
||||
historyKey: 'tren_populasi_harian',
|
||||
derive: {
|
||||
populasiKini_ekor: {
|
||||
field: 'populasi',
|
||||
agg: 'lastValid',
|
||||
replaces: 'populasi_kini_ekor',
|
||||
},
|
||||
},
|
||||
drop: ['populasi_kini_ekor'],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<section className="mb-6">
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
||||
<i className="fa-solid fa-wand-magic-sparkles text-purple-500 mr-2"></i>
|
||||
AI Insight
|
||||
</h2>
|
||||
<Banner>Pilih kandang untuk melihat dan generate AI Insight.</Banner>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Population Chart */}
|
||||
<div className="mb-6">
|
||||
@@ -413,7 +447,9 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-gray-600 mb-1">Tingkat Mortalitas</p>
|
||||
<p className="text-xl font-bold text-orange-600">{stats.mortalityRate}%</p>
|
||||
<p className="text-xl font-bold text-orange-600">
|
||||
{stats.mortalityRate == null ? 'N/A' : `${stats.mortalityRate.toFixed(2)}%`}
|
||||
</p>
|
||||
</div>
|
||||
<i className="fa-solid fa-chart-line text-orange-500 text-2xl"></i>
|
||||
</div>
|
||||
|
||||
@@ -28,13 +28,14 @@ vi.mock('../../../services/apiClient.ts', () => ({
|
||||
]),
|
||||
create: vi.fn(),
|
||||
},
|
||||
insights: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
},
|
||||
errorMessage: (err: unknown) => (err instanceof Error ? err.message : 'Terjadi kesalahan'),
|
||||
}));
|
||||
|
||||
vi.mock('../../shared/PageAiInsight.tsx', () => ({
|
||||
PageAiInsight: () => <div data-testid="page-ai-insight" />,
|
||||
}));
|
||||
|
||||
vi.mock('../../../context/FarmContext.tsx', () => ({
|
||||
useFarm: () => ({
|
||||
selectedSite: { id: 1, site_name: 'Sukawarna', user: 1, created_at: '', updated_at: '' },
|
||||
@@ -45,6 +46,7 @@ vi.mock('../../../context/FarmContext.tsx', () => ({
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
selectedKandangId: 1,
|
||||
selectedCycle: {
|
||||
id: 1,
|
||||
kandang: 1,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface ConditionBar {
|
||||
label: string;
|
||||
actual: number | null;
|
||||
standard: number | null;
|
||||
unit: string;
|
||||
/** Optional note shown under the bar, e.g. why a standard is absent. */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface InsightConditionChartProps {
|
||||
bars: ConditionBar[];
|
||||
title?: string;
|
||||
emptyLabel?: string;
|
||||
}
|
||||
|
||||
const AKTUAL = '#dc2626';
|
||||
const STANDAR = '#3b82f6';
|
||||
|
||||
const fmt = (value: number, unit: string) =>
|
||||
`${Number(value).toLocaleString('id-ID', { maximumFractionDigits: 1 })}${unit ? ` ${unit}` : ''}`;
|
||||
|
||||
/**
|
||||
* Current readings against their CP 707 standard, one row per metric.
|
||||
* Used when there is no per-day history (e.g. live IoT snapshot).
|
||||
*/
|
||||
const InsightConditionChart: React.FC<InsightConditionChartProps> = ({
|
||||
bars,
|
||||
title,
|
||||
emptyLabel = 'Belum ada pembacaan sensor untuk digambarkan.',
|
||||
}) => {
|
||||
const usable = bars.filter((bar) => bar.actual !== null || bar.standard !== null);
|
||||
|
||||
if (usable.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-100 bg-gray-50/60 p-4 text-center text-sm text-gray-500">
|
||||
{emptyLabel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
|
||||
{title && <h4 className="mb-1 text-sm font-bold text-gray-800">{title}</h4>}
|
||||
<div className="mb-3 flex items-center gap-4 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: AKTUAL }} />
|
||||
Aktual
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: STANDAR }} />
|
||||
Standar CP 707
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{usable.map((bar) => {
|
||||
const max = Math.max(bar.actual ?? 0, bar.standard ?? 0, 1);
|
||||
const pct = (value: number | null) =>
|
||||
value === null ? 0 : Math.max(2, Math.round((value / max) * 100));
|
||||
|
||||
return (
|
||||
<div key={bar.label}>
|
||||
<div className="mb-1 flex items-baseline justify-between">
|
||||
<span className="text-xs font-medium text-gray-600">{bar.label}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{bar.actual === null ? 'data tidak tersedia' : fmt(bar.actual, bar.unit)}
|
||||
{bar.standard !== null && (
|
||||
<span className="text-gray-400"> · standar {fmt(bar.standard, bar.unit)}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="h-2.5 w-full rounded-sm bg-gray-100">
|
||||
{bar.actual !== null && (
|
||||
<div
|
||||
className="h-full rounded-sm"
|
||||
style={{ width: `${pct(bar.actual)}%`, background: AKTUAL }}
|
||||
title={`Aktual ${fmt(bar.actual, bar.unit)}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-2.5 w-full rounded-sm bg-gray-100">
|
||||
{bar.standard !== null && (
|
||||
<div
|
||||
className="h-full rounded-sm"
|
||||
style={{ width: `${pct(bar.standard)}%`, background: STANDAR }}
|
||||
title={`Standar ${fmt(bar.standard, bar.unit)}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bar.note && <p className="mt-1 text-[11px] text-gray-400">{bar.note}</p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InsightConditionChart;
|
||||
@@ -0,0 +1,169 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Headline figures for AI Insight cards.
|
||||
* Matches NEW dashboard gray card patterns (white + border-gray-100).
|
||||
*/
|
||||
|
||||
export type InsightStatus = 'ok' | 'warning' | 'critical' | 'unknown';
|
||||
|
||||
const STATUS_CHIP: Record<InsightStatus, { cls: string; glyph: string }> = {
|
||||
ok: { cls: 'bg-green-50 text-green-800 border-green-200', glyph: '✓' },
|
||||
warning: { cls: 'bg-amber-50 text-amber-800 border-amber-200', glyph: '!' },
|
||||
critical: { cls: 'bg-red-50 text-red-800 border-red-200', glyph: '⚠' },
|
||||
unknown: { cls: 'bg-gray-50 text-gray-500 border-gray-200', glyph: '–' },
|
||||
};
|
||||
|
||||
interface DeltaChipProps {
|
||||
status: InsightStatus;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const DeltaChip: React.FC<DeltaChipProps> = ({ status, label }) => {
|
||||
const chip = STATUS_CHIP[status];
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-semibold ${chip.cls}`}
|
||||
>
|
||||
<span aria-hidden="true">{chip.glyph}</span>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/** Axis-less sparkline — shape only; the tile number carries the value. */
|
||||
export const Sparkline: React.FC<{ values: number[]; className?: string }> = ({
|
||||
values,
|
||||
className,
|
||||
}) => {
|
||||
const clean = values.filter((v) => typeof v === 'number' && Number.isFinite(v));
|
||||
if (clean.length < 2) return null;
|
||||
|
||||
const width = 88;
|
||||
const height = 28;
|
||||
const min = Math.min(...clean);
|
||||
const max = Math.max(...clean);
|
||||
const span = max - min || 1;
|
||||
const step = width / (clean.length - 1);
|
||||
const points = clean
|
||||
.map((value, index) => {
|
||||
const x = index * step;
|
||||
const y = height - 2 - ((value - min) / span) * (height - 4);
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
})
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className={className}
|
||||
width={width}
|
||||
height={height}
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
interface HeroFigureProps {
|
||||
label: string;
|
||||
value: string;
|
||||
unit?: string;
|
||||
context?: string | null;
|
||||
status?: InsightStatus;
|
||||
deltaLabel?: string | null;
|
||||
sparkline?: number[];
|
||||
}
|
||||
|
||||
export const HeroFigure: React.FC<HeroFigureProps> = ({
|
||||
label,
|
||||
value,
|
||||
unit,
|
||||
context,
|
||||
status = 'unknown',
|
||||
deltaLabel,
|
||||
sparkline,
|
||||
}) => (
|
||||
<div className="flex items-end justify-between gap-4 rounded-xl border border-gray-100 bg-white px-5 py-4 shadow-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500">{label}</div>
|
||||
<div className="mt-1 flex items-baseline gap-1.5 pb-0.5">
|
||||
<span className="text-5xl font-bold leading-[1.15] tracking-tight text-gray-900">
|
||||
{value}
|
||||
</span>
|
||||
{unit ? <span className="text-sm font-medium text-gray-500">{unit}</span> : null}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{deltaLabel ? <DeltaChip status={status} label={deltaLabel} /> : null}
|
||||
{context ? <span className="text-xs text-gray-500">{context}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
{sparkline && sparkline.length > 1 ? (
|
||||
<Sparkline values={sparkline} className="shrink-0 text-red-400" />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface StatTileProps {
|
||||
label: string;
|
||||
value: string;
|
||||
unit?: string;
|
||||
hint?: string | null;
|
||||
status?: InsightStatus;
|
||||
deltaLabel?: string | null;
|
||||
sparkline?: number[];
|
||||
}
|
||||
|
||||
export const StatTile: React.FC<StatTileProps> = ({
|
||||
label,
|
||||
value,
|
||||
unit,
|
||||
hint,
|
||||
status = 'unknown',
|
||||
deltaLabel,
|
||||
sparkline,
|
||||
}) => (
|
||||
<div className="rounded-xl border border-gray-100 bg-white px-4 py-3 shadow-sm">
|
||||
<div className="text-xs font-medium text-gray-500">
|
||||
{label}
|
||||
{hint ? <span className="ml-1 text-[11px] text-gray-400">{hint}</span> : null}
|
||||
</div>
|
||||
<div className="mt-1 flex items-baseline gap-1">
|
||||
<span className="text-xl font-bold leading-[1.3] text-gray-900">{value}</span>
|
||||
{unit ? <span className="text-xs font-medium text-gray-500">{unit}</span> : null}
|
||||
</div>
|
||||
{deltaLabel ? (
|
||||
<div className="mt-1.5">
|
||||
<DeltaChip status={status} label={deltaLabel} />
|
||||
</div>
|
||||
) : null}
|
||||
{sparkline && sparkline.length > 1 ? (
|
||||
<Sparkline values={sparkline} className="mt-2 text-gray-300" />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
/** Secondary figures: subordinate to hero / tiles. */
|
||||
export const MutedStatRow: React.FC<{ label: string; hint?: string | null; value: string }> = ({
|
||||
label,
|
||||
hint,
|
||||
value,
|
||||
}) => (
|
||||
<div className="flex items-center justify-between border-b border-gray-50 py-1.5">
|
||||
<span className="text-sm text-gray-600">
|
||||
{label}
|
||||
{hint ? <span className="ml-1 text-xs text-gray-400">{hint}</span> : null}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-gray-900">{value}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,230 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Area,
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Legend,
|
||||
Line,
|
||||
ReferenceArea,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
/** One plotted line: which field in the row, how to name it, what colour. */
|
||||
export interface InsightSeries {
|
||||
field: string;
|
||||
label: string;
|
||||
color: string;
|
||||
/** Draw as a dashed reference line — for a target or standard, not a measurement. */
|
||||
dashed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shade the distance between a measurement and the standard it is judged against.
|
||||
*
|
||||
* Recharts has no between-two-series area, so the band is two stacked areas: a
|
||||
* transparent base up to the lower of the pair, then the gap itself.
|
||||
*/
|
||||
export interface InsightGapSpec {
|
||||
actualField: string;
|
||||
targetField: string;
|
||||
}
|
||||
|
||||
export interface InsightTrendChartProps {
|
||||
/** FULL history — the analysed window is highlighted, not isolated. */
|
||||
rows: Array<Record<string, unknown>>;
|
||||
series: InsightSeries[];
|
||||
/** Inclusive day range the insight text actually covers. */
|
||||
highlight?: { firstDay: number; lastDay: number } | null;
|
||||
unit?: string;
|
||||
title?: string;
|
||||
emptyLabel?: string;
|
||||
gap?: InsightGapSpec | null;
|
||||
}
|
||||
|
||||
const dayOf = (row: Record<string, unknown>): number | null => {
|
||||
for (const key of ['hari', 'day']) {
|
||||
const value = row[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const numOrNull = (value: unknown) =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
|
||||
/**
|
||||
* Trend behind an insight, with the analysed period marked.
|
||||
*/
|
||||
const InsightTrendChart: React.FC<InsightTrendChartProps> = ({
|
||||
rows,
|
||||
series,
|
||||
highlight,
|
||||
unit,
|
||||
title,
|
||||
emptyLabel = 'Belum ada data untuk digambarkan.',
|
||||
gap = null,
|
||||
}) => {
|
||||
const data = (Array.isArray(rows) ? rows : [])
|
||||
.map((row) => {
|
||||
const day = dayOf(row);
|
||||
if (day === null) return null;
|
||||
const point: Record<string, number | null> = { hari: day };
|
||||
for (const s of series) point[s.field] = numOrNull(row[s.field]);
|
||||
if (gap) {
|
||||
const actual = numOrNull(row[gap.actualField]);
|
||||
const target = numOrNull(row[gap.targetField]);
|
||||
point.bandBase = actual !== null && target !== null ? Math.min(actual, target) : null;
|
||||
point.bandSize = actual !== null && target !== null ? Math.abs(actual - target) : null;
|
||||
}
|
||||
return point;
|
||||
})
|
||||
.filter((point): point is Record<string, number | null> & { hari: number } => point !== null)
|
||||
.sort((a, b) => a.hari - b.hari);
|
||||
|
||||
const drawn = series.filter((s) => data.some((point) => point[s.field] !== null));
|
||||
|
||||
if (data.length === 0 || drawn.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-100 bg-gray-50/60 p-4 text-center text-sm text-gray-500">
|
||||
{emptyLabel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const days = data.map((point) => point.hari);
|
||||
const step = Math.max(1, Math.ceil(days.length / 10));
|
||||
const ticks = days.filter((_, i) => i % step === 0 || i === days.length - 1);
|
||||
|
||||
const band =
|
||||
highlight && Number.isFinite(highlight.firstDay) && Number.isFinite(highlight.lastDay)
|
||||
? highlight
|
||||
: null;
|
||||
const bandFrom = band
|
||||
? band.firstDay === band.lastDay
|
||||
? band.firstDay - 0.4
|
||||
: band.firstDay
|
||||
: 0;
|
||||
const bandTo = band ? (band.firstDay === band.lastDay ? band.lastDay + 0.4 : band.lastDay) : 0;
|
||||
|
||||
const gapFill = (() => {
|
||||
if (!gap) return null;
|
||||
const last = [...data]
|
||||
.reverse()
|
||||
.find((point) => point[gap.actualField] !== null && point[gap.targetField] !== null);
|
||||
const actual = last?.[gap.actualField];
|
||||
const target = last?.[gap.targetField];
|
||||
if (typeof actual !== 'number' || typeof target !== 'number' || target === 0) {
|
||||
return 'rgba(107, 114, 128, 0.12)';
|
||||
}
|
||||
const deviation = ((actual - target) / target) * 100;
|
||||
if (deviation >= -5) return 'rgba(12, 163, 12, 0.12)';
|
||||
if (deviation >= -15) return 'rgba(250, 178, 25, 0.18)';
|
||||
return 'rgba(208, 59, 59, 0.14)';
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
|
||||
{title && <h4 className="mb-1 text-sm font-bold text-gray-800">{title}</h4>}
|
||||
{band && (
|
||||
<p className="mb-3 text-xs text-gray-500">
|
||||
Area berbayang = periode yang dibahas insight
|
||||
{band.firstDay === band.lastDay
|
||||
? ` (hari ke-${band.lastDay})`
|
||||
: ` (hari ke-${band.firstDay} s/d ke-${band.lastDay})`}
|
||||
</p>
|
||||
)}
|
||||
<ResponsiveContainer width="100%" height={230}>
|
||||
<ComposedChart data={data} margin={{ top: 8, right: 12, bottom: 4, left: 0 }}>
|
||||
<CartesianGrid stroke="#e5e7eb" vertical={false} />
|
||||
{band && (
|
||||
<ReferenceArea
|
||||
x1={bandFrom}
|
||||
x2={bandTo}
|
||||
fill="#6b7280"
|
||||
fillOpacity={0.12}
|
||||
stroke="#6b7280"
|
||||
strokeOpacity={0.25}
|
||||
/>
|
||||
)}
|
||||
<XAxis
|
||||
dataKey="hari"
|
||||
type="number"
|
||||
domain={[(days[0] ?? 0) - 0.5, (days[days.length - 1] ?? 0) + 0.5]}
|
||||
ticks={ticks}
|
||||
allowDecimals={false}
|
||||
tick={{ fontSize: 11, fill: '#6b7280' }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#e5e7eb' }}
|
||||
tickFormatter={(value) => `H${value}`}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11, fill: '#6b7280' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={52}
|
||||
tickFormatter={(value) => Number(value).toLocaleString('id-ID')}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
fontSize: 12,
|
||||
borderRadius: 8,
|
||||
border: '1px solid #d1d5db',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
}}
|
||||
labelFormatter={(value) => `Hari ke-${value}`}
|
||||
formatter={(value, name) => {
|
||||
const label = String(name ?? '');
|
||||
if (label === 'bandBase' || label === 'bandSize') return null;
|
||||
if (value == null || Array.isArray(value)) return null;
|
||||
return [`${Number(value).toLocaleString('id-ID')}${unit ? ` ${unit}` : ''}`, label];
|
||||
}}
|
||||
/>
|
||||
{drawn.length > 1 && <Legend wrapperStyle={{ fontSize: 12 }} iconType="plainline" />}
|
||||
{gap && gapFill && (
|
||||
<>
|
||||
<Area
|
||||
dataKey="bandBase"
|
||||
stackId="gap"
|
||||
stroke="none"
|
||||
fill="none"
|
||||
legendType="none"
|
||||
isAnimationActive={false}
|
||||
connectNulls
|
||||
/>
|
||||
<Area
|
||||
dataKey="bandSize"
|
||||
stackId="gap"
|
||||
stroke="none"
|
||||
fill={gapFill}
|
||||
fillOpacity={1}
|
||||
legendType="none"
|
||||
isAnimationActive={false}
|
||||
connectNulls
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{drawn.map((s) => (
|
||||
<Line
|
||||
key={s.field}
|
||||
type="monotone"
|
||||
dataKey={s.field}
|
||||
name={s.label}
|
||||
stroke={s.color}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={s.dashed ? '5 4' : undefined}
|
||||
dot={!s.dashed && data.length <= 20 ? { r: 3, strokeWidth: 0, fill: s.color } : false}
|
||||
activeDot={{ r: 5, strokeWidth: 2, stroke: '#ffffff' }}
|
||||
connectNulls
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InsightTrendChart;
|
||||
@@ -0,0 +1,942 @@
|
||||
/**
|
||||
* Per-page / per-topic AI Insight shell (FCR, weight, counting, IoT, …).
|
||||
*
|
||||
* Builds/scopes context, then loads cached insight on mount.
|
||||
* LLM generate runs only when the user clicks Generate (force=true).
|
||||
* Distinct from
|
||||
* `AiInsightCard` (Dashboard report modes). See docs/ai-insight/README.md.
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useFarm } from '../../context/FarmContext.tsx';
|
||||
import { api, errorMessage } from '../../services/apiClient.ts';
|
||||
import type {
|
||||
InsightCitation,
|
||||
InsightContext,
|
||||
InsightSource,
|
||||
InsightTopic,
|
||||
} from '../../types/api.ts';
|
||||
import { formatRatio } from '../../utils/format.ts';
|
||||
import { getCobbStandardForDay } from '../../utils/insightHelpers.ts';
|
||||
import { parseAiResult, type AiResult } from '../../utils/insightParse.ts';
|
||||
import {
|
||||
scopeContextToTimeframe,
|
||||
scopeScalarsToTimeframe,
|
||||
describeTimeframe,
|
||||
UNAVAILABLE,
|
||||
type ScalarScopeSpec,
|
||||
} from '../../utils/insightScope.ts';
|
||||
import InsightTrendChart, { type InsightSeries } from './InsightTrendChart.tsx';
|
||||
import InsightConditionChart, { type ConditionBar } from './InsightConditionChart.tsx';
|
||||
import { HeroFigure, type InsightStatus } from './InsightStatTiles.tsx';
|
||||
|
||||
const CLIENT_TIMEOUT_MS = 1_200_000; // 20 minutes — NUC LLM can take several minutes
|
||||
|
||||
const _insightMemoryCache = new Map<string, CachedPageInsight>();
|
||||
const _insightInFlight = new Map<string, Promise<void>>();
|
||||
|
||||
type CachedPageInsight = AiResult & {
|
||||
source: InsightSource;
|
||||
citations?: InsightCitation[];
|
||||
};
|
||||
|
||||
export type { InsightTopic };
|
||||
|
||||
export interface PageAiInsightProps {
|
||||
topic: InsightTopic;
|
||||
/** Curated context — must include kandangId (never null "all kandang"). */
|
||||
contextData: InsightContext | Record<string, unknown>;
|
||||
cacheKey: string;
|
||||
scalarScope?: ScalarScopeSpec;
|
||||
chartSpec?:
|
||||
| {
|
||||
type: 'trend';
|
||||
historyKey: string;
|
||||
series: InsightSeries[];
|
||||
unit?: string;
|
||||
title?: string;
|
||||
gap?: { actualField: string; targetField: string };
|
||||
}
|
||||
| { type: 'condition'; bars: ConditionBar[]; title?: string };
|
||||
}
|
||||
|
||||
const TOPIC_META: Record<InsightTopic, { title: string; icon: string }> = {
|
||||
hitung_ayam: { title: 'AI Insight — Hitung Ayam', icon: 'fa-kiwi-bird' },
|
||||
berat_ayam: { title: 'AI Insight — Berat Ayam', icon: 'fa-weight-scale' },
|
||||
fcr: { title: 'AI Insight — FCR', icon: 'fa-wheat-awn' },
|
||||
eef: { title: 'AI Insight — EEF', icon: 'fa-chart-line' },
|
||||
iot_panel: { title: 'AI Insight — Panel IoT', icon: 'fa-temperature-half' },
|
||||
hitung_karung: { title: 'AI Insight — Hitung Karung', icon: 'fa-boxes-stacked' },
|
||||
dashboard: { title: 'AI Insight — Dashboard', icon: 'fa-brain' },
|
||||
};
|
||||
|
||||
const 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 renderValue = (val: unknown, suffix: string = ''): string => {
|
||||
if (val === undefined || val === null || val === '') return 'N/A';
|
||||
if (val === UNAVAILABLE) return UNAVAILABLE;
|
||||
return `${val} ${suffix}`.trim();
|
||||
};
|
||||
|
||||
const requireKandangId = (ctx: Record<string, unknown>): number => {
|
||||
const raw = ctx.kandangId ?? ctx.kandang_id;
|
||||
const id = typeof raw === 'number' ? raw : Number(raw);
|
||||
if (!Number.isFinite(id) || id <= 0) {
|
||||
throw new Error('kandangId wajib — AI Insight tidak boleh digenerate untuk semua kandang');
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
const _saveToCache = (storageKey: string, result: CachedPageInsight): void => {
|
||||
_insightMemoryCache.set(storageKey, result);
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(result));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const CitationsBlock: React.FC<{ citations?: InsightCitation[] }> = ({ citations }) => {
|
||||
if (!citations || citations.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-4 rounded-lg border border-gray-100 bg-gray-50 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-2">
|
||||
Sumber referensi
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{citations.map((c, i) => (
|
||||
<li key={c.id ?? `${c.title ?? 'cite'}-${i}`} className="text-xs text-gray-600">
|
||||
<span className="font-medium text-gray-800">
|
||||
{c.chapter || c.title || c.source || `Referensi ${i + 1}`}
|
||||
</span>
|
||||
{c.excerpt ? <span className="text-gray-500"> — {c.excerpt}</span> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
topic,
|
||||
contextData,
|
||||
cacheKey,
|
||||
scalarScope,
|
||||
chartSpec,
|
||||
}) => {
|
||||
const { selectedCycle, selectedKandang, selectedSite, selectedKandangId } = useFarm();
|
||||
|
||||
const [result, setResult] = useState<CachedPageInsight | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const supportsTimeframe = Boolean(scalarScope);
|
||||
const timeframe: 'harian' | 'mingguan' = 'harian';
|
||||
|
||||
const selectedDayKey = `insight-day-${topic}`;
|
||||
const [selectedDay, setSelectedDayState] = useState<number | null>(() => {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(selectedDayKey);
|
||||
const parsed = raw === null ? NaN : parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const setSelectedDay = useCallback(
|
||||
(next: number | null) => {
|
||||
setSelectedDayState(next);
|
||||
try {
|
||||
if (next === null) sessionStorage.removeItem(selectedDayKey);
|
||||
else sessionStorage.setItem(selectedDayKey, String(next));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
[selectedDayKey]
|
||||
);
|
||||
|
||||
const availableDays = React.useMemo(() => {
|
||||
const key =
|
||||
scalarScope?.historyKey ?? (chartSpec?.type === 'trend' ? chartSpec.historyKey : null);
|
||||
if (!key) return [];
|
||||
const rows = (contextData as Record<string, unknown>)[key];
|
||||
if (!Array.isArray(rows)) return [];
|
||||
const days = new Set<number>();
|
||||
for (const row of rows) {
|
||||
if (typeof row !== 'object' || row === null) continue;
|
||||
const r = row as Record<string, unknown>;
|
||||
for (const field of ['hari', 'day']) {
|
||||
const value = r[field];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) days.add(value);
|
||||
}
|
||||
}
|
||||
return [...days].sort((a, b) => a - b);
|
||||
}, [contextData, scalarScope, chartSpec]);
|
||||
|
||||
const lastAvailableDay: number | null =
|
||||
availableDays.length > 0 ? availableDays[availableDays.length - 1]! : null;
|
||||
|
||||
// Dropdown shows last available day when nothing is selected — generate/cache
|
||||
// must use that same day, not cycle.current_day (calendar can be ahead of data).
|
||||
const activeDay: number | null =
|
||||
timeframe === 'harian' && selectedDay !== null && availableDays.includes(selectedDay)
|
||||
? selectedDay
|
||||
: null;
|
||||
const effectiveDay: number | null =
|
||||
timeframe === 'harian' ? (activeDay ?? lastAvailableDay) : null;
|
||||
|
||||
const scopeKey = `${cacheKey}-${timeframe}${effectiveDay === null ? '' : `-h${effectiveDay}`}`;
|
||||
|
||||
const inFlightRef = useRef(false);
|
||||
const lastCacheKeyRef = useRef<string | null>(null);
|
||||
const desiredKeyRef = useRef<string>('');
|
||||
const requestIdRef = useRef(0);
|
||||
const generateRef = useRef<(force?: boolean) => Promise<void>>(async () => {});
|
||||
|
||||
const chartHighlight = React.useMemo(() => {
|
||||
if (!chartSpec || chartSpec.type !== 'trend') return null;
|
||||
const rows = (contextData as Record<string, unknown>)[chartSpec.historyKey];
|
||||
if (!Array.isArray(rows)) return null;
|
||||
const days = rows
|
||||
.map((row) => {
|
||||
const r = row as Record<string, unknown>;
|
||||
for (const key of ['hari', 'day']) {
|
||||
const value = r[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((day): day is number => day !== null);
|
||||
if (days.length === 0) return null;
|
||||
if (timeframe === 'harian' && effectiveDay !== null) {
|
||||
return { firstDay: 0, lastDay: effectiveDay };
|
||||
}
|
||||
const lastDay = Math.max(...days);
|
||||
const firstDay = timeframe === 'harian' ? lastDay : Math.max(Math.min(...days), lastDay - 6);
|
||||
return { firstDay, lastDay };
|
||||
}, [chartSpec, contextData, timeframe, effectiveDay]);
|
||||
|
||||
const displayContext = React.useMemo(() => {
|
||||
const raw = contextData as Record<string, unknown>;
|
||||
if (!scalarScope) return raw;
|
||||
const sliced = scopeContextToTimeframe(raw, timeframe, effectiveDay);
|
||||
const scoped = scopeScalarsToTimeframe(
|
||||
sliced as Record<string, unknown>,
|
||||
timeframe,
|
||||
scalarScope,
|
||||
effectiveDay
|
||||
);
|
||||
const out: Record<string, unknown> = { ...raw };
|
||||
for (const [outKey, rule] of Object.entries(scalarScope.derive)) {
|
||||
if (rule.replaces) out[rule.replaces] = scoped[outKey];
|
||||
}
|
||||
for (const key of scalarScope.unavailable ?? []) out[key] = UNAVAILABLE;
|
||||
out.periode = scoped.periode;
|
||||
return out;
|
||||
}, [contextData, timeframe, effectiveDay, scalarScope]);
|
||||
|
||||
const periodeLabel = typeof displayContext.periode === 'string' ? displayContext.periode : null;
|
||||
|
||||
const trendChartRows = React.useMemo(() => {
|
||||
if (!chartSpec || chartSpec.type !== 'trend') return [];
|
||||
const rows = (contextData as Record<string, unknown>)[chartSpec.historyKey];
|
||||
if (!Array.isArray(rows)) return [];
|
||||
if (timeframe !== 'harian' || effectiveDay === null) {
|
||||
return rows as Array<Record<string, unknown>>;
|
||||
}
|
||||
return (rows as Array<Record<string, unknown>>).filter((row) => {
|
||||
for (const field of ['hari', 'day']) {
|
||||
const value = row[field];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value <= effectiveDay;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [chartSpec, contextData, timeframe, effectiveDay]);
|
||||
|
||||
const currentDay = selectedCycle?.current_day ?? null;
|
||||
|
||||
const heroSpec = React.useMemo(() => {
|
||||
const n = (value: unknown): number | null =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
const standardDay = effectiveDay ?? currentDay ?? null;
|
||||
const standard = standardDay ? getCobbStandardForDay(standardDay) : null;
|
||||
const dayLabel = standardDay ? `hari ke-${standardDay}` : null;
|
||||
|
||||
if (topic === 'berat_ayam') {
|
||||
const actual = n(displayContext.averageWeight);
|
||||
const target = n(displayContext.targetWeight) ?? standard?.weight ?? null;
|
||||
if (actual === null) return null;
|
||||
const dev = target ? ((actual - target) / target) * 100 : null;
|
||||
return {
|
||||
label: 'Rata-rata Bobot Aktual',
|
||||
value: Math.round(actual).toLocaleString('id-ID'),
|
||||
unit: 'g',
|
||||
context: target
|
||||
? `Target CP 707${dayLabel ? ` ${dayLabel}` : ''}: ${Math.round(target).toLocaleString('id-ID')} g`
|
||||
: null,
|
||||
status: (dev === null
|
||||
? 'unknown'
|
||||
: dev >= -5
|
||||
? 'ok'
|
||||
: dev >= -15
|
||||
? 'warning'
|
||||
: 'critical') as InsightStatus,
|
||||
deltaLabel: dev === null ? null : `${dev.toFixed(0)}% vs target`,
|
||||
};
|
||||
}
|
||||
|
||||
if (topic === 'fcr') {
|
||||
const actual = n(displayContext.fcr_terakhir);
|
||||
if (actual === null) return null;
|
||||
const std = standard?.fcr ?? null;
|
||||
return {
|
||||
label: 'FCR Aktual',
|
||||
value: formatRatio(actual, 3),
|
||||
unit: undefined,
|
||||
context: std ? `Standar CP 707${dayLabel ? ` ${dayLabel}` : ''}: ${formatRatio(std, 3)}` : null,
|
||||
status: (std === null
|
||||
? 'unknown'
|
||||
: actual <= std
|
||||
? 'ok'
|
||||
: actual <= std * 1.1
|
||||
? 'warning'
|
||||
: 'critical') as InsightStatus,
|
||||
deltaLabel:
|
||||
std === null
|
||||
? null
|
||||
: `${actual > std ? '+' : ''}${formatRatio(actual - std, 3)} vs standar`,
|
||||
};
|
||||
}
|
||||
|
||||
if (topic === 'eef') {
|
||||
const actual = n(displayContext.eef_terakhir);
|
||||
if (actual === null) return null;
|
||||
const atHarvest = (currentDay ?? 0) >= 35;
|
||||
return {
|
||||
label: 'EEF',
|
||||
value: formatRatio(actual, 3),
|
||||
unit: undefined,
|
||||
context: atHarvest ? 'Standar umur panen: >300' : 'Belum umur panen — wajar masih rendah',
|
||||
status: (!atHarvest
|
||||
? 'unknown'
|
||||
: actual >= 300
|
||||
? 'ok'
|
||||
: actual >= 250
|
||||
? 'warning'
|
||||
: 'critical') as InsightStatus,
|
||||
deltaLabel: atHarvest ? (actual >= 300 ? 'di atas standar' : 'di bawah standar') : null,
|
||||
};
|
||||
}
|
||||
|
||||
if (topic === 'iot_panel') {
|
||||
const actual = n(displayContext.suhu_rata_rata_C);
|
||||
const setpoint = n(displayContext.setpoint_C);
|
||||
if (actual === null) return null;
|
||||
const diff = setpoint === null ? null : actual - setpoint;
|
||||
return {
|
||||
label: 'Suhu Rata-rata Kandang',
|
||||
value: actual.toLocaleString('id-ID'),
|
||||
unit: '°C',
|
||||
context: setpoint === null ? null : `Setpoint: ${setpoint.toLocaleString('id-ID')} °C`,
|
||||
status: (diff === null
|
||||
? 'unknown'
|
||||
: Math.abs(diff) <= 1
|
||||
? 'ok'
|
||||
: Math.abs(diff) <= 2.5
|
||||
? 'warning'
|
||||
: 'critical') as InsightStatus,
|
||||
deltaLabel:
|
||||
diff === null ? null : `${diff > 0 ? '+' : ''}${diff.toFixed(1)} °C vs setpoint`,
|
||||
};
|
||||
}
|
||||
|
||||
if (topic === 'hitung_karung') {
|
||||
const actual = n(displayContext.total_karung_dituang_iot);
|
||||
if (actual === null) return null;
|
||||
const manual = n(displayContext.total_karung_dituang_manual);
|
||||
const perDay = n(displayContext.rata_karung_per_hari);
|
||||
const parts = [
|
||||
manual === null ? null : `Manual: ${manual.toLocaleString('id-ID')} karung`,
|
||||
perDay === null ? null : `${perDay.toLocaleString('id-ID')} karung/hari`,
|
||||
].filter(Boolean);
|
||||
return {
|
||||
label: 'Pakan Dituang (IOT)',
|
||||
value: actual.toLocaleString('id-ID'),
|
||||
unit: 'karung',
|
||||
context: parts.length > 0 ? parts.join(' · ') : null,
|
||||
status: 'unknown' as InsightStatus,
|
||||
deltaLabel: null,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [topic, displayContext, currentDay, effectiveDay]);
|
||||
|
||||
const meta = TOPIC_META[topic] ?? TOPIC_META.dashboard;
|
||||
|
||||
const generate = useCallback(
|
||||
async (force = false) => {
|
||||
const storageKey = `page-insight-v1-${scopeKey}`;
|
||||
const requestId = ++requestIdRef.current;
|
||||
const isCurrent = () => requestIdRef.current === requestId;
|
||||
|
||||
if (force) {
|
||||
_insightMemoryCache.delete(storageKey);
|
||||
try {
|
||||
sessionStorage.removeItem(storageKey);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
if (!force) {
|
||||
const memCached = _insightMemoryCache.get(storageKey);
|
||||
if (memCached) {
|
||||
if (!isCurrent()) return;
|
||||
setResult(memCached);
|
||||
setLoading(false);
|
||||
lastCacheKeyRef.current = scopeKey;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!force && _insightInFlight.has(storageKey)) {
|
||||
await _insightInFlight.get(storageKey);
|
||||
if (!isCurrent()) return;
|
||||
const memCached = _insightMemoryCache.get(storageKey);
|
||||
if (memCached) {
|
||||
setResult(memCached);
|
||||
setLoading(false);
|
||||
lastCacheKeyRef.current = scopeKey;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!force) {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(storageKey);
|
||||
if (raw) {
|
||||
const parsedJson = JSON.parse(raw) as CachedPageInsight;
|
||||
if (parsedJson?.summary || parsedJson?.insight) {
|
||||
_insightMemoryCache.set(storageKey, parsedJson);
|
||||
if (!isCurrent()) return;
|
||||
setResult(parsedJson);
|
||||
setLoading(false);
|
||||
lastCacheKeyRef.current = scopeKey;
|
||||
return;
|
||||
}
|
||||
const parsed = parseAiResult(raw);
|
||||
if (parsed) {
|
||||
const wrapped: CachedPageInsight = { ...parsed, source: 'cache' };
|
||||
_insightMemoryCache.set(storageKey, wrapped);
|
||||
if (!isCurrent()) return;
|
||||
setResult(wrapped);
|
||||
setLoading(false);
|
||||
lastCacheKeyRef.current = scopeKey;
|
||||
return;
|
||||
}
|
||||
sessionStorage.removeItem(storageKey);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
if (inFlightRef.current && !force) return;
|
||||
inFlightRef.current = true;
|
||||
// Only show the LLM loading UI when the user clicks Generate.
|
||||
if (force) {
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
const fetchPromise = (async () => {
|
||||
try {
|
||||
if (selectedKandangId == null && !(contextData as Record<string, unknown>).kandangId) {
|
||||
throw new Error(
|
||||
'Pilih kandang terlebih dahulu. AI Insight digenerate per kandang, bukan semua kandang.'
|
||||
);
|
||||
}
|
||||
if (!selectedCycle?.id) {
|
||||
throw new Error('Data siklus tidak tersedia. Pastikan ada siklus aktif.');
|
||||
}
|
||||
|
||||
const rawCtx = contextData as Record<string, unknown>;
|
||||
const kandangId = requireKandangId({
|
||||
...rawCtx,
|
||||
kandangId: rawCtx.kandangId ?? selectedKandangId,
|
||||
});
|
||||
|
||||
const rawCtxDay =
|
||||
effectiveDay ??
|
||||
rawCtx.hari_ke ??
|
||||
rawCtx.hari_terakhir ??
|
||||
rawCtx.currentDay ??
|
||||
currentDay;
|
||||
const maxDays = (rawCtx.totalDays as number | undefined) ?? selectedCycle.total_days;
|
||||
const currentDayVal =
|
||||
typeof rawCtxDay === 'number' && maxDays && rawCtxDay > maxDays ? maxDays : rawCtxDay;
|
||||
|
||||
const slicedContextData = scopeContextToTimeframe(contextData, timeframe, effectiveDay);
|
||||
const scopedContextData = scalarScope
|
||||
? scopeScalarsToTimeframe(
|
||||
slicedContextData as Record<string, unknown>,
|
||||
timeframe,
|
||||
scalarScope,
|
||||
effectiveDay
|
||||
)
|
||||
: slicedContextData;
|
||||
|
||||
const dataPeriode = (scopedContextData as Record<string, unknown>)?.periode;
|
||||
// Prefer explicit day-0..N wording when a day is selected (matches dashboard reports).
|
||||
const timeframeLabel =
|
||||
effectiveDay !== null
|
||||
? describeTimeframe(timeframe, currentDayVal as number | null, effectiveDay)
|
||||
: !supportsTimeframe
|
||||
? 'Kondisi terkini (pembacaan sesaat, bukan rentang waktu)'
|
||||
: typeof dataPeriode === 'string' && dataPeriode.trim().length > 0
|
||||
? dataPeriode
|
||||
: describeTimeframe(timeframe, currentDayVal as number | null, effectiveDay);
|
||||
|
||||
const reportPeriod =
|
||||
timeframe === 'harian'
|
||||
? effectiveDay !== null
|
||||
? `Hari ${effectiveDay}`
|
||||
: currentDayVal
|
||||
? `Hari ${currentDayVal}`
|
||||
: null
|
||||
: null;
|
||||
|
||||
const contextPayload: InsightContext = {
|
||||
...(scopedContextData as InsightContext),
|
||||
kandangId,
|
||||
kandangName:
|
||||
(rawCtx.kandangName as string | undefined) ??
|
||||
selectedKandang?.kandang_name ??
|
||||
undefined,
|
||||
cycleId: selectedCycle.id,
|
||||
hari_ke: typeof effectiveDay === 'number' ? effectiveDay : typeof currentDayVal === 'number' ? currentDayVal : null,
|
||||
totalDays: maxDays ?? null,
|
||||
periode: timeframeLabel,
|
||||
};
|
||||
|
||||
let apiResult = null as Awaited<ReturnType<typeof api.insights.generate>> | null;
|
||||
|
||||
if (!force) {
|
||||
try {
|
||||
apiResult = await api.insights.cached({
|
||||
cycle_id: selectedCycle.id,
|
||||
kandang_id: kandangId,
|
||||
topic,
|
||||
report_type: 'page',
|
||||
report_period: reportPeriod,
|
||||
});
|
||||
if (!apiResult?.insight_text && !apiResult?.summary) apiResult = null;
|
||||
} catch {
|
||||
apiResult = null;
|
||||
}
|
||||
// Manual-generate only: never POST generate on mount / soft refresh.
|
||||
if (!apiResult) {
|
||||
if (!isCurrent()) return;
|
||||
setResult(null);
|
||||
setLoading(false);
|
||||
lastCacheKeyRef.current = scopeKey;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const generateCall = api.insights.generate({
|
||||
cycle_id: selectedCycle.id,
|
||||
kandang_id: kandangId,
|
||||
topic,
|
||||
report_type: 'page',
|
||||
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 (!isCurrent()) return;
|
||||
|
||||
if (!apiResult?.insight_text && !apiResult?.summary && !apiResult?.insight) {
|
||||
throw new Error(apiResult?.message || 'Gagal menghasilkan AI Insight.');
|
||||
}
|
||||
|
||||
const text = apiResult.insight_text ?? '';
|
||||
const parsed =
|
||||
apiResult.summary || apiResult.insight
|
||||
? {
|
||||
summary: apiResult.summary || '',
|
||||
insight: apiResult.insight || '',
|
||||
}
|
||||
: parseAiResult(text);
|
||||
|
||||
if (!parsed || (!parsed.summary && !parsed.insight)) {
|
||||
throw new Error('Respons AI tidak dapat diproses.');
|
||||
}
|
||||
|
||||
const wrapped: CachedPageInsight = {
|
||||
summary: parsed.summary,
|
||||
insight: parsed.insight,
|
||||
source: apiResult.source ?? (force ? 'generated' : 'cache'),
|
||||
citations: apiResult.citations,
|
||||
};
|
||||
|
||||
_saveToCache(storageKey, wrapped);
|
||||
if (!isCurrent()) return;
|
||||
setResult(wrapped);
|
||||
lastCacheKeyRef.current = scopeKey;
|
||||
} catch (err) {
|
||||
if (!isCurrent()) return;
|
||||
console.error(`[PageAiInsight:${topic}] request failed:`, err);
|
||||
// Soft cache misses/errors should not look like a failed generate.
|
||||
if (force) {
|
||||
setError(errorMessage(err));
|
||||
setResult(null);
|
||||
} else {
|
||||
setResult(null);
|
||||
}
|
||||
} finally {
|
||||
if (isCurrent()) {
|
||||
setLoading(false);
|
||||
inFlightRef.current = false;
|
||||
}
|
||||
_insightInFlight.delete(storageKey);
|
||||
}
|
||||
})();
|
||||
|
||||
_insightInFlight.set(storageKey, fetchPromise);
|
||||
await fetchPromise;
|
||||
},
|
||||
[
|
||||
topic,
|
||||
contextData,
|
||||
selectedCycle,
|
||||
selectedKandang,
|
||||
selectedKandangId,
|
||||
currentDay,
|
||||
timeframe,
|
||||
effectiveDay,
|
||||
scalarScope,
|
||||
supportsTimeframe,
|
||||
scopeKey,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
desiredKeyRef.current = scopeKey;
|
||||
generateRef.current = generate;
|
||||
});
|
||||
|
||||
// Day / scope change: drop previous insight immediately and only soft-load cache (no generate UI).
|
||||
useEffect(() => {
|
||||
if (lastCacheKeyRef.current === scopeKey) return;
|
||||
requestIdRef.current += 1;
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
inFlightRef.current = false;
|
||||
const timer = setTimeout(() => generateRef.current(false), 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, [scopeKey]);
|
||||
|
||||
const sourceMeta = result ? SOURCE_LABEL[result.source] : null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-100 bg-white shadow-sm overflow-hidden mb-6">
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-gray-100 bg-gray-50/80">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="p-1.5 bg-red-50 rounded-lg">
|
||||
<i className={`fa-solid ${meta.icon} text-red-600 text-sm`} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-bold text-gray-900">{meta.title}</span>
|
||||
<p className="text-xs text-gray-400 truncate">
|
||||
{selectedKandang?.kandang_name ?? 'Kandang'}
|
||||
{selectedSite?.site_name ? ` · ${selectedSite.site_name}` : ''}
|
||||
{effectiveDay != null ? ` · Hari ke-${effectiveDay}` : currentDay != null ? ` · Hari ke-${currentDay}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{supportsTimeframe && timeframe === 'harian' && availableDays.length > 1 && (
|
||||
<select
|
||||
value={effectiveDay ?? availableDays[availableDays.length - 1]}
|
||||
onChange={(e) => setSelectedDay(parseInt(e.target.value, 10))}
|
||||
className="text-[11px] font-semibold border border-gray-200 bg-white text-gray-700 rounded-lg px-2 py-1 shadow-sm"
|
||||
title="Pilih hari siklus"
|
||||
>
|
||||
{availableDays.map((d) => (
|
||||
<option key={d} value={d}>
|
||||
Hari {d}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{sourceMeta && (
|
||||
<span
|
||||
className={`hidden sm:inline-flex rounded-full border px-2 py-0.5 text-[10px] font-semibold ${sourceMeta.cls}`}
|
||||
>
|
||||
{sourceMeta.text}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => generate(true)}
|
||||
disabled={loading || selectedKandangId == null}
|
||||
title="Refresh insight"
|
||||
className="p-1.5 rounded-lg border border-gray-200 bg-white hover:bg-gray-50 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<i
|
||||
className={`fa-solid fa-rotate-right text-gray-600 text-sm ${loading ? 'fa-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{selectedKandangId == null && (
|
||||
<div className="flex items-start gap-2 bg-amber-50 border border-amber-100 rounded-xl p-3">
|
||||
<i className="fa-solid fa-triangle-exclamation text-amber-500 mt-0.5" />
|
||||
<p className="text-xs text-amber-800">
|
||||
Pilih satu kandang untuk menghasilkan AI Insight. Generate untuk semua kandang tidak
|
||||
diizinkan.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && !result && (
|
||||
<div className="flex flex-col items-center gap-3 py-8 justify-center">
|
||||
<div className="w-8 h-8 rounded-full border-4 border-gray-100 border-t-red-600 animate-spin" />
|
||||
<p className="text-sm text-gray-500 text-center max-w-sm">
|
||||
Menganalisis data dengan model AI… proses ini bisa memakan waktu beberapa menit.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !result && (
|
||||
<div className="flex items-start gap-2 bg-red-50 border border-red-100 rounded-xl p-3">
|
||||
<i className="fa-solid fa-triangle-exclamation text-red-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-red-600">Gagal memuat insight</p>
|
||||
<p className="text-xs text-red-500 mt-0.5">{error}</p>
|
||||
<button
|
||||
onClick={() => generate(true)}
|
||||
className="text-xs text-red-600 underline mt-1 hover:text-red-800"
|
||||
>
|
||||
Coba lagi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && !result && selectedKandangId != null && (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center">
|
||||
<i className="fa-solid fa-brain text-gray-300 text-2xl" />
|
||||
<p className="text-sm text-gray-500">Belum ada AI Insight untuk periode ini.</p>
|
||||
<p className="text-xs text-gray-400">Klik tombol refresh untuk generate insight.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => generate(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>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="space-y-4">
|
||||
{heroSpec && (
|
||||
<HeroFigure
|
||||
label={heroSpec.label}
|
||||
value={heroSpec.value}
|
||||
unit={heroSpec.unit}
|
||||
context={heroSpec.context}
|
||||
status={heroSpec.status}
|
||||
deltaLabel={heroSpec.deltaLabel}
|
||||
/>
|
||||
)}
|
||||
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5">
|
||||
{periodeLabel && (
|
||||
<p className="mb-3 text-xs font-medium text-gray-500">
|
||||
Angka di bawah dihitung dari periode{' '}
|
||||
<span className="text-gray-700">{periodeLabel}</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-3 gap-x-6">
|
||||
{topic === 'fcr' && (
|
||||
<>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">
|
||||
Konsumsi Pakan Kumulatif
|
||||
</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.pakan_karung_terakhir, 'Karung')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Rata-rata Bobot</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.bobot_iot_gram_terakhir, 'g')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Populasi Hidup</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.ayam_hidup_ekor, 'Ekor')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Total Bobot Panen</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.beratPanen_kg, 'kg')}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{topic === 'berat_ayam' && (
|
||||
<>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Target Bobot</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.targetWeight, 'g')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Uniformity</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.uniformity, '%')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">
|
||||
Average Daily Gain (ADG)
|
||||
</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.adg, 'g/hari')}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{topic === 'eef' && (
|
||||
<>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">EEF Aktual</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{typeof displayContext.eef_terakhir === 'number'
|
||||
? formatRatio(displayContext.eef_terakhir, 3)
|
||||
: renderValue(displayContext.eef_terakhir)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">FCR Aktual</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{typeof displayContext.fcr_terakhir === 'number'
|
||||
? formatRatio(displayContext.fcr_terakhir, 3)
|
||||
: renderValue(displayContext.fcr_terakhir)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Persentase Hidup</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.persen_hidup, '%')}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{topic === 'iot_panel' && (
|
||||
<>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Kelembapan</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.kelembapan_persen, '%')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Kecepatan Angin</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.kecepatan_angin_mPerDetik, 'm/s')}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{topic === 'hitung_karung' && (
|
||||
<>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Saldo Awal Pakan</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.saldo_awal_karung, 'Karung')}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{chartSpec && (
|
||||
<div className="mt-6 pt-4 border-t border-gray-100">
|
||||
{chartSpec.type === 'trend' ? (
|
||||
<InsightTrendChart
|
||||
rows={trendChartRows}
|
||||
series={chartSpec.series}
|
||||
unit={chartSpec.unit}
|
||||
title={chartSpec.title}
|
||||
highlight={chartHighlight}
|
||||
gap={chartSpec.gap ?? null}
|
||||
/>
|
||||
) : (
|
||||
<InsightConditionChart bars={chartSpec.bars} title={chartSpec.title} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-gray-100">
|
||||
<h4 className="text-sm font-bold text-gray-800 mb-2 flex items-center gap-2">
|
||||
<i className="fa-solid fa-circle-check text-green-600" /> Kesimpulan
|
||||
</h4>
|
||||
<p className="text-sm text-gray-700 bg-gray-50 p-3 rounded-lg border border-gray-100">
|
||||
{result.summary}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-bold text-gray-800 mb-2 flex items-center gap-2">
|
||||
<i className="fa-solid fa-brain text-red-600" /> AI Insight
|
||||
</h4>
|
||||
<p className="text-sm text-gray-700 bg-gray-50 p-3 rounded-lg border border-gray-100 whitespace-pre-wrap">
|
||||
{result.insight}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CitationsBlock citations={result.citations} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageAiInsight;
|
||||
Reference in new issue
Block a user