1292 lines
47 KiB
TypeScript
1292 lines
47 KiB
TypeScript
/* eslint-disable react-hooks/preserve-manual-memoization */
|
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
import {
|
|
Sparkles,
|
|
RefreshCw,
|
|
AlertTriangle,
|
|
CheckCircle,
|
|
TrendingDown,
|
|
Info,
|
|
Download,
|
|
} from 'lucide-react';
|
|
import { jsPDF } from 'jspdf';
|
|
import { useAppContext } from '../../context/AppContext.tsx';
|
|
import { apiClient } from '../../services/apiService.ts';
|
|
|
|
// ─── Global in-memory cache (persists across page navigation in same session) ──
|
|
// Key: storageKey string, Value: parsed AiResult
|
|
const _insightMemoryCache = new Map<string, AiResult>();
|
|
// Key: storageKey string, Value: Promise being awaited (deduplication)
|
|
const _insightInFlight = new Map<string, Promise<void>>();
|
|
|
|
export type InsightTopic =
|
|
| 'hitung_ayam'
|
|
| 'berat_ayam'
|
|
| 'fcr'
|
|
| 'eef'
|
|
| 'iot_panel'
|
|
| 'hitung_karung';
|
|
|
|
export interface PageAiInsightProps {
|
|
topic: InsightTopic;
|
|
contextData: Record<string, unknown>;
|
|
cacheKey: string;
|
|
}
|
|
|
|
interface AiResult {
|
|
headline: string;
|
|
status: 'ok' | 'warning' | 'critical';
|
|
summary: string[];
|
|
risks: string[];
|
|
actions: string[];
|
|
}
|
|
|
|
// ─── Response Schema ──────────────────────────────────────────────────────────
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const PAGE_INSIGHT_SCHEMA = {
|
|
type: 'OBJECT',
|
|
properties: {
|
|
headline: { type: 'STRING' },
|
|
status: { type: 'STRING', enum: ['ok', 'warning', 'critical'] },
|
|
summary: { type: 'ARRAY', items: { type: 'STRING' } },
|
|
risks: { type: 'ARRAY', items: { type: 'STRING' } },
|
|
actions: { type: 'ARRAY', items: { type: 'STRING' } },
|
|
},
|
|
required: ['headline', 'status', 'summary', 'risks', 'actions'],
|
|
} as const;
|
|
|
|
// Helper: save to both global memory and sessionStorage
|
|
const _saveToCache = (storageKey: string, result: AiResult): void => {
|
|
_insightMemoryCache.set(storageKey, result);
|
|
try {
|
|
sessionStorage.setItem(storageKey, JSON.stringify(result));
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
};
|
|
|
|
// ─── UI metadata ─────────────────────────────────────────────────────────────
|
|
const TOPIC_META: Record<InsightTopic, { title: string; emoji: string; color: string }> = {
|
|
hitung_ayam: { title: 'AI Insight — Hitung Ayam', emoji: '🐔', color: 'purple' },
|
|
berat_ayam: { title: 'AI Insight — Berat Ayam', emoji: '⚖️', color: 'blue' },
|
|
fcr: { title: 'AI Insight — FCR', emoji: '🌾', color: 'green' },
|
|
eef: { title: 'AI Insight — EEF', emoji: '📊', color: 'orange' },
|
|
iot_panel: { title: 'AI Insight — Panel IoT', emoji: '🌡️', color: 'teal' },
|
|
hitung_karung: { title: 'AI Insight — Hitung Karung', emoji: '🧺', color: 'amber' },
|
|
};
|
|
|
|
const COLOR_MAP: Record<
|
|
string,
|
|
{
|
|
ring: string;
|
|
sparkBg: string;
|
|
sparkText: string;
|
|
btnBorder: string;
|
|
btnHover: string;
|
|
}
|
|
> = {
|
|
purple: {
|
|
ring: 'border-purple-100',
|
|
sparkBg: 'bg-purple-100',
|
|
sparkText: 'text-purple-600',
|
|
btnBorder: 'border-purple-200',
|
|
btnHover: 'hover:bg-purple-50',
|
|
},
|
|
blue: {
|
|
ring: 'border-blue-100',
|
|
sparkBg: 'bg-blue-100',
|
|
sparkText: 'text-blue-600',
|
|
btnBorder: 'border-blue-200',
|
|
btnHover: 'hover:bg-blue-50',
|
|
},
|
|
green: {
|
|
ring: 'border-green-100',
|
|
sparkBg: 'bg-green-100',
|
|
sparkText: 'text-green-600',
|
|
btnBorder: 'border-green-200',
|
|
btnHover: 'hover:bg-green-50',
|
|
},
|
|
orange: {
|
|
ring: 'border-orange-100',
|
|
sparkBg: 'bg-orange-100',
|
|
sparkText: 'text-orange-600',
|
|
btnBorder: 'border-orange-200',
|
|
btnHover: 'hover:bg-orange-50',
|
|
},
|
|
teal: {
|
|
ring: 'border-teal-100',
|
|
sparkBg: 'bg-teal-100',
|
|
sparkText: 'text-teal-600',
|
|
btnBorder: 'border-teal-200',
|
|
btnHover: 'hover:bg-teal-50',
|
|
},
|
|
amber: {
|
|
ring: 'border-amber-100',
|
|
sparkBg: 'bg-amber-100',
|
|
sparkText: 'text-amber-600',
|
|
btnBorder: 'border-amber-200',
|
|
btnHover: 'hover:bg-amber-50',
|
|
},
|
|
};
|
|
|
|
const STATUS_CFG = {
|
|
ok: {
|
|
bg: 'bg-emerald-50',
|
|
border: 'border-emerald-200',
|
|
text: 'text-emerald-700',
|
|
badge: 'bg-emerald-100 text-emerald-700',
|
|
label: 'Normal',
|
|
Icon: CheckCircle,
|
|
},
|
|
warning: {
|
|
bg: 'bg-amber-50',
|
|
border: 'border-amber-200',
|
|
text: 'text-amber-700',
|
|
badge: 'bg-amber-100 text-amber-700',
|
|
label: 'Perhatian',
|
|
Icon: AlertTriangle,
|
|
},
|
|
critical: {
|
|
bg: 'bg-red-50',
|
|
border: 'border-red-200',
|
|
text: 'text-red-700',
|
|
badge: 'bg-red-100 text-red-700',
|
|
label: 'Kritis',
|
|
Icon: TrendingDown,
|
|
},
|
|
};
|
|
|
|
const PAGE_FOCUSES: Record<InsightTopic, string[]> = {
|
|
hitung_ayam: [
|
|
'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',
|
|
],
|
|
berat_ayam: [
|
|
'Pencapaian target bobot (ADG) vs standar Cobb',
|
|
'Keseragaman (uniformity) bobot antar ayam',
|
|
'Hubungan stres lingkungan (jika ada indikasi) dengan hambatan pertumbuhan',
|
|
'Analisis tren pertumbuhan dalam 7 hari terakhir',
|
|
'Proyeksi waktu panen berdasarkan tren bobot saat ini',
|
|
'Evaluasi performa ayam di area depan vs belakang kandang',
|
|
'Deteksi indikasi stunting atau pertumbuhan terhambat',
|
|
],
|
|
fcr: [
|
|
'Efisiensi konversi pakan secara keseluruhan vs standar',
|
|
'Dampak fluktuasi asupan pakan terhadap nilai FCR harian',
|
|
'Korelasi antara mortalitas dengan pembengkakan FCR',
|
|
'Identifikasi pemborosan pakan atau distribusi pakan yang tidak merata',
|
|
'Proyeksi kerugian/keuntungan finansial berdasarkan FCR saat ini',
|
|
'Evaluasi kualitas pakan terhadap tingkat penyerapan (berdasarkan FCR)',
|
|
'Tindakan perbaikan tata laksana pakan (jam pemberian, feed sorting)',
|
|
],
|
|
eef: [
|
|
'Keseimbangan antara FCR, Bobot, dan Mortalitas dalam skor EEF',
|
|
'Potensi pencapaian bonus indeks performa di akhir siklus',
|
|
'Faktor utama yang menarik turun skor EEF (apakah FCR atau mortalitas?)',
|
|
'Evaluasi keberhasilan manajemen pemeliharaan secara holistik',
|
|
'Bandingkan performa siklus ini dengan target standar perusahaan (>300)',
|
|
'Proyeksi skor akhir EEF jika tren saat ini dipertahankan',
|
|
'Tindakan taktis penyelamatan nilai EEF di sisa hari pemeliharaan',
|
|
],
|
|
iot_panel: [
|
|
'Stabilitas suhu siang-malam dan risiko heat stress/cold stress',
|
|
'Manajemen kelembapan dan dampaknya pada litter (sekam)',
|
|
'Akumulasi gas amonia/CO2 dan pengaruhnya terhadap pernapasan ayam',
|
|
'Kinerja sistem ventilasi (exhaust fan & cooling pad)',
|
|
'Korelasi fluktuasi cuaca luar kandang dengan iklim mikro di dalam',
|
|
'Pengaturan setpoint suhu vs kenyamanan aktual ayam (THI)',
|
|
'Deteksi dini kegagalan fungsi sensor atau blind spot ventilasi',
|
|
],
|
|
hitung_karung: [
|
|
'Keseimbangan antara stok pakan dengan laju konsumsi harian',
|
|
'Deteksi anomali konsumsi pakan (turun drastis/naik tidak wajar)',
|
|
'Pemerataan jadwal penuangan pakan ke dalam silo/hopper',
|
|
'Efisiensi persediaan dan pencegahan pakan berjamur/rusak',
|
|
'Kesesuaian rasio asupan pakan per ekor dengan standar umur',
|
|
'Evaluasi manajemen stok menjelang jadwal panen (menghindari sisa)',
|
|
'Dampak suhu ekstrem terhadap nafsu makan dan penarikan pakan',
|
|
],
|
|
};
|
|
|
|
// ─── Prompt builders ─────────────────────────────────────────────────────────
|
|
const buildPrompt = (topic: InsightTopic, data: Record<string, unknown>): string => {
|
|
// Limit data to 2000 chars to avoid oversized prompts
|
|
const fullJson = JSON.stringify(data, null, 2);
|
|
const dataStr = fullJson.length > 2000 ? fullJson.slice(0, 2000) + '\n...' : fullJson;
|
|
|
|
const focuses = PAGE_FOCUSES[topic] || [];
|
|
const selectedFocus =
|
|
focuses[Math.floor(Math.random() * focuses.length)] || 'Analisis menyeluruh';
|
|
|
|
const INSTRUCTIONS: Record<InsightTopic, string> = {
|
|
hitung_ayam: `Anda adalah manajer peternakan ayam broiler strain CP 707.
|
|
Analisis data HITUNG AYAM berikut. Berikan insight singkat, profesional, dan actionable dalam Bahasa Indonesia.
|
|
Pedoman mortalitas strain CP 707: di bawah 5 persen = normal (ok), 5-7 persen = perhatian (warning), di atas 7 persen = kritis (critical).
|
|
|
|
Konteks & Aturan Variasi:
|
|
Fokus utama analisis Anda hari ini adalah: "${selectedFocus}".
|
|
Jadikan ini sebagai sudut pandang utama. Jangan sebutkan nama fokus secara eksplisit di hasil akhir.
|
|
|
|
Data kandang:
|
|
${dataStr}`,
|
|
|
|
berat_ayam: `Anda adalah ahli nutrisi peternakan ayam broiler strain CP 707.
|
|
Analisis data BERAT AYAM berikut. Berikan insight singkat, profesional, actionable dalam Bahasa Indonesia.
|
|
Pedoman: bobot di bawah target standar CP 707 lebih dari 5 persen = warning. ADG rendah menunjukkan masalah nutrisi atau penyakit.
|
|
|
|
Konteks & Aturan Variasi:
|
|
Fokus utama analisis Anda hari ini adalah: "${selectedFocus}".
|
|
Jadikan ini sebagai sudut pandang utama. Jangan sebutkan nama fokus secara eksplisit di hasil akhir.
|
|
|
|
Data bobot:
|
|
${dataStr}`,
|
|
|
|
fcr: `Anda adalah analis performa peternakan ayam broiler strain CP 707.
|
|
Analisis data FCR (Feed Conversion Ratio) berikut.
|
|
FCR = Total Pakan dibagi Total Bobot Hidup. Nilai FCR yang lebih kecil berarti lebih efisien.
|
|
Pedoman: FCR sesuai standar CP 707 = ok. FCR sedikit di atas standar = warning. FCR jauh di atas standar = critical.
|
|
Standar FCR CP 707 umur 35 hari sekitar 1.60 hingga 1.63.
|
|
Berikan insight singkat, profesional, actionable dalam Bahasa Indonesia.
|
|
|
|
Konteks & Aturan Variasi:
|
|
Fokus utama analisis Anda hari ini adalah: "${selectedFocus}".
|
|
Jadikan ini sebagai sudut pandang utama. Jangan sebutkan nama fokus secara eksplisit di hasil akhir.
|
|
|
|
Data FCR:
|
|
${dataStr}`,
|
|
|
|
eef: `Anda adalah analis performa peternakan ayam broiler strain CP 707.
|
|
Analisis data EEF (European Efficiency Factor) berikut.
|
|
EEF = persen_hidup dikali bw_panen_gram dibagi fcr dikali hari_panen dikali 100. Nilai lebih tinggi lebih baik.
|
|
Pedoman: EEF di atas 300 sangat baik (ok). EEF 250-300 baik (ok). EEF 200-250 perhatian (warning). EEF di bawah 200 kritis (critical).
|
|
Berikan insight singkat, profesional, actionable dalam Bahasa Indonesia.
|
|
|
|
Konteks & Aturan Variasi:
|
|
Fokus utama analisis Anda hari ini adalah: "${selectedFocus}".
|
|
Jadikan ini sebagai sudut pandang utama. Jangan sebutkan nama fokus secara eksplisit di hasil akhir.
|
|
|
|
Data EEF:
|
|
${dataStr}`,
|
|
|
|
iot_panel: `Anda adalah ahli manajemen lingkungan kandang ayam broiler berbasis IoT strain CP 707.
|
|
Analisis kondisi lingkungan kandang dari data sensor IoT berikut.
|
|
Berikan insight singkat, profesional, actionable dalam Bahasa Indonesia.
|
|
Pedoman: Suhu ideal 20-30 derajat Celsius. Kelembapan ideal 60-70 persen. Amonia di bawah 25 ppm aman. CO2 di bawah 3000 ppm aman.
|
|
|
|
Konteks & Aturan Variasi:
|
|
Fokus utama analisis Anda hari ini adalah: "${selectedFocus}".
|
|
Jadikan ini sebagai sudut pandang utama. Jangan sebutkan nama fokus secara eksplisit di hasil akhir.
|
|
|
|
Data sensor IoT:
|
|
${dataStr}`,
|
|
|
|
hitung_karung: `Anda adalah manajer logistik pakan peternakan ayam broiler strain CP 707.
|
|
Analisis data HITUNG KARUNG PAKAN berikut.
|
|
Berikan insight singkat, profesional, actionable dalam Bahasa Indonesia.
|
|
Pedoman: konsumsi pakan harian wajar strain CP 707 sekitar 170 gram per ekor per hari untuk broiler umur 35 hari.
|
|
|
|
Konteks & Aturan Variasi:
|
|
Fokus utama analisis Anda hari ini adalah: "${selectedFocus}".
|
|
Jadikan ini sebagai sudut pandang utama. Jangan sebutkan nama fokus secara eksplisit di hasil akhir.
|
|
|
|
Data karung pakan:
|
|
${dataStr}`,
|
|
};
|
|
|
|
return `${INSTRUCTIONS[topic]}`;
|
|
};
|
|
|
|
// ─── Result parser (fallback jika schema mode gagal) ─────────────────────────
|
|
const parseResult = (text: string, topic?: InsightTopic): AiResult | null => {
|
|
if (!text || typeof text !== 'string') return null;
|
|
const stripped = text.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
|
|
const cleaned = stripped
|
|
.replace(/^```(?:json)?\s*/i, '')
|
|
.replace(/\s*```\s*$/, '')
|
|
.replace(/\/\/.*/g, '')
|
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
.replace(/\.\.\.[\s\S]*?(?=[,}\]\n])/g, '')
|
|
.replace(/,\s*([}\]])/g, '$1')
|
|
.trim();
|
|
|
|
const first = cleaned.indexOf('{');
|
|
const last = cleaned.lastIndexOf('}');
|
|
if (first === -1 || last === -1 || last <= first) return null;
|
|
|
|
try {
|
|
const parsed = JSON.parse(cleaned.slice(first, last + 1));
|
|
if (!parsed || typeof parsed !== 'object') return null;
|
|
|
|
// Check if it's nested dashboard format ({ summary: {...}, detailed: {...} })
|
|
if (parsed.detailed && typeof parsed.detailed === 'object') {
|
|
const topicMap: Record<string, string> = {
|
|
hitung_ayam: 'hitung_ayam',
|
|
berat_ayam: 'berat_ayam',
|
|
fcr: 'fcr',
|
|
eef: 'eef',
|
|
iot_panel: 'panel_iot',
|
|
hitung_karung: 'hitung_karung',
|
|
};
|
|
const sectionKey = topic ? topicMap[topic] || topic : 'hitung_ayam';
|
|
const section = parsed.detailed[sectionKey] || Object.values(parsed.detailed)[0];
|
|
|
|
if (section && typeof section === 'object') {
|
|
let secStatus: 'ok' | 'warning' | 'critical' = 'ok';
|
|
if (section.status === 'critical') secStatus = 'critical';
|
|
else if (section.status === 'warning') secStatus = 'warning';
|
|
|
|
return {
|
|
headline: String(
|
|
section.bullets?.[0] ||
|
|
parsed.summary?.headline ||
|
|
'Analisis operasional kandang terpantau normal.'
|
|
),
|
|
status: secStatus,
|
|
summary: Array.isArray(section.bullets)
|
|
? section.bullets.map(String)
|
|
: Array.isArray(parsed.summary?.bullets)
|
|
? parsed.summary.bullets.map(String)
|
|
: [],
|
|
risks: Array.isArray(section.evidence) ? section.evidence.map(String) : [],
|
|
actions: Array.isArray(section.actions) ? section.actions.map(String) : [],
|
|
};
|
|
}
|
|
}
|
|
|
|
// Support flat JSON format
|
|
const headline =
|
|
parsed.headline ||
|
|
parsed.summary?.headline ||
|
|
parsed.judul ||
|
|
parsed.headlineText ||
|
|
parsed.headline_utama ||
|
|
parsed.message ||
|
|
'Analisis operasional kandang terpantau normal.';
|
|
|
|
const rawStatus = String(
|
|
parsed.status || parsed.summary?.status || parsed.kondisi || 'ok'
|
|
).toLowerCase();
|
|
let status: 'ok' | 'warning' | 'critical' = 'ok';
|
|
if (
|
|
rawStatus.includes('crit') ||
|
|
rawStatus.includes('kritis') ||
|
|
rawStatus.includes('tinggi') ||
|
|
rawStatus.includes('parah') ||
|
|
rawStatus.includes('critical')
|
|
) {
|
|
status = 'critical';
|
|
} else if (
|
|
rawStatus.includes('warn') ||
|
|
rawStatus.includes('perhatian') ||
|
|
rawStatus.includes('kurang') ||
|
|
rawStatus.includes('sedang') ||
|
|
rawStatus.includes('warning')
|
|
) {
|
|
status = 'warning';
|
|
}
|
|
|
|
// Support synonyms for lists, with fallback formatting for objects
|
|
const toArr = (v: unknown) =>
|
|
Array.isArray(v)
|
|
? v
|
|
.map((item) => {
|
|
if (item && typeof item === 'object') {
|
|
const obj = item as Record<string, unknown>;
|
|
const desc =
|
|
obj.description ||
|
|
obj.deskripsi ||
|
|
obj.text ||
|
|
obj.action ||
|
|
obj.tindakan ||
|
|
obj.parameter;
|
|
if (desc) {
|
|
return obj.parameter ? `[${obj.parameter}] ${desc}` : String(desc);
|
|
}
|
|
return JSON.stringify(item);
|
|
}
|
|
return String(item);
|
|
})
|
|
.filter(Boolean)
|
|
: [];
|
|
|
|
const summaryData =
|
|
parsed.summary?.bullets ||
|
|
parsed.summary ||
|
|
parsed.ringkasan ||
|
|
parsed.poin_ringkasan ||
|
|
parsed.bullets ||
|
|
[];
|
|
const risksData =
|
|
parsed.summary?.risks ||
|
|
parsed.risks ||
|
|
parsed.risiko ||
|
|
parsed.resiko ||
|
|
parsed.bahaya ||
|
|
parsed.evidence ||
|
|
[];
|
|
const actionsData =
|
|
parsed.summary?.actions ||
|
|
parsed.actions ||
|
|
parsed.tindakan ||
|
|
parsed.saran ||
|
|
parsed.rekomendasi ||
|
|
parsed.langkah_konkrit ||
|
|
parsed.langkah ||
|
|
[];
|
|
|
|
return {
|
|
headline: String(headline),
|
|
status,
|
|
summary: toArr(summaryData),
|
|
risks: toArr(risksData),
|
|
actions: toArr(actionsData),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// Helper to safely parse strings containing units and Indonesian commas into standard numbers
|
|
const toNum = (val: unknown, fallback: number): number => {
|
|
if (val === undefined || val === null) return fallback;
|
|
if (typeof val === 'number') return Number.isFinite(val) ? val : fallback;
|
|
const str = String(val)
|
|
.replace(/[^0-9,.-]/g, '')
|
|
.replace(',', '.')
|
|
.trim();
|
|
const num = Number(str);
|
|
return Number.isFinite(num) ? num : fallback;
|
|
};
|
|
|
|
// ─── Local Rule-Based Fallback Generator (CP 707 Standard Compliance) ─────────
|
|
const _generateLocalInsight = (topic: InsightTopic, data: Record<string, any>): AiResult => {
|
|
switch (topic) {
|
|
case 'hitung_ayam': {
|
|
const initial = toNum(data.initialPopulation || data.totalInitialPopulation, 10000);
|
|
const current = toNum(data.currentPopulation || data.iotCurrentPopulation, 9800);
|
|
const mortality = toNum(data.totalMortality, initial - current);
|
|
const rate =
|
|
data.mortalityRate !== undefined
|
|
? toNum(data.mortalityRate, 0)
|
|
: initial > 0
|
|
? (mortality / initial) * 100
|
|
: 0;
|
|
const day = toNum(data.currentDay || data.hari_ke, 1);
|
|
|
|
let status: 'ok' | 'warning' | 'critical' = 'ok';
|
|
if (rate > 7) status = 'critical';
|
|
else if (rate >= 5) status = 'warning';
|
|
|
|
const risks = [];
|
|
const actions = [];
|
|
if (status === 'critical') {
|
|
risks.push(
|
|
`Mortalitas kumulatif mencapai ${rate.toFixed(2)}%, jauh di atas batas toleransi CP 707 (>7%).`
|
|
);
|
|
actions.push('Lakukan bedah bangkai (nekropsi) segera untuk analisa patologi.');
|
|
actions.push('Perketat biosekuriti kandang dan berikan multivitamin suportif.');
|
|
} else if (status === 'warning') {
|
|
risks.push(
|
|
`Mortalitas kumulatif mencapai ${rate.toFixed(2)}%, mendekati batas toleransi CP 707 (5-7%).`
|
|
);
|
|
actions.push('Tingkatkan kualitas sanitasi dan sekam kandang.');
|
|
actions.push('Evaluasi kecukupan asupan air minum ayam.');
|
|
} else {
|
|
actions.push('Pertahankan manajemen biosekuriti dan sanitasi harian.');
|
|
}
|
|
|
|
return {
|
|
headline: `Tingkat deplesi/mortalitas kumulatif terpantau ${rate.toFixed(2)}% pada hari ke-${day}.`,
|
|
status,
|
|
summary: [
|
|
`Populasi awal siklus: ${initial.toLocaleString()} ekor.`,
|
|
`Populasi aktif saat ini: ${current.toLocaleString()} ekor.`,
|
|
`Total kematian kumulatif: ${mortality.toLocaleString()} ekor.`,
|
|
],
|
|
risks,
|
|
actions,
|
|
};
|
|
}
|
|
|
|
case 'berat_ayam': {
|
|
const avg = toNum(data.averageWeight, 0);
|
|
const target = toNum(data.targetWeight, 0);
|
|
const _day = toNum(data.currentDay, 1);
|
|
const uniformity = toNum(data.uniformity, 85);
|
|
const diffPct = target > 0 ? ((target - avg) / target) * 100 : 0;
|
|
|
|
let status: 'ok' | 'warning' | 'critical' = 'ok';
|
|
if (diffPct > 15 || uniformity < 75) status = 'critical';
|
|
else if (diffPct > 5 || uniformity < 85) status = 'warning';
|
|
|
|
const risks = [];
|
|
const actions = [];
|
|
if (status === 'critical') {
|
|
risks.push(`Bobot aktual tertinggal ${diffPct.toFixed(1)}% dari standar CP 707.`);
|
|
risks.push(`Keseragaman (uniformity) di bawah batas toleransi (${uniformity}%).`);
|
|
actions.push('Lakukan sekat pembatas (grading) untuk memisahkan ayam berbobot kecil.');
|
|
actions.push('Evaluasi kecukupan ruang pakan dan asupan nutrisi.');
|
|
} else if (status === 'warning') {
|
|
risks.push(`Bobot aktual tertinggal ${diffPct.toFixed(1)}% dari standar pertumbuhan.`);
|
|
actions.push('Tingkatkan stimulasi pakan di siang hari dengan cara digoyang.');
|
|
actions.push('Sesuaikan tinggi tempat pakan agar sejajar dengan punggung ayam.');
|
|
} else {
|
|
actions.push('Pertahankan manajemen ventilasi untuk mendukung ADG harian.');
|
|
}
|
|
|
|
return {
|
|
headline: `Rata-rata bobot mencapai ${avg}g vs Target standar ${target}g.`,
|
|
status,
|
|
summary: [
|
|
`Bobot rata-rata aktual: ${avg} gram.`,
|
|
`Target bobot standar strain CP 707: ${target} gram.`,
|
|
`Tingkat keseragaman (uniformity) populasi: ${uniformity}%.`,
|
|
],
|
|
risks,
|
|
actions,
|
|
};
|
|
}
|
|
|
|
case 'fcr': {
|
|
const fcr = toNum(data.fcr_terakhir, 1.62);
|
|
const day = toNum(data.hari_terakhir, 35);
|
|
|
|
let status: 'ok' | 'warning' | 'critical' = 'ok';
|
|
if (fcr > 1.7) status = 'critical';
|
|
else if (fcr > 1.63) status = 'warning';
|
|
|
|
const risks = [];
|
|
const actions = [];
|
|
if (status === 'critical') {
|
|
risks.push(`FCR mencapai ${fcr.toFixed(2)}, jauh di atas target standar CP 707 (>1.70).`);
|
|
actions.push('Periksa kebocoran silo/hopper dan potensi pakan tercecer.');
|
|
actions.push('Evaluasi kesehatan pencernaan ayam terhadap infeksi.');
|
|
} else if (status === 'warning') {
|
|
risks.push(
|
|
`FCR mencapai ${fcr.toFixed(2)}, sedikit di atas standar efisiensi (1.60-1.63).`
|
|
);
|
|
actions.push('Atur ketinggian piringan pakan untuk meminimalkan pakan tumpah.');
|
|
actions.push('Pantau kualitas fisik pakan (kadar debu/fines).');
|
|
} else {
|
|
actions.push('Pertahankan ketepatan penimbangan dan pemberian pakan harian.');
|
|
}
|
|
|
|
return {
|
|
headline: `Feed Conversion Ratio (FCR) tercatat sebesar ${fcr.toFixed(2)} pada hari ke-${day}.`,
|
|
status,
|
|
summary: [
|
|
`FCR aktual saat ini: ${fcr.toFixed(2)}.`,
|
|
`Nilai FCR memenuhi standar efisiensi konversi pakan strain CP 707.`,
|
|
],
|
|
risks,
|
|
actions,
|
|
};
|
|
}
|
|
|
|
case 'eef': {
|
|
const eef = toNum(data.eef_terakhir, 300);
|
|
const day = toNum(data.hari_terakhir, 35);
|
|
|
|
let status: 'ok' | 'warning' | 'critical' = 'ok';
|
|
if (eef < 200) status = 'critical';
|
|
else if (eef < 250) status = 'warning';
|
|
|
|
const risks = [];
|
|
const actions = [];
|
|
if (status === 'critical') {
|
|
risks.push(`EEF di zona kritis (<200) akibat penurunan viabilitas dan FCR buruk.`);
|
|
actions.push('Lakukan audit manajemen menyeluruh terkait ventilasi dan biosekuriti.');
|
|
} else if (status === 'warning') {
|
|
risks.push(
|
|
`Skor EEF (${eef.toFixed(0)}) berada di batas minimal standar optimal (200-250).`
|
|
);
|
|
actions.push('Tingkatkan pengelolaan sirkulasi udara kandang.');
|
|
} else {
|
|
actions.push('Pertahankan performa pemeliharaan hingga masa panen selesai.');
|
|
}
|
|
|
|
return {
|
|
headline: `Indeks performa EEF mencapai skor ${eef.toFixed(0)} pada hari ke-${day}.`,
|
|
status,
|
|
summary: [
|
|
`EEF aktual saat ini: ${eef.toFixed(0)}.`,
|
|
`Skor EEF melambangkan efisiensi produksi siklus berjalan secara keseluruhan.`,
|
|
],
|
|
risks,
|
|
actions,
|
|
};
|
|
}
|
|
|
|
case 'iot_panel': {
|
|
const temp = toNum(data.suhu_rata_rata_C, 25);
|
|
const hum = toNum(data.kelembapan_persen, 65);
|
|
const ammonia = toNum(data.amonia_ppm, 10);
|
|
const co2 = toNum(data.co2_ppm, 1500);
|
|
|
|
const isTempBad = temp < 20 || temp > 30;
|
|
const isHumBad = hum < 60 || hum > 70;
|
|
const isAmoniaBad = ammonia > 25;
|
|
|
|
let status: 'ok' | 'warning' | 'critical' = 'ok';
|
|
if (ammonia > 35 || temp > 34) status = 'critical';
|
|
else if (isTempBad || isHumBad || isAmoniaBad) status = 'warning';
|
|
|
|
const risks = [];
|
|
const actions = [];
|
|
if (isTempBad) {
|
|
risks.push(
|
|
`Suhu kandang (${temp.toFixed(1)}°C) berada di luar batas ideal CP 707 (20-30°C).`
|
|
);
|
|
actions.push(
|
|
temp > 30
|
|
? 'Aktifkan kipas pendingin tambahan atau exhaust fan.'
|
|
: 'Nyalakan pemanas tambahan.'
|
|
);
|
|
}
|
|
if (isAmoniaBad) {
|
|
risks.push(
|
|
`Kadar amonia tinggi (${ammonia.toFixed(1)} ppm), dapat mengiritasi mata dan paru ayam.`
|
|
);
|
|
actions.push('Tingkatkan minimal ventilasi kandang untuk mengeluarkan gas amonia.');
|
|
}
|
|
|
|
if (status === 'ok') {
|
|
actions.push('Pertahankan setting otomatis iklim mikro kandang.');
|
|
}
|
|
|
|
return {
|
|
headline: `Kondisi Lingkungan Kandang: Suhu Rata-rata ${temp.toFixed(1)}°C, Kelembapan ${hum.toFixed(1)}%.`,
|
|
status,
|
|
summary: [
|
|
`Suhu rata-rata: ${temp.toFixed(1)}°C.`,
|
|
`Kelembapan udara: ${hum.toFixed(1)}%.`,
|
|
`Kadar amonia: ${ammonia.toFixed(1)} ppm.`,
|
|
`Kadar CO2: ${co2.toFixed(1)} ppm.`,
|
|
],
|
|
risks,
|
|
actions,
|
|
};
|
|
}
|
|
|
|
case 'hitung_karung': {
|
|
const dituang = toNum(data.total_karung_dituang_iot, 0);
|
|
const masuk = toNum(data.total_karung_masuk_iot, 0);
|
|
const saldo = toNum(data.saldo_awal_karung, 100);
|
|
const sisa = saldo + masuk - dituang;
|
|
|
|
let status: 'ok' | 'warning' | 'critical' = 'ok';
|
|
if (sisa < 10) status = 'critical';
|
|
else if (sisa < 30) status = 'warning';
|
|
|
|
const risks = [];
|
|
const actions = [];
|
|
if (status === 'critical') {
|
|
risks.push(`Stok pakan menipis secara kritis (${sisa} karung tersisa).`);
|
|
actions.push('Segera ajukan pengiriman pakan darurat.');
|
|
} else if (status === 'warning') {
|
|
risks.push(`Stok pakan tersisa ${sisa} karung, cukup untuk 1-2 hari.`);
|
|
actions.push('Buat permintaan pakan baru hari ini.');
|
|
} else {
|
|
actions.push('Lakukan pemantauan konsumsi pakan harian.');
|
|
}
|
|
|
|
return {
|
|
headline: `Sisa stok pakan terhitung sebanyak ${sisa} karung.`,
|
|
status,
|
|
summary: [
|
|
`Pakan masuk: ${masuk} karung.`,
|
|
`Pakan dituang: ${dituang} karung.`,
|
|
`Sisa cadangan pakan: ${sisa} karung.`,
|
|
],
|
|
risks,
|
|
actions,
|
|
};
|
|
}
|
|
|
|
default:
|
|
return {
|
|
headline: 'Analisis operasional kandang terpantau normal.',
|
|
status: 'ok',
|
|
summary: ['Data operasional normal.'],
|
|
risks: [],
|
|
actions: ['Lanjutkan pengawasan berkala.'],
|
|
};
|
|
}
|
|
};
|
|
|
|
// ─── Component ───────────────────────────────────────────────────────────────
|
|
export const PageAiInsight: React.FC<PageAiInsightProps> = ({ topic, contextData, cacheKey }) => {
|
|
const { cycleData, kandangs, selectedKandangId } = useAppContext();
|
|
const [result, setResult] = useState<AiResult | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const inFlightRef = useRef(false);
|
|
const lastCacheKeyRef = useRef<string | null>(null);
|
|
|
|
const meta = TOPIC_META[topic] ?? TOPIC_META['hitung_ayam'];
|
|
const colors = COLOR_MAP[meta.color] ?? COLOR_MAP['purple'];
|
|
|
|
const fetchFromBackend = useCallback(
|
|
async (cycleId: string): Promise<AiResult | null> => {
|
|
try {
|
|
const res = await apiClient.getAiInsight(cycleId);
|
|
if (!res.success || !res.data?.insightText) {
|
|
return null;
|
|
}
|
|
|
|
const parsedDashboard = JSON.parse(res.data.insightText);
|
|
const detailed = parsedDashboard.detailed;
|
|
if (!detailed) return null;
|
|
|
|
const topicMap: Record<InsightTopic, string> = {
|
|
hitung_ayam: 'hitung_ayam',
|
|
berat_ayam: 'berat_ayam',
|
|
fcr: 'fcr',
|
|
eef: 'eef',
|
|
iot_panel: 'panel_iot',
|
|
hitung_karung: 'hitung_karung',
|
|
};
|
|
|
|
const key = topicMap[topic];
|
|
const section = detailed[key];
|
|
if (!section) return null;
|
|
|
|
let status: 'ok' | 'warning' | 'critical' = 'ok';
|
|
if (section.status === 'critical') status = 'critical';
|
|
else if (section.status === 'warning') status = 'warning';
|
|
|
|
const headlineMap: Record<InsightTopic, string> = {
|
|
hitung_ayam: 'Analisis estimasi populasi harian dan tingkat mortalitas kandang.',
|
|
berat_ayam: 'Evaluasi bobot harian (ADG) dan tingkat keseragaman ayam.',
|
|
fcr: 'Analisis efisiensi konversi pakan harian terhadap standar.',
|
|
eef: 'Indeks performa keseluruhan efisiensi produksi siklus berjalan.',
|
|
iot_panel: 'Pemantauan parameter suhu, kelembaban, dan gas amonia kandang.',
|
|
hitung_karung: 'Analisis stok pakan dan efisiensi konsumsi harian.',
|
|
};
|
|
|
|
return {
|
|
headline: section.bullets?.[0] || headlineMap[topic],
|
|
status,
|
|
summary: section.bullets || [],
|
|
risks: section.evidence || [],
|
|
actions: section.actions || [],
|
|
};
|
|
} catch (err) {
|
|
console.warn('Failed to load insight from backend cache:', err);
|
|
return null;
|
|
}
|
|
},
|
|
[topic]
|
|
);
|
|
|
|
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
|
const generate = useCallback(
|
|
async (force = false) => {
|
|
const storageKey = `page-insight-v5-${cacheKey}`;
|
|
|
|
// ── On force refresh, evict all caches first ──────────────────────────────
|
|
if (force) {
|
|
_insightMemoryCache.delete(storageKey);
|
|
try {
|
|
sessionStorage.removeItem(storageKey);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
// ── 1. Global memory cache (instant, survives page navigation) ────────────
|
|
if (!force) {
|
|
const memCached = _insightMemoryCache.get(storageKey);
|
|
if (memCached) {
|
|
setResult(memCached);
|
|
lastCacheKeyRef.current = cacheKey;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// ── 2. De-duplicate: if same key is already in-flight, wait for it ────────
|
|
if (!force && _insightInFlight.has(storageKey)) {
|
|
await _insightInFlight.get(storageKey);
|
|
const memCached = _insightMemoryCache.get(storageKey);
|
|
if (memCached) {
|
|
setResult(memCached);
|
|
lastCacheKeyRef.current = cacheKey;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// ── 3. sessionStorage fallback (survives component unmount) ───────────────
|
|
if (!force) {
|
|
try {
|
|
const raw = sessionStorage.getItem(storageKey);
|
|
if (raw) {
|
|
const parsed = parseResult(raw);
|
|
if (parsed) {
|
|
_insightMemoryCache.set(storageKey, parsed); // promote to memory
|
|
setResult(parsed);
|
|
lastCacheKeyRef.current = cacheKey;
|
|
return;
|
|
}
|
|
sessionStorage.removeItem(storageKey);
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
if (inFlightRef.current) return;
|
|
inFlightRef.current = true;
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
// Register this promise for deduplication
|
|
const fetchPromise = (async () => {
|
|
try {
|
|
// ── 4. Backend database cache (bypass if force-refreshing) ────────────
|
|
if (!force && cycleData?.id) {
|
|
const backendResult = await fetchFromBackend(cycleData.id);
|
|
if (backendResult) {
|
|
_saveToCache(storageKey, backendResult);
|
|
setResult(backendResult);
|
|
lastCacheKeyRef.current = cacheKey;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// ── 5. Generate via LM Studio proxy with 4000ms timeout ───────────────
|
|
const prompt = buildPrompt(topic, contextData);
|
|
const system_prompt =
|
|
'Anda adalah analis peternakan ayam broiler berpengalaman. Kembalikan HANYA objek JSON valid dengan format: {"headline": "kesimpulan singkat", "status": "ok/warning/critical", "summary": ["poin 1", "poin 2"], "risks": ["risiko 1"], "actions": ["tindakan 1"]}. Dilarang memberikan teks penjelasan di luar objek JSON.';
|
|
|
|
// Build a short semantic query for RAG (describe what we're analyzing)
|
|
const context_query = `Analisis ${topic.replace(/_/g, ' ')} hari ke-${contextData.currentDay || contextData.hari_ke || contextData.hari_terakhir || 0} broiler CP707`;
|
|
|
|
const fetchPromiseCall = apiClient.proxyLmStudio(
|
|
prompt,
|
|
system_prompt,
|
|
0.2,
|
|
topic,
|
|
context_query,
|
|
contextData
|
|
);
|
|
const timeoutPromise = new Promise<never>((_, reject) =>
|
|
setTimeout(() => reject(new Error('TIMEOUT_LIMIT')), 600000)
|
|
);
|
|
|
|
const json = await Promise.race([fetchPromiseCall, timeoutPromise]);
|
|
const text = json.response ?? '';
|
|
const parsed = parseResult(text, topic);
|
|
|
|
if (!parsed) {
|
|
console.error(`[PageAiInsight:${topic}] Raw response:`, text.slice(0, 500));
|
|
throw new Error('Respons AI tidak dapat diproses.');
|
|
}
|
|
|
|
if (result && JSON.stringify(parsed) === JSON.stringify(result)) {
|
|
throw new Error(
|
|
'Data belum diperbarui. Hasil analisis AI masih sama dengan versi sebelumnya.'
|
|
);
|
|
}
|
|
|
|
_saveToCache(storageKey, parsed);
|
|
setResult(parsed);
|
|
lastCacheKeyRef.current = cacheKey;
|
|
} catch (err) {
|
|
console.error(`[PageAiInsight:${topic}] LLM request failed/timed out:`, err);
|
|
const errMsg = err instanceof Error ? err.message : 'Gagal menghasilkan AI Insight.';
|
|
setError(errMsg);
|
|
setResult(null);
|
|
} finally {
|
|
setLoading(false);
|
|
inFlightRef.current = false;
|
|
_insightInFlight.delete(storageKey);
|
|
}
|
|
})();
|
|
|
|
_insightInFlight.set(storageKey, fetchPromise);
|
|
await fetchPromise;
|
|
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
},
|
|
[topic, cacheKey, contextData, cycleData?.id, fetchFromBackend, result]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (lastCacheKeyRef.current === cacheKey) return;
|
|
const timer = setTimeout(() => generate(false), 800);
|
|
return () => clearTimeout(timer);
|
|
}, [cacheKey, generate]);
|
|
|
|
const downloadPdf = () => {
|
|
if (!result) return;
|
|
|
|
const doc = new jsPDF({
|
|
orientation: 'portrait',
|
|
unit: 'mm',
|
|
format: 'a4',
|
|
});
|
|
|
|
const primaryColor = [79, 70, 229]; // Indigo [R, G, B]
|
|
const secondaryColor = [31, 41, 55]; // Gray-800
|
|
const lightGray = [243, 244, 246]; // Gray-100
|
|
|
|
// Page dimensions
|
|
const pageWidth = doc.internal.pageSize.getWidth();
|
|
const margin = 20;
|
|
const contentWidth = pageWidth - margin * 2;
|
|
|
|
// Helper to wrap and add text
|
|
let y = 20;
|
|
|
|
// 1. Header Title
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.setFontSize(20);
|
|
doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]);
|
|
doc.text('Daya Cognitive AI Insights', margin, y);
|
|
|
|
// Subtitle
|
|
y += 7;
|
|
doc.setFont('helvetica', 'normal');
|
|
doc.setFontSize(8.5);
|
|
doc.setTextColor(100, 116, 139); // Slate-500
|
|
doc.text(
|
|
'Strategic operations diagnostics & predictive actions recommended by sovereign LLMs',
|
|
margin,
|
|
y
|
|
);
|
|
|
|
// Divider line
|
|
y += 5;
|
|
doc.setDrawColor(226, 232, 240); // Slate-200
|
|
doc.setLineWidth(0.5);
|
|
doc.line(margin, y, pageWidth - margin, y);
|
|
|
|
// 2. Metadata Section (Horizontal tags)
|
|
y += 8;
|
|
doc.setFillColor(lightGray[0], lightGray[1], lightGray[2]);
|
|
doc.rect(margin, y, contentWidth, 10, 'F');
|
|
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.setFontSize(7.5);
|
|
doc.setTextColor(71, 85, 105); // Slate-600
|
|
|
|
const activeKandang = kandangs?.find((k: any) => k.id === selectedKandangId);
|
|
const kandangName = activeKandang?.name || 'Semua Kandang';
|
|
const rawCtxDay =
|
|
(contextData as any)?.currentDay ??
|
|
(contextData as any)?.hari_ke ??
|
|
(contextData as any)?.hari_terakhir ??
|
|
cycleData?.currentDay;
|
|
const maxDays = (contextData as any)?.totalDays ?? cycleData?.totalDays;
|
|
const currentDayVal = maxDays && rawCtxDay > maxDays ? maxDays : rawCtxDay;
|
|
const dayAge =
|
|
currentDayVal !== undefined && currentDayVal !== null ? `Hari ${currentDayVal}` : '-';
|
|
|
|
const metaText = `SCOPE: ${meta.title.toUpperCase()} | KANDANG: ${kandangName.toUpperCase()} | UMUR SIKLUS: ${dayAge} | TANGGAL: ${new Date().toLocaleDateString('id-ID')}`;
|
|
doc.text(metaText, margin + 4, y + 6.5);
|
|
|
|
// 3. Headline Box (Status-based background)
|
|
y += 18;
|
|
|
|
// Determine status badge properties
|
|
let statusLabel = 'OK';
|
|
let badgeColor = [16, 185, 129]; // Emerald (OK)
|
|
let badgeBg = [209, 250, 229];
|
|
if (result.status === 'warning') {
|
|
statusLabel = 'PERINGATAN';
|
|
badgeColor = [245, 158, 11]; // Amber
|
|
badgeBg = [254, 243, 199];
|
|
} else if (result.status === 'critical') {
|
|
statusLabel = 'KRITIS';
|
|
badgeColor = [239, 68, 68]; // Red
|
|
badgeBg = [254, 226, 226];
|
|
}
|
|
|
|
// Draw Headline status background
|
|
doc.setFillColor(badgeBg[0], badgeBg[1], badgeBg[2]);
|
|
doc.roundedRect(margin, y, contentWidth, 18, 2, 2, 'F');
|
|
|
|
// Status Badge text
|
|
doc.setFillColor(badgeColor[0], badgeColor[1], badgeColor[2]);
|
|
doc.roundedRect(margin + 4, y + 4, 26, 5.5, 1, 1, 'F');
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.setFontSize(7.5);
|
|
doc.setTextColor(255, 255, 255);
|
|
doc.text(statusLabel, margin + 6, y + 7.8);
|
|
|
|
// Headline Text
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.setFontSize(9);
|
|
doc.setTextColor(secondaryColor[0], secondaryColor[1], secondaryColor[2]);
|
|
|
|
const headlineLines = doc.splitTextToSize(result.headline, contentWidth - 38);
|
|
doc.text(headlineLines, margin + 34, y + 7.8);
|
|
|
|
y += 26;
|
|
|
|
// 4. Three core sections (Summary, Risks, Actions)
|
|
const drawSection = (
|
|
titleText: string,
|
|
items: string[],
|
|
titleColor: number[],
|
|
bulletBg: number[],
|
|
isOrdered = false
|
|
) => {
|
|
// Check if we need a new page
|
|
if (y > 240) {
|
|
doc.addPage();
|
|
y = 20;
|
|
}
|
|
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.setFontSize(11);
|
|
doc.setTextColor(titleColor[0], titleColor[1], titleColor[2]);
|
|
doc.text(titleText, margin, y);
|
|
|
|
y += 5;
|
|
doc.setDrawColor(titleColor[0], titleColor[1], titleColor[2]);
|
|
doc.setLineWidth(0.3);
|
|
doc.line(margin, y, margin + 18, y);
|
|
|
|
y += 8;
|
|
|
|
doc.setFont('helvetica', 'normal');
|
|
doc.setFontSize(9);
|
|
doc.setTextColor(55, 65, 81); // Slate-700
|
|
|
|
if (items.length === 0) {
|
|
doc.setFont('helvetica', 'italic');
|
|
doc.text('Tidak ada data analisis', margin + 4, y);
|
|
y += 8;
|
|
return;
|
|
}
|
|
|
|
items.forEach((item, index) => {
|
|
const textLines = doc.splitTextToSize(item, contentWidth - 12);
|
|
const textHeight = textLines.length * 4.5;
|
|
|
|
// Check page boundary for item
|
|
if (y + textHeight > 265) {
|
|
doc.addPage();
|
|
y = 20;
|
|
}
|
|
|
|
// Draw Bullet point / number badge
|
|
if (isOrdered) {
|
|
doc.setFillColor(bulletBg[0], bulletBg[1], bulletBg[2]);
|
|
doc.roundedRect(margin, y - 3, 4.5, 4.5, 1, 1, 'F');
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.setFontSize(7);
|
|
doc.setTextColor(titleColor[0], titleColor[1], titleColor[2]);
|
|
doc.text((index + 1).toString(), margin + 1.5, y + 0.2);
|
|
} else {
|
|
doc.setFillColor(bulletBg[0], bulletBg[1], bulletBg[2]);
|
|
doc.circle(margin + 2.2, y - 1, 1.0, 'F');
|
|
}
|
|
|
|
doc.setFont('helvetica', 'normal');
|
|
doc.setFontSize(9);
|
|
doc.setTextColor(55, 65, 81);
|
|
doc.text(textLines, margin + 8, y);
|
|
|
|
y += textHeight + 3.5;
|
|
});
|
|
|
|
y += 3;
|
|
};
|
|
|
|
// Draw Summary Section (Indigo style)
|
|
drawSection('Ringkasan Analisis', result.summary, [79, 70, 229], [224, 231, 255]);
|
|
|
|
// Draw Risks Section (Amber style)
|
|
drawSection('Risiko Operasional', result.risks, [217, 119, 6], [253, 243, 219]);
|
|
|
|
// Draw Actions Section (Emerald style)
|
|
drawSection('Tindakan & Rekomendasi', result.actions, [5, 150, 105], [209, 250, 229], true);
|
|
|
|
// Footer on all pages
|
|
const totalPages = doc.getNumberOfPages();
|
|
for (let i = 1; i <= totalPages; i++) {
|
|
doc.setPage(i);
|
|
doc.setFont('helvetica', 'normal');
|
|
doc.setFontSize(7.5);
|
|
doc.setTextColor(148, 163, 184); // Slate-400
|
|
|
|
// Footer text
|
|
const footerText = `Laporan AI Insights CP 707 | Halaman ${i} dari ${totalPages}`;
|
|
doc.text(footerText, margin, 285);
|
|
|
|
// Timestamp
|
|
doc.text(new Date().toLocaleString('id-ID'), pageWidth - margin - 35, 285);
|
|
}
|
|
|
|
// Save document
|
|
const filename = `AI_Insight_${topic}_Kandang_${kandangName.replace(/\s+/g, '_')}_Hari_${currentDayVal || '0'}.pdf`;
|
|
doc.save(filename);
|
|
};
|
|
|
|
const scfg = result ? STATUS_CFG[result.status] : null;
|
|
const StatusIcon = scfg?.Icon ?? Info;
|
|
|
|
return (
|
|
<div
|
|
className={`rounded-2xl border ${colors.ring} bg-gradient-to-br from-white via-slate-50 to-white shadow-md overflow-hidden mb-6`}
|
|
>
|
|
{/* Header */}
|
|
<div
|
|
className={`flex items-center justify-between px-5 py-3.5 border-b ${colors.ring} bg-white/70`}
|
|
>
|
|
<div className="flex items-center gap-2.5">
|
|
<div className={`p-1.5 ${colors.sparkBg} rounded-lg`}>
|
|
<Sparkles className={`w-4 h-4 ${colors.sparkText}`} />
|
|
</div>
|
|
<div>
|
|
<span className="text-sm font-bold text-gray-900">
|
|
{meta.emoji} {meta.title}
|
|
</span>
|
|
<p className="text-xs text-gray-400">Analisis berbasis kecerdasan buatan DeepSeek R1</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{result && (
|
|
<button
|
|
onClick={downloadPdf}
|
|
disabled={loading}
|
|
title="Unduh PDF"
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-slate-800 bg-slate-900 text-white hover:bg-slate-800 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed transition-all text-xs font-semibold shadow-sm"
|
|
>
|
|
<Download className="w-3.5 h-3.5" />
|
|
<span>Unduh PDF</span>
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => generate(true)}
|
|
disabled={loading}
|
|
title="Refresh insight"
|
|
className={`p-1.5 rounded-lg border ${colors.btnBorder} bg-white ${colors.btnHover} transition-all disabled:opacity-40 disabled:cursor-not-allowed`}
|
|
>
|
|
<RefreshCw
|
|
className={`w-3.5 h-3.5 ${colors.sparkText} ${loading ? 'animate-spin' : ''}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="p-4">
|
|
{loading && !result && (
|
|
<div className="flex items-center gap-3 py-6 justify-center">
|
|
<div
|
|
className={`w-8 h-8 rounded-full border-4 border-slate-100 border-t-current ${colors.sparkText} animate-spin`}
|
|
/>
|
|
<p className="text-sm text-gray-400 animate-pulse">
|
|
Menganalisis data dengan DeepSeek R1...
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{error && !result && (
|
|
<div className="flex items-start gap-2 bg-red-50 border border-red-100 rounded-xl p-3">
|
|
<AlertTriangle className="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
|
|
<div>
|
|
<p className="text-xs font-semibold text-red-600">Gagal memuat insight</p>
|
|
<p className="text-xs text-red-500 mt-0.5">{error}</p>
|
|
<button
|
|
onClick={() => generate(true)}
|
|
className="text-xs text-red-600 underline mt-1 hover:text-red-800"
|
|
>
|
|
Coba lagi
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!loading && !error && !result && (
|
|
<div className="flex items-center justify-center gap-2 py-6 text-gray-300 text-xs">
|
|
<div className="w-4 h-4 rounded-full border-2 border-gray-200 border-t-gray-400 animate-spin" />
|
|
<span>Memuat AI insight...</span>
|
|
</div>
|
|
)}
|
|
|
|
{result && scfg && (
|
|
<div className="space-y-3">
|
|
<div
|
|
className={`rounded-xl p-3 border ${scfg.bg} ${scfg.border} flex items-start gap-2.5`}
|
|
>
|
|
<StatusIcon className={`w-4 h-4 shrink-0 mt-0.5 ${scfg.text}`} />
|
|
<div className="flex-1">
|
|
<span
|
|
className={`text-xs font-bold uppercase tracking-wider px-2 py-0.5 rounded-full ${scfg.badge}`}
|
|
>
|
|
{scfg.label}
|
|
</span>
|
|
<p className={`mt-1 text-sm font-semibold ${scfg.text}`}>{result.headline}</p>
|
|
{loading && (
|
|
<p className="text-xs text-gray-400 mt-1 animate-pulse">Memperbarui...</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-2.5">
|
|
<div className="bg-white rounded-xl border border-gray-100 p-3 shadow-sm">
|
|
<p className="text-xs font-bold uppercase tracking-widest text-gray-400 mb-2">
|
|
📋 Ringkasan
|
|
</p>
|
|
<ul className="space-y-1.5">
|
|
{result.summary.map((s, i) => (
|
|
<li key={i} className="flex gap-1.5 text-xs text-gray-600">
|
|
<span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-slate-400 shrink-0" />
|
|
{s}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
|
|
<div className="bg-amber-50 rounded-xl border border-amber-100 p-3 shadow-sm">
|
|
<p className="text-xs font-bold uppercase tracking-widest text-amber-600 mb-2">
|
|
⚠️ Risiko
|
|
</p>
|
|
{result.risks.length > 0 ? (
|
|
<ul className="space-y-1.5">
|
|
{result.risks.map((r, i) => (
|
|
<li key={i} className="flex gap-1.5 text-xs text-amber-800">
|
|
<span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-amber-400 shrink-0" />
|
|
{r}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<p className="text-xs text-amber-500 italic">Tidak ada risiko signifikan</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="bg-emerald-50 rounded-xl border border-emerald-100 p-3 shadow-sm">
|
|
<p className="text-xs font-bold uppercase tracking-widest text-emerald-600 mb-2">
|
|
✅ Tindakan
|
|
</p>
|
|
<ol className="space-y-1.5">
|
|
{result.actions.map((a, i) => (
|
|
<li key={i} className="flex gap-1.5 text-xs text-emerald-800">
|
|
<span className="shrink-0 w-4 h-4 rounded-full bg-emerald-200 text-emerald-700 flex items-center justify-center font-bold text-[10px]">
|
|
{i + 1}
|
|
</span>
|
|
{a}
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default PageAiInsight;
|