368 lines
13 KiB
TypeScript
368 lines
13 KiB
TypeScript
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 lastCycleDay = Math.max(0, (cycleData.totalDays || 1) - 1);
|
|
const currentDayOfCycle = Math.min(
|
|
Math.floor((today.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)),
|
|
lastCycleDay
|
|
);
|
|
|
|
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, cycleData.totalDays]
|
|
);
|
|
|
|
// 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;
|