From 509fc5476d78da35e05e47ac392bf519055820b3 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 24 Feb 2026 14:48:09 +0700 Subject: [PATCH 1/9] refactor(FE): Refactor recording table and add filter modal --- src/app/production/recording/page.tsx | 2 +- .../production/recording/RecordingTable.tsx | 1852 +++++++++++------ .../recording/filter/RecordingFilter.ts | 15 + .../skeleton/RecordingTableSkeleton.tsx | 37 + 4 files changed, 1217 insertions(+), 689 deletions(-) create mode 100644 src/components/pages/production/recording/filter/RecordingFilter.ts create mode 100644 src/components/pages/production/recording/skeleton/RecordingTableSkeleton.tsx diff --git a/src/app/production/recording/page.tsx b/src/app/production/recording/page.tsx index 471ef648..fbbac7cb 100644 --- a/src/app/production/recording/page.tsx +++ b/src/app/production/recording/page.tsx @@ -2,7 +2,7 @@ import RecordingTable from '@/components/pages/production/recording/RecordingTab const Recording = () => { return ( -
+
); diff --git a/src/components/pages/production/recording/RecordingTable.tsx b/src/components/pages/production/recording/RecordingTable.tsx index 65e658f9..bd26670d 100644 --- a/src/components/pages/production/recording/RecordingTable.tsx +++ b/src/components/pages/production/recording/RecordingTable.tsx @@ -9,21 +9,33 @@ import React, { } from 'react'; import useSWR from 'swr'; import { Icon } from '@iconify/react'; -import { SortingState, CellContext } from '@tanstack/react-table'; +import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table'; import { cn, formatDate, formatNumber } from '@/lib/helper'; import RequirePermission from '@/components/helper/RequirePermission'; -import { useModal } from '@/components/Modal'; +import Modal, { useModal } from '@/components/Modal'; import Button from '@/components/Button'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import { OptionType } from '@/components/input/SelectInput'; -import SelectInput from '@/components/input/SelectInput'; +import SelectInput, { useSelect } from '@/components/input/SelectInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput'; +import PopoverButton from '@/components/popover/PopoverButton'; +import PopoverContent from '@/components/popover/PopoverContent'; +import { useFormik } from 'formik'; +import { AreaApi } from '@/services/api/master-data'; +import { LocationApi } from '@/services/api/master-data'; +import { ProjectFlockApi } from '@/services/api/production'; +import { Location } from '@/types/api/master-data/location'; +import { Area } from '@/types/api/master-data/area'; +import { ProjectFlock } from '@/types/api/production/project-flock'; +import { type BaseApiResponse } from '@/types/api/api-general'; +import { + RecordingFilterSchema, + RecordingFilterType, +} from '@/components/pages/production/recording/filter/RecordingFilter'; +import RecordingTableSkeleton from '@/components/pages/production/recording/skeleton/RecordingTableSkeleton'; import { ROWS_OPTIONS } from '@/config/constant'; import Table from '@/components/Table'; -import RowDropdownOptions from '@/components/table/RowDropdownOptions'; -import RowCollapseOptions from '@/components/table/RowCollapseOptions'; -import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; import { type Recording } from '@/types/api/production/recording'; import { RecordingApi } from '@/services/api/production'; import { isResponseSuccess } from '@/lib/api-helper'; @@ -61,18 +73,25 @@ const getStatusBadgeColor = (status: string): Color => { }; const RowOptionsMenu = ({ - type = 'dropdown', + popoverPosition = 'bottom', props, deleteClickHandler, approveClickHandler, rejectClickHandler, }: { - type: 'dropdown' | 'collapse'; + popoverPosition: 'bottom' | 'top'; props: CellContext; deleteClickHandler: () => void; approveClickHandler: () => void; rejectClickHandler: () => void; }) => { + const popoverId = `recording#${props.row.original.id}`; + const popoverAnchorName = `--anchor-recording#${props.row.original.id}`; + + const closePopover = () => { + document.getElementById(popoverId)?.hidePopover(); + }; + const isRecordingApproved = (recording: Recording) => { return ( recording.approval?.action === 'APPROVED' && @@ -89,72 +108,97 @@ const RowOptionsMenu = ({ const isRejected = isRecordingRejected(props.row.original); return ( - - - - - - - - {!isApproved && !isRejected && ( - - - - )} - {!isApproved && !isRejected && ( - - - - )} - - - - +
+ + + + + +
+ + + + + + + {!isApproved && !isRejected && ( + + + + )} + {!isApproved && !isRejected && ( + + + + )} + + + +
+
+
); }; @@ -174,16 +218,58 @@ const RecordingTable = () => { areaFilter: '', locationFilter: '', kandangFilter: '', - periodFilter: '', + projectFlockKandangFilter: '', }, paramMap: { page: 'page', pageSize: 'limit', search: 'search', - areaFilter: 'area_id', - locationFilter: 'location_id', kandangFilter: 'kandang_id', - periodFilter: 'period', + projectFlockKandangFilter: 'project_flock_kandang_id', + }, + }); + + // ===== FILTER MODAL STATE ===== + const filterModal = useModal(); + + // ===== FILTER STATE ===== + const [filterArea, setFilterArea] = useState(null); + const [filterLocation, setFilterLocation] = useState(null); + const [filterProjectFlock, setFilterProjectFlock] = + useState(null); + const [filterKandang, setFilterKandang] = useState(null); + const [, setFilterProjectFlockKandangId] = useState( + undefined + ); + const [filterLocationAreaId, setFilterLocationAreaId] = useState(''); + const [filterProjectFlockLocationId, setFilterProjectFlockLocationId] = + useState(''); + + // ===== FORMIK SETUP ===== + const formik = useFormik({ + initialValues: { + area_id: null, + location_id: null, + kandang_id: null, + project_flock_kandang_id: null, + }, + validationSchema: RecordingFilterSchema, + onSubmit: (values, { setSubmitting }) => { + updateFilter('areaFilter', values.area_id || ''); + updateFilter('locationFilter', values.location_id || ''); + updateFilter('kandangFilter', values.kandang_id || ''); + updateFilter( + 'projectFlockKandangFilter', + values.project_flock_kandang_id || '' + ); + filterModal.closeModal(); + setSubmitting(false); + }, + onReset: () => { + updateFilter('areaFilter', ''); + updateFilter('locationFilter', ''); + updateFilter('kandangFilter', ''); + updateFilter('projectFlockKandangFilter', ''); }, }); @@ -213,6 +299,250 @@ const RecordingTable = () => { RecordingApi.getAllFetcher ); + // ===== LOCATION, AREA, KANDANG OPTIONS ===== + const locationParams = useMemo(() => { + if (filterLocationAreaId) { + return { area_id: filterLocationAreaId }; + } + return undefined; + }, [filterLocationAreaId]); + + const { + setInputValue: setLocationInputValue, + options: locationOptions, + isLoadingOptions: isLoadingLocationOptions, + loadMore: loadMoreLocations, + } = useSelect( + LocationApi.basePath, + 'id', + 'name', + 'search', + locationParams + ); + + const { + setInputValue: setAreaInputValue, + options: areaOptions, + isLoadingOptions: isLoadingAreaOptions, + loadMore: loadMoreAreas, + } = useSelect(AreaApi.basePath, 'id', 'name', 'search'); + + const projectFlockParams = useMemo(() => { + if (filterProjectFlockLocationId) { + return { location_id: filterProjectFlockLocationId }; + } + return undefined; + }, [filterProjectFlockLocationId]); + + const { + setInputValue: setProjectFlockInputValue, + options: projectFlockOptions, + rawData: projectFlocksRawData, + isLoadingOptions: isLoadingProjectFlocks, + loadMore: loadMoreProjectFlocks, + } = useSelect( + ProjectFlockApi.basePath, + 'id', + 'flock_name', + 'search', + projectFlockParams + ); + + const kandangOptions = useMemo(() => { + if (!filterProjectFlock || !projectFlocksRawData) return []; + if (!isResponseSuccess(projectFlocksRawData)) return []; + + const data = projectFlocksRawData.data as ProjectFlock[]; + const selectedProjectFlockData = data.find( + (pf) => pf.id === filterProjectFlock.value + ); + + if (!selectedProjectFlockData?.kandangs) return []; + return selectedProjectFlockData.kandangs.map((k) => ({ + value: k.id, + label: k.name || '', + })); + }, [filterProjectFlock, projectFlocksRawData]); + + // ===== PROJECT FLOCK KANDANG LOOKUP ===== + const projectFlockKandangLookupUrl = useMemo(() => { + if (!filterProjectFlock || !filterKandang) return null; + const params = new URLSearchParams({ + project_flock_id: filterProjectFlock.value.toString(), + kandang_id: filterKandang.value.toString(), + }); + return `${ProjectFlockApi.basePath}/kandangs/lookup?${params.toString()}`; + }, [filterProjectFlock, filterKandang]); + + const { data: projectFlockKandangLookupData } = useSWR( + projectFlockKandangLookupUrl, + projectFlockKandangLookupUrl + ? () => + ProjectFlockApi.getAllFetcher( + projectFlockKandangLookupUrl + ) as Promise< + BaseApiResponse<{ + id: number; + project_flock: { flock_name: string }; + }> + > + : null + ); + + const projectFlockKandangLookup = + projectFlockKandangLookupData?.status === 'success' + ? projectFlockKandangLookupData.data + : undefined; + + const formikRef = useRef(formik); + + useEffect(() => { + formikRef.current = formik; + }); + + useEffect(() => { + if (projectFlockKandangLookup?.id) { + const pfkId = String(projectFlockKandangLookup.id); + setFilterProjectFlockKandangId(projectFlockKandangLookup.id); + formikRef.current.setFieldValue('project_flock_kandang_id', pfkId); + } else { + setFilterProjectFlockKandangId(undefined); + formikRef.current.setFieldValue('project_flock_kandang_id', null); + } + }, [projectFlockKandangLookup]); + + // ===== FILTER HANDLERS ===== + const handleFilterAreaChange = useCallback( + (val: OptionType | OptionType[] | null) => { + const area = val as OptionType | null; + const areaId = area?.value ? String(area.value) : null; + + formik.setFieldValue('area_id', areaId); + formik.setFieldValue('location_id', null); + formik.setFieldValue('kandang_id', null); + formik.setFieldValue('project_flock_kandang_id', null); + + setFilterArea(area); + setFilterLocation(null); + setFilterProjectFlock(null); + setFilterKandang(null); + setFilterLocationAreaId(areaId || ''); + setFilterProjectFlockLocationId(''); + }, + [formik] + ); + + const handleFilterLocationChange = useCallback( + (val: OptionType | OptionType[] | null) => { + const location = val as OptionType | null; + const locationId = location?.value ? String(location.value) : null; + + formik.setFieldValue('location_id', locationId); + formik.setFieldValue('kandang_id', null); + formik.setFieldValue('project_flock_kandang_id', null); + + setFilterLocation(location); + setFilterProjectFlock(null); + setFilterKandang(null); + setFilterProjectFlockLocationId(locationId || ''); + }, + [formik] + ); + + const handleFilterProjectFlockChange = useCallback( + (val: OptionType | OptionType[] | null) => { + const projectFlock = val as OptionType | null; + + formik.setFieldValue('kandang_id', null); + formik.setFieldValue('project_flock_kandang_id', null); + + setFilterProjectFlock(projectFlock); + setFilterKandang(null); + }, + [formik] + ); + + const handleFilterKandangChange = useCallback( + (val: OptionType | OptionType[] | null) => { + const kandang = val as OptionType | null; + const kandangId = kandang?.value ? String(kandang.value) : null; + + formik.setFieldValue('kandang_id', kandangId); + formik.setFieldValue('project_flock_kandang_id', null); + + setFilterKandang(kandang); + }, + [formik] + ); + + // ===== FILTER HELPERS ===== + const areaIdValue = useMemo(() => { + if (!formik.values.area_id) return null; + return ( + areaOptions.find((opt) => String(opt.value) === formik.values.area_id) || + null + ); + }, [formik.values.area_id, areaOptions]); + + const locationIdValue = useMemo(() => { + if (!formik.values.location_id) return null; + return ( + locationOptions.find( + (opt) => String(opt.value) === formik.values.location_id + ) || null + ); + }, [formik.values.location_id, locationOptions]); + + const projectFlockIdValue = useMemo(() => { + if (!filterProjectFlock) return null; + return filterProjectFlock; + }, [filterProjectFlock]); + + const kandangIdValue = useMemo(() => { + if (!formik.values.kandang_id) return null; + return ( + kandangOptions.find( + (opt) => String(opt.value) === formik.values.kandang_id + ) || null + ); + }, [formik.values.kandang_id, kandangOptions]); + + // ===== ACTIVE FILTERS COUNT ===== + const activeFiltersCount = useMemo(() => { + let count = 0; + + if (tableFilterState.areaFilter) { + count += 1; + } + + if (tableFilterState.locationFilter) { + count += 1; + } + + if (tableFilterState.kandangFilter) { + count += 1; + } + + if (tableFilterState.projectFlockKandangFilter) { + count += 1; + } + + return count; + }, [ + tableFilterState.areaFilter, + tableFilterState.locationFilter, + tableFilterState.kandangFilter, + tableFilterState.projectFlockKandangFilter, + ]); + + const hasFilters = activeFiltersCount > 0; + + // ===== HANDLE FILTER MODAL OPEN ===== + const handleFilterModalOpen = () => { + filterModal.openModal(); + formik.validateForm(); + }; + const isRecordingApproved = useCallback((recording: Recording): boolean => { return ( recording.approval?.action === 'APPROVED' && @@ -221,13 +551,11 @@ const RecordingTable = () => { }, []); useEffect(() => { - // Store current path on mount previousPathRef.current = window.location.pathname; return () => { const currentPath = window.location.pathname; - // if both paths are within /production/recording module const isCurrentPathRecording = currentPath.includes( '/production/recording' ); @@ -235,7 +563,6 @@ const RecordingTable = () => { '/production/recording' ); - // reset if we outside recording module entirely if (isPreviousPathRecording && !isCurrentPathRecording) { resetSearchValue(); } @@ -360,624 +687,773 @@ const RecordingTable = () => { } }, [recordings, rowSelection, isRecordingApproved, setRowSelection]); + // ===== TABLE COLUMNS ===== + const recordingColumns: ColumnDef[] = useMemo( + () => [ + { + id: 'select', + header: ({ table }) => { + const allRows = table.getRowModel().rows; + const selectableRows = allRows.filter((row) => { + const recording = row.original; + return !isRecordingApproved(recording); + }); + + const hasNoSelectableRows = selectableRows.length === 0; + + const handleSelectAll = () => { + const isAllSelected = selectableRows.every((row) => + row.getIsSelected() + ); + + selectableRows.forEach((row) => { + row.toggleSelected(!isAllSelected); + }); + }; + + const isAllSelected = + selectableRows.length > 0 && + selectableRows.every((row) => row.getIsSelected()); + + const isSomeSelected = selectableRows.some((row) => + row.getIsSelected() + ); + + return ( +
+ +
+ ); + }, + cell: ({ row }) => { + const recording = row.original; + const isDisabled = isRecordingApproved(recording); + + const handleToggleSelection = (e: unknown) => { + if (!isDisabled) { + row.getToggleSelectedHandler()(e); + } + }; + + return ( +
+ +
+ ); + }, + }, + { + header: 'No', + cell: (props) => + tableFilterState.pageSize * (tableFilterState.page - 1) + + props.row.index + + 1, + }, + { + header: 'Lokasi', + cell: (props) => props.row.original.location?.name || '-', + }, + { + header: 'Flock', + cell: (props) => props.row.original.project_flock?.flock_name || '-', + }, + { + header: 'Kandang', + cell: (props) => props.row.original.kandang?.name || '-', + }, + { + header: 'Periode', + cell: (props) => props.row.original.project_flock?.period || '-', + }, + { + header: 'Kategori', + cell: (props) => { + const category = + props.row.original.project_flock?.project_flock_category; + if (!category) return '-'; + const color = category === 'LAYING' ? 'info' : 'warning'; + return ( + + {category} + + ); + }, + }, + { + header: 'Umur (hari)', + cell: (props) => { + return ( + <> + + {props.row.original.day} (Minggu ke- + {props.row.original.project_flock.production_standart.week}) + + + ); + }, + }, + { + header: 'Waktu Recording', + cell: (props) => + formatDate(props.row.original.record_datetime, 'DD MMMM YYYY'), + }, + { + header: 'Populasi Akhir', + cell: (props) => + props.row.original.project_flock?.total_chick_qty != null + ? formatNumber(props.row.original.project_flock.total_chick_qty) + : '-', + }, + { + id: 'fcr', + header: 'FCR', + columns: [ + { + id: 'fcr_actual', + header: 'Actual', + cell: (props) => { + const value = props.row.original.fcr_value; + return ( +
+ {value !== null && value !== undefined + ? formatNumber(value) + : '-'} +
+ ); + }, + }, + { + id: 'fcr_standard', + header: 'Standard', + cell: (props) => { + const value = props.row.original.project_flock?.fcr?.fcr_std; + return ( +
+ {value !== null && value !== undefined + ? formatNumber(value) + : '-'} +
+ ); + }, + }, + ], + }, + { + id: 'feed_intake', + header: 'Feed Intake (KG)', + columns: [ + { + id: 'feed_intake_actual', + header: 'Actual', + cell: (props) => { + const value = props.row.original.feed_intake; + return ( +
+ {value !== null && value !== undefined + ? formatNumber(value) + : '-'} +
+ ); + }, + }, + { + id: 'feed_intake_standard', + header: 'Standard', + cell: (props) => { + const value = + props.row.original.project_flock?.production_standart + ?.feed_intake_std; + return ( +
+ {value !== null && value !== undefined + ? formatNumber(value) + : '-'} +
+ ); + }, + }, + ], + }, + { + id: 'mortality', + header: 'Mortality', + columns: [ + { + id: 'cum_depletion_rate_actual', + header: 'Cum Depletion Rate', + cell: (props) => { + const value = props.row.original.cum_depletion_rate; + return ( +
+ {value !== null && value !== undefined + ? `${value.toFixed(2)}%` + : '-'} +
+ ); + }, + }, + { + id: 'max_depletion_std', + header: 'Max Depletion Std', + cell: (props) => { + const value = + props.row.original.project_flock?.production_standart + ?.max_depletion_std; + return ( +
+ {value !== null && value !== undefined + ? `${value.toFixed(2)}%` + : '-'} +
+ ); + }, + }, + { + id: 'total_depletion', + header: 'Total Depletion', + cell: (props) => { + const value = props.row.original.total_depletion_qty; + return ( +
+ {value !== null && value !== undefined + ? formatNumber(value) + : '-'} +
+ ); + }, + }, + ], + }, + { + id: 'egg_production', + header: 'Egg Production', + columns: [ + { + id: 'egg_mass_actual', + header: 'Egg Mass Actual', + cell: (props) => { + const value = props.row.original.egg_mass; + return ( +
+ {value !== null && value !== undefined + ? formatNumber(value) + : '-'} +
+ ); + }, + }, + { + id: 'egg_mass_standard', + header: 'Egg Mass Standar', + cell: (props) => { + const value = + props.row.original.project_flock?.production_standart + ?.egg_mass_std; + return ( +
+ {value !== null && value !== undefined + ? formatNumber(value) + : '-'} +
+ ); + }, + }, + { + id: 'egg_weight_actual', + header: 'Egg Weight Actual', + cell: (props) => { + const value = props.row.original.egg_weight; + return ( +
+ {value !== null && value !== undefined + ? formatNumber(value) + : '-'} +
+ ); + }, + }, + { + id: 'egg_weight_standard', + header: 'Egg Weight Standar', + cell: (props) => { + const value = + props.row.original.project_flock?.production_standart + ?.egg_weight_std; + return ( +
+ {value !== null && value !== undefined + ? formatNumber(value) + : '-'} +
+ ); + }, + }, + ], + }, + { + id: 'hen_performance', + header: 'Hen Performance', + columns: [ + { + id: 'hen_day_actual', + header: 'Hen Day Actual', + cell: (props) => { + const value = props.row.original.hen_day; + return ( +
+ {value !== null && value !== undefined + ? `${value.toFixed(2)}%` + : '-'} +
+ ); + }, + }, + { + id: 'hen_day_standard', + header: 'Hen Day Standar', + cell: (props) => { + const value = + props.row.original.project_flock?.production_standart + ?.hen_day_std; + return ( +
+ {value !== null && value !== undefined + ? `${value.toFixed(2)}%` + : '-'} +
+ ); + }, + }, + { + id: 'hen_house_actual', + header: 'Hen House Actual', + cell: (props) => { + const value = props.row.original.hen_house; + return ( +
+ {value !== null && value !== undefined + ? `${value.toFixed(2)}%` + : '-'} +
+ ); + }, + }, + { + id: 'hen_house_standard', + header: 'Hen House Standar', + cell: (props) => { + const value = + props.row.original.project_flock?.production_standart + ?.hen_house_std; + return ( +
+ {value !== null && value !== undefined + ? `${value.toFixed(2)}%` + : '-'} +
+ ); + }, + }, + ], + }, + { + header: 'Status Approval', + cell: (props) => { + const approval = props.row.original.approval; + if (!approval) return '-'; + + const status = approval.action; + const statusColor = getStatusBadgeColor(status); + const statusText = getStatusText(status); + + return ( + + ); + }, + }, + { + header: 'Catatan Approval', + cell: (props) => { + const approval = props.row.original.approval; + if (!approval?.notes) return '-'; + + return ( +
+

{approval.notes}

+
+ ); + }, + }, + { + header: 'Dibuat Oleh', + cell: (props) => props.row.original.created_user?.name || '-', + }, + { + header: 'Tanggal Submit', + cell: (props) => + formatDate(props.row.original.created_at, 'DD MMMM YYYY'), + }, + { + header: 'Aksi', + cell: (props: CellContext) => { + const currentPageSize = + props.table.getPaginationRowModel().rows.length; + const currentPageRows = props.table.getPaginationRowModel().flatRows; + const currentRowRelativeIndex = + currentPageRows.findIndex((r) => r.id === props.row.id) + 1; + + const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; + + const deleteClickHandler = () => { + setSelectedRecording(props.row.original); + singleDeleteModal.openModal(); + }; + + const approveClickHandler = () => { + setRowSelection({ + [String(props.row.original.id)]: true, + }); + setApprovalNotes(''); + approveModal.openModal(); + }; + + const rejectClickHandler = () => { + setRowSelection({ + [String(props.row.original.id)]: true, + }); + setApprovalNotes(''); + rejectModal.openModal(); + }; + + return ( + + ); + }, + }, + ], + [ + isRecordingApproved, + tableFilterState.pageSize, + tableFilterState.page, + selectedRecording, + singleDeleteModal, + approveModal, + rejectModal, + rowSelection, + setRowSelection, + setApprovalNotes, + setSelectedRecording, + ] + ); + return ( -
-
-
-
- + <> +
+
+ {/* Action Buttons, Search and Filter Section */} +
+ {/* Action Buttons */} +
+ + + + + {selectedRowIds.length > 0 && ( + <> + + + + + + + + + )} +
+ + {/* Search and Filter */} +
+ + } + className={{ + wrapper: 'w-full min-w-24 max-w-3xs', + inputWrapper: 'rounded-xl! shadow-button-soft', + input: + 'placeholder:font-semibold placeholder:text-base-content/50', + }} + /> + - - {selectedRowIds.length > 0 && ( - <> - - - - - - - - - )} + +
- -
- -
- + {/* Table Section */} + {isLoading ? ( +
+ +
+ ) : !isResponseSuccess(recordings) || + recordings.data?.length === 0 ? ( + + } + title='Data Recording Belum Tersedia' + subtitle='Tidak ada data recording untuk saat ini.' + /> + ) : ( + + data={isResponseSuccess(recordings) ? recordings?.data : []} + columns={recordingColumns} + pageSize={tableFilterState.pageSize} + page={isResponseSuccess(recordings) ? recordings?.meta?.page : 0} + totalItems={ + isResponseSuccess(recordings) + ? recordings?.meta?.total_results + : 0 + } + onPageChange={setPage} + isLoading={isLoading} + sorting={sorting} + setSorting={setSorting} + rowSelection={rowSelection} + setRowSelection={setRowSelection} + className={{ + containerClassName: cn('mt-3 mb-0', { + 'w-full': + isResponseSuccess(recordings) && + recordings?.data?.length === 0, + }), + headerColumnClassName: 'text-nowrap', + }} + /> + )}
- - data={isResponseSuccess(recordings) ? recordings?.data : []} - columns={[ - { - id: 'select', - header: ({ table }) => { - const allRows = table.getRowModel().rows; - const selectableRows = allRows.filter((row) => { - const recording = row.original; - return !isRecordingApproved(recording); - }); - - const hasNoSelectableRows = selectableRows.length === 0; - - const handleSelectAll = () => { - const isAllSelected = selectableRows.every((row) => - row.getIsSelected() - ); - - selectableRows.forEach((row) => { - row.toggleSelected(!isAllSelected); - }); - }; - - const isAllSelected = - selectableRows.length > 0 && - selectableRows.every((row) => row.getIsSelected()); - - const isSomeSelected = selectableRows.some((row) => - row.getIsSelected() - ); - - return ( -
- -
- ); - }, - cell: ({ row }) => { - const recording = row.original; - const isDisabled = isRecordingApproved(recording); - - const handleToggleSelection = (e: unknown) => { - if (!isDisabled) { - row.getToggleSelectedHandler()(e); - } - }; - - return ( -
- -
- ); - }, - }, - { - header: 'No', - cell: (props) => - tableFilterState.pageSize * (tableFilterState.page - 1) + - props.row.index + - 1, - }, - { - header: 'Lokasi', - cell: (props) => props.row.original.location?.name || '-', - }, - { - header: 'Flock', - cell: (props) => - props.row.original.project_flock?.flock_name || '-', - }, - { - header: 'Kandang', - cell: (props) => props.row.original.kandang?.name || '-', - }, - { - header: 'Periode', - cell: (props) => props.row.original.project_flock?.period || '-', - }, - { - header: 'Kategori', - cell: (props) => { - const category = - props.row.original.project_flock?.project_flock_category; - if (!category) return '-'; - const color = category === 'LAYING' ? 'info' : 'warning'; - return ( - - {category} - - ); - }, - }, - { - header: 'Umur (hari)', - cell: (props) => { - return ( - <> - - {props.row.original.day} (Minggu ke- - {props.row.original.project_flock.production_standart.week}) - - - ); - }, - }, - { - header: 'Waktu Recording', - cell: (props) => - formatDate(props.row.original.record_datetime, 'DD MMMM YYYY'), - }, - { - header: 'Populasi Akhir', - cell: (props) => - props.row.original.project_flock?.total_chick_qty != null - ? formatNumber(props.row.original.project_flock.total_chick_qty) - : '-', - }, - { - id: 'fcr', - header: 'FCR', - columns: [ - { - id: 'fcr_actual', - header: 'Actual', - cell: (props) => { - const value = props.row.original.fcr_value; - return ( -
- {value !== null && value !== undefined - ? formatNumber(value) - : '-'} -
- ); - }, - }, - { - id: 'fcr_standard', - header: 'Standard', - cell: (props) => { - const value = props.row.original.project_flock?.fcr?.fcr_std; - return ( -
- {value !== null && value !== undefined - ? formatNumber(value) - : '-'} -
- ); - }, - }, - ], - }, - { - id: 'feed_intake', - header: 'Feed Intake (KG)', - columns: [ - { - id: 'feed_intake_actual', - header: 'Actual', - cell: (props) => { - const value = props.row.original.feed_intake; - return ( -
- {value !== null && value !== undefined - ? formatNumber(value) - : '-'} -
- ); - }, - }, - { - id: 'feed_intake_standard', - header: 'Standard', - cell: (props) => { - const value = - props.row.original.project_flock?.production_standart - ?.feed_intake_std; - return ( -
- {value !== null && value !== undefined - ? formatNumber(value) - : '-'} -
- ); - }, - }, - ], - }, - { - id: 'mortality', - header: 'Mortality', - columns: [ - { - id: 'cum_depletion_rate_actual', - header: 'Cum Depletion Rate', - cell: (props) => { - const value = props.row.original.cum_depletion_rate; - return ( -
- {value !== null && value !== undefined - ? `${value.toFixed(2)}%` - : '-'} -
- ); - }, - }, - { - id: 'max_depletion_std', - header: 'Max Depletion Std', - cell: (props) => { - const value = - props.row.original.project_flock?.production_standart - ?.max_depletion_std; - return ( -
- {value !== null && value !== undefined - ? `${value.toFixed(2)}%` - : '-'} -
- ); - }, - }, - { - id: 'total_depletion', - header: 'Total Depletion', - cell: (props) => { - const value = props.row.original.total_depletion_qty; - return ( -
- {value !== null && value !== undefined - ? formatNumber(value) - : '-'} -
- ); - }, - }, - ], - }, - { - id: 'egg_production', - header: 'Egg Production', - columns: [ - { - id: 'egg_mass_actual', - header: 'Egg Mass Actual', - cell: (props) => { - const value = props.row.original.egg_mass; - return ( -
- {value !== null && value !== undefined - ? formatNumber(value) - : '-'} -
- ); - }, - }, - { - id: 'egg_mass_standard', - header: 'Egg Mass Standar', - cell: (props) => { - const value = - props.row.original.project_flock?.production_standart - ?.egg_mass_std; - return ( -
- {value !== null && value !== undefined - ? formatNumber(value) - : '-'} -
- ); - }, - }, - { - id: 'egg_weight_actual', - header: 'Egg Weight Actual', - cell: (props) => { - const value = props.row.original.egg_weight; - return ( -
- {value !== null && value !== undefined - ? formatNumber(value) - : '-'} -
- ); - }, - }, - { - id: 'egg_weight_standard', - header: 'Egg Weight Standar', - cell: (props) => { - const value = - props.row.original.project_flock?.production_standart - ?.egg_weight_std; - return ( -
- {value !== null && value !== undefined - ? formatNumber(value) - : '-'} -
- ); - }, - }, - ], - }, - { - id: 'hen_performance', - header: 'Hen Performance', - columns: [ - { - id: 'hen_day_actual', - header: 'Hen Day Actual', - cell: (props) => { - const value = props.row.original.hen_day; - return ( -
- {value !== null && value !== undefined - ? `${value.toFixed(2)}%` - : '-'} -
- ); - }, - }, - { - id: 'hen_day_standard', - header: 'Hen Day Standar', - cell: (props) => { - const value = - props.row.original.project_flock?.production_standart - ?.hen_day_std; - return ( -
- {value !== null && value !== undefined - ? `${value.toFixed(2)}%` - : '-'} -
- ); - }, - }, - { - id: 'hen_house_actual', - header: 'Hen House Actual', - cell: (props) => { - const value = props.row.original.hen_house; - return ( -
- {value !== null && value !== undefined - ? `${value.toFixed(2)}%` - : '-'} -
- ); - }, - }, - { - id: 'hen_house_standard', - header: 'Hen House Standar', - cell: (props) => { - const value = - props.row.original.project_flock?.production_standart - ?.hen_house_std; - return ( -
- {value !== null && value !== undefined - ? `${value.toFixed(2)}%` - : '-'} -
- ); - }, - }, - ], - }, - { - header: 'Status Approval', - cell: (props) => { - const approval = props.row.original.approval; - if (!approval) return '-'; - - const status = approval.action; - const statusColor = getStatusBadgeColor(status); - const statusText = getStatusText(status); - - return ( - - ); - }, - }, - { - header: 'Catatan Approval', - cell: (props) => { - const approval = props.row.original.approval; - if (!approval?.notes) return '-'; - - return ( -
-

{approval.notes}

-
- ); - }, - }, - // { - // header: 'Status Grading Telur', - // cell: (props) => { - // const status = props.row.original.egg_grading_status; - // if (!status) return '-'; - // const color = status === 'COMPLETED' ? 'success' : 'warning'; - // return ( - // - // {status} - // - // ); - // }, - // }, - { - header: 'Dibuat Oleh', - cell: (props) => props.row.original.created_user?.name || '-', - }, - { - header: 'Tanggal Submit', - cell: (props) => - formatDate(props.row.original.created_at, 'DD MMMM YYYY'), - }, - { - header: 'Aksi', - cell: (props: CellContext) => { - const currentPageSize = - props.table.getPaginationRowModel().rows.length; - const currentPageRows = - props.table.getPaginationRowModel().flatRows; - const currentRowRelativeIndex = - currentPageRows.findIndex((r) => r.id === props.row.id) + 1; - - const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; - - const deleteClickHandler = () => { - setSelectedRecording(props.row.original); - singleDeleteModal.openModal(); - }; - - const approveClickHandler = () => { - setRowSelection({ - [String(props.row.original.id)]: true, - }); - setApprovalNotes(''); - approveModal.openModal(); - }; - - const rejectClickHandler = () => { - setRowSelection({ - [String(props.row.original.id)]: true, - }); - setApprovalNotes(''); - rejectModal.openModal(); - }; - - return ( - <> - {currentPageSize > 2 && ( - - - - )} - - {currentPageSize <= 2 && ( - - - - )} - - ); - }, - }, - ]} - pageSize={tableFilterState.pageSize} - page={isResponseSuccess(recordings) ? recordings?.meta?.page : 0} - totalItems={ - isResponseSuccess(recordings) ? recordings?.meta?.total_results : 0 - } - onPageChange={setPage} - isLoading={isLoading} - sorting={sorting} - setSorting={setSorting} - rowSelection={rowSelection} - setRowSelection={setRowSelection} + {/* Filter Modal */} + + > + {/* Modal Header */} +
+
+ +

Filter Data

+
+ +
+
+
+ + + + + + + +
+ + {/* Modal Footer */} +
+ + +
+
+
{ placeholder='(Opsional) Tambahkan catatan untuk reject ini...' rows={3} /> -
+ ); }; diff --git a/src/components/pages/production/recording/filter/RecordingFilter.ts b/src/components/pages/production/recording/filter/RecordingFilter.ts new file mode 100644 index 00000000..955ae744 --- /dev/null +++ b/src/components/pages/production/recording/filter/RecordingFilter.ts @@ -0,0 +1,15 @@ +import { string, object } from 'yup'; + +export const RecordingFilterSchema = object().shape({ + area_id: string().nullable(), + location_id: string().nullable(), + kandang_id: string().nullable(), + project_flock_kandang_id: string().nullable(), +}); + +export type RecordingFilterType = { + area_id: string | null; + location_id: string | null; + kandang_id: string | null; + project_flock_kandang_id: string | null; +}; diff --git a/src/components/pages/production/recording/skeleton/RecordingTableSkeleton.tsx b/src/components/pages/production/recording/skeleton/RecordingTableSkeleton.tsx new file mode 100644 index 00000000..dc96f7c4 --- /dev/null +++ b/src/components/pages/production/recording/skeleton/RecordingTableSkeleton.tsx @@ -0,0 +1,37 @@ +import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton'; +import Table from '@/components/Table'; +import { Recording } from '@/types/api/production/recording'; +import { ColumnDef } from '@tanstack/react-table'; + +const RecordingTableSkeleton = ({ + columns, + icon, + title = 'Data Recording Belum Tersedia', + subtitle = 'Tidak ada data recording untuk saat ini.', +}: { + columns: ColumnDef[]; + icon: React.ReactNode; + title?: string; + subtitle?: string; +}) => { + return ( +
+ +
+ +
+ + ); +}; + +export default RecordingTableSkeleton; From a5b4deaac4d34586057eab866c9ab04baaa8d35f Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 24 Feb 2026 15:13:37 +0700 Subject: [PATCH 2/9] refactor(FE): Refactor RecordingTable layout for improved readability --- src/app/production/recording/page.tsx | 2 +- .../production/recording/RecordingTable.tsx | 241 +++++++++--------- 2 files changed, 122 insertions(+), 121 deletions(-) diff --git a/src/app/production/recording/page.tsx b/src/app/production/recording/page.tsx index fbbac7cb..368a59ea 100644 --- a/src/app/production/recording/page.tsx +++ b/src/app/production/recording/page.tsx @@ -2,7 +2,7 @@ import RecordingTable from '@/components/pages/production/recording/RecordingTab const Recording = () => { return ( -
+
); diff --git a/src/components/pages/production/recording/RecordingTable.tsx b/src/components/pages/production/recording/RecordingTable.tsx index bd26670d..13737cf3 100644 --- a/src/components/pages/production/recording/RecordingTable.tsx +++ b/src/components/pages/production/recording/RecordingTable.tsx @@ -1173,131 +1173,132 @@ const RecordingTable = () => { return ( <>
-
- {/* Action Buttons, Search and Filter Section */} -
- {/* Action Buttons */} -
- - - - - {selectedRowIds.length > 0 && ( - <> - - - - - - - - - )} -
- - {/* Search and Filter */} -
- - } - className={{ - wrapper: 'w-full min-w-24 max-w-3xs', - inputWrapper: 'rounded-xl! shadow-button-soft', - input: - 'placeholder:font-semibold placeholder:text-base-content/50', - }} - /> - +
+ {/* Action Buttons */} +
+ + - -
+ {selectedRowIds.length > 0 && ( + <> +
+ + + + + + + + + + )}
- {/* Table Section */} + {/* Search and Filter */} +
+ + } + className={{ + wrapper: 'w-full min-w-24 max-w-3xs', + inputWrapper: 'rounded-xl! shadow-button-soft', + input: + 'placeholder:font-semibold placeholder:text-base-content/50', + }} + /> + + + + +
+
+ + {/* Table Section */} +
{isLoading ? (
@@ -1335,7 +1336,7 @@ const RecordingTable = () => { rowSelection={rowSelection} setRowSelection={setRowSelection} className={{ - containerClassName: cn('mt-3 mb-0', { + containerClassName: cn('p-3 mb-0', { 'w-full': isResponseSuccess(recordings) && recordings?.data?.length === 0, From 9a5e2987d5de84f7fc76138a0c1e627d9c058e8d Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 24 Feb 2026 15:22:06 +0700 Subject: [PATCH 3/9] refactor(FE): Conditionally load data based on filter modal state --- .../pages/production/recording/RecordingTable.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/pages/production/recording/RecordingTable.tsx b/src/components/pages/production/recording/RecordingTable.tsx index 13737cf3..752ae1d1 100644 --- a/src/components/pages/production/recording/RecordingTable.tsx +++ b/src/components/pages/production/recording/RecordingTable.tsx @@ -313,7 +313,7 @@ const RecordingTable = () => { isLoadingOptions: isLoadingLocationOptions, loadMore: loadMoreLocations, } = useSelect( - LocationApi.basePath, + filterModal.open ? LocationApi.basePath : null, 'id', 'name', 'search', @@ -325,7 +325,12 @@ const RecordingTable = () => { options: areaOptions, isLoadingOptions: isLoadingAreaOptions, loadMore: loadMoreAreas, - } = useSelect(AreaApi.basePath, 'id', 'name', 'search'); + } = useSelect( + filterModal.open ? AreaApi.basePath : null, + 'id', + 'name', + 'search' + ); const projectFlockParams = useMemo(() => { if (filterProjectFlockLocationId) { @@ -341,7 +346,7 @@ const RecordingTable = () => { isLoadingOptions: isLoadingProjectFlocks, loadMore: loadMoreProjectFlocks, } = useSelect( - ProjectFlockApi.basePath, + filterModal.open ? ProjectFlockApi.basePath : null, 'id', 'flock_name', 'search', From bdca10e0ac79b5f64f28429870c34846856fad61 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 24 Feb 2026 15:31:22 +0700 Subject: [PATCH 4/9] refactor(FE): Handle rejected recordings in selection logic --- .../pages/production/recording/RecordingTable.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/pages/production/recording/RecordingTable.tsx b/src/components/pages/production/recording/RecordingTable.tsx index 752ae1d1..eea30412 100644 --- a/src/components/pages/production/recording/RecordingTable.tsx +++ b/src/components/pages/production/recording/RecordingTable.tsx @@ -701,7 +701,11 @@ const RecordingTable = () => { const allRows = table.getRowModel().rows; const selectableRows = allRows.filter((row) => { const recording = row.original; - return !isRecordingApproved(recording); + const isRecordingApproved = recording.approval?.action === 'APPROVED' && + recording.approval?.step_number === 2 && + recording.approval?.step_name === 'Disetujui'; + const isRecordingRejected = recording.approval?.action === 'REJECTED'; + return !isRecordingApproved && !isRecordingRejected; }); const hasNoSelectableRows = selectableRows.length === 0; @@ -738,7 +742,11 @@ const RecordingTable = () => { }, cell: ({ row }) => { const recording = row.original; - const isDisabled = isRecordingApproved(recording); + const isRecordingApproved = recording.approval?.action === 'APPROVED' && + recording.approval?.step_number === 2 && + recording.approval?.step_name === 'Disetujui'; + const isRecordingRejected = recording.approval?.action === 'REJECTED'; + const isDisabled = isRecordingApproved || isRecordingRejected; const handleToggleSelection = (e: unknown) => { if (!isDisabled) { From 9de31c991dc7f50f30ffff4522ad03ab081bcc84 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 24 Feb 2026 15:34:11 +0700 Subject: [PATCH 5/9] refactor(FE): Refactor approval checks for readability --- .../pages/production/recording/RecordingTable.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/pages/production/recording/RecordingTable.tsx b/src/components/pages/production/recording/RecordingTable.tsx index eea30412..d7f69229 100644 --- a/src/components/pages/production/recording/RecordingTable.tsx +++ b/src/components/pages/production/recording/RecordingTable.tsx @@ -701,10 +701,12 @@ const RecordingTable = () => { const allRows = table.getRowModel().rows; const selectableRows = allRows.filter((row) => { const recording = row.original; - const isRecordingApproved = recording.approval?.action === 'APPROVED' && + const isRecordingApproved = + recording.approval?.action === 'APPROVED' && recording.approval?.step_number === 2 && recording.approval?.step_name === 'Disetujui'; - const isRecordingRejected = recording.approval?.action === 'REJECTED'; + const isRecordingRejected = + recording.approval?.action === 'REJECTED'; return !isRecordingApproved && !isRecordingRejected; }); @@ -742,7 +744,8 @@ const RecordingTable = () => { }, cell: ({ row }) => { const recording = row.original; - const isRecordingApproved = recording.approval?.action === 'APPROVED' && + const isRecordingApproved = + recording.approval?.action === 'APPROVED' && recording.approval?.step_number === 2 && recording.approval?.step_name === 'Disetujui'; const isRecordingRejected = recording.approval?.action === 'REJECTED'; From ced3970aaed5c03061c0a569272da0b5dd60bc07 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 24 Feb 2026 15:38:19 +0700 Subject: [PATCH 6/9] refactor(FE): Add padding to RecordingTableSkeleton container class --- .../production/recording/skeleton/RecordingTableSkeleton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/pages/production/recording/skeleton/RecordingTableSkeleton.tsx b/src/components/pages/production/recording/skeleton/RecordingTableSkeleton.tsx index dc96f7c4..b4e4d0f7 100644 --- a/src/components/pages/production/recording/skeleton/RecordingTableSkeleton.tsx +++ b/src/components/pages/production/recording/skeleton/RecordingTableSkeleton.tsx @@ -23,7 +23,7 @@ const RecordingTableSkeleton = ({ className={{ skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4', headerColumnClassName: 'whitespace-nowrap', - containerClassName: 'mb-0 overflow-hidden', + containerClassName: 'mb-0 overflow-hidden p-3', tableWrapperClassName: 'overflow-hidden', }} /> From 1a2e38568b6256afcba1eab75a8d274defb713c9 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 24 Feb 2026 15:46:32 +0700 Subject: [PATCH 7/9] refactor(FE): Remove unused page size selector and related logic --- .../production/recording/RecordingTable.tsx | 24 +------------------ 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/src/components/pages/production/recording/RecordingTable.tsx b/src/components/pages/production/recording/RecordingTable.tsx index d7f69229..120c1e62 100644 --- a/src/components/pages/production/recording/RecordingTable.tsx +++ b/src/components/pages/production/recording/RecordingTable.tsx @@ -34,7 +34,6 @@ import { RecordingFilterType, } from '@/components/pages/production/recording/filter/RecordingFilter'; import RecordingTableSkeleton from '@/components/pages/production/recording/skeleton/RecordingTableSkeleton'; -import { ROWS_OPTIONS } from '@/config/constant'; import Table from '@/components/Table'; import { type Recording } from '@/types/api/production/recording'; import { RecordingApi } from '@/services/api/production'; @@ -583,15 +582,6 @@ const RecordingTable = () => { [updateFilter, setSearchValue, setPage] ); - const pageSizeChangeHandler = useCallback( - (val: OptionType | OptionType[] | null) => { - const newVal = val as OptionType; - setPageSize(newVal.value as number); - setPage(1); - }, - [setPageSize, setPage] - ); - const singleDeleteHandler = async () => { setIsDeleteLoading(true); @@ -1297,19 +1287,6 @@ const RecordingTable = () => { )} - -
@@ -1346,6 +1323,7 @@ const RecordingTable = () => { : 0 } onPageChange={setPage} + onPageSizeChange={setPageSize} isLoading={isLoading} sorting={sorting} setSorting={setSorting} From 6566b881b2a8f5c2a16f7c308da32268819916d5 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 24 Feb 2026 21:02:56 +0700 Subject: [PATCH 8/9] refactor(FE): Use `formatTitleCase` for category text in StatusBadge --- .../pages/production/recording/RecordingTable.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/components/pages/production/recording/RecordingTable.tsx b/src/components/pages/production/recording/RecordingTable.tsx index 120c1e62..cd7002a9 100644 --- a/src/components/pages/production/recording/RecordingTable.tsx +++ b/src/components/pages/production/recording/RecordingTable.tsx @@ -10,7 +10,7 @@ import React, { import useSWR from 'swr'; import { Icon } from '@iconify/react'; import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table'; -import { cn, formatDate, formatNumber } from '@/lib/helper'; +import { cn, formatDate, formatNumber, formatTitleCase } from '@/lib/helper'; import RequirePermission from '@/components/helper/RequirePermission'; import Modal, { useModal } from '@/components/Modal'; import Button from '@/components/Button'; @@ -790,11 +790,7 @@ const RecordingTable = () => { props.row.original.project_flock?.project_flock_category; if (!category) return '-'; const color = category === 'LAYING' ? 'info' : 'warning'; - return ( - - {category} - - ); + return ; }, }, { From f0041ca9384b1d24c65b3f3ee487f0ff719ee020 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 24 Feb 2026 21:05:22 +0700 Subject: [PATCH 9/9] refactor(FE): Filter inactive kandangs in UniformityForm options --- .../pages/production/uniformity/form/UniformityForm.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/pages/production/uniformity/form/UniformityForm.tsx b/src/components/pages/production/uniformity/form/UniformityForm.tsx index 724f7b81..6cbb134e 100644 --- a/src/components/pages/production/uniformity/form/UniformityForm.tsx +++ b/src/components/pages/production/uniformity/form/UniformityForm.tsx @@ -155,9 +155,12 @@ const UniformityForm = ({ const kandangOpts = selectedProjectFlockData.kandangs .filter((kandang: Kandang) => { if (formType === 'add') { - return approvedKandangIds.includes(kandang.id); + return ( + approvedKandangIds.includes(kandang.id) && + kandang.status === 'ACTIVE' + ); } - return true; + return kandang.status === 'ACTIVE'; }) .map((kandang: Kandang) => ({ value: kandang.id,