Files
dashboard/components/counting/ChickenCountingAiInsight.tsx
T
Alberto-Audrix 32a36cceff
CI / lint-and-test (push) Canceled after 0s
first commit
2026-07-28 08:55:05 +07:00

486 lines
18 KiB
TypeScript

import React, { useState, useCallback, useEffect, useRef } from 'react';
import { Sparkles, RefreshCw, TrendingDown, AlertTriangle, CheckCircle, Info } from 'lucide-react';
import { apiClient } from '../../services/apiService.ts';
interface KandangInsight {
name: string;
docIn: number;
currentPopulation: number;
mortalityTotal: number;
mortalityRate: number;
}
interface PopulationPoint {
date: string;
population: number;
}
export interface ChickenCountingInsightProps {
cycleId: string;
cycleStartDate: string;
currentDay: number;
totalDays: number;
totalInitialPopulation: number;
populationTrend: PopulationPoint[];
kandangInsights: KandangInsight[];
}
interface AiResult {
headline: string;
status: 'ok' | 'warning' | 'critical';
summary: string[];
risks: string[];
actions: string[];
perKandang: Array<{
name: string;
status: 'ok' | 'warning' | 'critical';
insight: string;
action: string;
}>;
}
const STATUS_CONFIG = {
ok: {
bg: 'bg-emerald-50',
border: 'border-emerald-200',
text: 'text-emerald-700',
badge: 'bg-emerald-100 text-emerald-700',
icon: CheckCircle,
label: 'Normal',
},
warning: {
bg: 'bg-amber-50',
border: 'border-amber-200',
text: 'text-amber-700',
badge: 'bg-amber-100 text-amber-700',
icon: AlertTriangle,
label: 'Perhatian',
},
critical: {
bg: 'bg-red-50',
border: 'border-red-200',
text: 'text-red-700',
badge: 'bg-red-100 text-red-700',
icon: TrendingDown,
label: 'Kritis',
},
};
const CC_FOCUSES = [
'Tren mortalitas harian dan dampaknya terhadap populasi akhir',
'Distribusi ayam di berbagai area/kamera (penumpukan)',
'Perbandingan populasi aktual dengan target DOC In',
'Deteksi dini potensi wabah penyakit dari lonjakan mortalitas',
'Evaluasi efektivitas afkir selektif vs kematian alami',
'Proyeksi sisa populasi layak panen',
'Evaluasi tingkat kepadatan (density) vs kapasitas kandang',
];
const buildPrompt = (props: ChickenCountingInsightProps): string => {
const { currentDay, totalDays, totalInitialPopulation, populationTrend, kandangInsights } = props;
const selectedFocus = CC_FOCUSES[Math.floor(Math.random() * CC_FOCUSES.length)];
const latestPop =
populationTrend.length > 0
? populationTrend[populationTrend.length - 1].population
: totalInitialPopulation;
const totalMortality = Math.max(0, totalInitialPopulation - latestPop);
const overallMortalityRate =
totalInitialPopulation > 0 ? ((totalMortality / totalInitialPopulation) * 100).toFixed(2) : '0';
const recentPops = populationTrend.slice(-7).map((p) => p.population);
const trendStr = recentPops.length > 0 ? recentPops.join(' → ') : 'belum ada data trend';
const kandangStr =
kandangInsights.length > 0
? kandangInsights
.map(
(k) =>
`- ${k.name}: DOC=${k.docIn.toLocaleString()}, populasi_kini=${k.currentPopulation.toLocaleString()}, mati+afkir=${k.mortalityTotal.toLocaleString()}, mortalitas=${k.mortalityRate.toFixed(2)}%`
)
.join('\n')
: '- Data per kandang belum tersedia (backend offline). Gunakan data total untuk analisis.';
return `Anda adalah dokter hewan dan manajer peternakan ayam broiler berpengalaman.
Analisis data hitung ayam berikut dan buat AI Insight yang MUDAH DIPAHAMI, PROFESIONAL, dan ACTIONABLE bagi peternak.
Jika ada data yang belum tersedia, tetap berikan insight dan rekomendasi berdasarkan data yang ada.
DATA SIKLUS:
- Hari ke: ${currentDay} dari total ${totalDays} hari
- Populasi awal (DOC in): ${totalInitialPopulation.toLocaleString()} ekor
- Populasi estimasi saat ini: ${latestPop.toLocaleString()} ekor
- Total kematian+afkir: ${totalMortality.toLocaleString()} ekor
- Tingkat mortalitas keseluruhan: ${overallMortalityRate}%
- Trend populasi (7 hari terakhir): ${trendStr}
DATA PER KANDANG:
${kandangStr}
PEDOMAN MORTALITAS:
- Mortalitas <3%: NORMAL (ok)
- Mortalitas 3-5%: PERLU PERHATIAN (warning)
- Mortalitas >5%: KRITIS, perlu tindakan segera (critical)
Konteks & Aturan Variasi:
Fokus utama analisis Anda hari ini adalah: "${selectedFocus}".
Jadikan ini sebagai sudut pandang utama analisis. Jangan sebutkan nama fokus secara eksplisit di hasil akhir. Dilarang keras berhalusinasi atau menambahkan angka fiktif.
INSTRUKSI OUTPUT:
Kembalikan HANYA satu objek JSON valid tanpa markdown, tanpa komentar, tanpa teks tambahan di luar JSON.
Format JSON yang harus dikembalikan:
{
"headline": "satu kalimat ringkas tentang kondisi populasi saat ini yang mencerminkan fokus terpilih",
"status": "ok",
"summary": ["poin ringkasan 1", "poin ringkasan 2", "poin ringkasan 3"],
"risks": ["risiko 1", "risiko 2"],
"actions": ["tindakan konkret 1 yang bisa langsung dipraktikkan peternak hari ini", "tindakan 2", "tindakan 3"],
"perKandang": [
{
"name": "nama kandang",
"status": "ok",
"insight": "satu kalimat insight tentang kandang ini",
"action": "satu tindakan konkret spesifik untuk kandang ini"
}
]
}`;
};
const parseResult = (text: string): AiResult | null => {
try {
const first = text.indexOf('{');
const last = text.lastIndexOf('}');
if (first === -1 || last === -1) return null;
const parsed = JSON.parse(text.slice(first, last + 1));
if (!parsed.headline) return null;
return {
headline: String(parsed.headline ?? ''),
status: ['ok', 'warning', 'critical'].includes(parsed.status) ? parsed.status : 'ok',
summary: Array.isArray(parsed.summary) ? parsed.summary.map(String) : [],
risks: Array.isArray(parsed.risks) ? parsed.risks.map(String) : [],
actions: Array.isArray(parsed.actions) ? parsed.actions.map(String) : [],
perKandang: Array.isArray(parsed.perKandang)
? parsed.perKandang.map((k: any) => ({
name: String(k.name ?? ''),
status: ['ok', 'warning', 'critical'].includes(k.status) ? k.status : 'ok',
insight: String(k.insight ?? ''),
action: String(k.action ?? ''),
}))
: [],
};
} catch {
return null;
}
};
export const ChickenCountingAiInsight: React.FC<ChickenCountingInsightProps> = (props) => {
const [result, setResult] = useState<AiResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showPerKandang, setShowPerKandang] = useState(false);
const inFlightRef = useRef(false);
const hasGeneratedRef = useRef(false);
const generate = useCallback(
async (force = false) => {
if (inFlightRef.current) return;
// Minimal data check
if (!props.cycleId) return;
const cacheKey = `cc-insight-${props.cycleId}-${props.currentDay}`;
if (!force) {
try {
const cached = sessionStorage.getItem(cacheKey);
if (cached) {
const parsed = parseResult(cached);
if (parsed) {
setResult(parsed);
hasGeneratedRef.current = true;
return;
}
}
} catch {
// sessionStorage might be unavailable
}
}
inFlightRef.current = true;
setLoading(true);
setError(null);
try {
const prompt = buildPrompt(props);
const system_prompt =
'Anda adalah manajer peternakan ayam broiler CP 707. Analisis HANYA berdasarkan standar buku CP 707. Kembalikan HANYA objek JSON valid. Dilarang memberikan teks di luar JSON.';
// Race antara LM Studio dan timeout 300 detik
const fetchCall = apiClient.proxyLmStudio(prompt, system_prompt, 0.2);
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('LM Studio timeout setelah 300 detik.')), 300000)
);
const json = await Promise.race([fetchCall, timeout]);
const text = json.response ?? '';
const parsed = parseResult(text);
if (!parsed) throw new Error('Format respons LM Studio tidak valid. Coba lagi.');
if (result && JSON.stringify(parsed) === JSON.stringify(result)) {
throw new Error(
'Data belum diperbarui. Hasil analisis AI masih sama dengan versi sebelumnya.'
);
}
setResult(parsed);
hasGeneratedRef.current = true;
try {
sessionStorage.setItem(cacheKey, text);
} catch {
// ignore
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Terjadi kesalahan tidak diketahui.';
setError(msg);
} finally {
setLoading(false);
inFlightRef.current = false;
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
props.cycleId,
props.currentDay,
props.totalInitialPopulation,
props.kandangInsights.length,
props.populationTrend.length,
]
);
// Auto-generate on mount and when cycle changes
useEffect(() => {
if (!props.cycleId) return;
if (hasGeneratedRef.current) return;
const timer = setTimeout(() => {
generate(false);
}, 600);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.cycleId]);
// Re-generate when data becomes available (population data loaded from API)
useEffect(() => {
if (!hasGeneratedRef.current) return;
if (props.populationTrend.length === 0) return;
const timer = setTimeout(() => {
generate(true);
}, 1000);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.populationTrend.length, props.kandangInsights.length]);
const cfg = result ? STATUS_CONFIG[result.status] : null;
const StatusIcon = cfg?.icon ?? Info;
return (
<div className="rounded-2xl border border-purple-100 bg-gradient-to-br from-purple-50 via-white to-blue-50 shadow-lg overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-purple-100 bg-white/60 backdrop-blur-sm">
<div className="flex items-center gap-2">
<div className="p-1.5 bg-purple-100 rounded-lg">
<Sparkles className="w-5 h-5 text-purple-600" />
</div>
<div>
<h2 className="text-base font-bold text-gray-900">AI Insight — Hitung Ayam</h2>
<p className="text-xs text-gray-500">
Analisis populasi &amp; mortalitas berbasis kecerdasan buatan
</p>
</div>
</div>
<button
onClick={() => generate(true)}
disabled={loading}
className={`p-2 rounded-xl transition-all border ${
loading
? 'bg-gray-100 border-gray-200 cursor-not-allowed'
: 'bg-white border-purple-200 hover:bg-purple-50 hover:border-purple-300 active:scale-95 shadow-sm'
}`}
title="Refresh insight"
>
<RefreshCw className={`w-4 h-4 text-purple-600 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
{/* Body */}
<div className="p-5">
{/* Loading */}
{loading && !result && (
<div className="flex flex-col items-center justify-center py-10 gap-3">
<div className="relative">
<div className="w-12 h-12 rounded-full border-4 border-purple-100 border-t-purple-600 animate-spin" />
<Sparkles className="w-5 h-5 text-purple-500 absolute inset-0 m-auto animate-pulse" />
</div>
<p className="text-sm text-gray-500 animate-pulse">
Menganalisis data populasi ayam...
</p>
</div>
)}
{/* Error */}
{error && !result && (
<div className="flex items-start gap-3 bg-red-50 border border-red-200 rounded-xl p-4">
<AlertTriangle className="w-5 h-5 text-red-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-semibold text-red-700">Gagal memuat insight</p>
<p className="text-sm text-red-600 mt-1">{error}</p>
<button
onClick={() => generate(true)}
className="mt-2 text-xs text-red-700 underline hover:no-underline"
>
Coba lagi
</button>
</div>
</div>
)}
{/* No data / waiting */}
{!loading && !error && !result && (
<div className="flex items-center justify-center py-8 text-gray-400 text-sm gap-2">
<div className="w-4 h-4 rounded-full border-2 border-gray-300 border-t-purple-400 animate-spin" />
<span>Memuat insight AI...</span>
</div>
)}
{/* Result */}
{result && cfg && (
<div className="space-y-4">
{/* Headline banner */}
<div className={`rounded-xl p-4 border ${cfg.bg} ${cfg.border} flex items-start gap-3`}>
<StatusIcon className={`w-5 h-5 shrink-0 mt-0.5 ${cfg.text}`} />
<div className="flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span
className={`text-xs font-bold uppercase tracking-wider px-2 py-0.5 rounded-full ${cfg.badge}`}
>
{cfg.label}
</span>
{loading && (
<span className="text-xs text-gray-400 animate-pulse">Memperbarui...</span>
)}
</div>
<p className={`mt-1.5 text-sm font-semibold ${cfg.text}`}>{result.headline}</p>
</div>
</div>
{/* 3-column grid */}
<div className="grid md:grid-cols-3 gap-3">
{/* Ringkasan */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-4">
<p className="text-xs font-bold uppercase tracking-widest text-purple-600 mb-3">
📋 Ringkasan
</p>
<ul className="space-y-2">
{result.summary.map((item, i) => (
<li key={i} className="flex gap-2 text-sm text-gray-700">
<span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-purple-400 shrink-0" />
<span>{item}</span>
</li>
))}
</ul>
</div>
{/* Risiko */}
<div className="bg-amber-50 rounded-xl border border-amber-100 shadow-sm p-4">
<p className="text-xs font-bold uppercase tracking-widest text-amber-700 mb-3">
⚠️ Risiko
</p>
{result.risks.length > 0 ? (
<ul className="space-y-2">
{result.risks.map((item, i) => (
<li key={i} className="flex gap-2 text-sm text-amber-800">
<span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-amber-500 shrink-0" />
<span>{item}</span>
</li>
))}
</ul>
) : (
<p className="text-sm text-amber-600 italic">Tidak ada risiko signifikan</p>
)}
</div>
{/* Tindakan */}
<div className="bg-emerald-50 rounded-xl border border-emerald-100 shadow-sm p-4">
<p className="text-xs font-bold uppercase tracking-widest text-emerald-700 mb-3">
✅ Tindakan
</p>
<ol className="space-y-2">
{result.actions.map((item, i) => (
<li key={i} className="flex gap-2 text-sm text-emerald-800">
<span className="shrink-0 w-5 h-5 rounded-full bg-emerald-200 text-emerald-700 flex items-center justify-center text-xs font-bold">
{i + 1}
</span>
<span>{item}</span>
</li>
))}
</ol>
</div>
</div>
{/* Per-Kandang */}
{result.perKandang.length > 0 && (
<div>
<button
onClick={() => setShowPerKandang((v) => !v)}
className="inline-flex items-center gap-2 text-sm font-medium text-purple-700 border border-purple-200 bg-white rounded-full px-4 py-1.5 hover:bg-purple-50 transition shadow-sm"
>
{showPerKandang
? '▲ Sembunyikan detail per kandang'
: '▼ Lihat insight per kandang'}
</button>
{showPerKandang && (
<div className="mt-3 grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
{result.perKandang.map((k, i) => {
const kCfg = STATUS_CONFIG[k.status] ?? STATUS_CONFIG.ok;
const KIcon = kCfg.icon;
return (
<div key={i} className={`rounded-xl border p-4 ${kCfg.bg} ${kCfg.border}`}>
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-bold text-gray-800">{k.name}</p>
<span
className={`text-xs font-bold px-2 py-0.5 rounded-full ${kCfg.badge}`}
>
{kCfg.label}
</span>
</div>
<div className="flex gap-2 mb-2">
<KIcon className={`w-4 h-4 shrink-0 mt-0.5 ${kCfg.text}`} />
<p className="text-sm text-gray-700">{k.insight}</p>
</div>
{k.action && (
<div className="mt-2 pt-2 border-t border-white/60">
<p className="text-xs text-gray-500 font-semibold uppercase mb-1">
Tindakan:
</p>
<p className="text-sm text-gray-700">{k.action}</p>
</div>
)}
</div>
);
})}
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
);
};
export default ChickenCountingAiInsight;