From a9a2acb705eb957d33d65837371312d6cddcc3f7 Mon Sep 17 00:00:00 2001 From: Alberto-Audrix Date: Fri, 9 Oct 2026 16:24:54 +0700 Subject: [PATCH] update ai insight card to include status --- components/AiInsightCard.tsx | 444 ++++++++++++++----------- components/shared/InsightStatTiles.tsx | 86 ++++- components/shared/PageAiInsight.tsx | 126 +++++-- 3 files changed, 416 insertions(+), 240 deletions(-) diff --git a/components/AiInsightCard.tsx b/components/AiInsightCard.tsx index 6d04c02..2a88bbc 100644 --- a/components/AiInsightCard.tsx +++ b/components/AiInsightCard.tsx @@ -27,7 +27,10 @@ import { parseAiResult, type AiResult } from '../utils/insightParse.ts'; import { formatRatio } from '../utils/format.ts'; import { HeroFigure, + InsightStatusBadge, + INSIGHT_STATUS_META, MutedStatRow, + normalizeInsightStatus, StatTile, type InsightStatus, } from './shared/InsightStatTiles.tsx'; @@ -36,13 +39,6 @@ import InsightTrendChart from './shared/InsightTrendChart.tsx'; const CLIENT_TIMEOUT_MS = 1_200_000; // 20 minutes const END_CYCLE_JSON_MARKER = ''; -const sectionBadgeClasses: Record = { - ok: 'border-emerald-200 bg-emerald-50 text-emerald-700', - warning: 'border-amber-200 bg-amber-50 text-amber-700', - critical: 'border-red-200 bg-red-50 text-red-700', - unknown: 'border-slate-200 bg-slate-50 text-slate-600', -}; - const confidenceBadge: Record = { high: 'border-red-200 bg-red-50 text-red-700', med: 'border-amber-200 bg-amber-50 text-amber-700', @@ -261,6 +257,7 @@ export const AiInsightCard: React.FC = ({ const [loading, setLoading] = useState(false); const [lastUpdated, setLastUpdated] = useState(null); const [error, setError] = useState(null); + const [insightAlert, setInsightAlert] = useState(null); const [detailsOpen, setDetailsOpen] = useState(false); const [reportType, setReportType] = useState('daily'); const [dayPeriod, setDayPeriod] = useState(null); @@ -297,10 +294,12 @@ export const AiInsightCard: React.FC = ({ structuredEndCycle?: EndCycleStructuredInsight | null, summary?: string | null, akarMasalah?: string | null, - insightBody?: string | null + insightBody?: string | null, + alert?: string | null ) => { const citationsArg = Array.isArray(nextCitations) ? nextCitations : []; setInsightText(text); + setInsightAlert(alert ?? null); const endCycle = structuredEndCycle ?? parseEndCycleFromText(text, summary) ?? @@ -473,7 +472,8 @@ export const AiInsightCard: React.FC = ({ apiResult.structured_end_cycle ?? null, apiResult.summary, apiResult.akar_masalah, - apiResult.insight + apiResult.insight, + (apiResult as Record | null)?.alert as string | undefined ); setLastUpdated(new Date()); setDetailsOpen(false); @@ -541,19 +541,11 @@ export const AiInsightCard: React.FC = ({ ); }; - const renderDetailSection = ( - title: string, - section: InsightStructuredSection, - accentClass: string - ) => ( + const renderDetailSection = (title: string, section: InsightStructuredSection) => (

{title}

