commit
32a36cceff
444 files changed
+67186
No files matched your search
@@ -0,0 +1,485 @@
|
||||
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;
|
||||
Reference in new issue
Block a user