Files
lti-web-client/src/components/pages/dashboard/chart/DashboardLineChart.tsx
T
2026-01-29 16:33:02 +07:00

753 lines
27 KiB
TypeScript

import Button from '@/components/Button';
import Card from '@/components/Card';
import Dropdown from '@/components/Dropdown';
import { OptionType } from '@/components/input/SelectInput';
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
import { formatNumber } from '@/lib/helper';
import {
Dashboard,
DashboardOverviewCharts,
DashboardComparisonCharts,
DashboardChartsSeries,
DashboardChartsDataset,
} from '@/types/api/dashboard/dashboard';
import { Icon } from '@iconify/react';
import { useState, useEffect } from 'react';
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
type DashboardLineChartProps = {
analysisMode: 'OVERVIEW' | 'COMPARISON';
data: Dashboard;
selectedKandang?: OptionType;
};
// Type guard to check if charts is DashboardOverviewCharts
function isOverviewCharts(
charts: DashboardOverviewCharts | DashboardComparisonCharts | undefined
): charts is DashboardOverviewCharts {
if (!charts) return false;
return (
'deplesi' in charts ||
'body_weight' in charts ||
'fcr' in charts ||
'performance' in charts ||
'quality_control' in charts
);
}
// Type guard to check if charts is DashboardComparisonCharts
function isComparisonCharts(
charts: DashboardOverviewCharts | DashboardComparisonCharts | undefined
): charts is DashboardComparisonCharts {
if (!charts) return false;
return 'farm' in charts || 'flock' in charts || 'kandang' in charts;
}
const lineColors: Record<string, string> = {
body_weight: '#10B981',
std_body_weight: '#10B981',
act_laying: '#1062B9',
std_laying: '#1062B9',
act_egg_weight: '#10B981',
std_egg_weight: '#10B981',
act_feed_intake: '#F52419',
std_feed_intake: '#F52419',
act_uniformity: '#F59E0B',
std_uniformity: '#F59E0B',
act_fcr: '#10B981',
std_fcr: '#10B981',
act_fcr_cum: '#F52419',
std_fcr_cum: '#10B981',
normal: '#10B981',
abnormal: '#F52419',
act_deplesi: '#10B981',
std_deplesi: '#10B981',
};
const defaultLineColors: string[] = [
'#10B981',
'#1062B9',
'#F52419',
'#F59E0B',
'#7F56D9',
];
// Helper function to get line color
const getLineColor = (
seriesId: string | number,
index: number,
mode: 'OVERVIEW' | 'COMPARISON'
): string => {
// For COMPARISON mode, use default colors with cycling
if (mode === 'COMPARISON') {
return defaultLineColors[index % defaultLineColors.length];
}
// For OVERVIEW mode, use predefined colors or fallback to default
const predefinedColor = lineColors[seriesId];
if (predefinedColor) {
return predefinedColor;
}
// Fallback to default colors with cycling
return defaultLineColors[index % defaultLineColors.length];
};
const DashboardLineChart = ({
analysisMode,
data,
selectedKandang,
}: DashboardLineChartProps) => {
const [chartData, setChartData] =
useState<keyof DashboardOverviewCharts>('body_weight');
const [open, setOpen] = useState(false);
// Track which series are visible (by series id)
const [visibleSeries, setVisibleSeries] = useState<Set<string | number>>(
new Set()
);
// Mapping for chart type labels
const chartTypeLabels: Record<keyof DashboardOverviewCharts, string> = {
body_weight: 'Body Weight',
performance: 'Performance',
fcr: 'FCR',
quality_control: 'Quality Control',
deplesi: 'Deplesi',
};
// Initialize all series as visible when chartData changes
useEffect(() => {
let seriesData: DashboardChartsSeries[] = [];
if (analysisMode === 'OVERVIEW' && isOverviewCharts(data.charts)) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.farm || data.charts.flock || data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
// Set all series as visible by default
const allSeriesIds = new Set(seriesData.map((s) => s.id));
setVisibleSeries(allSeriesIds);
}, [chartData, analysisMode, data.charts]);
return (
<Card
className={{
wrapper: 'w-full rounded-lg p-0',
body: 'p-4',
}}
variant='bordered'
>
<div className='flex flex-col sm:flex-row justify-between items-start gap-4 mb-3'>
<div className='text-lg font-semibold'>
Performance{' '}
<Icon
icon='heroicons:information-circle'
width={20}
height={20}
className='inline text-neutral-500'
/>
</div>
{analysisMode == 'OVERVIEW' && (
<Dropdown
align='end'
direction='bottom'
className={{
content: 'mt-1 min-w-full',
}}
trigger={
<Button
variant='outline'
color='none'
className='py-2.5 pl-3 pr-2 text-base-content/50 rounded-lg text-sm border-base-content/10 shadow-button-soft'
onClick={() => setOpen(!open)}
>
{chartTypeLabels[chartData]}{' '}
<div className='w-6 h-5 flex items-center justify-center border-l border-base-content/10'>
<Icon icon='heroicons:chevron-down' width={14} height={14} />
</div>
</Button>
}
controlled={open}
>
<Menu className='p-0 w-full shadow-button-soft border border-base-content/10 rounded-lg'>
<MenuItem
title='Body weight'
className='text-sm padding-3 whitespace-nowrap'
onClick={() => {
setChartData('body_weight');
setOpen(!open);
}}
/>
<MenuItem
title='Performance'
className='text-sm padding-3 whitespace-nowrap'
onClick={() => {
setChartData('performance');
setOpen(!open);
}}
/>
<MenuItem
title='FCR'
className='text-sm padding-3 whitespace-nowrap'
onClick={() => {
setChartData('fcr');
setOpen(!open);
}}
/>
<MenuItem
title='Quality Control'
className='text-sm padding-3 whitespace-nowrap'
onClick={() => {
setChartData('quality_control');
setOpen(!open);
}}
/>
<MenuItem
title='Deplesi'
className='text-sm padding-3 whitespace-nowrap'
onClick={() => {
setChartData('deplesi');
setOpen(!open);
}}
/>
</Menu>
</Dropdown>
)}
</div>
{/* Legend - Dynamic based on series data */}
<div className='flex flex-wrap gap-3 mb-6'>
{(() => {
// Get series data based on current mode and chartData
let seriesData: DashboardChartsSeries[] = [];
if (analysisMode === 'OVERVIEW' && isOverviewCharts(data.charts)) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.farm || data.charts.flock || data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
return seriesData.map((series, index) => {
const isVisible = visibleSeries.has(series.id);
const isStandard = series.id
.toString()
.toLowerCase()
.includes('std');
return (
<Button
key={`${series.id}-${index}`}
onClick={() => {
const newVisible = new Set(visibleSeries);
if (isVisible) {
newVisible.delete(series.id);
} else {
newVisible.add(series.id);
}
setVisibleSeries(newVisible);
}}
variant='outline'
color='none'
className={`flex items-center gap-2 p-3 rounded-lg border transition-colors ${
isVisible
? 'border-base-content/10 hover:bg-base-content/4'
: 'border-base-content/10 bg-base-content/4'
}`}
>
<div
className={`w-5 h-0.5 ${
isStandard ? 'border-t-2 border-dashed' : ''
} ${!isVisible ? 'opacity-30' : ''}`}
style={{
backgroundColor: isStandard
? 'transparent'
: getLineColor(series.id, index, analysisMode),
borderColor: isStandard
? getLineColor(series.id, index, analysisMode)
: 'transparent',
}}
></div>
<span
className={`font-semibold text-sm ${isVisible ? 'text-base-content/50' : 'text-base-content/50'}`}
>
{series.label}
</span>
<Icon
icon='heroicons:eye'
width={16}
height={16}
className='text-base-content/40'
/>
</Button>
);
});
})()}
</div>
{/* Chart Container with Empty State Overlay */}
<div className='relative'>
{/* Chart */}
<ResponsiveContainer width='100%' height={350}>
<LineChart
data={(() => {
// Transform data based on analysisMode
if (analysisMode === 'OVERVIEW') {
// For OVERVIEW mode, use the selected chart data
if (isOverviewCharts(data.charts)) {
const selectedChartData = data.charts[chartData];
if (!selectedChartData || !selectedChartData.dataset)
return [];
return selectedChartData.dataset;
}
return [];
} else {
// For COMPARISON mode, use the first available comparison chart
if (isComparisonCharts(data.charts)) {
const chartData =
data.charts.farm ||
data.charts.flock ||
data.charts.kandang;
if (!chartData || !chartData.dataset) return [];
return chartData.dataset;
}
return [];
}
})()}
margin={{
top: 5,
right: 10,
left: 0,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' />
<XAxis
dataKey='week'
tick={{
fontSize: 12,
fill: '#18181B',
opacity: 0.5,
fontWeight: 600,
}}
tickLine={false}
axisLine={{ stroke: '#C1C1C180', opacity: 0.5 }}
label={{
value: 'Weeks',
position: 'insideBottom',
offset: -5,
style: {
fontSize: 12,
fill: '#18181B',
opacity: 0.2,
fontWeight: 600,
},
}}
/>
<YAxis
tick={{
fontSize: 12,
fill: '#18181B',
opacity: 0.5,
fontWeight: 600,
}}
label={
(chartData === 'body_weight' || chartData === 'performance') &&
analysisMode === 'OVERVIEW'
? {
value:
chartData === 'body_weight'
? 'Body Weight'
: 'Percentage',
position: 'insideLeft',
angle: -90,
offset: 5,
style: {
fontSize: 12,
fill: '#18181B',
opacity: 0.2,
fontWeight: 600,
},
}
: analysisMode === 'COMPARISON'
? {
value: 'Percentage',
position: 'insideLeft',
angle: -90,
offset: 5,
style: {
fontSize: 12,
fill: '#18181B',
opacity: 0.2,
fontWeight: 600,
},
}
: undefined
}
tickLine={false}
axisLine={{ stroke: '#C1C1C180', opacity: 0.5 }}
domain={(() => {
// Calculate dynamic domain based on visible data
let seriesData: DashboardChartsSeries[] = [];
let dataset: DashboardChartsDataset[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.farm ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
dataset = comparisonChart?.dataset || [];
}
// Get all values from visible series
const visibleSeriesIds = Array.from(visibleSeries);
const allValues: number[] = [];
dataset.forEach((item: DashboardChartsDataset) => {
visibleSeriesIds.forEach((seriesId) => {
const value = item[seriesId];
if (typeof value === 'number') {
allValues.push(value);
}
});
});
if (allValues.length === 0) return [0, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
// Add padding (10% on each side)
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding);
return [domainMin, domainMax];
})()}
ticks={(() => {
// Calculate dynamic ticks based on domain
let dataset: DashboardChartsDataset[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.farm ||
data.charts.flock ||
data.charts.kandang;
dataset = comparisonChart?.dataset || [];
}
const visibleSeriesIds = Array.from(visibleSeries);
const allValues: number[] = [];
dataset.forEach((item: DashboardChartsDataset) => {
visibleSeriesIds.forEach((seriesId) => {
const value = item[seriesId];
if (typeof value === 'number') {
allValues.push(value);
}
});
});
if (allValues.length === 0) return [0, 25, 50, 75, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
// Handle edge case where min equals max
if (minValue === maxValue) {
const value = Math.round(minValue);
const padding = Math.max(10, Math.abs(value) * 0.2);
return [
Math.floor(value - padding),
Math.floor(value - padding / 2),
value,
Math.ceil(value + padding / 2),
Math.ceil(value + padding),
];
}
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding);
// Generate 5 evenly spaced ticks
const range = domainMax - domainMin;
const step = range / 4;
// Use Set to ensure unique values
const tickSet = new Set([
domainMin,
Math.round(domainMin + step),
Math.round(domainMin + step * 2),
Math.round(domainMin + step * 3),
domainMax,
]);
return Array.from(tickSet).sort((a, b) => a - b);
})()}
tickFormatter={(value) => formatNumber(Number(value))}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1f2937',
border: 'none',
borderRadius: '8px',
padding: '12px 12px',
color: 'white',
}}
labelStyle={{ color: 'white', marginBottom: '4px' }}
itemStyle={{ color: 'white', fontSize: '12px' }}
labelFormatter={(value) => `Week ${value}`}
content={(props) => {
return (
<div className='flex flex-col gap-1.5 rounded-lg bg-neutral-950 p-4 text-white'>
<p className='text-white/50 text-xs font-semibold text-start'>
{analysisMode === 'OVERVIEW'
? selectedKandang
? selectedKandang.label || 'Overview Performance'
: 'Overview Performance'
: 'Comparison Performance'}
</p>
<ul className='flex flex-col gap-1'>
{props.payload.map((item, index) => {
if (item.name.startsWith('STD. ')) return null;
// Get series data to find the unit
let seriesData: DashboardChartsSeries[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.farm ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
// Find the series that matches this line's name
const series = seriesData.find(
(s) => s.label === item.name
);
const color = series?.id
? getLineColor(series.id, index, analysisMode)
: '#9ca3af';
const unit = series?.unit;
return (
<li
key={`${item.name}-${index}`}
className='flex w-full justify-between items-center flex-row gap-y-1.5 gap-x-3 p-0'
>
<span className='flex flex-row gap-1.5 items-center'>
<div
className='h-5 w-5 m-0 rounded'
style={{
backgroundColor: color,
}}
></div>
<div className='m-0'>
{formatNumber(item.value)}
{unit}
</div>
</span>
<span className='m-0'>{item.name}</span>
</li>
);
})}
</ul>
<p className='text-white/50 text-xs text-start'>
Week {props.label}
</p>
</div>
);
}}
formatter={(
value: number | undefined,
name: string | undefined
) => {
if (
value === undefined ||
name === undefined ||
name.startsWith('STD. ')
)
return [undefined, undefined];
// Get series data to find the unit
let seriesData: DashboardChartsSeries[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.farm ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
// Find the series that matches this line's name
const series = seriesData.find((s) => s.label === name);
const id = series?.id || '';
return [value, id];
}}
/>
{/* Dynamic Line rendering based on visible series */}
{(() => {
let seriesData: DashboardChartsSeries[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.farm || data.charts.flock || data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
return seriesData
.filter((series) => visibleSeries.has(series.id))
.map((series, index) => {
const isStandard = series.id
.toString()
.toLowerCase()
.includes('std');
// Use series.id directly as dataKey to match dataset fields
const dataKey = series.id.toString();
return (
<Line
key={`${series.id}--${index}`}
type='monotone'
dataKey={dataKey}
name={series.label}
stroke={getLineColor(series.id, index, analysisMode)}
opacity={isStandard ? 0.5 : 1}
strokeWidth={2}
strokeDasharray={isStandard ? '5 5' : undefined}
dot={
isStandard
? false
: {
r: 3,
fill: '#fff',
stroke: getLineColor(
series.id,
index,
analysisMode
),
strokeWidth: 2,
}
}
activeDot={isStandard ? undefined : { r: 5 }}
/>
);
});
})()}
</LineChart>
</ResponsiveContainer>
{/* Empty State Overlay */}
{(() => {
// Get current dataset
let dataset: DashboardChartsDataset[] = [];
if (analysisMode === 'OVERVIEW' && isOverviewCharts(data.charts)) {
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.farm || data.charts.flock || data.charts.kandang;
dataset = comparisonChart?.dataset || [];
}
// Show empty state if dataset is empty
if (dataset.length === 0) {
return (
<div className='absolute inset-x-0 inset-y-15 z-10 flex flex-col items-center justify-center rounded-lg'>
{/* Chart icon */}
<div className='w-12.5 h-12.5 bg-[var(--main-color-base-100,#FFFFFF)] border border-base-content/10 rounded-[0.875rem] border border-base-content shadow-[0px_25px_50px_-12px_#00000040] flex items-center justify-center mb-2'>
<div className='w-9.5 h-9.5 bg-primary rounded-lg border border-primary flex items-center justify-center shadow-[inset_0px_4px_4px_0px_#FFFFFF80,inset_0px_2px_0px_0px_#FFFFFF80]'>
<Icon
icon='heroicons:chart-bar'
className='text-white'
width={20}
height={20}
/>
</div>
</div>
{/* Empty state text */}
<h3 className='text-base-content/50 font-semibold text-sm mb-1'>
Data Not Yet Available
</h3>
<p className='text-base-content/50 text-xs text-center max-w-xs'>
Please change your filters to get the data.
</p>
</div>
);
}
return null;
})()}
</div>
</Card>
);
};
export default DashboardLineChart;