486 lines
18 KiB
TypeScript
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 & 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;
|