354 lines
12 KiB
TypeScript
354 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 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;
|