219 lines
6.9 KiB
TypeScript
219 lines
6.9 KiB
TypeScript
import React, { useMemo } from 'react';
|
|
import {
|
|
LineChart,
|
|
Line,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
Legend,
|
|
ResponsiveContainer,
|
|
} from 'recharts';
|
|
import {
|
|
FeedSackLogEntry,
|
|
ManualFeedSackEntry,
|
|
FeedSackColumnConfig,
|
|
} from '../../types/counting.ts';
|
|
|
|
interface FeedSackComparisonChartsProps {
|
|
iotData: FeedSackLogEntry[];
|
|
manualEntries: ManualFeedSackEntry[];
|
|
cycleStartDate: Date;
|
|
currentDay: number;
|
|
columnConfig: FeedSackColumnConfig[];
|
|
}
|
|
|
|
const FeedSackComparisonCharts: React.FC<FeedSackComparisonChartsProps> = ({
|
|
iotData,
|
|
manualEntries,
|
|
cycleStartDate,
|
|
currentDay,
|
|
columnConfig,
|
|
}) => {
|
|
// Aggregate data per camera
|
|
const chartDataPerCamera = useMemo(() => {
|
|
const cycleStart = new Date(cycleStartDate);
|
|
cycleStart.setHours(0, 0, 0, 0);
|
|
|
|
return columnConfig.map((col) => {
|
|
const dailyMap = new Map<string, { date: string; manual: number; iot: number }>();
|
|
|
|
// Process IOT data for this camera
|
|
iotData.forEach((log) => {
|
|
if (log.cameraName !== col.cameraName) return;
|
|
|
|
const logDate = new Date(log.timestamp);
|
|
logDate.setHours(0, 0, 0, 0);
|
|
|
|
const dayNumber = Math.floor(
|
|
(logDate.getTime() - cycleStart.getTime()) / (1000 * 60 * 60 * 24)
|
|
);
|
|
|
|
if (dayNumber < 0 || dayNumber > currentDay) return;
|
|
|
|
const dateKey = logDate.toISOString().split('T')[0];
|
|
|
|
if (!dailyMap.has(dateKey)) {
|
|
dailyMap.set(dateKey, {
|
|
date: logDate.toLocaleDateString('id-ID', {
|
|
day: '2-digit',
|
|
month: 'short',
|
|
}),
|
|
manual: 0,
|
|
iot: 0,
|
|
});
|
|
}
|
|
|
|
const entry = dailyMap.get(dateKey)!;
|
|
entry.iot += log.count;
|
|
});
|
|
|
|
// Process manual data for this camera
|
|
manualEntries.forEach((manual) => {
|
|
if (manual.cameraName !== col.cameraName) return;
|
|
|
|
const manualDate = new Date(manual.date);
|
|
if (isNaN(manualDate.getTime())) return;
|
|
|
|
manualDate.setHours(0, 0, 0, 0);
|
|
const dateKey = manualDate.toISOString().split('T')[0];
|
|
|
|
if (!dailyMap.has(dateKey)) {
|
|
dailyMap.set(dateKey, {
|
|
date: manualDate.toLocaleDateString('id-ID', {
|
|
day: '2-digit',
|
|
month: 'short',
|
|
}),
|
|
manual: 0,
|
|
iot: 0,
|
|
});
|
|
}
|
|
|
|
const entry = dailyMap.get(dateKey)!;
|
|
entry.manual = manual.count;
|
|
});
|
|
|
|
// Fill missing days
|
|
const filled = [];
|
|
for (let d = 0; d <= currentDay; d++) {
|
|
const date = new Date(cycleStart);
|
|
date.setDate(cycleStart.getDate() + d);
|
|
const dateKey = date.toISOString().split('T')[0];
|
|
|
|
if (dailyMap.has(dateKey)) {
|
|
filled.push(dailyMap.get(dateKey)!);
|
|
} else {
|
|
filled.push({
|
|
date: date.toLocaleDateString('id-ID', {
|
|
day: '2-digit',
|
|
month: 'short',
|
|
}),
|
|
manual: 0,
|
|
iot: 0,
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
camera: col,
|
|
data: filled,
|
|
};
|
|
});
|
|
}, [iotData, manualEntries, cycleStartDate, currentDay, columnConfig]);
|
|
|
|
if (columnConfig.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<h2 className="text-xl font-bold text-gray-800">Perbandingan Manual vs IOT per Kamera</h2>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{chartDataPerCamera.map(({ camera, data }) => {
|
|
const hasData = data.some((d) => d.manual > 0 || d.iot > 0);
|
|
|
|
return (
|
|
<div key={camera.id} className="bg-white p-6 rounded-xl shadow-sm">
|
|
<div className="mb-4">
|
|
<h3 className="text-lg font-bold" style={{ color: camera.color }}>
|
|
{camera.label}
|
|
</h3>
|
|
<p className="text-sm text-gray-500">{camera.cameraName}</p>
|
|
</div>
|
|
|
|
{!hasData ? (
|
|
<div className="h-64 flex items-center justify-center">
|
|
<div className="text-center text-gray-400">
|
|
<i className="fa-solid fa-chart-line text-4xl mb-2"></i>
|
|
<p className="text-sm">Belum ada data untuk kamera ini</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="h-64">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<LineChart data={data} margin={{ top: 5, right: 30, left: 0, bottom: 5 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
|
|
<XAxis dataKey="date" fontSize={12} tick={{ fill: '#6b7280' }} />
|
|
<YAxis
|
|
fontSize={12}
|
|
tick={{ fill: '#6b7280' }}
|
|
domain={[
|
|
(dataMin: number) => {
|
|
if (dataMin === 0) return 0;
|
|
return Math.floor(dataMin * 0.9);
|
|
},
|
|
(dataMax: number) => Math.ceil(dataMax * 1.1),
|
|
]}
|
|
tickFormatter={(value) => `${value}`}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
|
border: '1px solid #ccc',
|
|
borderRadius: '8px',
|
|
padding: '10px',
|
|
}}
|
|
formatter={(value: number, name: string) => [
|
|
`${value} karung`,
|
|
name === 'manual' ? 'Manual' : 'IOT',
|
|
]}
|
|
/>
|
|
<Legend
|
|
wrapperStyle={{ paddingTop: '10px' }}
|
|
formatter={(value) => (value === 'manual' ? 'Manual' : 'IOT')}
|
|
/>
|
|
<Line
|
|
type="monotone"
|
|
dataKey="manual"
|
|
stroke="#3b82f6"
|
|
strokeWidth={2.5}
|
|
name="manual"
|
|
dot={{ r: 4, fill: '#3b82f6' }}
|
|
activeDot={{ r: 6 }}
|
|
connectNulls={false}
|
|
/>
|
|
<Line
|
|
type="monotone"
|
|
dataKey="iot"
|
|
stroke={camera.color}
|
|
strokeWidth={2.5}
|
|
name="iot"
|
|
dot={{ r: 4, fill: camera.color }}
|
|
activeDot={{ r: 6 }}
|
|
connectNulls={false}
|
|
/>
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default FeedSackComparisonCharts;
|