diff --git a/package-lock.json b/package-lock.json index 38844543..61da9724 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "cmdk": "^1.1.1", "embla-carousel-react": "^8.6.0", "formik": "^2.4.6", + "html-to-image": "^1.11.13", "input-otp": "^1.4.2", "jspdf": "^3.0.4", "jspdf-autotable": "^5.0.2", @@ -7380,6 +7381,12 @@ "integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==", "license": "ISC" }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, "node_modules/html2canvas": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", diff --git a/package.json b/package.json index 3a775db2..981413b3 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "cmdk": "^1.1.1", "embla-carousel-react": "^8.6.0", "formik": "^2.4.6", + "html-to-image": "^1.11.13", "input-otp": "^1.4.2", "jspdf": "^3.0.4", "jspdf-autotable": "^5.0.2", diff --git a/src/components/pages/dashboard/DashboardProduction.tsx b/src/components/pages/dashboard/DashboardProduction.tsx index 0175a8be..079b082c 100644 --- a/src/components/pages/dashboard/DashboardProduction.tsx +++ b/src/components/pages/dashboard/DashboardProduction.tsx @@ -8,19 +8,22 @@ import SelectInput, { OptionType, useSelect, } from '@/components/input/SelectInput'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import useSWR from 'swr'; import { DashboardApi } from '@/services/api/dashboard'; import { useFormik } from 'formik'; import { ProjectFlockApi } from '@/services/api/production'; import { KandangApi, LocationApi } from '@/services/api/master-data'; - +import { generateDashboardPDF } from '@/components/pages/dashboard/export/DashboardPDF'; import { DashboardFilterType, getDashboardFilterSchema, } from '@/components/pages/dashboard/filter/DashboardProductionFilter.schema'; import DashboardLineChart from '@/components/pages/dashboard/chart/DashboardLineChart'; 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 { DashboardFilter, @@ -31,10 +34,10 @@ import { isResponseSuccess } from '@/lib/api-helper'; import AlertErrorList from '@/components/helper/form/FormErrors'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import ButtonFilter from '@/components/helper/ButtonFilter'; -import toast from 'react-hot-toast'; import Dropdown from '@/components/Dropdown'; import Menu from '@/components/menu/Menu'; import MenuItem from '@/components/menu/MenuItem'; +import { useDashboardStore } from '@/stores/dashboard'; // Helper function to normalize values to array const normalizeToArray = ( @@ -49,12 +52,22 @@ const normalizeToArray = ( const DashboardProduction = () => { const filterModal = useModal(); + + // ===== DASHBOARD STORE ===== + const { filterValues, setFilterValues, resetFilterValues } = + useDashboardStore(); + const [analysisMode, setAnalysisMode] = useState<'OVERVIEW' | 'COMPARISON'>( - 'OVERVIEW' + (filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON') || 'OVERVIEW' ); const [endpointUrl, setEndpointUrl] = useState('/dashboards'); - const [selectedLocationIds, setSelectedLocationIds] = useState([]); + const [selectedLocationIds, setSelectedLocationIds] = useState( + normalizeToArray(filterValues.location) + ); const [exporting, setExporting] = useState(false); + const statsRef = useRef(null); + const chartRef = useRef(null); + const allChartsRef = useRef(null); // ===== FETCH DATA ===== const { @@ -105,19 +118,22 @@ const DashboardProduction = () => { // ===== FORMIK ===== const formik = useFormik({ initialValues: { - startDate: '', - endDate: '', - flock: [] as OptionType[], - location: [] as OptionType[], - kandang: [] as OptionType[], - analysisMode: analysisMode, - comparisonType: '', - lokasiIds: [], - flockIds: [], - kandangIds: [], + startDate: filterValues.startDate || '', + endDate: filterValues.endDate || '', + flock: filterValues.flock || ([] as OptionType[]), + location: filterValues.location || ([] as OptionType[]), + kandang: filterValues.kandang || ([] as OptionType[]), + analysisMode: filterValues.analysisMode || analysisMode, + comparisonType: filterValues.comparisonType || '', + locationIds: filterValues.locationIds || [], + flockIds: filterValues.flockIds || [], + kandangIds: filterValues.kandangIds || [], } as DashboardFilterType, validationSchema: getDashboardFilterSchema(analysisMode), onSubmit: (values) => { + // Save filter values to store + setFilterValues(values); + handleApplyFilter({ start_date: values.startDate || '', end_date: values.endDate || '', @@ -132,8 +148,10 @@ const DashboardProduction = () => { const handleResetFilter = () => { formik.resetForm(); + resetFilterValues(); // Clear stored filter values setAnalysisMode('OVERVIEW'); setEndpointUrl('/dashboards'); + setSelectedLocationIds([]); }; const handleApplyFilter = (values: DashboardFilter) => { @@ -156,25 +174,33 @@ const DashboardProduction = () => { refreshDashboardProductionData(); }; + // ===== Load filter from store on mount ===== + useEffect(() => { + if (!filterValues) return; + handleApplyFilter({ + start_date: filterValues.startDate, + end_date: filterValues.endDate, + analysis_mode: filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON', + location_ids: normalizeToArray(filterValues.location), + flock_ids: normalizeToArray(filterValues.flock), + kandang_ids: normalizeToArray(filterValues.kandang), + comparison_type: filterValues.comparisonType, + }); + }, [filterValues]); + // ===== Formik Error List ===== const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik); // ===== Export PDF ===== - const handleExportPDF = () => { - setExporting(true); + const handleExportPDF = async () => { + 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) { return (
@@ -219,34 +245,71 @@ const DashboardProduction = () => {
{/* Dashboard Stats */} - +
+ +
{/* Use DashboardLineChart component or skeleton */} - {isLoadingDashboardProductionData ? ( - - ) : dashboardProductionData && - dashboardProductionData.charts && - Object.keys(dashboardProductionData.charts).length > 0 ? ( - + {isLoadingDashboardProductionData ? ( + + ) : dashboardProductionData && + dashboardProductionData.charts && + Object.keys(dashboardProductionData.charts).length > 0 ? ( + - ) : ( - + } + data={dashboardProductionData} + selectedKandang={ + analysisMode === 'OVERVIEW' + ? (formik.values.kandang as OptionType) + : undefined + } + /> + ) : ( + + )} + + + {/* Hidden container for all charts (used for PDF export in OVERVIEW mode) */} + {dashboardProductionData && ( +
+ +
)} @@ -475,7 +538,6 @@ const DashboardProduction = () => { type='reset' variant='soft' className='ms-4 min-w-36 rounded-lg' - onClick={handleResetFilter} > Reset Filter diff --git a/src/components/pages/dashboard/chart/DashboardAllCharts.tsx b/src/components/pages/dashboard/chart/DashboardAllCharts.tsx new file mode 100644 index 00000000..fe0db0a7 --- /dev/null +++ b/src/components/pages/dashboard/chart/DashboardAllCharts.tsx @@ -0,0 +1,343 @@ +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 | 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 = { + 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 = { + 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 = { + 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 ( +
+ {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 ( +
{ + chartRefs.current[key] = el; + }} + > + +
+
+ {label}{' '} + +
+
+ + {/* Legend */} +
+ {seriesData.map((series, index) => { + const isStandard = series.id + .toString() + .toLowerCase() + .includes('std'); + + return ( +
+
+ + {series.label} + + +
+ ); + })} +
+ + {/* Chart */} + + + + + { + 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 ( + + ); + })} + + +
+
+ ); + })} +
+ ); +}); + +DashboardAllCharts.displayName = 'DashboardAllCharts'; + +export default DashboardAllCharts; diff --git a/src/components/pages/dashboard/chart/DashboardLineChart.tsx b/src/components/pages/dashboard/chart/DashboardLineChart.tsx index 348f6c43..f2449795 100644 --- a/src/components/pages/dashboard/chart/DashboardLineChart.tsx +++ b/src/components/pages/dashboard/chart/DashboardLineChart.tsx @@ -1,8 +1,10 @@ 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, @@ -25,20 +27,29 @@ import { type DashboardLineChartProps = { analysisMode: 'OVERVIEW' | 'COMPARISON'; data: Dashboard; + selectedKandang?: OptionType; }; // Type guard to check if charts is DashboardOverviewCharts function isOverviewCharts( - charts: DashboardOverviewCharts | DashboardComparisonCharts + charts: DashboardOverviewCharts | DashboardComparisonCharts | undefined ): charts is DashboardOverviewCharts { - return 'deplesi' in charts; + 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 + charts: DashboardOverviewCharts | DashboardComparisonCharts | undefined ): charts is DashboardComparisonCharts { - return 'location' in charts || 'flock' in charts || 'kandang' in charts; + if (!charts) return false; + return 'farm' in charts || 'flock' in charts || 'kandang' in charts; } const lineColors: Record = { @@ -94,6 +105,7 @@ const getLineColor = ( const DashboardLineChart = ({ analysisMode, data, + selectedKandang, }: DashboardLineChartProps) => { const [chartData, setChartData] = useState('body_weight'); @@ -123,7 +135,7 @@ const DashboardLineChart = ({ isComparisonCharts(data.charts) ) { const comparisonChart = - data.charts.location || data.charts.flock || data.charts.kandang; + data.charts.farm || data.charts.flock || data.charts.kandang; seriesData = comparisonChart?.series || []; } @@ -224,7 +236,7 @@ const DashboardLineChart = ({ isComparisonCharts(data.charts) ) { const comparisonChart = - data.charts.location || data.charts.flock || data.charts.kandang; + data.charts.farm || data.charts.flock || data.charts.kandang; seriesData = comparisonChart?.series || []; } @@ -303,7 +315,7 @@ const DashboardLineChart = ({ // For COMPARISON mode, use the first available comparison chart if (isComparisonCharts(data.charts)) { const chartData = - data.charts.location || + data.charts.farm || data.charts.flock || data.charts.kandang; @@ -353,7 +365,7 @@ const DashboardLineChart = ({ isComparisonCharts(data.charts) ) { const comparisonChart = - data.charts.location || + data.charts.farm || data.charts.flock || data.charts.kandang; seriesData = comparisonChart?.series || []; @@ -401,7 +413,7 @@ const DashboardLineChart = ({ isComparisonCharts(data.charts) ) { const comparisonChart = - data.charts.location || + data.charts.farm || data.charts.flock || data.charts.kandang; seriesData = comparisonChart?.series || []; @@ -452,11 +464,84 @@ const DashboardLineChart = ({ labelStyle={{ color: 'white', marginBottom: '4px' }} itemStyle={{ color: 'white', fontSize: '12px' }} labelFormatter={(value) => `Week ${value}`} + content={(props) => { + return ( +
+

+ {analysisMode === 'OVERVIEW' + ? selectedKandang + ? selectedKandang.label || 'Overview Performance' + : 'Overview Performance' + : 'Comparison Performance'} +

+
    + {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 ( +
  • + +
    +
    + {formatNumber(item.value)} + {unit} +
    +
    + {item.name} +
  • + ); + })} +