- - {section.status} - +
@@ -634,6 +626,42 @@ export const AiInsightCard: React.FC = ({ const hasInsight = Boolean(insightText.trim()) || structuredInsight != null || endCycleInsight != null; + const overallStatus = useMemo((): InsightStatus => { + if (insightAlert) { + const norm = normalizeInsightStatus(insightAlert); + if (norm !== 'unknown') return norm; + } + + if (structuredInsight?.detailed) { + const sections = Object.values(structuredInsight.detailed).filter(Boolean); + if (sections.some((s) => s.status === 'critical')) return 'critical'; + if (sections.some((s) => s.status === 'warning')) return 'warning'; + if (sections.some((s) => s.status === 'ok')) return 'ok'; + } + + if (endCycleInsight) { + if (endCycleInsight.masalah && endCycleInsight.masalah.length > 0) { + return 'warning'; + } + return 'ok'; + } + + const tileStatuses: InsightStatus[] = [fcrStatus, eefStatus, mortalityStatus, weightStatus]; + if (tileStatuses.includes('critical')) return 'critical'; + if (tileStatuses.includes('warning')) return 'warning'; + if (tileStatuses.includes('ok')) return 'ok'; + + return 'unknown'; + }, [ + insightAlert, + structuredInsight, + endCycleInsight, + fcrStatus, + eefStatus, + mortalityStatus, + weightStatus, + ]); + const renderVisualBlock = () => { if (!hasInsight || !scopedMetrics || page !== 'dashboard') return null; @@ -802,10 +830,15 @@ export const AiInsightCard: React.FC = ({

AI Insight

- {isCollapsed && hasInsight && ( - - Tersedia - + {hasInsight && ( + )}

@@ -898,125 +931,143 @@ export const AiInsightCard: React.FC = ({

) : 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. +

+
+
+

+ Kesimpulan +

+ +
+

+ {endCycleInsight.kesimpulan}

- ) : ( -
    - {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} +

    +

    + 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}

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

    {endCycleInsight.insight}

    - ) : null} - +

    {item.aksi}

    + {item.metrik_pantau ? ( +

    + Pantau: {item.metrik_pantau} +

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

+ {endCycleInsight.insight} +

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

- Kesimpulan -

-

+

+
+

+ Kesimpulan +

+ +
+

{summary.headline}

AI Insight

-
+
{renderBulletList([ ...summary.bullets, ...summary.risks, @@ -1024,8 +1075,8 @@ export const AiInsightCard: React.FC = ({ ])}
-
+ {page === 'dashboard' && detailed && ( <> @@ -1038,41 +1089,15 @@ export const AiInsightCard: React.FC = ({ {detailsOpen && (
{detailed.hitung_ayam && - renderDetailSection( - 'Hitung Ayam', - detailed.hitung_ayam, - sectionBadgeClasses[detailed.hitung_ayam.status] - )} + renderDetailSection('Hitung Ayam', detailed.hitung_ayam)} {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] - )} + renderDetailSection('Berat Ayam', detailed.berat_ayam)} + {detailed.fcr && renderDetailSection('FCR', detailed.fcr)} + {detailed.eef && renderDetailSection('EEF', detailed.eef)} {detailed.panel_iot && - renderDetailSection( - 'Panel IoT', - detailed.panel_iot, - sectionBadgeClasses[detailed.panel_iot.status] - )} + renderDetailSection('Panel IoT', detailed.panel_iot)} {detailed.data_quality && - renderDetailSection( - 'Kualitas Data', - detailed.data_quality, - sectionBadgeClasses[detailed.data_quality.status] - )} + renderDetailSection('Kualitas Data', detailed.data_quality)}
)} @@ -1080,44 +1105,57 @@ export const AiInsightCard: React.FC = ({
) : dailyInsight ? (
-
-

- Kesimpulan -

-

- {dailyInsight.summary} -

-
- - {dailyInsight.akar_masalah && ( -
-

- Akar - Masalah -

-

- {dailyInsight.akar_masalah} +

+
+
+

+ Kesimpulan +

+ +
+

+ {dailyInsight.summary}

- )} -
-

- AI Insight & Rekomendasi -

-

- {dailyInsight.insight} -

- + {dailyInsight.akar_masalah && ( +
+

+ Akar + Masalah +

+

+ {dailyInsight.akar_masalah} +

+
+ )} + +
+

+ AI Insight & Rekomendasi +

+

+ {dailyInsight.insight} +

+
+
) : insightText ? ( -
- {insightText.includes(END_CYCLE_JSON_MARKER) - ? insightText.split(END_CYCLE_JSON_MARKER)[0]?.trim() - : insightText.includes('') - ? insightText.split('')[0]?.trim() - : insightText} +
+
+
+ {insightText.includes(END_CYCLE_JSON_MARKER) + ? insightText.split(END_CYCLE_JSON_MARKER)[0]?.trim() + : insightText.includes('') + ? insightText.split('')[0]?.trim() + : insightText} +
+
) : ( @@ -1139,7 +1177,15 @@ export const AiInsightCard: React.FC = ({ )}
-
+
+
+ {hasInsight && ( + + Status AI Insight: + + + )} +
- {lastUpdated && ( -

- Terakhir diperbarui: {formatLastUpdated()} -

- )} +
+ {lastUpdated && Terakhir: {formatLastUpdated()}} +
)} diff --git a/components/shared/InsightStatTiles.tsx b/components/shared/InsightStatTiles.tsx index a27afb3..4aac7d8 100644 --- a/components/shared/InsightStatTiles.tsx +++ b/components/shared/InsightStatTiles.tsx @@ -7,11 +7,85 @@ import React from 'react'; export type InsightStatus = 'ok' | 'warning' | 'critical' | 'unknown'; -const STATUS_CHIP: Record = { - 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: '–' }, +export const INSIGHT_STATUS_META: Record< + InsightStatus, + { cls: string; glyph: string; label: string; bg: string; text: string; containerCls: string } +> = { + ok: { + cls: 'bg-green-50 text-green-800 border-green-200', + glyph: '✓', + label: 'Normal', + bg: 'bg-green-50', + text: 'text-green-800', + containerCls: 'bg-green-50/40 border-green-200', + }, + warning: { + cls: 'bg-amber-50 text-amber-800 border-amber-200', + glyph: '!', + label: 'Perhatian', + bg: 'bg-amber-50', + text: 'text-amber-800', + containerCls: 'bg-amber-50/40 border-amber-200', + }, + critical: { + cls: 'bg-red-50 text-red-800 border-red-200', + glyph: '⚠', + label: 'Kritis', + bg: 'bg-red-50', + text: 'text-red-800', + containerCls: 'bg-red-50/40 border-red-200', + }, + unknown: { + cls: 'bg-gray-50 text-gray-600 border-gray-200', + glyph: '–', + label: 'Belum Dinilai', + bg: 'bg-gray-50', + text: 'text-gray-600', + containerCls: 'bg-gray-50/60 border-gray-200', + }, +}; + +export const normalizeInsightStatus = (status: unknown): InsightStatus => { + if (!status || typeof status !== 'string') return 'unknown'; + const lower = status.toLowerCase(); + if (lower === 'ok' || lower === 'healthy' || lower === 'normal' || lower === 'aman') return 'ok'; + if (lower === 'warning' || lower === 'perhatian' || lower === 'waspada') return 'warning'; + if (lower === 'critical' || lower === 'kritis' || lower === 'bahaya') return 'critical'; + return 'unknown'; +}; + +export interface InsightStatusBadgeProps { + status: InsightStatus | string; + customLabel?: string; + className?: string; + size?: 'xs' | 'sm' | 'md'; +} + +export const InsightStatusBadge: React.FC = ({ + status, + customLabel, + className = '', + size = 'sm', +}) => { + const norm = normalizeInsightStatus(status); + const meta = INSIGHT_STATUS_META[norm]; + const sizeClasses = + size === 'xs' + ? 'px-2 py-0.5 text-[10px]' + : size === 'md' + ? 'px-3 py-1 text-xs' + : 'px-2.5 py-0.5 text-[11px]'; + + return ( + + + {customLabel || meta.label} + + ); }; interface DeltaChipProps { @@ -20,7 +94,7 @@ interface DeltaChipProps { } export const DeltaChip: React.FC = ({ status, label }) => { - const chip = STATUS_CHIP[status]; + const chip = INSIGHT_STATUS_META[status] ?? INSIGHT_STATUS_META.unknown; return ( = ({ manual === null ? null : `Manual dituang: ${manual.toLocaleString('id-ID')} karung`, perDay === null ? null : `${perDay.toLocaleString('id-ID')} karung/hari`, ].filter(Boolean); + + const saldoKarung = masukTotal != null && actual != null ? masukTotal - actual : null; + let karungStatus: InsightStatus = 'ok'; + if (saldoKarung !== null && saldoKarung < 0) { + karungStatus = 'critical'; + } else if (manual !== null && manual > 0 && Math.abs(actual - manual) / manual > 0.1) { + karungStatus = 'warning'; + } else { + karungStatus = 'ok'; + } + 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, + status: karungStatus, + deltaLabel: + saldoKarung !== null + ? `Saldo: ${saldoKarung.toLocaleString('id-ID')} karung` + : manual !== null && manual > 0 + ? `${Math.round(((actual - manual) / manual) * 100)}% vs manual` + : null, }; } @@ -620,11 +644,14 @@ export const PageAiInsight: React.FC = ({ throw new Error('Respons AI tidak dapat diproses.'); } + const alertRaw = (apiResult as Record | null)?.alert as string | undefined; const wrapped: PageInsightResult = { summary: parsed.summary, akar_masalah: parsed.akar_masalah, insight: parsed.insight, citations: apiResult.citations, + alert: alertRaw, + status: normalizeInsightStatus(alertRaw), }; if (!isCurrent()) return wrapped; @@ -662,6 +689,16 @@ export const PageAiInsight: React.FC = ({ generateRef.current = generate; }); + const effectiveStatus: InsightStatus = useMemo(() => { + if (result?.status && result.status !== 'unknown') return result.status; + if (result?.alert) { + const norm = normalizeInsightStatus(result.alert); + if (norm !== 'unknown') return norm; + } + if (heroSpec?.status && heroSpec.status !== 'unknown') return heroSpec.status; + return 'unknown'; + }, [result, heroSpec]); + // Day / scope change: clear UI and soft-load from DB (no generate). useEffect(() => { if (lastCacheKeyRef.current === scopeKey) return; @@ -693,10 +730,15 @@ export const PageAiInsight: React.FC = ({ {meta.title} - {isCollapsed && result && ( - - Tersedia - + {result && ( + )}

@@ -1073,34 +1115,41 @@ export const PageAiInsight: React.FC = ({

)} -
-

- Kesimpulan -

-

- {result.summary} -

-
- - {result.akar_masalah && ( -
-

- Akar - Masalah -

-

- {result.akar_masalah} +

+
+
+

+ Kesimpulan +

+ +
+

+ {result.summary}

- )} -
-

- AI Insight & Rekomendasi -

-

- {result.insight} -

+ {result.akar_masalah && ( +
+

+ Akar + Masalah +

+

+ {result.akar_masalah} +

+
+ )} + +
+

+ AI Insight & Rekomendasi +

+

+ {result.insight} +

+
@@ -1108,7 +1157,15 @@ export const PageAiInsight: React.FC = ({
)} -
+
+
+ {result && ( + + Status AI Insight: + + + )} +
+
)}