diff --git a/CLAUDE.md b/CLAUDE.md index 8b6b07a2..b1025264 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,3 +63,100 @@ src/ - Detail pages that read `useSearchParams` MUST be wrapped in `` via a `layout.tsx` (see [src/app/finance/detail/layout.tsx](src/app/finance/detail/layout.tsx) for the canonical pattern). - API service classes inherit CRUD methods (`getAll`, `getSingle`, etc.) from `BaseApiService` — extend the class for feature-specific endpoints rather than calling `httpClient` directly from components. - Pre-commit runs format + lint + typecheck + build; do not bypass with `--no-verify`. + +## Table filter persistence pattern + +Data tables across all modules (master-data, inventory, finance, purchase, etc.) use `useTableFilter` with `persist: true` to persist filter state in localStorage. This allows users' search, pagination, and filter choices to survive page refreshes. + +**Three core principles (apply to all table components):** + +1. **Set formik initialValues from tableFilterState** (not hardcoded defaults) + - Ensures the filter modal displays currently active filters when opened + - Initialize directly from persisted state: `location: tableFilterState.locationFilter` + +2. **Pass `true` as last parameter to updateFilter calls** + - `updateFilter('fieldName', value, true)` immediately persists to localStorage + - Resets pagination to page 1 when filters change (via SWR revalidation) + - Apply to: search handlers, filter form submissions, reset handlers + +3. **Create custom formikResetHandler function** + - Clear each filter with `updateFilter(fieldName, defaultValue, true)` + - Call `formik.resetForm({ values: { ...defaults } })` + - Close the modal at the end + - Attach to both button `onClick` and form `onReset` handler + +**Optimization: Avoid useCallback for simple handlers** + +- `useCallback` adds overhead and is only useful for complex logic or memoized child components +- Simple pass-through handlers don't need it: + + ```tsx + // ✅ Good: Simple handler without useCallback + const handleFilterChange = (val) => setFieldValue('location', val); + + // ❌ Avoid: Unnecessary useCallback overhead + const handleFilterChange = useCallback( + (val) => setFieldValue('location', val), + [setFieldValue] + ); + ``` + +**Best practice: Store OptionType objects directly, not IDs** + +For select inputs, store the complete `OptionType` object in both formik state and tableFilterState. This eliminates the need for computed helper values (like searching options arrays to find the matching object). + +```tsx +// Type the useTableFilter with the filter state structure +const { state: tableFilterState, updateFilter, ... } = useTableFilter<{ + search: string; + locationFilter?: OptionType; + picFilter?: OptionType; +}>({ + initial: { + search: '', + locationFilter: undefined, + picFilter: undefined + }, + paramMap: { + page: 'page', + pageSize: 'limit', + locationFilter: 'location_id', + picFilter: 'pic_id', + }, + persist: true, + storeName: 'kandangs-table', +}); + +// Initialize formik with tableFilterState values (now typed OptionType objects) +const formik = useFormik({ + initialValues: { + location: tableFilterState.locationFilter, + pic: tableFilterState.picFilter, + }, + ... +}); + +// Handlers store the complete OptionType, not just the ID +const handleFilterLocationChange = useCallback( + (val) => setFieldValue('location', val), + [setFieldValue] +); + +// Use formik values directly in select inputs (no computed helpers needed) + +``` + +**Apply this pattern to:** + +- Any data table component across any module that needs persistent filters +- Master-data tables, inventory lists, finance reports, purchase orders, etc. +- Whenever users' filter/search/pagination choices should survive page refreshes + +**Reference implementations:** + +- `SupplierTable`, `KandangsTable`, `LocationsTable`, `CustomersTable` in `src/components/pages/master-data/` +- Use same pattern for data tables in other modules (inventory, finance, purchase, etc.) diff --git a/src/components/pages/master-data/area/AreasTable.tsx b/src/components/pages/master-data/area/AreasTable.tsx index 95f91ee9..af730c9c 100644 --- a/src/components/pages/master-data/area/AreasTable.tsx +++ b/src/components/pages/master-data/area/AreasTable.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ChangeEventHandler, useEffect, useMemo, useState } from 'react'; +import { ChangeEventHandler, useMemo, useState } from 'react'; import useSWR from 'swr'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import toast from 'react-hot-toast'; @@ -20,8 +20,6 @@ import { Area } from '@/types/api/master-data/area'; import { AreaApi } from '@/services/api/master-data'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { useTableFilter } from '@/services/hooks/useTableFilter'; -import { usePathname } from 'next/navigation'; -import { useUiStore } from '@/stores/ui/ui.store'; const RowOptionsMenu = ({ popoverPosition = 'bottom', @@ -103,9 +101,6 @@ const RowOptionsMenu = ({ }; const AreasTable = () => { - const { searchValue, setSearchValue, setTableState } = useUiStore(); - const pathname = usePathname(); - const { state: tableFilterState, updateFilter, @@ -114,12 +109,14 @@ const AreasTable = () => { toQueryString: getTableFilterQueryString, } = useTableFilter({ initial: { - search: searchValue, + search: '', }, paramMap: { page: 'page', pageSize: 'limit', }, + persist: true, + storeName: 'areas-table', }); const [sorting, setSorting] = useState([]); @@ -137,17 +134,8 @@ const AreasTable = () => { const [selectedArea, setSelectedArea] = useState(undefined); const [isDeleteLoading, setIsDeleteLoading] = useState(false); - useEffect(() => { - updateFilter('search', searchValue); - }, [searchValue, updateFilter]); - - useEffect(() => { - setTableState('areas-table', pathname); - }, [pathname, setTableState]); - const searchChangeHandler: ChangeEventHandler = (e) => { - setSearchValue(e.target.value); - updateFilter('search', e.target.value); + updateFilter('search', e.target.value, true); }; const confirmationModalDeleteClickHandler = async () => { diff --git a/src/components/pages/master-data/bank/BanksTable.tsx b/src/components/pages/master-data/bank/BanksTable.tsx index cc62cf70..0284c907 100644 --- a/src/components/pages/master-data/bank/BanksTable.tsx +++ b/src/components/pages/master-data/bank/BanksTable.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ChangeEventHandler, useEffect, useMemo, useState } from 'react'; +import { ChangeEventHandler, useMemo, useState } from 'react'; import useSWR from 'swr'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import toast from 'react-hot-toast'; @@ -20,8 +20,6 @@ import { Bank } from '@/types/api/master-data/bank'; import { BankApi } from '@/services/api/master-data'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { useTableFilter } from '@/services/hooks/useTableFilter'; -import { usePathname } from 'next/navigation'; -import { useUiStore } from '@/stores/ui/ui.store'; const RowOptionsMenu = ({ popoverPosition = 'bottom', @@ -103,9 +101,6 @@ const RowOptionsMenu = ({ }; const BanksTable = () => { - const { searchValue, setSearchValue, setTableState } = useUiStore(); - const pathname = usePathname(); - const { state: tableFilterState, updateFilter, @@ -114,12 +109,14 @@ const BanksTable = () => { toQueryString: getTableFilterQueryString, } = useTableFilter({ initial: { - search: searchValue, + search: '', }, paramMap: { page: 'page', pageSize: 'limit', }, + persist: true, + storeName: 'banks-table', }); const [sorting, setSorting] = useState([]); @@ -137,17 +134,8 @@ const BanksTable = () => { const [selectedBank, setSelectedBank] = useState(undefined); const [isDeleteLoading, setIsDeleteLoading] = useState(false); - useEffect(() => { - updateFilter('search', searchValue); - }, [searchValue, updateFilter]); - - useEffect(() => { - setTableState('banks-table', pathname); - }, [pathname, setTableState]); - const searchChangeHandler: ChangeEventHandler = (e) => { - setSearchValue(e.target.value); - updateFilter('search', e.target.value); + updateFilter('search', e.target.value, true); }; const confirmationModalDeleteClickHandler = async () => { diff --git a/src/components/pages/master-data/customer/CustomersTable.tsx b/src/components/pages/master-data/customer/CustomersTable.tsx index 1f02428a..b1fb8051 100644 --- a/src/components/pages/master-data/customer/CustomersTable.tsx +++ b/src/components/pages/master-data/customer/CustomersTable.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ChangeEventHandler, useEffect, useMemo, useState } from 'react'; +import { ChangeEventHandler, useMemo, useState } from 'react'; import useSWR from 'swr'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import toast from 'react-hot-toast'; @@ -20,8 +20,6 @@ import { Customer } from '@/types/api/master-data/customer'; import { CustomerApi } from '@/services/api/master-data'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { useTableFilter } from '@/services/hooks/useTableFilter'; -import { usePathname } from 'next/navigation'; -import { useUiStore } from '@/stores/ui/ui.store'; const RowOptionsMenu = ({ popoverPosition = 'bottom', @@ -103,9 +101,6 @@ const RowOptionsMenu = ({ }; const CustomersTable = () => { - const { searchValue, setSearchValue, setTableState } = useUiStore(); - const pathname = usePathname(); - const { state: tableFilterState, updateFilter, @@ -114,12 +109,14 @@ const CustomersTable = () => { toQueryString: getTableFilterQueryString, } = useTableFilter({ initial: { - search: searchValue, + search: '', }, paramMap: { page: 'page', pageSize: 'limit', }, + persist: true, + storeName: 'customers-table', }); const [sorting, setSorting] = useState([]); @@ -139,17 +136,8 @@ const CustomersTable = () => { >(undefined); const [isDeleteLoading, setIsDeleteLoading] = useState(false); - useEffect(() => { - updateFilter('search', searchValue); - }, [searchValue, updateFilter]); - - useEffect(() => { - setTableState('customers-table', pathname); - }, [pathname, setTableState]); - const searchChangeHandler: ChangeEventHandler = (e) => { - setSearchValue(e.target.value); - updateFilter('search', e.target.value); + updateFilter('search', e.target.value, true); }; const confirmationModalDeleteClickHandler = async () => { diff --git a/src/components/pages/master-data/flock/FlocksTable.tsx b/src/components/pages/master-data/flock/FlocksTable.tsx index ed9f4007..76b3cae8 100644 --- a/src/components/pages/master-data/flock/FlocksTable.tsx +++ b/src/components/pages/master-data/flock/FlocksTable.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ChangeEventHandler, useEffect, useMemo, useState } from 'react'; +import { ChangeEventHandler, useMemo, useState } from 'react'; import useSWR from 'swr'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import toast from 'react-hot-toast'; @@ -20,8 +20,6 @@ import { Flock } from '@/types/api/master-data/flock'; import { FlockApi } from '@/services/api/master-data'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { useTableFilter } from '@/services/hooks/useTableFilter'; -import { usePathname } from 'next/navigation'; -import { useUiStore } from '@/stores/ui/ui.store'; const RowOptionsMenu = ({ popoverPosition = 'bottom', @@ -103,9 +101,6 @@ const RowOptionsMenu = ({ }; const FlockTable = () => { - const { searchValue, setSearchValue, setTableState } = useUiStore(); - const pathname = usePathname(); - const { state: tableFilterState, updateFilter, @@ -114,12 +109,14 @@ const FlockTable = () => { toQueryString: getTableFilterQueryString, } = useTableFilter({ initial: { - search: searchValue, + search: '', }, paramMap: { page: 'page', pageSize: 'limit', }, + persist: true, + storeName: 'flock-table', }); const [sorting, setSorting] = useState([]); @@ -139,17 +136,8 @@ const FlockTable = () => { ); const [isDeleteLoading, setIsDeleteLoading] = useState(false); - useEffect(() => { - updateFilter('search', searchValue); - }, [searchValue, updateFilter]); - - useEffect(() => { - setTableState('flocks-table', pathname); - }, [pathname, setTableState]); - const searchChangeHandler: ChangeEventHandler = (e) => { - setSearchValue(e.target.value); - updateFilter('search', e.target.value); + updateFilter('search', e.target.value, true); }; const confirmationModalDeleteClickHandler = async () => { diff --git a/src/components/pages/master-data/kandang/KandangsTable.tsx b/src/components/pages/master-data/kandang/KandangsTable.tsx index 698d3a96..b12192d8 100644 --- a/src/components/pages/master-data/kandang/KandangsTable.tsx +++ b/src/components/pages/master-data/kandang/KandangsTable.tsx @@ -1,13 +1,6 @@ 'use client'; -import { - ChangeEventHandler, - useCallback, - useEffect, - useMemo, - useState, -} from 'react'; -import { usePathname } from 'next/navigation'; +import { ChangeEventHandler, useMemo, useState } from 'react'; import useSWR from 'swr'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import toast from 'react-hot-toast'; @@ -35,7 +28,6 @@ import { User } from '@/types/api/api-general'; import { formatNumber } from '@/lib/helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { useTableFilter } from '@/services/hooks/useTableFilter'; -import { useUiStore } from '@/stores/ui/ui.store'; import { KandangFilterSchema, KandangFilterType, @@ -122,20 +114,21 @@ const RowOptionsMenu = ({ }; const KandangsTable = () => { - const { searchValue, setSearchValue, setTableState } = useUiStore(); - const pathname = usePathname(); - const { state: tableFilterState, updateFilter, setPage, setPageSize, toQueryString: getTableFilterQueryString, - } = useTableFilter({ + } = useTableFilter<{ + search: string; + locationFilter?: OptionType; + picFilter?: OptionType; + }>({ initial: { search: '', - locationFilter: '', - picFilter: '', + locationFilter: undefined, + picFilter: undefined, }, paramMap: { page: 'page', @@ -143,6 +136,8 @@ const KandangsTable = () => { locationFilter: 'location_id', picFilter: 'pic_id', }, + persist: true, + storeName: 'kandangs-table', }); // ===== FILTER MODAL STATE ===== @@ -151,22 +146,34 @@ const KandangsTable = () => { // ===== FORMIK SETUP ===== const formik = useFormik({ initialValues: { - location_id: null, - pic_id: null, + location: tableFilterState.locationFilter, + pic: tableFilterState.picFilter, }, validationSchema: KandangFilterSchema, onSubmit: (values, { setSubmitting }) => { - updateFilter('locationFilter', values.location_id || ''); - updateFilter('picFilter', values.pic_id || ''); + updateFilter('locationFilter', values.location || undefined, true); + updateFilter('picFilter', values.pic || undefined, true); filterModal.closeModal(); setSubmitting(false); }, - onReset: () => { - updateFilter('locationFilter', ''); - updateFilter('picFilter', ''); - }, }); + const formikResetHandler = () => { + updateFilter('locationFilter', undefined, true); + updateFilter('picFilter', undefined, true); + + formik.resetForm({ + values: { + location: undefined, + pic: undefined, + }, + }); + + filterModal.closeModal(); + }; + + const { setFieldValue } = formik; + // ===== LOCATION OPTIONS ===== const { setInputValue: setLocationInputValue, @@ -194,43 +201,15 @@ const KandangsTable = () => { ); // ===== FILTER HANDLERS ===== - const handleFilterLocationChange = useCallback( - (val: OptionType | OptionType[] | null) => { - const location = val as OptionType | null; - const locationId = location?.value ? String(location.value) : null; + const handleFilterLocationChange = ( + val: OptionType | OptionType[] | null + ) => { + setFieldValue('location', val); + }; - formik.setFieldValue('location_id', locationId); - }, - [formik] - ); - - const handleFilterPicChange = useCallback( - (val: OptionType | OptionType[] | null) => { - const pic = val as OptionType | null; - const picId = pic?.value ? String(pic.value) : null; - - formik.setFieldValue('pic_id', picId); - }, - [formik] - ); - - // ===== FILTER HELPERS ===== - 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 picIdValue = useMemo(() => { - if (!formik.values.pic_id) return null; - return ( - picOptions.find((opt) => String(opt.value) === formik.values.pic_id) || - null - ); - }, [formik.values.pic_id, picOptions]); + const handleFilterPicChange = (val: OptionType | OptionType[] | null) => { + setFieldValue('pic', val); + }; // ===== HANDLE FILTER MODAL OPEN ===== const handleFilterModalOpen = () => { @@ -255,17 +234,8 @@ const KandangsTable = () => { ); const [isDeleteLoading, setIsDeleteLoading] = useState(false); - useEffect(() => { - updateFilter('search', searchValue); - }, [searchValue, updateFilter]); - - useEffect(() => { - setTableState('kandangs-table', pathname); - }, [pathname, setTableState]); - const searchChangeHandler: ChangeEventHandler = (e) => { - setSearchValue(e.target.value); - updateFilter('search', e.target.value); + updateFilter('search', e.target.value, true); }; const confirmationModalDeleteClickHandler = async () => { @@ -475,13 +445,13 @@ const KandangsTable = () => { -
+
{ label='PIC' placeholder='Pilih PIC' options={picOptions} - value={picIdValue} + value={formik.values.pic} onChange={handleFilterPicChange} onInputChange={setPicInputValue} isLoading={isLoadingPicOptions} @@ -510,17 +480,14 @@ const KandangsTable = () => { type='button' variant='soft' className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2' - onClick={() => { - formik.resetForm(); - filterModal.closeModal(); - }} + onClick={formikResetHandler} > Reset Filter diff --git a/src/components/pages/master-data/kandang/filter/KandangFilter.ts b/src/components/pages/master-data/kandang/filter/KandangFilter.ts index 30132611..6c945f8d 100644 --- a/src/components/pages/master-data/kandang/filter/KandangFilter.ts +++ b/src/components/pages/master-data/kandang/filter/KandangFilter.ts @@ -1,11 +1,19 @@ -import { string, object } from 'yup'; +import { OptionType } from '@/components/input/SelectInput'; +import * as Yup from 'yup'; -export const KandangFilterSchema = object().shape({ - location_id: string().nullable(), - pic_id: string().nullable(), +export const KandangFilterSchema = Yup.object().shape({ + location: Yup.object({ + value: Yup.string().nullable(), + label: Yup.string().nullable(), + }).nullable(), + + pic: Yup.object({ + value: Yup.string().nullable(), + label: Yup.string().nullable(), + }).nullable(), }); export type KandangFilterType = { - location_id: string | null; - pic_id: string | null; + location?: OptionType; + pic?: OptionType; }; diff --git a/src/components/pages/master-data/location/LocationsTable.tsx b/src/components/pages/master-data/location/LocationsTable.tsx index 89a00539..c396b1c5 100644 --- a/src/components/pages/master-data/location/LocationsTable.tsx +++ b/src/components/pages/master-data/location/LocationsTable.tsx @@ -1,13 +1,6 @@ 'use client'; -import { - ChangeEventHandler, - useCallback, - useEffect, - useMemo, - useState, -} from 'react'; -import { usePathname } from 'next/navigation'; +import { ChangeEventHandler, useMemo, useState } from 'react'; import useSWR from 'swr'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import toast from 'react-hot-toast'; @@ -32,7 +25,6 @@ import { Area } from '@/types/api/master-data/area'; import { LocationApi, AreaApi } from '@/services/api/master-data'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { useTableFilter } from '@/services/hooks/useTableFilter'; -import { useUiStore } from '@/stores/ui/ui.store'; import { LocationFilterSchema, LocationFilterType, @@ -118,25 +110,27 @@ const RowOptionsMenu = ({ }; const LocationsTable = () => { - const { searchValue, setSearchValue, setTableState } = useUiStore(); - const pathname = usePathname(); - const { state: tableFilterState, updateFilter, setPage, setPageSize, toQueryString: getTableFilterQueryString, - } = useTableFilter({ + } = useTableFilter<{ + search: string; + areaFilter?: OptionType; + }>({ initial: { search: '', - areaFilter: '', + areaFilter: undefined, }, paramMap: { page: 'page', pageSize: 'limit', areaFilter: 'area_id', }, + persist: true, + storeName: 'locations-table', }); // ===== FILTER MODAL STATE ===== @@ -145,19 +139,28 @@ const LocationsTable = () => { // ===== FORMIK SETUP ===== const formik = useFormik({ initialValues: { - area_id: null, + area: tableFilterState.areaFilter, }, validationSchema: LocationFilterSchema, onSubmit: (values, { setSubmitting }) => { - updateFilter('areaFilter', values.area_id || ''); + updateFilter('areaFilter', values.area || undefined, true); filterModal.closeModal(); setSubmitting(false); }, - onReset: () => { - updateFilter('areaFilter', ''); - }, }); + const formikResetHandler = () => { + updateFilter('areaFilter', undefined, true); + + formik.resetForm({ + values: { + area: undefined, + }, + }); + + filterModal.closeModal(); + }; + // ===== AREA OPTIONS ===== const { setInputValue: setAreaInputValue, @@ -172,24 +175,9 @@ const LocationsTable = () => { ); // ===== 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] - ); - - // ===== 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 handleFilterAreaChange = (val: OptionType | OptionType[] | null) => { + formik.setFieldValue('area', val); + }; // ===== HANDLE FILTER MODAL OPEN ===== const handleFilterModalOpen = () => { @@ -212,19 +200,10 @@ const LocationsTable = () => { >(undefined); const [isDeleteLoading, setIsDeleteLoading] = useState(false); - useEffect(() => { - updateFilter('search', searchValue); - }, [searchValue, updateFilter]); - - useEffect(() => { - setTableState('locations-table', pathname); - }, [pathname, setTableState]); - const [sorting, setSorting] = useState([]); const searchChangeHandler: ChangeEventHandler = (e) => { - setSearchValue(e.target.value); - updateFilter('search', e.target.value); + updateFilter('search', e.target.value, true); }; const confirmationModalDeleteClickHandler = async () => { @@ -425,13 +404,13 @@ const LocationsTable = () => {
- +
{ type='button' variant='soft' className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2' - onClick={() => { - formik.resetForm(); - filterModal.closeModal(); - }} + onClick={formikResetHandler} > Reset Filter diff --git a/src/components/pages/master-data/location/filter/LocationFilter.ts b/src/components/pages/master-data/location/filter/LocationFilter.ts index 1235d782..51d58c7c 100644 --- a/src/components/pages/master-data/location/filter/LocationFilter.ts +++ b/src/components/pages/master-data/location/filter/LocationFilter.ts @@ -1,9 +1,13 @@ -import { string, object } from 'yup'; +import { OptionType } from '@/components/input/SelectInput'; +import * as Yup from 'yup'; -export const LocationFilterSchema = object().shape({ - area_id: string().nullable(), +export const LocationFilterSchema = Yup.object().shape({ + area: Yup.object({ + value: Yup.string().nullable(), + label: Yup.string().nullable(), + }).nullable(), }); export type LocationFilterType = { - area_id: string | null; + area?: OptionType; }; diff --git a/src/components/pages/master-data/nonstock/NonstocksTable.tsx b/src/components/pages/master-data/nonstock/NonstocksTable.tsx index cb1b4a17..76e51bb3 100644 --- a/src/components/pages/master-data/nonstock/NonstocksTable.tsx +++ b/src/components/pages/master-data/nonstock/NonstocksTable.tsx @@ -20,8 +20,6 @@ import { Nonstock } from '@/types/api/master-data/nonstock'; import { NonstockApi } from '@/services/api/master-data'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { useTableFilter } from '@/services/hooks/useTableFilter'; -import { usePathname } from 'next/navigation'; -import { useUiStore } from '@/stores/ui/ui.store'; const RowOptionsMenu = ({ popoverPosition = 'bottom', @@ -103,9 +101,6 @@ const RowOptionsMenu = ({ }; const NonstocksTable = () => { - const { searchValue, setSearchValue, setTableState } = useUiStore(); - const pathname = usePathname(); - const { state: tableFilterState, updateFilter, @@ -114,22 +109,16 @@ const NonstocksTable = () => { toQueryString: getTableFilterQueryString, } = useTableFilter({ initial: { - search: searchValue, + search: '', }, paramMap: { page: 'page', pageSize: 'limit', }, + persist: true, + storeName: 'nonstock-table', }); - useEffect(() => { - updateFilter('search', searchValue); - }, [searchValue, updateFilter]); - - useEffect(() => { - setTableState('nonstocks-table', pathname); - }, [pathname, setTableState]); - const [sorting, setSorting] = useState([]); const { @@ -148,8 +137,7 @@ const NonstocksTable = () => { const [isDeleteLoading, setIsDeleteLoading] = useState(false); const searchChangeHandler: ChangeEventHandler = (e) => { - setSearchValue(e.target.value); - updateFilter('search', e.target.value); + updateFilter('search', e.target.value, true); }; const confirmationModalDeleteClickHandler = async () => { diff --git a/src/components/pages/master-data/product-category/ProductCategoryTable.tsx b/src/components/pages/master-data/product-category/ProductCategoryTable.tsx index 331f87f7..2b5b99ab 100644 --- a/src/components/pages/master-data/product-category/ProductCategoryTable.tsx +++ b/src/components/pages/master-data/product-category/ProductCategoryTable.tsx @@ -1,7 +1,6 @@ 'use client'; -import { ChangeEventHandler, useEffect, useMemo, useState } from 'react'; -import { usePathname } from 'next/navigation'; +import { ChangeEventHandler, useMemo, useState } from 'react'; import useSWR from 'swr'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import toast from 'react-hot-toast'; @@ -21,7 +20,6 @@ import { ProductCategory } from '@/types/api/master-data/product-category'; import { ProductCategoryApi } from '@/services/api/master-data'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { useTableFilter } from '@/services/hooks/useTableFilter'; -import { useUiStore } from '@/stores/ui/ui.store'; const RowOptionsMenu = ({ popoverPosition = 'bottom', @@ -103,9 +101,6 @@ const RowOptionsMenu = ({ }; const ProductCategoryTable = () => { - const { searchValue, setSearchValue, setTableState } = useUiStore(); - const pathname = usePathname(); - const { state: tableFilterState, updateFilter, @@ -120,12 +115,10 @@ const ProductCategoryTable = () => { page: 'page', pageSize: 'limit', }, + persist: true, + storeName: 'product-category-table', }); - useEffect(() => { - updateFilter('search', searchValue); - }, [searchValue, updateFilter]); - const [sorting, setSorting] = useState([]); const { @@ -144,8 +137,7 @@ const ProductCategoryTable = () => { const [isDeleteLoading, setIsDeleteLoading] = useState(false); const searchChangeHandler: ChangeEventHandler = (e) => { - setSearchValue(e.target.value); - updateFilter('search', e.target.value); + updateFilter('search', e.target.value, true); }; const confirmationModalDeleteClickHandler = async () => { @@ -214,10 +206,6 @@ const ProductCategoryTable = () => { [tableFilterState.pageSize, tableFilterState.page, deleteModal] ); - useEffect(() => { - setTableState('product-category-table', pathname); - }, [pathname, setTableState]); - return ( <>
diff --git a/src/components/pages/master-data/product/ProductTable.tsx b/src/components/pages/master-data/product/ProductTable.tsx index c58caeed..e422330a 100644 --- a/src/components/pages/master-data/product/ProductTable.tsx +++ b/src/components/pages/master-data/product/ProductTable.tsx @@ -1,13 +1,6 @@ 'use client'; -import { - ChangeEventHandler, - useCallback, - useEffect, - useMemo, - useState, -} from 'react'; -import { usePathname } from 'next/navigation'; +import { ChangeEventHandler, useMemo, useState } from 'react'; import useSWR from 'swr'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import toast from 'react-hot-toast'; @@ -33,7 +26,6 @@ import { ProductApi, ProductCategoryApi } from '@/services/api/master-data'; import { formatCurrency } from '@/lib/helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { useTableFilter } from '@/services/hooks/useTableFilter'; -import { useUiStore } from '@/stores/ui/ui.store'; import { ProductFilterSchema, ProductFilterType, @@ -119,25 +111,27 @@ const RowOptionsMenu = ({ }; const ProductsTable = () => { - const { searchValue, setSearchValue, setTableState } = useUiStore(); - const pathname = usePathname(); - const { state: tableFilterState, updateFilter, setPage, setPageSize, toQueryString: getTableFilterQueryString, - } = useTableFilter({ + } = useTableFilter<{ + search: string; + productCategoryFilter?: OptionType; + }>({ initial: { search: '', - productCategoryFilter: '', + productCategoryFilter: undefined, }, paramMap: { page: 'page', pageSize: 'limit', productCategoryFilter: 'product_category_id', }, + persist: true, + storeName: 'product-table', }); // ===== FILTER MODAL STATE ===== @@ -146,19 +140,32 @@ const ProductsTable = () => { // ===== FORMIK SETUP ===== const formik = useFormik({ initialValues: { - product_category_id: null, + product_category: tableFilterState.productCategoryFilter, }, validationSchema: ProductFilterSchema, onSubmit: (values, { setSubmitting }) => { - updateFilter('productCategoryFilter', values.product_category_id || ''); + updateFilter( + 'productCategoryFilter', + values.product_category || undefined, + true + ); filterModal.closeModal(); setSubmitting(false); }, - onReset: () => { - updateFilter('productCategoryFilter', ''); - }, }); + const formikResetHandler = () => { + updateFilter('productCategoryFilter', undefined, true); + + formik.resetForm({ + values: { + product_category: undefined, + }, + }); + + filterModal.closeModal(); + }; + // ===== PRODUCT CATEGORY OPTIONS ===== const { setInputValue: setProductCategoryInputValue, @@ -173,25 +180,11 @@ const ProductsTable = () => { ); // ===== FILTER HANDLERS ===== - const handleFilterProductCategoryChange = useCallback( - (val: OptionType | OptionType[] | null) => { - const category = val as OptionType | null; - const categoryId = category?.value ? String(category.value) : null; - - formik.setFieldValue('product_category_id', categoryId); - }, - [formik] - ); - - // ===== FILTER HELPERS ===== - const productCategoryIdValue = useMemo(() => { - if (!formik.values.product_category_id) return null; - return ( - productCategoryOptions.find( - (opt) => String(opt.value) === formik.values.product_category_id - ) || null - ); - }, [formik.values.product_category_id, productCategoryOptions]); + const handleFilterProductCategoryChange = ( + val: OptionType | OptionType[] | null + ) => { + formik.setFieldValue('product_category', val); + }; // ===== HANDLE FILTER MODAL OPEN ===== const handleFilterModalOpen = () => { @@ -199,10 +192,6 @@ const ProductsTable = () => { formik.validateForm(); }; - useEffect(() => { - updateFilter('search', searchValue); - }, [searchValue, updateFilter]); - const [sorting, setSorting] = useState([]); const { @@ -220,13 +209,8 @@ const ProductsTable = () => { ); const [isDeleteLoading, setIsDeleteLoading] = useState(false); - useEffect(() => { - setTableState('product-table', pathname); - }, [pathname, setTableState]); - const searchChangeHandler: ChangeEventHandler = (e) => { - setSearchValue(e.target.value); - updateFilter('search', e.target.value); + updateFilter('search', e.target.value, true); }; const confirmationModalDeleteClickHandler = async () => { @@ -477,13 +461,13 @@ const ProductsTable = () => {
- +
{ type='button' variant='soft' className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2' - onClick={() => { - formik.resetForm(); - filterModal.closeModal(); - }} + onClick={formikResetHandler} > Reset Filter diff --git a/src/components/pages/master-data/product/filter/ProductFilter.ts b/src/components/pages/master-data/product/filter/ProductFilter.ts index 365dc5de..a2afd7ee 100644 --- a/src/components/pages/master-data/product/filter/ProductFilter.ts +++ b/src/components/pages/master-data/product/filter/ProductFilter.ts @@ -1,9 +1,13 @@ -import { string, object } from 'yup'; +import { OptionType } from '@/components/input/SelectInput'; +import * as Yup from 'yup'; -export const ProductFilterSchema = object().shape({ - product_category_id: string().nullable(), +export const ProductFilterSchema = Yup.object().shape({ + product_category: Yup.object({ + value: Yup.string().nullable(), + label: Yup.string().nullable(), + }).nullable(), }); export type ProductFilterType = { - product_category_id: string | null; + product_category?: OptionType; }; diff --git a/src/components/pages/master-data/production-standard/ProductionStandardTable.tsx b/src/components/pages/master-data/production-standard/ProductionStandardTable.tsx index d843a929..9bb6e432 100644 --- a/src/components/pages/master-data/production-standard/ProductionStandardTable.tsx +++ b/src/components/pages/master-data/production-standard/ProductionStandardTable.tsx @@ -128,27 +128,44 @@ const ProductionStandardTable = () => { pageSize: 'limit', projectCategoryFilter: 'project_category', }, + persist: true, + storeName: 'production-standard-table', }); // ===== FILTER MODAL STATE ===== const filterModal = useModal(); + // ===== FILTER INITIAL VALUES (derived from persisted state) ===== + const filterInitialValues = useMemo( + () => ({ + project_category: tableFilterState.projectCategoryFilter || null, + }), + [tableFilterState.projectCategoryFilter] + ); + // ===== FORMIK SETUP ===== const formik = useFormik({ - initialValues: { - project_category: null, - }, + initialValues: filterInitialValues, validationSchema: ProductionStandardFilterSchema, onSubmit: (values, { setSubmitting }) => { updateFilter('projectCategoryFilter', values.project_category || ''); filterModal.closeModal(); setSubmitting(false); }, - onReset: () => { - updateFilter('projectCategoryFilter', ''); - }, }); + const formikResetHandler = () => { + updateFilter('projectCategoryFilter', '', true); + + formik.resetForm({ + values: { + project_category: null, + }, + }); + + filterModal.closeModal(); + }; + // ===== PROJECT CATEGORY OPTIONS (GROWING or LAYING) ===== const projectCategoryOptions = useMemo( () => [ @@ -381,7 +398,7 @@ const ProductionStandardTable = () => {
- +
{ {/* Modal Footer */}
diff --git a/src/components/pages/master-data/supplier/SupplierTable.tsx b/src/components/pages/master-data/supplier/SupplierTable.tsx index ad2c4ca3..763c18bf 100644 --- a/src/components/pages/master-data/supplier/SupplierTable.tsx +++ b/src/components/pages/master-data/supplier/SupplierTable.tsx @@ -7,7 +7,6 @@ import { useMemo, useState, } from 'react'; -import { usePathname } from 'next/navigation'; import useSWR from 'swr'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import toast from 'react-hot-toast'; @@ -30,7 +29,7 @@ import { Supplier } from '@/types/api/master-data/supplier'; import { SupplierApi } from '@/services/api/master-data'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { useTableFilter } from '@/services/hooks/useTableFilter'; -import { useUiStore } from '@/stores/ui/ui.store'; + import { SupplierFilterSchema, SupplierFilterType, @@ -117,20 +116,21 @@ const RowOptionsMenu = ({ }; const SuppliersTable = () => { - const { searchValue, setSearchValue, setTableState } = useUiStore(); - const pathname = usePathname(); - const { state: tableFilterState, updateFilter, setPage, setPageSize, toQueryString: getTableFilterQueryString, - } = useTableFilter({ + } = useTableFilter<{ + search: string; + categoryFilter?: OptionType; + flagFilter?: string; + }>({ initial: { search: '', - categoryFilter: '', - flagFilter: '', + categoryFilter: undefined, + flagFilter: undefined, }, paramMap: { page: 'page', @@ -138,6 +138,8 @@ const SuppliersTable = () => { categoryFilter: 'category_id', flagFilter: 'flag', }, + persist: true, + storeName: 'supplier-table', }); // ===== FILTER MODAL STATE ===== @@ -146,26 +148,33 @@ const SuppliersTable = () => { // ===== FORMIK SETUP ===== const formik = useFormik({ initialValues: { - category_id: null, - flag: false, + category: tableFilterState.categoryFilter, + flag: tableFilterState.flagFilter === 'EKSPEDISI', }, validationSchema: SupplierFilterSchema, onSubmit: (values, { setSubmitting }) => { - updateFilter('categoryFilter', values.category_id || ''); - updateFilter( - 'flagFilter', - values.flag === true ? 'EKSPEDISI' : values.flag === false ? '' : '' - ); + updateFilter('categoryFilter', values.category || undefined, true); + updateFilter('flagFilter', values.flag === true ? 'EKSPEDISI' : '', true); filterModal.closeModal(); + setSubmitting(false); }, - onReset: () => { - updateFilter('categoryFilter', ''); - updateFilter('flagFilter', ''); - formik.setFieldValue('flag', false); - }, }); + const formikResetHandler = () => { + updateFilter('categoryFilter', undefined, true); + updateFilter('flagFilter', '', true); + + formik.resetForm({ + values: { + category: undefined, + flag: false, + }, + }); + + filterModal.closeModal(); + }; + const { setFieldValue } = formik; // ===== CATEGORY OPTIONS (SAPRONAK or BOP) ===== @@ -187,15 +196,11 @@ const SuppliersTable = () => { ); // ===== FILTER HANDLERS ===== - const handleFilterCategoryChange = useCallback( - (val: OptionType | OptionType[] | null) => { - const option = val as OptionType | null; - const categoryId = option?.value ? String(option.value) : null; - - setFieldValue('category_id', categoryId); - }, - [setFieldValue] - ); + const handleFilterCategoryChange = ( + val: OptionType | OptionType[] | null + ) => { + setFieldValue('category', val); + }; const handleFilterFlagChange = useCallback( (val: OptionType | OptionType[] | null) => { @@ -213,13 +218,13 @@ const SuppliersTable = () => { ); // ===== FILTER HELPERS ===== - const categoryIdValue = useMemo(() => { - if (!formik.values.category_id) return null; - return ( - categoryOptions.find((opt) => opt.value === formik.values.category_id) || - null - ); - }, [formik.values.category_id, categoryOptions]); + // const categoryIdValue = useMemo(() => { + // if (!formik.values.category_id) return null; + // return ( + // categoryOptions.find((opt) => opt.value === formik.values.category_id) || + // null + // ); + // }, [formik.values.category_id, categoryOptions]); const flagValue = useMemo(() => { if (formik.values.flag === null) return null; @@ -243,14 +248,6 @@ const SuppliersTable = () => { } }, [filterModal.open, tableFilterState.flagFilter, setFieldValue]); - useEffect(() => { - updateFilter('search', searchValue); - }, [searchValue, updateFilter]); - - useEffect(() => { - setTableState('suppliers-table', pathname); - }, [pathname, setTableState]); - const [sorting, setSorting] = useState([]); const { @@ -269,8 +266,7 @@ const SuppliersTable = () => { const [isDeleteLoading, setIsDeleteLoading] = useState(false); const searchChangeHandler: ChangeEventHandler = (e) => { - setSearchValue(e.target.value); - updateFilter('search', e.target.value); + updateFilter('search', e.target.value, true); }; const confirmationModalDeleteClickHandler = async () => { @@ -491,13 +487,13 @@ const SuppliersTable = () => {
- +
{ {/* Modal Footer */}
diff --git a/src/components/pages/master-data/supplier/filter/SupplierFilter.ts b/src/components/pages/master-data/supplier/filter/SupplierFilter.ts index 4eaa3a1f..ad5dfbe4 100644 --- a/src/components/pages/master-data/supplier/filter/SupplierFilter.ts +++ b/src/components/pages/master-data/supplier/filter/SupplierFilter.ts @@ -1,11 +1,16 @@ -import { string, boolean, object } from 'yup'; +import { OptionType } from '@/components/input/SelectInput'; +import * as Yup from 'yup'; -export const SupplierFilterSchema = object().shape({ - category_id: string().nullable(), - flag: boolean().nullable(), +export const SupplierFilterSchema = Yup.object().shape({ + category: Yup.object({ + value: Yup.string().required(), + label: Yup.string().required(), + }).nullable(), + + flag: Yup.boolean().nullable(), }); export type SupplierFilterType = { - category_id: string | null; + category?: OptionType; flag: boolean | null; }; diff --git a/src/components/pages/master-data/uom/UomsTable.tsx b/src/components/pages/master-data/uom/UomsTable.tsx index a39fdb1a..1334714d 100644 --- a/src/components/pages/master-data/uom/UomsTable.tsx +++ b/src/components/pages/master-data/uom/UomsTable.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ChangeEventHandler, useEffect, useMemo, useState } from 'react'; +import { ChangeEventHandler, useMemo, useState } from 'react'; import useSWR from 'swr'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import toast from 'react-hot-toast'; @@ -20,8 +20,6 @@ import { Uom } from '@/types/api/master-data/uom'; import { UomApi } from '@/services/api/master-data'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { useTableFilter } from '@/services/hooks/useTableFilter'; -import { usePathname } from 'next/navigation'; -import { useUiStore } from '@/stores/ui/ui.store'; const RowOptionsMenu = ({ popoverPosition = 'bottom', @@ -103,9 +101,6 @@ const RowOptionsMenu = ({ }; const UomsTable = () => { - const { searchValue, setSearchValue, setTableState } = useUiStore(); - const pathname = usePathname(); - const { state: tableFilterState, updateFilter, @@ -114,22 +109,16 @@ const UomsTable = () => { toQueryString: getTableFilterQueryString, } = useTableFilter({ initial: { - search: searchValue, + search: '', }, paramMap: { page: 'page', pageSize: 'limit', }, + persist: true, + storeName: 'uom-table', }); - useEffect(() => { - updateFilter('search', searchValue); - }, [searchValue, updateFilter]); - - useEffect(() => { - setTableState('uoms-table', pathname); - }, [pathname, setTableState]); - const [sorting, setSorting] = useState([]); const { @@ -146,8 +135,7 @@ const UomsTable = () => { const [isDeleteLoading, setIsDeleteLoading] = useState(false); const searchChangeHandler: ChangeEventHandler = (e) => { - setSearchValue(e.target.value); - updateFilter('search', e.target.value); + updateFilter('search', e.target.value, true); }; const confirmationModalDeleteClickHandler = async () => { diff --git a/src/components/pages/master-data/warehouse/WarehousesTable.tsx b/src/components/pages/master-data/warehouse/WarehousesTable.tsx index 25642411..5789605d 100644 --- a/src/components/pages/master-data/warehouse/WarehousesTable.tsx +++ b/src/components/pages/master-data/warehouse/WarehousesTable.tsx @@ -1,13 +1,6 @@ 'use client'; -import { - ChangeEventHandler, - useCallback, - useEffect, - useMemo, - useState, -} from 'react'; -import { usePathname } from 'next/navigation'; +import { ChangeEventHandler, useCallback, useMemo, useState } from 'react'; import useSWR from 'swr'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import toast from 'react-hot-toast'; @@ -31,7 +24,6 @@ import { Warehouse } from '@/types/api/master-data/warehouse'; import { WarehouseApi, AreaApi } from '@/services/api/master-data'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { useTableFilter } from '@/services/hooks/useTableFilter'; -import { useUiStore } from '@/stores/ui/ui.store'; import { WarehouseFilterSchema, WarehouseFilterType, @@ -120,9 +112,6 @@ const RowOptionsMenu = ({ }; const WarehousesTable = () => { - const { searchValue, setSearchValue, setTableState } = useUiStore(); - const pathname = usePathname(); - const { state: tableFilterState, updateFilter, @@ -141,6 +130,8 @@ const WarehousesTable = () => { areaFilter: 'area_id', activeProjectFlockFilter: 'active_project_flock', }, + persist: true, + storeName: 'warehouses-table', }); // ===== FILTER MODAL STATE ===== @@ -149,27 +140,36 @@ const WarehousesTable = () => { // ===== FORMIK SETUP ===== const formik = useFormik({ initialValues: { - area_id: null, - active_project_flock: false, + area_id: tableFilterState.areaFilter || null, + active_project_flock: + tableFilterState.activeProjectFlockFilter === 'true', }, validationSchema: WarehouseFilterSchema, onSubmit: (values, { setSubmitting }) => { - updateFilter('areaFilter', values.area_id || ''); + updateFilter('areaFilter', values.area_id || '', true); updateFilter( 'activeProjectFlockFilter', - values.active_project_flock === true ? 'true' : '' + values.active_project_flock === true ? 'true' : '', + true ); filterModal.closeModal(); setSubmitting(false); }, - onReset: () => { - updateFilter('areaFilter', ''); - updateFilter('activeProjectFlockFilter', ''); - formik.setFieldValue('active_project_flock', false); - }, }); - const { setFieldValue } = formik; + const formikResetHandler = () => { + updateFilter('areaFilter', '', true); + updateFilter('activeProjectFlockFilter', '', true); + + formik.resetForm({ + values: { + area_id: null, + active_project_flock: false, + }, + }); + + filterModal.closeModal(); + }; // ===== AREA OPTIONS ===== const { @@ -243,26 +243,6 @@ const WarehousesTable = () => { formik.validateForm(); }; - useEffect(() => { - if (filterModal.open) { - const activeProjectFlockValue = - tableFilterState.activeProjectFlockFilter === 'true' ? true : false; // Default ke false (Semua Kandang) - setFieldValue('active_project_flock', activeProjectFlockValue); - } - }, [ - filterModal.open, - tableFilterState.activeProjectFlockFilter, - setFieldValue, - ]); - - useEffect(() => { - updateFilter('search', searchValue); - }, [searchValue, updateFilter]); - - useEffect(() => { - setTableState('warehouses-table', pathname); - }, [pathname, setTableState]); - const [sorting, setSorting] = useState([]); const { @@ -281,8 +261,7 @@ const WarehousesTable = () => { const [isDeleteLoading, setIsDeleteLoading] = useState(false); const searchChangeHandler: ChangeEventHandler = (e) => { - setSearchValue(e.target.value); - updateFilter('search', e.target.value); + updateFilter('search', e.target.value, true); }; const confirmationModalDeleteClickHandler = async () => { @@ -507,7 +486,7 @@ const WarehousesTable = () => {
- +
{ type='button' variant='soft' className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2' - onClick={() => { - formik.resetForm(); - filterModal.closeModal(); - }} + onClick={formikResetHandler} > Reset Filter diff --git a/src/services/hooks/useTableFilter.tsx b/src/services/hooks/useTableFilter.tsx index 5db57bcb..ad9c6679 100644 --- a/src/services/hooks/useTableFilter.tsx +++ b/src/services/hooks/useTableFilter.tsx @@ -1,13 +1,29 @@ import { useCallback, useEffect, useMemo, useReducer } from 'react'; import { useTableFilterStore } from '@/stores/table/table-filter.store'; +import { OptionType } from '@/components/input/SelectInput'; + +type TableFilterStateValue = + | undefined + | null + | boolean + | string + | string[] + | number + | number[] + | OptionType + | OptionType[] + | OptionType + | OptionType[]; /** Core filter shape (page + pageSize) extended by your custom fields */ -export type TableFilterState> = { +export type TableFilterState< + TExtra extends Record, +> = { page: number; pageSize: number; } & TExtra; -type Action> = +type Action> = | { type: 'SET_PAGE'; page: number } | { type: 'SET_PAGE_SIZE'; pageSize: number; resetPage?: boolean } | { type: 'SET_FILTERS'; filters: Partial; resetPage?: boolean } @@ -20,7 +36,9 @@ type Action> = | { type: 'REPLACE_ALL'; next: TableFilterState } | { type: 'RESET' }; -export type UseTableFilterOptions> = { +export type UseTableFilterOptions< + TExtra extends Record, +> = { /** Initial state; anything you omit falls back to defaults */ initial?: Partial>; /** Called after any state change */ @@ -43,9 +61,9 @@ function clampToInt(n: number, min = 1) { return v < min ? min : v; } -function createInitialState>( - opts: UseTableFilterOptions | undefined -): TableFilterState { +function createInitialState< + TExtra extends Record, +>(opts: UseTableFilterOptions | undefined): TableFilterState { const defaults = { page: 1, pageSize: opts?.defaultPageSize ?? 10, @@ -59,10 +77,22 @@ function createInitialState>( function serializeValue(v: unknown): string | null { if (v === undefined || v === null) return null; + if (v instanceof Date) return v.toISOString(); - if (Array.isArray(v)) return v.map((x) => x ?? '').join(','); // e.g., ids=1,2,3 + + if (v instanceof Object && (v as OptionType).value) + return String((v as OptionType).value); + + if (Array.isArray(v)) + return v + .map((x) => serializeValue(x)) + .filter((x) => x !== null) + .join(','); + const t = typeof v; + if (t === 'string' || t === 'number' || t === 'boolean') return String(v); + try { return JSON.stringify(v); } catch { @@ -70,32 +100,16 @@ function serializeValue(v: unknown): string | null { } } -// function shallowEqual(a: unknown, b: unknown): boolean { -// if (a === b) return true; -// if (!a || !b) return false; -// const ka = Object.keys(a); -// const kb = Object.keys(b); -// if (ka.length !== kb.length) return false; -// for (const k of ka) if (a[k] !== b[k]) return false; -// return true; -// } - -function shallowEqual>( +function shallowEqual( a: T | undefined | null, b: T | undefined | null ): boolean { - if (a === b) return true; - if (!a || !b) return false; - const ka = Object.keys(a) as (keyof T)[]; - const kb = Object.keys(b) as (keyof T)[]; - if (ka.length !== kb.length) return false; - for (const k of ka) if (a[k] !== b[k]) return false; - return true; + return JSON.stringify(a) === JSON.stringify(b); } -export function useTableFilter>( - options?: UseTableFilterOptions -) { +export function useTableFilter< + TExtra extends Record, +>(options?: UseTableFilterOptions) { if (options?.persist && !options?.storeName) { throw new Error( 'storeName is required if persist is true in useTableFilter!' @@ -220,7 +234,9 @@ export function useTableFilter>( ); const extras = useMemo(() => { - const stateWithExtras = state as TableFilterState>; + const stateWithExtras = state as TableFilterState< + Record + >; const rest = Object.fromEntries( Object.entries(stateWithExtras).filter( ([key]) => key !== 'page' && key !== 'pageSize' @@ -241,10 +257,8 @@ export function useTableFilter>( /** Build URLSearchParams from current state */ const toSearchParams = useCallback(() => { const params = new URLSearchParams(); - const source = state as Record; - const baseline = options?.omitDefaultsInUrl - ? (defaults as Record) - : null; + const source = state as Record; + const baseline = options?.omitDefaultsInUrl ? defaults : null; const excludedKeys = new Set( (options?.excludeKeysFromUrl as string[] | undefined) ?? [] ); @@ -255,13 +269,7 @@ export function useTableFilter>( const value = source[key]; if (value === undefined || value === null) continue; - if ( - baseline && - shallowEqual( - value as Record, - baseline[key] as Record - ) - ) { + if (baseline && shallowEqual(value, baseline[key])) { continue; }