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