feat(FE): Add filter functionality to inventory tables

This commit is contained in:
rstubryan
2026-03-04 10:44:22 +07:00
parent 4f6d71f1f4
commit 1fb9687142
4 changed files with 659 additions and 138 deletions
@@ -1,22 +1,48 @@
'use client'; 'use client';
import { useCallback, useEffect, useMemo, useState } from 'react'; import {
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { ColumnDef, ColumnSort, SortingState } from '@tanstack/react-table'; import { ColumnDef, ColumnSort, SortingState } from '@tanstack/react-table';
import { useFormik } from 'formik';
import Button from '@/components/Button'; import Button from '@/components/Button';
import Table from '@/components/Table'; import Table from '@/components/Table';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { useSelect } from '@/components/input/SelectInput';
import { OptionType } from '@/components/input/SelectInput';
import ButtonFilter from '@/components/helper/ButtonFilter';
import Modal, { useModal } from '@/components/Modal';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { cn, formatNumber, formatDate, formatCurrency } from '@/lib/helper'; import { cn, formatNumber, formatDate, formatCurrency } from '@/lib/helper';
import { InventoryAdjustmentApi } from '@/services/api/inventory'; import { InventoryAdjustmentApi } from '@/services/api/inventory';
import { WarehouseApi, ProductApi } from '@/services/api/master-data';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import { InventoryAdjustment } from '@/types/api/inventory/adjustment'; import { InventoryAdjustment } from '@/types/api/inventory/adjustment';
import { Warehouse } from '@/types/api/master-data/warehouse';
import { TRANSACTION_SUBTYPE_OPTIONS } from '@/config/constant';
import { Product } from '@/types/api/master-data/product';
import StatusBadge from '@/components/helper/StatusBadge'; import StatusBadge from '@/components/helper/StatusBadge';
import InventoryAdjustmentTableSkeleton from '@/components/pages/inventory/adjustment/skeleton/InventoryAdjustmentTableSkeleton'; import InventoryAdjustmentTableSkeleton from '@/components/pages/inventory/adjustment/skeleton/InventoryAdjustmentTableSkeleton';
import { TRANSACTION_SUBTYPE_OPTIONS } from '@/config/constant';
import {
AdjustmentFilterSchema,
AdjustmentFilterType,
} from '@/components/pages/inventory/adjustment/filter/AdjustmentFilter';
import SelectInputRadio from '@/components/input/SelectInputRadio';
const InventoryAdjustmentTable = () => { const InventoryAdjustmentTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -30,6 +56,9 @@ const InventoryAdjustmentTable = () => {
productSort: '', productSort: '',
warehouseSort: '', warehouseSort: '',
stockSort: '', stockSort: '',
productFilter: '',
warehouseFilter: '',
transactionTypeFilter: '',
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
@@ -38,9 +67,133 @@ const InventoryAdjustmentTable = () => {
productSort: 'sort_product', productSort: 'sort_product',
warehouseSort: 'sort_warehouse', warehouseSort: 'sort_warehouse',
stockSort: 'sort_stock', stockSort: 'sort_stock',
productFilter: 'product_id',
warehouseFilter: 'warehouse_id',
transactionTypeFilter: 'transaction_type',
}, },
}); });
// ===== FILTER MODAL STATE =====
const filterModal = useModal();
// ===== FORMIK SETUP =====
const formik = useFormik<AdjustmentFilterType>({
initialValues: {
product_id: null,
warehouse_id: null,
transaction_type: null,
},
validationSchema: AdjustmentFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('productFilter', values.product_id || '');
updateFilter('warehouseFilter', values.warehouse_id || '');
updateFilter('transactionTypeFilter', values.transaction_type || '');
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('productFilter', '');
updateFilter('warehouseFilter', '');
updateFilter('transactionTypeFilter', '');
},
});
// ===== PRODUCT OPTIONS =====
const {
setInputValue: setProductInputValue,
options: productOptions,
isLoadingOptions: isLoadingProductOptions,
loadMore: loadMoreProducts,
} = useSelect<Product>(
filterModal.open ? ProductApi.basePath : null,
'id',
'name',
'search'
);
// ===== WAREHOUSE OPTIONS =====
const {
setInputValue: setWarehouseInputValue,
options: warehouseOptions,
isLoadingOptions: isLoadingWarehouseOptions,
loadMore: loadMoreWarehouses,
} = useSelect<Warehouse>(
filterModal.open ? WarehouseApi.basePath : null,
'id',
'name',
'search'
);
// ===== TRANSACTION TYPE OPTIONS =====
const transactionTypeOptions = useMemo(() => {
return [
{ value: 'increase', label: 'Increase' },
{ value: 'decrease', label: 'Decrease' },
];
}, []);
// ===== FILTER HANDLERS =====
const handleFilterProductChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const product = val as OptionType | null;
const productId = product?.value ? String(product.value) : null;
formik.setFieldValue('product_id', productId);
},
[formik]
);
const handleFilterWarehouseChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const warehouse = val as OptionType | null;
const warehouseId = warehouse?.value ? String(warehouse.value) : null;
formik.setFieldValue('warehouse_id', warehouseId);
},
[formik]
);
const handleFilterTransactionTypeChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const type = val as OptionType | null;
const typeValue = type?.value ? String(type.value) : null;
formik.setFieldValue('transaction_type', typeValue);
},
[formik]
);
// ===== FILTER HELPERS =====
const productIdValue = useMemo(() => {
if (!formik.values.product_id) return null;
return (
productOptions.find(
(opt) => String(opt.value) === formik.values.product_id
) || null
);
}, [formik.values.product_id, productOptions]);
const warehouseIdValue = useMemo(() => {
if (!formik.values.warehouse_id) return null;
return (
warehouseOptions.find(
(opt) => String(opt.value) === formik.values.warehouse_id
) || null
);
}, [formik.values.warehouse_id, warehouseOptions]);
const transactionTypeValue = useMemo(() => {
if (!formik.values.transaction_type) return null;
return (
transactionTypeOptions.find(
(opt) => String(opt.value) === formik.values.transaction_type
) || null
);
}, [formik.values.transaction_type, transactionTypeOptions]);
// ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => {
filterModal.openModal();
formik.validateForm();
};
const { data: inventoryAdjustments, isLoading } = useSWR( const { data: inventoryAdjustments, isLoading } = useSWR(
`${InventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`, `${InventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`,
InventoryAdjustmentApi.getAllFetcher InventoryAdjustmentApi.getAllFetcher
@@ -48,6 +201,19 @@ const InventoryAdjustmentTable = () => {
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('inventory-adjustment-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
};
const inventoryAdjustmentsColumns: ColumnDef<InventoryAdjustment>[] = useMemo( const inventoryAdjustmentsColumns: ColumnDef<InventoryAdjustment>[] = useMemo(
() => [ () => [
{ {
@@ -182,76 +348,200 @@ const InventoryAdjustmentTable = () => {
}, [sorting, updateSortingFilter]); }, [sorting, updateSortingFilter]);
return ( return (
<div className='w-full'> <>
{/* Header Section */} <div className='w-full'>
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'> {/* Header Section */}
<div className='w-fit flex flex-row gap-3 flex-wrap'> <div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
<RequirePermission permissions='lti.inventory.create'> {/* Action Buttons */}
<Button <div className='w-fit flex flex-row gap-3 flex-wrap'>
href='/inventory/adjustment/add' <RequirePermission permissions='lti.inventory.create'>
color='primary' <Button
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm' href='/inventory/adjustment/add'
> color='primary'
<Icon icon='heroicons:plus' width={20} height={20} /> className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
Add Adjustment >
</Button> <Icon icon='heroicons:plus' width={20} height={20} />
</RequirePermission> Add Adjustment
</div> </Button>
</div> </RequirePermission>
{/* Table Section */}
<div className='flex flex-col mb-4'>
{isLoading ? (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div> </div>
) : !isResponseSuccess(inventoryAdjustments) ||
inventoryAdjustments.data?.length === 0 ? ( {/* Search and Filter */}
<div className='p-3'> <div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<InventoryAdjustmentTableSkeleton <DebouncedTextInput
columns={inventoryAdjustmentsColumns} name='search'
icon={ placeholder='Search'
value={tableFilterState.search ?? ''}
onChange={searchChangeHandler}
startAdornment={
<Icon <Icon
icon='heroicons:document-text' icon='heroicons:magnifying-glass'
className='text-white'
width={20} width={20}
height={20} height={20}
/> />
} }
className={{
wrapper: 'w-full min-w-24 max-w-3xs',
inputWrapper: 'rounded-xl! shadow-button-soft',
input:
'placeholder:font-semibold placeholder:text-base-content/50',
}}
/>
<ButtonFilter
values={tableFilterState}
excludeFields={[
'page',
'pageSize',
'search',
'productCategorySort',
'productSort',
'warehouseSort',
'stockSort',
]}
onClick={handleFilterModalOpen}
className='px-3 py-2.5'
/> />
</div> </div>
) : ( </div>
<Table<InventoryAdjustment>
data={ {/* Table Section */}
isResponseSuccess(inventoryAdjustments) <div className='flex flex-col mb-4'>
? inventoryAdjustments?.data {isLoading ? (
: [] <div className='w-full flex flex-row justify-center items-center p-4'>
} <span className='loading loading-spinner loading-xl' />
columns={inventoryAdjustmentsColumns} </div>
pageSize={tableFilterState.pageSize} ) : !isResponseSuccess(inventoryAdjustments) ||
page={ inventoryAdjustments.data?.length === 0 ? (
isResponseSuccess(inventoryAdjustments) <div className='p-3'>
? inventoryAdjustments?.meta?.page <InventoryAdjustmentTableSkeleton
: 0 columns={inventoryAdjustmentsColumns}
} icon={
totalItems={ <Icon
isResponseSuccess(inventoryAdjustments) icon='heroicons:document-text'
? inventoryAdjustments?.meta?.total_results className='text-white'
: 0 width={20}
} height={20}
onPageChange={setPage} />
onPageSizeChange={setPageSize} }
isLoading={isLoading} />
sorting={sorting} </div>
setSorting={setSorting} ) : (
className={{ <Table<InventoryAdjustment>
containerClassName: cn('p-3 mb-0'), data={
headerColumnClassName: 'text-nowrap', isResponseSuccess(inventoryAdjustments)
}} ? inventoryAdjustments?.data
/> : []
)} }
columns={inventoryAdjustmentsColumns}
pageSize={tableFilterState.pageSize}
page={
isResponseSuccess(inventoryAdjustments)
? inventoryAdjustments?.meta?.page
: 0
}
totalItems={
isResponseSuccess(inventoryAdjustments)
? inventoryAdjustments?.meta?.total_results
: 0
}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn('p-3 mb-0'),
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div>
</div> </div>
</div>
{/* Filter Modal */}
<Modal
ref={filterModal.ref}
className={{
modal: 'p-0',
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
}}
>
{/* Modal Header */}
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
<div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='font-medium text-sm'>Filter Data</h3>
</div>
<Button
variant='link'
onClick={filterModal.closeModal}
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInput
label='Produk'
placeholder='Pilih Produk'
options={productOptions}
value={productIdValue}
onChange={handleFilterProductChange}
onInputChange={setProductInputValue}
isLoading={isLoadingProductOptions}
isClearable
onMenuScrollToBottom={loadMoreProducts}
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Gudang'
placeholder='Pilih Gudang'
options={warehouseOptions}
value={warehouseIdValue}
onChange={handleFilterWarehouseChange}
onInputChange={setWarehouseInputValue}
isLoading={isLoadingWarehouseOptions}
isClearable
onMenuScrollToBottom={loadMoreWarehouses}
className={{ wrapper: 'w-full' }}
/>
<SelectInputRadio
label='Tipe Transaksi'
placeholder='Pilih Tipe Transaksi'
options={transactionTypeOptions}
value={transactionTypeValue}
onChange={handleFilterTransactionTypeChange}
isClearable
className={{ wrapper: 'w-full' }}
/>
</div>
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
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();
}}
>
Reset Filter
</Button>
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={!formik.isValid || formik.isSubmitting}
>
Apply Filter
</Button>
</div>
</form>
</Modal>
</>
); );
}; };
@@ -0,0 +1,13 @@
import { string, object } from 'yup';
export const AdjustmentFilterSchema = object().shape({
product_id: string().nullable(),
warehouse_id: string().nullable(),
transaction_type: string().nullable(),
});
export type AdjustmentFilterType = {
product_id: string | null;
warehouse_id: string | null;
transaction_type: string | null;
};
@@ -1,22 +1,42 @@
'use client'; 'use client';
import { ChangeEventHandler, useMemo, useState } from 'react'; import {
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table'; import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table';
import { useFormik } from 'formik';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { Movement } from '@/types/api/inventory/movement'; import { Movement } from '@/types/api/inventory/movement';
import { MovementApi } from '@/services/api/inventory'; import { MovementApi } from '@/services/api/inventory';
import { WarehouseApi, ProductApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import Button from '@/components/Button'; import Button from '@/components/Button';
import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { useSelect } from '@/components/input/SelectInput';
import { OptionType } from '@/components/input/SelectInput';
import ButtonFilter from '@/components/helper/ButtonFilter';
import Modal, { useModal } from '@/components/Modal';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import PopoverButton from '@/components/popover/PopoverButton'; import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent'; import PopoverContent from '@/components/popover/PopoverContent';
import MovementTableSkeleton from '@/components/pages/inventory/movement/skeleton/MovementTableSkeleton'; import MovementTableSkeleton from '@/components/pages/inventory/movement/skeleton/MovementTableSkeleton';
import { Warehouse } from '@/types/api/master-data/warehouse';
import { Product } from '@/types/api/master-data/product';
import {
MovementFilterSchema,
MovementFilterType,
} from '@/components/pages/inventory/movement/filter/MovementFilter';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
popoverPosition = 'bottom', popoverPosition = 'bottom',
@@ -70,6 +90,9 @@ const RowOptionsMenu = ({
}; };
const MovementTable = () => { const MovementTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -79,13 +102,105 @@ const MovementTable = () => {
} = useTableFilter({ } = useTableFilter({
initial: { initial: {
search: '', search: '',
productFilter: '',
warehouseFilter: '',
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
productFilter: 'product_id',
warehouseFilter: 'warehouse_id',
}, },
}); });
// ===== FILTER MODAL STATE =====
const filterModal = useModal();
// ===== FORMIK SETUP =====
const formik = useFormik<MovementFilterType>({
initialValues: {
product_id: null,
warehouse_id: null,
},
validationSchema: MovementFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('productFilter', values.product_id || '');
updateFilter('warehouseFilter', values.warehouse_id || '');
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('productFilter', '');
updateFilter('warehouseFilter', '');
},
});
// ===== PRODUCT OPTIONS =====
const {
setInputValue: setProductInputValue,
options: productOptions,
isLoadingOptions: isLoadingProductOptions,
loadMore: loadMoreProducts,
} = useSelect<Product>(
filterModal.open ? ProductApi.basePath : null,
'id',
'name',
'search'
);
// ===== WAREHOUSE OPTIONS =====
const {
setInputValue: setWarehouseInputValue,
options: warehouseOptions,
isLoadingOptions: isLoadingWarehouseOptions,
loadMore: loadMoreWarehouses,
} = useSelect<Warehouse>(
filterModal.open ? WarehouseApi.basePath : null,
'id',
'name',
'search'
);
// ===== FILTER HANDLERS =====
const handleFilterProductChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const product = val as OptionType | null;
const productId = product?.value ? String(product.value) : null;
formik.setFieldValue('product_id', productId);
},
[formik]
);
const handleFilterWarehouseChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const warehouse = val as OptionType | null;
const warehouseId = warehouse?.value ? String(warehouse.value) : null;
formik.setFieldValue('warehouse_id', warehouseId);
},
[formik]
);
// ===== FILTER HELPERS =====
const productIdValue = useMemo(() => {
if (!formik.values.product_id) return null;
return (
productOptions.find((opt) => String(opt.value) === formik.values.product_id) || null
);
}, [formik.values.product_id, productOptions]);
const warehouseIdValue = useMemo(() => {
if (!formik.values.warehouse_id) return null;
return (
warehouseOptions.find((opt) => String(opt.value) === formik.values.warehouse_id) || null
);
}, [formik.values.warehouse_id, warehouseOptions]);
// ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => {
filterModal.openModal();
formik.validateForm();
};
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const { data: movements, isLoading } = useSWR( const { data: movements, isLoading } = useSWR(
@@ -93,7 +208,16 @@ const MovementTable = () => {
MovementApi.getAllFetcher MovementApi.getAllFetcher
); );
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('movement-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value); updateFilter('search', e.target.value);
}; };
@@ -160,85 +284,168 @@ const MovementTable = () => {
); );
return ( return (
<div className='w-full'> <>
{/* Header Section */} <div className='w-full'>
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'> {/* Header Section */}
{/* Action Buttons */} <div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
<div className='w-fit flex flex-row gap-3 flex-wrap'> {/* Action Buttons */}
<RequirePermission permissions='lti.inventory.transfer.create'> <div className='w-fit flex flex-row gap-3 flex-wrap'>
<Button <RequirePermission permissions='lti.inventory.transfer.create'>
href='/inventory/movement/add' <Button
color='primary' href='/inventory/movement/add'
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm' color='primary'
> className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
<Icon icon='heroicons:plus' width={20} height={20} /> >
Add Movement <Icon icon='heroicons:plus' width={20} height={20} />
</Button> Add Movement
</RequirePermission> </Button>
</div> </RequirePermission>
{/* Search */}
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<DebouncedTextInput
name='search'
placeholder='Search'
value={tableFilterState.search ?? ''}
onChange={searchChangeHandler}
startAdornment={
<Icon icon='heroicons:magnifying-glass' width={20} height={20} />
}
className={{
wrapper: 'w-full min-w-24 max-w-3xs',
inputWrapper: 'rounded-xl! shadow-button-soft',
input:
'placeholder:font-semibold placeholder:text-base-content/50',
}}
/>
</div>
</div>
{/* Table Section */}
<div className='flex flex-col mb-4'>
{isLoading ? (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div> </div>
) : !isResponseSuccess(movements) || movements.data?.length === 0 ? (
<div className='p-3'> {/* Search and Filter */}
<MovementTableSkeleton <div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
columns={movementColumns} <DebouncedTextInput
icon={ name='search'
<Icon placeholder='Search'
icon='heroicons:document-text' value={tableFilterState.search ?? ''}
className='text-white' onChange={searchChangeHandler}
width={20} startAdornment={
height={20} <Icon icon='heroicons:magnifying-glass' width={20} height={20} />
/>
} }
className={{
wrapper: 'w-full min-w-24 max-w-3xs',
inputWrapper: 'rounded-xl! shadow-button-soft',
input:
'placeholder:font-semibold placeholder:text-base-content/50',
}}
/>
<ButtonFilter
values={tableFilterState}
excludeFields={['page', 'pageSize', 'search']}
onClick={handleFilterModalOpen}
className='px-3 py-2.5'
/> />
</div> </div>
) : ( </div>
<Table<Movement>
data={isResponseSuccess(movements) ? movements?.data : []} {/* Table Section */}
columns={movementColumns} <div className='flex flex-col mb-4'>
pageSize={tableFilterState.pageSize} {isLoading ? (
page={isResponseSuccess(movements) ? movements?.meta?.page : 0} <div className='w-full flex flex-row justify-center items-center p-4'>
totalItems={ <span className='loading loading-spinner loading-xl' />
isResponseSuccess(movements) ? movements?.meta?.total_results : 0 </div>
} ) : !isResponseSuccess(movements) || movements.data?.length === 0 ? (
onPageChange={setPage} <div className='p-3'>
onPageSizeChange={setPageSize} <MovementTableSkeleton
isLoading={isLoading} columns={movementColumns}
sorting={sorting} icon={
setSorting={setSorting} <Icon
className={{ icon='heroicons:document-text'
containerClassName: cn('p-3 mb-0'), className='text-white'
headerColumnClassName: 'text-nowrap', width={20}
}} height={20}
/> />
)} }
/>
</div>
) : (
<Table<Movement>
data={isResponseSuccess(movements) ? movements?.data : []}
columns={movementColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(movements) ? movements?.meta?.page : 0}
totalItems={
isResponseSuccess(movements) ? movements?.meta?.total_results : 0
}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn('p-3 mb-0'),
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div>
</div> </div>
</div>
{/* Filter Modal */}
<Modal
ref={filterModal.ref}
className={{
modal: 'p-0',
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
}}
>
{/* Modal Header */}
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
<div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='font-medium text-sm'>Filter Data</h3>
</div>
<Button
variant='link'
onClick={filterModal.closeModal}
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInput
label='Produk'
placeholder='Pilih Produk'
options={productOptions}
value={productIdValue}
onChange={handleFilterProductChange}
onInputChange={setProductInputValue}
isLoading={isLoadingProductOptions}
isClearable
onMenuScrollToBottom={loadMoreProducts}
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Gudang'
placeholder='Pilih Gudang'
options={warehouseOptions}
value={warehouseIdValue}
onChange={handleFilterWarehouseChange}
onInputChange={setWarehouseInputValue}
isLoading={isLoadingWarehouseOptions}
isClearable
onMenuScrollToBottom={loadMoreWarehouses}
className={{ wrapper: 'w-full' }}
/>
</div>
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
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();
}}
>
Reset Filter
</Button>
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={!formik.isValid || formik.isSubmitting}
>
Apply Filter
</Button>
</div>
</form>
</Modal>
</>
); );
}; };
@@ -0,0 +1,11 @@
import { string, object } from 'yup';
export const MovementFilterSchema = object().shape({
product_id: string().nullable(),
warehouse_id: string().nullable(),
});
export type MovementFilterType = {
product_id: string | null;
warehouse_id: string | null;
};