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/input/DateInput.tsx b/src/components/input/DateInput.tsx index 2d55fe6d..a424d723 100644 --- a/src/components/input/DateInput.tsx +++ b/src/components/input/DateInput.tsx @@ -113,7 +113,15 @@ const DateInput = ({ }; const handleSelectSingle = (selectedDate?: Date) => { - if (!selectedDate) return; + if (!selectedDate) { + setSelected(undefined); + setDisplayValue(''); + const syntheticEvent = { + target: { name, value: '' }, + } as unknown as React.ChangeEvent; + onChange?.(syntheticEvent); + return; + } if (minDate && selectedDate < minDate) { setInternalError(`Tanggal tidak boleh sebelum ${min}`); return; @@ -136,7 +144,15 @@ const DateInput = ({ }; const handleSelectRange = (range?: { from?: Date; to?: Date }) => { - if (!range) return; + if (!range) { + setSelectedRange({}); + setDisplayValue(''); + const syntheticEvent = { + target: { name, value: { from: '', to: '' } }, + } as unknown as React.ChangeEvent; + onChange?.(syntheticEvent); + return; + } setSelectedRange(range); const fromStr = range.from ? formatDate(range.from, 'DD/MM/YYYY') : ''; diff --git a/src/components/pages/closing/ClosingIncomingSapronaksSummaryTable.tsx b/src/components/pages/closing/ClosingIncomingSapronaksSummaryTable.tsx new file mode 100644 index 00000000..49e4f108 --- /dev/null +++ b/src/components/pages/closing/ClosingIncomingSapronaksSummaryTable.tsx @@ -0,0 +1,174 @@ +'use client'; + +import { ChangeEventHandler, useEffect, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; +import { ColumnDef, SortingState } from '@tanstack/react-table'; + +import { Icon } from '@iconify/react'; +import Table from '@/components/Table'; +import Card from '@/components/Card'; +import Collapse from '@/components/Collapse'; + +import { cn, formatNumber } from '@/lib/helper'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import { ClosingApi } from '@/services/api/closing'; +import { ClosingIncomingSapronakSummary } from '@/types/api/closing'; + +interface ClosingIncomingSapronaksSummaryTableProps { + projectFlockId: number; +} + +const ClosingIncomingSapronaksSummaryTable = ({ + projectFlockId, +}: ClosingIncomingSapronaksSummaryTableProps) => { + const searchParams = useSearchParams(); + const kandangId = searchParams.get('kandangId'); + + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { + search: '', + nameSort: '', + }, + paramMap: { + page: 'page', + pageSize: 'limit', + nameSort: 'sort_name', + }, + }); + + const { + data: incomingSapronakSummaries, + isLoading: isLoadingIncomingSapronakSummaries, + } = useSWR( + `${ClosingApi.basePath}/${projectFlockId}/sapronak/summary${getTableFilterQueryString()}&type=incoming&kandang_id=${kandangId ? `${kandangId}` : ''}`, + ClosingApi.getAllIncomingSapronakSummaryFetcher, + { + keepPreviousData: true, + } + ); + + const [open, setOpen] = useState(true); + + const [sorting, setSorting] = useState([]); + const [rowSelection, setRowSelection] = useState>({}); + + const incomingSapronaksColumns: ColumnDef[] = + [ + { + header: '#', + cell: (props) => props.row.index + 1, + }, + { + accessorKey: 'category', + header: 'Kategori', + }, + { + accessorKey: 'total_qty', + header: 'Total Kuantitas', + cell: (props) => + `${formatNumber(props.row.original.total_qty)} ${props.row.original.uom.name}`, + }, + ]; + + const searchChangeHandler: ChangeEventHandler = (e) => { + updateFilter('search', e.target.value); + }; + + // track sorting + useEffect(() => { + const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name'); + + if (!isNameSorted) { + updateFilter('nameSort', ''); + } else { + updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc'); + } + }, [sorting, updateFilter]); + + useEffect(() => { + if (!open) { + setOpen( + isResponseSuccess(incomingSapronakSummaries) + ? incomingSapronakSummaries.data.length > 0 + : false + ); + } + }, [incomingSapronakSummaries, isResponseSuccess]); + + return ( + + +
Ringkasan Sapronak Masuk
+ + + + } + className='w-full!' + titleClassName='w-full p-0!' + > +
+ + data={ + isResponseSuccess(incomingSapronakSummaries) + ? incomingSapronakSummaries?.data + : [] + } + columns={incomingSapronaksColumns} + pageSize={tableFilterState.pageSize} + onPageSizeChange={setPageSize} + rowOptions={[10, 20, 50, 100]} + page={ + isResponseSuccess(incomingSapronakSummaries) + ? incomingSapronakSummaries?.meta?.page + : 0 + } + totalItems={ + isResponseSuccess(incomingSapronakSummaries) + ? incomingSapronakSummaries?.meta?.total_results + : 0 + } + onPageChange={setPage} + isLoading={isLoadingIncomingSapronakSummaries} + sorting={sorting} + setSorting={setSorting} + rowSelection={rowSelection} + setRowSelection={setRowSelection} + className={{ + containerClassName: cn({ + 'w-full mb-20': + isResponseSuccess(incomingSapronakSummaries) && + incomingSapronakSummaries?.data?.length === 0, + }), + }} + /> +
+
+
+ ); +}; + +export default ClosingIncomingSapronaksSummaryTable; diff --git a/src/components/pages/closing/ClosingOutgoingSapronaksSummaryTable.tsx b/src/components/pages/closing/ClosingOutgoingSapronaksSummaryTable.tsx new file mode 100644 index 00000000..42fcb588 --- /dev/null +++ b/src/components/pages/closing/ClosingOutgoingSapronaksSummaryTable.tsx @@ -0,0 +1,174 @@ +'use client'; + +import { ChangeEventHandler, useEffect, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; +import { ColumnDef, SortingState } from '@tanstack/react-table'; + +import { Icon } from '@iconify/react'; +import Table from '@/components/Table'; +import Card from '@/components/Card'; +import Collapse from '@/components/Collapse'; + +import { cn, formatNumber } from '@/lib/helper'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import { ClosingApi } from '@/services/api/closing'; +import { ClosingOutgoingSapronakSummary } from '@/types/api/closing'; + +interface ClosingOutgoingSapronaksSummaryTableProps { + projectFlockId: number; +} + +const ClosingOutgoingSapronaksSummaryTable = ({ + projectFlockId, +}: ClosingOutgoingSapronaksSummaryTableProps) => { + const searchParams = useSearchParams(); + const kandangId = searchParams.get('kandangId'); + + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { + search: '', + nameSort: '', + }, + paramMap: { + page: 'page', + pageSize: 'limit', + nameSort: 'sort_name', + }, + }); + + const { + data: outgoingSapronakSummaries, + isLoading: isLoadingOutgoingSapronakSummaries, + } = useSWR( + `${ClosingApi.basePath}/${projectFlockId}/sapronak/summary${getTableFilterQueryString()}&type=outgoing&kandang_id=${kandangId ? `${kandangId}` : ''}`, + ClosingApi.getAllIncomingSapronakSummaryFetcher, + { + keepPreviousData: true, + } + ); + + const [open, setOpen] = useState(true); + + const [sorting, setSorting] = useState([]); + const [rowSelection, setRowSelection] = useState>({}); + + const outgoingSapronaksColumns: ColumnDef[] = + [ + { + header: '#', + cell: (props) => props.row.index + 1, + }, + { + accessorKey: 'category', + header: 'Kategori', + }, + { + accessorKey: 'total_qty', + header: 'Total Kuantitas', + cell: (props) => + `${formatNumber(props.row.original.total_qty)} ${props.row.original.uom.name}`, + }, + ]; + + const searchChangeHandler: ChangeEventHandler = (e) => { + updateFilter('search', e.target.value); + }; + + // track sorting + useEffect(() => { + const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name'); + + if (!isNameSorted) { + updateFilter('nameSort', ''); + } else { + updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc'); + } + }, [sorting, updateFilter]); + + useEffect(() => { + if (!open) { + setOpen( + isResponseSuccess(outgoingSapronakSummaries) + ? outgoingSapronakSummaries.data.length > 0 + : false + ); + } + }, [outgoingSapronakSummaries, isResponseSuccess]); + + return ( + + +
Ringkasan Sapronak Keluar
+ + + + } + className='w-full!' + titleClassName='w-full p-0!' + > +
+ + data={ + isResponseSuccess(outgoingSapronakSummaries) + ? outgoingSapronakSummaries?.data + : [] + } + columns={outgoingSapronaksColumns} + pageSize={tableFilterState.pageSize} + onPageSizeChange={setPageSize} + rowOptions={[10, 20, 50, 100]} + page={ + isResponseSuccess(outgoingSapronakSummaries) + ? outgoingSapronakSummaries?.meta?.page + : 0 + } + totalItems={ + isResponseSuccess(outgoingSapronakSummaries) + ? outgoingSapronakSummaries?.meta?.total_results + : 0 + } + onPageChange={setPage} + isLoading={isLoadingOutgoingSapronakSummaries} + sorting={sorting} + setSorting={setSorting} + rowSelection={rowSelection} + setRowSelection={setRowSelection} + className={{ + containerClassName: cn({ + 'w-full mb-20': + isResponseSuccess(outgoingSapronakSummaries) && + outgoingSapronakSummaries?.data?.length === 0, + }), + }} + /> +
+
+
+ ); +}; + +export default ClosingOutgoingSapronaksSummaryTable; diff --git a/src/components/pages/closing/ClosingSapronakTabContent.tsx b/src/components/pages/closing/ClosingSapronakTabContent.tsx index 41c7aa05..03c3c984 100644 --- a/src/components/pages/closing/ClosingSapronakTabContent.tsx +++ b/src/components/pages/closing/ClosingSapronakTabContent.tsx @@ -2,6 +2,8 @@ import ClosingIncomingSapronaksTable from '@/components/pages/closing/ClosingIncomingSapronaksTable'; import ClosingOutgoingSapronaksTable from '@/components/pages/closing/ClosingOutgoingSapronaksTable'; +import ClosingIncomingSapronaksSummaryTable from '@/components/pages/closing/ClosingIncomingSapronaksSummaryTable'; +import ClosingOutgoingSapronaksSummaryTable from './ClosingOutgoingSapronaksSummaryTable'; interface ClosingSapronakTableProps { projectFlockId?: number; @@ -16,7 +18,15 @@ const ClosingSapronakTabContent = ({ <> + + + + )} diff --git a/src/components/pages/closing/sale/SalesReportTable.tsx b/src/components/pages/closing/sale/SalesReportTable.tsx index fe8d46a5..6c12347e 100644 --- a/src/components/pages/closing/sale/SalesReportTable.tsx +++ b/src/components/pages/closing/sale/SalesReportTable.tsx @@ -82,12 +82,12 @@ const SalesReportTable = ({
Total Penjualan
), }, - { - id: 'age', - accessorKey: 'age', - header: 'Umur', - cell: (props) => props.getValue() || '-', - }, + // { + // id: 'age', + // accessorKey: 'age', + // header: 'Umur', + // cell: (props) => props.getValue() || '-', + // }, { id: 'do_number', accessorKey: 'do_number', 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..17c5bde4 --- /dev/null +++ b/src/components/pages/dashboard/export/DashboardPDF.ts @@ -0,0 +1,262 @@ +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) { + 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/components/pages/expense/ExpenseDetail.tsx b/src/components/pages/expense/ExpenseDetail.tsx index 9c84ed4d..1f43eae1 100644 --- a/src/components/pages/expense/ExpenseDetail.tsx +++ b/src/components/pages/expense/ExpenseDetail.tsx @@ -43,7 +43,7 @@ const ExpenseDetail: React.FC = ({ initialValues }) => { return ( <> -
+
diff --git a/src/components/pages/expense/ExpenseRealizationContent.tsx b/src/components/pages/expense/ExpenseRealizationContent.tsx index ea4a0e8d..7057ba19 100644 --- a/src/components/pages/expense/ExpenseRealizationContent.tsx +++ b/src/components/pages/expense/ExpenseRealizationContent.tsx @@ -68,7 +68,7 @@ const ExpenseRealizationContent = ({ return (
-
+
-
+
@@ -179,7 +179,7 @@ const ExpenseRealizationContent = ({
-
+
@@ -216,127 +216,141 @@ const ExpenseRealizationContent = ({
-
-

- Rincian Pengajuan Biaya Operasional -

+
+
+

+ Rincian Pengajuan Biaya Operasional +

-
- {initialValues?.kandangs.map((kandangExpense, kandangExpenseIdx) => { - let expenseGrandTotal = 0; +
+ {initialValues?.kandangs.map( + (kandangExpense, kandangExpenseIdx) => { + let expenseGrandTotal = 0; - kandangExpense.pengajuans?.forEach( - (item) => (expenseGrandTotal += item.qty * item.price) - ); + kandangExpense.pengajuans?.forEach( + (item) => (expenseGrandTotal += item.qty * item.price) + ); - return ( -
- - - - - - - - - - - - - - {kandangExpense.pengajuans?.map( - (pengajuanItem, pengajuanIdx) => ( - - - - - + return ( +
+
- Biaya {kandangExpense.name} -
NonstockTotal KuantitasTotal BiayaCatatan
{pengajuanItem.nonstock.name}{pengajuanItem.qty}{formatCurrency(pengajuanItem.price)}{pengajuanItem.note ?? '-'}
+ + + - ) - )} - - - - - - - -
+ Biaya {kandangExpense.name} +
- Total Biaya Keseluruhan: - {formatCurrency(expenseGrandTotal)}
-
- ); - })} + + Nonstock + Total Kuantitas + Total Biaya + Catatan + + + + {kandangExpense.pengajuans?.map( + (pengajuanItem, pengajuanIdx) => ( + + {pengajuanItem.nonstock.name} + {pengajuanItem.qty} + {formatCurrency(pengajuanItem.price)} + + {pengajuanItem.notes ?? '-'} + + + ) + )} + + + + + Total Biaya Keseluruhan: + + + {formatCurrency(expenseGrandTotal)} + + + + +
+ ); + } + )} +
-
-
-

- Rincian Realisasi Biaya Operasional -

+
+

+ Rincian Realisasi Biaya Operasional +

-
- {initialValues?.kandangs.map((kandangExpense, kandangExpenseIdx) => { - let expenseGrandTotal = 0; +
+ {initialValues?.kandangs.map( + (kandangExpense, kandangExpenseIdx) => { + let expenseGrandTotal = 0; - kandangExpense.realisasi?.forEach( - (item) => (expenseGrandTotal += item.qty * item.price) - ); + kandangExpense.realisasi?.forEach( + (item) => (expenseGrandTotal += item.qty * item.price) + ); - return ( -
- - - - - - - - - - - - - - {kandangExpense.realisasi?.map( - (realisasiItem, realisasiIdx) => ( - - - - - + return ( +
+
- Biaya {kandangExpense.name} -
NonstockTotal KuantitasTotal BiayaCatatan
{realisasiItem.nonstock.name}{realisasiItem.qty}{formatCurrency(realisasiItem.price)}{realisasiItem.note ?? '-'}
+ + + - ) - )} - - - - - - - -
+ Biaya {kandangExpense.name} +
- Total Biaya Keseluruhan: - {formatCurrency(expenseGrandTotal)}
-
- ); - })} + + Nonstock + Total Kuantitas + Total Biaya + Catatan + + + + {kandangExpense.realisasi?.map( + (realisasiItem, realisasiIdx) => ( + + {realisasiItem.nonstock.name} + {realisasiItem.qty} + {formatCurrency(realisasiItem.price)} + + {realisasiItem.notes ?? '-'} + + + ) + )} + + + + + Total Biaya Keseluruhan: + + + {formatCurrency(expenseGrandTotal)} + + + + +
+ ); + } + )} +
diff --git a/src/components/pages/expense/ExpenseRequestContent.tsx b/src/components/pages/expense/ExpenseRequestContent.tsx index a1ad4643..ac814bcf 100644 --- a/src/components/pages/expense/ExpenseRequestContent.tsx +++ b/src/components/pages/expense/ExpenseRequestContent.tsx @@ -273,7 +273,7 @@ const ExpenseRequestContent = ({ <>
{initialValues && !isLoadingApprovalHistory && approvalHistory && ( -
+
)} @@ -281,7 +281,7 @@ const ExpenseRequestContent = ({
{/* TODO: apply RBAC */} -
+
{isCurrentApprovalOnHeadArea && (
-
+
@@ -608,7 +608,7 @@ const ExpenseRequestContent = ({
-
+

Rincian Pengajuan Biaya Operasional

@@ -654,7 +654,7 @@ const ExpenseRequestContent = ({ {pengajuanItem.qty} {formatCurrency(pengajuanItem.price)} - {pengajuanItem.note ?? '-'} + {pengajuanItem.notes ?? '-'} ) diff --git a/src/components/pages/expense/ExpensesTable.tsx b/src/components/pages/expense/ExpensesTable.tsx index fdfd9cc3..895c0997 100644 --- a/src/components/pages/expense/ExpensesTable.tsx +++ b/src/components/pages/expense/ExpensesTable.tsx @@ -54,17 +54,19 @@ const RowOptionsMenu = ({ rejectClickHandler: () => void; deleteClickHandler: () => void; }) => { - const showEditButton = - props.row.original.latest_approval.step_number !== 6 && - (props.row.original.latest_approval.step_number === 1 || - props.row.original.latest_approval.step_number === 2 || - props.row.original.latest_approval.step_number === 3 || - props.row.original.latest_approval.step_number === 4); + const showEditButton = props.row.original.latest_approval + ? props.row.original.latest_approval.step_number !== 6 && + (props.row.original.latest_approval.step_number === 1 || + props.row.original.latest_approval.step_number === 2 || + props.row.original.latest_approval.step_number === 3 || + props.row.original.latest_approval.step_number === 4) + : false; // TODO: apply RBAC - const showRealizationButton = - props.row.original.latest_approval.action !== 'REJECTED' && - props.row.original.latest_approval.step_number === 4; + const showRealizationButton = props.row.original.latest_approval + ? props.row.original.latest_approval.action !== 'REJECTED' && + props.row.original.latest_approval.step_number === 4 + : false; return ( @@ -278,6 +280,7 @@ const ExpensesTable = () => { cell: ({ row }) => { const isCheckboxDisabled = !row.getCanSelect() || + !row.original.latest_approval || row.original.latest_approval.action === 'REJECTED'; return ( @@ -413,6 +416,8 @@ const ExpensesTable = () => { const tableEnableRowSelectionHandler: (row: Row) => boolean = ( row ) => { + if (!row.original.latest_approval) return false; + return ( row.original.latest_approval.action !== 'REJECTED' && row.original.latest_approval.step_number !== 6 @@ -692,14 +697,6 @@ const ExpensesTable = () => { )}
- -
@@ -753,17 +750,12 @@ const ExpensesTable = () => { }} /> -
diff --git a/src/components/pages/expense/form/ExpenseKandangsTable.tsx b/src/components/pages/expense/form/ExpenseKandangsTable.tsx index 7d7f76ca..5c60ae1e 100644 --- a/src/components/pages/expense/form/ExpenseKandangsTable.tsx +++ b/src/components/pages/expense/form/ExpenseKandangsTable.tsx @@ -19,6 +19,7 @@ import { isResponseSuccess } from '@/lib/api-helper'; interface ExpenseKandangsTableProps { locationId?: number; type: 'add' | 'edit' | 'detail'; + formType?: 'request' | 'realization'; selectedKandangs: { id?: number; name?: string; @@ -31,6 +32,7 @@ interface ExpenseKandangsTableProps { const ExpenseKandangsTable = ({ type, + formType = 'request', locationId, selectedKandangs, onChange, @@ -172,69 +174,84 @@ const ExpenseKandangsTable = ({ updateSortingFilter('picSort', picSortFilter); }, [sorting, updateSortingFilter]); - return ( - - -
Pilih Kandang
+ // Tampilkan tabel jika: + // 1. Mode request pertama kali (type='add' dan formType='request') + // 2. Atau sudah ada kandang yang dipilih + const shouldShowTable = + (type === 'add' && formType === 'request') || + (selectedKandangs.length > 0 && selectedKandangs.some((k) => k.id)); - -
- } - className='w-full!' - titleClassName='w-full p-0!' - > - - data={isResponseSuccess(kandangs) ? kandangs?.data : []} - columns={kandangsColumns} - pageSize={tableFilterState.pageSize} - page={isResponseSuccess(kandangs) ? kandangs?.meta?.page : 0} - totalItems={ - isResponseSuccess(kandangs) ? kandangs?.meta?.total_results : 0 - } - onPageChange={setPage} - isLoading={isLoading} - sorting={sorting} - setSorting={setSorting} - rowSelection={rowSelection} - setRowSelection={setRowSelection} + return ( + <> + {shouldShowTable && ( + - - + > + +
+ {formType === 'realization' + ? 'Kandang yang Direalisasikan' + : 'Pilih Kandang'} +
+ + +
+ } + className='w-full!' + titleClassName='w-full p-0!' + > + + data={isResponseSuccess(kandangs) ? kandangs?.data : []} + columns={kandangsColumns} + pageSize={tableFilterState.pageSize} + page={isResponseSuccess(kandangs) ? kandangs?.meta?.page : 0} + totalItems={ + isResponseSuccess(kandangs) ? kandangs?.meta?.total_results : 0 + } + onPageChange={setPage} + isLoading={isLoading} + sorting={sorting} + setSorting={setSorting} + rowSelection={rowSelection} + setRowSelection={setRowSelection} + className={{ + containerClassName: cn({ + 'mb-20': + isResponseSuccess(kandangs) && kandangs?.data?.length === 0, + }), + tableWrapperClassName: 'overflow-x-auto min-h-full!', + tableClassName: 'font-inter w-full table-auto min-h-full!', + headerRowClassName: 'border-b border-b-gray-200', + headerColumnClassName: + 'px-6 py-3 text-xs font-semibold text-gray-500 first:flex first:flex-row first:justify-start', + bodyRowClassName: 'border-b border-b-gray-200', + bodyColumnClassName: + 'px-6 py-3 first:flex first:flex-row first:justify-start', + paginationClassName: cn({ + hidden: + isResponseSuccess(kandangs) && + kandangs?.meta?.total_pages === 1, + }), + }} + /> + + + )} + ); }; diff --git a/src/components/pages/expense/form/ExpenseRealizationForm.schema.ts b/src/components/pages/expense/form/ExpenseRealizationForm.schema.ts index 1f3682ea..94d6e38d 100644 --- a/src/components/pages/expense/form/ExpenseRealizationForm.schema.ts +++ b/src/components/pages/expense/form/ExpenseRealizationForm.schema.ts @@ -130,7 +130,7 @@ export const getExpenseRealizationFormInitialValues = ( ? formatDate(initialValues?.realization_date, 'YYYY-MM-DD') : undefined, kandangs: initialValues?.kandangs.map((kandang) => ({ - id: kandang.kandang_id, + id: kandang.id, name: kandang.name, })), supplier: initialValues?.supplier @@ -159,7 +159,7 @@ export const getExpenseRealizationFormInitialValues = ( }, quantity: realisasiItem.qty, price: realisasiItem.price, - notes: realisasiItem.note, + notes: realisasiItem.notes, }; }) : kandangExpense.pengajuans @@ -170,7 +170,7 @@ export const getExpenseRealizationFormInitialValues = ( }, quantity: expenseItem.qty, price: expenseItem.price, - notes: expenseItem.note, + notes: expenseItem.notes, })) : []; diff --git a/src/components/pages/expense/form/ExpenseRealizationForm.tsx b/src/components/pages/expense/form/ExpenseRealizationForm.tsx index ed5aea3e..6117c920 100644 --- a/src/components/pages/expense/form/ExpenseRealizationForm.tsx +++ b/src/components/pages/expense/form/ExpenseRealizationForm.tsx @@ -249,7 +249,7 @@ const ExpenseRealizationForm = ({ }, [formikSetValues, getExpenseRealizationFormInitialValues, initialValues]); return ( -
+
- - )} + {FINANCE_TRANSACTION_STATUS.includes(finance.transaction_type) && + finance.party?.type !== 'SUPPLIER' && ( + + + + )} {FINANCE_INITIAL_BALANCE_STATUS.includes(finance.transaction_type) && ( - - )} + ) && + props.row.original.party?.type !== 'SUPPLIER' && ( + + + + )} {FINANCE_INITIAL_BALANCE_STATUS.includes( props.row.original.transaction_type @@ -194,20 +199,25 @@ const FinanceTable = () => { // ===== Options ===== const transactionTypeOptions = useMemo(() => { - return [ - { label: 'Transfer', value: 'TRANSFER' }, - { label: 'Cash', value: 'CASH' }, - { label: 'Card', value: 'CARD' }, - { label: 'Cheque', value: 'CHEQUE' }, - { label: 'Saldo', value: 'SALDO' }, - ]; - }, []); - const partyTypeOptions = useMemo(() => { return [ { label: 'Customer', value: 'CUSTOMER' }, { label: 'Supplier', value: 'SUPPLIER' }, ]; }, []); + const { + options: partyTypeOptions, + isLoadingOptions: partyTypeIsLoadingOptions, + setInputValue: partyTypeInputValue, + loadMore: partyTypeLoadMore, + } = useSelect( + selectedTransactionType + ? selectedTransactionType.value === 'CUSTOMER' + ? CustomerApi.basePath + : SupplierApi.basePath + : '', + 'id', + 'name' + ); const sortByOptions = useMemo(() => { return [ { label: 'Tanggal Pembayaran', value: 'payment_date' }, @@ -336,10 +346,10 @@ const FinanceTable = () => { }, { header: 'Pihak', - accessorFn: (finance: Finance) => finance.party.name, + accessorFn: (finance: Finance) => finance.party?.name, cell: (props: CellContext) => { - if (props.row.original.party.id) { - return {props.row.original.party.name}; + if (props.row.original.party?.id) { + return {props.row.original.party?.name}; } return {'-'}; }, @@ -360,12 +370,12 @@ const FinanceTable = () => { { header: 'Bank', accessorFn: (finance: Finance) => - `${finance.bank.alias} - ${finance.bank.account_number} - ${finance.bank.owner}`, + `${finance.bank?.alias} - ${finance.bank?.account_number} - ${finance.bank?.owner}`, }, { header: 'Pengeluaran (Rp)', accessorFn: (finance: Finance) => - formatCurrency(finance.expense_amount), + formatCurrency(Math.abs(finance.expense_amount)), }, { header: 'Pemasukan (Rp)', @@ -468,25 +478,41 @@ const FinanceTable = () => {
+ ({ label: - bankRawData.data.find((data) => data.id === bank.value) + bankRawData.data.find((data) => data.id === bank?.value) ?.alias + ' - ' + - bankRawData.data.find((data) => data.id === bank.value) + bankRawData.data.find((data) => data.id === bank?.value) ?.account_number + ' - ' + - bankRawData.data.find((data) => data.id === bank.value) + bankRawData.data.find((data) => data.id === bank?.value) ?.owner, - value: bank.value, + value: bank?.value, })) : [] } @@ -497,13 +523,6 @@ const FinanceTable = () => { onMenuScrollToBottom={bankLoadMore} isClearable /> - { const router = useRouter(); + const [serverErrorMessage, setServerErrorMessage] = useState(''); + const [isSupplier, setIsSupplier] = useState( + initialValues?.party?.type === 'SUPPLIER' + ); // ===== Formik ===== const formikInitialValues = useMemo((): FinanceFormValues => { return { party_type_option: FINANCE_PARTY_TYPE_OPTIONS.find( - (option) => option.value === initialValues?.party.type + (option) => option.value === initialValues?.party?.type ) || null, party_id_option: initialValues?.party ? { - label: initialValues?.party.name || '', - value: initialValues?.party.id || 0, + label: initialValues?.party?.name || '', + value: initialValues?.party?.id || 0, } : null, payment_date: initialValues?.payment_date || '', @@ -72,11 +78,11 @@ const FormFinanceAdd = ({ ) || null, bank_id_option: initialValues?.bank ? { - label: initialValues.bank.name, - value: initialValues.bank.id, + label: initialValues?.bank?.name, + value: initialValues?.bank?.id, } : null, - party_account_number: initialValues?.party.account_number || '', + party_account_number: initialValues?.party?.account_number || '', reference_number: initialValues?.reference_number || '', nominal: initialValues?.nominal.toString() || '', notes: initialValues?.notes || '', @@ -153,6 +159,7 @@ const FormFinanceAdd = ({ if (isResponseError(response)) { toast.error(response.message); + setServerErrorMessage(response.message); return; } @@ -168,6 +175,7 @@ const FormFinanceAdd = ({ if (isResponseError(response)) { toast.error(response.message); + setServerErrorMessage(response.message); return; } @@ -207,6 +215,7 @@ const FormFinanceAdd = ({ ? formik.errors.party_type_option : '' } + isDisabled={type === 'edit' || isSupplier} required isClearable /> @@ -245,7 +254,7 @@ const FormFinanceAdd = ({ } required isClearable - isDisabled={!formik.values.party_type_option?.value} + isDisabled={!formik.values.party_type_option?.value || isSupplier} />