271 lines
9.5 KiB
TypeScript
271 lines
9.5 KiB
TypeScript
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
|
import CombinedTrendChart from './counting/CombinedTrendChart.tsx';
|
|
import ChickenCountingAiInsight from './counting/ChickenCountingAiInsight.tsx';
|
|
import { CalculatorIcon } from './icons/Icons.tsx';
|
|
import { useAppContext } from '../context/AppContext.tsx';
|
|
import { apiClient } from '../services/apiService.ts';
|
|
import { Kandang } 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;
|
|
mortality?: number;
|
|
}
|
|
|
|
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',
|
|
];
|
|
|
|
const ChickenCountingPage: React.FC = () => {
|
|
const { cycleData } = useAppContext();
|
|
const [chickenCountHistory, setChickenCountHistory] = useState<ChickenCountDataPoint[]>([]);
|
|
const [populationTrendHistory, setPopulationTrendHistory] = useState<PopulationDataPoint[]>([]);
|
|
const [kandangs, setKandangs] = useState<Kandang[]>([]);
|
|
const [perKandangData, setPerKandangData] = useState<KandangPopulationData[]>([]);
|
|
|
|
// Load kandangs
|
|
useEffect(() => {
|
|
const loadKandangs = async () => {
|
|
try {
|
|
const data = await apiClient.getKandangsByCycle(cycleData.id);
|
|
setKandangs(data);
|
|
} catch (error) {
|
|
console.error('Failed to load kandangs:', error);
|
|
}
|
|
};
|
|
loadKandangs();
|
|
}, [cycleData]);
|
|
|
|
// 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]);
|
|
|
|
// 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;
|
|
let cumulativePanen = 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);
|
|
cumulativePanen += record.panen || 0;
|
|
}
|
|
const calculatedPopulation = initPop - cumulativeMortality - cumulativePanen;
|
|
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
|
|
const allRecords = await apiClient.getMortalityTotals(cycleData.id);
|
|
const { chickenCounts, populations } = buildPopulationData(
|
|
allRecords,
|
|
totalInitialPopulation
|
|
);
|
|
setChickenCountHistory(chickenCounts);
|
|
setPopulationTrendHistory(populations);
|
|
|
|
// Per-kandang data
|
|
if (kandangs.length > 0) {
|
|
const perKandang: KandangPopulationData[] = [];
|
|
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);
|
|
const kInitPop = k.docInCount || 0;
|
|
const { populations: kPop } = buildPopulationData(kRecords, kInitPop);
|
|
const mortality = kRecords.reduce(
|
|
(sum: number, record: { mortalityCount?: number; afkir?: number }) =>
|
|
sum + (record.mortalityCount || 0) + (record.afkir || 0),
|
|
0
|
|
);
|
|
perKandang.push({
|
|
name: k.name,
|
|
data: kPop,
|
|
color: KANDANG_COLORS[i % KANDANG_COLORS.length] ?? '#6b7280',
|
|
mortality,
|
|
});
|
|
} catch {
|
|
// Skip if error fetching individual kandang
|
|
}
|
|
}
|
|
setPerKandangData(perKandang);
|
|
} else {
|
|
setPerKandangData([]);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch mortality records:', error);
|
|
}
|
|
};
|
|
|
|
fetchData();
|
|
}, [cycleData, kandangs, totalInitialPopulation, buildPopulationData]);
|
|
|
|
// Removed unused kandangInsights and currentDay hooks
|
|
|
|
return (
|
|
<main className="px-4 sm:px-6 lg:px-8 pb-8">
|
|
<div className="max-w-7xl mx-auto">
|
|
<div className="flex items-center mb-6">
|
|
<CalculatorIcon className="text-3xl text-red-600 mr-4" />
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-gray-800">Dashboard Penghitungan Ayam</h1>
|
|
<p className="mt-1 text-gray-600">
|
|
Pemantauan populasi dari berbagai kamera secara real-time.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* AI Insight */}
|
|
<div className="mb-8">
|
|
<ChickenCountingAiInsight
|
|
cycleId={cycleData.id}
|
|
currentDay={cycleData.currentDay}
|
|
totalDays={cycleData.totalDays || 1}
|
|
totalInitialPopulation={totalInitialPopulation}
|
|
populationTrend={populationTrendHistory as never}
|
|
kandangInsights={[]}
|
|
/>
|
|
</div>
|
|
|
|
{kandangs.length > 0 && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
|
{/* Total card */}
|
|
<div className="bg-white rounded-xl shadow-sm p-4 border-l-4 border-l-red-600">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<i className="fa-solid fa-layer-group text-red-600"></i>
|
|
<p className="text-sm font-semibold text-gray-700">Total Semua Kandang</p>
|
|
</div>
|
|
<p className="text-2xl font-bold text-gray-800">
|
|
{(
|
|
populationTrendHistory[populationTrendHistory.length - 1]?.population ??
|
|
totalInitialPopulation
|
|
).toLocaleString()}
|
|
<span className="text-sm font-normal text-gray-500 ml-1">ekor</span>
|
|
</p>
|
|
<p className="text-xs text-gray-500">
|
|
DOC: {totalInitialPopulation.toLocaleString()}
|
|
</p>
|
|
</div>
|
|
{/* Individual kandang cards */}
|
|
{kandangs.map((k, i) => {
|
|
const kData = perKandangData.find((d) => d.name === k.name);
|
|
const lastPop = kData?.data[kData.data.length - 1]?.population;
|
|
const mortality = kData?.mortality || 0;
|
|
return (
|
|
<div
|
|
key={k.id}
|
|
className="bg-white rounded-xl shadow-sm p-4 border-l-4"
|
|
style={{ borderLeftColor: KANDANG_COLORS[i % KANDANG_COLORS.length] }}
|
|
>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<i
|
|
className="fa-solid fa-warehouse"
|
|
style={{ color: KANDANG_COLORS[i % KANDANG_COLORS.length] }}
|
|
></i>
|
|
<p className="text-sm font-semibold text-gray-700">{k.name}</p>
|
|
</div>
|
|
<p className="text-2xl font-bold text-gray-800">
|
|
{lastPop?.toLocaleString() || '-'}
|
|
<span className="text-sm font-normal text-gray-500 ml-1">ekor</span>
|
|
</p>
|
|
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
|
<span>DOC: {(k.docInCount || 0).toLocaleString()}</span>
|
|
{mortality > 0 && (
|
|
<span className="text-red-500">Mortalitas: {mortality.toLocaleString()}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-8">
|
|
<CombinedTrendChart
|
|
populationData={populationTrendHistory}
|
|
chickenCountData={chickenCountHistory}
|
|
kandangs={kandangs}
|
|
cycleData={cycleData}
|
|
apiClient={apiClient}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
);
|
|
};
|
|
|
|
export default ChickenCountingPage;
|