update ai insight card to include status
This commit is contained in:
1 parent
3e3eb88751
commit
a9a2acb705
3 files changed
+416
-240
No files matched your search
@@ -7,11 +7,85 @@ import React from 'react';
|
||||
|
||||
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: '–' },
|
||||
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<InsightStatusBadgeProps> = ({
|
||||
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 (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border font-semibold tracking-wide ${meta.cls} ${sizeClasses} ${className}`}
|
||||
>
|
||||
<span aria-hidden="true" className="font-bold">
|
||||
{meta.glyph}
|
||||
</span>
|
||||
<span>{customLabel || meta.label}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
interface DeltaChipProps {
|
||||
@@ -20,7 +94,7 @@ interface DeltaChipProps {
|
||||
}
|
||||
|
||||
export const DeltaChip: React.FC<DeltaChipProps> = ({ status, label }) => {
|
||||
const chip = STATUS_CHIP[status];
|
||||
const chip = INSIGHT_STATUS_META[status] ?? INSIGHT_STATUS_META.unknown;
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-semibold ${chip.cls}`}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* the DB row is the source of truth.
|
||||
* Distinct from `AiInsightCard` (Dashboard report modes).
|
||||
*/
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useFarm } from '../../context/FarmContext.tsx';
|
||||
import { api, errorMessage } from '../../services/apiClient.ts';
|
||||
import type {
|
||||
@@ -29,7 +29,13 @@ import {
|
||||
} 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';
|
||||
import {
|
||||
HeroFigure,
|
||||
InsightStatusBadge,
|
||||
INSIGHT_STATUS_META,
|
||||
normalizeInsightStatus,
|
||||
type InsightStatus,
|
||||
} from './InsightStatTiles.tsx';
|
||||
import InsightProgressSteps from './InsightProgressSteps.tsx';
|
||||
|
||||
const CLIENT_TIMEOUT_MS = 1_200_000; // 20 minutes — NUC LLM can take several minutes
|
||||
@@ -57,6 +63,8 @@ async function pollInsightJob(
|
||||
|
||||
type PageInsightResult = AiResult & {
|
||||
citations?: InsightCitation[];
|
||||
alert?: string;
|
||||
status?: InsightStatus;
|
||||
};
|
||||
|
||||
/** Dedupes concurrent soft DB loads for the same scope (not a content cache). */
|
||||
@@ -426,13 +434,29 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
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<PageAiInsightProps> = ({
|
||||
throw new Error('Respons AI tidak dapat diproses.');
|
||||
}
|
||||
|
||||
const alertRaw = (apiResult as Record<string, unknown> | 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<PageAiInsightProps> = ({
|
||||
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<PageAiInsightProps> = ({
|
||||
<span className="text-sm font-bold text-gray-900 group-hover:text-red-700 transition-colors">
|
||||
{meta.title}
|
||||
</span>
|
||||
{isCollapsed && result && (
|
||||
<span className="text-[10px] font-semibold text-emerald-700 bg-emerald-50 border border-emerald-200 px-2 py-0.5 rounded-full">
|
||||
Tersedia
|
||||
</span>
|
||||
{result && (
|
||||
<InsightStatusBadge
|
||||
status={effectiveStatus}
|
||||
customLabel={
|
||||
isCollapsed
|
||||
? `Tersedia (${INSIGHT_STATUS_META[effectiveStatus].label})`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 truncate">
|
||||
@@ -1073,34 +1115,41 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
</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>
|
||||
|
||||
{result.akar_masalah && (
|
||||
<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-magnifying-glass-chart text-amber-600" /> Akar
|
||||
Masalah
|
||||
</h4>
|
||||
<p className="text-sm text-gray-700 bg-amber-50/50 p-3 rounded-lg border border-amber-100/80">
|
||||
{result.akar_masalah}
|
||||
<div
|
||||
className={`mt-6 rounded-xl border p-4 sm:p-5 space-y-4 shadow-xs ${INSIGHT_STATUS_META[effectiveStatus].containerCls}`}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-bold text-gray-800 flex items-center gap-2">
|
||||
<i className="fa-solid fa-file-lines text-blue-600" /> Kesimpulan
|
||||
</h4>
|
||||
<InsightStatusBadge status={effectiveStatus} />
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 bg-white/85 p-3.5 rounded-lg border border-gray-200/70 shadow-xs leading-relaxed">
|
||||
{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 & Rekomendasi
|
||||
</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>
|
||||
{result.akar_masalah && (
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-gray-800 mb-2 flex items-center gap-2">
|
||||
<i className="fa-solid fa-magnifying-glass-chart text-amber-600" /> Akar
|
||||
Masalah
|
||||
</h4>
|
||||
<p className="text-sm text-gray-700 bg-white/85 p-3.5 rounded-lg border border-amber-200/70 shadow-xs leading-relaxed">
|
||||
{result.akar_masalah}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<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 & Rekomendasi
|
||||
</h4>
|
||||
<p className="text-sm text-gray-700 bg-white/85 p-3.5 rounded-lg border border-gray-200/70 shadow-xs whitespace-pre-wrap leading-relaxed">
|
||||
{result.insight}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CitationsBlock citations={result.citations} />
|
||||
@@ -1108,7 +1157,15 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 pt-3 border-t border-gray-100 flex justify-center">
|
||||
<div className="mt-5 pt-3 border-t border-gray-100 flex items-center justify-between">
|
||||
<div className="text-xs text-gray-400">
|
||||
{result && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span>Status AI Insight:</span>
|
||||
<InsightStatusBadge status={effectiveStatus} size="xs" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -1121,6 +1178,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
>
|
||||
<i className="fa-solid fa-chevron-up text-xs transition-transform group-hover:-translate-y-0.5" />
|
||||
</button>
|
||||
<div className="w-12" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user