From b87f20e3b3d349b11679d5548ed41255deac5c3a Mon Sep 17 00:00:00 2001 From: Alberto-Audrix Date: Mon, 5 Oct 2026 14:25:22 +0700 Subject: [PATCH] update applying current ai insight from dashboard-cpsp --- .../0012_aiinsight_upgrade_cache_scope.py | 77 ++ backend/apps/operations/models.py | 24 +- backend/apps/operations/serializers.py | 15 + backend/apps/operations/views.py | 111 +- backend/apps/sync/services/mirror_pull.py | 35 +- components/AiInsightCard.tsx | 1007 +++++++++++++++++ components/Dashboard.tsx | 150 ++- components/EefDashboardPage.tsx | 126 ++- components/FcrDashboardPage.tsx | 115 +- components/FeedSackCountingPage.tsx | 138 ++- components/IotPanelPage.tsx | 139 ++- components/WeightMonitoringPage.tsx | 117 +- components/counting/DataManagementPage.tsx | 135 ++- components/shared/InsightConditionChart.tsx | 105 ++ components/shared/InsightProgressSteps.tsx | 49 + components/shared/InsightStatTiles.tsx | 169 +++ components/shared/InsightTrendChart.tsx | 230 ++++ components/shared/PageAiInsight.tsx | 960 ++++++++++++++++ services/apiClient.ts | 25 + types/api.ts | 147 ++- utils/cp707EnvStandards.ts | 87 ++ utils/insightContextBuilders.ts | 989 ++++++++++++++++ utils/insightHelpers.ts | 201 ++++ utils/insightParse.ts | 156 +++ utils/insightScope.ts | 277 +++++ utils/kpiStock.ts | 10 + utils/mortality.ts | 49 + 27 files changed, 5300 insertions(+), 343 deletions(-) create mode 100644 backend/apps/operations/migrations/0012_aiinsight_upgrade_cache_scope.py create mode 100644 components/AiInsightCard.tsx create mode 100644 components/shared/InsightConditionChart.tsx create mode 100644 components/shared/InsightProgressSteps.tsx create mode 100644 components/shared/InsightStatTiles.tsx create mode 100644 components/shared/InsightTrendChart.tsx create mode 100644 components/shared/PageAiInsight.tsx create mode 100644 utils/cp707EnvStandards.ts create mode 100644 utils/insightContextBuilders.ts create mode 100644 utils/insightHelpers.ts create mode 100644 utils/insightParse.ts create mode 100644 utils/insightScope.ts create mode 100644 utils/kpiStock.ts create mode 100644 utils/mortality.ts diff --git a/backend/apps/operations/migrations/0012_aiinsight_upgrade_cache_scope.py b/backend/apps/operations/migrations/0012_aiinsight_upgrade_cache_scope.py new file mode 100644 index 0000000..9170ab0 --- /dev/null +++ b/backend/apps/operations/migrations/0012_aiinsight_upgrade_cache_scope.py @@ -0,0 +1,77 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("farms", "0020_kandang_flock_source_ids"), + ("operations", "0011_aiinsight_unique_cycle_date_section_session"), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="aiinsight", + name="uniq_ai_insight_cycle_date_section_session", + ), + migrations.RemoveField( + model_name="aiinsight", + name="section", + ), + migrations.RemoveField( + model_name="aiinsight", + name="session", + ), + migrations.AddField( + model_name="aiinsight", + name="kandang", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="ai_insights", + to="farms.kandang", + ), + ), + migrations.AddField( + model_name="aiinsight", + name="topic", + field=models.CharField(blank=True, db_index=True, default="", max_length=64), + ), + migrations.AddField( + model_name="aiinsight", + name="report_type", + field=models.CharField(blank=True, db_index=True, default="page", max_length=32), + ), + migrations.AddField( + model_name="aiinsight", + name="report_period", + field=models.CharField(blank=True, db_index=True, default="current", max_length=64), + ), + migrations.AddField( + model_name="aiinsight", + name="summary", + field=models.TextField(blank=True, default=""), + ), + migrations.AddField( + model_name="aiinsight", + name="citations", + field=models.TextField(blank=True, default="[]"), + ), + migrations.AlterField( + model_name="aiinsight", + name="alert", + field=models.CharField(blank=True, default="", max_length=100), + ), + migrations.AlterModelOptions( + name="aiinsight", + options={"ordering": ["-date", "-created_at"]}, + ), + migrations.AddConstraint( + model_name="aiinsight", + constraint=models.UniqueConstraint( + fields=("cycle", "kandang", "topic", "report_type", "report_period"), + name="uniq_ai_insight_cache_scope", + ), + ), + ] diff --git a/backend/apps/operations/models.py b/backend/apps/operations/models.py index 99666fb..3fb88a4 100644 --- a/backend/apps/operations/models.py +++ b/backend/apps/operations/models.py @@ -29,21 +29,31 @@ class IotPanel(models.Model): class AIInsight(models.Model): date = models.DateField() insight_text = models.TextField() - alert = models.CharField(max_length=100) - section = models.CharField(max_length=100) - session = models.CharField(max_length=100) + alert = models.CharField(max_length=100, blank=True, default="") cycle = models.ForeignKey(Cycle, on_delete=models.CASCADE, related_name="ai_insights") + kandang = models.ForeignKey( + "farms.Kandang", + on_delete=models.CASCADE, + related_name="ai_insights", + null=True, + blank=True, + ) + topic = models.CharField(max_length=64, blank=True, default="", db_index=True) + report_type = models.CharField(max_length=32, blank=True, default="page", db_index=True) + report_period = models.CharField(max_length=64, blank=True, default="current", db_index=True) + summary = models.TextField(blank=True, default="") + citations = models.TextField(blank=True, default="[]") created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: db_table = "ai_insight" - ordering = ["-date"] + ordering = ["-date", "-created_at"] constraints = [ models.UniqueConstraint( - fields=["cycle", "date", "section", "session"], - name="uniq_ai_insight_cycle_date_section_session", - ), + fields=["cycle", "kandang", "topic", "report_type", "report_period"], + name="uniq_ai_insight_cache_scope", + ) ] diff --git a/backend/apps/operations/serializers.py b/backend/apps/operations/serializers.py index 17a68bb..bcefb9c 100644 --- a/backend/apps/operations/serializers.py +++ b/backend/apps/operations/serializers.py @@ -136,3 +136,18 @@ class AIInsightSerializer(PkAsIdMixin, CycleContextMixin, serializers.ModelSeria model = AIInsight fields = "__all__" read_only_fields = ["id", "created_at", "updated_at"] + + def to_representation(self, instance): + import json + data = super().to_representation(instance) + raw_citations = getattr(instance, "citations", "") + if isinstance(raw_citations, list): + data["citations"] = raw_citations + elif isinstance(raw_citations, str) and raw_citations.strip(): + try: + data["citations"] = json.loads(raw_citations) + except Exception: + data["citations"] = [] + else: + data["citations"] = [] + return data diff --git a/backend/apps/operations/views.py b/backend/apps/operations/views.py index c13bef0..fc3d584 100644 --- a/backend/apps/operations/views.py +++ b/backend/apps/operations/views.py @@ -236,9 +236,118 @@ class IotPanelViewSet(viewsets.ModelViewSet): class AIInsightViewSet(CycleScopedViewSet): - queryset = AIInsight.objects.select_related("cycle").all() + queryset = AIInsight.objects.select_related("cycle", "kandang").all() serializer_class = AIInsightSerializer + def get_queryset(self): + qs = super().get_queryset() + params = self.request.query_params + if params.get("kandang_id"): + qs = qs.filter(kandang_id=params["kandang_id"]) + if params.get("topic"): + qs = qs.filter(topic=params["topic"]) + if params.get("report_type"): + qs = qs.filter(report_type=params["report_type"]) + if params.get("report_period"): + qs = qs.filter(report_period=params["report_period"]) + return qs + + @action(detail=False, methods=["get"], url_path="cached") + def cached(self, request): + import json + params = request.query_params + cycle_id = params.get("cycle_id") + kandang_id = params.get("kandang_id") + topic = params.get("topic") + if not cycle_id or not kandang_id or not topic: + return Response( + {"detail": "cycle_id, kandang_id, and topic are required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + report_type = params.get("report_type") or "page" + report_period = params.get("report_period") or "current" + row = ( + AIInsight.objects.filter( + cycle_id=cycle_id, + kandang_id=kandang_id, + topic=topic, + report_type=report_type, + report_period=report_period, + ) + .order_by("-updated_at") + .first() + ) + if row is None: + return Response({"detail": "No cached insight."}, status=status.HTTP_404_NOT_FOUND) + + raw_cites = row.citations + if isinstance(raw_cites, list): + cites = raw_cites + elif isinstance(raw_cites, str) and raw_cites.strip(): + try: + cites = json.loads(raw_cites) + except Exception: + cites = [] + else: + cites = [] + + return Response({ + "success": True, + "insight_text": row.insight_text, + "summary": row.summary, + "alert": row.alert, + "citations": cites, + "topic": row.topic, + "report_type": row.report_type, + "report_period": row.report_period, + }) + + @action(detail=False, methods=["post"], url_path="generate") + def generate(self, request): + params = request.data or {} + cycle_id = params.get("cycle_id") + kandang_id = params.get("kandang_id") + topic = params.get("topic") + if not cycle_id or not kandang_id or not topic: + return Response( + {"detail": "cycle_id, kandang_id, and topic are required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + report_type = params.get("report_type") or "page" + report_period = params.get("report_period") or "current" + row = ( + AIInsight.objects.filter( + cycle_id=cycle_id, + kandang_id=kandang_id, + topic=topic, + report_type=report_type, + report_period=report_period, + ) + .order_by("-updated_at") + .first() + ) + if row is not None: + import json + raw_cites = row.citations + try: + cites = json.loads(raw_cites) if isinstance(raw_cites, str) else (raw_cites or []) + except Exception: + cites = [] + return Response({ + "success": True, + "insight_text": row.insight_text, + "summary": row.summary, + "alert": row.alert, + "citations": cites, + "topic": row.topic, + "report_type": row.report_type, + "report_period": row.report_period, + }) + return Response( + {"detail": "Insight belum tersedia pada mirror HQ. Pastikan insight telah di-generate pada site dashboard dan disinkronkan."}, + status=status.HTTP_404_NOT_FOUND, + ) + class HealthView(APIView): permission_classes = [AllowAny] diff --git a/backend/apps/sync/services/mirror_pull.py b/backend/apps/sync/services/mirror_pull.py index 9ace67c..b99b564 100644 --- a/backend/apps/sync/services/mirror_pull.py +++ b/backend/apps/sync/services/mirror_pull.py @@ -421,21 +421,36 @@ def _upsert_kpis(cycle: Cycle, rows: list[dict[str, Any]]) -> int: def _upsert_ai_insights(cycle: Cycle, rows: list[dict[str, Any]]) -> int: + import json count = 0 for raw in rows: - row_date = _as_date(raw.get("date")) - section = str(raw.get("section") or "")[:100] - session = str(raw.get("session") or "")[:100] - if row_date is None: - continue + row_date = _as_date(raw.get("date")) or cycle.start_date + topic = str(raw.get("topic") or "")[:64] + report_type = str(raw.get("report_type") or "page")[:32] + report_period = str(raw.get("report_period") or "current")[:64] + insight_text = str(raw.get("insight_text") or "") + summary = str(raw.get("summary") or "") + alert = str(raw.get("alert") or "")[:100] + raw_citations = raw.get("citations") + if isinstance(raw_citations, (list, dict)): + citations_str = json.dumps(raw_citations, ensure_ascii=False) + elif isinstance(raw_citations, str): + citations_str = raw_citations + else: + citations_str = "[]" + AIInsight.objects.update_or_create( cycle=cycle, - date=row_date, - section=section, - session=session, + kandang=cycle.kandang, + topic=topic, + report_type=report_type, + report_period=report_period, defaults={ - "insight_text": str(raw.get("insight_text") or ""), - "alert": str(raw.get("alert") or "")[:100], + "date": row_date, + "insight_text": insight_text, + "summary": summary, + "alert": alert, + "citations": citations_str, }, ) count += 1 diff --git a/components/AiInsightCard.tsx b/components/AiInsightCard.tsx new file mode 100644 index 0000000..adb3a96 --- /dev/null +++ b/components/AiInsightCard.tsx @@ -0,0 +1,1007 @@ +/** + * Dashboard AI Insight card (page / daily / weekly / end_cycle report modes). + * + * Parallel client to `PageAiInsight` (topic pages) — same API, different UX. + * Architecture: docs/ai-insight/README.md + */ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useFarm } from '../context/FarmContext.tsx'; +import { api, errorMessage } from '../services/apiClient.ts'; +import type { + EndCycleStructuredInsight, + InsightCitation, + InsightContext, + InsightReportType, + InsightStructuredReport, + InsightStructuredSection, + InsightStatusLabel, +} from '../types/api.ts'; +import { + buildEndCycleRollups, + type DashboardInsightMetrics, + type EndCycleIotDayAggregate, + scopeDashboardInsightMetrics, +} from '../utils/insightContextBuilders.ts'; +import { getCobbStandardForDay } from '../utils/insightHelpers.ts'; +import { formatRatio } from '../utils/format.ts'; +import { + HeroFigure, + MutedStatRow, + StatTile, + type InsightStatus, +} from './shared/InsightStatTiles.tsx'; +import InsightTrendChart from './shared/InsightTrendChart.tsx'; + +const END_CYCLE_JSON_MARKER = ''; + +const sectionBadgeClasses: Record = { + ok: 'border-emerald-200 bg-emerald-50 text-emerald-700', + warning: 'border-amber-200 bg-amber-50 text-amber-700', + critical: 'border-red-200 bg-red-50 text-red-700', + unknown: 'border-slate-200 bg-slate-50 text-slate-600', +}; + +const confidenceBadge: Record = { + high: 'border-red-200 bg-red-50 text-red-700', + med: 'border-amber-200 bg-amber-50 text-amber-700', + low: 'border-slate-200 bg-slate-50 text-slate-600', +}; + +export interface AiInsightCardProps { + /** Required — insight is always per kandang. */ + kandangId: number; + /** Optional curated context from a page builder (e.g. buildFcrInsightContext). */ + context?: InsightContext; + /** Display-only KPIs + weight series (not sent to the LLM). */ + dashboardMetrics?: DashboardInsightMetrics | null; + /** Optional IoT day aggregates for end-cycle root-cause enrichment. */ + iotDayAggregates?: EndCycleIotDayAggregate[]; + page?: 'hitung_ayam' | 'berat_ayam' | 'fcr' | 'eef' | 'panel_iot' | 'data_quality' | 'dashboard'; +} + +const parseDayFromPeriod = (period: string | null): number | null => { + if (!period) return null; + const match = String(period).match(/\d+/); + if (!match) return null; + const day = parseInt(match[0], 10); + return Number.isFinite(day) && day >= 0 ? day : null; +}; + +const stripJsonFences = (text: string) => + text + .trim() + .replace(/^```json\s*/i, '') + .replace(/^```\s*/i, '') + .replace(/```$/i, '') + .trim(); + +const extractJsonObject = (text: string): string | null => { + const stripped = stripJsonFences(text); + if (!stripped) return null; + if (stripped.startsWith('{') && stripped.endsWith('}')) return stripped; + const first = stripped.indexOf('{'); + const last = stripped.lastIndexOf('}'); + if (first === -1 || last === -1 || last <= first) return null; + return stripped.slice(first, last + 1); +}; + +const asNonEmptyString = (value: unknown): string | null => { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +}; + +const asStringArray = (value: unknown, limit = 3): string[] => { + if (!Array.isArray(value)) return []; + return value + .map((item) => asNonEmptyString(item)) + .filter((item): item is string => item !== null) + .slice(0, limit); +}; + +const asInsightStatus = (value: unknown): InsightStatusLabel => { + if (value === 'ok' || value === 'warning' || value === 'critical' || value === 'unknown') { + return value; + } + return 'unknown'; +}; + +const normalizeInsightSection = (value: unknown): InsightStructuredSection => { + const section = (value && typeof value === 'object' ? value : {}) as Record; + return { + status: asInsightStatus(section.status), + bullets: asStringArray(section.bullets, 3), + evidence: asStringArray(section.evidence, 4), + actions: asStringArray(section.actions, 3), + }; +}; + +const normalizeStructuredInsight = (rawText: string): InsightStructuredReport | null => { + try { + const jsonText = extractJsonObject(rawText); + if (!jsonText) return null; + const parsed = JSON.parse(jsonText) as Record; + const summary = ( + parsed.summary && typeof parsed.summary === 'object' ? parsed.summary : {} + ) as Record; + const detailed = ( + parsed.detailed && typeof parsed.detailed === 'object' ? parsed.detailed : {} + ) as Record; + + return { + summary: { + headline: asNonEmptyString(summary.headline) || 'Ringkasan belum tersedia', + bullets: asStringArray(summary.bullets, 3), + risks: asStringArray(summary.risks, 3), + actions: asStringArray(summary.actions, 3), + }, + detailed: { + hitung_ayam: normalizeInsightSection(detailed.hitung_ayam), + berat_ayam: normalizeInsightSection(detailed.berat_ayam), + fcr: normalizeInsightSection(detailed.fcr), + eef: normalizeInsightSection(detailed.eef), + panel_iot: normalizeInsightSection(detailed.panel_iot), + data_quality: normalizeInsightSection(detailed.data_quality), + }, + }; + } catch { + return null; + } +}; + +const normalizeEndCycleInsight = ( + raw: unknown, + fallbackSummary?: string | null +): EndCycleStructuredInsight | null => { + if (!raw || typeof raw !== 'object') return null; + const parsed = raw as Record; + const hasShape = + Array.isArray(parsed.masalah) || + Array.isArray(parsed.akar_penyebab) || + Array.isArray(parsed.perbaikan_siklus_berikutnya); + if (!hasShape) return null; + + const akarRaw = Array.isArray(parsed.akar_penyebab) ? parsed.akar_penyebab : []; + const fixRaw = Array.isArray(parsed.perbaikan_siklus_berikutnya) + ? parsed.perbaikan_siklus_berikutnya + : []; + + return { + kesimpulan: + asNonEmptyString(parsed.kesimpulan) || + asNonEmptyString(fallbackSummary) || + 'Ringkasan akhir siklus', + masalah: asStringArray(parsed.masalah, 8), + akar_penyebab: akarRaw + .filter((item): item is Record => !!item && typeof item === 'object') + .map((item) => ({ + id: asNonEmptyString(item.id) ?? undefined, + hipotesis: asNonEmptyString(item.hipotesis) || 'Hipotesis', + bukti: asStringArray(item.bukti, 6), + confidence: asNonEmptyString(item.confidence) ?? undefined, + fase: asNonEmptyString(item.fase) ?? undefined, + })), + perbaikan_siklus_berikutnya: fixRaw + .filter((item): item is Record => !!item && typeof item === 'object') + .map((item) => ({ + id: asNonEmptyString(item.id) ?? undefined, + fase: asNonEmptyString(item.fase) || 'growth', + aksi: asNonEmptyString(item.aksi) || '', + metrik_pantau: asNonEmptyString(item.metrik_pantau) ?? undefined, + })) + .filter((item) => item.aksi.length > 0), + insight: asNonEmptyString(parsed.insight) ?? undefined, + }; +}; + +const parseEndCycleFromText = ( + text: string, + fallbackSummary?: string | null +): EndCycleStructuredInsight | null => { + if (!text) return null; + if (text.includes(END_CYCLE_JSON_MARKER)) { + const raw = text.split(END_CYCLE_JSON_MARKER, 2)[1]?.trim() ?? ''; + try { + return normalizeEndCycleInsight(JSON.parse(raw), fallbackSummary); + } catch { + /* fall through */ + } + } + const jsonText = extractJsonObject(text); + if (!jsonText) return null; + try { + return normalizeEndCycleInsight(JSON.parse(jsonText), fallbackSummary); + } catch { + return null; + } +}; + +const CitationsBlock: React.FC<{ citations?: InsightCitation[] }> = ({ citations }) => { + if (!citations || citations.length === 0) return null; + return ( +
+

+ Sumber referensi +

+
    + {citations.map((c, i) => ( +
  • + + {c.chapter || c.title || c.source || `Referensi ${i + 1}`} + + {c.excerpt ? — {c.excerpt} : null} +
  • + ))} +
+
+ ); +}; + +/** + * Dashboard AI Insight report card (shell). + * Requires a kandangId — never generates for "all kandang". + * Wire page-specific KPIs/charts via `context` from insightContextBuilders. + */ +export const AiInsightCard: React.FC = ({ + kandangId, + context: contextProp, + dashboardMetrics = null, + iotDayAggregates = [], + page = 'dashboard', +}) => { + const { selectedCycle, selectedKandang, selectedSite, selectedKandangId } = useFarm(); + + const [insightText, setInsightText] = useState(''); + const [structuredInsight, setStructuredInsight] = useState(null); + const [endCycleInsight, setEndCycleInsight] = useState(null); + const [citations, setCitations] = useState([]); + const [loading, setLoading] = useState(false); + const [lastUpdated, setLastUpdated] = useState(null); + const [error, setError] = useState(null); + const [detailsOpen, setDetailsOpen] = useState(false); + const [reportType, setReportType] = useState('daily'); + const [dayPeriod, setDayPeriod] = useState(null); + const insightInFlightRef = useRef(false); + + const effectiveKandangId = kandangId ?? selectedKandangId; + const contextDay = + typeof contextProp?.hari_ke === 'number' && Number.isFinite(contextProp.hari_ke) + ? contextProp.hari_ke + : null; + // Prefer context/data day over cycle calendar day (can be ahead of KPI/weight data). + const currentDayVal = contextDay ?? dashboardMetrics?.hari_ke ?? selectedCycle?.current_day ?? 0; + const reportPeriod = + reportType === 'daily' + ? (dayPeriod ?? + (typeof currentDayVal === 'number' && currentDayVal >= 0 ? `Hari ${currentDayVal}` : null)) + : reportType === 'weekly' + ? dayPeriod + : null; + + const scopedMetrics = useMemo(() => { + if (!dashboardMetrics) return null; + if (reportType === 'end_cycle') return dashboardMetrics; + const selectedDay = parseDayFromPeriod(reportPeriod); + return scopeDashboardInsightMetrics(dashboardMetrics, selectedDay); + }, [dashboardMetrics, reportType, reportPeriod]); + + const topic = page === 'dashboard' ? 'dashboard' : page === 'panel_iot' ? 'iot_panel' : page; + + const applyInsight = useCallback( + ( + text: string, + nextCitations?: InsightCitation[] | 'cache' | 'local_fallback', + structuredEndCycle?: EndCycleStructuredInsight | null, + summary?: string | null + ) => { + const citationsArg = Array.isArray(nextCitations) ? nextCitations : []; + setInsightText(text); + const endCycle = + structuredEndCycle ?? + parseEndCycleFromText(text, summary) ?? + (typeof nextCitations === 'string' ? null : null); + setEndCycleInsight(endCycle); + // Prefer end-cycle structured UI over legacy multi-topic structured dump. + setStructuredInsight(endCycle ? null : normalizeStructuredInsight(text)); + setCitations(citationsArg); + }, + [] + ); + + const generateInsight = useCallback( + async (forceRefresh = false) => { + if (insightInFlightRef.current) return; + if (effectiveKandangId == null) { + setError('Pilih kandang terlebih dahulu. Insight digenerate per kandang.'); + return; + } + if (!selectedCycle?.id) { + setError('Data siklus tidak tersedia. Pastikan ada siklus aktif.'); + return; + } + + insightInFlightRef.current = true; + if (forceRefresh) setLoading(true); + setError(null); + + try { + const effectiveDay = + typeof currentDayVal === 'number' && currentDayVal >= 0 + ? currentDayVal + : selectedCycle.current_day; + const contextPayload: InsightContext = { + kandangId: effectiveKandangId, + kandangName: selectedKandang?.kandang_name, + cycleId: selectedCycle.id, + hari_ke: effectiveDay, + totalDays: selectedCycle.total_days, + ...(contextProp ?? {}), + }; + + // Always stamp required identity after spread so caller cannot clear it. + contextPayload.kandangId = effectiveKandangId; + if (typeof currentDayVal === 'number' && currentDayVal >= 0) + contextPayload.hari_ke = currentDayVal; + + // Prefer period-scoped display metrics so the model analyzes the same + // numbers the muted rows / tiles show for the selected day. + if (scopedMetrics) { + contextPayload.hari_ke = scopedMetrics.hari_ke ?? contextPayload.hari_ke; + contextPayload.fcr_terakhir = scopedMetrics.fcr; + contextPayload.eef_terakhir = scopedMetrics.eef; + contextPayload.bobot_avg_gram = scopedMetrics.bobot_avg_gram; + contextPayload.ayam_hidup_ekor = scopedMetrics.ayam_hidup_ekor; + contextPayload.mortalitas_kumulatif_persen = + scopedMetrics.mortalitas_kumulatif_persen ?? null; + contextPayload.persen_hidup = + scopedMetrics.mortalitas_kumulatif_persen == null + ? null + : Math.round((100 - scopedMetrics.mortalitas_kumulatif_persen) * 100) / 100; + contextPayload.doc_in_ekor = scopedMetrics.doc_in_ekor; + contextPayload.kematian_kumulatif_ekor = scopedMetrics.kematian_kumulatif_ekor; + contextPayload.panen_kumulatif_ekor = scopedMetrics.panen_kumulatif_ekor; + contextPayload.tonase_panen_kg = scopedMetrics.tonase_panen_kg; + contextPayload.uniformity_persen = scopedMetrics.uniformity_persen; + contextPayload.pakan_kumulatif_karung = scopedMetrics.pakan_kumulatif_karung; + + const currentTarget = + scopedMetrics.weightSeries.find((row) => row.hari === contextPayload.hari_ke)?.target ?? + [...scopedMetrics.weightSeries].reverse().find((row) => row.target != null)?.target ?? + null; + if (currentTarget != null && currentTarget > 0) { + contextPayload.target_bw_gram = currentTarget; + } + + // EEF series for trend computation (eef, dashboard, end_cycle topics) + if (topic === 'eef' || topic === 'dashboard' || reportType === 'end_cycle') { + contextPayload.eef_seri = scopedMetrics.kpiSeries + .filter((row) => row.eef != null && row.hari > 0) + .map((row) => ({ hari: row.hari, eef: row.eef })); + } + } + + if (reportType === 'end_cycle' && scopedMetrics) { + const rollups = buildEndCycleRollups(scopedMetrics, iotDayAggregates); + contextPayload.weekly_summaries = rollups.weekly_summaries; + contextPayload.phase_summaries = rollups.phase_summaries; + contextPayload.tren_kpi_harian = rollups.tren_kpi_harian; + contextPayload.mati_ekor = rollups.mati_ekor; + contextPayload.panen_ekor = rollups.panen_ekor; + contextPayload.saldo_ekor = rollups.saldo_ekor; + if (iotDayAggregates.length > 0) { + const latestIot = iotDayAggregates[iotDayAggregates.length - 1]!; + contextPayload.suhu_rata_rata_C = latestIot.avg_temp_C; + contextPayload.kelembapan_persen = latestIot.avg_humidity_pct; + contextPayload.iot_day_aggregates = iotDayAggregates; + } + } + + let apiResult = null as Awaited> | null; + + 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; + } + + if (!apiResult) { + applyInsight('', 'cache'); + setLastUpdated(null); + setError(null); + return; + } + + if (!apiResult?.insight_text && !apiResult?.summary) { + throw new Error(apiResult?.message || 'Gagal mengambil insight AI dari server'); + } + + applyInsight( + apiResult.insight_text, + apiResult.citations, + apiResult.structured_end_cycle ?? null, + apiResult.summary + ); + setLastUpdated(new Date()); + setDetailsOpen(false); + } catch (err) { + console.error('Error generating insight:', err); + if (forceRefresh) { + applyInsight('', 'local_fallback'); + setLastUpdated(null); + setError(errorMessage(err)); + setDetailsOpen(false); + } else { + applyInsight('', 'cache'); + setLastUpdated(null); + setError(null); + } + } finally { + setLoading(false); + insightInFlightRef.current = false; + } + }, + [ + effectiveKandangId, + selectedCycle, + selectedKandang, + contextProp, + currentDayVal, + topic, + reportType, + reportPeriod, + scopedMetrics, + iotDayAggregates, + applyInsight, + ] + ); + + useEffect(() => { + if (effectiveKandangId == null || !selectedCycle?.id) return; + // Soft cache load only — never auto-generate. + const timer = setTimeout(() => generateInsight(false), 50); + return () => clearTimeout(timer); + }, [effectiveKandangId, selectedCycle?.id, reportType, reportPeriod, generateInsight]); + + // NOTE: no interval auto-refresh / auto-generate (manual Generate only). + const formatLastUpdated = () => { + if (!lastUpdated) return ''; + const diff = Date.now() - lastUpdated.getTime(); + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(minutes / 60); + if (hours > 0) return `${hours} jam yang lalu`; + if (minutes > 0) return `${minutes} menit yang lalu`; + return 'Baru saja'; + }; + + const renderBulletList = (items: string[], emptyLabel = 'data belum tersedia') => { + if (!items.length) return

{emptyLabel}

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

{title}

+ + {section.status} + +
+
+
+

Poin

+
{renderBulletList(section.bullets)}
+
+
+

Bukti

+
{renderBulletList(section.evidence)}
+
+
+

Aksi

+
{renderBulletList(section.actions)}
+
+
+
+ ); + + const summary = structuredInsight?.summary; + const detailed = structuredInsight?.detailed; + + const gradeDay = + scopedMetrics?.hari_ke ?? + (typeof currentDayVal === 'number' && currentDayVal >= 0 ? currentDayVal : null); + const fcrStandard = gradeDay != null ? (getCobbStandardForDay(gradeDay)?.fcr ?? null) : null; + + const fcrStatus = ((): InsightStatus => { + const actual = scopedMetrics?.fcr ?? null; + if (actual === null || fcrStandard === null) return 'unknown'; + if (actual <= fcrStandard) return 'ok'; + if (actual <= fcrStandard * 1.1) return 'warning'; + return 'critical'; + })(); + + const eefStatus = ((): InsightStatus => { + const actual = scopedMetrics?.eef ?? null; + if (actual === null) return 'unknown'; + if ((gradeDay ?? 0) < 35) return 'unknown'; + if (actual >= 300) return 'ok'; + if (actual >= 250) return 'warning'; + return 'critical'; + })(); + + const mortalityStatus = ((): InsightStatus => { + const pct = scopedMetrics?.mortalitas_kumulatif_persen ?? null; + if (pct === null) return 'unknown'; + if (pct < 5) return 'ok'; + if (pct <= 7) return 'warning'; + return 'critical'; + })(); + + const weightTarget = + scopedMetrics?.weightSeries.find((row) => row.hari === gradeDay)?.target ?? + [...(scopedMetrics?.weightSeries ?? [])].reverse().find((row) => row.target != null)?.target ?? + null; + + const weightStatus = ((): InsightStatus => { + const actual = scopedMetrics?.bobot_avg_gram ?? null; + if (actual === null || weightTarget === null || weightTarget <= 0) return 'unknown'; + const dev = ((actual - weightTarget) / weightTarget) * 100; + if (dev >= -5) return 'ok'; + if (dev >= -15) return 'warning'; + return 'critical'; + })(); + + const weightGapPct = + scopedMetrics?.bobot_avg_gram != null && weightTarget != null && weightTarget > 0 + ? ((scopedMetrics.bobot_avg_gram - weightTarget) / weightTarget) * 100 + : null; + + const chartHighlight = + reportType === 'daily' && gradeDay != null + ? { firstDay: gradeDay, lastDay: gradeDay } + : gradeDay != null + ? { firstDay: 1, lastDay: gradeDay } + : null; + + const hasInsight = + Boolean(insightText.trim()) || structuredInsight != null || endCycleInsight != null; + + const renderVisualBlock = () => { + if (!hasInsight || !scopedMetrics || page !== 'dashboard') return null; + + const fcrVal = scopedMetrics.fcr; + const eefVal = scopedMetrics.eef; + const popVal = scopedMetrics.ayam_hidup_ekor; + const mortVal = scopedMetrics.mortalitas_kumulatif_persen; + const bobotVal = scopedMetrics.bobot_avg_gram; + + return ( +
+
+ fcrStandard ? '+' : ''}${formatRatio(fcrVal - fcrStandard, 3)} vs standar` + : null + } + /> + = 35 + ? 'Standar umur panen: >300' + : 'Belum umur panen — wajar masih rendah' + } + status={eefStatus} + deltaLabel={ + eefVal == null || (gradeDay ?? 0) < 35 + ? null + : eefVal >= 300 + ? 'di atas standar' + : 'di bawah standar' + } + /> +
+ +
+ + + +
+ +
+ + + + + + +
+ +
+ >} + series={[ + { field: 'actual', label: 'Bobot aktual', color: '#dc2626' }, + { field: 'target', label: 'Target CP 707', color: '#9ca3af', dashed: true }, + ]} + unit="g" + title="Bobot Aktual vs Target CP 707" + highlight={chartHighlight} + gap={{ actualField: 'actual', targetField: 'target' }} + emptyLabel="Belum ada data bobot untuk digambarkan." + /> +
+
+ ); + }; + + return ( +
+
+
+
+ +
+

AI Insight

+

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

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

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

+
+
+ ) : error && !insightText ? ( +
+

Error

+

{error}

+ +
+ ) : endCycleInsight ? ( +
+
+

+ Kesimpulan +

+

+ {endCycleInsight.kesimpulan} +

+
+ +
+

+ Masalah +

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

+ Akar penyebab +

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

+ Belum ada kandidat akar penyebab berbukti. +

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

    {item.hipotesis}

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

+ Perbaikan siklus + berikutnya +

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

Belum ada checklist perbaikan.

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

    + {item.fase} +

    +

    {item.aksi}

    + {item.metrik_pantau ? ( +

    Pantau: {item.metrik_pantau}

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

{endCycleInsight.insight}

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

+ Kesimpulan +

+

+ {summary.headline} +

+
+

+ AI Insight +

+
+ {renderBulletList([...summary.bullets, ...summary.risks, ...summary.actions])} +
+
+ +
+ + {page === 'dashboard' && detailed && ( + <> + + {detailsOpen && ( +
+ {detailed.hitung_ayam && + renderDetailSection( + 'Hitung Ayam', + detailed.hitung_ayam, + sectionBadgeClasses[detailed.hitung_ayam.status] + )} + {detailed.berat_ayam && + renderDetailSection( + 'Berat Ayam', + detailed.berat_ayam, + sectionBadgeClasses[detailed.berat_ayam.status] + )} + {detailed.fcr && + renderDetailSection( + 'FCR', + detailed.fcr, + sectionBadgeClasses[detailed.fcr.status] + )} + {detailed.eef && + renderDetailSection( + 'EEF', + detailed.eef, + sectionBadgeClasses[detailed.eef.status] + )} + {detailed.panel_iot && + renderDetailSection( + 'Panel IoT', + detailed.panel_iot, + sectionBadgeClasses[detailed.panel_iot.status] + )} + {detailed.data_quality && + renderDetailSection( + 'Kualitas Data', + detailed.data_quality, + sectionBadgeClasses[detailed.data_quality.status] + )} +
+ )} + + )} +
+ ) : insightText ? ( +
+ {insightText.includes(END_CYCLE_JSON_MARKER) + ? insightText.split(END_CYCLE_JSON_MARKER)[0]?.trim() + : insightText} + +
+ ) : ( +
+ +

Belum ada AI Insight untuk periode ini.

+

Insight akan tampil setelah disinkronkan dari farm site.

+
+ )} +
+ + {lastUpdated && ( +
+

Terakhir diperbarui: {formatLastUpdated()}

+
+ )} +
+
+ ); +}; + +export default AiInsightCard; diff --git a/components/Dashboard.tsx b/components/Dashboard.tsx index 8f197ff..37d74ae 100644 --- a/components/Dashboard.tsx +++ b/components/Dashboard.tsx @@ -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,20 @@ import { } from '../utils/format.ts'; import { throughVisibleDate } from '../utils/visibility.ts'; import { getCountingAccuracyWarning } from '../utils/countingAccuracyWarning.ts'; +import { getTempStandardByDay } from '../utils/cp707EnvStandards.ts'; +import { + buildDashboardInsightContext, + buildDashboardInsightMetrics, + buildIotDayAggregates, + type EndCycleIotDayAggregate, +} from '../utils/insightContextBuilders.ts'; import DashboardReportCtas from './dashboard/DashboardReportCtas.tsx'; import ReportPreviewOverlay from './dashboard/ReportPreviewOverlay.tsx'; import CountingAccuracyWarningBanner from './counting/CountingAccuracyWarningBanner.tsx'; import PendingCloseQueue from './PendingCloseQueue.tsx'; import KandangRankingTop5 from './dashboard/KandangRankingTop5.tsx'; import type { DashboardReportType } from '../types/dashboardReport.ts'; +import { AiInsightCard } from './AiInsightCard.tsx'; type Props = { setView: (view: View) => void }; @@ -35,7 +42,7 @@ function chronological(rows: T[]): T[] { } const Dashboard: React.FC = ({ setView }) => { - const { selectedCycle, selectedKandang, selectedSite } = useFarm(); + const { selectedCycle, selectedKandang, selectedSite, selectedKandangId } = useFarm(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [kpis, setKpis] = useState([]); @@ -43,7 +50,7 @@ const Dashboard: React.FC = ({ setView }) => { const [manualInputs, setManualInputs] = useState([]); const [weights, setWeights] = useState([]); const [karung, setKarung] = useState(null); - const [insights, setInsights] = useState([]); + const [iotDayAggregates, setIotDayAggregates] = useState([]); const [reportOpen, setReportOpen] = useState(false); const [reportType, setReportType] = useState('daily'); @@ -54,7 +61,7 @@ const Dashboard: React.FC = ({ setView }) => { setManualInputs([]); setWeights([]); setKarung(null); - setInsights([]); + setIotDayAggregates([]); return; } const cycleId = selectedCycle.id; @@ -63,12 +70,11 @@ const Dashboard: React.FC = ({ 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 +88,31 @@ const Dashboard: React.FC = ({ setView }) => { setManualInputs(manualRows); setWeights(weightRows); setKarung(latestKarung); - setInsights(insightRows); + + // Best-effort IoT aggregates for end-cycle root cause (non-blocking on failure). + try { + const flockId = + kpiRows.find((row) => row.flock_id != null)?.flock_id ?? + (selectedKandang + ? (await api.flocks.list({ kandang_id: selectedKandang.id }))[0]?.id + : null); + if (flockId != null) { + const panels = await api.panelIot.list({ flock_id: flockId }); + if (!cancelled) { + setIotDayAggregates( + buildIotDayAggregates({ + rows: panels, + cycleStartDate: selectedCycle.start_date, + getTempTarget: getTempStandardByDay, + }) + ); + } + } else { + if (!cancelled) setIotDayAggregates([]); + } + } catch { + if (!cancelled) setIotDayAggregates([]); + } } catch (err) { if (!cancelled) setError(errorMessage(err)); } finally { @@ -92,7 +122,72 @@ const Dashboard: React.FC = ({ setView }) => { return () => { cancelled = true; }; - }, [selectedCycle]); + }, [selectedCycle, selectedKandang]); + + 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]); + + 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, + 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, + mortalitas_kumulatif_persen: + latest?.chicken_life_percentage == null + ? null + : Math.round((100 - latest.chicken_life_percentage) * 100) / 100, + 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 != null ? Math.max(0, latest.chicken_life - (latest.harvest_total ?? 0)) : 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 ? latest.harvest_weight_total / 1000 : null, + uniformity_persen: weightOnAnchor?.uniformity ?? null, + }); + }, [selectedKandangId, selectedKandang, selectedCycle, kpis, weights, karung]); const cards: KpiCardModel[] = useMemo(() => { // All cards share the latest complete KPI day (not calendar current_day / live IoT). @@ -352,32 +447,21 @@ const Dashboard: React.FC = ({ setView }) => {
-

AI Insight

- {insights.length === 0 ? ( - Belum ada insight untuk siklus ini. + {selectedKandangId != null ? ( + ) : ( -
- {insights.slice(0, 4).map((item) => ( -
-
-
-

- {item.section} -

-

- {item.alert || 'Insight'} -

-
- {item.session} -
-

- {item.insight_text} -

-
- ))} +
+
+ +

AI Insight

+
+ Pilih satu kandang untuk melihat AI Insight.
)}
diff --git a/components/EefDashboardPage.tsx b/components/EefDashboardPage.tsx index c26dbc8..7f388f1 100644 --- a/components/EefDashboardPage.tsx +++ b/components/EefDashboardPage.tsx @@ -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([]); - const [insights, setInsights] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [lastUpdated, setLastUpdated] = useState(null); @@ -175,12 +176,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)); @@ -190,7 +187,6 @@ const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) useEffect(() => { if (!selectedCycle) { setRows([]); - setInsights([]); setLastUpdated(null); return; } @@ -252,21 +248,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 ; @@ -286,37 +277,64 @@ const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) )} -
-

- - AI Insight EEF -

- {loading ? ( - - ) : eefInsights.length === 0 ? ( - Belum ada insight untuk siklus ini. - ) : ( -
- {eefInsights.map((item) => ( -
-
-
-

{item.section}

-

{item.alert || 'Insight'}

-
- {item.session} -
-

- {item.insight_text} -

-
- ))} -
- )} -
+ {selectedKandang && insightContext ? ( + + ) : ( +
+

+ + AI Insight EEF +

+ Pilih kandang untuk melihat AI Insight. +
+ )} {loading ? ( diff --git a/components/FcrDashboardPage.tsx b/components/FcrDashboardPage.tsx index 1baeed6..a9ce993 100644 --- a/components/FcrDashboardPage.tsx +++ b/components/FcrDashboardPage.tsx @@ -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([]); const [weightRows, setWeightRows] = useState([]); - const [insights, setInsights] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [lastUpdated, setLastUpdated] = useState(null); @@ -167,13 +168,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) { @@ -185,7 +184,6 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) if (!selectedCycle) { setRows([]); setWeightRows([]); - setInsights([]); setLastUpdated(null); return; } @@ -258,20 +256,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 ; @@ -291,37 +285,56 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) )} -
-

- - AI Insight FCR -

- {loading ? ( - - ) : fcrInsights.length === 0 ? ( - Belum ada insight untuk siklus ini. - ) : ( -
- {fcrInsights.map((item) => ( -
-
-
-

{item.section}

-

{item.alert || 'Insight'}

-
- {item.session} -
-

- {item.insight_text} -

-
- ))} -
- )} -
+ {selectedKandang && insightContext ? ( + + ) : ( +
+

+ + AI Insight FCR +

+ Pilih kandang untuk melihat AI Insight. +
+ )} {loading ? ( diff --git a/components/FeedSackCountingPage.tsx b/components/FeedSackCountingPage.tsx index 68eca08..5eb468c 100644 --- a/components/FeedSackCountingPage.tsx +++ b/components/FeedSackCountingPage.tsx @@ -1,9 +1,11 @@ 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 { PageAiInsight } from './shared/PageAiInsight.tsx'; import FeedStats from './feedSack/FeedStats.tsx'; import FeedSaldoCard from './feedSack/FeedSaldoCard.tsx'; import FeedTrendChart from './feedSack/FeedTrendChart.tsx'; @@ -15,7 +17,6 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings useFarm(); const [rows, setRows] = useState([]); const [manualRows, setManualRows] = useState([]); - const [insights, setInsights] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [showCyclePicker, setShowCyclePicker] = useState(false); @@ -24,13 +25,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)); @@ -42,7 +41,6 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings useEffect(() => { if (!selectedCycle) { setRows([]); - setInsights([]); setManualRows([]); return; } @@ -92,6 +90,36 @@ 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((r) => [r.date, r])); + const dailyRows = sortedRows.map((r) => { + const manual = manualByDate.get(r.date); + return { + date: r.date, + day: cycleDayForDate(selectedCycle.start_date, r.date), + masuk: r.in_today, + dituang: r.feed_use_today, + keluar: r.out_today, + masukManual: manual?.feed_in_manual ?? 0, + dituangManual: manual?.feed_use_manual ?? 0, + }; + }); + 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 ; const cycleStatus = resolveCycleStatus(selectedCycle); @@ -197,40 +225,70 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings ) : (
- {/* AI Insight */} -
-

- - AI Insight Hitung Karung -

- {insights.length === 0 ? ( - Belum ada insight untuk siklus ini. - ) : ( -
- {insights.slice(0, 4).map((item) => ( -
-
-
-

- {item.section} -

-

- {item.alert || 'Insight'} -

-
- {item.session} -
-

- {item.insight_text} -

-
- ))} -
- )} -
+ {selectedKandang && insightContext ? ( + + ) : ( +
+

+ + AI Insight Hitung Karung +

+ Pilih kandang untuk melihat AI Insight. +
+ )} {/* Saldo Awal */} diff --git a/components/IotPanelPage.tsx b/components/IotPanelPage.tsx index 36a9b54..fd90640 100644 --- a/components/IotPanelPage.tsx +++ b/components/IotPanelPage.tsx @@ -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 { + 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([]); const [selectedDate, setSelectedDate] = useState(''); const [dayRows, setDayRows] = useState([]); - const [insights, setInsights] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [selectedChartKey, setSelectedChartKey] = useState<'humidity' | 'wind' | 'water' | null>( @@ -80,7 +86,6 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { setDates([]); setSelectedDate(''); setDayRows([]); - setInsights([]); return; } let cancelled = false; @@ -107,7 +112,6 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { setDates([]); setSelectedDate(''); setDayRows([]); - setInsights([]); return; } let cancelled = false; @@ -115,14 +119,13 @@ 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 @@ -222,21 +225,26 @@ 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, + experience_suhu_min_C: summary?.minExp ?? null, + experience_suhu_max_C: summary?.maxExp ?? null, }); - return (matched.length > 0 ? matched : insights).slice(0, 4); - }, [insights]); + }, [selectedKandang, selectedCycle, dayAge, summary]); if (!selectedCycle) return ; @@ -266,43 +274,62 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { /> {error && {error}} + + {selectedKandang && insightContext ? ( + + ) : ( +
+

+ + AI Insight Panel IoT +

+ Pilih kandang untuk melihat AI Insight. +
+ )} + {loading && dayRows.length === 0 ? ( ) : summary ? ( <> -
-

- - AI Insight Panel IoT -

- {iotInsights.length === 0 ? ( - Belum ada insight untuk siklus ini. - ) : ( -
- {iotInsights.map((item) => ( -
-
-
-

- {item.section} -

-

- {item.alert || 'Insight'} -

-
- {item.session} -
-

- {item.insight_text} -

-
- ))} -
- )} -
diff --git a/components/WeightMonitoringPage.tsx b/components/WeightMonitoringPage.tsx index caba957..e4ff12d 100644 --- a/components/WeightMonitoringPage.tsx +++ b/components/WeightMonitoringPage.tsx @@ -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, Spinner } from './ui/Feedback.tsx'; import { formatDateId, formatNumber } from '../utils/format.ts'; import { formatWeight } from '../utils/weight.ts'; +import { buildWeightInsightContext } from '../utils/insightContextBuilders.ts'; +import { PageAiInsight } from './shared/PageAiInsight.tsx'; import WeightStats from './weight/WeightStats.tsx'; import DailyWeightProgressionChart from './weight/DailyWeightProgressionChart.tsx'; import DailyWeightGainChart from './weight/DailyWeightGainChart.tsx'; import DailyDetailPanel from './weight/DailyDetailPanel.tsx'; const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { - const { selectedCycle } = useFarm(); + const { selectedCycle, selectedKandang } = useFarm(); const [rows, setRows] = useState([]); const [latest, setLatest] = useState(null); - const [insights, setInsights] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [selectedDate, setSelectedDate] = useState(''); @@ -22,12 +23,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) { @@ -43,7 +40,6 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings if (!selectedCycle) { setRows([]); setLatest(null); - setInsights([]); setSelectedDate(''); return; } @@ -86,14 +82,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(() => { @@ -112,6 +100,21 @@ 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, + docWeight: docWeight, + weightRows: chartRows, + averageWeight: kpiLatest?.average_weight ?? null, + uniformity: kpiLatest?.uniformity ?? null, + adg: kpiLatest?.average_daily_gain ?? null, + }); + }, [selectedKandang, selectedCycle, docWeight, chartRows, kpiLatest]); + if (!selectedCycle) return ; return ( @@ -147,37 +150,55 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings ) : (
- {/* AI Insight */} -
-

AI Insight

- {weightInsights.length === 0 ? ( - Belum ada insight untuk siklus ini. - ) : ( -
- {weightInsights.map((item) => ( -
-
-
-

- {item.section} -

-

- {item.alert || 'Insight'} -

-
- {item.session} -
-

- {item.insight_text} -

-
- ))} -
- )} -
+ {selectedKandang && insightContext ? ( + + ) : ( +
+

+ + AI Insight Berat Ayam +

+ Pilih kandang untuk melihat AI Insight. +
+ )} {/* TOP SECTION: DATA SELURUH SIKLUS */}
diff --git a/components/counting/DataManagementPage.tsx b/components/counting/DataManagementPage.tsx index 94c4aec..588ab96 100644 --- a/components/counting/DataManagementPage.tsx +++ b/components/counting/DataManagementPage.tsx @@ -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 { @@ -12,6 +12,8 @@ import { resolveCycleStatus, todayIso, } from '../../utils/format.ts'; +import { buildCountingInsightContext } from '../../utils/insightContextBuilders.ts'; +import { PageAiInsight } from '../shared/PageAiInsight.tsx'; import PopulationTrendChart from './PopulationTrendChart.tsx'; import CountingAccuracyWarningBanner from './CountingAccuracyWarningBanner.tsx'; import { getCountingAccuracyWarning } from '../../utils/countingAccuracyWarning.ts'; @@ -49,7 +51,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings } const [kpis, setKpis] = useState([]); const [countings, setCountings] = useState([]); const [manualRows, setManualRows] = useState([]); - const [insights, setInsights] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [searchDay, setSearchDay] = useState(''); @@ -61,16 +62,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 { @@ -83,7 +82,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings } setKpis([]); setCountings([]); setManualRows([]); - setInsights([]); return; } void loadData(selectedCycle.id); @@ -175,9 +173,7 @@ 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, @@ -189,6 +185,33 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings } }; }, [rows, selectedCycle]); + const insightContext = useMemo(() => { + if (!selectedKandang || !selectedCycle) return null; + 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) => ({ @@ -269,36 +292,70 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings } )} - {/* AI Insight */} -
-