+

+ Week {props.label} +

+
+ ); + }} formatter={( value: number | 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 let seriesData: DashboardChartsSeries[] = []; @@ -470,7 +555,7 @@ const DashboardLineChart = ({ isComparisonCharts(data.charts) ) { const comparisonChart = - data.charts.location || + data.charts.farm || data.charts.flock || data.charts.kandang; seriesData = comparisonChart?.series || []; @@ -478,9 +563,9 @@ const DashboardLineChart = ({ // Find the series that matches this line's 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 */} @@ -497,9 +582,7 @@ const DashboardLineChart = ({ isComparisonCharts(data.charts) ) { const comparisonChart = - data.charts.location || - data.charts.flock || - data.charts.kandang; + data.charts.farm || data.charts.flock || data.charts.kandang; seriesData = comparisonChart?.series || []; } @@ -557,7 +640,7 @@ const DashboardLineChart = ({ isComparisonCharts(data.charts) ) { const comparisonChart = - data.charts.location || data.charts.flock || data.charts.kandang; + data.charts.farm || data.charts.flock || data.charts.kandang; dataset = comparisonChart?.dataset || []; } diff --git a/src/components/pages/dashboard/export/DashboardPDF.ts b/src/components/pages/dashboard/export/DashboardPDF.ts new file mode 100644 index 00000000..dfb1a766 --- /dev/null +++ b/src/components/pages/dashboard/export/DashboardPDF.ts @@ -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; + allChartsRef: React.RefObject; + setExporting: (value: boolean) => void; +} + +export const generateDashboardPDF = async ({ + filterValues, + statsRef, + allChartsRef, + setExporting, +}: DashboardPDFExportParams): Promise => { + 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); + } +}; diff --git a/src/components/pages/dashboard/filter/DashboardProductionFilter.schema.ts b/src/components/pages/dashboard/filter/DashboardProductionFilter.schema.ts index d62157a8..1e03252b 100644 --- a/src/components/pages/dashboard/filter/DashboardProductionFilter.schema.ts +++ b/src/components/pages/dashboard/filter/DashboardProductionFilter.schema.ts @@ -7,7 +7,7 @@ export type DashboardFilterType = { analysisMode: string; comparisonType: string | undefined; location: OptionType | OptionType[]; - lokasiIds: number[] | undefined; + locationIds: number[] | undefined; flock: OptionType | OptionType[] | undefined; flockIds: number[] | undefined; kandang: OptionType | OptionType[] | undefined; @@ -25,7 +25,7 @@ export const DashboardFilterOverviewSchema: yup.ObjectSchema schema.required('Compared by is required'), otherwise: (schema) => schema.optional(), }), - lokasiIds: yup.array().optional(), + locationIds: yup.array().optional(), flockIds: yup.array().optional(), kandangIds: yup.array().optional(), location: yup @@ -68,7 +68,7 @@ export const DashboardFilterComparisonSchema: yup.ObjectSchema schema.required('Compared by is required'), otherwise: (schema) => schema.optional(), }), - lokasiIds: yup.array().optional(), + locationIds: yup.array().optional(), flockIds: yup.array().optional(), kandangIds: yup.array().optional(), location: yup diff --git a/src/stores/dashboard/dashboard.store.ts b/src/stores/dashboard/dashboard.store.ts new file mode 100644 index 00000000..439ab3d3 --- /dev/null +++ b/src/stores/dashboard/dashboard.store.ts @@ -0,0 +1,24 @@ +'use client'; + +import { create } from 'zustand'; +import { devtools, persist } from 'zustand/middleware'; +import { createDashboardFilterSlice } from '@/stores/dashboard/slices/dashboard-filter.slice'; +import { DashboardFilterSlice } from '@/types/stores'; + +export type DashboardStore = DashboardFilterSlice; + +export const useDashboardStore = create()( + devtools( + persist( + (...args) => ({ + ...createDashboardFilterSlice(...args), + }), + { + name: 'dashboard-filter-cache', + } + ), + { + name: 'DashboardStore', + } + ) +); diff --git a/src/stores/dashboard/index.ts b/src/stores/dashboard/index.ts new file mode 100644 index 00000000..3ccba369 --- /dev/null +++ b/src/stores/dashboard/index.ts @@ -0,0 +1,2 @@ +export { useDashboardStore } from './dashboard.store'; +export type { DashboardStore } from './dashboard.store'; diff --git a/src/stores/dashboard/slices/dashboard-filter.slice.ts b/src/stores/dashboard/slices/dashboard-filter.slice.ts new file mode 100644 index 00000000..086ea959 --- /dev/null +++ b/src/stores/dashboard/slices/dashboard-filter.slice.ts @@ -0,0 +1,45 @@ +import { DashboardFilterSlice } from '@/types/stores'; +import { StateCreator } from 'zustand'; + +export const createDashboardFilterSlice: StateCreator< + DashboardFilterSlice, + [], + [], + DashboardFilterSlice +> = (set) => ({ + // Initial state + filterValues: { + startDate: '', + endDate: '', + analysisMode: 'OVERVIEW', + comparisonType: undefined, + location: [], + locationIds: undefined, + flock: undefined, + flockIds: undefined, + kandang: undefined, + kandangIds: undefined, + }, + + // Actions + setFilterValues: (values) => set({ filterValues: values }), + + resetFilterValues: () => { + alert('reset filter values'); + + return set({ + filterValues: { + startDate: '', + endDate: '', + analysisMode: 'OVERVIEW', + comparisonType: undefined, + location: [], + locationIds: undefined, + flock: undefined, + flockIds: undefined, + kandang: undefined, + kandangIds: undefined, + }, + }); + }, +}); diff --git a/src/types/api/dashboard/dashboard.d.ts b/src/types/api/dashboard/dashboard.d.ts index ec3dafdb..749b469a 100644 --- a/src/types/api/dashboard/dashboard.d.ts +++ b/src/types/api/dashboard/dashboard.d.ts @@ -6,7 +6,7 @@ export interface Dashboard { } export interface DashboardComparisonCharts { - location: DashboardCharts; + farm: DashboardCharts; flock: DashboardCharts; kandang: DashboardCharts; } diff --git a/src/types/stores.d.ts b/src/types/stores.d.ts index 48873805..528309c7 100644 --- a/src/types/stores.d.ts +++ b/src/types/stores.d.ts @@ -1,3 +1,4 @@ +import { DashboardFilterType } from '@/components/pages/dashboard/filter/DashboardProductionFilter.schema'; import type { ProductionStandardRepeaterFormSchemaValues } from '@/components/pages/master-data/production-standard/form/ProductionStandardForm.schema'; import type { UniformityFormData, @@ -70,3 +71,13 @@ export type UniformitySlice = { setCreatedUniformity: (data: UniformityDetail | null) => void; resetUniformity: () => void; }; + +// Dashboard Filter Slice +export type DashboardFilterSlice = { + // State + filterValues: DashboardFilterType; + + // Actions + setFilterValues: (values: DashboardFilterType) => void; + resetFilterValues: () => void; +};