feat(FE): Add filter modal and skeleton components for ClosingsTable

This commit is contained in:
rstubryan
2026-02-19 14:18:22 +07:00
parent 40b3d779bc
commit 4f018eb2b1
3 changed files with 296 additions and 100 deletions
+246 -100
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { ChangeEventHandler, useEffect, useState } from 'react'; import { ChangeEventHandler, useEffect, useState, useMemo } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
@@ -8,14 +8,14 @@ import { Icon } from '@iconify/react';
import Table from '@/components/Table'; import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button'; import Button from '@/components/Button';
import SelectInput, { import SelectInput, { useSelect } from '@/components/input/SelectInput';
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import Modal, { useModal } from '@/components/Modal';
import SelectInputRadio from '@/components/input/SelectInputRadio';
import { useFormik } from 'formik';
import { cn, formatDate } from '@/lib/helper'; import { cn, formatDate } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
@@ -24,17 +24,11 @@ import { LocationApi } from '@/services/api/master-data';
import { Location } from '@/types/api/master-data/location'; import { Location } from '@/types/api/master-data/location';
import { ClosingApi } from '@/services/api/closing'; import { ClosingApi } from '@/services/api/closing';
import { Closing } from '@/types/api/closing'; import { Closing } from '@/types/api/closing';
import {
const PROJECT_STATUS_OPTIONS = [ ClosingFilterSchema,
{ ClosingFilterType,
value: 1, } from '@/components/pages/closing/filter/ClosingFilter';
label: 'Pengajuan', import ClosingTableSkeleton from '@/components/pages/closing/skeleton/ClosingTableSkeleton';
},
{
value: 2,
label: 'Aktif',
},
];
const RowOptionsMenu = ({ const RowOptionsMenu = ({
type = 'dropdown', type = 'dropdown',
@@ -63,6 +57,9 @@ const RowOptionsMenu = ({
}; };
const ClosingsTable = () => { const ClosingsTable = () => {
// ===== FILTER MODAL STATE =====
const filterModal = useModal();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -72,33 +69,64 @@ const ClosingsTable = () => {
} = useTableFilter({ } = useTableFilter({
initial: { initial: {
search: '', search: '',
nameSort: '', // nameSort: '',
transactionDate: '', // transactionDate: '',
realizationDate: '', // realizationDate: '',
locationId: '', location_id: '',
projectStatus: '', project_status: '',
userId: '', // userId: '',
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
nameSort: 'sort_name', // nameSort: 'sort_name',
transactionDate: 'transaction_date', // transactionDate: 'transaction_date',
realizationDate: 'realization_date', // realizationDate: 'realization_date',
locationId: 'location_id', // locationId: 'location_id',
projectStatus: 'project_status', // projectStatus: 'project_status',
userId: 'user_id', // userId: 'user_id',
search: 'search',
location_id: 'location_id',
project_status: 'project_status',
}, },
}); });
// ===== FORMIK SETUP =====
const formik = useFormik<ClosingFilterType>({
initialValues: {
location_id: null,
project_status: null,
},
validationSchema: ClosingFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('location_id', values.location_id || '');
updateFilter('project_status', values.project_status || '');
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('location_id', '');
updateFilter('project_status', '');
},
});
// ===== DATA FETCHING =====
const { data: closings, isLoading: isLoadingClosings } = useSWR( const { data: closings, isLoading: isLoadingClosings } = useSWR(
`${ClosingApi.basePath}${getTableFilterQueryString()}`, `${ClosingApi.basePath}${getTableFilterQueryString()}`,
ClosingApi.getAllFetcher ClosingApi.getAllFetcher
); );
const data = useMemo(
() =>
isResponseSuccess(closings) ? (closings?.data as Closing[]) || [] : [],
[closings]
);
// ===== PAGINATION & STATE =====
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({}); const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
// ===== TABLE COLUMNS =====
const closingsColumns: ColumnDef<Closing>[] = [ const closingsColumns: ColumnDef<Closing>[] = [
{ {
header: 'No', header: 'No',
@@ -163,6 +191,7 @@ const ClosingsTable = () => {
}, },
]; ];
// ===== LOCATION OPTIONS =====
const { const {
setInputValue: setLocationInputValue, setInputValue: setLocationInputValue,
options: locationOptions, options: locationOptions,
@@ -170,45 +199,72 @@ const ClosingsTable = () => {
loadMore: loadMoreLocations, loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name'); } = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>( // ===== PROJECT STATUS OPTIONS =====
null const projectStatusOptions = useMemo(
() => [
{ value: '1', label: 'Pengajuan' },
{ value: '2', label: 'Aktif' },
],
[]
); );
const locationChangeHandler = (val: OptionType | OptionType[] | null) => { // ===== FILTER HELPERS =====
setSelectedLocation(val as OptionType); const locationIdValue = useMemo(() => {
updateFilter( if (!formik.values.location_id) return null;
'locationId', return (
val ? ((val as OptionType).value as string) : '' locationOptions.find(
(opt) => String(opt.value) === formik.values.location_id
) || null
); );
}; }, [formik.values.location_id, locationOptions]);
const [selectedProjectStatus, setSelectedProjectStatus] = const projectStatusValue = useMemo(() => {
useState<OptionType | null>(null); if (!formik.values.project_status) return null;
return (
const projectStatusChangeHandler = ( projectStatusOptions.find(
val: OptionType | OptionType[] | null (opt) => opt.value === formik.values.project_status
) => { ) || null
setSelectedProjectStatus(val as OptionType);
updateFilter(
'projectStatus',
val ? ((val as OptionType).value as string) : ''
); );
}; }, [formik.values.project_status, projectStatusOptions]);
// ===== ACTIVE FILTERS COUNT =====
const activeFiltersCount = useMemo(() => {
let count = 0;
if (tableFilterState.location_id) {
count += 1;
}
if (tableFilterState.project_status) {
count += 1;
}
return count;
}, [tableFilterState.location_id, tableFilterState.project_status]);
const hasFilters = activeFiltersCount > 0;
// ===== SEARCH CHANGE HANDLER =====
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value); updateFilter('search', e.target.value);
}; };
// ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => {
filterModal.openModal();
formik.validateForm();
};
// track sorting // track sorting
useEffect(() => { useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name'); const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
if (!isNameSorted) { if (!isNameSorted) {
updateFilter('nameSort', ''); // updateFilter('nameSort', '');
} else { } else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc'); // updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
} }
}, [sorting, updateFilter]); }, [sorting]);
return ( return (
<> <>
@@ -223,62 +279,152 @@ const ClosingsTable = () => {
onChange={searchChangeHandler} onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }} className={{ wrapper: 'sm:max-w-3xs' }}
/> />
</div>
<div className='grid grid-cols-12 justify-end gap-2'> <Button
<SelectInput variant='outline'
label='Lokasi' color='none'
options={locationOptions} onClick={handleFilterModalOpen}
isLoading={isLoadingLocationOptions} className={cn(
value={selectedLocation} 'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
onChange={locationChangeHandler} {
onInputChange={setLocationInputValue} 'border-primary-gradient text-primary': hasFilters,
onMenuScrollToBottom={loadMoreLocations} }
isClearable )}
className={{ >
wrapper: 'col-span-12 sm:col-span-6', <Icon icon='heroicons:funnel' width={20} height={20} />
}} Filter
/> {hasFilters && (
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
<SelectInput {activeFiltersCount}
label='Status Project' </span>
placeholder='Pilih Status' )}
options={PROJECT_STATUS_OPTIONS} </Button>
value={selectedProjectStatus}
onChange={projectStatusChangeHandler}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6',
}}
/>
</div> </div>
</div> </div>
</div> </div>
<Table<Closing> {isLoadingClosings ? (
data={isResponseSuccess(closings) ? closings?.data : []} <div className='w-full flex flex-row justify-center items-center p-4'>
columns={closingsColumns} <span className='loading loading-spinner loading-xl' />
pageSize={tableFilterState.pageSize} </div>
onPageSizeChange={setPageSize} ) : data.length === 0 ? (
rowOptions={[10, 20, 50, 100]} <ClosingTableSkeleton
page={isResponseSuccess(closings) ? closings?.meta?.page : 0} columns={closingsColumns}
totalItems={ icon={
isResponseSuccess(closings) ? closings?.meta?.total_results : 0 <Icon
} icon='heroicons:chart-bar'
onPageChange={setPage} className='text-white'
isLoading={isLoadingClosings} width={20}
sorting={sorting} height={20}
setSorting={setSorting} />
rowSelection={rowSelection} }
setRowSelection={setRowSelection} title='Data Closing Belum Tersedia'
className={{ subtitle='Tidak ada data closing untuk saat ini.'
containerClassName: cn({ />
'w-full mb-0': ) : (
isResponseSuccess(closings) && closings?.data?.length === 0, <Table<Closing>
}), data={data}
}} columns={closingsColumns}
/> pageSize={tableFilterState.pageSize}
onPageSizeChange={setPageSize}
rowOptions={[10, 20, 50, 100]}
page={tableFilterState.page}
totalItems={
isResponseSuccess(closings) ? closings?.meta?.total_results : 0
}
onPageChange={setPage}
isLoading={isLoadingClosings}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'w-full mb-0': data.length === 0,
}),
}}
/>
)}
</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='Lokasi'
placeholder='Pilih Lokasi'
options={locationOptions}
value={locationIdValue}
onChange={(val) => {
if (!Array.isArray(val)) {
formik.setFieldValue(
'location_id',
val?.value ? String(val.value) : null
);
}
}}
onInputChange={setLocationInputValue}
isLoading={isLoadingLocationOptions}
isClearable
onMenuScrollToBottom={loadMoreLocations}
className={{ wrapper: 'w-full' }}
/>
<SelectInputRadio
label='Status Project'
placeholder='Pilih Status'
options={projectStatusOptions}
value={projectStatusValue}
onChange={(val) => {
if (!Array.isArray(val)) {
formik.setFieldValue('project_status', val?.value || null);
}
}}
className={{ wrapper: 'w-full' }}
isClearable={true}
/>
</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='reset'
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'
>
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 * as yup from 'yup';
export type ClosingFilterType = {
location_id: string | null;
project_status: string | null;
};
export const ClosingFilterSchema = yup.object({
location_id: yup.string().nullable(),
project_status: yup.string().nullable(),
});
export type ClosingFilterValues = yup.InferType<typeof ClosingFilterSchema>;
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { Closing } from '@/types/api/closing';
import { ColumnDef } from '@tanstack/react-table';
const ClosingTableSkeleton = ({
columns,
icon,
title,
subtitle,
}: {
columns: ColumnDef<Closing>[];
icon: React.ReactNode;
title: string;
subtitle: string;
}) => {
return (
<div className='relative size-full'>
<Table
data={[]}
columns={columns}
isLoading={true}
className={{
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
headerColumnClassName: 'whitespace-nowrap',
containerClassName: 'mb-0 overflow-hidden',
tableWrapperClassName: 'overflow-hidden',
}}
/>
<div className='absolute inset-0 flex items-center justify-center'>
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
</div>
</div>
);
};
export default ClosingTableSkeleton;