first commit
CI / lint-and-test (push) Canceled after 0s

This commit is contained in:
Alberto-Audrix committed 2026-07-28 08:55:05 +07:00
commit 32a36cceff
444 files changed
+67186

No files matched your search

+64
View File
@@ -0,0 +1,64 @@
import React from 'react';
import CountBadge from './CountBadge.tsx';
const AccordionItem: React.FC<{
id: string;
title: string;
count: number;
icon: string;
level: 'location' | 'coop' | 'floor';
children?: React.ReactNode;
isOpen: boolean;
onToggle: (id: string) => void;
}> = ({ id, title, count, icon, level, children, isOpen, onToggle }) => {
const levelStyles = {
location: {
button: 'bg-gray-100 hover:bg-gray-200',
icon: 'text-gray-600',
title: 'text-lg font-bold text-gray-800',
},
coop: {
button: 'bg-white hover:bg-gray-50',
icon: 'text-gray-500',
title: 'font-semibold text-gray-700',
},
floor: {
button: 'bg-white',
icon: 'text-gray-500',
title: 'font-medium text-gray-700',
},
};
const styles = levelStyles[level];
return (
<div className={level === 'coop' ? 'pl-4' : ''}>
<div
className={`flex items-center justify-between p-3 rounded-md transition-colors ${styles.button}`}
>
<div className="flex items-center gap-4 flex-grow truncate">
<i className={`fa-solid ${icon} w-6 text-center text-xl ${styles.icon}`}></i>
<span className={`truncate ${styles.title}`} title={title}>
{title}
</span>
</div>
<div className="flex items-center gap-4 flex-shrink-0">
<CountBadge count={count} />
{children && (
<button
onClick={() => onToggle(id)}
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-300/50"
>
<i
className={`fa-solid fa-chevron-down transform transition-transform ${isOpen ? 'rotate-180' : ''}`}
></i>
</button>
)}
</div>
</div>
{isOpen && <div className="mt-2">{children}</div>}
</div>
);
};
export default AccordionItem;
+71
View File
@@ -0,0 +1,71 @@
import React from 'react';
import { LiveCount } from '../../types';
import { formatNumber } from '../../utils/formatters.ts';
const statusConfig: {
[key in LiveCount['status']]: { text: string; bg: string; pulse: boolean; icon: string };
} = {
online: { text: 'Menghitung', bg: 'bg-green-500', pulse: true, icon: 'fa-video' },
offline: { text: 'Offline', bg: 'bg-gray-500', pulse: false, icon: 'fa-video-slash' },
processing: { text: 'Memproses', bg: 'bg-blue-500', pulse: true, icon: 'fa-cogs' },
};
const CameraCountCard: React.FC<{ liveCount: LiveCount }> = ({ liveCount }) => {
const status = statusConfig[liveCount.status];
return (
<div className="bg-white rounded-xl shadow-sm transition-all duration-300 hover:shadow-lg flex flex-col border border-gray-200">
<div className="relative aspect-video bg-black rounded-t-xl flex items-center justify-center overflow-hidden group">
<img
src={liveCount.imageUrl}
alt={liveCount.cameraName}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-black/30"></div>
<div className="absolute top-2 left-2 flex items-center space-x-2">
<span
className={`flex items-center text-xs font-semibold text-white px-2 py-0.5 rounded-full ${status.bg}`}
>
{status.pulse && (
<span className="relative flex h-2 w-2 mr-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-white/75 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-white"></span>
</span>
)}
{status.text}
</span>
</div>
{liveCount.status === 'online' && (
<div className="absolute inset-0 flex flex-col items-center justify-center">
<p
className="text-white text-4xl font-bold drop-shadow-lg"
style={{ textShadow: '0 2px 4px rgba(0,0,0,0.5)' }}
>
{formatNumber(liveCount.count)}
</p>
<p className="text-white/80 text-sm font-semibold drop-shadow-md">ekor terdeteksi</p>
</div>
)}
{liveCount.status !== 'online' && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/50">
<i className={`fa-solid ${status.icon} text-4xl text-white/70`}></i>
</div>
)}
</div>
<div className="p-3">
<h3 className="font-bold text-gray-800 truncate" title={liveCount.cameraName}>
{liveCount.cameraName}
</h3>
<p className="text-xs text-gray-500 truncate" title={liveCount.locationName}>
<i className="fa-solid fa-map-marker-alt mr-1.5"></i>
{liveCount.locationName}
</p>
</div>
</div>
);
};
export default CameraCountCard;
@@ -0,0 +1,87 @@
import React from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { useAppContext } from '../../context/AppContext.tsx';
import { formatNumber, createYAxisFormatter } from '../../utils/formatters.ts';
interface ChickenCountDataPoint {
date: string;
count: number | null;
}
interface ChickenCountTrendChartProps {
data: ChickenCountDataPoint[];
}
const ChickenCountTrendChart: React.FC<ChickenCountTrendChartProps> = ({ data }) => {
const { apiMode: _apiMode } = useAppContext();
// In LIVE mode, show empty state if no data
if (data.length === 0) {
return (
<div className="bg-white p-6 rounded-xl shadow-sm">
<h2 className="text-xl font-bold mb-4">Tren Chicken Counting</h2>
<div className="h-60 flex items-center justify-center">
<div className="text-center text-gray-500">
<i className="fa-solid fa-chart-line text-5xl text-gray-300 mb-3"></i>
<p className="font-medium text-gray-600">Tidak ada data</p>
<p className="text-sm text-gray-500 mt-1">
Belum ada data chicken counting yang diinput
</p>
</div>
</div>
</div>
);
}
return (
<div className="bg-white p-6 rounded-xl shadow-sm">
<h2 className="text-xl font-bold mb-4">Tren Chicken Counting</h2>
<div className="h-60">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 5, right: 20, left: -15, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
<XAxis dataKey="date" fontSize={12} tick={{ fill: '#6b7280' }} />
<YAxis
fontSize={12}
tick={{ fill: '#6b7280' }}
domain={['dataMin - 50', 'dataMax + 50']}
tickFormatter={createYAxisFormatter()}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.9)',
border: '1px solid #ccc',
borderRadius: '8px',
}}
formatter={(value: number | null) => {
if (value === null || value === undefined)
return ['Tidak ada data', 'Chicken Count'];
return [formatNumber(value), 'Chicken Count'];
}}
/>
<Line
type="monotone"
dataKey="count"
stroke="#3b82f6"
strokeWidth={2.5}
name="Chicken Count"
dot={{ r: 4 }}
activeDot={{ r: 6 }}
connectNulls={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
);
};
export default ChickenCountTrendChart;
@@ -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 &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;
@@ -0,0 +1,365 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import CombinedTrendChart from './CombinedTrendChart.tsx';
import { useAppContext } from '../../context/AppContext.tsx';
import { apiClient } from '../../services/apiService.ts';
import { Kandang, MortalityRecord } from '../../types/counting.ts';
interface ChickenCountDataPoint {
date: string;
count: number | null;
}
interface PopulationDataPoint {
date: string;
population: number;
}
interface KandangPopulationData {
name: string;
data: PopulationDataPoint[];
color: string;
}
interface MortalitySourceRecord {
day: number;
mortalityCount?: number;
afkir?: number;
panen?: number;
population?: number;
chickenCount?: number;
}
const KANDANG_COLORS = [
'#ef4444',
'#3b82f6',
'#10b981',
'#f59e0b',
'#8b5cf6',
'#ec4899',
'#14b8a6',
'#f97316',
];
interface ChickenCountingDashboardProps {
selectedKandangId: number | null;
onKandangChange: (kandangId: number | null) => void;
kandangs: Kandang[];
}
const ChickenCountingDashboard: React.FC<ChickenCountingDashboardProps> = ({
selectedKandangId,
onKandangChange,
kandangs,
}) => {
const { cycleData } = useAppContext();
const [chickenCountHistory, setChickenCountHistory] = useState<ChickenCountDataPoint[]>([]);
const [populationTrendHistory, setPopulationTrendHistory] = useState<PopulationDataPoint[]>([]);
const [_perKandangData, setPerKandangData] = useState<KandangPopulationData[]>([]);
const [mortalityRecords, setMortalityRecords] = useState<MortalityRecord[]>([]);
const [perKandangMortalityRecords, setPerKandangMortalityRecords] = useState<
Map<number, MortalityRecord[]>
>(new Map());
const [iotCurrentPopulation, setIotCurrentPopulation] = useState<number | null>(null);
// Fetch latest IOT Chicken Counting data (for selected kandang or all kandangs)
useEffect(() => {
const fetchLatestChickenCount = async () => {
try {
// Calculate date range for fetching latest data
const today = new Date();
const startDate = new Date(cycleData.startDate);
startDate.setHours(0, 0, 0, 0);
today.setHours(23, 59, 59, 999);
const startDateStr = startDate.toISOString().split('T')[0];
const todayStr = today.toISOString().split('T')[0];
// Fetch chicken counting data for selected kandang or all kandangs
const chickenCounts = await apiClient.getChickenCounting(
startDateStr,
todayStr,
selectedKandangId || undefined // Filter by kandang if one is selected
);
if (chickenCounts && chickenCounts.length > 0) {
// Get the most recent chicken count
const sortedCounts = [...chickenCounts].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
);
const latestCount = sortedCounts[0].totalCount;
setIotCurrentPopulation(latestCount);
} else {
// No IOT data available
setIotCurrentPopulation(null);
}
} catch (error) {
console.error('Failed to fetch latest chicken counting data:', error);
setIotCurrentPopulation(null);
}
};
fetchLatestChickenCount();
// Refresh IOT data every 2 minutes
const interval = setInterval(fetchLatestChickenCount, 2 * 60 * 1000);
return () => clearInterval(interval);
}, [cycleData.startDate, selectedKandangId, kandangs]);
// Total initial population across all kandangs
const totalInitialPopulation = useMemo(() => {
const total = kandangs.reduce((sum, k) => sum + (k.docInCount || 0), 0);
return total > 0 ? total : cycleData.docInCount || 20000;
}, [kandangs, cycleData]);
// Calculate statistics for the 5 boxes based on selected kandang
const stats = useMemo(() => {
let rawRecords: MortalitySourceRecord[];
let initialPop: number;
if (selectedKandangId === null) {
// Show all kandangs combined
rawRecords = mortalityRecords;
initialPop = totalInitialPopulation;
} else {
// Show specific kandang
rawRecords = perKandangMortalityRecords.get(selectedKandangId) || [];
const selectedKandang = kandangs.find((k) => k.id === selectedKandangId);
initialPop = selectedKandang?.docInCount || 0;
}
// Calculate cumulative mortality and stock from raw records (same logic as DataManagementPage)
let cumulativeMortality = 0;
let cumulativeAfkir = 0;
let cumulativePanen = 0;
rawRecords.forEach((record) => {
cumulativeMortality += record.mortalityCount || 0;
cumulativeAfkir += record.afkir || 0;
cumulativePanen += record.panen || 0;
});
const totalMortality = cumulativeMortality + cumulativeAfkir;
const totalPanen = cumulativePanen;
const mortalityRate =
initialPop > 0 ? ((totalMortality / initialPop) * 100).toFixed(2) : '0.00';
// Use IOT Chicken Counting data for all views (Semua Kandang or individual kandang)
// Fall back to calculated population only when IOT data is unavailable
let currentPopulation: number;
if (iotCurrentPopulation !== null) {
// Use latest IOT chicken count detection
currentPopulation = iotCurrentPopulation;
} else {
// Calculate from mortality records when IOT data unavailable
currentPopulation = initialPop - cumulativeMortality - cumulativeAfkir - cumulativePanen;
}
return { totalMortality, currentPopulation, totalPanen, mortalityRate, initialPop };
}, [
mortalityRecords,
totalInitialPopulation,
selectedKandangId,
perKandangMortalityRecords,
kandangs,
iotCurrentPopulation,
]);
// Helper: build population data from records
const buildPopulationData = useCallback(
(records: MortalitySourceRecord[], initPop: number) => {
const chickenCounts: ChickenCountDataPoint[] = [];
const populations: PopulationDataPoint[] = [];
const sortedRecords = [...records].sort((a, b) => a.day - b.day);
const today = new Date();
const startDate = new Date(cycleData.startDate);
today.setHours(0, 0, 0, 0);
startDate.setHours(0, 0, 0, 0);
const currentDayOfCycle = Math.floor(
(today.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)
);
const recordMap = new Map<number, MortalitySourceRecord>();
sortedRecords.forEach((record) => {
recordMap.set(record.day, record);
});
let cumulativeMortality = 0;
for (let day = 0; day <= currentDayOfCycle; day++) {
const date = new Date(cycleData.startDate);
date.setDate(date.getDate() + day);
const formattedDate = date.toLocaleDateString('id-ID', { day: '2-digit', month: 'short' });
const record = recordMap.get(day);
if (record && record.chickenCount) {
chickenCounts.push({ date: formattedDate, count: record.chickenCount });
} else {
chickenCounts.push({ date: formattedDate, count: null });
}
if (record) {
cumulativeMortality += (record.mortalityCount || 0) + (record.afkir || 0);
}
const calculatedPopulation = initPop - cumulativeMortality;
const populationValue = record?.population || calculatedPopulation;
populations.push({ date: formattedDate, population: populationValue });
}
return { chickenCounts, populations };
},
[cycleData.startDate]
);
// Fetch all data: total + per-kandang
useEffect(() => {
const fetchData = async () => {
try {
// Total across all kandangs - use getMortalityTotals for aggregated data
const allRecords = await apiClient.getMortalityTotals(cycleData.id);
setMortalityRecords(allRecords); // Store for statistics calculation
const { chickenCounts, populations } = buildPopulationData(
allRecords,
totalInitialPopulation
);
setChickenCountHistory(chickenCounts);
setPopulationTrendHistory(populations);
// Per-kandang data
if (kandangs.length > 0) {
const perKandang: KandangPopulationData[] = [];
const mortalityMap = new Map<number, MortalityRecord[]>();
for (let i = 0; i < kandangs.length; i++) {
const k = kandangs[i];
if (!k) continue;
try {
const kRecords = await apiClient.getMortalityRecords(cycleData.id, k.id);
mortalityMap.set(k.id, kRecords); // Store mortality records for each kandang
const kInitPop = k.docInCount || 0;
const { populations: kPop } = buildPopulationData(kRecords, kInitPop);
perKandang.push({
name: k.name || `Kandang ${i + 1}`,
data: kPop,
color: KANDANG_COLORS[i % KANDANG_COLORS.length] || '#ef4444',
});
} catch {
// Skip if error fetching individual kandang
}
}
setPerKandangData(perKandang);
setPerKandangMortalityRecords(mortalityMap);
} else {
setPerKandangData([]);
setPerKandangMortalityRecords(new Map());
}
} catch (error) {
console.error('Failed to fetch mortality records:', error);
}
};
fetchData();
}, [cycleData, kandangs, totalInitialPopulation, buildPopulationData]);
return (
<div className="bg-white rounded-xl shadow-sm p-6">
{/* Header */}
<div className="flex items-center mb-6">
<i className="fa-solid fa-calculator text-3xl text-red-600 mr-4"></i>
<div>
<h2 className="text-2xl font-bold text-gray-800">Dashboard Penghitungan Ayam</h2>
<p className="text-sm text-gray-600">
Pemantauan populasi dari berbagai kandang secara real-time
</p>
</div>
</div>
{/* Statistics Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-6">
{/* Populasi Awal */}
<div className="bg-white rounded-xl shadow-sm p-4 border-l-4 border-blue-500">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-gray-600 mb-1">Populasi Awal</p>
<p className="text-xl font-bold text-gray-800">{stats.initialPop.toLocaleString()}</p>
</div>
<i className="fa-solid fa-egg text-blue-500 text-2xl"></i>
</div>
</div>
{/* Populasi Saat Ini */}
<div className="bg-white rounded-xl shadow-sm p-4 border-l-4 border-green-500">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-gray-600 mb-1">Populasi Saat Ini</p>
<p className="text-xl font-bold text-green-600">
{stats.currentPopulation.toLocaleString()}
</p>
</div>
<i className="fa-solid fa-drumstick-bite text-green-500 text-2xl"></i>
</div>
</div>
{/* Total Mortalitas */}
<div className="bg-white rounded-xl shadow-sm p-4 border-l-4 border-red-500">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-gray-600 mb-1">Total Mortalitas</p>
<p className="text-xl font-bold text-red-600">
{stats.totalMortality.toLocaleString()}
</p>
</div>
<i className="fa-solid fa-heart-crack text-red-500 text-2xl"></i>
</div>
</div>
{/* Total Panen */}
<div className="bg-white rounded-xl shadow-sm p-4 border-l-4 border-purple-500">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-gray-600 mb-1">Total Panen</p>
<p className="text-xl font-bold text-purple-700">
{stats.totalPanen.toLocaleString()}
</p>
</div>
<i className="fa-solid fa-box text-purple-500 text-2xl"></i>
</div>
</div>
{/* Tingkat Mortalitas */}
<div className="bg-white rounded-xl shadow-sm p-4 border-l-4 border-orange-500">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-gray-600 mb-1">Tingkat Mortalitas</p>
<p className="text-xl font-bold text-orange-600">{stats.mortalityRate}%</p>
</div>
<i className="fa-solid fa-chart-line text-orange-500 text-2xl"></i>
</div>
</div>
</div>
{/* Chart */}
<CombinedTrendChart
populationData={populationTrendHistory}
chickenCountData={chickenCountHistory}
kandangs={kandangs.map((k) => ({
id: k.id,
name: k.name || '',
docInCount: k.docInCount || 0,
}))}
cycleData={cycleData}
apiClient={apiClient}
selectedKandangId={selectedKandangId}
onKandangChange={onKandangChange}
hideKandangSelector={true}
/>
</div>
);
};
export default ChickenCountingDashboard;
+353
View File
@@ -0,0 +1,353 @@
import React, { useState, useMemo } from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import { formatNumber, createYAxisFormatter } from '../../utils/formatters.ts';
interface PopulationDataPoint {
date: string;
population: number;
}
interface ChickenCountDataPoint {
date: string;
count: number | null;
}
interface KandangData {
id: number;
name: string;
docInCount: number;
}
interface CombinedTrendChartProps {
populationData: PopulationDataPoint[];
chickenCountData: ChickenCountDataPoint[];
kandangs: KandangData[];
cycleData: any;
apiClient: any;
selectedKandangId?: number | null;
onKandangChange?: (kandangId: number | null) => void;
hideKandangSelector?: boolean;
}
const KANDANG_COLORS = [
'#ef4444',
'#3b82f6',
'#10b981',
'#f59e0b',
'#8b5cf6',
'#ec4899',
'#14b8a6',
'#f97316',
];
const CombinedTrendChart: React.FC<CombinedTrendChartProps> = ({
populationData,
chickenCountData,
kandangs,
cycleData,
apiClient,
selectedKandangId: externalSelectedKandangId,
onKandangChange,
hideKandangSelector = false,
}) => {
const [internalSelectedKandangId, setInternalSelectedKandangId] = useState<number | null>(null);
// Use external state if provided, otherwise use internal state
const selectedKandangId =
externalSelectedKandangId !== undefined ? externalSelectedKandangId : internalSelectedKandangId;
const _setSelectedKandangId = onKandangChange || setInternalSelectedKandangId;
const [filteredPopulation, setFilteredPopulation] =
useState<PopulationDataPoint[]>(populationData);
const [filteredChickenCount, setFilteredChickenCount] =
useState<ChickenCountDataPoint[]>(chickenCountData);
const [loading, setLoading] = useState(false);
// Helper: build population and chicken count data from records
const buildChartData = async (records: any[], initPop: number, kandangId: number | null) => {
const populations: PopulationDataPoint[] = [];
const chickenCounts: ChickenCountDataPoint[] = [];
const sortedRecords = [...records].sort((a: any, b: any) => a.day - b.day);
const today = new Date();
const startDate = new Date(cycleData.startDate);
today.setHours(0, 0, 0, 0);
startDate.setHours(0, 0, 0, 0);
const startDateMs = startDate.getTime();
const currentDayOfCycle = Math.floor((today.getTime() - startDateMs) / (1000 * 60 * 60 * 24));
const recordMap = new Map();
sortedRecords.forEach((record: any) => {
recordMap.set(record.day, record);
});
// Fetch chicken counting data from IoT table
const chickenCountMap: Record<number, number> = {};
try {
const endDate = new Date(startDate);
endDate.setDate(endDate.getDate() + currentDayOfCycle);
const startDateStr = new Date(startDate).toISOString().split('T')[0];
const endDateStr = endDate.toISOString().split('T')[0];
const chickenCountData = await apiClient.getChickenCounting(
startDateStr,
endDateStr,
kandangId || undefined
);
chickenCountData.forEach((cc: any) => {
// Parse date string in local timezone to avoid off-by-one errors
const [year, month, day] = cc.date.split('T')[0].split('-').map(Number);
const ccDate = new Date(year, month - 1, day);
ccDate.setHours(0, 0, 0, 0);
const dayNum = Math.floor((ccDate.getTime() - startDateMs) / (1000 * 60 * 60 * 24));
if (dayNum >= 0 && dayNum <= currentDayOfCycle) {
chickenCountMap[dayNum] = (chickenCountMap[dayNum] || 0) + cc.totalCount;
}
});
} catch (err) {
console.warn('Failed to load chicken counting data:', err);
}
let previousStockAkhir = 0;
for (let day = 0; day <= currentDayOfCycle; day++) {
const date = new Date(cycleData.startDate);
date.setDate(date.getDate() + day);
const formattedDate = date.toLocaleDateString('id-ID', { day: '2-digit', month: 'short' });
const record = recordMap.get(day);
const mati = record?.mortalityCount || 0;
const afkir = record?.afkir || 0;
const panen = record?.panen || 0;
// Stock calculation
const stockAwal = day === 0 ? initPop : previousStockAkhir;
const stockAkhir = stockAwal - mati - afkir - panen;
populations.push({ date: formattedDate, population: stockAkhir });
chickenCounts.push({
date: formattedDate,
count: chickenCountMap[day] !== undefined ? chickenCountMap[day]! : null,
});
previousStockAkhir = stockAkhir;
}
return { populations, chickenCounts };
};
// Handle kandang selection
const handleKandangChange = async (kandangId: number | null) => {
if (onKandangChange) {
onKandangChange(kandangId);
} else {
setInternalSelectedKandangId(kandangId);
}
setLoading(true);
try {
if (kandangId === null) {
// "Semua Kandang" - use totals
const allRecords = await apiClient.getMortalityTotals(cycleData.id);
const totalInitPop = kandangs.reduce((sum, k) => sum + (k.docInCount || 0), 0);
const { populations, chickenCounts } = await buildChartData(allRecords, totalInitPop, null);
setFilteredPopulation(populations);
setFilteredChickenCount(chickenCounts);
} else {
// Single kandang
const kandangRecords = await apiClient.getMortalityRecords(cycleData.id, kandangId);
const kandang = kandangs.find((k) => k.id === kandangId);
const initPop = kandang?.docInCount || 0;
const { populations, chickenCounts } = await buildChartData(
kandangRecords,
initPop,
kandangId
);
setFilteredPopulation(populations);
setFilteredChickenCount(chickenCounts);
}
} catch (error) {
console.error('Failed to fetch kandang data:', error);
} finally {
setLoading(false);
}
};
// Fetch proper data when kandangs are ready
React.useEffect(() => {
if (kandangs.length > 0 && selectedKandangId === null) {
// Trigger initial data fetch to get chicken counting from IoT API
handleKandangChange(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [kandangs.length]);
// Sync chart data when external selectedKandangId changes
React.useEffect(() => {
if (externalSelectedKandangId !== undefined && kandangs.length > 0) {
handleKandangChange(externalSelectedKandangId);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [externalSelectedKandangId]);
// Merge population and chicken count data for chart
const mergedData = useMemo(() => {
return filteredPopulation.map((popPoint, index) => {
const countPoint = filteredChickenCount[index];
return {
date: popPoint.date,
population: popPoint.population,
chickenCount: countPoint?.count !== null ? countPoint?.count : undefined,
};
});
}, [filteredPopulation, filteredChickenCount]);
// Calculate domain for logarithmic scale
const yAxisDomain = useMemo(() => {
const allValues: number[] = [];
mergedData.forEach((point) => {
if (point.population && point.population > 0) allValues.push(point.population);
if (point.chickenCount && point.chickenCount > 0) allValues.push(point.chickenCount);
});
if (allValues.length === 0) return [1, 100000];
const dataMin = Math.min(...allValues);
const dataMax = Math.max(...allValues);
// For log scale, use multipliers instead of additive padding
const domainMin = Math.max(1, Math.floor(dataMin * 0.7));
const domainMax = Math.ceil(dataMax * 1.3);
return [domainMin, domainMax];
}, [mergedData]);
// Show empty state if no data
if (populationData.length === 0 && chickenCountData.length === 0) {
return (
<div className="bg-white p-6 rounded-xl shadow-sm">
<h2 className="text-xl font-bold mb-4">Tren Populasi & Chicken Counting</h2>
<div className="h-60 flex items-center justify-center">
<div className="text-center text-gray-500">
<i className="fa-solid fa-chart-line text-5xl text-gray-300 mb-3"></i>
<p className="font-medium text-gray-600">Tidak ada data</p>
<p className="text-sm text-gray-500 mt-1">Belum ada data yang tersedia</p>
</div>
</div>
</div>
);
}
return (
<div className="bg-white p-6 rounded-xl shadow-sm">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold">Tren Populasi & Chicken Counting</h2>
{/* Kandang filter buttons */}
{!hideKandangSelector && kandangs.length > 0 && (
<div className="flex gap-2 flex-wrap">
<button
onClick={() => handleKandangChange(null)}
disabled={loading}
className={`px-4 py-2 rounded-lg font-medium text-sm transition-colors ${
selectedKandangId === null
? 'bg-red-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
} ${loading ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<i className="fa-solid fa-layer-group mr-2"></i>
Semua Kandang
</button>
{kandangs.map((k, i) => (
<button
key={k.id}
onClick={() => handleKandangChange(k.id)}
disabled={loading}
className={`px-4 py-2 rounded-lg font-medium text-sm transition-colors ${
selectedKandangId === k.id
? 'text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
} ${loading ? 'opacity-50 cursor-not-allowed' : ''}`}
style={
selectedKandangId === k.id
? {
backgroundColor: KANDANG_COLORS[i % KANDANG_COLORS.length],
}
: {}
}
>
<i className="fa-solid fa-warehouse mr-2"></i>
{k.name}
</button>
))}
</div>
)}
</div>
{loading ? (
<div className="h-80 flex items-center justify-center">
<i className="fa-solid fa-spinner fa-spin text-4xl text-gray-400"></i>
</div>
) : (
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={mergedData} margin={{ top: 5, right: 20, left: -15, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
<XAxis dataKey="date" fontSize={12} tick={{ fill: '#6b7280' }} />
<YAxis
fontSize={12}
tick={{ fill: '#6b7280' }}
scale="log"
domain={yAxisDomain}
tickFormatter={createYAxisFormatter()}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.9)',
border: '1px solid #ccc',
borderRadius: '8px',
}}
formatter={(value: any, name: string) => {
if (value === null || value === undefined) return ['Tidak ada data', name];
return [formatNumber(value as number), name];
}}
/>
<Legend />
<Line
type="monotone"
dataKey="population"
stroke="#ef4444"
strokeWidth={2.5}
name="Populasi (Stock Akhir)"
dot={{ r: 4 }}
activeDot={{ r: 6 }}
/>
<Line
type="monotone"
dataKey="chickenCount"
stroke="#3b82f6"
strokeWidth={2.5}
name="Chicken Counting (IOT)"
dot={{ r: 4 }}
activeDot={{ r: 6 }}
connectNulls={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
</div>
);
};
export default CombinedTrendChart;
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import { formatNumber } from '../../utils/formatters.ts';
const CountBadge: React.FC<{ count: number }> = ({ count }) => (
<span className="text-sm font-bold text-red-600 bg-red-100 px-3 py-1 rounded-full">
{formatNumber(count)}
</span>
);
export default CountBadge;
+121
View File
@@ -0,0 +1,121 @@
import React, { useMemo, useEffect, useState } from 'react';
import { LiveCount } from '../../types.ts';
import { CalculatorIcon } from '../icons/Icons.tsx';
import { useAppContext } from '../../context/AppContext.tsx';
import { apiClient } from '../../services/apiService.ts';
import { formatNumber } from '../../utils/formatters.ts';
const StatItem: React.FC<{
icon: React.ReactNode;
label: string;
value: string;
valueColor?: string;
}> = ({ icon, label, value, valueColor = 'text-gray-900' }) => (
<div className="flex items-center">
<div className="text-xl text-gray-500 w-8">{icon}</div>
<div>
<p className="text-sm text-gray-600">{label}</p>
<p className={`text-lg font-bold ${valueColor}`}>{value}</p>
</div>
</div>
);
interface CountingStatsProps {
liveCounts: LiveCount[];
kandangId?: number | null;
initialPop?: number;
}
const CountingStats: React.FC<CountingStatsProps> = ({ liveCounts, kandangId, initialPop }) => {
const { cycleData, apiMode } = useAppContext();
const [currentPopulation, setCurrentPopulation] = useState<number | null>(null);
// Fetch current population from mortality data
useEffect(() => {
const fetchPopulation = async () => {
try {
const dbRecords = await apiClient.getMortalityRecords(cycleData.id, kandangId ?? undefined);
const initPop = initialPop ?? cycleData.docInCount ?? 20000;
// Calculate total mortality
const totalMortality = dbRecords.reduce(
(sum: number, record: { mortalityCount?: number; afkir?: number }) =>
sum + (record.mortalityCount || 0) + (record.afkir || 0),
0
);
const population = initPop - totalMortality;
setCurrentPopulation(population);
} catch (error) {
console.error('Failed to fetch population:', error);
setCurrentPopulation(null);
}
};
if (apiMode === 'LIVE') {
fetchPopulation();
}
}, [cycleData, apiMode, kandangId, initialPop]);
const { totalCount, activeCameras } = useMemo(() => {
const onlineCams = liveCounts.filter((cam) => cam.status === 'online');
const total = onlineCams.reduce((sum, cam) => sum + cam.count, 0);
return {
totalCount: total,
activeCameras: onlineCams.length,
};
}, [liveCounts]);
const totalCameras = liveCounts.length;
// In LIVE mode, use real population data and show N/A for unimplemented features
const displayTotalCount =
apiMode === 'LIVE' && currentPopulation !== null
? formatNumber(currentPopulation)
: apiMode === 'DEMO'
? formatNumber(totalCount)
: 'N/A';
const displayCameras = apiMode === 'DEMO' ? `${activeCameras} / ${totalCameras}` : 'N/A';
const displayAccuracy = apiMode === 'DEMO' ? '98.2%' : 'N/A';
const displayLastUpdate = apiMode === 'DEMO' ? 'Baru saja' : 'N/A';
return (
<div className="bg-white p-6 rounded-xl shadow-sm">
<h2 className="text-xl font-bold mb-4">Statistik Agregat</h2>
<div className="space-y-4">
<StatItem
icon={<CalculatorIcon />}
label="Total Populasi Terhitung"
value={`${displayTotalCount} ekor`}
valueColor={apiMode === 'LIVE' ? 'text-red-600' : 'text-red-600'}
/>
<StatItem
icon={<i className="fa-solid fa-video"></i>}
label="Kamera Aktif (Menghitung)"
value={displayCameras}
valueColor={
apiMode === 'DEMO' && activeCameras === totalCameras
? 'text-green-600'
: 'text-gray-500'
}
/>
<StatItem
icon={<i className="fa-solid fa-crosshairs"></i>}
label="Akurasi Model AI (Avg)"
value={displayAccuracy}
valueColor={apiMode === 'DEMO' ? 'text-green-600' : 'text-gray-500'}
/>
<StatItem
icon={<i className="fa-solid fa-clock"></i>}
label="Pembaruan Terakhir"
value={displayLastUpdate}
valueColor="text-gray-500"
/>
</div>
</div>
);
};
export default CountingStats;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,358 @@
import React, { useState, useEffect } from 'react';
import { useAppContext } from '../../context/AppContext.tsx';
import { apiClient } from '../../services/apiService.ts';
import { Kandang } from '../../types/counting.ts';
const KandangManagementPage: React.FC = () => {
const { cycleData } = useAppContext();
const [kandangs, setKandangs] = useState<Kandang[]>([]);
const [newName, setNewName] = useState('');
const [editingId, setEditingId] = useState<number | null>(null);
const [editName, setEditName] = useState('');
const [editDocInCount, setEditDocInCount] = useState('');
const [isAdding, setIsAdding] = useState(false);
const [newDocInCount, setNewDocInCount] = useState('');
const loadKandangs = async () => {
try {
const data = await apiClient.getKandangsByCycle(cycleData.id);
setKandangs(data);
} catch (error) {
console.error('Failed to load kandangs:', error);
}
};
useEffect(() => {
loadKandangs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cycleData]);
const handleAdd = async () => {
if (!newName.trim()) return;
try {
const kandang = await apiClient.createKandang(newName.trim());
const docCount = parseInt(newDocInCount, 10);
if (!isNaN(docCount) && docCount > 0) {
await apiClient.setKandangDocInCount(kandang.id, cycleData.id, docCount);
}
setNewName('');
setNewDocInCount('');
setIsAdding(false);
loadKandangs();
} catch (error) {
console.error('Failed to create kandang:', error);
alert('Gagal membuat kandang');
}
};
const handleStartEdit = (kandang: Kandang) => {
setEditingId(kandang.id);
setEditName(kandang.name);
setEditDocInCount((kandang.docInCount || 0).toString());
};
const handleSaveEdit = async () => {
if (editingId === null || !editName.trim()) return;
try {
await apiClient.updateKandang(editingId, editName.trim());
const docCount = parseInt(editDocInCount, 10);
if (!isNaN(docCount) && docCount >= 0) {
await apiClient.setKandangDocInCount(editingId, cycleData.id, docCount);
}
setEditingId(null);
loadKandangs();
} catch (error) {
console.error('Failed to update kandang:', error);
alert('Gagal mengupdate kandang');
}
};
const handleDelete = async (id: number, name: string) => {
if (!confirm(`Hapus "${name}"? Data mortalitas terkait juga akan terpengaruh.`)) return;
try {
await apiClient.deleteKandang(id);
loadKandangs();
} catch (error) {
console.error('Failed to delete kandang:', error);
alert('Gagal menghapus kandang');
}
};
const totalDocInCount = kandangs.reduce((sum, k) => sum + (k.docInCount || 0), 0);
return (
<main className="px-4 sm:px-6 lg:px-8 pb-8">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-800">Manajemen Kandang</h1>
<p className="mt-1 text-gray-600">
Kelola kandang dan populasi awal (DOC In) untuk setiap siklus produksi
</p>
</div>
{/* Current Cycle Indicator */}
<div className="mb-6 bg-gradient-to-r from-red-50 to-orange-50 border-l-4 border-red-600 rounded-xl p-4">
<div className="flex items-center gap-3">
<div className="bg-red-600 text-white rounded-full w-10 h-10 flex items-center justify-center">
<i className="fa-solid fa-calendar-day"></i>
</div>
<div>
<p className="text-sm font-medium text-gray-600">Siklus Produksi Saat Ini</p>
<p className="text-lg font-bold text-gray-900">
{cycleData.id}{' '}
<span className="text-sm font-normal text-gray-600">
(Hari {cycleData.currentDay} dari {cycleData.totalDays})
</span>
</p>
</div>
</div>
</div>
{/* Summary Card */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="bg-white rounded-xl shadow-sm p-6 border-l-4 border-blue-500">
<p className="text-sm text-gray-600 mb-1">Jumlah Kandang</p>
<p className="text-2xl font-bold text-gray-800">{kandangs.length}</p>
</div>
<div className="bg-white rounded-xl shadow-sm p-6 border-l-4 border-green-500">
<p className="text-sm text-gray-600 mb-1">
Total DOC In <span className="text-xs">(Siklus {cycleData.id})</span>
</p>
<p className="text-2xl font-bold text-green-600">
{totalDocInCount.toLocaleString()}{' '}
<span className="text-sm font-normal text-gray-500">ekor</span>
</p>
</div>
<div className="bg-white rounded-xl shadow-sm p-6 border-l-4 border-orange-500">
<p className="text-sm text-gray-600 mb-1">Rata-rata per Kandang</p>
<p className="text-2xl font-bold text-orange-600">
{kandangs.length > 0
? Math.floor(totalDocInCount / kandangs.length).toLocaleString()
: 0}{' '}
<span className="text-sm font-normal text-gray-500">ekor</span>
</p>
</div>
</div>
{/* Add button */}
{!isAdding && (
<div className="mb-6">
<button
onClick={() => setIsAdding(true)}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium"
>
<i className="fa-solid fa-plus mr-2"></i>
Tambah Kandang
</button>
</div>
)}
{/* Add form */}
{isAdding && (
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
<h3 className="text-lg font-semibold text-gray-800 mb-4">Tambah Kandang Baru</h3>
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">Nama Kandang</label>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Contoh: Kandang 2"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500"
autoFocus
/>
</div>
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">
DOC In (Siklus {cycleData.id})
</label>
<input
type="number"
value={newDocInCount}
onChange={(e) => setNewDocInCount(e.target.value)}
placeholder="Contoh: 10000"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500"
/>
<p className="text-xs text-gray-500 mt-1">
<i className="fa-solid fa-info-circle mr-1"></i>
Nilai ini khusus untuk siklus produksi ini
</p>
</div>
<div className="flex items-end gap-2">
<button
onClick={handleAdd}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium"
>
<i className="fa-solid fa-check mr-1"></i> Simpan
</button>
<button
onClick={() => {
setIsAdding(false);
setNewName('');
setNewDocInCount('');
}}
className="px-4 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors font-medium"
>
Batal
</button>
</div>
</div>
</div>
)}
{/* Kandang Table */}
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider">
#
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider">
Nama Kandang
</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase tracking-wider">
DOC (Siklus Aktif)
</th>
<th className="px-6 py-4 text-center text-xs font-semibold text-gray-700 uppercase tracking-wider">
Aksi
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{kandangs.length === 0 ? (
<tr>
<td colSpan={4} className="px-6 py-12 text-center text-gray-500">
<i className="fa-solid fa-warehouse text-4xl mb-3 block text-gray-300"></i>
Belum ada kandang. Tambahkan kandang baru untuk mulai.
</td>
</tr>
) : (
kandangs.map((kandang, index) => (
<tr key={kandang.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-red-100 text-red-800 font-bold text-sm">
{index + 1}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{editingId === kandang.id ? (
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="px-3 py-1 border border-blue-500 rounded-lg focus:ring-2 focus:ring-blue-500"
autoFocus
/>
) : (
<span className="text-lg font-semibold text-gray-800">{kandang.name}</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
{editingId === kandang.id ? (
<input
type="number"
value={editDocInCount}
onChange={(e) => setEditDocInCount(e.target.value)}
className="w-32 px-3 py-1 border border-blue-500 rounded-lg text-right focus:ring-2 focus:ring-blue-500"
/>
) : (
<span className="text-lg font-semibold text-green-600">
{(kandang.docInCount || 0).toLocaleString()}
<span className="text-xs ml-1 text-gray-500">ekor</span>
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
{editingId === kandang.id ? (
<div className="flex justify-center gap-2">
<button
onClick={handleSaveEdit}
className="px-3 py-1 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors text-sm font-medium"
>
<i className="fa-solid fa-check"></i>
</button>
<button
onClick={() => setEditingId(null)}
className="px-3 py-1 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors text-sm font-medium"
>
<i className="fa-solid fa-times"></i>
</button>
</div>
) : (
<div className="flex justify-center gap-2">
<button
onClick={() => handleStartEdit(kandang)}
className="px-3 py-1 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors text-sm font-medium"
>
<i className="fa-solid fa-edit mr-1"></i> Edit
</button>
<button
onClick={() => handleDelete(kandang.id, kandang.name)}
className="px-3 py-1 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors text-sm font-medium"
>
<i className="fa-solid fa-trash"></i>
</button>
</div>
)}
</td>
</tr>
))
)}
</tbody>
{kandangs.length > 0 && (
<tfoot className="bg-gray-50 border-t-2 border-gray-300">
<tr>
<td className="px-6 py-4" colSpan={2}>
<span className="font-bold text-gray-700">Total</span>
</td>
<td className="px-6 py-4 text-right">
<span className="text-lg font-bold text-gray-800">
{totalDocInCount.toLocaleString()}
<span className="text-xs ml-1 text-gray-500">ekor</span>
</span>
</td>
<td></td>
</tr>
</tfoot>
)}
</table>
</div>
</div>
{/* Info */}
<div className="mt-6 bg-blue-50 border-l-4 border-blue-500 rounded-lg p-6">
<div className="flex items-start">
<div className="flex-shrink-0">
<i className="fa-solid fa-info-circle text-blue-500 text-2xl"></i>
</div>
<div className="ml-4">
<h3 className="text-sm font-semibold text-blue-800 mb-2">Informasi Penting</h3>
<ul className="text-sm text-blue-700 space-y-1">
<li>
<i className="fa-solid fa-circle text-xs mr-2"></i>
<strong>DOC In Count diatur per kandang untuk setiap siklus</strong> - ketika
membuat siklus baru, Anda perlu mengatur ulang DOC In untuk semua kandang
</li>
<li>
<i className="fa-solid fa-circle text-xs mr-2"></i>Data mortalitas di "Manajemen
Data" akan dipisahkan per kandang
</li>
<li>
<i className="fa-solid fa-circle text-xs mr-2"></i>Dashboard akan menampilkan data
per kandang dan total gabungan
</li>
<li>
<i className="fa-solid fa-circle text-xs mr-2"></i>Menghapus kandang akan
mempengaruhi semua data historis yang terkait
</li>
</ul>
</div>
</div>
</div>
</main>
);
};
export default KandangManagementPage;
Whitespace-only changes.
@@ -0,0 +1,146 @@
import React, { useState } from 'react';
import { useAppContext } from '../../context/AppContext.tsx';
import CameraCountCard from './CameraCountCard.tsx';
import { VideoIcon } from '../icons/Icons.tsx';
import AccordionItem from './AccordionItem.tsx';
const LocationCountBreakdown: React.FC = () => {
const { aggregatedCounts } = useAppContext();
const [openAccordions, setOpenAccordions] = useState<Record<string, boolean>>(() => {
const initialState: Record<string, boolean> = {};
if (aggregatedCounts.length > 0) {
const firstLocation = aggregatedCounts[0];
initialState[firstLocation.locationId] = true;
if (firstLocation.coops.length > 0) {
const firstCoop = firstLocation.coops[0];
initialState[firstCoop.coopId] = true;
// Open all floors within that first coop by default as they don't have sub-items
firstCoop.floors.forEach((floor) => {
initialState[floor.floorId] = true;
});
}
}
return initialState;
});
const handleToggle = (id: string) => {
setOpenAccordions((prev) => ({ ...prev, [id]: !prev[id] }));
};
const expandAll = () => {
const allIds: Record<string, boolean> = {};
aggregatedCounts.forEach((location) => {
allIds[location.locationId] = true;
location.coops.forEach((coop) => {
allIds[coop.coopId] = true;
coop.floors.forEach((floor) => {
allIds[floor.floorId] = true;
});
});
});
setOpenAccordions(allIds);
};
const collapseAll = () => {
setOpenAccordions({});
};
if (aggregatedCounts.length === 0) {
return (
<div className="bg-white p-6 rounded-xl shadow-sm h-full">
<h2 className="text-xl font-bold mb-4">Rincian Populasi per Lokasi</h2>
<div className="text-center py-16 border-dashed border-2 rounded-lg mt-4">
<VideoIcon className="text-6xl text-gray-300 mx-auto" />
<h3 className="mt-4 text-xl font-semibold text-gray-700">Tidak Ada Data Penghitungan</h3>
<p className="mt-1 text-gray-500">
Tidak ada kamera online yang melaporkan data penghitungan saat ini.
</p>
</div>
</div>
);
}
return (
<div className="bg-white p-6 rounded-xl shadow-sm h-full flex flex-col">
<div className="flex justify-between items-center mb-4 flex-shrink-0">
<h2 className="text-xl font-bold">Rincian Populasi per Lokasi</h2>
<div className="flex items-center space-x-2">
<button
onClick={expandAll}
className="flex items-center space-x-2 text-sm text-gray-600 font-medium px-3 py-1.5 rounded-md hover:bg-gray-100 transition-colors"
title="Buka Semua"
>
<i className="fa-solid fa-expand-alt"></i>
<span>Buka Semua</span>
</button>
<button
onClick={collapseAll}
className="flex items-center space-x-2 text-sm text-gray-600 font-medium px-3 py-1.5 rounded-md hover:bg-gray-100 transition-colors"
title="Tutup Semua"
>
<i className="fa-solid fa-compress-alt"></i>
<span>Tutup Semua</span>
</button>
</div>
</div>
<div className="space-y-2 overflow-y-auto pr-2 flex-grow">
{aggregatedCounts.map((location) => (
<AccordionItem
key={location.locationId}
id={location.locationId}
title={location.locationName}
count={location.totalCount}
icon="fa-building"
level="location"
isOpen={!!openAccordions[location.locationId]}
onToggle={handleToggle}
>
<div className="space-y-2">
{location.coops.map((coop) => (
<AccordionItem
key={coop.coopId}
id={coop.coopId}
title={coop.coopName}
count={coop.totalCount}
icon="fa-cube"
level="coop"
isOpen={!!openAccordions[coop.coopId]}
onToggle={handleToggle}
>
<div className="space-y-4 pl-6 pt-2">
{coop.floors.map((floor) => (
<div key={floor.floorId}>
<div className="pl-4 border-l-2 border-gray-200">
<AccordionItem
id={floor.floorId}
title={floor.floorName}
count={floor.count}
icon="fa-layer-group"
level="floor"
isOpen={!!openAccordions[floor.floorId]}
onToggle={handleToggle}
>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-3 gap-3 pt-3">
{floor.cameras.map((lc) => (
<CameraCountCard key={lc.cameraId} liveCount={lc} />
))}
</div>
</AccordionItem>
</div>
</div>
))}
</div>
</AccordionItem>
))}
</div>
</AccordionItem>
))}
</div>
</div>
);
};
export default LocationCountBreakdown;
@@ -0,0 +1,119 @@
import React, { useMemo } from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import { PopulationDataPoint } from '../../types.ts';
import { useAppContext } from '../../context/AppContext.tsx';
import { formatNumber, createYAxisFormatter } from '../../utils/formatters.ts';
interface KandangPopulationData {
name: string;
data: PopulationDataPoint[];
color: string;
}
interface PopulationTrendChartProps {
data: PopulationDataPoint[];
perKandangData?: KandangPopulationData[];
}
const PopulationTrendChart: React.FC<PopulationTrendChartProps> = ({ data, perKandangData }) => {
const { apiMode: _apiMode } = useAppContext();
// Merge per-kandang data into chart data
const mergedData = useMemo(() => {
if (!perKandangData || perKandangData.length === 0) return data;
return data.map((point, index) => {
const merged: any = { ...point };
perKandangData.forEach((kandang) => {
if (kandang.data[index]) {
merged[kandang.name] = kandang.data[index].population;
}
});
return merged;
});
}, [data, perKandangData]);
// Show empty state if no data
if (data.length === 0) {
return (
<div className="bg-white p-6 rounded-xl shadow-sm">
<h2 className="text-xl font-bold mb-4">Tren Populasi</h2>
<div className="h-60 flex items-center justify-center">
<div className="text-center text-gray-500">
<i className="fa-solid fa-chart-line text-5xl text-gray-300 mb-3"></i>
<p className="font-medium text-gray-600">Tidak ada data</p>
<p className="text-sm text-gray-500 mt-1">Belum ada data populasi yang tersedia</p>
</div>
</div>
</div>
);
}
const hasPerKandang = perKandangData && perKandangData.length > 0;
return (
<div className="bg-white p-6 rounded-xl shadow-sm">
<h2 className="text-xl font-bold mb-4">Tren Populasi</h2>
<div className={hasPerKandang ? 'h-80' : 'h-60'}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={mergedData} margin={{ top: 5, right: 20, left: -15, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
<XAxis dataKey="date" fontSize={12} tick={{ fill: '#6b7280' }} />
<YAxis
fontSize={12}
tick={{ fill: '#6b7280' }}
domain={['dataMin - 50', 'dataMax + 50']}
tickFormatter={createYAxisFormatter()}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.9)',
border: '1px solid #ccc',
borderRadius: '8px',
}}
formatter={(value: number, name: string) => [
formatNumber(value),
name === 'population' ? 'Total' : name,
]}
/>
{hasPerKandang && <Legend />}
<Line
type="monotone"
dataKey="population"
stroke="#ef4444"
strokeWidth={2.5}
name={hasPerKandang ? 'Total' : 'Populasi'}
dot={{ r: hasPerKandang ? 3 : 4 }}
activeDot={{ r: 6 }}
/>
{hasPerKandang &&
perKandangData!.map((kandang) => (
<Line
key={kandang.name}
type="monotone"
dataKey={kandang.name}
stroke={kandang.color}
strokeWidth={1.5}
strokeDasharray="5 5"
name={kandang.name}
dot={false}
activeDot={{ r: 4 }}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
</div>
);
};
export default PopulationTrendChart;
@@ -0,0 +1,111 @@
import React from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import { formatNumber, createYAxisFormatter } from '../../utils/formatters.ts';
interface PopulationDataPoint {
date: string;
population: number;
chickenCount?: number;
}
interface SimplePopulationChartProps {
data: PopulationDataPoint[];
title?: string;
}
const SimplePopulationChart: React.FC<SimplePopulationChartProps> = ({
data,
title = 'Tren Populasi',
}) => {
if (!data || data.length === 0) {
return (
<div className="bg-white p-6 rounded-xl shadow-sm">
<h3 className="text-lg font-bold text-gray-800 mb-4">{title}</h3>
<div className="h-64 flex items-center justify-center text-gray-400">
<div className="text-center">
<i className="fa-solid fa-chart-line text-4xl mb-2"></i>
<p>Tidak ada data tersedia</p>
</div>
</div>
</div>
);
}
return (
<div className="bg-white p-6 rounded-xl shadow-sm">
<h3 className="text-lg font-bold text-gray-800 mb-4">{title}</h3>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
<XAxis
dataKey="date"
fontSize={11}
tick={{ fill: '#6b7280' }}
interval="preserveStartEnd"
/>
<YAxis
fontSize={11}
tick={{ fill: '#6b7280' }}
scale="log"
domain={[
(dataMin: number) => {
// For log scale, use multipliers
return Math.max(1, Math.floor(dataMin * 0.7));
},
(dataMax: number) => {
return Math.ceil(dataMax * 1.3);
},
]}
tickFormatter={createYAxisFormatter()}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: '1px solid #e0e0e0',
borderRadius: '8px',
padding: '8px 12px',
}}
formatter={(value: any, name: string) => {
if (value === null || value === undefined) return ['Tidak ada data', name];
return [formatNumber(value as number), name];
}}
labelStyle={{ fontWeight: 600, marginBottom: '4px' }}
/>
<Legend />
<Line
type="monotone"
dataKey="population"
stroke="#ef4444"
strokeWidth={2.5}
name="Populasi (Stock Akhir)"
dot={{ r: 3, fill: '#ef4444' }}
activeDot={{ r: 5, strokeWidth: 2, stroke: '#fff' }}
/>
<Line
type="monotone"
dataKey="chickenCount"
stroke="#3b82f6"
strokeWidth={2.5}
name="Chicken Counting (IOT)"
dot={{ r: 3, fill: '#3b82f6' }}
activeDot={{ r: 5, strokeWidth: 2, stroke: '#fff' }}
connectNulls={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
);
};
export default SimplePopulationChart;