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