- - AI Insight -

- {insights.length === 0 ? ( - Belum ada insight untuk siklus ini. - ) : ( -
- {insights.slice(0, 4).map((item) => ( -
-
-
-

{item.section}

-

{item.alert || 'Insight'}

-
- {item.session} -
-

- {item.insight_text} -

-
- ))} -
- )} -
+ {selectedKandang && insightContext ? ( +
+ +
+ ) : ( +
+

+ + AI Insight +

+ Pilih kandang untuk melihat AI Insight. +
+ )} {/* Population Chart */}
diff --git a/components/shared/InsightConditionChart.tsx b/components/shared/InsightConditionChart.tsx new file mode 100644 index 0000000..10139de --- /dev/null +++ b/components/shared/InsightConditionChart.tsx @@ -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 = ({ + 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 ( +
+ {emptyLabel} +
+ ); + } + + return ( +
+ {title &&

{title}

} +
+ + + Aktual + + + + Standar CP 707 + +
+ +
+ {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 ( +
+
+ {bar.label} + + {bar.actual === null ? 'data tidak tersedia' : fmt(bar.actual, bar.unit)} + {bar.standard !== null && ( + · standar {fmt(bar.standard, bar.unit)} + )} + +
+ +
+
+ {bar.actual !== null && ( +
+ )} +
+
+ {bar.standard !== null && ( +
+ )} +
+
+ + {bar.note &&

{bar.note}

} +
+ ); + })} +
+
+ ); +}; + +export default InsightConditionChart; diff --git a/components/shared/InsightProgressSteps.tsx b/components/shared/InsightProgressSteps.tsx new file mode 100644 index 0000000..3698415 --- /dev/null +++ b/components/shared/InsightProgressSteps.tsx @@ -0,0 +1,49 @@ +import type { InsightJobStage } from '../../types/api.ts'; + +/** Phase 4 pipeline step indicator for async AI Insight generation. */ +const STEPS: { stage: InsightJobStage; label: string; icon: string }[] = [ + { stage: 'grading', label: 'Evaluating CP 707 rules...', icon: 'fa-solid fa-scale-balanced' }, + { stage: 'retrieving', label: 'Fetching SOP guidelines...', icon: 'fa-solid fa-book-open' }, + { + stage: 'synthesizing', + label: 'Synthesizing recommendations...', + icon: 'fa-solid fa-wand-magic-sparkles', + }, + { stage: 'saving', label: 'Saving insight...', icon: 'fa-solid fa-floppy-disk' }, +]; + +const ORDER: InsightJobStage[] = ['queued', 'starting', ...STEPS.map((s) => s.stage), 'done']; + +function stageIndex(stage: InsightJobStage | null): number { + if (!stage) return 0; + const i = ORDER.indexOf(stage); + return i < 0 ? 0 : i; +} + +export default function InsightProgressSteps({ stage }: { stage: InsightJobStage | null }) { + const current = stageIndex(stage); + return ( +
    + {STEPS.map((step, i) => { + const state = current > i ? 'done' : current === i ? 'active' : 'pending'; + return ( +
  1. +