mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-24 15:25:46 +00:00
refactor(FE): export charts with hidden html renderer and adding package html-to-image
This commit is contained in:
@@ -8,12 +8,13 @@ import SelectInput, {
|
|||||||
OptionType,
|
OptionType,
|
||||||
useSelect,
|
useSelect,
|
||||||
} from '@/components/input/SelectInput';
|
} from '@/components/input/SelectInput';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import { DashboardApi } from '@/services/api/dashboard';
|
import { DashboardApi } from '@/services/api/dashboard';
|
||||||
import { useFormik } from 'formik';
|
import { useFormik } from 'formik';
|
||||||
import { ProjectFlockApi } from '@/services/api/production';
|
import { ProjectFlockApi } from '@/services/api/production';
|
||||||
import { KandangApi, LocationApi } from '@/services/api/master-data';
|
import { KandangApi, LocationApi } from '@/services/api/master-data';
|
||||||
|
import { generateDashboardPDF } from '@/components/pages/dashboard/export/DashboardPDF';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DashboardFilterType,
|
DashboardFilterType,
|
||||||
@@ -21,6 +22,9 @@ import {
|
|||||||
} from '@/components/pages/dashboard/filter/DashboardProductionFilter.schema';
|
} from '@/components/pages/dashboard/filter/DashboardProductionFilter.schema';
|
||||||
import DashboardLineChart from '@/components/pages/dashboard/chart/DashboardLineChart';
|
import DashboardLineChart from '@/components/pages/dashboard/chart/DashboardLineChart';
|
||||||
import DashboardLineChartSkeleton from '@/components/pages/dashboard/skeleton/DashboardLineChartSkeleton';
|
import DashboardLineChartSkeleton from '@/components/pages/dashboard/skeleton/DashboardLineChartSkeleton';
|
||||||
|
import DashboardAllCharts, {
|
||||||
|
DashboardAllChartsRef,
|
||||||
|
} from '@/components/pages/dashboard/chart/DashboardAllCharts';
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/input/RadioInput';
|
import { RadioGroup, RadioGroupItem } from '@/components/input/RadioInput';
|
||||||
import {
|
import {
|
||||||
DashboardFilter,
|
DashboardFilter,
|
||||||
@@ -31,7 +35,6 @@ import { isResponseSuccess } from '@/lib/api-helper';
|
|||||||
import AlertErrorList from '@/components/helper/form/FormErrors';
|
import AlertErrorList from '@/components/helper/form/FormErrors';
|
||||||
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
|
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
|
||||||
import ButtonFilter from '@/components/helper/ButtonFilter';
|
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
import Dropdown from '@/components/Dropdown';
|
import Dropdown from '@/components/Dropdown';
|
||||||
import Menu from '@/components/menu/Menu';
|
import Menu from '@/components/menu/Menu';
|
||||||
import MenuItem from '@/components/menu/MenuItem';
|
import MenuItem from '@/components/menu/MenuItem';
|
||||||
@@ -55,6 +58,9 @@ const DashboardProduction = () => {
|
|||||||
const [endpointUrl, setEndpointUrl] = useState('/dashboards');
|
const [endpointUrl, setEndpointUrl] = useState('/dashboards');
|
||||||
const [selectedLocationIds, setSelectedLocationIds] = useState<number[]>([]);
|
const [selectedLocationIds, setSelectedLocationIds] = useState<number[]>([]);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const statsRef = useRef<HTMLDivElement>(null);
|
||||||
|
const chartRef = useRef<HTMLDivElement>(null);
|
||||||
|
const allChartsRef = useRef<DashboardAllChartsRef>(null);
|
||||||
|
|
||||||
// ===== FETCH DATA =====
|
// ===== FETCH DATA =====
|
||||||
const {
|
const {
|
||||||
@@ -160,21 +166,15 @@ const DashboardProduction = () => {
|
|||||||
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
|
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
|
||||||
|
|
||||||
// ===== Export PDF =====
|
// ===== Export PDF =====
|
||||||
const handleExportPDF = () => {
|
const handleExportPDF = async () => {
|
||||||
setExporting(true);
|
await generateDashboardPDF({
|
||||||
|
filterValues: formik.values,
|
||||||
|
statsRef,
|
||||||
|
allChartsRef,
|
||||||
|
setExporting,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wait for state to render, then trigger print
|
|
||||||
useEffect(() => {
|
|
||||||
if (exporting) {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
window.print();
|
|
||||||
setExporting(false);
|
|
||||||
}, 100);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [exporting]);
|
|
||||||
|
|
||||||
if (isLoadingDashboardProductionData) {
|
if (isLoadingDashboardProductionData) {
|
||||||
return (
|
return (
|
||||||
<div className='w-full min-h-screen flex items-center justify-center'>
|
<div className='w-full min-h-screen flex items-center justify-center'>
|
||||||
@@ -219,34 +219,71 @@ const DashboardProduction = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dashboard Stats */}
|
{/* Dashboard Stats */}
|
||||||
<DashboardStats data={dashboardProductionData?.statistics_data ?? []} />
|
<div ref={statsRef}>
|
||||||
|
<DashboardStats
|
||||||
|
data={dashboardProductionData?.statistics_data ?? []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Use DashboardLineChart component or skeleton */}
|
{/* Use DashboardLineChart component or skeleton */}
|
||||||
{isLoadingDashboardProductionData ? (
|
<div ref={chartRef}>
|
||||||
<DashboardLineChartSkeleton />
|
{isLoadingDashboardProductionData ? (
|
||||||
) : dashboardProductionData &&
|
<DashboardLineChartSkeleton />
|
||||||
dashboardProductionData.charts &&
|
) : dashboardProductionData &&
|
||||||
Object.keys(dashboardProductionData.charts).length > 0 ? (
|
dashboardProductionData.charts &&
|
||||||
<DashboardLineChart
|
Object.keys(dashboardProductionData.charts).length > 0 ? (
|
||||||
analysisMode={
|
<DashboardLineChart
|
||||||
isResponseSuccess(dashboardProductionResponse)
|
analysisMode={
|
||||||
? dashboardProductionResponse.meta
|
isResponseSuccess(dashboardProductionResponse)
|
||||||
? (
|
? dashboardProductionResponse.meta
|
||||||
dashboardProductionResponse.meta as unknown as DashboardMeta
|
? (
|
||||||
).filters?.analysis_mode
|
dashboardProductionResponse.meta as unknown as DashboardMeta
|
||||||
|
).filters?.analysis_mode
|
||||||
|
: analysisMode
|
||||||
: analysisMode
|
: analysisMode
|
||||||
: analysisMode
|
}
|
||||||
}
|
data={dashboardProductionData}
|
||||||
data={dashboardProductionData}
|
selectedKandang={
|
||||||
/>
|
analysisMode === 'OVERVIEW'
|
||||||
) : (
|
? (formik.values.kandang as OptionType)
|
||||||
<DashboardLineChartSkeleton
|
: undefined
|
||||||
meta={
|
}
|
||||||
isResponseSuccess(dashboardProductionResponse)
|
/>
|
||||||
? (dashboardProductionResponse.meta as unknown as DashboardMeta)
|
) : (
|
||||||
: undefined
|
<DashboardLineChartSkeleton
|
||||||
}
|
meta={
|
||||||
/>
|
isResponseSuccess(dashboardProductionResponse)
|
||||||
|
? (dashboardProductionResponse.meta as unknown as DashboardMeta)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hidden container for all charts (used for PDF export in OVERVIEW mode) */}
|
||||||
|
{dashboardProductionData && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '-9999px',
|
||||||
|
top: 0,
|
||||||
|
width: '1200px', // Fixed width for consistent PDF rendering
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DashboardAllCharts
|
||||||
|
ref={allChartsRef}
|
||||||
|
data={dashboardProductionData}
|
||||||
|
analysisMode={
|
||||||
|
isResponseSuccess(dashboardProductionResponse)
|
||||||
|
? dashboardProductionResponse.meta
|
||||||
|
? (
|
||||||
|
dashboardProductionResponse.meta as unknown as DashboardMeta
|
||||||
|
).filters?.analysis_mode
|
||||||
|
: analysisMode
|
||||||
|
: analysisMode
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import Card from '@/components/Card';
|
||||||
|
import {
|
||||||
|
Dashboard,
|
||||||
|
DashboardOverviewCharts,
|
||||||
|
DashboardComparisonCharts,
|
||||||
|
DashboardChartsSeries,
|
||||||
|
DashboardChartsDataset,
|
||||||
|
} from '@/types/api/dashboard/dashboard';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import { forwardRef, useImperativeHandle, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
CartesianGrid,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
type DashboardAllChartsProps = {
|
||||||
|
data: Dashboard;
|
||||||
|
analysisMode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardAllChartsRef = {
|
||||||
|
getChartRefs: () => {
|
||||||
|
key: string;
|
||||||
|
ref: HTMLDivElement | null;
|
||||||
|
label: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Type guard to check if charts is DashboardOverviewCharts
|
||||||
|
function isOverviewCharts(
|
||||||
|
charts: DashboardOverviewCharts | DashboardComparisonCharts
|
||||||
|
): charts is DashboardOverviewCharts {
|
||||||
|
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
|
||||||
|
): charts is DashboardComparisonCharts {
|
||||||
|
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): string => {
|
||||||
|
const predefinedColor = lineColors[seriesId];
|
||||||
|
if (predefinedColor) {
|
||||||
|
return predefinedColor;
|
||||||
|
}
|
||||||
|
return defaultLineColors[index % defaultLineColors.length];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DashboardAllCharts = forwardRef<
|
||||||
|
DashboardAllChartsRef,
|
||||||
|
DashboardAllChartsProps
|
||||||
|
>(({ data, analysisMode }, ref) => {
|
||||||
|
// Create refs for charts - use string keys for flexibility
|
||||||
|
const chartRefs = useRef<{
|
||||||
|
[key: string]: HTMLDivElement | null;
|
||||||
|
}>({});
|
||||||
|
|
||||||
|
// Determine chart keys and labels based on analysis mode
|
||||||
|
const getChartConfig = () => {
|
||||||
|
if (analysisMode === 'OVERVIEW' && isOverviewCharts(data.charts)) {
|
||||||
|
const overviewKeys: (keyof DashboardOverviewCharts)[] = [
|
||||||
|
'body_weight',
|
||||||
|
'performance',
|
||||||
|
'fcr',
|
||||||
|
'quality_control',
|
||||||
|
'deplesi',
|
||||||
|
];
|
||||||
|
return overviewKeys.map((key) => ({
|
||||||
|
key,
|
||||||
|
label: chartTypeLabels[key],
|
||||||
|
chartData: (data.charts as DashboardOverviewCharts)[key],
|
||||||
|
}));
|
||||||
|
} else if (
|
||||||
|
analysisMode === 'COMPARISON' &&
|
||||||
|
isComparisonCharts(data.charts)
|
||||||
|
) {
|
||||||
|
// For comparison mode, find which comparison type has data
|
||||||
|
const comparisonKey = data.charts.farm
|
||||||
|
? 'farm'
|
||||||
|
: data.charts.flock
|
||||||
|
? 'flock'
|
||||||
|
: 'kandang';
|
||||||
|
|
||||||
|
const comparisonLabels: Record<string, string> = {
|
||||||
|
farm: 'Farm Comparison',
|
||||||
|
flock: 'Flock Comparison',
|
||||||
|
kandang: 'Kandang Comparison',
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: comparisonKey,
|
||||||
|
label: comparisonLabels[comparisonKey],
|
||||||
|
chartData: data.charts[comparisonKey],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartConfig = getChartConfig();
|
||||||
|
|
||||||
|
// Expose method to get all chart refs
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
getChartRefs: () => {
|
||||||
|
return chartConfig
|
||||||
|
.map(({ key, label }) => ({
|
||||||
|
key,
|
||||||
|
ref: chartRefs.current[key] || null,
|
||||||
|
label,
|
||||||
|
}))
|
||||||
|
.filter((item) => item.ref !== null);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
{chartConfig.map(({ key, label, chartData }) => {
|
||||||
|
if (
|
||||||
|
!chartData ||
|
||||||
|
!chartData.dataset ||
|
||||||
|
chartData.dataset.length === 0
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seriesData: DashboardChartsSeries[] = chartData.series || [];
|
||||||
|
const dataset: DashboardChartsDataset[] = chartData.dataset || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
ref={(el: HTMLDivElement | null) => {
|
||||||
|
chartRefs.current[key] = el;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
className={{
|
||||||
|
wrapper: 'w-full rounded-lg',
|
||||||
|
}}
|
||||||
|
variant='bordered'
|
||||||
|
>
|
||||||
|
<div className='flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6'>
|
||||||
|
<div className='text-lg font-semibold'>
|
||||||
|
{label}{' '}
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:information-circle'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
className='inline text-neutral-500'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className='flex flex-wrap gap-3 mb-6'>
|
||||||
|
{seriesData.map((series, index) => {
|
||||||
|
const isStandard = series.id
|
||||||
|
.toString()
|
||||||
|
.toLowerCase()
|
||||||
|
.includes('std');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={series.id}
|
||||||
|
className='flex items-center gap-2 px-3 py-2 rounded-lg border border-neutral-400 bg-neutral-50'
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`w-6 h-0.5 ${
|
||||||
|
isStandard ? 'border-t-2 border-dashed' : ''
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: isStandard
|
||||||
|
? 'transparent'
|
||||||
|
: getLineColor(series.id, index),
|
||||||
|
borderColor: isStandard
|
||||||
|
? getLineColor(series.id, index)
|
||||||
|
: 'transparent',
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
<span className='text-sm text-neutral-900 font-medium'>
|
||||||
|
{series.label}
|
||||||
|
</span>
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:information-circle'
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
className='text-neutral-400'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart */}
|
||||||
|
<ResponsiveContainer width='100%' height={350}>
|
||||||
|
<LineChart
|
||||||
|
data={dataset}
|
||||||
|
margin={{
|
||||||
|
top: 5,
|
||||||
|
right: 10,
|
||||||
|
left: 0,
|
||||||
|
bottom: 5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' />
|
||||||
|
<XAxis
|
||||||
|
dataKey='week'
|
||||||
|
tick={{ fontSize: 11, fill: '#9ca3af' }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: '#e5e7eb' }}
|
||||||
|
label={{
|
||||||
|
value: 'Weeks',
|
||||||
|
position: 'insideBottom',
|
||||||
|
offset: -5,
|
||||||
|
style: { fontSize: 12, fill: '#9ca3af' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 11, fill: '#9ca3af' }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: '#e5e7eb' }}
|
||||||
|
domain={(() => {
|
||||||
|
const allValues: number[] = [];
|
||||||
|
dataset.forEach((item: DashboardChartsDataset) => {
|
||||||
|
seriesData.forEach((series) => {
|
||||||
|
const value = item[series.id];
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
allValues.push(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (allValues.length === 0) return [0, 100];
|
||||||
|
|
||||||
|
const minValue = Math.min(...allValues);
|
||||||
|
const maxValue = Math.max(...allValues);
|
||||||
|
const padding = (maxValue - minValue) * 0.1;
|
||||||
|
const domainMin = Math.floor(
|
||||||
|
Math.max(0, minValue - padding)
|
||||||
|
);
|
||||||
|
const domainMax = Math.ceil(maxValue + padding);
|
||||||
|
|
||||||
|
return [domainMin, domainMax];
|
||||||
|
})()}
|
||||||
|
/>
|
||||||
|
{seriesData.map((series, index) => {
|
||||||
|
const isStandard = series.id
|
||||||
|
.toString()
|
||||||
|
.toLowerCase()
|
||||||
|
.includes('std');
|
||||||
|
const dataKey = series.id.toString();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Line
|
||||||
|
key={series.id}
|
||||||
|
type='monotone'
|
||||||
|
dataKey={dataKey}
|
||||||
|
name={series.label}
|
||||||
|
stroke={getLineColor(series.id, index)}
|
||||||
|
opacity={isStandard ? 0.5 : 1}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray={isStandard ? '5 5' : undefined}
|
||||||
|
dot={
|
||||||
|
isStandard
|
||||||
|
? false
|
||||||
|
: {
|
||||||
|
r: 3,
|
||||||
|
fill: '#fff',
|
||||||
|
stroke: getLineColor(series.id, index),
|
||||||
|
strokeWidth: 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeDot={isStandard ? undefined : { r: 5 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
DashboardAllCharts.displayName = 'DashboardAllCharts';
|
||||||
|
|
||||||
|
export default DashboardAllCharts;
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import Card from '@/components/Card';
|
import Card from '@/components/Card';
|
||||||
import Dropdown from '@/components/Dropdown';
|
import Dropdown from '@/components/Dropdown';
|
||||||
|
import { OptionType } from '@/components/input/SelectInput';
|
||||||
import Menu from '@/components/menu/Menu';
|
import Menu from '@/components/menu/Menu';
|
||||||
import MenuItem from '@/components/menu/MenuItem';
|
import MenuItem from '@/components/menu/MenuItem';
|
||||||
|
import { formatNumber } from '@/lib/helper';
|
||||||
import {
|
import {
|
||||||
Dashboard,
|
Dashboard,
|
||||||
DashboardOverviewCharts,
|
DashboardOverviewCharts,
|
||||||
@@ -25,13 +27,20 @@ import {
|
|||||||
type DashboardLineChartProps = {
|
type DashboardLineChartProps = {
|
||||||
analysisMode: 'OVERVIEW' | 'COMPARISON';
|
analysisMode: 'OVERVIEW' | 'COMPARISON';
|
||||||
data: Dashboard;
|
data: Dashboard;
|
||||||
|
selectedKandang?: OptionType;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Type guard to check if charts is DashboardOverviewCharts
|
// Type guard to check if charts is DashboardOverviewCharts
|
||||||
function isOverviewCharts(
|
function isOverviewCharts(
|
||||||
charts: DashboardOverviewCharts | DashboardComparisonCharts
|
charts: DashboardOverviewCharts | DashboardComparisonCharts
|
||||||
): charts is DashboardOverviewCharts {
|
): charts is DashboardOverviewCharts {
|
||||||
return 'deplesi' in charts;
|
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
|
// Type guard to check if charts is DashboardComparisonCharts
|
||||||
@@ -94,6 +103,7 @@ const getLineColor = (
|
|||||||
const DashboardLineChart = ({
|
const DashboardLineChart = ({
|
||||||
analysisMode,
|
analysisMode,
|
||||||
data,
|
data,
|
||||||
|
selectedKandang,
|
||||||
}: DashboardLineChartProps) => {
|
}: DashboardLineChartProps) => {
|
||||||
const [chartData, setChartData] =
|
const [chartData, setChartData] =
|
||||||
useState<keyof DashboardOverviewCharts>('body_weight');
|
useState<keyof DashboardOverviewCharts>('body_weight');
|
||||||
@@ -452,11 +462,84 @@ const DashboardLineChart = ({
|
|||||||
labelStyle={{ color: 'white', marginBottom: '4px' }}
|
labelStyle={{ color: 'white', marginBottom: '4px' }}
|
||||||
itemStyle={{ color: 'white', fontSize: '12px' }}
|
itemStyle={{ color: 'white', fontSize: '12px' }}
|
||||||
labelFormatter={(value) => `Week ${value}`}
|
labelFormatter={(value) => `Week ${value}`}
|
||||||
|
content={(props) => {
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col gap-2 rounded-lg bg-neutral-950 p-4 text-white'>
|
||||||
|
<p className='text-neutral-300 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}
|
||||||
|
className='flex w-full justify-between items-center flex-row gap-6 p-0'
|
||||||
|
>
|
||||||
|
<span className='flex flex-row gap-1 items-center'>
|
||||||
|
<div
|
||||||
|
className='h-4 w-4 m-0 rounded-md'
|
||||||
|
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-neutral-300 text-xs text-start'>
|
||||||
|
Week {props.label}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
formatter={(
|
formatter={(
|
||||||
value: number | undefined,
|
value: number | undefined,
|
||||||
name: string | undefined
|
name: string | undefined
|
||||||
) => {
|
) => {
|
||||||
if (value === undefined || name === undefined) return ['', ''];
|
if (
|
||||||
|
value === undefined ||
|
||||||
|
name === undefined ||
|
||||||
|
name.startsWith('STD. ')
|
||||||
|
)
|
||||||
|
return [undefined, undefined];
|
||||||
|
|
||||||
// Get series data to find the unit
|
// Get series data to find the unit
|
||||||
let seriesData: DashboardChartsSeries[] = [];
|
let seriesData: DashboardChartsSeries[] = [];
|
||||||
@@ -478,9 +561,9 @@ const DashboardLineChart = ({
|
|||||||
|
|
||||||
// Find the series that matches this line's name
|
// Find the series that matches this line's name
|
||||||
const series = seriesData.find((s) => s.label === name);
|
const series = seriesData.find((s) => s.label === name);
|
||||||
const unit = series?.unit || '';
|
const id = series?.id || '';
|
||||||
|
|
||||||
return [`${value} ${unit}`, name];
|
return [value, id];
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Dynamic Line rendering based on visible series */}
|
{/* Dynamic Line rendering based on visible series */}
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import jsPDF from 'jspdf';
|
||||||
|
import { toPng } from 'html-to-image';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { formatDate } from '@/lib/helper';
|
||||||
|
import { DashboardFilterType } from '@/components/pages/dashboard/filter/DashboardProductionFilter.schema';
|
||||||
|
import { DashboardAllChartsRef } from '@/components/pages/dashboard/chart/DashboardAllCharts';
|
||||||
|
|
||||||
|
interface DashboardPDFExportParams {
|
||||||
|
filterValues: DashboardFilterType;
|
||||||
|
statsRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
allChartsRef: React.RefObject<DashboardAllChartsRef | null>;
|
||||||
|
setExporting: (value: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateDashboardPDF = async ({
|
||||||
|
filterValues,
|
||||||
|
statsRef,
|
||||||
|
allChartsRef,
|
||||||
|
setExporting,
|
||||||
|
}: DashboardPDFExportParams): Promise<void> => {
|
||||||
|
try {
|
||||||
|
setExporting(true);
|
||||||
|
toast.loading('Generating PDF...', { id: 'export-pdf' });
|
||||||
|
|
||||||
|
// Wait for DOM to update
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
const pdf = new jsPDF('p', 'mm', 'a4');
|
||||||
|
const pageWidth = pdf.internal.pageSize.getWidth();
|
||||||
|
const pageHeight = pdf.internal.pageSize.getHeight();
|
||||||
|
const margin = 10;
|
||||||
|
let yPosition = margin;
|
||||||
|
|
||||||
|
// Add title
|
||||||
|
pdf.setFontSize(16);
|
||||||
|
pdf.setFont('helvetica', 'bold');
|
||||||
|
pdf.text('Dashboard Produksi', margin, yPosition);
|
||||||
|
yPosition += 10;
|
||||||
|
|
||||||
|
// Add filter information (horizontal layout)
|
||||||
|
pdf.setFontSize(6);
|
||||||
|
pdf.setFont('helvetica', 'normal');
|
||||||
|
|
||||||
|
const filterItems: string[] = [];
|
||||||
|
|
||||||
|
// Period
|
||||||
|
if (filterValues.startDate || filterValues.endDate) {
|
||||||
|
const periodText = `Periode: ${
|
||||||
|
filterValues.startDate
|
||||||
|
? formatDate(filterValues.startDate, 'DD MMM YYYY')
|
||||||
|
: '-'
|
||||||
|
} s.d ${
|
||||||
|
filterValues.endDate
|
||||||
|
? formatDate(filterValues.endDate, 'DD MMM YYYY')
|
||||||
|
: '-'
|
||||||
|
}`;
|
||||||
|
filterItems.push(periodText);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analysis Mode
|
||||||
|
const analysisModeText = `Analysis Mode: ${
|
||||||
|
filterValues.analysisMode === 'OVERVIEW'
|
||||||
|
? 'Performance Overview'
|
||||||
|
: 'Performance Comparison'
|
||||||
|
}`;
|
||||||
|
filterItems.push(analysisModeText);
|
||||||
|
|
||||||
|
// Comparison Type (only for COMPARISON mode)
|
||||||
|
if (
|
||||||
|
filterValues.analysisMode === 'COMPARISON' &&
|
||||||
|
filterValues.comparisonType
|
||||||
|
) {
|
||||||
|
const comparisonTypeLabel =
|
||||||
|
filterValues.comparisonType === 'FARM'
|
||||||
|
? 'Farm'
|
||||||
|
: filterValues.comparisonType === 'FLOCK'
|
||||||
|
? 'Flock'
|
||||||
|
: filterValues.comparisonType === 'KANDANG'
|
||||||
|
? 'Kandang'
|
||||||
|
: filterValues.comparisonType;
|
||||||
|
filterItems.push(`Compared By: ${comparisonTypeLabel}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Farm
|
||||||
|
if (filterValues.location) {
|
||||||
|
const locationText = Array.isArray(filterValues.location)
|
||||||
|
? filterValues.location.map((loc) => loc.label).join(', ')
|
||||||
|
: filterValues.location.label;
|
||||||
|
filterItems.push(`Farm: ${locationText || '-'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flock
|
||||||
|
if (
|
||||||
|
filterValues.flock &&
|
||||||
|
(Array.isArray(filterValues.flock)
|
||||||
|
? filterValues.flock.length > 0
|
||||||
|
: filterValues.flock)
|
||||||
|
) {
|
||||||
|
const flockText = Array.isArray(filterValues.flock)
|
||||||
|
? filterValues.flock.map((f) => f.label).join(', ')
|
||||||
|
: filterValues.flock.label;
|
||||||
|
filterItems.push(`Flock: ${flockText || '-'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kandang
|
||||||
|
if (
|
||||||
|
filterValues.kandang &&
|
||||||
|
(Array.isArray(filterValues.kandang)
|
||||||
|
? filterValues.kandang.length > 0
|
||||||
|
: filterValues.kandang)
|
||||||
|
) {
|
||||||
|
const kandangText = Array.isArray(filterValues.kandang)
|
||||||
|
? filterValues.kandang.map((k) => k.label).join(', ')
|
||||||
|
: filterValues.kandang.label;
|
||||||
|
filterItems.push(`Kandang: ${kandangText || '-'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generated timestamp
|
||||||
|
filterItems.push(`Dicetak: ${formatDate(new Date(), 'DD MMM YYYY HH:mm')}`);
|
||||||
|
|
||||||
|
// Render filter items horizontally with word wrap and gray background
|
||||||
|
const maxWidth = pageWidth - 2 * margin;
|
||||||
|
let currentLine = '';
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
// First pass: calculate all lines
|
||||||
|
filterItems.forEach((item, index) => {
|
||||||
|
const separator = index > 0 ? ' | ' : '';
|
||||||
|
const testLine = currentLine + separator + item;
|
||||||
|
const testWidth = pdf.getTextWidth(testLine);
|
||||||
|
|
||||||
|
if (testWidth > maxWidth && currentLine !== '') {
|
||||||
|
lines.push(currentLine);
|
||||||
|
currentLine = item;
|
||||||
|
} else {
|
||||||
|
currentLine = testLine;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add last line
|
||||||
|
if (currentLine) {
|
||||||
|
lines.push(currentLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate background dimensions
|
||||||
|
const lineHeight = 3;
|
||||||
|
const padding = 1;
|
||||||
|
const backgroundHeight = lines.length * lineHeight + padding * 2;
|
||||||
|
|
||||||
|
// Draw gray background
|
||||||
|
pdf.setFillColor(240, 240, 240); // Light gray (RGB: 240, 240, 240)
|
||||||
|
pdf.rect(
|
||||||
|
margin - padding,
|
||||||
|
yPosition - padding - 2,
|
||||||
|
pageWidth - 2 * margin + padding * 2,
|
||||||
|
backgroundHeight,
|
||||||
|
'F'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render text on top of background
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
pdf.text(line, margin, yPosition);
|
||||||
|
if (index < lines.length - 1) {
|
||||||
|
yPosition += lineHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
yPosition += 10;
|
||||||
|
|
||||||
|
// Capture and add stats if available
|
||||||
|
if (statsRef.current) {
|
||||||
|
const statsImage = await toPng(statsRef.current, {
|
||||||
|
quality: 1,
|
||||||
|
pixelRatio: 2,
|
||||||
|
});
|
||||||
|
const statsImgProps = pdf.getImageProperties(statsImage);
|
||||||
|
const statsWidth = pageWidth - 2 * margin;
|
||||||
|
const statsHeight =
|
||||||
|
(statsImgProps.height * statsWidth) / statsImgProps.width;
|
||||||
|
|
||||||
|
// Check if we need a new page
|
||||||
|
if (yPosition + statsHeight > pageHeight - margin) {
|
||||||
|
pdf.addPage();
|
||||||
|
yPosition = margin;
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf.addImage(
|
||||||
|
statsImage,
|
||||||
|
'PNG',
|
||||||
|
margin,
|
||||||
|
yPosition,
|
||||||
|
statsWidth,
|
||||||
|
statsHeight
|
||||||
|
);
|
||||||
|
yPosition += statsHeight + 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allChartsRef.current) {
|
||||||
|
// Get all individual chart refs
|
||||||
|
const chartRefs = allChartsRef.current.getChartRefs();
|
||||||
|
|
||||||
|
// Capture each chart separately and add to PDF
|
||||||
|
for (let i = 0; i < chartRefs.length; i++) {
|
||||||
|
const { ref: chartElement, label } = chartRefs[i];
|
||||||
|
|
||||||
|
if (chartElement) {
|
||||||
|
// Add chart title
|
||||||
|
pdf.setFontSize(12);
|
||||||
|
pdf.setFont('helvetica', 'bold');
|
||||||
|
|
||||||
|
const chartImage = await toPng(chartElement, {
|
||||||
|
quality: 1,
|
||||||
|
pixelRatio: 2,
|
||||||
|
});
|
||||||
|
const chartImgProps = pdf.getImageProperties(chartImage);
|
||||||
|
const chartWidth = pageWidth - 2 * margin;
|
||||||
|
const chartHeight =
|
||||||
|
(chartImgProps.height * chartWidth) / chartImgProps.width;
|
||||||
|
|
||||||
|
// Calculate total height needed (title + spacing + chart)
|
||||||
|
const titleHeight = 10;
|
||||||
|
const totalHeight = titleHeight + chartHeight;
|
||||||
|
|
||||||
|
// Check if chart fits on current page
|
||||||
|
if (yPosition + totalHeight > pageHeight - margin) {
|
||||||
|
pdf.addPage();
|
||||||
|
yPosition = margin;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add title
|
||||||
|
pdf.text(label, margin, yPosition);
|
||||||
|
yPosition += titleHeight;
|
||||||
|
|
||||||
|
// Add chart image
|
||||||
|
pdf.addImage(
|
||||||
|
chartImage,
|
||||||
|
'PNG',
|
||||||
|
margin,
|
||||||
|
yPosition,
|
||||||
|
chartWidth,
|
||||||
|
chartHeight
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update yPosition for next chart (add spacing between charts)
|
||||||
|
yPosition += chartHeight + 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the PDF
|
||||||
|
const fileName = `dashboard-production-${new Date().toISOString().split('T')[0]}.pdf`;
|
||||||
|
pdf.save(fileName);
|
||||||
|
|
||||||
|
toast.success('PDF exported successfully!', { id: 'export-pdf' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating PDF:', error);
|
||||||
|
toast.error('Failed to export PDF. Please try again.', {
|
||||||
|
id: 'export-pdf',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user