Files
dashboard/components/counting/CombinedTrendChart.tsx
T
2026-07-29 10:48:03 +07:00

358 lines
12 KiB
TypeScript

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