From 35001ff4228f0ba1ac95f95e1ab5048a366a6f9f Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Tue, 5 May 2026 16:10:44 +0700 Subject: [PATCH 01/20] fix: make depletion and egg optional --- .../recording/form/RecordingForm.schema.ts | 14 +++++----- .../recording/form/RecordingForm.tsx | 28 +++++++++---------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.schema.ts b/src/components/pages/production/recording/form/RecordingForm.schema.ts index 51d176f1..a96254b0 100644 --- a/src/components/pages/production/recording/form/RecordingForm.schema.ts +++ b/src/components/pages/production/recording/form/RecordingForm.schema.ts @@ -42,7 +42,7 @@ type RecordingGrowingFormSchemaType = { product_warehouse_id?: { value: number; label: string; - }; + } | null; source_product_warehouse_id?: number; qty?: number | string; }[]; @@ -53,7 +53,7 @@ type RecordingLayingFormSchemaType = RecordingGrowingFormSchemaType & { product_warehouse_id?: { value: number; label: string; - }; + } | null; qty?: number | string; weight?: number | string; }[]; @@ -71,7 +71,7 @@ export type DepletionSchema = { product_warehouse_id?: { value: number; label: string; - }; + } | null; source_product_warehouse_id?: number; qty?: number | string; }; @@ -80,7 +80,7 @@ export type EggSchema = { product_warehouse_id?: { value: number; label: string; - }; + } | null; qty?: number | string; weight?: number | string; }; @@ -104,7 +104,7 @@ const DepletionObjectSchema: Yup.ObjectSchema = Yup.object({ label: Yup.string().required(), }) .optional() - .typeError('Depletions harus berupa angka!'), + .nullable(), source_product_warehouse_id: Yup.number() .optional() .typeError('Gudang sumber harus berupa angka!'), @@ -119,7 +119,7 @@ const EggObjectSchema: Yup.ObjectSchema = Yup.object({ label: Yup.string().required(), }) .optional() - .typeError('Kondisi telur harus berupa angka!'), + .nullable(), qty: Yup.number().optional().typeError('Jumlah telur harus berupa angka!'), weight: Yup.number().optional().typeError('Berat telur harus berupa angka!'), }); @@ -324,7 +324,7 @@ export const getRecordingLayingFormInitialValues = ( weight: egg.weight, })) ?? [ { - product_warehouse_id: undefined, + product_warehouse_id: null, qty: '', weight: '', }, diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index 9a7e11b6..39b13066 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -1472,7 +1472,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { (productWarehouseId: number) => { if ((type === 'edit' || type === 'detail') && initialValues?.stocks) { const existingStock = initialValues.stocks.find( - (s) => s.product_warehouse_id === productWarehouseId + (s) => Number(s.product_warehouse_id) === Number(productWarehouseId) ) as RecordingStock | undefined; if (existingStock) { return { @@ -1731,14 +1731,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { formik.setFieldTouched('stocks', false, false); formik.setFieldValue('stocks', [ { - product_warehouse_id: 0, + product_warehouse_id: null, qty: '', }, ]); formik.setFieldTouched('depletions', false, false); formik.setFieldValue('depletions', [ { - product_warehouse_id: 0, + product_warehouse_id: null, qty: '', }, ]); @@ -1746,7 +1746,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { formik.setFieldTouched('eggs', false, false); formik.setFieldValue('eggs', [ { - product_warehouse_id: 0, + product_warehouse_id: null, qty: '', weight: '', }, @@ -1795,14 +1795,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { formik.setFieldTouched('stocks', false, false); formik.setFieldValue('stocks', [ { - product_warehouse_id: 0, + product_warehouse_id: null, qty: '', }, ]); formik.setFieldTouched('depletions', false, false); formik.setFieldValue('depletions', [ { - product_warehouse_id: 0, + product_warehouse_id: null, qty: '', }, ]); @@ -1810,7 +1810,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { formik.setFieldTouched('eggs', false, false); formik.setFieldValue('eggs', [ { - product_warehouse_id: 0, + product_warehouse_id: null, qty: '', weight: '', }, @@ -1848,14 +1848,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { formik.setFieldTouched('stocks', false, false); formik.setFieldValue('stocks', [ { - product_warehouse_id: 0, + product_warehouse_id: null, qty: '', }, ]); formik.setFieldTouched('depletions', false, false); formik.setFieldValue('depletions', [ { - product_warehouse_id: 0, + product_warehouse_id: null, qty: '', }, ]); @@ -1863,7 +1863,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { formik.setFieldTouched('eggs', false, false); formik.setFieldValue('eggs', [ { - product_warehouse_id: 0, + product_warehouse_id: null, qty: '', weight: '', }, @@ -2076,7 +2076,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const newStocks = [ ...(formik.values.stocks || []), { - product_warehouse_id: 0, + product_warehouse_id: null, qty: '', }, ]; @@ -2108,7 +2108,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const newDepletions = [ ...(formik.values.depletions || []), { - product_warehouse_id: 0, + product_warehouse_id: null, qty: '', }, ]; @@ -2142,7 +2142,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const newEggs = [ ...((formik.values as RecordingLayingFormValues).eggs || []), { - product_warehouse_id: 0, + product_warehouse_id: null, qty: '', }, ]; @@ -2185,7 +2185,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { if (isLayingCategory && (type as 'add' | 'edit' | 'detail') !== 'detail') { const layingValues = formik.values as RecordingLayingFormValues; if (!layingValues.eggs || layingValues.eggs.length === 0) { - setFieldValue('eggs', [{ product_warehouse_id: 0, qty: '' }]); + setFieldValue('eggs', [{ product_warehouse_id: null, qty: '' }]); } } }, [isLayingCategory, type, formik.values, setFieldValue]); From 79e41d8a6ffc6cb0a25c5a459aeb0761df376efa Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Tue, 5 May 2026 16:10:57 +0700 Subject: [PATCH 02/20] fix: implement table persist state in recording filter --- .../production/recording/RecordingTable.tsx | 422 +++++++----------- .../recording/filter/RecordingFilter.ts | 51 ++- 2 files changed, 184 insertions(+), 289 deletions(-) diff --git a/src/components/pages/production/recording/RecordingTable.tsx b/src/components/pages/production/recording/RecordingTable.tsx index 04c36ef2..f5050844 100644 --- a/src/components/pages/production/recording/RecordingTable.tsx +++ b/src/components/pages/production/recording/RecordingTable.tsx @@ -1,13 +1,6 @@ 'use client'; -import axios from 'axios'; -import React, { - useCallback, - useState, - useMemo, - useEffect, - useRef, -} from 'react'; +import React, { useCallback, useState, useMemo, useEffect } from 'react'; import useSWR from 'swr'; import { Icon } from '@iconify/react'; import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table'; @@ -46,8 +39,6 @@ import { useTableFilter } from '@/services/hooks/useTableFilter'; import toast from 'react-hot-toast'; import StatusBadge from '@/components/helper/StatusBadge'; import CheckboxInput from '@/components/input/CheckboxInput'; -import { useUiStore } from '@/stores/ui/ui.store'; -import { usePathname } from 'next/navigation'; import { Color } from '@/types/theme'; import ButtonFilter from '@/components/helper/ButtonFilter'; import Dropdown from '@/components/Dropdown'; @@ -77,6 +68,26 @@ const getStatusBadgeColor = (status: string): Color => { return statusBadgeColorMap[normalizedStatus] || 'neutral'; }; +const isRecordingApproved = (recording: Recording): boolean => { + return ( + recording.approval?.action === 'APPROVED' && + recording.approval?.step_name === 'Disetujui' + ); +}; + +// ===== FILTER HELPERS ===== +const recordingApprovalStatusOptions: OptionType[] = [ + { value: 'CREATED', label: 'Pengajuan' }, + { value: 'UPDATED', label: 'Diperbarui' }, + { value: 'APPROVED', label: 'Disetujui' }, + { value: 'REJECTED', label: 'Ditolak' }, +]; + +const projectFlockCategoryOptions: OptionType[] = [ + { value: 'GROWING', label: 'Growing' }, + { value: 'LAYING', label: 'Laying' }, +]; + const RowOptionsMenu = ({ popoverPosition = 'bottom', props, @@ -268,25 +279,31 @@ const RowOptionsMenu = ({ }; const RecordingTable = () => { - const { searchValue, setSearchValue, setTableState } = useUiStore(); - const pathname = usePathname(); - const { state: tableFilterState, updateFilter, setPage, setPageSize, toQueryString: getTableFilterQueryString, - } = useTableFilter({ + } = useTableFilter<{ + search: string; + areaFilter: OptionType | null; + locationFilter: OptionType | null; + projectFlockFilter: OptionType | null; + kandangFilter: OptionType | null; + projectFlockKandangFilter: number | null; + approvalStatusFilter: OptionType | null; + projectFlockCategoryFilter: OptionType | null; + }>({ initial: { search: '', - areaFilter: '', - locationFilter: '', - projectFlockFilter: '', - kandangFilter: '', - projectFlockKandangFilter: '', - approvalStatusFilter: '', - projectFlockCategoryFilter: '', + areaFilter: null, + locationFilter: null, + projectFlockFilter: null, + kandangFilter: null, + projectFlockKandangFilter: null, + approvalStatusFilter: null, + projectFlockCategoryFilter: null, }, paramMap: { page: 'page', @@ -300,68 +317,73 @@ const RecordingTable = () => { approvalStatusFilter: 'approval_status', projectFlockCategoryFilter: 'project_flock_category', }, - }); - useEffect(() => { - updateFilter('search', searchValue); - }, [searchValue, updateFilter]); + persist: true, + storeName: 'recording-table', + }); // ===== 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, - project_flock_id: null, - kandang_id: null, - project_flock_kandang_id: null, - approval_status: null, - project_flock_category: null, + area_id: tableFilterState.areaFilter, + location_id: tableFilterState.locationFilter, + project_flock_id: tableFilterState.projectFlockFilter, + kandang_id: tableFilterState.kandangFilter, + project_flock_kandang_id: tableFilterState.projectFlockKandangFilter, + approval_status: tableFilterState.approvalStatusFilter, + project_flock_category: tableFilterState.projectFlockCategoryFilter, }, validationSchema: RecordingFilterSchema, onSubmit: (values, { setSubmitting }) => { - updateFilter('areaFilter', values.area_id || ''); - updateFilter('locationFilter', values.location_id || ''); - updateFilter('projectFlockFilter', values.project_flock_id || ''); - updateFilter('kandangFilter', values.kandang_id || ''); + updateFilter('areaFilter', values.area_id, true); + updateFilter('locationFilter', values.location_id, true); + updateFilter('projectFlockFilter', values.project_flock_id, true); + updateFilter('kandangFilter', values.kandang_id, true); updateFilter( 'projectFlockKandangFilter', - values.project_flock_kandang_id || '' + values.project_flock_kandang_id, + true ); - updateFilter('approvalStatusFilter', values.approval_status || ''); + updateFilter('approvalStatusFilter', values.approval_status, true); updateFilter( 'projectFlockCategoryFilter', - values.project_flock_category || '' + values.project_flock_category, + true ); filterModal.closeModal(); setSubmitting(false); }, - onReset: () => { - updateFilter('areaFilter', ''); - updateFilter('locationFilter', ''); - updateFilter('projectFlockFilter', ''); - updateFilter('kandangFilter', ''); - updateFilter('projectFlockKandangFilter', ''); - updateFilter('approvalStatusFilter', ''); - updateFilter('projectFlockCategoryFilter', ''); - }, }); + const formikResetHandler = () => { + updateFilter('areaFilter', null, true); + updateFilter('locationFilter', null, true); + updateFilter('projectFlockFilter', null, true); + updateFilter('kandangFilter', null, true); + updateFilter('projectFlockKandangFilter', null, true); + updateFilter('approvalStatusFilter', null, true); + updateFilter('projectFlockCategoryFilter', null, true); + + formik.resetForm({ + values: { + area_id: null, + location_id: null, + project_flock_id: null, + kandang_id: null, + project_flock_kandang_id: null, + approval_status: null, + project_flock_category: null, + }, + }); + + filterModal.closeModal(); + }; + + const { project_flock_id, kandang_id } = formik.values; + const [sorting, setSorting] = useState([]); const [rowSelection, setRowSelection] = useState>({}); const selectedRowIds = Object.keys(rowSelection).map((item) => @@ -396,13 +418,6 @@ const RecordingTable = () => { ); // ===== LOCATION, AREA, KANDANG OPTIONS ===== - const locationParams = useMemo(() => { - if (filterLocationAreaId) { - return { area_id: filterLocationAreaId }; - } - return undefined; - }, [filterLocationAreaId]); - const { setInputValue: setLocationInputValue, options: locationOptions, @@ -413,7 +428,9 @@ const RecordingTable = () => { 'id', 'name', 'search', - locationParams + { + area_id: String(formik.values.area_id?.value), + } ); const { @@ -428,13 +445,6 @@ const RecordingTable = () => { 'search' ); - const projectFlockParams = useMemo(() => { - if (filterProjectFlockLocationId) { - return { location_id: filterProjectFlockLocationId }; - } - return undefined; - }, [filterProjectFlockLocationId]); - const { setInputValue: setProjectFlockInputValue, options: projectFlockOptions, @@ -446,34 +456,41 @@ const RecordingTable = () => { 'id', 'flock_name', 'search', - projectFlockParams + { + location_id: String(formik.values.location_id?.value), + } ); const kandangOptions = useMemo(() => { - if (!filterProjectFlock || !projectFlocksRawData) return []; + if (!project_flock_id || !projectFlocksRawData) return []; if (!isResponseSuccess(projectFlocksRawData)) return []; const data = projectFlocksRawData.data as ProjectFlock[]; - const selectedProjectFlockData = data.find( - (pf) => pf.id === filterProjectFlock.value + const selectedProjectFlockData = data.find((pf) => + pf.id === formik.values.project_flock_id?.value + ? Number(formik.values.project_flock_id.value) + : 0 ); if (!selectedProjectFlockData?.kandangs) return []; + return selectedProjectFlockData.kandangs.map((k) => ({ value: k.id, label: k.name || '', })); - }, [filterProjectFlock, projectFlocksRawData]); + }, [project_flock_id, projectFlocksRawData]); // ===== PROJECT FLOCK KANDANG LOOKUP ===== const projectFlockKandangLookupUrl = useMemo(() => { - if (!filterProjectFlock || !filterKandang) return null; + if (!project_flock_id?.value || !kandang_id?.value) return null; + const params = new URLSearchParams({ - project_flock_id: filterProjectFlock.value.toString(), - kandang_id: filterKandang.value.toString(), + project_flock_id: project_flock_id.value.toString(), + kandang_id: kandang_id.value.toString(), }); + return `${ProjectFlockApi.basePath}/kandangs/lookup?${params.toString()}`; - }, [filterProjectFlock, filterKandang]); + }, [project_flock_id, kandang_id]); const { data: projectFlockKandangLookupData } = useSWR( projectFlockKandangLookupUrl, @@ -495,154 +512,45 @@ const RecordingTable = () => { ? 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); + formik.setFieldValue('project_flock_kandang_id', pfkId); } else { - setFilterProjectFlockKandangId(undefined); - formikRef.current.setFieldValue('project_flock_kandang_id', null); + formik.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; + const handleFilterAreaChange = (val: OptionType | OptionType[] | null) => { + formik.setFieldValue('area_id', val); + formik.setFieldValue('location_id', null); + formik.setFieldValue('project_flock_id', null); + formik.setFieldValue('kandang_id', null); + formik.setFieldValue('project_flock_kandang_id', null); + }; - formik.setFieldValue('area_id', areaId); - formik.setFieldValue('location_id', null); - formik.setFieldValue('project_flock_id', null); - formik.setFieldValue('kandang_id', null); - formik.setFieldValue('project_flock_kandang_id', null); + const handleFilterLocationChange = ( + val: OptionType | OptionType[] | null + ) => { + formik.setFieldValue('location_id', val); + formik.setFieldValue('project_flock_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 handleFilterProjectFlockChange = ( + val: OptionType | OptionType[] | null + ) => { + formik.setFieldValue('project_flock_id', val); + formik.setFieldValue('kandang_id', null); + formik.setFieldValue('project_flock_kandang_id', null); + }; - 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('project_flock_id', null); - 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; - const projectFlockId = projectFlock?.value - ? String(projectFlock.value) - : null; - - formik.setFieldValue('project_flock_id', projectFlockId); - 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]); - - const recordingApprovalStatusOptions: OptionType[] = [ - { value: 'CREATED', label: 'Pengajuan' }, - { value: 'UPDATED', label: 'Diperbarui' }, - { value: 'APPROVED', label: 'Disetujui' }, - { value: 'REJECTED', label: 'Ditolak' }, - ]; - - const projectFlockCategoryOptions: OptionType[] = [ - { value: 'GROWING', label: 'Growing' }, - { value: 'LAYING', label: 'Laying' }, - ]; - - const approvalStatusValue = useMemo(() => { - if (!formik.values.approval_status) return null; - return ( - recordingApprovalStatusOptions.find( - (opt) => opt.value === formik.values.approval_status - ) || null - ); - }, [formik.values.approval_status]); - - const projectFlockCategoryValue = useMemo(() => { - if (!formik.values.project_flock_category) return null; - return ( - projectFlockCategoryOptions.find( - (opt) => opt.value === formik.values.project_flock_category - ) || null - ); - }, [formik.values.project_flock_category]); + const handleFilterKandangChange = (val: OptionType | OptionType[] | null) => { + formik.setFieldValue('kandang_id', val); + formik.setFieldValue('project_flock_kandang_id', null); + }; // ===== HANDLE FILTER MODAL OPEN ===== const handleFilterModalOpen = () => { @@ -650,25 +558,9 @@ const RecordingTable = () => { formik.validateForm(); }; - const isRecordingApproved = useCallback((recording: Recording): boolean => { - return ( - recording.approval?.action === 'APPROVED' && - recording.approval?.step_name === 'Disetujui' - ); - }, []); - - useEffect(() => { - setTableState('recording-table', pathname); - }, [pathname, setTableState]); - - const searchChangeHandler = useCallback( - (e: React.ChangeEvent) => { - updateFilter('search', e.target.value); - setSearchValue(e.target.value); - setPage(1); - }, - [updateFilter, setSearchValue, setPage] - ); + const searchChangeHandler = (e: React.ChangeEvent) => { + updateFilter('search', e.target.value, true); + }; const singleDeleteHandler = async () => { setIsDeleteLoading(true); @@ -1220,7 +1112,7 @@ const RecordingTable = () => { return (
{value !== null && value !== undefined - ? `${value.toFixed(2)}%` + ? `${value.toFixed(2)} butir` : '-'}
); @@ -1236,7 +1128,7 @@ const RecordingTable = () => { return (
{value !== null && value !== undefined - ? `${value.toFixed(2)}%` + ? `${value.toFixed(2)} btr` : '-'}
); @@ -1572,13 +1464,13 @@ const RecordingTable = () => { -
+
{ label='Lokasi' placeholder='Pilih Lokasi' options={locationOptions} - value={locationIdValue} + value={formik.values.location_id} onChange={handleFilterLocationChange} onInputChange={setLocationInputValue} isLoading={isLoadingLocationOptions} isClearable onMenuScrollToBottom={loadMoreLocations} - isDisabled={!filterArea} + isDisabled={!formik.values.area_id?.value} className={{ wrapper: 'w-full' }} /> @@ -1605,13 +1497,13 @@ const RecordingTable = () => { label='Project Flock' placeholder='Pilih Project Flock' options={projectFlockOptions} - value={projectFlockIdValue} + value={formik.values.project_flock_id} onChange={handleFilterProjectFlockChange} onInputChange={setProjectFlockInputValue} isLoading={isLoadingProjectFlocks} isClearable onMenuScrollToBottom={loadMoreProjectFlocks} - isDisabled={!filterLocation} + isDisabled={!formik.values.location_id?.value} className={{ wrapper: 'w-full' }} /> @@ -1619,11 +1511,11 @@ const RecordingTable = () => { label='Kandang' placeholder='Pilih Kandang' options={kandangOptions} - value={kandangIdValue} + value={formik.values.kandang_id} onChange={handleFilterKandangChange} - isLoading={!filterProjectFlock} + isLoading={!formik.values.project_flock_id?.value} isClearable - isDisabled={!filterProjectFlock} + isDisabled={!formik.values.project_flock_id?.value} className={{ wrapper: 'w-full' }} /> @@ -1631,12 +1523,9 @@ const RecordingTable = () => { label='Kategori' placeholder='Pilih Kategori' options={projectFlockCategoryOptions} - value={projectFlockCategoryValue} + value={formik.values.project_flock_category} onChange={(val) => { - formik.setFieldValue( - 'project_flock_category', - !Array.isArray(val) && val ? String(val.value) : null - ); + formik.setFieldValue('project_flock_category', val); }} isClearable className={{ wrapper: 'w-full' }} @@ -1646,12 +1535,9 @@ const RecordingTable = () => { label='Status Approval' placeholder='Pilih Status Approval' options={recordingApprovalStatusOptions} - value={approvalStatusValue} + value={formik.values.approval_status} onChange={(val) => { - formik.setFieldValue( - 'approval_status', - !Array.isArray(val) && val ? String(val.value) : null - ); + formik.setFieldValue('approval_status', val); }} isClearable className={{ wrapper: 'w-full' }} @@ -1661,19 +1547,9 @@ const RecordingTable = () => { {/* Modal Footer */}
diff --git a/src/components/pages/production/recording/filter/RecordingFilter.ts b/src/components/pages/production/recording/filter/RecordingFilter.ts index 01caced0..c119494e 100644 --- a/src/components/pages/production/recording/filter/RecordingFilter.ts +++ b/src/components/pages/production/recording/filter/RecordingFilter.ts @@ -1,21 +1,40 @@ -import { string, object } from 'yup'; +import { OptionType } from '@/components/input/SelectInput'; +import * as Yup from 'yup'; -export const RecordingFilterSchema = object().shape({ - area_id: string().nullable(), - location_id: string().nullable(), - project_flock_id: string().nullable(), - kandang_id: string().nullable(), - project_flock_kandang_id: string().nullable(), - approval_status: string().nullable(), - project_flock_category: string().nullable(), +export const RecordingFilterSchema = Yup.object().shape({ + area_id: Yup.object({ + value: Yup.number().nullable(), + label: Yup.string().nullable(), + }).nullable(), + location_id: Yup.object({ + value: Yup.number().nullable(), + label: Yup.string().nullable(), + }).nullable(), + project_flock_id: Yup.object({ + value: Yup.number().nullable(), + label: Yup.string().nullable(), + }).nullable(), + kandang_id: Yup.object({ + value: Yup.number().nullable(), + label: Yup.string().nullable(), + }).nullable(), + project_flock_kandang_id: Yup.number().nullable(), + approval_status: Yup.object({ + value: Yup.string().nullable(), + label: Yup.string().nullable(), + }).nullable(), + project_flock_category: Yup.object({ + value: Yup.string().nullable(), + label: Yup.string().nullable(), + }).nullable(), }); export type RecordingFilterType = { - area_id: string | null; - location_id: string | null; - project_flock_id: string | null; - kandang_id: string | null; - project_flock_kandang_id: string | null; - approval_status: string | null; - project_flock_category: string | null; + area_id: OptionType | null; + location_id: OptionType | null; + project_flock_id: OptionType | null; + kandang_id: OptionType | null; + project_flock_kandang_id: number | null; + approval_status: OptionType | null; + project_flock_category: OptionType | null; }; From 50378a2ee272684f2214a59d63faa77bdeca831a Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 6 May 2026 09:49:13 +0700 Subject: [PATCH 03/20] fix: remote realization_date validation --- .../pages/expense/filter/ExpensesFilter.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/src/components/pages/expense/filter/ExpensesFilter.ts b/src/components/pages/expense/filter/ExpensesFilter.ts index 7c833f2a..b520a49f 100644 --- a/src/components/pages/expense/filter/ExpensesFilter.ts +++ b/src/components/pages/expense/filter/ExpensesFilter.ts @@ -14,18 +14,7 @@ export type ExpensesFilterType = { export const ExpensesFilterSchema = yup.object({ transaction_date: yup.string().nullable(), - realization_date: yup - .string() - .nullable() - .test( - 'is-greater-or-equal-transaction', - 'Tanggal realisasi tidak boleh sebelum tanggal transaksi', - function (value) { - const { transaction_date } = this.parent; - if (!transaction_date || !value) return true; - return new Date(value) >= new Date(transaction_date); - } - ), + realization_date: yup.string().nullable(), location: yup .object({ value: yup.number().required(), From cdee616e18cdc75f55d3c9fa614a901581a42782 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 6 May 2026 10:01:35 +0700 Subject: [PATCH 04/20] fix: remove realization_date validation --- .../pages/expense/filter/ExpensesFilter.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/src/components/pages/expense/filter/ExpensesFilter.ts b/src/components/pages/expense/filter/ExpensesFilter.ts index 7c833f2a..b520a49f 100644 --- a/src/components/pages/expense/filter/ExpensesFilter.ts +++ b/src/components/pages/expense/filter/ExpensesFilter.ts @@ -14,18 +14,7 @@ export type ExpensesFilterType = { export const ExpensesFilterSchema = yup.object({ transaction_date: yup.string().nullable(), - realization_date: yup - .string() - .nullable() - .test( - 'is-greater-or-equal-transaction', - 'Tanggal realisasi tidak boleh sebelum tanggal transaksi', - function (value) { - const { transaction_date } = this.parent; - if (!transaction_date || !value) return true; - return new Date(value) >= new Date(transaction_date); - } - ), + realization_date: yup.string().nullable(), location: yup .object({ value: yup.number().required(), From 4b5ad0dcabf26301cfe2da63a8e15ef73ac937a4 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 6 May 2026 10:23:22 +0700 Subject: [PATCH 05/20] fix: show total item data --- src/components/Pagination.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx index 43b26d90..1f2ba533 100644 --- a/src/components/Pagination.tsx +++ b/src/components/Pagination.tsx @@ -226,7 +226,7 @@ const Pagination = ({ const PageInfo = () => ( - Page {currentPage} of {totalPages} + Total Item: {totalItems} | Page {currentPage} of {totalPages} ); From e0a1922ed4744ce198a50e1e8da8f43cfb83b7b9 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 6 May 2026 10:31:00 +0700 Subject: [PATCH 06/20] fix: implement table filter --- .../product/detail/StockLogTable.tsx | 127 ++++++++++-------- .../detail/StockProductWarehouseTable.tsx | 59 ++++---- 2 files changed, 104 insertions(+), 82 deletions(-) diff --git a/src/components/pages/inventory/product/detail/StockLogTable.tsx b/src/components/pages/inventory/product/detail/StockLogTable.tsx index 0a305659..a8240952 100644 --- a/src/components/pages/inventory/product/detail/StockLogTable.tsx +++ b/src/components/pages/inventory/product/detail/StockLogTable.tsx @@ -1,13 +1,76 @@ import Card from '@/components/Card'; import Table from '@/components/Table'; import { formatDate, formatNumber, formatTitleCase } from '@/lib/helper'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; import { StockLog } from '@/types/api/inventory/product'; +import { ColumnDef } from '@tanstack/react-table'; + +const stockLogTableColumns: ColumnDef[] = [ + { + header: 'ID', + accessorKey: 'id', + }, + { + header: 'Tanggal', + accessorKey: 'created_at', + cell: (props) => { + return formatDate(props.row.original.created_at, 'DD-MMM-yyyy'); + }, + }, + { + header: 'Gudang', + accessorKey: 'warehouse_name', + }, + { + header: 'Stock Akhir', + accessorKey: 'stock', + cell: (props) => { + return formatNumber(props.row.original.stock); + }, + }, + { + header: 'Peningkatan', + accessorKey: 'increase', + cell: (props) => { + return formatNumber(props.row.original.increase); + }, + }, + { + header: 'Penurunan', + accessorKey: 'decrease', + cell: (props) => { + return formatNumber(props.row.original.decrease); + }, + }, + { + header: 'Jenis Transaksi', + accessorKey: 'loggable_type', + cell: (props) => { + return props.row.original.loggable_type + ? formatTitleCase(props.row.original.loggable_type) + : '-'; + }, + }, + { + header: 'Catatan', + accessorKey: 'notes', + cell: (props) => { + return props.row.original.notes ? props.row.original.notes : '-'; + }, + }, + { + header: 'Oleh', + accessorKey: 'created_user.name', + }, +]; const StockLogTable = ({ stockLogs, }: { stockLogs: (StockLog & { warehouse_name: string; warehouse_id: number })[]; }) => { + const { state: tableFilterState, setPage, setPageSize } = useTableFilter(); + return ( data={stockLogs} - columns={[ - { - header: 'ID', - accessorKey: 'id', - }, - { - header: 'Tanggal', - accessorKey: 'created_at', - cell: (props) => { - return formatDate(props.row.original.created_at, 'DD-MMM-yyyy'); - }, - }, - { - header: 'Gudang', - accessorKey: 'warehouse_name', - }, - { - header: 'Stock Akhir', - accessorKey: 'stock', - cell: (props) => { - return formatNumber(props.row.original.stock); - }, - }, - { - header: 'Peningkatan', - accessorKey: 'increase', - cell: (props) => { - return formatNumber(props.row.original.increase); - }, - }, - { - header: 'Penurunan', - accessorKey: 'decrease', - cell: (props) => { - return formatNumber(props.row.original.decrease); - }, - }, - { - header: 'Jenis Transaksi', - accessorKey: 'loggable_type', - cell: (props) => { - return props.row.original.loggable_type - ? formatTitleCase(props.row.original.loggable_type) - : '-'; - }, - }, - { - header: 'Catatan', - accessorKey: 'notes', - cell: (props) => { - return props.row.original.notes ? props.row.original.notes : '-'; - }, - }, - { - header: 'Oleh', - accessorKey: 'created_user.name', - }, - ]} + columns={stockLogTableColumns} + page={tableFilterState.page ?? 0} + pageSize={tableFilterState.pageSize} + onPageChange={setPage} + onPageSizeChange={setPageSize} + totalItems={stockLogs?.length ?? 0} className={{ containerClassName: 'mt-6', tableWrapperClassName: 'overflow-x-auto min-h-full!', diff --git a/src/components/pages/inventory/product/detail/StockProductWarehouseTable.tsx b/src/components/pages/inventory/product/detail/StockProductWarehouseTable.tsx index aa375bdc..4d361c5c 100644 --- a/src/components/pages/inventory/product/detail/StockProductWarehouseTable.tsx +++ b/src/components/pages/inventory/product/detail/StockProductWarehouseTable.tsx @@ -1,13 +1,42 @@ import Card from '@/components/Card'; import Table from '@/components/Table'; import { formatNumber } from '@/lib/helper'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; import { ProductWarehouseStock } from '@/types/api/inventory/product'; +import { ColumnDef } from '@tanstack/react-table'; + +const stockProductWarehouseTableColumns: ColumnDef[] = [ + { + header: 'Nama Gudang', + accessorKey: 'warehouse_name', + }, + { + header: 'Lokasi', + accessorKey: 'location', + cell: (props) => { + return props.row.original.location != null + ? props.row.original.location.name + : '-'; + }, + }, + { + header: 'Stok', + accessorFn(row) { + return row.current_stock; + }, + cell: (props) => { + return formatNumber(props.row.original.current_stock); + }, + }, +]; const StockProductWarehouseTable = ({ productWarehouseStock, }: { productWarehouseStock?: ProductWarehouseStock[]; }) => { + const { state: tableFilterState, setPage, setPageSize } = useTableFilter(); + return ( data={productWarehouseStock ?? []} - columns={[ - { - header: 'Nama Gudang', - accessorKey: 'warehouse_name', - }, - { - header: 'Lokasi', - accessorKey: 'location', - cell: (props) => { - return props.row.original.location != null - ? props.row.original.location.name - : '-'; - }, - }, - { - header: 'Stok', - accessorFn(row) { - return row.current_stock; - }, - cell: (props) => { - return formatNumber(props.row.original.current_stock); - }, - }, - ]} + columns={stockProductWarehouseTableColumns} + pageSize={tableFilterState.pageSize} + page={tableFilterState.page ?? 0} + totalItems={productWarehouseStock?.length ?? 0} + onPageChange={setPage} + onPageSizeChange={setPageSize} className={{ containerClassName: 'mt-6', tableWrapperClassName: 'overflow-x-auto min-h-full!', From 6255367366c40638fee93b663866d5d1499556a6 Mon Sep 17 00:00:00 2001 From: M1 AIR Date: Thu, 7 May 2026 14:15:17 +0700 Subject: [PATCH 07/20] Update env --- .gitlab-ci.yml | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index afcc082d..b897da97 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -30,6 +30,10 @@ default: - echo "NEXT_PUBLIC_LTI_URL=$NEXT_PUBLIC_LTI_URL" - echo "NEXT_PUBLIC_SSO_LOGIN_URL=$NEXT_PUBLIC_SSO_LOGIN_URL" - echo "NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL" + - echo "NEXT_PUBLIC_APP_ENV=$NEXT_PUBLIC_APP_ENV" + - echo "NEXT_PUBLIC_HELPDESK_URL=$NEXT_PUBLIC_HELPDESK_URL" + - echo "NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL=$NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL" + - echo "NEXT_PUBLIC_S3_PUBLIC_BASE_URL=$NEXT_PUBLIC_S3_PUBLIC_BASE_URL" - echo "Building Next.js static export..." - npx next build - | @@ -41,7 +45,11 @@ default: "built_at": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")", "NEXT_PUBLIC_LTI_URL": "$NEXT_PUBLIC_LTI_URL", "NEXT_PUBLIC_SSO_LOGIN_URL": "$NEXT_PUBLIC_SSO_LOGIN_URL", - "NEXT_PUBLIC_API_BASE_URL": "$NEXT_PUBLIC_API_BASE_URL" + "NEXT_PUBLIC_API_BASE_URL": "$NEXT_PUBLIC_API_BASE_URL", + "NEXT_PUBLIC_APP_ENV": "$NEXT_PUBLIC_APP_ENV", + "NEXT_PUBLIC_HELPDESK_URL": "$NEXT_PUBLIC_HELPDESK_URL", + "NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL": "$NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL" + "NEXT_PUBLIC_S3_PUBLIC_BASE_URL": "NEXT_PUBLIC_S3_PUBLIC_BASE_URL" } EOF artifacts: @@ -142,6 +150,11 @@ build:dev: NEXT_PUBLIC_SSO_LOGIN_URL: 'https://dev-auth-erp.mbugroup.id' NEXT_PUBLIC_API_BASE_URL: 'https://dev-api-lti.mbugroup.id/api' NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia' + NEXT_PUBLIC_APP_ENV: 'development' + NEXT_PUBLIC_HELPDESK_URL: 'https://dev-helpdesk.mbugroup.id/' + NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL: 'https://dev-dashboard-ho.mbugroup.id/' + NEXT_PUBLIC_S3_PUBLIC_BASE_URL: 'https://mbu-lti-storage.s3.ap-southeast-3.amazonaws.com/' + deploy:dev: <<: *deploy_template @@ -170,6 +183,9 @@ build:staging: NEXT_PUBLIC_SSO_LOGIN_URL: 'https://stg-auth-erp.mbugroup.id' NEXT_PUBLIC_API_BASE_URL: 'https://stg-api-lti.mbugroup.id/api' NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia' + NEXT_PUBLIC_APP_ENV: 'staging' + NEXT_PUBLIC_HELPDESK_URL: 'https://stg-helpdesk.mbugroup.id/' + NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL: 'https://stg-dashboard-ho.mbugroup.id/' deploy:staging: <<: *deploy_template @@ -185,7 +201,7 @@ deploy:staging: url: https://stg-lti-erp.mbugroup.id # ========================================================== -# ====== STAGING (Branch production) ====== +# ====== (Branch production) ====== # ========================================================== build:production: <<: *build_template @@ -198,6 +214,10 @@ build:production: NEXT_PUBLIC_SSO_LOGIN_URL: 'https://auth-erp.mbugroup.id' NEXT_PUBLIC_API_BASE_URL: 'https://api-lti.mbugroup.id/api' NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia' + NEXT_PUBLIC_APP_ENV: 'production' + NEXT_PUBLIC_HELPDESK_URL: 'https://helpdesk.mbugroup.id/' + NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL: 'https://dashboard-ho.mbugroup.id/' + NEXT_PUBLIC_S3_PUBLIC_BASE_URL: 'https://mbu-lti-storage.s3.ap-southeast-3.amazonaws.com/' deploy:production: <<: *deploy_template From 978067ac6c1e2862cb5882af51c78037dfb31807 Mon Sep 17 00:00:00 2001 From: M1 AIR Date: Thu, 7 May 2026 15:08:33 +0700 Subject: [PATCH 08/20] Update env not slash --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b897da97..2aa4da5a 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -153,7 +153,7 @@ build:dev: NEXT_PUBLIC_APP_ENV: 'development' NEXT_PUBLIC_HELPDESK_URL: 'https://dev-helpdesk.mbugroup.id/' NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL: 'https://dev-dashboard-ho.mbugroup.id/' - NEXT_PUBLIC_S3_PUBLIC_BASE_URL: 'https://mbu-lti-storage.s3.ap-southeast-3.amazonaws.com/' + NEXT_PUBLIC_S3_PUBLIC_BASE_URL: 'https://mbu-lti-storage.s3.ap-southeast-3.amazonaws.com' deploy:dev: From cce5a8df437e2d264e72d8a28f5845ef18b8d8db Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 8 May 2026 15:25:48 +0700 Subject: [PATCH 09/20] fix: set stocks quantity to usage_amount + pending_qty --- .../pages/production/recording/form/RecordingForm.schema.ts | 6 ++++-- .../pages/production/recording/form/RecordingForm.tsx | 4 ++-- src/types/api/production/recording.d.ts | 1 + 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/components/pages/production/recording/form/RecordingForm.schema.ts b/src/components/pages/production/recording/form/RecordingForm.schema.ts index a96254b0..cc977ca6 100644 --- a/src/components/pages/production/recording/form/RecordingForm.schema.ts +++ b/src/components/pages/production/recording/form/RecordingForm.schema.ts @@ -4,6 +4,7 @@ import { CreateGrowingRecordingPayload, CreateLayingRecordingPayload, CreateEggPayload, + RecordingStock, } from '@/types/api/production/recording'; import { getProductWarehouseOptionLabel } from '@/lib/product-warehouse'; @@ -282,8 +283,9 @@ export const getRecordingGrowingFormInitialValues = ( label: getProductWarehouseOptionLabel(stock.product_warehouse), }, qty: - (stock as { qty?: number; usage_amount?: number }).qty || - (stock as { qty?: number; usage_amount?: number }).usage_amount || + (stock as RecordingStock).qty || + ((stock as RecordingStock).usage_amount || 0) + + ((stock as RecordingStock).pending_qty || 0) || '', })) ?? [ { diff --git a/src/components/pages/production/recording/form/RecordingForm.tsx b/src/components/pages/production/recording/form/RecordingForm.tsx index 39b13066..22cbdb1b 100644 --- a/src/components/pages/production/recording/form/RecordingForm.tsx +++ b/src/components/pages/production/recording/form/RecordingForm.tsx @@ -1508,9 +1508,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { if (pendingQty > 0) { return ( - (tersedia: {formatNumber(requestedUsage)} | pending:{' '} + (tersedia: {formatNumber(availableStock)} | pending:{' '} {formatNumber(pendingQty)} | - pakai: {formatNumber(requestedUsage + pendingQty)}) + pakai: {formatNumber(requestedUsage)}) ); } diff --git a/src/types/api/production/recording.d.ts b/src/types/api/production/recording.d.ts index 98f56058..aeb71a4e 100644 --- a/src/types/api/production/recording.d.ts +++ b/src/types/api/production/recording.d.ts @@ -62,6 +62,7 @@ export type RecordingDepletion = { export type RecordingStock = { product_warehouse_id: number; + qty?: number; usage_amount?: number; pending_qty: number; product_warehouse: ProductWarehouse; From 073d7eee03c123cf17e847626440d04cc77c2a59 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 8 May 2026 15:25:58 +0700 Subject: [PATCH 10/20] chore: prettier format --- .gitlab-ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2aa4da5a..0249c009 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -155,7 +155,6 @@ build:dev: NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL: 'https://dev-dashboard-ho.mbugroup.id/' NEXT_PUBLIC_S3_PUBLIC_BASE_URL: 'https://mbu-lti-storage.s3.ap-southeast-3.amazonaws.com' - deploy:dev: <<: *deploy_template needs: ['build:dev'] From 3b1e7e3b031e3f3f028d3403c39a84932b600b6e Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 8 May 2026 16:16:16 +0700 Subject: [PATCH 11/20] feat: add server-side sorting pattern --- CLAUDE.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index d0a2f23c..06bbd33d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -161,6 +161,45 @@ const handleFilterLocationChange = useCallback( - `SupplierTable`, `KandangsTable`, `LocationsTable`, `CustomersTable` in `src/components/pages/master-data/` - Use same pattern for data tables in other modules (inventory, finance, purchase, etc.) +## Server-side sorting pattern + +Data tables use TanStack Table's `SortingState` wired to `useTableFilter` so that sorting triggers a server re-fetch rather than client-side reordering. + +**Four-part wiring:** + +1. **Local sort state** — `const [sorting, setSorting] = useState([]);` + +2. **`useTableFilter` config** — Add `sort_by` and `order_by` to `initial` and `paramMap`. The `paramMap` key is the internal name; the value is the query param name sent to the server (they can differ, e.g. `order_by` → `sort_order`): + + ```ts + initial: { sort_by: '', order_by: '' } + paramMap: { sort_by: 'sort_by', order_by: 'sort_order' } + ``` + +3. **`useEffect` sync** — Watches `sorting` and pushes changes into `useTableFilter`: + + ```ts + useEffect(() => { + if (sorting.length > 0) { + updateFilter('sort_by', sorting[0].id, true); + updateFilter('order_by', sorting[0].desc ? 'desc' : 'asc', true); + } else { + updateFilter('sort_by', ''); + updateFilter('order_by', ''); + } + }, [sorting]); + ``` + +4. **SWR key** — SWR uses `getTableFilterToQueryString()` as its key, so any filter change (including sort) automatically re-fetches with the new query params. TanStack Table's built-in client sorting is effectively disabled; the server does the sorting. + +**Pass `sorting` / `setSorting` to ``:** + +```tsx +
+``` + +**Reference implementation:** `MarketingTable` in [src/components/pages/marketing/MarketingTable.tsx](src/components/pages/marketing/MarketingTable.tsx). + ## Server-side file export pattern All file exports (Excel, PDF, or any format) must use **server-side generation** — the server returns a binary blob and the browser triggers a download. Never generate files client-side with `xlsx`, `@react-pdf/renderer`, `jspdf`, or similar libraries. From 76436456433f73c9672b752350ac18ecae1ff7a4 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 8 May 2026 16:25:51 +0700 Subject: [PATCH 12/20] feat: implement server-side sorting --- .../pages/marketing/MarketingTable.tsx | 168 ++++++++++-------- 1 file changed, 97 insertions(+), 71 deletions(-) diff --git a/src/components/pages/marketing/MarketingTable.tsx b/src/components/pages/marketing/MarketingTable.tsx index 06949171..c901c571 100644 --- a/src/components/pages/marketing/MarketingTable.tsx +++ b/src/components/pages/marketing/MarketingTable.tsx @@ -1,6 +1,5 @@ 'use client'; -import axios from 'axios'; import Button from '@/components/Button'; import CheckboxInput from '@/components/input/CheckboxInput'; import DateInput from '@/components/input/DateInput'; @@ -27,7 +26,13 @@ import { MarketingFilter, } from '@/types/api/marketing/marketing'; import { Icon } from '@iconify/react'; -import { CellContext, ColumnDef, Row } from '@tanstack/react-table'; +import { + CellContext, + ColumnDef, + Row, + SortingState, + Updater, +} from '@tanstack/react-table'; import { useRouter } from 'next/navigation'; import { ChangeEventHandler, useCallback, useMemo, useState } from 'react'; import toast from 'react-hot-toast'; @@ -198,6 +203,8 @@ const MarketingTable = () => { project_flock_name: '', project_flock_kandang_id: '', project_flock_kandang_name: '', + sort_by: '', + order_by: '', }, paramMap: { page: 'page', @@ -207,6 +214,8 @@ const MarketingTable = () => { customer_id: 'customer_id', project_flock_id: 'project_flock_id', project_flock_kandang_id: 'project_flock_kandang_id', + sort_by: 'sort_by', + order_by: 'sort_order', }, excludeKeysFromUrl: [ 'product_names', @@ -220,6 +229,26 @@ const MarketingTable = () => { storeName: 'marketing-table', }); + const sorting: SortingState = tableFilterState.sort_by + ? [ + { + id: tableFilterState.sort_by, + desc: tableFilterState.order_by === 'desc', + }, + ] + : []; + + const handleSortingChange = (updater: Updater) => { + const next = typeof updater === 'function' ? updater(sorting) : updater; + if (next.length > 0) { + updateFilter('sort_by', next[0].id, true); + updateFilter('order_by', next[0].desc ? 'desc' : 'asc', true); + } else { + updateFilter('sort_by', '', true); + updateFilter('order_by', '', true); + } + }; + // ===== FETCH DATA ===== const { data: marketing, @@ -359,55 +388,50 @@ const MarketingTable = () => { ? 'DELIVERY_ORDER' : null; - const marketingFilterInitialValues = useMemo(() => { - const productIds = tableFilterState.product_ids - ? tableFilterState.product_ids - .split(',') - .map((item) => item.trim()) - .filter(Boolean) - : []; + const productIds = tableFilterState.product_ids + ? tableFilterState.product_ids + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + : []; - const productLabels = tableFilterState.product_names - ? tableFilterState.product_names - .split(',') - .map((item) => item.trim()) - .filter(Boolean) - : []; + const productLabels = tableFilterState.product_names + ? tableFilterState.product_names + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + : []; - return { - product_ids: productIds.map((value, idx) => ({ - value: Number(value), - label: productLabels[idx] || '-', - })), - status: tableFilterState.status - ? { - value: tableFilterState.status, - label: tableFilterState.status_name, - } - : null, - - customer: tableFilterState.customer_id - ? { - value: Number(tableFilterState.customer_id), - label: tableFilterState.customer_name, - } - : null, - - project_flock: tableFilterState.project_flock_id - ? { - value: Number(tableFilterState.project_flock_id), - label: tableFilterState.project_flock_name, - } - : null, - - project_flock_kandang: tableFilterState.project_flock_kandang_id - ? { - value: Number(tableFilterState.project_flock_kandang_id), - label: tableFilterState.project_flock_kandang_name, - } - : null, - }; - }, [tableFilterState]); + const marketingFilterInitialValues = { + product_ids: productIds.map((value, idx) => ({ + value: Number(value), + label: productLabels[idx] || '-', + })), + status: tableFilterState.status + ? { + value: tableFilterState.status, + label: tableFilterState.status_name, + } + : null, + customer: tableFilterState.customer_id + ? { + value: Number(tableFilterState.customer_id), + label: tableFilterState.customer_name, + } + : null, + project_flock: tableFilterState.project_flock_id + ? { + value: Number(tableFilterState.project_flock_id), + label: tableFilterState.project_flock_name, + } + : null, + project_flock_kandang: tableFilterState.project_flock_kandang_id + ? { + value: Number(tableFilterState.project_flock_kandang_id), + label: tableFilterState.project_flock_kandang_name, + } + : null, + }; const approveMarketingHandler = async (notes: string) => { if (idsToProcess.length === 0) { @@ -542,27 +566,29 @@ const MarketingTable = () => { setIsLoadingExportingToExcel(false); }; - const resetExportProgressForm = useCallback(() => { + const resetExportProgressForm = () => { setExportProgressStartDate(''); setExportProgressEndDate(''); - }, []); + }; - const exportProgressStartDateChangeHandler: ChangeEventHandler = - useCallback((e) => { - setExportProgressStartDate(e.target.value); - }, []); + const exportProgressStartDateChangeHandler: ChangeEventHandler< + HTMLInputElement + > = (e) => { + setExportProgressStartDate(e.target.value); + }; - const exportProgressEndDateChangeHandler: ChangeEventHandler = - useCallback((e) => { - setExportProgressEndDate(e.target.value); - }, []); + const exportProgressEndDateChangeHandler: ChangeEventHandler< + HTMLInputElement + > = (e) => { + setExportProgressEndDate(e.target.value); + }; - const exportProgressInputToExcelClickHandler = useCallback(() => { + const exportProgressInputToExcelClickHandler = () => { resetExportProgressForm(); exportProgressInputModal.openModal(); - }, [exportProgressInputModal, resetExportProgressForm]); + }; - const submitExportProgressInputHandler = useCallback(async () => { + const submitExportProgressInputHandler = async () => { if (!exportProgressStartDate || !exportProgressEndDate) { return; } @@ -585,12 +611,7 @@ const MarketingTable = () => { } finally { setIsExportProgressLoading(false); } - }, [ - exportProgressEndDate, - exportProgressInputModal, - exportProgressStartDate, - resetExportProgressForm, - ]); + }; const columns = useMemo[]>(() => { return [ @@ -656,7 +677,7 @@ const MarketingTable = () => { }, }, { - accessorKey: 'so_do_number', + accessorKey: 'so_number', header: 'No. Order', cell: (props) => { return props.row.original.do_number @@ -672,7 +693,7 @@ const MarketingTable = () => { }, }, { - accessorKey: 'approval.step_name', + accessorKey: 'status', header: 'Status', cell: (props) => { const approval = props.row.original.latest_approval; @@ -707,10 +728,12 @@ const MarketingTable = () => { }, }, { - accessorKey: 'customer.name', + accessorKey: 'customer', header: 'Customer', + cell: (props) => props.row.original.customer.name, }, { + accessorKey: 'grand_total', accessorFn: (row) => row.sales_order ?.map((product) => product.total_price) @@ -727,6 +750,7 @@ const MarketingTable = () => { { accessorKey: 'marketing_products.length', header: 'Product Details', + enableSorting: false, cell: (props) => { if (props?.row?.original?.sales_order?.length) { if (props?.row?.original?.sales_order?.length > 1) { @@ -949,6 +973,8 @@ const MarketingTable = () => { columns={columns} pageSize={tableFilterState.pageSize} page={isResponseSuccess(marketing) ? marketing?.meta?.page : 1} + sorting={sorting} + setSorting={handleSortingChange} totalItems={ isResponseSuccess(marketing) ? marketing?.meta?.total_results From e7f378823c53e0cd66c207cb4f1f4c6e009fde39 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 8 May 2026 18:57:21 +0700 Subject: [PATCH 13/20] feat: implement export product stock log --- .../product/detail/InventoryProductDetail.tsx | 22 +++--- .../product/detail/StockLogTable.tsx | 74 ++++++++++++++++--- src/services/api/inventory.ts | 42 ++++++++++- 3 files changed, 115 insertions(+), 23 deletions(-) diff --git a/src/components/pages/inventory/product/detail/InventoryProductDetail.tsx b/src/components/pages/inventory/product/detail/InventoryProductDetail.tsx index 39609b06..715a7a43 100644 --- a/src/components/pages/inventory/product/detail/InventoryProductDetail.tsx +++ b/src/components/pages/inventory/product/detail/InventoryProductDetail.tsx @@ -1,5 +1,6 @@ import Card from '@/components/Card'; import { FormHeader } from '@/components/helper/form/FormHeader'; +import RequirePermission from '@/components/helper/RequirePermission'; import StockLogTable from '@/components/pages/inventory/product/detail/StockLogTable'; import StockProductWarehouseTable from '@/components/pages/inventory/product/detail/StockProductWarehouseTable'; import { formatCurrency, formatNumber } from '@/lib/helper'; @@ -11,18 +12,6 @@ const InventoryProductDetail = ({ }: { inventoryProduct?: InventoryProduct; }) => { - const stockLogs = useMemo(() => { - return ( - inventoryProduct?.product_warehouses?.flatMap((warehouse) => - warehouse.stock_logs.map((log) => ({ - ...log, - warehouse_name: warehouse.warehouse_name, - warehouse_id: warehouse.warehouse_id, - })) - ) || [] - ); - }, [inventoryProduct]); - return (
- + + {inventoryProduct?.product_warehouses?.map((productWarehouse) => ( + + ))} +
); }; diff --git a/src/components/pages/inventory/product/detail/StockLogTable.tsx b/src/components/pages/inventory/product/detail/StockLogTable.tsx index a8240952..b92a4512 100644 --- a/src/components/pages/inventory/product/detail/StockLogTable.tsx +++ b/src/components/pages/inventory/product/detail/StockLogTable.tsx @@ -1,11 +1,20 @@ +import Button from '@/components/Button'; import Card from '@/components/Card'; import Table from '@/components/Table'; +import { isResponseSuccess } from '@/lib/api-helper'; import { formatDate, formatNumber, formatTitleCase } from '@/lib/helper'; +import { StockLogApi } from '@/services/api/inventory'; import { useTableFilter } from '@/services/hooks/useTableFilter'; -import { StockLog } from '@/types/api/inventory/product'; +import { ProductWarehouseStock, StockLog } from '@/types/api/inventory/product'; import { ColumnDef } from '@tanstack/react-table'; +import { FileDown } from 'lucide-react'; +import toast from 'react-hot-toast'; +import { useState } from 'react'; +import useSWR from 'swr'; -const stockLogTableColumns: ColumnDef[] = [ +const stockLogTableColumns: (warehouseName: string) => ColumnDef[] = ( + warehouseName +) => [ { header: 'ID', accessorKey: 'id', @@ -20,6 +29,7 @@ const stockLogTableColumns: ColumnDef[] = [ { header: 'Gudang', accessorKey: 'warehouse_name', + cell: warehouseName, }, { header: 'Stock Akhir', @@ -65,31 +75,77 @@ const stockLogTableColumns: ColumnDef[] = [ ]; const StockLogTable = ({ - stockLogs, + productWarehouse, }: { - stockLogs: (StockLog & { warehouse_name: string; warehouse_id: number })[]; + productWarehouse: ProductWarehouseStock; }) => { - const { state: tableFilterState, setPage, setPageSize } = useTableFilter(); + const [isExportLoading, setIsExportLoading] = useState(false); + + const { + state: tableFilterState, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { + product_warehouse_id: productWarehouse.id, + }, + }); + + const handleExportExcel = async () => { + setIsExportLoading(true); + try { + await StockLogApi.exportToExcel( + productWarehouse.warehouse_name, + getTableFilterQueryString() + ); + toast.success('Excel berhasil dibuat dan diunduh.'); + } catch { + toast.error('Gagal membuat Excel. Silakan coba lagi.'); + } finally { + setIsExportLoading(false); + } + }; + + const { data: stockLogsResponse, isLoading: isLoadingStockLogs } = useSWR( + `${StockLogApi.basePath}${getTableFilterQueryString()}`, + StockLogApi.getAllFetcher + ); + + const stockLogs = isResponseSuccess(stockLogsResponse) + ? stockLogsResponse.data + : []; return ( +
+ +
data={stockLogs} - columns={stockLogTableColumns} + columns={stockLogTableColumns(productWarehouse.warehouse_name)} page={tableFilterState.page ?? 0} pageSize={tableFilterState.pageSize} onPageChange={setPage} onPageSizeChange={setPageSize} - totalItems={stockLogs?.length ?? 0} + isLoading={isLoadingStockLogs} + totalItems={ + isResponseSuccess(stockLogsResponse) + ? stockLogsResponse.meta?.total_results + : 0 + } className={{ - containerClassName: 'mt-6', + containerClassName: 'mt-4 mb-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', diff --git a/src/services/api/inventory.ts b/src/services/api/inventory.ts index 70a7c8f9..df72959f 100644 --- a/src/services/api/inventory.ts +++ b/src/services/api/inventory.ts @@ -13,7 +13,9 @@ import { CreateInventoryAdjustmentPayload, InventoryAdjustment, } from '@/types/api/inventory/adjustment'; -import { InventoryProduct } from '@/types/api/inventory/product'; +import { InventoryProduct, StockLog } from '@/types/api/inventory/product'; +import { httpClient } from '../http/client'; +import { formatDate } from '@/lib/helper'; export const ProductWarehouseApi = new BaseApiService< ProductWarehouse, @@ -65,3 +67,41 @@ export const InventoryProductApi = new BaseApiService< unknown, unknown >('/inventory/product-stocks'); + +export class StockLogService extends BaseApiService< + StockLog, + unknown, + unknown +> { + constructor(basePath: string = '/inventory/stock-logs') { + super(basePath); + } + + async exportToExcel(warehouseName: string, initialQueryString: string) { + const params = new URLSearchParams(initialQueryString); + + params.set('export', 'excel'); + params.set('page', '1'); + params.set('limit', '99999999999'); + + const queryString = `?${params.toString()}`; + + const res = await httpClient(`${this.basePath}${queryString}`, { + method: 'GET', + responseType: 'blob', + }); + + const url = window.URL.createObjectURL(new Blob([res])); + const link = document.createElement('a'); + link.href = url; + + const fileName = `informasi-stok-produk-${warehouseName.toLowerCase().replaceAll(' ', '-')}-${formatDate(Date.now(), 'DD-MM-YYYY')}.xlsx`; + link.setAttribute('download', fileName); + + document.body.appendChild(link); + link.click(); + link.remove(); + } +} + +export const StockLogApi = new StockLogService('/inventory/stock-logs'); From a0e8c600820fb5eff9a8dc84212de6051f18657e Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 8 May 2026 18:57:37 +0700 Subject: [PATCH 14/20] chore: adjust styling --- .../inventory/product/detail/StockProductWarehouseTable.tsx | 2 +- src/components/pages/report/finance/tab/CustomerPaymentTab.tsx | 2 +- src/components/pages/report/finance/tab/DebtSupplierTab.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/pages/inventory/product/detail/StockProductWarehouseTable.tsx b/src/components/pages/inventory/product/detail/StockProductWarehouseTable.tsx index 4d361c5c..8b36ee4b 100644 --- a/src/components/pages/inventory/product/detail/StockProductWarehouseTable.tsx +++ b/src/components/pages/inventory/product/detail/StockProductWarehouseTable.tsx @@ -55,7 +55,7 @@ const StockProductWarehouseTable = ({ onPageChange={setPage} onPageSizeChange={setPageSize} className={{ - containerClassName: 'mt-6', + containerClassName: 'mt-6 mb-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', diff --git a/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx b/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx index ad59cfb0..55cf08f3 100644 --- a/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx +++ b/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx @@ -732,7 +732,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { )} {!isLoading && data.length > 0 && meta && ( -
+
{ )} {!isLoading && data.length > 0 && meta && ( -
+
Date: Fri, 8 May 2026 18:58:02 +0700 Subject: [PATCH 15/20] feat: add stock log permission --- src/config/constant.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/config/constant.ts b/src/config/constant.ts index 0f06d499..251d10e5 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -197,6 +197,7 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [ icon: 'heroicons-outline:folder', permission: [ 'lti.inventory.product_stock.list', + 'lti.inventory.stock_log.list', 'lti.inventory.product_warehouses.list', 'lti.inventory.transfer.list', ], @@ -204,7 +205,10 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [ { text: 'Stok Produk', link: '/inventory/product', - permission: ['lti.inventory.product_stock.list'], + permission: [ + 'lti.inventory.product_stock.list', + 'lti.inventory.stock_log.list', + ], }, { text: 'Penyesuaian Stok', From 7f9bb8e11de2910f753f5a008b4d2f4938c75352 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 8 May 2026 18:58:13 +0700 Subject: [PATCH 16/20] chore: remove unnecessary code --- src/services/api/marketing/marketing.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/services/api/marketing/marketing.ts b/src/services/api/marketing/marketing.ts index 923f9724..2cd225a5 100644 --- a/src/services/api/marketing/marketing.ts +++ b/src/services/api/marketing/marketing.ts @@ -1,6 +1,5 @@ -import { isResponseError } from '@/lib/api-helper'; import { BaseApiService } from '@/services/api/base'; -import { httpClient, httpClientFetcher } from '@/services/http/client'; +import { httpClient } from '@/services/http/client'; import { BaseApiResponse } from '@/types/api/api-general'; import axios from 'axios'; import { @@ -11,9 +10,8 @@ import { CreateDeliveryOrderPayload, UpdateDeliveryOrderPayload, } from '@/types/api/marketing/marketing'; -import toast from 'react-hot-toast'; -import * as XLSX from 'xlsx'; -import { formatCurrency, formatDate, formatTitleCase } from '@/lib/helper'; + +import { formatDate } from '@/lib/helper'; /** * 💡 Helper untuk membuat respons dummy From a9a5098a21f31ae18c6dc75dd5394ce48114e774 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 8 May 2026 18:58:25 +0700 Subject: [PATCH 17/20] fix: set default map for pageSize to limit --- src/services/hooks/useTableFilter.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/services/hooks/useTableFilter.tsx b/src/services/hooks/useTableFilter.tsx index ad9c6679..acc1dff7 100644 --- a/src/services/hooks/useTableFilter.tsx +++ b/src/services/hooks/useTableFilter.tsx @@ -249,6 +249,9 @@ export function useTableFilter< const mapKey = useCallback( (key: string) => { const m = options?.paramMap as Record | undefined; + + if (key === 'pageSize' && ((m && !m[key]) || !m)) return 'limit'; + return (m && m[key]) || key; }, [options?.paramMap] From e5f6ef8a853b7f5b594ad31efba59a59dccbf036 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 9 May 2026 10:55:38 +0700 Subject: [PATCH 18/20] fix: show document name and use document path from the API response --- src/components/pages/expense/ExpenseRequestContent.tsx | 10 ++-------- src/types/api/expense.d.ts | 1 + 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/components/pages/expense/ExpenseRequestContent.tsx b/src/components/pages/expense/ExpenseRequestContent.tsx index e92ecc82..a0438197 100644 --- a/src/components/pages/expense/ExpenseRequestContent.tsx +++ b/src/components/pages/expense/ExpenseRequestContent.tsx @@ -548,21 +548,15 @@ const ExpenseRequestContent = ({
    {initialValues?.documents.map( (requestDocument, requestDocumentIdx) => { - const path = requestDocument.path.startsWith( - '/' - ) - ? requestDocument.path.slice(1) - : requestDocument.path; - const documentUrl = `${S3_PUBLIC_BASE_URL}/${path}`; return (
  • - {requestDocument.path}{' '} + {requestDocument.name}{' '} Date: Sat, 9 May 2026 10:55:57 +0700 Subject: [PATCH 19/20] fix: pass manualSorting to Table --- src/components/pages/marketing/MarketingTable.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/pages/marketing/MarketingTable.tsx b/src/components/pages/marketing/MarketingTable.tsx index c901c571..1026a11a 100644 --- a/src/components/pages/marketing/MarketingTable.tsx +++ b/src/components/pages/marketing/MarketingTable.tsx @@ -883,6 +883,8 @@ const MarketingTable = () => { 'customer_name', 'project_flock_name', 'project_flock_kandang_name', + 'sort_by', + 'order_by', ]} onClick={() => { filterModal.openModal(); @@ -975,6 +977,7 @@ const MarketingTable = () => { page={isResponseSuccess(marketing) ? marketing?.meta?.page : 1} sorting={sorting} setSorting={handleSortingChange} + manualSorting totalItems={ isResponseSuccess(marketing) ? marketing?.meta?.total_results From 3dc64d01dbc2c39dfc6c14ba5d9ab3057e00905f Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 9 May 2026 10:56:39 +0700 Subject: [PATCH 20/20] chore: update server-side sorting pattern context --- CLAUDE.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 06bbd33d..d8f15df6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -192,12 +192,14 @@ Data tables use TanStack Table's `SortingState` wired to `useTableFilter` so tha 4. **SWR key** — SWR uses `getTableFilterToQueryString()` as its key, so any filter change (including sort) automatically re-fetches with the new query params. TanStack Table's built-in client sorting is effectively disabled; the server does the sorting. -**Pass `sorting` / `setSorting` to `
`:** +**Pass `sorting`, `setSorting`, and `manualSorting` to `
`:** ```tsx -
+
``` +`manualSorting={true}` is required — without it TanStack Table still applies its own client-side sort pass on top of the server-sorted data, producing incorrect order. + **Reference implementation:** `MarketingTable` in [src/components/pages/marketing/MarketingTable.tsx](src/components/pages/marketing/MarketingTable.tsx). ## Server-side file export pattern