Merge branch 'fix/master-data-product-nonstock' into 'development'

[FIX/FE] Adjustment Product and Nonstock Match with FIFO Stock V2

See merge request mbugroup/lti-web-client!337
This commit is contained in:
Rivaldi A N S
2026-03-02 07:33:43 +00:00
75 changed files with 5067 additions and 3507 deletions
+1 -6
View File
@@ -3,12 +3,7 @@
import FinanceTable from '@/components/pages/finance/FinanceTable'; import FinanceTable from '@/components/pages/finance/FinanceTable';
const Finance = () => { const Finance = () => {
return ( return <FinanceTable />;
<section className='size-full p-6'>
<div className='flex flex-row gap-4'></div>
<FinanceTable />
</section>
);
}; };
export default Finance; export default Finance;
+1 -5
View File
@@ -1,11 +1,7 @@
import AreasTable from '@/components/pages/master-data/area/AreasTable'; import AreasTable from '@/components/pages/master-data/area/AreasTable';
const Nonstock = () => { const Nonstock = () => {
return ( return <AreasTable />;
<section className='w-full p-4'>
<AreasTable />
</section>
);
}; };
export default Nonstock; export default Nonstock;
+1 -5
View File
@@ -1,11 +1,7 @@
import BanksTable from '@/components/pages/master-data/bank/BanksTable'; import BanksTable from '@/components/pages/master-data/bank/BanksTable';
const Bank = () => { const Bank = () => {
return ( return <BanksTable />;
<section className='w-full p-4'>
<BanksTable />
</section>
);
}; };
export default Bank; export default Bank;
+1 -5
View File
@@ -1,11 +1,7 @@
import CustomersTable from '@/components/pages/master-data/customer/CustomersTable'; import CustomersTable from '@/components/pages/master-data/customer/CustomersTable';
const Customer = () => { const Customer = () => {
return ( return <CustomersTable />;
<section className='w-full p-4'>
<CustomersTable />
</section>
);
}; };
export default Customer; export default Customer;
+1 -5
View File
@@ -1,11 +1,7 @@
import FlockTable from '@/components/pages/master-data/flock/FlocksTable'; import FlockTable from '@/components/pages/master-data/flock/FlocksTable';
const Flock = () => { const Flock = () => {
return ( return <FlockTable />;
<section className='w-full p-4'>
<FlockTable />
</section>
);
}; };
export default Flock; export default Flock;
+1 -5
View File
@@ -1,11 +1,7 @@
import KandangsTable from '@/components/pages/master-data/kandang/KandangsTable'; import KandangsTable from '@/components/pages/master-data/kandang/KandangsTable';
const Nonstock = () => { const Nonstock = () => {
return ( return <KandangsTable />;
<section className='w-full p-4'>
<KandangsTable />
</section>
);
}; };
export default Nonstock; export default Nonstock;
+1 -5
View File
@@ -1,11 +1,7 @@
import LocationsTable from '@/components/pages/master-data/location/LocationsTable'; import LocationsTable from '@/components/pages/master-data/location/LocationsTable';
const Nonstock = () => { const Nonstock = () => {
return ( return <LocationsTable />;
<section className='w-full p-4'>
<LocationsTable />
</section>
);
}; };
export default Nonstock; export default Nonstock;
+1 -5
View File
@@ -1,11 +1,7 @@
import NonstocksTable from '@/components/pages/master-data/nonstock/NonstocksTable'; import NonstocksTable from '@/components/pages/master-data/nonstock/NonstocksTable';
const Nonstock = () => { const Nonstock = () => {
return ( return <NonstocksTable />;
<section className='w-full p-4'>
<NonstocksTable />
</section>
);
}; };
export default Nonstock; export default Nonstock;
@@ -1,11 +1,7 @@
import ProductCategoryTable from '@/components/pages/master-data/product-category/ProductCategoryTable'; import ProductCategoryTable from '@/components/pages/master-data/product-category/ProductCategoryTable';
const ProductCategory = () => { const ProductCategory = () => {
return ( return <ProductCategoryTable />;
<section className='w-full p-4'>
<ProductCategoryTable />
</section>
);
}; };
export default ProductCategory; export default ProductCategory;
+1 -5
View File
@@ -1,11 +1,7 @@
import ProductsTable from '@/components/pages/master-data/product/ProductTable'; import ProductsTable from '@/components/pages/master-data/product/ProductTable';
const Product = () => { const Product = () => {
return ( return <ProductsTable />;
<section className='w-full p-4'>
<ProductsTable />
</section>
);
}; };
export default Product; export default Product;
@@ -1,11 +1,7 @@
import ProductionStandardTable from '@/components/pages/master-data/production-standard/ProductionStandardTable'; import ProductionStandardTable from '@/components/pages/master-data/production-standard/ProductionStandardTable';
const ProductionStandardPage = () => { const ProductionStandardPage = () => {
return ( return <ProductionStandardTable />;
<div className='w-full'>
<ProductionStandardTable />
</div>
);
}; };
export default ProductionStandardPage; export default ProductionStandardPage;
+1 -5
View File
@@ -1,11 +1,7 @@
import SuppliersTable from '@/components/pages/master-data/supplier/SupplierTable'; import SuppliersTable from '@/components/pages/master-data/supplier/SupplierTable';
const Supplier = () => { const Supplier = () => {
return ( return <SuppliersTable />;
<section className='w-full p-4'>
<SuppliersTable />
</section>
);
}; };
export default Supplier; export default Supplier;
+1 -5
View File
@@ -1,11 +1,7 @@
import UomsTable from '@/components/pages/master-data/uom/UomsTable'; import UomsTable from '@/components/pages/master-data/uom/UomsTable';
const Nonstock = () => { const Nonstock = () => {
return ( return <UomsTable />;
<section className='w-full p-4'>
<UomsTable />
</section>
);
}; };
export default Nonstock; export default Nonstock;
+1 -5
View File
@@ -1,11 +1,7 @@
import WarehousesTable from '@/components/pages/master-data/warehouse/WarehousesTable'; import WarehousesTable from '@/components/pages/master-data/warehouse/WarehousesTable';
const Warehouse = () => { const Warehouse = () => {
return ( return <WarehousesTable />;
<section className='w-full p-4'>
<WarehousesTable />
</section>
);
}; };
export default Warehouse; export default Warehouse;
+14 -17
View File
@@ -351,19 +351,19 @@ const ClosingsTable = () => {
<span className='loading loading-spinner loading-xl' /> <span className='loading loading-spinner loading-xl' />
</div> </div>
) : data.length === 0 ? ( ) : data.length === 0 ? (
<ClosingTableSkeleton <div className='mt-3'>
columns={closingsColumns} <ClosingTableSkeleton
icon={ columns={closingsColumns}
<Icon icon={
icon='heroicons:chart-bar' <Icon
className='text-white' icon='heroicons:document-text'
width={20} className='text-white'
height={20} width={20}
/> height={20}
} />
title='Data Closing Belum Tersedia' }
subtitle='Tidak ada data closing untuk saat ini.' />
/> </div>
) : ( ) : (
<Table<Closing> <Table<Closing>
data={isResponseSuccess(closings) ? closings?.data : []} data={isResponseSuccess(closings) ? closings?.data : []}
@@ -382,10 +382,7 @@ const ClosingsTable = () => {
rowSelection={rowSelection} rowSelection={rowSelection}
setRowSelection={setRowSelection} setRowSelection={setRowSelection}
className={{ className={{
containerClassName: cn('mt-3', { containerClassName: cn('mt-3 mb-0'),
'w-full mb-0':
isResponseSuccess(closings) && closings?.data?.length === 0,
}),
headerColumnClassName: 'text-nowrap', headerColumnClassName: 'text-nowrap',
}} }}
/> />
@@ -6,13 +6,13 @@ import { ColumnDef } from '@tanstack/react-table';
const ClosingTableSkeleton = ({ const ClosingTableSkeleton = ({
columns, columns,
icon, icon,
title, title = 'No Data Available',
subtitle, subtitle = 'There is no closing data displayed. Enter closing data to get started.',
}: { }: {
columns: ColumnDef<Closing>[]; columns: ColumnDef<Closing>[];
icon: React.ReactNode; icon: React.ReactNode;
title: string; title?: string;
subtitle: string; subtitle?: string;
}) => { }) => {
return ( return (
<div className='relative size-full'> <div className='relative size-full'>
+42 -24
View File
@@ -25,6 +25,7 @@ import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWith
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import ButtonFilter from '@/components/helper/ButtonFilter'; import ButtonFilter from '@/components/helper/ButtonFilter';
import ExpensesFilterModal from '@/components/pages/expense/filter/ExpensesFilterModal'; import ExpensesFilterModal from '@/components/pages/expense/filter/ExpensesFilterModal';
import ExpenseTableSkeleton from '@/components/pages/expense/skeleton/ExpenseTableSkeleton';
import { Expense } from '@/types/api/expense'; import { Expense } from '@/types/api/expense';
import { ExpenseApi } from '@/services/api/expense'; import { ExpenseApi } from '@/services/api/expense';
@@ -692,30 +693,47 @@ const ExpensesTable = () => {
{/* Table Section */} {/* Table Section */}
<div className='flex flex-col mb-4'> <div className='flex flex-col mb-4'>
<Table<Expense> {isLoading ? (
data={isResponseSuccess(expenses) ? expenses?.data : []} <div className='w-full flex flex-row justify-center items-center p-4'>
columns={expensesColumns} <span className='loading loading-spinner loading-xl' />
pageSize={tableFilterState.pageSize} </div>
page={isResponseSuccess(expenses) ? expenses?.meta?.page : 0} ) : !isResponseSuccess(expenses) || expenses.data?.length === 0 ? (
totalItems={ <div className='p-3'>
isResponseSuccess(expenses) ? expenses?.meta?.total_results : 0 <ExpenseTableSkeleton
} columns={expensesColumns}
onPageChange={setPage} icon={
onPageSizeChange={setPageSize} <Icon
isLoading={isLoading} icon='heroicons:document-text'
sorting={sorting} className='text-white'
setSorting={setSorting} width={20}
rowSelection={rowSelection} height={20}
setRowSelection={setRowSelection} />
enableRowSelection={tableEnableRowSelectionHandler} }
className={{ />
containerClassName: cn('p-3 mb-0', { </div>
'w-full': ) : (
isResponseSuccess(expenses) && expenses?.data?.length === 0, <Table<Expense>
}), data={isResponseSuccess(expenses) ? expenses?.data : []}
headerColumnClassName: 'text-nowrap', columns={expensesColumns}
}} pageSize={tableFilterState.pageSize}
/> page={isResponseSuccess(expenses) ? expenses?.meta?.page : 0}
totalItems={
isResponseSuccess(expenses) ? expenses?.meta?.total_results : 0
}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
enableRowSelection={tableEnableRowSelectionHandler}
className={{
containerClassName: cn('p-3 mb-0'),
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div> </div>
</div> </div>
@@ -121,37 +121,40 @@ const ExpensesFilterModal = ({
{/* Modal Body */} {/* Modal Body */}
<div className='p-4 flex flex-col gap-1.5'> <div className='p-4 flex flex-col gap-1.5'>
<DateInput <div className='flex flex-col'>
label='Tanggal Transaksi' <span className='py-2 text-xs font-semibold'>Tanggal</span>
name='transaction_date' <div className='flex flex-row items-center gap-1.5'>
placeholder='Masukkan tanggal transaksi' <DateInput
value={formik.values.transaction_date || ''} name='transaction_date'
onChange={formik.handleChange} placeholder='Tanggal Transaksi'
onBlur={formik.handleBlur} value={formik.values.transaction_date || ''}
isError={ onChange={formik.handleChange}
formik.touched.transaction_date && onBlur={formik.handleBlur}
!!formik.errors.transaction_date isError={
} formik.touched.transaction_date &&
/> !!formik.errors.transaction_date
}
<DateInput />
label='Tanggal Realisasi' <hr className='w-full max-w-3 h-px border-base-content/10' />
name='realization_date' <DateInput
placeholder='Masukkan tanggal realisasi' name='realization_date'
value={formik.values.realization_date || ''} placeholder='Tanggal Realisasi'
onChange={formik.handleChange} value={formik.values.realization_date || ''}
onBlur={formik.handleBlur} onChange={formik.handleChange}
isError={ onBlur={formik.handleBlur}
formik.touched.realization_date && isError={
!!formik.errors.realization_date formik.touched.realization_date &&
} !!formik.errors.realization_date
/> }
{formik.touched.realization_date && />
formik.errors.realization_date && ( </div>
<span className='text-xs text-error'> {formik.touched.realization_date &&
{formik.errors.realization_date} formik.errors.realization_date && (
</span> <span className='text-xs text-error'>
)} {formik.errors.realization_date}
</span>
)}
</div>
<SelectInput <SelectInput
label='Lokasi' label='Lokasi'
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { Expense } from '@/types/api/expense';
import { ColumnDef } from '@tanstack/react-table';
const ExpenseTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no expense data displayed. Enter expense data to get started.',
}: {
columns: ColumnDef<Expense>[];
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 ExpenseTableSkeleton;
+464 -261
View File
@@ -1,10 +1,19 @@
import { useEffect, useMemo, useRef, useState } from 'react'; 'use client';
import { CellContext } from '@tanstack/react-table';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { CellContext, ColumnDef } from '@tanstack/react-table';
import useSWR from 'swr'; import useSWR from 'swr';
import { Icon } from '@iconify/react';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { cn, formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
import Button from '@/components/Button'; import Button from '@/components/Button';
import Card from '@/components/Card';
import DateInput from '@/components/input/DateInput'; import DateInput from '@/components/input/DateInput';
import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { import SelectInput, {
@@ -12,7 +21,6 @@ import SelectInput, {
useSelect, useSelect,
} from '@/components/input/SelectInput'; } from '@/components/input/SelectInput';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { Finance } from '@/types/api/finance/finance'; import { Finance } from '@/types/api/finance/finance';
import { import {
@@ -25,115 +33,145 @@ import { FinanceApi } from '@/services/api/finance';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { BankApi, CustomerApi, SupplierApi } from '@/services/api/master-data'; import { BankApi, CustomerApi, SupplierApi } from '@/services/api/master-data';
import { Bank } from '@/types/api/master-data/bank'; import { Bank } from '@/types/api/master-data/bank';
import { useModal } from '@/components/Modal'; import Modal, { useModal } from '@/components/Modal';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import { Icon } from '@iconify/react';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import { useUiStore } from '@/stores/ui/ui.store'; import { useUiStore } from '@/stores/ui/ui.store';
import { import {
FinanceTableFilterSchema, FinanceTableFilterSchema,
FinanceTableFilterValues, FinanceTableFilterValues,
} from './FinanceTableFilter.schema'; } from '@/components/pages/finance/filter/FinanceFilter';
import FinanceTableSkeleton from '@/components/pages/finance/skeleton/FinanceTableSkeleton';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
type = 'dropdown', popoverPosition = 'bottom',
props, props,
deleteClickHandler, deleteClickHandler,
}: { }: {
type: 'dropdown' | 'collapse'; popoverPosition: 'bottom' | 'top';
props: CellContext<Finance, unknown>; props: CellContext<Finance, unknown>;
deleteClickHandler: () => void; deleteClickHandler: () => void;
}) => { }) => {
const popoverId = `finance#${props.row.original.id}`;
const popoverAnchorName = `--anchor-finance#${props.row.original.id}`;
const closePopover = () => {
const popover = document.getElementById(popoverId) as
| HTMLDivElement
| undefined;
popover?.hidePopover?.();
};
return ( return (
<RowOptionsMenuWrapper type={type}> <div className='relative'>
<RequirePermission <PopoverButton
permissions={[ tabIndex={0}
'lti.finance.transactions.detail', variant='ghost'
'lti.finance.initial_balances.detail', color='none'
'lti.finance.injections.detail', popoverTarget={popoverId}
'lti.finance.payments.detail', anchorName={popoverAnchorName}
]}
> >
<Button <Icon icon='material-symbols:more-vert' width={16} height={16} />
href={`/finance/detail?financeId=${props.row.original.id}`} </PopoverButton>
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
</RequirePermission>
{FINANCE_TRANSACTION_STATUS.includes( <PopoverContent
props.row.original.transaction_type id={popoverId}
) && ( anchorName={popoverAnchorName}
<RequirePermission permissions='lti.finance.payments.update'> position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
<Button className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
href={`/finance/detail/edit?financeId=${props.row.original.id}`} >
variant='ghost' <div className='flex flex-col bg-base-100 rounded-xl'>
color='warning' <RequirePermission
className='justify-start text-sm' permissions={[
'lti.finance.transactions.detail',
'lti.finance.initial_balances.detail',
'lti.finance.injections.detail',
'lti.finance.payments.detail',
]}
> >
<Icon icon='material-symbols:edit-outline' width={16} height={16} /> <Button
Edit href={`/finance/detail?financeId=${props.row.original.id}`}
</Button> variant='ghost'
</RequirePermission> color='none'
)} className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon icon='heroicons:eye' width={20} height={20} />
Detail
</Button>
</RequirePermission>
{FINANCE_INITIAL_BALANCE_STATUS.includes( {FINANCE_TRANSACTION_STATUS.includes(
props.row.original.transaction_type props.row.original.transaction_type
) && ( ) && (
<RequirePermission permissions='lti.finance.initial_balances.update'> <RequirePermission permissions='lti.finance.payments.update'>
<Button <Button
href={`/finance/detail/edit/initial-balance?financeId=${props.row.original.id}`} href={`/finance/detail/edit?financeId=${props.row.original.id}`}
variant='ghost' variant='ghost'
color='warning' color='none'
className='justify-start text-sm' className='p-3 justify-start text-sm font-semibold w-full'
> onClick={closePopover}
<Icon icon='material-symbols:edit-outline' width={16} height={16} /> >
Edit <Icon icon='mdi:pencil-outline' width={20} height={20} />
</Button> Edit
</RequirePermission> </Button>
)} </RequirePermission>
)}
{FINANCE_INJECTION_STATUS.includes( {FINANCE_INITIAL_BALANCE_STATUS.includes(
props.row.original.transaction_type props.row.original.transaction_type
) && ( ) && (
<RequirePermission permissions='lti.finance.injections.update'> <RequirePermission permissions='lti.finance.initial_balances.update'>
<Button <Button
href={`/finance/detail/edit/injection?financeId=${props.row.original.id}`} href={`/finance/detail/edit/initial-balance?financeId=${props.row.original.id}`}
variant='ghost' variant='ghost'
color='warning' color='none'
className='justify-start text-sm' className='p-3 justify-start text-sm font-semibold w-full'
> onClick={closePopover}
<Icon icon='material-symbols:edit-outline' width={16} height={16} /> >
Edit <Icon icon='mdi:pencil-outline' width={20} height={20} />
</Button> Edit
</RequirePermission> </Button>
)} </RequirePermission>
)}
<RequirePermission permissions='lti.finance.transactions.delete'> {FINANCE_INJECTION_STATUS.includes(
<Button props.row.original.transaction_type
onClick={deleteClickHandler} ) && (
variant='ghost' <RequirePermission permissions='lti.finance.injections.update'>
color='error' <Button
className='text-error hover:text-inherit' href={`/finance/detail/edit/injection?financeId=${props.row.original.id}`}
> variant='ghost'
<Icon color='none'
icon='material-symbols:delete-outline-rounded' className='p-3 justify-start text-sm font-semibold w-full'
width={16} onClick={closePopover}
height={16} >
className='justify-start text-sm' <Icon icon='mdi:pencil-outline' width={20} height={20} />
/> Edit
Delete </Button>
</Button> </RequirePermission>
</RequirePermission> )}
</RowOptionsMenuWrapper>
<RequirePermission permissions='lti.finance.transactions.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='error'
className='p-3 justify-start text-sm font-semibold w-full focus-visible:text-error-content hover:text-error-content'
>
<Icon icon='mdi:delete-outline' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
); );
}; };
@@ -171,6 +209,9 @@ const FinanceTable = () => {
}, },
}); });
// ===== FILTER MODAL STATE =====
const filterModal = useModal();
// ===== State ===== // ===== State =====
const deleteModal = useModal(); const deleteModal = useModal();
const [selectedTransactionType, setSelectedTransactionType] = useState< const [selectedTransactionType, setSelectedTransactionType] = useState<
@@ -215,6 +256,18 @@ const FinanceTable = () => {
updateFilter('sortBy', values.sort_by); updateFilter('sortBy', values.sort_by);
updateFilter('startDate', values.start_date); updateFilter('startDate', values.start_date);
updateFilter('endDate', values.end_date); updateFilter('endDate', values.end_date);
filterModal.closeModal();
},
onReset: () => {
updateFilter('search', '');
resetSearchValue();
updateFilter('transactionTypes', '');
updateFilter('bankIds', '');
updateFilter('customerIds', '');
updateFilter('supplierIds', '');
updateFilter('sortBy', '');
updateFilter('startDate', '');
updateFilter('endDate', '');
}, },
}); });
@@ -267,10 +320,41 @@ const FinanceTable = () => {
}); });
}, [bankOptions, bankRawData]); }, [bankOptions, bankRawData]);
// ===== ACTIVE FILTERS COUNT =====
const activeFiltersCount = useMemo(() => {
let count = 0;
if (tableFilterState.transactionTypes) count += 1;
if (tableFilterState.bankIds) count += 1;
if (tableFilterState.customerIds) count += 1;
if (tableFilterState.supplierIds) count += 1;
if (tableFilterState.sortBy) count += 1;
if (tableFilterState.startDate) count += 1;
if (tableFilterState.endDate) count += 1;
return count;
}, [
tableFilterState.transactionTypes,
tableFilterState.bankIds,
tableFilterState.customerIds,
tableFilterState.supplierIds,
tableFilterState.sortBy,
tableFilterState.startDate,
tableFilterState.endDate,
]);
const hasFilters = activeFiltersCount > 0;
// ===== Handler ===== // ===== Handler =====
const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => { const searchChangeHandler = useCallback(
filterFormik.setFieldValue('search', e.target.value); (e: React.ChangeEvent<HTMLInputElement>) => {
}; updateFilter('search', e.target.value);
setSearchValue(e.target.value);
setPage(1);
},
[updateFilter, setSearchValue, setPage]
);
const transactionTypeChangeHandler = ( const transactionTypeChangeHandler = (
val: OptionType | OptionType[] | null val: OptionType | OptionType[] | null
) => { ) => {
@@ -384,6 +468,11 @@ const FinanceTable = () => {
} }
}; };
const handleFilterModalOpen = () => {
filterModal.openModal();
filterFormik.validateForm();
};
const resetFilterHandler = () => { const resetFilterHandler = () => {
setSelectedTransactionType(null); setSelectedTransactionType(null);
setSelectedBank(null); setSelectedBank(null);
@@ -403,6 +492,7 @@ const FinanceTable = () => {
updateFilter('startDate', ''); updateFilter('startDate', '');
updateFilter('endDate', ''); updateFilter('endDate', '');
}; };
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
@@ -414,8 +504,8 @@ const FinanceTable = () => {
setIsDeleteLoading(false); setIsDeleteLoading(false);
}; };
const columns = useMemo(() => { const columns: ColumnDef<Finance>[] = useMemo(
return [ () => [
{ {
header: 'ID', header: 'ID',
accessorKey: 'payment_code', accessorKey: 'payment_code',
@@ -495,32 +585,17 @@ const FinanceTable = () => {
}; };
return ( return (
<> <RowOptionsMenu
{currentPageSize > 2 && ( props={props}
<RowDropdownOptions isLast2Rows={isLast2Rows}> deleteClickHandler={deleteClickHandler}
<RowOptionsMenu popoverPosition={isLast2Rows ? 'top' : 'bottom'}
type='dropdown' />
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
); );
}, },
}, },
]; ],
}, []); []
);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -552,152 +627,280 @@ const FinanceTable = () => {
}, [resetSearchValue, dateErrorShown]); }, [resetSearchValue, dateErrorShown]);
return ( return (
<section className='size-full flex flex-col gap-6'> <>
<div className='flex justify-end gap-2'> <div className='w-full'>
<RequirePermission permissions='lti.finance.injections.create'> {/* Header Section */}
<Button <div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
color='warning' {/* Action Buttons */}
className='min-w-24' <div className='w-fit flex flex-row gap-3 flex-wrap'>
href='/finance/add/injection' <RequirePermission permissions='lti.finance.injections.create'>
> <Button
Injection Saldo Bank href='/finance/add/injection'
</Button> color='warning'
</RequirePermission> className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
<RequirePermission permissions='lti.finance.initial_balances.create'> >
<Button <Icon icon='mdi:bank-transfer-in' width={20} height={20} />
color='info' Add Injection (Saldo Bank)
className='text-white min-w-24' </Button>
href='/finance/add/initial-balance' </RequirePermission>
> <RequirePermission permissions='lti.finance.initial_balances.create'>
Saldo Awal <Button
</Button> href='/finance/add/initial-balance'
</RequirePermission> color='info'
<RequirePermission permissions='lti.finance.payments.create'> className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
<Button color='primary' className='min-w-24' href='/finance/add'> >
Tambah <Icon icon='mdi:cash-register' width={20} height={20} />
</Button> Add Initial Balance
</RequirePermission> </Button>
</div> </RequirePermission>
<Card <RequirePermission permissions='lti.finance.payments.create'>
variant='bordered' <Button
className={{ href='/finance/add'
wrapper: 'w-full', color='primary'
}} className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
footer={ >
<div className='flex justify-end gap-2'> <Icon icon='heroicons:plus' width={20} height={20} />
Add Finance
</Button>
</RequirePermission>
</div>
{/* Search and Filter */}
<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',
}}
/>
<Button <Button
color='warning' variant='outline'
className='min-w-24' color='none'
onClick={resetFilterHandler} onClick={handleFilterModalOpen}
className={cn(
'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',
{
'border-primary-gradient text-primary': hasFilters,
}
)}
> >
Reset <Icon icon='heroicons:funnel' width={20} height={20} />
</Button> Filter
<Button {hasFilters && (
color='primary' <span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
className='min-w-24' {activeFiltersCount}
onClick={() => filterFormik.handleSubmit()} </span>
> )}
Cari
</Button> </Button>
</div> </div>
}
>
<div className='grid grid-cols-4 gap-6'>
<SelectInput
options={FINANCE_TRANSACTION_TYPE_OPTIONS}
label='Jenis Transaksi'
value={selectedTransactionType}
onChange={transactionTypeChangeHandler}
closeMenuOnSelect={false}
isClearable
isMulti
/>
<SelectInput
options={customerOptions}
label={'Customer'}
value={selectedCustomerId}
onChange={customerIdChangeHandler}
onInputChange={customerInputValue}
onMenuScrollToBottom={customerLoadMore}
isLoading={customerIsLoadingOptions}
closeMenuOnSelect={false}
isClearable
isMulti
/>
<SelectInput
options={supplierOptions}
label={'Supplier'}
value={selectedSupplierId}
onChange={supplierIdChangeHandler}
onInputChange={supplierInputValue}
onMenuScrollToBottom={supplierLoadMore}
isLoading={supplierIsLoadingOptions}
closeMenuOnSelect={false}
isClearable
isMulti
/>
<SelectInput
options={bankSelectOptions}
label='Bank'
value={selectedBank}
onChange={bankChangeHandler}
onInputChange={bankInputValue}
onMenuScrollToBottom={bankLoadMore}
closeMenuOnSelect={false}
isClearable
isMulti
/>
<SelectInput
options={sortByOptions}
label='Urutkan Berdasarkan'
value={selectedSortBy}
onChange={sortByChangeHandler}
isClearable
/>
<DateInput
name='start_date'
label='Periode Tanggal (Mulai)'
value={filterFormik.values.start_date}
errorMessage={filterFormik.errors.start_date}
onChange={startDateChangeHandler}
isError={
filterFormik.touched.start_date &&
Boolean(filterFormik.errors.start_date)
}
/>
<DateInput
name='end_date'
label='Periode Tanggal (Akhir)'
value={filterFormik.values.end_date}
errorMessage={filterFormik.errors.end_date}
onChange={endDateChangeHandler}
isError={
(filterFormik.touched.end_date &&
Boolean(filterFormik.errors.end_date)) ||
hasDateError
}
/>
<DebouncedTextInput
name='search'
label='Cari'
placeholder='Cari'
value={filterFormik.values.search}
onChange={searchChangeHandler}
/>
</div> </div>
</Card>
<Table<Finance> {/* Table Section */}
data={isResponseSuccess(finances) ? finances.data : []} <div className='flex flex-col mb-4'>
columns={columns} {isLoading ? (
pageSize={tableFilterState.pageSize} <div className='w-full flex flex-row justify-center items-center p-4'>
page={tableFilterState.page} <span className='loading loading-spinner loading-xl' />
onPageChange={setPage} </div>
onPageSizeChange={setPageSize} ) : !isResponseSuccess(finances) || finances.data?.length === 0 ? (
totalItems={ <div className='p-3'>
isResponseSuccess(finances) ? finances.meta?.total_results : 0 <FinanceTableSkeleton
} columns={columns}
isLoading={isLoading} icon={
/> <Icon
icon='heroicons:document-text'
className='text-white'
width={20}
height={20}
/>
}
/>
</div>
) : (
<Table<Finance>
data={isResponseSuccess(finances) ? finances.data : []}
columns={columns}
pageSize={tableFilterState.pageSize}
page={tableFilterState.page}
totalItems={
isResponseSuccess(finances) ? finances.meta?.total_results : 0
}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoading}
className={{
containerClassName: cn('p-3 mb-0'),
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</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={filterFormik.handleSubmit}
onReset={filterFormik.handleReset}
>
<div className='p-4 flex flex-col gap-1.5'>
<div className='flex flex-col'>
<span className='py-2 text-xs font-semibold'>Tanggal</span>
<div className='flex flex-row items-center gap-1.5'>
<DateInput
name='start_date'
placeholder='Periode Tanggal Awal'
value={filterFormik.values.start_date}
errorMessage={filterFormik.errors.start_date}
onChange={startDateChangeHandler}
isError={
filterFormik.touched.start_date &&
Boolean(filterFormik.errors.start_date)
}
/>
<hr className='w-full max-w-3 h-px border-base-content/10' />
<DateInput
name='end_date'
placeholder='Periode Tanggal Akhir'
value={filterFormik.values.end_date}
errorMessage={filterFormik.errors.end_date}
onChange={endDateChangeHandler}
isError={
(filterFormik.touched.end_date &&
Boolean(filterFormik.errors.end_date)) ||
hasDateError
}
/>
</div>
</div>
<SelectInput
options={FINANCE_TRANSACTION_TYPE_OPTIONS}
label='Jenis Transaksi'
placeholder='Pilih Jenis Transaksi'
value={selectedTransactionType}
onChange={transactionTypeChangeHandler}
onInputChange={() => {}}
closeMenuOnSelect={false}
isClearable
isMulti
className={{ wrapper: 'w-full' }}
/>
<SelectInput
options={customerOptions}
label='Customer'
placeholder='Pilih Customer'
value={selectedCustomerId}
onChange={customerIdChangeHandler}
onInputChange={customerInputValue}
onMenuScrollToBottom={customerLoadMore}
isLoading={customerIsLoadingOptions}
closeMenuOnSelect={false}
isClearable
isMulti
className={{ wrapper: 'w-full' }}
/>
<SelectInput
options={supplierOptions}
label='Supplier'
placeholder='Pilih Supplier'
value={selectedSupplierId}
onChange={supplierIdChangeHandler}
onInputChange={supplierInputValue}
onMenuScrollToBottom={supplierLoadMore}
isLoading={supplierIsLoadingOptions}
closeMenuOnSelect={false}
isClearable
isMulti
className={{ wrapper: 'w-full' }}
/>
<SelectInput
options={bankSelectOptions}
label='Bank'
placeholder='Pilih Bank'
value={selectedBank}
onChange={bankChangeHandler}
onInputChange={bankInputValue}
onMenuScrollToBottom={bankLoadMore}
closeMenuOnSelect={false}
isClearable
isMulti
className={{ wrapper: 'w-full' }}
/>
<SelectInput
options={sortByOptions}
label='Urutkan Berdasarkan'
placeholder='Pilih Urutan'
value={selectedSortBy}
onChange={sortByChangeHandler}
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={() => {
filterFormik.resetForm();
setSelectedTransactionType(null);
setSelectedBank(null);
setSelectedCustomerId(null);
setSelectedSupplierId(null);
setSelectedSortBy(null);
resetFilterHandler();
filterModal.closeModal();
}}
>
Reset Filter
</Button>
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={!filterFormik.isValid || filterFormik.isSubmitting}
>
Apply Filter
</Button>
</div>
</form>
</Modal>
<ConfirmationModal <ConfirmationModal
ref={deleteModal.ref} ref={deleteModal.ref}
type='error' type='error'
@@ -712,7 +915,7 @@ const FinanceTable = () => {
onClick: confirmationModalDeleteClickHandler, onClick: confirmationModalDeleteClickHandler,
}} }}
/> />
</section> </>
); );
}; };
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { Finance } from '@/types/api/finance/finance';
import { ColumnDef } from '@tanstack/react-table';
const FinanceTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no finance data displayed. Enter finance data to get started.',
}: {
columns: ColumnDef<Finance>[];
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 FinanceTableSkeleton;
@@ -13,6 +13,7 @@ import { InventoryAdjustmentApi } from '@/services/api/inventory';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { InventoryAdjustment } from '@/types/api/inventory/adjustment'; import { InventoryAdjustment } from '@/types/api/inventory/adjustment';
import StatusBadge from '@/components/helper/StatusBadge'; import StatusBadge from '@/components/helper/StatusBadge';
import InventoryAdjustmentTableSkeleton from '@/components/pages/inventory/adjustment/skeleton/InventoryAdjustmentTableSkeleton';
const InventoryAdjustmentTable = () => { const InventoryAdjustmentTable = () => {
const { const {
@@ -192,38 +193,55 @@ const InventoryAdjustmentTable = () => {
{/* Table Section */} {/* Table Section */}
<div className='flex flex-col mb-4'> <div className='flex flex-col mb-4'>
<Table<InventoryAdjustment> {isLoading ? (
data={ <div className='w-full flex flex-row justify-center items-center p-4'>
isResponseSuccess(inventoryAdjustments) <span className='loading loading-spinner loading-xl' />
? inventoryAdjustments?.data </div>
: [] ) : !isResponseSuccess(inventoryAdjustments) ||
} inventoryAdjustments.data?.length === 0 ? (
columns={inventoryAdjustmentsColumns} <div className='p-3'>
pageSize={tableFilterState.pageSize} <InventoryAdjustmentTableSkeleton
page={ columns={inventoryAdjustmentsColumns}
isResponseSuccess(inventoryAdjustments) icon={
? inventoryAdjustments?.meta?.page <Icon
: 0 icon='heroicons:document-text'
} className='text-white'
totalItems={ width={20}
isResponseSuccess(inventoryAdjustments) height={20}
? inventoryAdjustments?.meta?.total_results />
: 0 }
} />
onPageChange={setPage} </div>
onPageSizeChange={setPageSize} ) : (
isLoading={isLoading} <Table<InventoryAdjustment>
sorting={sorting} data={
setSorting={setSorting} isResponseSuccess(inventoryAdjustments)
className={{ ? inventoryAdjustments?.data
containerClassName: cn('p-3 mb-0', { : []
'w-full': }
isResponseSuccess(inventoryAdjustments) && columns={inventoryAdjustmentsColumns}
inventoryAdjustments?.data?.length === 0, pageSize={tableFilterState.pageSize}
}), page={
headerColumnClassName: 'text-nowrap', 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>
); );
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { InventoryAdjustment } from '@/types/api/inventory/adjustment';
import { ColumnDef } from '@tanstack/react-table';
const InventoryAdjustmentTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no inventory adjustment data displayed. Enter inventory adjustment data to get started.',
}: {
columns: ColumnDef<InventoryAdjustment>[];
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 InventoryAdjustmentTableSkeleton;
@@ -16,6 +16,7 @@ import DebouncedTextInput from '@/components/input/DebouncedTextInput';
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';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
popoverPosition = 'bottom', popoverPosition = 'bottom',
@@ -198,27 +199,44 @@ const MovementTable = () => {
{/* Table Section */} {/* Table Section */}
<div className='flex flex-col mb-4'> <div className='flex flex-col mb-4'>
<Table<Movement> {isLoading ? (
data={isResponseSuccess(movements) ? movements?.data : []} <div className='w-full flex flex-row justify-center items-center p-4'>
columns={movementColumns} <span className='loading loading-spinner loading-xl' />
pageSize={tableFilterState.pageSize} </div>
page={isResponseSuccess(movements) ? movements?.meta?.page : 0} ) : !isResponseSuccess(movements) || movements.data?.length === 0 ? (
totalItems={ <div className='p-3'>
isResponseSuccess(movements) ? movements?.meta?.total_results : 0 <MovementTableSkeleton
} columns={movementColumns}
onPageChange={setPage} icon={
onPageSizeChange={setPageSize} <Icon
isLoading={isLoading} icon='heroicons:document-text'
sorting={sorting} className='text-white'
setSorting={setSorting} width={20}
className={{ height={20}
containerClassName: cn('p-3 mb-0', { />
'w-full': }
isResponseSuccess(movements) && movements?.data?.length === 0, />
}), </div>
headerColumnClassName: 'text-nowrap', ) : (
}} <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>
); );
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { Movement } from '@/types/api/inventory/movement';
import { ColumnDef } from '@tanstack/react-table';
const MovementTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no movement data displayed. Enter movement data to get started.',
}: {
columns: ColumnDef<Movement>[];
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 MovementTableSkeleton;
@@ -15,6 +15,7 @@ import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
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 InventoryProductTableSkeleton from '@/components/pages/inventory/product/skeleton/InventoryProductTableSkeleton';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
popoverPosition = 'bottom', popoverPosition = 'bottom',
@@ -206,36 +207,55 @@ const InventoryProductTable = () => {
{/* Table Section */} {/* Table Section */}
<div className='flex flex-col mb-4'> <div className='flex flex-col mb-4'>
<Table<InventoryProduct> {isLoading ? (
data={ <div className='w-full flex flex-row justify-center items-center p-4'>
isResponseSuccess(inventoryProducts) ? inventoryProducts?.data : [] <span className='loading loading-spinner loading-xl' />
} </div>
columns={columns} ) : !isResponseSuccess(inventoryProducts) ||
pageSize={tableFilterState.pageSize} inventoryProducts.data?.length === 0 ? (
page={ <div className='p-3'>
isResponseSuccess(inventoryProducts) <InventoryProductTableSkeleton
? inventoryProducts?.meta?.page columns={columns}
: 0 icon={
} <Icon
totalItems={ icon='heroicons:document-text'
isResponseSuccess(inventoryProducts) className='text-white'
? inventoryProducts?.meta?.total_results width={20}
: 0 height={20}
} />
onPageChange={setPage} }
onPageSizeChange={setPageSize} />
isLoading={isLoading} </div>
sorting={sorting} ) : (
setSorting={setSorting} <Table<InventoryProduct>
className={{ data={
containerClassName: cn('p-3 mb-0', { isResponseSuccess(inventoryProducts)
'w-full': ? inventoryProducts?.data
isResponseSuccess(inventoryProducts) && : []
inventoryProducts?.data?.length === 0, }
}), columns={columns}
headerColumnClassName: 'text-nowrap', pageSize={tableFilterState.pageSize}
}} page={
/> isResponseSuccess(inventoryProducts)
? inventoryProducts?.meta?.page
: 0
}
totalItems={
isResponseSuccess(inventoryProducts)
? inventoryProducts?.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>
); );
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { InventoryProduct } from '@/types/api/inventory/product';
import { ColumnDef } from '@tanstack/react-table';
const InventoryProductTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no inventory product data displayed. Enter inventory product data to get started.',
}: {
columns: ColumnDef<InventoryProduct>[];
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 InventoryProductTableSkeleton;
@@ -31,6 +31,7 @@ import PopoverContent from '@/components/popover/PopoverContent';
import StatusBadge from '@/components/helper/StatusBadge'; import StatusBadge from '@/components/helper/StatusBadge';
import MarketingFilterModal from '@/components/pages/marketing/MarketingFilter'; import MarketingFilterModal from '@/components/pages/marketing/MarketingFilter';
import ButtonFilter from '@/components/helper/ButtonFilter'; import ButtonFilter from '@/components/helper/ButtonFilter';
import MarketingTableSkeleton from '@/components/pages/marketing/skeleton/MarketingTableSkeleton';
const RowsOptionsMenu = ({ const RowsOptionsMenu = ({
props, props,
@@ -616,28 +617,49 @@ const MarketingTable = () => {
</Dropdown> </Dropdown>
</div> </div>
</div> </div>
<Table <div className='flex flex-col mb-4'>
rowSelection={rowSelection} {isLoadingMarketing ? (
setRowSelection={setRowSelection} <div className='w-full flex flex-row justify-center items-center p-4'>
onPageChange={setPage} <span className='loading loading-spinner loading-xl' />
onPageSizeChange={setPageSize} </div>
data={allData} ) : !isResponseSuccess(marketing) || marketing.data?.length === 0 ? (
columns={columns} <div className='p-3'>
pageSize={tableFilterState.pageSize} <MarketingTableSkeleton
page={isResponseSuccess(marketing) ? marketing?.meta?.page : 1} columns={columns}
totalItems={ icon={
isResponseSuccess(marketing) ? marketing?.meta?.total_results : 0 <Icon
} icon='heroicons:document-text'
isLoading={isLoadingMarketing} className='text-white'
className={{ width={20}
containerClassName: cn('p-3', { height={20}
'w-full mb-20': />
isResponseSuccess(marketing) && marketing?.data?.length === 0, }
}), />
bodyColumnClassName: </div>
'last:text-end last:w-17 first:text-start first:w-5', ) : (
}} <Table
/> rowSelection={rowSelection}
setRowSelection={setRowSelection}
onPageChange={setPage}
onPageSizeChange={setPageSize}
data={allData}
columns={columns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(marketing) ? marketing?.meta?.page : 1}
totalItems={
isResponseSuccess(marketing)
? marketing?.meta?.total_results
: 0
}
isLoading={isLoadingMarketing}
className={{
containerClassName: cn('p-3 mb-0'),
bodyColumnClassName:
'last:text-end last:w-17 first:text-start first:w-5',
}}
/>
)}
</div>
</div> </div>
<ConfirmationModal <ConfirmationModal
ref={deleteModal.ref} ref={deleteModal.ref}
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { Marketing } from '@/types/api/marketing/marketing';
import { ColumnDef } from '@tanstack/react-table';
const MarketingTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no marketing data displayed. Enter marketing data to get started.',
}: {
columns: ColumnDef<Marketing>[];
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 MarketingTableSkeleton;
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { ChangeEventHandler, useEffect, useState } from 'react'; import { ChangeEventHandler, useMemo, useState } 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';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -11,71 +11,92 @@ import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import PopoverButton from '@/components/popover/PopoverButton';
import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import PopoverContent from '@/components/popover/PopoverContent';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import AreaTableSkeleton from '@/components/pages/master-data/area/skeleton/AreaTableSkeleton';
import { Area } from '@/types/api/master-data/area'; import { Area } from '@/types/api/master-data/area';
import { AreaApi } from '@/services/api/master-data'; import { AreaApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
type = 'dropdown', popoverPosition = 'bottom',
props, props,
deleteClickHandler, deleteClickHandler,
}: { }: {
type: 'dropdown' | 'collapse'; popoverPosition: 'bottom' | 'top';
props: CellContext<Area, unknown>; props: CellContext<Area, unknown>;
deleteClickHandler: () => void; deleteClickHandler: () => void;
}) => { }) => {
const popoverId = `area#${props.row.original.id}`;
const popoverAnchorName = `--anchor-area#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
return ( return (
<RowOptionsMenuWrapper type={type}> <div className='relative'>
<RequirePermission permissions='lti.master.area.detail'> <PopoverButton
<Button tabIndex={0}
href={`/master-data/area/detail/?areaId=${props.row.original.id}`} variant='ghost'
variant='ghost' color='none'
color='primary' popoverTarget={popoverId}
className='justify-start text-sm' anchorName={popoverAnchorName}
> >
<Icon icon='mdi:eye-outline' width={16} height={16} /> <Icon icon='material-symbols:more-vert' width={16} height={16} />
Detail </PopoverButton>
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.area.update'> <PopoverContent
<Button id={popoverId}
href={`/master-data/area/detail/edit/?areaId=${props.row.original.id}`} anchorName={popoverAnchorName}
variant='ghost' position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
color='warning' className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
className='justify-start text-sm' >
> <div className='flex flex-col bg-base-100 rounded-xl'>
<Icon icon='material-symbols:edit-outline' width={16} height={16} /> <RequirePermission permissions='lti.master.area.detail'>
Edit <Button
</Button> href={`/master-data/area/detail/?areaId=${props.row.original.id}`}
</RequirePermission> variant='ghost'
color='none'
<RequirePermission permissions='lti.master.area.delete'> className='p-3 justify-start text-sm font-semibold w-full'
<Button onClick={closePopover}
onClick={deleteClickHandler} >
variant='ghost' <Icon icon='heroicons:eye' width={20} height={20} />
color='error' Detail
className='text-error hover:text-inherit' </Button>
> </RequirePermission>
<Icon <RequirePermission permissions='lti.master.area.update'>
icon='material-symbols:delete-outline-rounded' <Button
width={16} href={`/master-data/area/detail/edit/?areaId=${props.row.original.id}`}
height={16} variant='ghost'
className='justify-start text-sm' color='none'
/> className='p-3 justify-start text-sm font-semibold w-full'
Delete onClick={closePopover}
</Button> >
</RequirePermission> <Icon icon='heroicons:pencil-square' width={20} height={20} />
</RowOptionsMenuWrapper> Edit
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.area.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
>
<Icon icon='heroicons:trash' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
); );
}; };
@@ -87,10 +108,17 @@ const AreasTable = () => {
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter({
initial: { search: '', nameSort: '' }, initial: {
paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' }, search: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
}); });
const [sorting, setSorting] = useState<SortingState>([]);
const { const {
data: areas, data: areas,
isLoading, isLoading,
@@ -101,65 +129,12 @@ const AreasTable = () => {
); );
const deleteModal = useModal(); const deleteModal = useModal();
const [selectedArea, setSelectedArea] = useState<Area | undefined>(undefined); const [selectedArea, setSelectedArea] = useState<Area | undefined>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]); const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
const areasColumns: ColumnDef<Area>[] = [ };
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedArea(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
@@ -179,95 +154,132 @@ const AreasTable = () => {
setIsDeleteLoading(false); setIsDeleteLoading(false);
}; };
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const areasColumns: ColumnDef<Area>[] = useMemo(
updateFilter('search', e.target.value); () => [
}; {
header: 'No',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
header: 'Aksi',
cell: (props: CellContext<Area, unknown>) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const newVal = val as OptionType;
setPageSize(newVal.value as number); const deleteClickHandler = () => {
}; setSelectedArea(props.row.original);
deleteModal.openModal();
};
// track sorting return (
useEffect(() => { <RowOptionsMenu
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name'); props={props}
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
if (!isNameSorted) { deleteClickHandler={deleteClickHandler}
updateFilter('nameSort', ''); />
} else { );
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc'); },
} },
}, [sorting, updateFilter]); ],
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
);
return ( return (
<> <>
<div className='w-full p-0 sm:p-4'> <div className='w-full'>
<div className='flex flex-col gap-2 mb-4'> {/* Header Section */}
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'> <div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
<div className='w-full flex flex-row'> {/* Action Buttons */}
<div className='w-full flex flex-row'> <div className='w-fit flex flex-row gap-3 flex-wrap'>
<RequirePermission permissions='lti.master.area.create'> <RequirePermission permissions='lti.master.area.create'>
<Button <Button
href='/master-data/area/add' href='/master-data/area/add'
variant='outline' color='primary'
color='primary' className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
className='w-full sm:w-fit' >
> <Icon icon='heroicons:plus' width={20} height={20} />
<Icon icon='ic:round-plus' width={24} height={24} /> Add Area
Tambah </Button>
</Button> </RequirePermission>
</RequirePermission> </div>
</div>
</div>
{/* Search */}
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<DebouncedTextInput <DebouncedTextInput
name='search' name='search'
placeholder='Cari Area' placeholder='Cari Area'
value={tableFilterState.search} value={tableFilterState.search ?? ''}
onChange={searchChangeHandler} onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }} startAdornment={
/> <Icon
</div> icon='heroicons:magnifying-glass'
width={20}
<div className='flex flex-row justify-end'> height={20}
<SelectInput />
label='Baris' }
options={ROWS_OPTIONS} className={{
value={{ wrapper: 'w-full min-w-24 max-w-3xs',
label: String(tableFilterState.pageSize), inputWrapper: 'rounded-xl! shadow-button-soft',
value: tableFilterState.pageSize, input:
'placeholder:font-semibold placeholder:text-base-content/50',
}} }}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/> />
</div> </div>
</div> </div>
<Table<Area> {/* Table Section */}
data={isResponseSuccess(areas) ? areas?.data : []} <div className='flex flex-col mb-4'>
columns={areasColumns} {isLoading ? (
pageSize={tableFilterState.pageSize} <div className='w-full flex flex-row justify-center items-center p-4'>
page={isResponseSuccess(areas) ? areas?.meta?.page : 0} <span className='loading loading-spinner loading-xl' />
totalItems={isResponseSuccess(areas) ? areas?.meta?.total_results : 0} </div>
onPageChange={setPage} ) : !isResponseSuccess(areas) || areas.data?.length === 0 ? (
isLoading={isLoading} <div className='p-3'>
sorting={sorting} <AreaTableSkeleton
setSorting={setSorting} columns={areasColumns}
className={{ icon={
containerClassName: cn({ <Icon
'mb-20': isResponseSuccess(areas) && areas?.data?.length === 0, icon='heroicons:document-text'
}), className='text-white'
tableWrapperClassName: 'overflow-x-auto min-h-full!', width={20}
tableClassName: 'font-inter w-full table-auto min-h-full!', height={20}
headerRowClassName: 'border-b border-b-gray-200', />
headerColumnClassName: }
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', />
bodyRowClassName: 'border-b border-b-gray-200', </div>
bodyColumnClassName: ) : (
'px-6 py-3 last:flex last:flex-row last:justify-end', <Table<Area>
}} data={isResponseSuccess(areas) ? areas?.data : []}
/> columns={areasColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(areas) ? areas?.meta?.page : 0}
totalItems={
isResponseSuccess(areas) ? areas?.meta?.total_results : 0
}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: 'p-3 mb-0',
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div>
</div> </div>
<ConfirmationModal <ConfirmationModal
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { Area } from '@/types/api/master-data/area';
import { ColumnDef } from '@tanstack/react-table';
const AreaTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no area data displayed. Enter area data to get started.',
}: {
columns: ColumnDef<Area>[];
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 AreaTableSkeleton;
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { ChangeEventHandler, useEffect, useState } from 'react'; import { ChangeEventHandler, useMemo, useState } 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';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -11,71 +11,92 @@ import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import BankTableSkeleton from '@/components/pages/master-data/bank/skeleton/BankTableSkeleton';
import { Bank } from '@/types/api/master-data/bank'; import { Bank } from '@/types/api/master-data/bank';
import { BankApi } from '@/services/api/master-data'; import { BankApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
type = 'dropdown', popoverPosition = 'bottom',
props, props,
deleteClickHandler, deleteClickHandler,
}: { }: {
type: 'dropdown' | 'collapse'; popoverPosition: 'bottom' | 'top';
props: CellContext<Bank, unknown>; props: CellContext<Bank, unknown>;
deleteClickHandler: () => void; deleteClickHandler: () => void;
}) => { }) => {
const popoverId = `bank#${props.row.original.id}`;
const popoverAnchorName = `--anchor-bank#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
return ( return (
<RowOptionsMenuWrapper type={type}> <div className='relative'>
<RequirePermission permissions='lti.master.banks.detail'> <PopoverButton
<Button tabIndex={0}
href={`/master-data/bank/detail/?bankId=${props.row.original.id}`} variant='ghost'
variant='ghost' color='none'
color='primary' popoverTarget={popoverId}
className='justify-start text-sm' anchorName={popoverAnchorName}
> >
<Icon icon='mdi:eye-outline' width={16} height={16} /> <Icon icon='material-symbols:more-vert' width={16} height={16} />
Detail </PopoverButton>
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.banks.update'> <PopoverContent
<Button id={popoverId}
href={`/master-data/bank/detail/edit/?bankId=${props.row.original.id}`} anchorName={popoverAnchorName}
variant='ghost' position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
color='warning' className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
className='justify-start text-sm' >
> <div className='flex flex-col bg-base-100 rounded-xl'>
<Icon icon='material-symbols:edit-outline' width={16} height={16} /> <RequirePermission permissions='lti.master.banks.detail'>
Edit <Button
</Button> href={`/master-data/bank/detail/?bankId=${props.row.original.id}`}
</RequirePermission> variant='ghost'
color='none'
<RequirePermission permissions='lti.master.banks.delete'> className='p-3 justify-start text-sm font-semibold w-full'
<Button onClick={closePopover}
onClick={deleteClickHandler} >
variant='ghost' <Icon icon='heroicons:eye' width={20} height={20} />
color='error' Detail
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content' </Button>
> </RequirePermission>
<Icon <RequirePermission permissions='lti.master.banks.update'>
icon='material-symbols:delete-outline-rounded' <Button
width={16} href={`/master-data/bank/detail/edit/?bankId=${props.row.original.id}`}
height={16} variant='ghost'
className='justify-start text-sm' color='none'
/> className='p-3 justify-start text-sm font-semibold w-full'
Delete onClick={closePopover}
</Button> >
</RequirePermission> <Icon icon='heroicons:pencil-square' width={20} height={20} />
</RowOptionsMenuWrapper> Edit
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.banks.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
>
<Icon icon='heroicons:trash' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
); );
}; };
@@ -87,10 +108,17 @@ const BanksTable = () => {
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter({
initial: { search: '', nameSort: '' }, initial: {
paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' }, search: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
}); });
const [sorting, setSorting] = useState<SortingState>([]);
const { const {
data: banks, data: banks,
isLoading, isLoading,
@@ -101,78 +129,12 @@ const BanksTable = () => {
); );
const deleteModal = useModal(); const deleteModal = useModal();
const [selectedBank, setSelectedBank] = useState<Bank | undefined>(undefined); const [selectedBank, setSelectedBank] = useState<Bank | undefined>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]); const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
const banksColumns: ColumnDef<Bank>[] = [ };
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'alias',
header: 'Alias',
},
{
accessorKey: 'account_number',
header: 'No. Rekening',
},
{
accessorKey: 'owner',
header: 'Pemilik',
cell: (props) => (props.getValue() ? props.getValue() : '-'),
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedBank(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
@@ -192,93 +154,145 @@ const BanksTable = () => {
setIsDeleteLoading(false); setIsDeleteLoading(false);
}; };
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const banksColumns: ColumnDef<Bank>[] = useMemo(
updateFilter('search', e.target.value); () => [
}; {
header: 'No',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'alias',
header: 'Alias',
},
{
accessorKey: 'account_number',
header: 'No. Rekening',
},
{
accessorKey: 'owner',
header: 'Pemilik',
cell: (props) => props.getValue() || '-',
},
{
header: 'Aksi',
cell: (props: CellContext<Bank, unknown>) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const newVal = val as OptionType;
setPageSize(newVal.value as number); const deleteClickHandler = () => {
}; setSelectedBank(props.row.original);
deleteModal.openModal();
};
// track sorting return (
useEffect(() => { <RowOptionsMenu
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name'); props={props}
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
if (!isNameSorted) { deleteClickHandler={deleteClickHandler}
updateFilter('nameSort', ''); />
} else { );
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc'); },
} },
}, [sorting]); ],
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
);
return ( return (
<> <>
<div className='w-full p-0 sm:p-4'> <div className='w-full'>
<div className='flex flex-col gap-2 mb-4'> {/* Header Section */}
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'> <div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
<div className='w-full flex flex-row'> {/* Action Buttons */}
<RequirePermission permissions='lti.master.banks.create'> <div className='w-fit flex flex-row gap-3 flex-wrap'>
<Button <RequirePermission permissions='lti.master.banks.create'>
href='/master-data/bank/add' <Button
variant='outline' href='/master-data/bank/add'
color='primary' color='primary'
className='w-full sm:w-fit' className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
> >
<Icon icon='ic:round-plus' width={24} height={24} /> <Icon icon='heroicons:plus' width={20} height={20} />
Tambah Add Bank
</Button> </Button>
</RequirePermission> </RequirePermission>
</div> </div>
{/* Search */}
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<DebouncedTextInput <DebouncedTextInput
name='search' name='search'
placeholder='Cari Bank' placeholder='Cari Bank'
value={tableFilterState.search} value={tableFilterState.search ?? ''}
onChange={searchChangeHandler} onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }} startAdornment={
/> <Icon
</div> icon='heroicons:magnifying-glass'
width={20}
<div className='flex flex-row justify-end'> height={20}
<SelectInput />
label='Baris' }
options={ROWS_OPTIONS} className={{
value={{ wrapper: 'w-full min-w-24 max-w-3xs',
label: String(tableFilterState.pageSize), inputWrapper: 'rounded-xl! shadow-button-soft',
value: tableFilterState.pageSize, input:
'placeholder:font-semibold placeholder:text-base-content/50',
}} }}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/> />
</div> </div>
</div> </div>
<Table<Bank> {/* Table Section */}
data={isResponseSuccess(banks) ? banks?.data : []} <div className='flex flex-col mb-4'>
columns={banksColumns} {isLoading ? (
pageSize={tableFilterState.pageSize} <div className='w-full flex flex-row justify-center items-center p-4'>
page={isResponseSuccess(banks) ? banks?.meta?.page : 0} <span className='loading loading-spinner loading-xl' />
totalItems={isResponseSuccess(banks) ? banks?.meta?.total_results : 0} </div>
onPageChange={setPage} ) : !isResponseSuccess(banks) || banks.data?.length === 0 ? (
isLoading={isLoading} <div className='p-3'>
sorting={sorting} <BankTableSkeleton
setSorting={setSorting} columns={banksColumns}
className={{ icon={
containerClassName: cn({ <Icon
'mb-20': isResponseSuccess(banks) && banks?.data?.length === 0, icon='heroicons:document-text'
}), className='text-white'
tableWrapperClassName: 'overflow-x-auto min-h-full!', width={20}
tableClassName: 'font-inter w-full table-auto min-h-full!', height={20}
headerRowClassName: 'border-b border-b-gray-200', />
headerColumnClassName: }
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', />
bodyRowClassName: 'border-b border-b-gray-200', </div>
bodyColumnClassName: ) : (
'px-6 py-3 last:flex last:flex-row last:justify-end', <Table<Bank>
}} data={isResponseSuccess(banks) ? banks?.data : []}
/> columns={banksColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(banks) ? banks?.meta?.page : 0}
totalItems={
isResponseSuccess(banks) ? banks?.meta?.total_results : 0
}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: 'p-3 mb-0',
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div>
</div> </div>
<ConfirmationModal <ConfirmationModal
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { Bank } from '@/types/api/master-data/bank';
import { ColumnDef } from '@tanstack/react-table';
const BankTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no bank data displayed. Enter bank data to get started.',
}: {
columns: ColumnDef<Bank>[];
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 BankTableSkeleton;
@@ -1,77 +1,102 @@
'use client'; 'use client';
import Button from '@/components/Button'; import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import Button from '@/components/Button';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import Table from '@/components/Table';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import { ROWS_OPTIONS } from '@/config/constant'; import PopoverButton from '@/components/popover/PopoverButton';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import PopoverContent from '@/components/popover/PopoverContent';
import { cn } from '@/lib/helper'; import CustomerTableSkeleton from '@/components/pages/master-data/customer/skeleton/CustomerTableSkeleton';
import { CustomerApi } from '@/services/api/master-data';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { Customer } from '@/types/api/master-data/customer'; import { Customer } from '@/types/api/master-data/customer';
import { Icon } from '@iconify/react'; import { CustomerApi } from '@/services/api/master-data';
import { CellContext, ColumnDef } from '@tanstack/react-table'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useState } from 'react'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import toast from 'react-hot-toast';
import useSWR from 'swr';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
type = 'dropdown', popoverPosition = 'bottom',
props, props,
deleteClickHandler, deleteClickHandler,
}: { }: {
type: 'dropdown' | 'collapse'; popoverPosition: 'bottom' | 'top';
props: CellContext<Customer, unknown>; props: CellContext<Customer, unknown>;
deleteClickHandler: () => void; deleteClickHandler: () => void;
}) => { }) => {
const popoverId = `customer#${props.row.original.id}`;
const popoverAnchorName = `--anchor-customer#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
return ( return (
<RowOptionsMenuWrapper type={type}> <div className='relative'>
<RequirePermission permissions='lti.master.customer.detail'> <PopoverButton
<Button tabIndex={0}
href={`/master-data/customer/detail/?customerId=${props.row.original.id}`} variant='ghost'
variant='ghost' color='none'
color='primary' popoverTarget={popoverId}
className='justify-start text-sm' anchorName={popoverAnchorName}
> >
<Icon icon='mdi:eye-outline' width={16} height={16} /> <Icon icon='material-symbols:more-vert' width={16} height={16} />
Detail </PopoverButton>
</Button>
</RequirePermission> <PopoverContent
<RequirePermission permissions='lti.master.customer.update'> id={popoverId}
<Button anchorName={popoverAnchorName}
href={`/master-data/customer/detail/edit/?customerId=${props.row.original.id}`} position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
variant='ghost' className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
color='warning' >
className='justify-start text-sm' <div className='flex flex-col bg-base-100 rounded-xl'>
> <RequirePermission permissions='lti.master.customer.detail'>
<Icon icon='material-symbols:edit-outline' width={16} height={16} /> <Button
Edit href={`/master-data/customer/detail/?customerId=${props.row.original.id}`}
</Button> variant='ghost'
</RequirePermission> color='none'
<RequirePermission permissions='lti.master.customer.delete'> className='p-3 justify-start text-sm font-semibold w-full'
<Button onClick={closePopover}
onClick={deleteClickHandler} >
variant='ghost' <Icon icon='heroicons:eye' width={20} height={20} />
color='error' Detail
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content' </Button>
> </RequirePermission>
<Icon <RequirePermission permissions='lti.master.customer.update'>
icon='material-symbols:delete-outline-rounded' <Button
width={16} href={`/master-data/customer/detail/edit/?customerId=${props.row.original.id}`}
height={16} variant='ghost'
className='justify-start text-sm' color='none'
/> className='p-3 justify-start text-sm font-semibold w-full'
Delete onClick={closePopover}
</Button> >
</RequirePermission> <Icon icon='heroicons:pencil-square' width={20} height={20} />
</RowOptionsMenuWrapper> Edit
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.customer.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
>
<Icon icon='heroicons:trash' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
); );
}; };
@@ -83,16 +108,17 @@ const CustomersTable = () => {
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter({
initial: { search: '', nameSort: '', picSort: '' }, initial: {
search: '',
},
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
nameSort: 'sort_name',
picSort: 'sort_pic',
}, },
}); });
// Fetch Data const [sorting, setSorting] = useState<SortingState>([]);
const { const {
data: customers, data: customers,
isLoading, isLoading,
@@ -102,87 +128,16 @@ const CustomersTable = () => {
CustomerApi.getAllFetcher CustomerApi.getAllFetcher
); );
// State
const deleteModal = useModal(); const deleteModal = useModal();
const [selectedCustomer, setSelectedCustomer] = useState< const [selectedCustomer, setSelectedCustomer] = useState<
Customer | undefined Customer | undefined
>(undefined); >(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
// Columns Definition const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
const customersColumns: ColumnDef<Customer>[] = [ updateFilter('search', e.target.value);
{ };
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'pic',
header: 'PIC',
cell: (props) => props.row.original.pic.name,
},
{
accessorKey: 'type',
header: 'Type',
cell: (props) => props.row.original.type,
},
{
accessorKey: 'phone',
header: 'Phone',
},
{
accessorKey: 'email',
header: 'Email',
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedCustomer(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
// Handler
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
@@ -202,81 +157,147 @@ const CustomersTable = () => {
toast.success('Successfully delete Customer!'); toast.success('Successfully delete Customer!');
setIsDeleteLoading(false); setIsDeleteLoading(false);
}; };
const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
updateFilter('search', e.target.value); const customersColumns: ColumnDef<Customer>[] = useMemo(
}; () => [
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { {
const newVal = val as OptionType; header: 'No',
setPageSize(newVal.value as number); cell: (props) =>
}; tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorFn: (row) => row.pic?.name ?? '-',
header: 'PIC',
},
{
accessorKey: 'type',
header: 'Type',
},
{
accessorKey: 'phone',
header: 'Phone',
},
{
accessorKey: 'email',
header: 'Email',
},
{
header: 'Aksi',
cell: (props: CellContext<Customer, unknown>) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedCustomer(props.row.original);
deleteModal.openModal();
};
return (
<RowOptionsMenu
props={props}
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
deleteClickHandler={deleteClickHandler}
/>
);
},
},
],
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
);
return ( return (
<> <>
<div className='w-full p-0 sm:p-4'> <div className='w-full'>
<div className='flex flex-col gap-2 mb-4'> {/* Header Section */}
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'> <div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
<div className='w-full flex flex-row'> {/* Action Buttons */}
<RequirePermission permissions='lti.master.customer.create'> <div className='w-fit flex flex-row gap-3 flex-wrap'>
<Button <RequirePermission permissions='lti.master.customer.create'>
href='/master-data/customer/add' <Button
variant='outline' href='/master-data/customer/add'
color='primary' color='primary'
className='w-full sm:w-fit' className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
> >
<Icon icon='ic:round-plus' width={24} height={24} /> <Icon icon='heroicons:plus' width={20} height={20} />
Tambah Add Customer
</Button> </Button>
</RequirePermission> </RequirePermission>
</div>
<DebouncedTextInput
name='search'
placeholder='Cari Kandang'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div> </div>
<div className='flex flex-row justify-end'> {/* Search */}
<SelectInput <div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
label='Baris' <DebouncedTextInput
options={ROWS_OPTIONS} name='search'
value={{ placeholder='Cari Customer'
label: String(tableFilterState.pageSize), value={tableFilterState.search ?? ''}
value: tableFilterState.pageSize, 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',
}} }}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/> />
</div> </div>
</div> </div>
<Table<Customer> {/* Table Section */}
data={isResponseSuccess(customers) ? customers?.data : []} <div className='flex flex-col mb-4'>
columns={customersColumns} {isLoading ? (
pageSize={tableFilterState.pageSize} <div className='w-full flex flex-row justify-center items-center p-4'>
page={isResponseSuccess(customers) ? customers?.meta?.page : 0} <span className='loading loading-spinner loading-xl' />
totalItems={ </div>
isResponseSuccess(customers) ? customers?.meta?.total_results : 0 ) : !isResponseSuccess(customers) || customers.data?.length === 0 ? (
} <div className='p-3'>
onPageChange={setPage} <CustomerTableSkeleton
isLoading={isLoading} columns={customersColumns}
className={{ icon={
containerClassName: cn({ <Icon
'mb-20': icon='heroicons:document-text'
isResponseSuccess(customers) && customers?.data?.length === 0, className='text-white'
}), width={20}
tableWrapperClassName: 'overflow-x-auto min-h-full!', height={20}
tableClassName: 'font-inter w-full table-auto min-h-full!', />
headerRowClassName: 'border-b border-b-gray-200', }
headerColumnClassName: />
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', </div>
bodyRowClassName: 'border-b border-b-gray-200', ) : (
bodyColumnClassName: <Table<Customer>
'px-6 py-3 last:flex last:flex-row last:justify-end', data={customers?.data}
}} columns={customersColumns}
/> pageSize={tableFilterState.pageSize}
page={customers?.meta?.page ?? 0}
totalItems={customers?.meta?.total_results ?? 0}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={false}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: 'p-3 mb-0',
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div>
</div> </div>
<ConfirmationModal <ConfirmationModal
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { Customer } from '@/types/api/master-data/customer';
import { ColumnDef } from '@tanstack/react-table';
const CustomerTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no customer data displayed. Enter customer data to get started.',
}: {
columns: ColumnDef<Customer>[];
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 CustomerTableSkeleton;
@@ -1,87 +1,102 @@
'use client'; 'use client';
import { CellContext, ColumnDef } from '@tanstack/react-table'; import { ChangeEventHandler, useMemo, useState } from 'react';
import { Flock } from '@/types/api/master-data/flock';
import { cn } from '@/lib/helper';
import Button from '@/components/Button';
import { Icon } from '@iconify/react';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { FlockApi } from '@/services/api/master-data'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import { useModal } from '@/components/Modal';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import { ROWS_OPTIONS } from '@/config/constant';
import Table from '@/components/Table';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
const RowsOptions = ({ import { Icon } from '@iconify/react';
type = 'dropdown', import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import RequirePermission from '@/components/helper/RequirePermission';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import FlockTableSkeleton from '@/components/pages/master-data/flock/skeleton/FlockTableSkeleton';
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';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
props, props,
deleteClickHandler, deleteClickHandler,
}: { }: {
type: 'dropdown' | 'collapse'; popoverPosition: 'bottom' | 'top';
props: CellContext<Flock, unknown>; props: CellContext<Flock, unknown>;
deleteClickHandler: () => void; deleteClickHandler: () => void;
}) => { }) => {
const popoverId = `flock#${props.row.original.id}`;
const popoverAnchorName = `--anchor-flock#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
return ( return (
<RowOptionsMenuWrapper type={type}> <div className='relative'>
<RequirePermission permissions='lti.master.flocks.detail'> <PopoverButton
<Button tabIndex={0}
href={`/master-data/flock/detail/?flockId=${props.row.original.id}`} variant='ghost'
variant='ghost' color='none'
color='primary' popoverTarget={popoverId}
className='justify-start text-sm' anchorName={popoverAnchorName}
> >
<Icon <Icon icon='material-symbols:more-vert' width={16} height={16} />
icon='mdi:eye-outline' </PopoverButton>
width={16}
height={16} <PopoverContent
className='justify-start text-sm' id={popoverId}
/> anchorName={popoverAnchorName}
Detail position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
</Button> className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
</RequirePermission> >
<RequirePermission permissions='lti.master.flocks.update'> <div className='flex flex-col bg-base-100 rounded-xl'>
<Button <RequirePermission permissions='lti.master.flocks.detail'>
href={`/master-data/flock/detail/edit/?flockId=${props.row.original.id}`} <Button
variant='ghost' href={`/master-data/flock/detail/?flockId=${props.row.original.id}`}
color='warning' variant='ghost'
className='justify-start text-sm' color='none'
> className='p-3 justify-start text-sm font-semibold w-full'
<Icon onClick={closePopover}
icon='material-symbols:edit-outline' >
width={16} <Icon icon='heroicons:eye' width={20} height={20} />
height={16} Detail
className='justify-start text-sm' </Button>
/> </RequirePermission>
Edit <RequirePermission permissions='lti.master.flocks.update'>
</Button> <Button
</RequirePermission> href={`/master-data/flock/detail/edit/?flockId=${props.row.original.id}`}
<RequirePermission permissions='lti.master.flocks.delete'> variant='ghost'
<Button color='none'
onClick={deleteClickHandler} className='p-3 justify-start text-sm font-semibold w-full'
variant='ghost' onClick={closePopover}
color='error' >
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content' <Icon icon='heroicons:pencil-square' width={20} height={20} />
> Edit
<Icon </Button>
icon='material-symbols:delete-outline-rounded' </RequirePermission>
width={16} <RequirePermission permissions='lti.master.flocks.delete'>
height={16} <Button
className='justify-start text-sm' onClick={() => {
/> deleteClickHandler();
Delete closePopover();
</Button> }}
</RequirePermission> variant='ghost'
</RowOptionsMenuWrapper> color='none'
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
>
<Icon icon='heroicons:trash' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
); );
}; };
@@ -93,15 +108,17 @@ const FlockTable = () => {
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter({
initial: { search: '', nameSort: '' }, initial: {
search: '',
},
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
nameSort: 'sort_name',
}, },
}); });
// Fetch Data const [sorting, setSorting] = useState<SortingState>([]);
const { const {
data: flocks, data: flocks,
isLoading, isLoading,
@@ -111,74 +128,16 @@ const FlockTable = () => {
FlockApi.getAllFetcher FlockApi.getAllFetcher
); );
// State
const deleteModal = useModal(); const deleteModal = useModal();
const [selectedFlock, setSelectedFlock] = useState<Flock | undefined>( const [selectedFlock, setSelectedFlock] = useState<Flock | undefined>(
undefined undefined
); );
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
// Columns Definition const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
const flocksColumns: ColumnDef<Flock>[] = [ updateFilter('search', e.target.value);
{ };
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'created_at',
header: 'Dibuat pada',
cell: (props) =>
new Date(props.row.original.created_at).toLocaleDateString(),
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedFlock(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowsOptions
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowsOptions
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
// Handler
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
@@ -196,85 +155,143 @@ const FlockTable = () => {
toast.success('Successfully delete Flock!'); toast.success('Successfully delete Flock!');
setIsDeleteLoading(false); setIsDeleteLoading(false);
}; };
const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
updateFilter('search', e.target.value); const flocksColumns: ColumnDef<Flock>[] = useMemo(
}; () => [
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { {
const newVal = val as OptionType; header: 'No',
setPageSize(newVal.value as number); cell: (props) =>
}; tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'created_at',
header: 'Dibuat pada',
cell: (props) =>
new Date(props.row.original.created_at).toLocaleDateString('id-ID'),
},
{
header: 'Aksi',
cell: (props: CellContext<Flock, unknown>) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedFlock(props.row.original);
deleteModal.openModal();
};
return (
<RowOptionsMenu
props={props}
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
deleteClickHandler={deleteClickHandler}
/>
);
},
},
],
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
);
return ( return (
<> <>
<div className='w-full p-0 sm:p-4'> <div className='w-full'>
<div className='flex flex-col gap-2 mb-4'> {/* Header Section */}
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'> <div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
<div className='w-full flex flex-row'> {/* Action Buttons */}
<RequirePermission permissions='lti.master.flocks.create'> <div className='w-fit flex flex-row gap-3 flex-wrap'>
<Button <RequirePermission permissions='lti.master.flocks.create'>
href='/master-data/flock/add' <Button
variant='outline' href='/master-data/flock/add'
color='primary' color='primary'
className='w-full sm:w-fit' className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
> >
<Icon icon='ic:round-plus' width={24} height={24} /> <Icon icon='heroicons:plus' width={20} height={20} />
Tambah Add Flock
</Button> </Button>
</RequirePermission> </RequirePermission>
</div> </div>
{/* Search */}
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<DebouncedTextInput <DebouncedTextInput
name='search' name='search'
placeholder='Cari Flock' placeholder='Cari Flock'
value={tableFilterState.search} value={tableFilterState.search ?? ''}
onChange={searchChangeHandler} onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }} startAdornment={
/> <Icon
</div> icon='heroicons:magnifying-glass'
width={20}
<div className='flex flex-row justify-end'> height={20}
<SelectInput />
label='Baris' }
options={ROWS_OPTIONS} className={{
value={{ wrapper: 'w-full min-w-24 max-w-3xs',
label: String(tableFilterState.pageSize), inputWrapper: 'rounded-xl! shadow-button-soft',
value: tableFilterState.pageSize, input:
'placeholder:font-semibold placeholder:text-base-content/50',
}} }}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/> />
</div> </div>
</div> </div>
<Table<Flock> {/* Table Section */}
data={isResponseSuccess(flocks) ? flocks?.data : []} <div className='flex flex-col mb-4'>
columns={flocksColumns} {isLoading ? (
pageSize={tableFilterState.pageSize} <div className='w-full flex flex-row justify-center items-center p-4'>
page={isResponseSuccess(flocks) ? flocks?.meta?.page : 0} <span className='loading loading-spinner loading-xl' />
totalItems={ </div>
isResponseSuccess(flocks) ? flocks?.meta?.total_results : 0 ) : !isResponseSuccess(flocks) || flocks.data?.length === 0 ? (
} <div className='p-3'>
onPageChange={setPage} <FlockTableSkeleton
isLoading={isLoading} columns={flocksColumns}
className={{ icon={
containerClassName: cn({ <Icon
'mb-20': isResponseSuccess(flocks) && flocks?.data?.length === 0, icon='heroicons:document-text'
}), className='text-white'
tableWrapperClassName: 'overflow-x-auto min-h-full!', width={20}
tableClassName: 'font-inter w-full table-auto min-h-full!', height={20}
headerRowClassName: 'border-b border-b-gray-200', />
headerColumnClassName: }
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', />
bodyRowClassName: 'border-b border-b-gray-200', </div>
bodyColumnClassName: ) : (
'px-6 py-3 last:flex last:flex-row last:justify-end', <Table<Flock>
}} data={flocks?.data}
/> columns={flocksColumns}
pageSize={tableFilterState.pageSize}
page={flocks?.meta?.page ?? 0}
totalItems={flocks?.meta?.total_results ?? 0}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={false}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: 'p-3 mb-0',
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div>
</div> </div>
<ConfirmationModal <ConfirmationModal
ref={deleteModal.ref} ref={deleteModal.ref}
type='error' type='error'
text={`Apakah anda yakin ingin menghapus data Supplier ini (${selectedFlock?.name})?`} text={`Apakah anda yakin ingin menghapus data Flock ini (${selectedFlock?.name})?`}
secondaryButton={{ secondaryButton={{
text: 'Tidak', text: 'Tidak',
}} }}
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { Flock } from '@/types/api/master-data/flock';
import { ColumnDef } from '@tanstack/react-table';
const FlockTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no flock data displayed. Enter flock data to get started.',
}: {
columns: ColumnDef<Flock>[];
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 FlockTableSkeleton;
@@ -1,13 +1,8 @@
'use client'; 'use client';
import { ChangeEventHandler, useCallback, useEffect, useState } from 'react'; import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
CellContext,
ColumnDef,
ColumnSort,
SortingState,
} from '@tanstack/react-table';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
@@ -16,71 +11,93 @@ import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import KandangTableSkeleton from '@/components/pages/master-data/kandang/skeleton/KandangTableSkeleton';
import { Kandang } from '@/types/api/master-data/kandang'; import { Kandang } from '@/types/api/master-data/kandang';
import { KandangApi } from '@/services/api/master-data'; import { KandangApi } from '@/services/api/master-data';
import { cn, formatNumber } from '@/lib/helper'; import { formatNumber } from '@/lib/helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
type = 'dropdown', popoverPosition = 'bottom',
props, props,
deleteClickHandler, deleteClickHandler,
}: { }: {
type: 'dropdown' | 'collapse'; popoverPosition: 'bottom' | 'top';
props: CellContext<Kandang, unknown>; props: CellContext<Kandang, unknown>;
deleteClickHandler: () => void; deleteClickHandler: () => void;
}) => { }) => {
const popoverId = `kandang#${props.row.original.id}`;
const popoverAnchorName = `--anchor-kandang#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
return ( return (
<RowOptionsMenuWrapper type={type}> <div className='relative'>
<RequirePermission permissions='lti.master.kandangs.detail'> <PopoverButton
<Button tabIndex={0}
href={`/master-data/kandang/detail/?kandangId=${props.row.original.id}`} variant='ghost'
variant='ghost' color='none'
color='primary' popoverTarget={popoverId}
className='justify-start text-sm' anchorName={popoverAnchorName}
> >
<Icon icon='mdi:eye-outline' width={16} height={16} /> <Icon icon='material-symbols:more-vert' width={16} height={16} />
Detail </PopoverButton>
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.kandangs.update'> <PopoverContent
<Button id={popoverId}
href={`/master-data/kandang/detail/edit/?kandangId=${props.row.original.id}`} anchorName={popoverAnchorName}
variant='ghost' position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
color='warning' className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
className='justify-start text-sm' >
> <div className='flex flex-col bg-base-100 rounded-xl'>
<Icon icon='material-symbols:edit-outline' width={16} height={16} /> <RequirePermission permissions='lti.master.kandangs.detail'>
Edit <Button
</Button> href={`/master-data/kandang/detail/?kandangId=${props.row.original.id}`}
</RequirePermission> variant='ghost'
color='none'
<RequirePermission permissions='lti.master.kandangs.delete'> className='p-3 justify-start text-sm font-semibold w-full'
<Button onClick={closePopover}
onClick={deleteClickHandler} >
variant='ghost' <Icon icon='heroicons:eye' width={20} height={20} />
color='error' Detail
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content' </Button>
> </RequirePermission>
<Icon <RequirePermission permissions='lti.master.kandangs.update'>
icon='material-symbols:delete-outline-rounded' <Button
width={16} href={`/master-data/kandang/detail/edit/?kandangId=${props.row.original.id}`}
height={16} variant='ghost'
className='justify-start text-sm' color='none'
/> className='p-3 justify-start text-sm font-semibold w-full'
Delete onClick={closePopover}
</Button> >
</RequirePermission> <Icon icon='heroicons:pencil-square' width={20} height={20} />
</RowOptionsMenuWrapper> Edit
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.kandangs.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
>
<Icon icon='heroicons:trash' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
); );
}; };
@@ -94,21 +111,15 @@ const KandangsTable = () => {
} = useTableFilter({ } = useTableFilter({
initial: { initial: {
search: '', search: '',
nameSort: '',
locationSort: '',
capacitySort: '',
picSort: '',
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
nameSort: 'sort_name',
locationSort: 'sort_location',
capacitySort: 'sort_capacity',
picSort: ' sort_pic',
}, },
}); });
const [sorting, setSorting] = useState<SortingState>([]);
const { const {
data: kandangs, data: kandangs,
isLoading, isLoading,
@@ -119,82 +130,14 @@ const KandangsTable = () => {
); );
const deleteModal = useModal(); const deleteModal = useModal();
const [selectedKandang, setSelectedKandang] = useState<Kandang | undefined>( const [selectedKandang, setSelectedKandang] = useState<Kandang | undefined>(
undefined undefined
); );
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]); const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
const kandangsColumns: ColumnDef<Kandang>[] = [ };
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'location',
header: 'Lokasi',
cell: (props) => props.row.original.location.name,
},
{
accessorKey: 'capacity',
header: 'Kapasitas',
cell: (props) => formatNumber(props.row.original.capacity ?? 0),
},
{
accessorKey: 'pic',
header: 'PIC',
cell: (props) => props.row.original.pic.name,
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedKandang(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
@@ -216,114 +159,143 @@ const KandangsTable = () => {
setIsDeleteLoading(false); setIsDeleteLoading(false);
}; };
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const kandangsColumns: ColumnDef<Kandang>[] = useMemo(
updateFilter('search', e.target.value); () => [
}; {
header: 'No',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorFn: (row) => row.location?.name ?? '-',
header: 'Lokasi',
},
{
accessorKey: 'capacity',
header: 'Kapasitas',
cell: (props) => formatNumber(props.row.original.capacity ?? 0),
},
{
accessorFn: (row) => row.pic?.name ?? '-',
header: 'PIC',
},
{
header: 'Aksi',
cell: (props: CellContext<Kandang, unknown>) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const newVal = val as OptionType;
setPageSize(newVal.value as number); const deleteClickHandler = () => {
}; setSelectedKandang(props.row.original);
deleteModal.openModal();
};
const updateSortingFilter = useCallback( return (
( <RowOptionsMenu
sortName: Exclude<keyof typeof tableFilterState, 'page' | 'pageSize'>, props={props}
sortFilter: ColumnSort | undefined popoverPosition={isLast2Rows ? 'top' : 'bottom'}
) => { deleteClickHandler={deleteClickHandler}
if (!sortFilter) { />
updateFilter(sortName, ''); );
} else { },
updateFilter(sortName, sortFilter.desc ? 'desc' : 'asc'); },
} ],
}, [tableFilterState.pageSize, tableFilterState.page, deleteModal]
[updateFilter]
); );
// track sorting
useEffect(() => {
const nameSortFilter = sorting.find((sortItem) => sortItem.id === 'name');
const locationSortFilter = sorting.find(
(sortItem) => sortItem.id === 'location'
);
const picSortFilter = sorting.find((sortItem) => sortItem.id === 'pic');
updateSortingFilter('nameSort', nameSortFilter);
updateSortingFilter('locationSort', locationSortFilter);
updateSortingFilter('picSort', picSortFilter);
}, [sorting, updateSortingFilter]);
return ( return (
<> <>
<div className='w-full p-0 sm:p-4'> <div className='w-full'>
<div className='flex flex-col gap-2 mb-4'> {/* Header Section */}
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'> <div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
<div className='w-full flex flex-row'> {/* Action Buttons */}
<div className='w-full flex flex-row'> <div className='w-fit flex flex-row gap-3 flex-wrap'>
<RequirePermission permissions='lti.master.kandangs.create'> <RequirePermission permissions='lti.master.kandangs.create'>
<Button <Button
href='/master-data/kandang/add' href='/master-data/kandang/add'
variant='outline' color='primary'
color='primary' className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
className='w-full sm:w-fit' >
> <Icon icon='heroicons:plus' width={20} height={20} />
<Icon icon='ic:round-plus' width={24} height={24} /> Add Kandang
Tambah </Button>
</Button> </RequirePermission>
</RequirePermission> </div>
</div>
</div>
{/* Search */}
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<DebouncedTextInput <DebouncedTextInput
name='search' name='search'
placeholder='Cari Kandang' placeholder='Cari Kandang'
value={tableFilterState.search} value={tableFilterState.search ?? ''}
onChange={searchChangeHandler} onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }} startAdornment={
/> <Icon
</div> icon='heroicons:magnifying-glass'
width={20}
<div className='flex flex-row justify-end'> height={20}
<SelectInput />
label='Baris' }
options={ROWS_OPTIONS} className={{
value={{ wrapper: 'w-full min-w-24 max-w-3xs',
label: String(tableFilterState.pageSize), inputWrapper: 'rounded-xl! shadow-button-soft',
value: tableFilterState.pageSize, input:
'placeholder:font-semibold placeholder:text-base-content/50',
}} }}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/> />
</div> </div>
</div> </div>
<Table<Kandang> {/* Table Section */}
data={isResponseSuccess(kandangs) ? kandangs?.data : []} <div className='flex flex-col mb-4'>
columns={kandangsColumns} {isLoading ? (
pageSize={tableFilterState.pageSize} <div className='w-full flex flex-row justify-center items-center p-4'>
page={isResponseSuccess(kandangs) ? kandangs?.meta?.page : 0} <span className='loading loading-spinner loading-xl' />
totalItems={ </div>
isResponseSuccess(kandangs) ? kandangs?.meta?.total_results : 0 ) : !isResponseSuccess(kandangs) || kandangs.data?.length === 0 ? (
} <div className='p-3'>
onPageChange={setPage} <KandangTableSkeleton
isLoading={isLoading} columns={kandangsColumns}
sorting={sorting} icon={
setSorting={setSorting} <Icon
className={{ icon='heroicons:document-text'
containerClassName: cn({ className='text-white'
'mb-20': width={20}
isResponseSuccess(kandangs) && kandangs?.data?.length === 0, height={20}
}), />
tableWrapperClassName: 'overflow-x-auto min-h-full!', }
tableClassName: 'font-inter w-full table-auto min-h-full!', />
headerRowClassName: 'border-b border-b-gray-200', </div>
headerColumnClassName: ) : (
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', <Table<Kandang>
bodyRowClassName: 'border-b border-b-gray-200', data={kandangs?.data}
bodyColumnClassName: columns={kandangsColumns}
'px-6 py-3 last:flex last:flex-row last:justify-end', pageSize={tableFilterState.pageSize}
}} page={kandangs?.meta?.page ?? 0}
/> totalItems={kandangs?.meta?.total_results ?? 0}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={false}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: 'p-3 mb-0',
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div>
</div> </div>
<ConfirmationModal <ConfirmationModal
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { Kandang } from '@/types/api/master-data/kandang';
import { ColumnDef } from '@tanstack/react-table';
const KandangTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no kandang data displayed. Enter kandang data to get started.',
}: {
columns: ColumnDef<Kandang>[];
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 KandangTableSkeleton;
@@ -1,13 +1,8 @@
'use client'; 'use client';
import { ChangeEventHandler, useCallback, useEffect, useState } from 'react'; import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
CellContext,
ColumnDef,
ColumnSort,
SortingState,
} from '@tanstack/react-table';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
@@ -16,71 +11,92 @@ import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import LocationTableSkeleton from '@/components/pages/master-data/location/skeleton/LocationTableSkeleton';
import { Location } from '@/types/api/master-data/location'; import { Location } from '@/types/api/master-data/location';
import { LocationApi } from '@/services/api/master-data'; import { LocationApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
type = 'dropdown', popoverPosition = 'bottom',
props, props,
deleteClickHandler, deleteClickHandler,
}: { }: {
type: 'dropdown' | 'collapse'; popoverPosition: 'bottom' | 'top';
props: CellContext<Location, unknown>; props: CellContext<Location, unknown>;
deleteClickHandler: () => void; deleteClickHandler: () => void;
}) => { }) => {
const popoverId = `location#${props.row.original.id}`;
const popoverAnchorName = `--anchor-location#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
return ( return (
<RowOptionsMenuWrapper type={type}> <div className='relative'>
<RequirePermission permissions='lti.master.locations.detail'> <PopoverButton
<Button tabIndex={0}
href={`/master-data/location/detail/?locationId=${props.row.original.id}`} variant='ghost'
variant='ghost' color='none'
color='primary' popoverTarget={popoverId}
className='justify-start text-sm' anchorName={popoverAnchorName}
> >
<Icon icon='mdi:eye-outline' width={16} height={16} /> <Icon icon='material-symbols:more-vert' width={16} height={16} />
Detail </PopoverButton>
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.locations.update'> <PopoverContent
<Button id={popoverId}
href={`/master-data/location/detail/edit/?locationId=${props.row.original.id}`} anchorName={popoverAnchorName}
variant='ghost' position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
color='warning' className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
className='justify-start text-sm' >
> <div className='flex flex-col bg-base-100 rounded-xl'>
<Icon icon='material-symbols:edit-outline' width={16} height={16} /> <RequirePermission permissions='lti.master.locations.detail'>
Edit <Button
</Button> href={`/master-data/location/detail/?locationId=${props.row.original.id}`}
</RequirePermission> variant='ghost'
color='none'
<RequirePermission permissions='lti.master.locations.delete'> className='p-3 justify-start text-sm font-semibold w-full'
<Button onClick={closePopover}
onClick={deleteClickHandler} >
variant='ghost' <Icon icon='heroicons:eye' width={20} height={20} />
color='error' Detail
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content' </Button>
> </RequirePermission>
<Icon <RequirePermission permissions='lti.master.locations.update'>
icon='material-symbols:delete-outline-rounded' <Button
width={16} href={`/master-data/location/detail/edit/?locationId=${props.row.original.id}`}
height={16} variant='ghost'
className='justify-start text-sm' color='none'
/> className='p-3 justify-start text-sm font-semibold w-full'
Delete onClick={closePopover}
</Button> >
</RequirePermission> <Icon icon='heroicons:pencil-square' width={20} height={20} />
</RowOptionsMenuWrapper> Edit
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.locations.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
>
<Icon icon='heroicons:trash' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
); );
}; };
@@ -92,16 +108,17 @@ const LocationsTable = () => {
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter({
initial: { search: '', nameSort: '', addressSort: '', areaSort: '' }, initial: {
search: '',
},
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
nameSort: 'sort_name',
addressSort: 'sort_address',
areaSort: ' sort_area',
}, },
}); });
const [sorting, setSorting] = useState<SortingState>([]);
const { const {
data: locations, data: locations,
isLoading, isLoading,
@@ -112,76 +129,14 @@ const LocationsTable = () => {
); );
const deleteModal = useModal(); const deleteModal = useModal();
const [selectedLocation, setSelectedLocation] = useState< const [selectedLocation, setSelectedLocation] = useState<
Location | undefined Location | undefined
>(undefined); >(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]); const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
const locationsColumns: ColumnDef<Location>[] = [ };
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'address',
header: 'Alamat',
},
{
accessorKey: 'area',
header: 'Area',
cell: (props) => props.row.original.area.name,
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedLocation(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
@@ -203,114 +158,138 @@ const LocationsTable = () => {
setIsDeleteLoading(false); setIsDeleteLoading(false);
}; };
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const locationsColumns: ColumnDef<Location>[] = useMemo(
updateFilter('search', e.target.value); () => [
}; {
header: 'No',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'address',
header: 'Alamat',
},
{
accessorFn: (row) => row.area?.name ?? '-',
header: 'Area',
},
{
header: 'Aksi',
cell: (props: CellContext<Location, unknown>) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const newVal = val as OptionType;
setPageSize(newVal.value as number); const deleteClickHandler = () => {
}; setSelectedLocation(props.row.original);
deleteModal.openModal();
};
const updateSortingFilter = useCallback( return (
( <RowOptionsMenu
sortName: Exclude<keyof typeof tableFilterState, 'page' | 'pageSize'>, props={props}
sortFilter: ColumnSort | undefined popoverPosition={isLast2Rows ? 'top' : 'bottom'}
) => { deleteClickHandler={deleteClickHandler}
if (!sortFilter) { />
updateFilter(sortName, ''); );
} else { },
updateFilter(sortName, sortFilter.desc ? 'desc' : 'asc'); },
} ],
}, [tableFilterState.pageSize, tableFilterState.page, deleteModal]
[updateFilter]
); );
// track sorting
useEffect(() => {
const nameSortFilter = sorting.find((sortItem) => sortItem.id === 'name');
const addressSortFilter = sorting.find(
(sortItem) => sortItem.id === 'address'
);
const areaSortFilter = sorting.find((sortItem) => sortItem.id === 'area');
updateSortingFilter('nameSort', nameSortFilter);
updateSortingFilter('addressSort', addressSortFilter);
updateSortingFilter('areaSort', areaSortFilter);
}, [sorting, updateSortingFilter]);
return ( return (
<> <>
<div className='w-full p-0 sm:p-4'> <div className='w-full'>
<div className='flex flex-col gap-2 mb-4'> {/* Header Section */}
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'> <div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
<div className='w-full flex flex-row'> {/* Action Buttons */}
<div className='w-full flex flex-row'> <div className='w-fit flex flex-row gap-3 flex-wrap'>
<RequirePermission permissions='lti.master.locations.create'> <RequirePermission permissions='lti.master.locations.create'>
<Button <Button
href='/master-data/location/add' href='/master-data/location/add'
variant='outline' color='primary'
color='primary' className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
className='w-full sm:w-fit' >
> <Icon icon='heroicons:plus' width={20} height={20} />
<Icon icon='ic:round-plus' width={24} height={24} /> Add Location
Tambah </Button>
</Button> </RequirePermission>
</RequirePermission> </div>
</div>
</div>
{/* Search */}
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<DebouncedTextInput <DebouncedTextInput
name='search' name='search'
placeholder='Cari Location' placeholder='Cari Location'
value={tableFilterState.search} value={tableFilterState.search ?? ''}
onChange={searchChangeHandler} onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }} startAdornment={
/> <Icon
</div> icon='heroicons:magnifying-glass'
width={20}
<div className='flex flex-row justify-end'> height={20}
<SelectInput />
label='Baris' }
options={ROWS_OPTIONS} className={{
value={{ wrapper: 'w-full min-w-24 max-w-3xs',
label: String(tableFilterState.pageSize), inputWrapper: 'rounded-xl! shadow-button-soft',
value: tableFilterState.pageSize, input:
'placeholder:font-semibold placeholder:text-base-content/50',
}} }}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/> />
</div> </div>
</div> </div>
<Table<Location> {/* Table Section */}
data={isResponseSuccess(locations) ? locations?.data : []} <div className='flex flex-col mb-4'>
columns={locationsColumns} {isLoading ? (
pageSize={tableFilterState.pageSize} <div className='w-full flex flex-row justify-center items-center p-4'>
page={isResponseSuccess(locations) ? locations?.meta?.page : 0} <span className='loading loading-spinner loading-xl' />
totalItems={ </div>
isResponseSuccess(locations) ? locations?.meta?.total_results : 0 ) : !isResponseSuccess(locations) || locations.data?.length === 0 ? (
} <div className='p-3'>
onPageChange={setPage} <LocationTableSkeleton
isLoading={isLoading} columns={locationsColumns}
sorting={sorting} icon={
setSorting={setSorting} <Icon
className={{ icon='heroicons:document-text'
containerClassName: cn({ className='text-white'
'mb-20': width={20}
isResponseSuccess(locations) && locations?.data?.length === 0, height={20}
}), />
tableWrapperClassName: 'overflow-x-auto min-h-full!', }
tableClassName: 'font-inter w-full table-auto min-h-full!', />
headerRowClassName: 'border-b border-b-gray-200', </div>
headerColumnClassName: ) : (
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', <Table<Location>
bodyRowClassName: 'border-b border-b-gray-200', data={locations?.data}
bodyColumnClassName: columns={locationsColumns}
'px-6 py-3 last:flex last:flex-row last:justify-end', pageSize={tableFilterState.pageSize}
}} page={locations?.meta?.page ?? 0}
/> totalItems={locations?.meta?.total_results ?? 0}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={false}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: 'p-3 mb-0',
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div>
</div> </div>
<ConfirmationModal <ConfirmationModal
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { Location } from '@/types/api/master-data/location';
import { ColumnDef } from '@tanstack/react-table';
const LocationTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no location data displayed. Enter location data to get started.',
}: {
columns: ColumnDef<Location>[];
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 LocationTableSkeleton;
@@ -1,13 +1,8 @@
'use client'; 'use client';
import { ChangeEventHandler, useCallback, useEffect, useState } from 'react'; import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
CellContext,
ColumnDef,
ColumnSort,
SortingState,
} from '@tanstack/react-table';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
@@ -16,71 +11,92 @@ import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import NonstockTableSkeleton from '@/components/pages/master-data/nonstock/skeleton/NonstockTableSkeleton';
import { Nonstock } from '@/types/api/master-data/nonstock'; import { Nonstock } from '@/types/api/master-data/nonstock';
import { NonstockApi } from '@/services/api/master-data'; import { NonstockApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
type = 'dropdown', popoverPosition = 'bottom',
props, props,
deleteClickHandler, deleteClickHandler,
}: { }: {
type: 'dropdown' | 'collapse'; popoverPosition: 'bottom' | 'top';
props: CellContext<Nonstock, unknown>; props: CellContext<Nonstock, unknown>;
deleteClickHandler: () => void; deleteClickHandler: () => void;
}) => { }) => {
const popoverId = `nonstock#${props.row.original.id}`;
const popoverAnchorName = `--anchor-nonstock#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
return ( return (
<RowOptionsMenuWrapper type={type}> <div className='relative'>
<RequirePermission permissions='lti.master.nonstocks.detail'> <PopoverButton
<Button tabIndex={0}
href={`/master-data/nonstock/detail/?nonstockId=${props.row.original.id}`} variant='ghost'
variant='ghost' color='none'
color='primary' popoverTarget={popoverId}
className='justify-start text-sm' anchorName={popoverAnchorName}
> >
<Icon icon='mdi:eye-outline' width={16} height={16} /> <Icon icon='material-symbols:more-vert' width={16} height={16} />
Detail </PopoverButton>
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.nonstocks.update'> <PopoverContent
<Button id={popoverId}
href={`/master-data/nonstock/detail/edit/?nonstockId=${props.row.original.id}`} anchorName={popoverAnchorName}
variant='ghost' position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
color='warning' className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
className='justify-start text-sm' >
> <div className='flex flex-col bg-base-100 rounded-xl'>
<Icon icon='material-symbols:edit-outline' width={16} height={16} /> <RequirePermission permissions='lti.master.nonstocks.detail'>
Edit <Button
</Button> href={`/master-data/nonstock/detail/?nonstockId=${props.row.original.id}`}
</RequirePermission> variant='ghost'
color='none'
<RequirePermission permissions='lti.master.nonstocks.delete'> className='p-3 justify-start text-sm font-semibold w-full'
<Button onClick={closePopover}
onClick={deleteClickHandler} >
variant='ghost' <Icon icon='heroicons:eye' width={20} height={20} />
color='error' Detail
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content' </Button>
> </RequirePermission>
<Icon <RequirePermission permissions='lti.master.nonstocks.update'>
icon='material-symbols:delete-outline-rounded' <Button
width={16} href={`/master-data/nonstock/detail/edit/?nonstockId=${props.row.original.id}`}
height={16} variant='ghost'
className='justify-start text-sm' color='none'
/> className='p-3 justify-start text-sm font-semibold w-full'
Delete onClick={closePopover}
</Button> >
</RequirePermission> <Icon icon='heroicons:pencil-square' width={20} height={20} />
</RowOptionsMenuWrapper> Edit
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.nonstocks.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
>
<Icon icon='heroicons:trash' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
); );
}; };
@@ -92,16 +108,17 @@ const NonstocksTable = () => {
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter({
initial: { search: '', nameSort: '', locationSort: '', picSort: '' }, initial: {
search: '',
},
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
nameSort: 'sort_name',
locationSort: 'sort_location',
picSort: ' sort_pic',
}, },
}); });
const [sorting, setSorting] = useState<SortingState>([]);
const { const {
data: nonstocks, data: nonstocks,
isLoading, isLoading,
@@ -112,88 +129,14 @@ const NonstocksTable = () => {
); );
const deleteModal = useModal(); const deleteModal = useModal();
const [selectedNonstock, setSelectedNonstock] = useState< const [selectedNonstock, setSelectedNonstock] = useState<
Nonstock | undefined Nonstock | undefined
>(undefined); >(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]); const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
const nonstocksColumns: ColumnDef<Nonstock>[] = [ };
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'uom',
header: 'UOM',
cell: (props) => props.row.original.uom.name,
},
{
accessorKey: 'suppliers',
header: 'Supplier',
cell: (props) => {
const supplierNames = props.row.original.suppliers.map(
(supplier) => supplier.name
);
return supplierNames.join(', ') || '-';
},
},
{
accessorKey: 'flags',
header: 'Flag',
cell: (props) => props.row.original.flags?.join(', ') || '-',
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedNonstock(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
@@ -215,112 +158,143 @@ const NonstocksTable = () => {
setIsDeleteLoading(false); setIsDeleteLoading(false);
}; };
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const nonstocksColumns: ColumnDef<Nonstock>[] = useMemo(
updateFilter('search', e.target.value); () => [
}; {
header: 'No',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorFn: (row) => row.uom?.name ?? '-',
header: 'UOM',
},
{
accessorFn: (row) =>
row.suppliers?.map((supplier) => supplier.name).join(', ') || '-',
header: 'Supplier',
},
{
accessorFn: (row) => row.flags?.join(', ') || '-',
header: 'Flag',
},
{
header: 'Aksi',
cell: (props: CellContext<Nonstock, unknown>) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const newVal = val as OptionType;
setPageSize(newVal.value as number); const deleteClickHandler = () => {
}; setSelectedNonstock(props.row.original);
deleteModal.openModal();
};
const updateSortingFilter = useCallback( return (
( <RowOptionsMenu
sortName: Exclude<keyof typeof tableFilterState, 'page' | 'pageSize'>, props={props}
sortFilter: ColumnSort | undefined popoverPosition={isLast2Rows ? 'top' : 'bottom'}
) => { deleteClickHandler={deleteClickHandler}
if (!sortFilter) { />
updateFilter(sortName, ''); );
} else { },
updateFilter(sortName, sortFilter.desc ? 'desc' : 'asc'); },
} ],
}, [tableFilterState.pageSize, tableFilterState.page, deleteModal]
[updateFilter]
); );
// track sorting
useEffect(() => {
const nameSortFilter = sorting.find((sortItem) => sortItem.id === 'name');
const locationSortFilter = sorting.find(
(sortItem) => sortItem.id === 'location'
);
const picSortFilter = sorting.find((sortItem) => sortItem.id === 'pic');
updateSortingFilter('nameSort', nameSortFilter);
updateSortingFilter('locationSort', locationSortFilter);
updateSortingFilter('picSort', picSortFilter);
}, [sorting]);
return ( return (
<> <>
<div className='w-full p-0 sm:p-4'> <div className='w-full'>
<div className='flex flex-col gap-2 mb-4'> {/* Header Section */}
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'> <div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
<div className='w-full flex flex-row'> {/* Action Buttons */}
<RequirePermission permissions='lti.master.nonstocks.create'> <div className='w-fit flex flex-row gap-3 flex-wrap'>
<Button <RequirePermission permissions='lti.master.nonstocks.create'>
href='/master-data/nonstock/add' <Button
variant='outline' href='/master-data/nonstock/add'
color='primary' color='primary'
className='w-full sm:w-fit' className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
> >
<Icon icon='ic:round-plus' width={24} height={24} /> <Icon icon='heroicons:plus' width={20} height={20} />
Tambah Add Nonstock
</Button> </Button>
</RequirePermission> </RequirePermission>
</div> </div>
{/* Search */}
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<DebouncedTextInput <DebouncedTextInput
name='search' name='search'
placeholder='Cari Nonstock' placeholder='Cari Nonstock'
value={tableFilterState.search} value={tableFilterState.search ?? ''}
onChange={searchChangeHandler} onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }} startAdornment={
/> <Icon
</div> icon='heroicons:magnifying-glass'
width={20}
<div className='flex flex-row justify-end'> height={20}
<SelectInput />
label='Baris' }
options={ROWS_OPTIONS} className={{
value={{ wrapper: 'w-full min-w-24 max-w-3xs',
label: String(tableFilterState.pageSize), inputWrapper: 'rounded-xl! shadow-button-soft',
value: tableFilterState.pageSize, input:
'placeholder:font-semibold placeholder:text-base-content/50',
}} }}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/> />
</div> </div>
</div> </div>
<Table<Nonstock> {/* Table Section */}
data={isResponseSuccess(nonstocks) ? nonstocks?.data : []} <div className='flex flex-col mb-4'>
columns={nonstocksColumns} {isLoading ? (
pageSize={tableFilterState.pageSize} <div className='w-full flex flex-row justify-center items-center p-4'>
page={isResponseSuccess(nonstocks) ? nonstocks?.meta?.page : 0} <span className='loading loading-spinner loading-xl' />
totalItems={ </div>
isResponseSuccess(nonstocks) ? nonstocks?.meta?.total_results : 0 ) : !isResponseSuccess(nonstocks) || nonstocks.data?.length === 0 ? (
} <div className='p-3'>
onPageChange={setPage} <NonstockTableSkeleton
isLoading={isLoading} columns={nonstocksColumns}
sorting={sorting} icon={
setSorting={setSorting} <Icon
className={{ icon='heroicons:document-text'
containerClassName: cn({ className='text-white'
'mb-20': width={20}
isResponseSuccess(nonstocks) && nonstocks?.data?.length === 0, height={20}
}), />
tableWrapperClassName: 'overflow-x-auto min-h-full!', }
tableClassName: 'font-inter w-full table-auto min-h-full!', />
headerRowClassName: 'border-b border-b-gray-200', </div>
headerColumnClassName: ) : (
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', <Table<Nonstock>
bodyRowClassName: 'border-b border-b-gray-200', data={nonstocks?.data}
bodyColumnClassName: columns={nonstocksColumns}
'px-6 py-3 last:flex last:flex-row last:justify-end', pageSize={tableFilterState.pageSize}
}} page={nonstocks?.meta?.page ?? 0}
/> totalItems={nonstocks?.meta?.total_results ?? 0}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={false}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: 'p-3 mb-0',
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div>
</div> </div>
<ConfirmationModal <ConfirmationModal
@@ -17,7 +17,7 @@ export const NonstockFormSchema = Yup.object({
}) })
), ),
flags: Yup.array().of(Yup.string()).notRequired(), flags: Yup.boolean().default(false),
}); });
export const UpdateNonstockFormSchema = NonstockFormSchema; export const UpdateNonstockFormSchema = NonstockFormSchema;
@@ -12,6 +12,7 @@ import SelectInput, {
OptionType, OptionType,
useSelect, useSelect,
} from '@/components/input/SelectInput'; } from '@/components/input/SelectInput';
import { RadioGroup } from '@/components/input/RadioInput';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
@@ -29,8 +30,7 @@ import {
} from '@/types/api/master-data/nonstock'; } from '@/types/api/master-data/nonstock';
import { NonstockApi, SupplierApi, UomApi } from '@/services/api/master-data'; import { NonstockApi, SupplierApi, UomApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { flags } from '@/types/api/api-general';
import { SUPPLIER_FLAG_OPTIONS } from '@/config/constant';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { Supplier } from '@/types/api/master-data/supplier'; import { Supplier } from '@/types/api/master-data/supplier';
@@ -97,7 +97,7 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
label: supplier.name, label: supplier.name,
})) ?? [], })) ?? [],
flags: initialValues?.flags ?? [], flags: initialValues?.flags?.includes('EKSPEDISI') ?? false,
}; };
}, [initialValues]); }, [initialValues]);
@@ -112,7 +112,7 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
name: values.name, name: values.name,
uom_id: values.uomId, uom_id: values.uomId,
supplier_ids: values.supplierIds as number[], supplier_ids: values.supplierIds as number[],
flags: values.flags as flags[], flags: values.flags ? ['EKSPEDISI'] : [],
}; };
switch (type) { switch (type) {
@@ -183,12 +183,8 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
router.push('/master-data/nonstock'); router.push('/master-data/nonstock');
}; };
const flagsChangeHandler = (val: OptionType | OptionType[] | null) => { const expeditionChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
const formattedFlags = (val as OptionType[]).map( formik.setFieldValue('flags', e.target.value === 'true');
(flag) => flag.value as string
);
formik.setFieldValue('flags', formattedFlags);
}; };
useEffect(() => { useEffect(() => {
@@ -268,18 +264,19 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
/> />
<SelectInput <RadioGroup
label='Flags' label='Flags Ekspedisi'
isMulti name='flags'
value={SUPPLIER_FLAG_OPTIONS.filter((opt) => value={String(formik.values.flags)}
formik.values.flags?.includes(opt.value) onChange={expeditionChangeHandler}
)} onBlur={formik.handleBlur}
onChange={flagsChangeHandler} options={[
options={SUPPLIER_FLAG_OPTIONS} { label: 'Ya', value: 'true' },
{ label: 'Tidak', value: 'false' },
]}
isError={formik.touched.flags && Boolean(formik.errors.flags)} isError={formik.touched.flags && Boolean(formik.errors.flags)}
errorMessage={formik.errors.flags as string} errorMessage={formik.errors.flags as string}
isDisabled={type === 'detail'} disabled={type === 'detail'}
isClearable
/> />
</div> </div>
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { Nonstock } from '@/types/api/master-data/nonstock';
import { ColumnDef } from '@tanstack/react-table';
const NonstockTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no nonstock data displayed. Enter nonstock data to get started.',
}: {
columns: ColumnDef<Nonstock>[];
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 NonstockTableSkeleton;
@@ -1,6 +1,12 @@
'use client'; 'use client';
import { ChangeEventHandler, useEffect, useRef, useState } from 'react'; import {
ChangeEventHandler,
useEffect,
useMemo,
useRef,
useState,
} 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';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -11,72 +17,93 @@ import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import ProductCategoryTableSkeleton from '@/components/pages/master-data/product-category/skeleton/ProductCategoryTableSkeleton';
import { ProductCategory } from '@/types/api/master-data/product-category'; import { ProductCategory } from '@/types/api/master-data/product-category';
import { ProductCategoryApi } from '@/services/api/master-data'; import { ProductCategoryApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, 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 { useUiStore } from '@/stores/ui/ui.store';
import { ROWS_OPTIONS } from '@/config/constant';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
type = 'dropdown', popoverPosition = 'bottom',
props, props,
deleteClickHandler, deleteClickHandler,
}: { }: {
type: 'dropdown' | 'collapse'; popoverPosition: 'bottom' | 'top';
props: CellContext<ProductCategory, unknown>; props: CellContext<ProductCategory, unknown>;
deleteClickHandler: () => void; deleteClickHandler: () => void;
}) => { }) => {
const popoverId = `product-category#${props.row.original.id}`;
const popoverAnchorName = `--anchor-product-category#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
return ( return (
<RowOptionsMenuWrapper type={type}> <div className='relative'>
<RequirePermission permissions='lti.master.product_categories.detail'> <PopoverButton
<Button tabIndex={0}
href={`/master-data/product-category/detail/?productCategoryId=${props.row.original.id}`} variant='ghost'
variant='ghost' color='none'
color='primary' popoverTarget={popoverId}
className='justify-start text-sm' anchorName={popoverAnchorName}
> >
<Icon icon='mdi:eye-outline' width={16} height={16} /> <Icon icon='material-symbols:more-vert' width={16} height={16} />
Detail </PopoverButton>
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.product_categories.update'> <PopoverContent
<Button id={popoverId}
href={`/master-data/product-category/detail/edit/?productCategoryId=${props.row.original.id}`} anchorName={popoverAnchorName}
variant='ghost' position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
color='warning' className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
className='justify-start text-sm' >
> <div className='flex flex-col bg-base-100 rounded-xl'>
<Icon icon='mdi:pencil-outline' width={16} height={16} /> <RequirePermission permissions='lti.master.product_categories.detail'>
Edit <Button
</Button> href={`/master-data/product-category/detail/?productCategoryId=${props.row.original.id}`}
</RequirePermission> variant='ghost'
color='none'
<RequirePermission permissions='lti.master.product_categories.delete'> className='p-3 justify-start text-sm font-semibold w-full'
<Button onClick={closePopover}
onClick={deleteClickHandler} >
variant='ghost' <Icon icon='heroicons:eye' width={20} height={20} />
color='error' Detail
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content' </Button>
> </RequirePermission>
<Icon <RequirePermission permissions='lti.master.product_categories.update'>
icon='mdi:delete-outline' <Button
width={16} href={`/master-data/product-category/detail/edit/?productCategoryId=${props.row.original.id}`}
height={16} variant='ghost'
className='justify-start text-sm' color='none'
/> className='p-3 justify-start text-sm font-semibold w-full'
Delete onClick={closePopover}
</Button> >
</RequirePermission> <Icon icon='heroicons:pencil-square' width={20} height={20} />
</RowOptionsMenuWrapper> Edit
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.product_categories.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
>
<Icon icon='heroicons:trash' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
); );
}; };
@@ -91,10 +118,17 @@ const ProductCategoryTable = () => {
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter({
initial: { search: searchValue, nameSort: '' }, initial: {
paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' }, search: searchValue,
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
}); });
const [sorting, setSorting] = useState<SortingState>([]);
const { const {
data: productCategories, data: productCategories,
isLoading, isLoading,
@@ -105,71 +139,15 @@ const ProductCategoryTable = () => {
); );
const deleteModal = useModal(); const deleteModal = useModal();
const [selectedProductCategory, setSelectedProductCategory] = useState< const [selectedProductCategory, setSelectedProductCategory] = useState<
ProductCategory | undefined ProductCategory | undefined
>(undefined); >(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]); const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
const productCategoryColumns: ColumnDef<ProductCategory>[] = [ updateFilter('search', e.target.value);
{ };
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'code',
header: 'Code',
},
{
accessorKey: 'name',
header: 'Nama',
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedProductCategory(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
@@ -191,15 +169,51 @@ const ProductCategoryTable = () => {
setIsDeleteLoading(false); setIsDeleteLoading(false);
}; };
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const productCategoryColumns: ColumnDef<ProductCategory>[] = useMemo(
setSearchValue(e.target.value); () => [
updateFilter('search', e.target.value); {
}; header: 'No',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'code',
header: 'Code',
},
{
accessorKey: 'name',
header: 'Nama',
},
{
header: 'Aksi',
cell: (props: CellContext<ProductCategory, unknown>) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const newVal = val as OptionType;
setPageSize(newVal.value as number); const deleteClickHandler = () => {
}; setSelectedProductCategory(props.row.original);
deleteModal.openModal();
};
return (
<RowOptionsMenu
props={props}
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
deleteClickHandler={deleteClickHandler}
/>
);
},
},
],
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
);
useEffect(() => { useEffect(() => {
// Store current path on mount // Store current path on mount
@@ -223,91 +237,91 @@ const ProductCategoryTable = () => {
}; };
}, [resetSearchValue]); }, [resetSearchValue]);
useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
if (!isNameSorted) {
updateFilter('nameSort', '');
} else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
}
}, [sorting, updateFilter]);
return ( return (
<> <>
<div className='w-full p-0 sm:p-4'> <div className='w-full'>
<div className='flex flex-col gap-2 mb-4'> {/* Header Section */}
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'> <div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
<div className='w-full flex flex-row'> {/* Action Buttons */}
<RequirePermission permissions='lti.master.product_categories.create'> <div className='w-fit flex flex-row gap-3 flex-wrap'>
<Button <RequirePermission permissions='lti.master.product_categories.create'>
href='/master-data/product-category/add' <Button
variant='outline' href='/master-data/product-category/add'
color='primary' color='primary'
className='w-full sm:w-fit' className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
> >
<Icon icon='ic:round-plus' width={24} height={24} /> <Icon icon='heroicons:plus' width={20} height={20} />
Tambah Add Product Category
</Button> </Button>
</RequirePermission> </RequirePermission>
</div> </div>
{/* Search */}
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<DebouncedTextInput <DebouncedTextInput
name='search' name='search'
placeholder='Cari Product Category' placeholder='Cari Product Category'
value={tableFilterState.search} value={tableFilterState.search ?? ''}
onChange={searchChangeHandler} onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }} startAdornment={
/> <Icon
</div> icon='heroicons:magnifying-glass'
<div className='flex flex-row justify-end'> width={20}
<SelectInput height={20}
label='Baris' />
options={ROWS_OPTIONS} }
value={{ className={{
label: String(tableFilterState.pageSize), wrapper: 'w-full min-w-24 max-w-3xs',
value: tableFilterState.pageSize, inputWrapper: 'rounded-xl! shadow-button-soft',
input:
'placeholder:font-semibold placeholder:text-base-content/50',
}} }}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/> />
</div> </div>
</div> </div>
<Table<ProductCategory>
data={ {/* Table Section */}
isResponseSuccess(productCategories) ? productCategories?.data : [] <div className='flex flex-col mb-4'>
} {isLoading ? (
columns={productCategoryColumns} <div className='w-full flex flex-row justify-center items-center p-4'>
pageSize={tableFilterState.pageSize} <span className='loading loading-spinner loading-xl' />
page={ </div>
isResponseSuccess(productCategories) ) : !isResponseSuccess(productCategories) ||
? productCategories?.meta?.page productCategories.data?.length === 0 ? (
: 0 <div className='p-3'>
} <ProductCategoryTableSkeleton
totalItems={ columns={productCategoryColumns}
isResponseSuccess(productCategories) icon={
? productCategories?.meta?.total_results <Icon
: 0 icon='heroicons:document-text'
} className='text-white'
onPageChange={setPage} width={20}
isLoading={isLoading} height={20}
sorting={sorting} />
setSorting={setSorting} }
className={{ />
containerClassName: cn({ </div>
'mb-20': ) : (
isResponseSuccess(productCategories) && <Table<ProductCategory>
productCategories?.data?.length === 0, data={productCategories?.data}
}), columns={productCategoryColumns}
tableWrapperClassName: 'overflow-x-auto min-h-full!', pageSize={tableFilterState.pageSize}
tableClassName: 'font-inter w-full table-auto min-h-full!', page={productCategories?.meta?.page ?? 0}
headerRowClassName: 'border-b border-b-gray-200', totalItems={productCategories?.meta?.total_results ?? 0}
headerColumnClassName: onPageChange={setPage}
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', onPageSizeChange={setPageSize}
bodyRowClassName: 'border-b border-b-gray-200', isLoading={false}
bodyColumnClassName: sorting={sorting}
'px-6 py-3 last:flex last:flex-row last:justify-end', setSorting={setSorting}
}} className={{
/> containerClassName: 'p-3 mb-0',
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div>
</div> </div>
<ConfirmationModal <ConfirmationModal
ref={deleteModal.ref} ref={deleteModal.ref}
type='error' type='error'
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { ProductCategory } from '@/types/api/master-data/product-category';
import { ColumnDef } from '@tanstack/react-table';
const ProductCategoryTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no product category data displayed. Enter product category data to get started.',
}: {
columns: ColumnDef<ProductCategory>[];
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 ProductCategoryTableSkeleton;
@@ -1,13 +1,8 @@
'use client'; 'use client';
import { ChangeEventHandler, useCallback, useEffect, useState } from 'react'; import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
CellContext,
ColumnDef,
ColumnSort,
SortingState,
} from '@tanstack/react-table';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
@@ -16,69 +11,95 @@ import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import ProductTableSkeleton from '@/components/pages/master-data/product/skeleton/ProductTableSkeleton';
import { Product } from '@/types/api/master-data/product'; import { Product } from '@/types/api/master-data/product';
import { ProductApi } from '@/services/api/master-data'; import { ProductApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { formatCurrency } from '@/lib/helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
type = 'dropdown', popoverPosition = 'bottom',
props, props,
deleteClickHandler, deleteClickHandler,
}: { }: {
type: 'dropdown' | 'collapse'; popoverPosition: 'bottom' | 'top';
props: CellContext<Product, unknown>; props: CellContext<Product, unknown>;
deleteClickHandler: () => void; deleteClickHandler: () => void;
}) => ( }) => {
<RowOptionsMenuWrapper type={type}> const popoverId = `product#${props.row.original.id}`;
<RequirePermission permissions='lti.master.products.detail'> const popoverAnchorName = `--anchor-product#${props.row.original.id}`;
<Button
href={`/master-data/product/detail/?productId=${props.row.original.id}`} const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
return (
<div className='relative'>
<PopoverButton
tabIndex={0}
variant='ghost' variant='ghost'
color='primary' color='none'
className='justify-start text-sm' popoverTarget={popoverId}
anchorName={popoverAnchorName}
> >
<Icon icon='mdi:eye-outline' width={16} height={16} /> <Icon icon='material-symbols:more-vert' width={16} height={16} />
Detail </PopoverButton>
</Button>
</RequirePermission> <PopoverContent
<RequirePermission permissions='lti.master.products.update'> id={popoverId}
<Button anchorName={popoverAnchorName}
href={`/master-data/product/detail/edit/?productId=${props.row.original.id}`} position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
variant='ghost' className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
color='warning'
className='justify-start text-sm'
> >
<Icon icon='material-symbols:edit-outline' width={16} height={16} /> <div className='flex flex-col bg-base-100 rounded-xl'>
Edit <RequirePermission permissions='lti.master.products.detail'>
</Button> <Button
</RequirePermission> href={`/master-data/product/detail/?productId=${props.row.original.id}`}
<RequirePermission permissions='lti.master.products.delete'> variant='ghost'
<Button color='none'
onClick={deleteClickHandler} className='p-3 justify-start text-sm font-semibold w-full'
variant='ghost' onClick={closePopover}
color='error' >
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content' <Icon icon='heroicons:eye' width={20} height={20} />
> Detail
<Icon </Button>
icon='material-symbols:delete-outline-rounded' </RequirePermission>
width={16} <RequirePermission permissions='lti.master.products.update'>
height={16} <Button
className='justify-start text-sm' href={`/master-data/product/detail/edit/?productId=${props.row.original.id}`}
/> variant='ghost'
Delete color='none'
</Button> className='p-3 justify-start text-sm font-semibold w-full'
</RequirePermission> onClick={closePopover}
</RowOptionsMenuWrapper> >
); <Icon icon='heroicons:pencil-square' width={20} height={20} />
Edit
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.products.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
>
<Icon icon='heroicons:trash' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
);
};
const ProductsTable = () => { const ProductsTable = () => {
const { const {
@@ -90,21 +111,15 @@ const ProductsTable = () => {
} = useTableFilter({ } = useTableFilter({
initial: { initial: {
search: '', search: '',
nameSort: '',
skuSort: '',
brandSort: '',
categorySort: '',
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
nameSort: 'sort_name',
skuSort: 'sort_sku',
brandSort: 'sort_brand',
categorySort: 'sort_category',
}, },
}); });
const [sorting, setSorting] = useState<SortingState>([]);
const { const {
data: products, data: products,
isLoading, isLoading,
@@ -119,114 +134,10 @@ const ProductsTable = () => {
undefined undefined
); );
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]);
const productsColumns: ColumnDef<Product>[] = [ const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
{ updateFilter('search', e.target.value);
header: '#', };
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'sku',
header: 'SKU',
},
{
accessorKey: 'brand',
header: 'Merek',
},
{
accessorKey: 'product_category',
header: 'Kategori',
cell: (props) => props.row.original.product_category?.name ?? '-',
},
{
accessorKey: 'uom',
header: 'Satuan',
cell: (props) => props.row.original.uom?.name ?? '-',
},
{
accessorKey: 'product_price',
header: 'Harga Produk',
cell: (props) =>
props.row.original.product_price?.toLocaleString() ?? '-',
},
{
accessorKey: 'selling_price',
header: 'Harga Jual',
cell: (props) =>
props.row.original.selling_price?.toLocaleString() ?? '-',
},
{
accessorKey: 'tax',
header: 'Pajak (%)',
cell: (props) => props.row.original.tax ?? '-',
},
{
accessorKey: 'expiry_period',
header: 'Kadaluarsa (hari)',
cell: (props) => props.row.original.expiry_period ?? '-',
},
{
accessorKey: 'suppliers',
header: 'Supplier',
cell: (props) =>
props.row.original.suppliers?.map((s) => s.name).join(', ') || '-',
},
{
accessorKey: 'flags',
header: 'Flags',
cell: (props) =>
props.row.original.flags?.length
? props.row.original.flags.join(', ')
: '-',
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedProduct(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
@@ -248,110 +159,190 @@ const ProductsTable = () => {
setIsDeleteLoading(false); setIsDeleteLoading(false);
}; };
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const productsColumns: ColumnDef<Product>[] = useMemo(
updateFilter('search', e.target.value); () => [
}; {
header: 'No',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'sku',
header: 'SKU',
},
{
accessorKey: 'brand',
header: 'Merek',
},
{
accessorFn: (row) => row.product_category?.name ?? '-',
header: 'Kategori',
},
{
accessorFn: (row) => row.uom?.name ?? '-',
header: 'Satuan',
},
{
accessorKey: 'product_price',
header: 'Harga Produk',
cell: (props) =>
props.row.original.product_price
? formatCurrency(props.row.original.product_price)
: '-',
},
{
accessorKey: 'selling_price',
header: 'Harga Jual',
cell: (props) =>
props.row.original.selling_price
? formatCurrency(props.row.original.selling_price)
: '-',
},
{
accessorKey: 'tax',
header: 'Pajak (%)',
cell: (props) => props.row.original.tax ?? '-',
},
{
accessorKey: 'expiry_period',
header: 'Kadaluarsa (hari)',
cell: (props) => props.row.original.expiry_period ?? '-',
},
{
accessorFn: (row) =>
row.suppliers?.map((s) => s.name).join(', ') || '-',
header: 'Supplier',
},
{
accessorKey: 'flag',
header: 'Flag',
cell: (props) =>
props.row.original.flag ? props.row.original.flag : '-',
},
{
accessorFn: (row) =>
row.sub_flags?.length ? row.sub_flags.join(', ') : '-',
header: 'Kategori Flags',
},
{
header: 'Aksi',
cell: (props: CellContext<Product, unknown>) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const newVal = val as OptionType;
setPageSize(newVal.value as number);
};
const updateSortingFilter = useCallback( const deleteClickHandler = () => {
( setSelectedProduct(props.row.original);
sortName: Exclude<keyof typeof tableFilterState, 'page' | 'pageSize'>, deleteModal.openModal();
sortFilter: ColumnSort | undefined };
) => {
if (!sortFilter) { return (
updateFilter(sortName, ''); <RowOptionsMenu
} else { props={props}
updateFilter(sortName, sortFilter.desc ? 'desc' : 'asc'); popoverPosition={isLast2Rows ? 'top' : 'bottom'}
} deleteClickHandler={deleteClickHandler}
}, />
[updateFilter] );
},
},
],
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
); );
useEffect(() => {
const nameSortFilter = sorting.find((sortItem) => sortItem.id === 'name');
const skuSortFilter = sorting.find((sortItem) => sortItem.id === 'sku');
const brandSortFilter = sorting.find((sortItem) => sortItem.id === 'brand');
const categorySortFilter = sorting.find(
(sortItem) => sortItem.id === 'product_category'
);
updateSortingFilter('nameSort', nameSortFilter);
updateSortingFilter('skuSort', skuSortFilter);
updateSortingFilter('brandSort', brandSortFilter);
updateSortingFilter('categorySort', categorySortFilter);
}, [sorting, updateSortingFilter]);
return ( return (
<> <>
<div className='w-full p-0 sm:p-4'> <div className='w-full'>
<div className='flex flex-col gap-2 mb-4'> {/* Header Section */}
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'> <div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
<div className='w-full flex flex-row'> {/* Action Buttons */}
<RequirePermission permissions='lti.master.products.create'> <div className='w-fit flex flex-row gap-3 flex-wrap'>
<Button <RequirePermission permissions='lti.master.products.create'>
href='/master-data/product/add' <Button
variant='outline' href='/master-data/product/add'
className='w-full sm:w-fit' color='primary'
color='primary' className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
> >
<Icon icon='ic:round-plus' width={24} height={24} /> <Icon icon='heroicons:plus' width={20} height={20} />
Tambah Add Product
</Button> </Button>
</RequirePermission> </RequirePermission>
</div> </div>
{/* Search */}
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<DebouncedTextInput <DebouncedTextInput
name='search' name='search'
placeholder='Cari Produk' placeholder='Cari Produk'
value={tableFilterState.search} value={tableFilterState.search ?? ''}
onChange={searchChangeHandler} onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }} startAdornment={
/> <Icon
</div> icon='heroicons:magnifying-glass'
<div className='flex flex-row justify-end'> width={20}
<SelectInput height={20}
label='Baris' />
options={ROWS_OPTIONS} }
value={{ className={{
label: String(tableFilterState.pageSize), wrapper: 'w-full min-w-24 max-w-3xs',
value: tableFilterState.pageSize, inputWrapper: 'rounded-xl! shadow-button-soft',
input:
'placeholder:font-semibold placeholder:text-base-content/50',
}} }}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/> />
</div> </div>
</div> </div>
<Table<Product>
data={isResponseSuccess(products) ? products?.data : []} {/* Table Section */}
columns={productsColumns} <div className='flex flex-col mb-4'>
pageSize={tableFilterState.pageSize} {isLoading ? (
page={isResponseSuccess(products) ? products?.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(products) ? products?.meta?.total_results : 0 </div>
} ) : !isResponseSuccess(products) || products.data?.length === 0 ? (
onPageChange={setPage} <div className='p-3'>
isLoading={isLoading} <ProductTableSkeleton
sorting={sorting} columns={productsColumns}
setSorting={setSorting} icon={
className={{ <Icon
containerClassName: cn({ icon='heroicons:document-text'
'mb-20': className='text-white'
isResponseSuccess(products) && products?.data?.length === 0, width={20}
}), height={20}
tableWrapperClassName: 'overflow-x-auto min-h-full!', />
tableClassName: 'font-inter w-full table-auto min-h-full!', }
headerRowClassName: 'border-b border-b-gray-200', />
headerColumnClassName: </div>
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', ) : (
bodyRowClassName: 'border-b border-b-gray-200', <Table<Product>
bodyColumnClassName: data={products?.data}
'px-6 py-3 last:flex last:flex-row last:justify-end', columns={productsColumns}
}} pageSize={tableFilterState.pageSize}
/> page={products?.meta?.page ?? 0}
totalItems={products?.meta?.total_results ?? 0}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={false}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: 'p-3 mb-0',
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div>
</div> </div>
<ConfirmationModal <ConfirmationModal
ref={deleteModal.ref} ref={deleteModal.ref}
type='error' type='error'
@@ -1,4 +1,5 @@
import * as Yup from 'yup'; import * as Yup from 'yup';
import { PRODUCT_FLAG_MAPPING } from '@/config/constant';
type ProductFormSchemaType = { type ProductFormSchemaType = {
name: string; name: string;
@@ -25,7 +26,8 @@ type ProductFormSchemaType = {
} | null; } | null;
price: number; price: number;
}[]; }[];
flags: string[]; flag: string;
sub_flags?: string[];
}; };
export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> = export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
@@ -94,10 +96,26 @@ export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
) )
.required('Supplier wajib diisi!'), .required('Supplier wajib diisi!'),
flags: Yup.array() flag: Yup.string()
.min(1, 'Flag wajib diisi!')
.required('Flag wajib diisi!')
.typeError('Flag wajib diisi!'),
sub_flags: Yup.array()
.of(Yup.string().required()) .of(Yup.string().required())
.min(1, 'Minimal harus ada 1 flag!') .when('flag', {
.required('Flag wajib diisi!'), is: (flag: string) => {
const mapping = PRODUCT_FLAG_MAPPING.options.find(
(opt) => opt.flag.value === flag
);
return mapping?.allow_without_sub_flag === false;
},
then: (schema) =>
schema
.required('Sub flag wajib diisi!')
.min(1, 'Sub flag wajib diisi!'),
otherwise: (schema) => schema,
}),
}); });
export const UpdateProductFormSchema = ProductFormSchema; export const UpdateProductFormSchema = ProductFormSchema;
@@ -36,8 +36,16 @@ import {
ProductApi, ProductApi,
} from '@/services/api/master-data'; } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { PRODUCT_FLAG_OPTIONS } from '@/config/constant';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import { ConstantsApi } from '@/services/api/constants/constants';
import type {
TransformedConstants,
ProductFlagMapping,
} from '@/types/api/constants/constants';
import useSWR from 'swr';
import { PRODUCT_FLAG_MAPPING } from '@/config/constant';
import { Supplier } from '@/types/api/master-data/supplier'; import { Supplier } from '@/types/api/master-data/supplier';
import Card from '@/components/Card'; import Card from '@/components/Card';
import { removeArrayItemAndSync } from '@/lib/utils/formik'; import { removeArrayItemAndSync } from '@/lib/utils/formik';
@@ -53,6 +61,24 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
const [productFormErrorMessage, setProductFormErrorMessage] = useState(''); const [productFormErrorMessage, setProductFormErrorMessage] = useState('');
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const {
data: constants,
error: constantsError,
isLoading: isLoadingConstants,
} = useSWR<TransformedConstants | undefined>(
'constants',
ConstantsApi.fetchTransformedConstants.bind(ConstantsApi),
{
shouldRetryOnError: false,
}
);
const productFlagMapping: ProductFlagMapping | null = useMemo(() => {
if (constantsError || !constants?.product_flag_mapping) {
return PRODUCT_FLAG_MAPPING as unknown as ProductFlagMapping;
}
return constants.product_flag_mapping;
}, [constants, constantsError]);
const createProductHandler = useCallback( const createProductHandler = useCallback(
async (payload: CreateProductPayload) => { async (payload: CreateProductPayload) => {
@@ -110,7 +136,8 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
price: supplier.price, price: supplier.price,
})) }))
: [], : [],
flags: initialValues?.flags ?? [], flag: initialValues?.flag ?? '',
sub_flags: initialValues?.sub_flags ?? [],
}), }),
[initialValues] [initialValues]
); );
@@ -139,7 +166,8 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
supplier_id: s.supplier?.value as number, supplier_id: s.supplier?.value as number,
price: parseInt(s.price.toString()) || 0, price: parseInt(s.price.toString()) || 0,
})), })),
flags: values.flags.filter((f): f is string => typeof f === 'string'), flag: values.flag,
sub_flags: values.sub_flags,
}; };
switch (type) { switch (type) {
case 'add': case 'add':
@@ -200,6 +228,28 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
}); });
}, [supplierOptions, formik.values.suppliers]); }, [supplierOptions, formik.values.suppliers]);
const selectedFlagMapping = useMemo(() => {
return productFlagMapping?.options.find(
(opt) => opt.flag.value === formik.values.flag
);
}, [formik.values.flag, productFlagMapping]);
const subFlagOptions = useMemo<OptionType[]>(() => {
return selectedFlagMapping?.sub_flags ?? [];
}, [selectedFlagMapping]);
const selectedSubFlagValues = useMemo<OptionType[]>(() => {
return (
selectedFlagMapping?.sub_flags.filter((subFlag) =>
formik.values.sub_flags?.includes(subFlag.value as string)
) ?? []
);
}, [selectedFlagMapping, formik.values.sub_flags]);
const isSubFlagRequired = useMemo(() => {
return selectedFlagMapping?.allow_without_sub_flag === false;
}, [selectedFlagMapping]);
const addSupplierHandler = () => { const addSupplierHandler = () => {
formik.setFieldValue('suppliers', [ formik.setFieldValue('suppliers', [
...formik.values.suppliers, ...formik.values.suppliers,
@@ -213,7 +263,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
const deleteSupplierItemHandler = (idx: number) => { const deleteSupplierItemHandler = (idx: number) => {
const path = 'suppliers'; const path = 'suppliers';
// trims values, errors, and touched at idx
removeArrayItemAndSync(formik, path, idx); removeArrayItemAndSync(formik, path, idx);
}; };
@@ -428,26 +477,48 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
</div> </div>
<div className='grid sm:grid-cols-1 gap-4'> <div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<SelectInput <SelectInput
required required
label='Flags' label='Flags'
placeholder='Pilih flags...' placeholder='Pilih flags...'
isMulti value={productFlagMapping?.flags.find(
value={PRODUCT_FLAG_OPTIONS.filter((opt) => (opt) => opt.value === formik.values.flag
(formik.values.flags || []).includes(opt.value)
)} )}
onChange={(val) => {
const selectedFlag = String((val as OptionType)?.value ?? '');
formik.setFieldValue('flag', selectedFlag);
formik.setFieldValue('sub_flags', []);
}}
options={productFlagMapping?.flags ?? []}
isLoading={isLoadingConstants && !productFlagMapping}
isError={formik.touched.flag && Boolean(formik.errors.flag)}
errorMessage={formik.errors.flag as string}
isDisabled={type === 'detail'}
isClearable
/>
<SelectInput
label='Kategori Flags'
placeholder='Pilih kategori flags...'
isMulti
required={isSubFlagRequired}
value={selectedSubFlagValues}
onChange={(val) => { onChange={(val) => {
const arr = Array.isArray(val) ? val : val ? [val] : []; const arr = Array.isArray(val) ? val : val ? [val] : [];
formik.setFieldValue( formik.setFieldValue(
'flags', 'sub_flags',
arr.map((v) => (v as OptionType).value) arr.map((v) => String((v as OptionType).value))
); );
}} }}
options={PRODUCT_FLAG_OPTIONS} options={subFlagOptions}
isError={formik.touched.flags && Boolean(formik.errors.flags)} isLoading={isLoadingConstants && !productFlagMapping}
errorMessage={formik.errors.flags as string} isError={
isDisabled={type === 'detail'} formik.touched.sub_flags && Boolean(formik.errors.sub_flags)
}
errorMessage={formik.errors.sub_flags as string}
isDisabled={type === 'detail' || !formik.values.flag}
closeMenuOnSelect={false}
isClearable isClearable
/> />
</div> </div>
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { Product } from '@/types/api/master-data/product';
import { ColumnDef } from '@tanstack/react-table';
const ProductTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no product data displayed. Enter product data to get started.',
}: {
columns: ColumnDef<Product>[];
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 ProductTableSkeleton;
@@ -1,92 +1,121 @@
'use client'; 'use client';
import Button from '@/components/Button'; import { useMemo, useState } from 'react';
import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
import { ProductionStandard } from '@/types/api/master-data/production-standard';
import { Icon } from '@iconify/react';
import useSWR from 'swr'; import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import RequirePermission from '@/components/helper/RequirePermission';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import ProductionStandardTableSkeleton from '@/components/pages/master-data/production-standard/skeleton/ProductionStandardTableSkeleton';
import { ProductionStandard } from '@/types/api/master-data/production-standard';
import { ProductionStandardApi } from '@/services/api/master-data'; import { ProductionStandardApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import { CellContext } from '@tanstack/react-table';
import { useModal } from '@/components/Modal';
import { useState } from 'react';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import toast from 'react-hot-toast';
import { cn } from '@/lib/helper';
import RequirePermission from '@/components/helper/RequirePermission';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
type = 'dropdown', popoverPosition = 'bottom',
props, props,
deleteClickHandler, deleteClickHandler,
}: { }: {
type: 'dropdown' | 'collapse'; popoverPosition: 'bottom' | 'top';
props: CellContext<ProductionStandard, unknown>; props: CellContext<ProductionStandard, unknown>;
deleteClickHandler: () => void; deleteClickHandler: () => void;
}) => { }) => {
const popoverId = `production-standard#${props.row.original.id}`;
const popoverAnchorName = `--anchor-production-standard#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
return ( return (
<RowOptionsMenuWrapper type={type}> <div className='relative'>
<RequirePermission permissions='lti.master.production_standards.detail'> <PopoverButton
<Button tabIndex={0}
href={`/master-data/production-standard/detail/?productionStandardId=${props.row.original.id}`} variant='ghost'
variant='ghost' color='none'
color='primary' popoverTarget={popoverId}
className='justify-start text-sm' anchorName={popoverAnchorName}
> >
<Icon icon='mdi:eye-outline' width={16} height={16} /> <Icon icon='material-symbols:more-vert' width={16} height={16} />
Detail </PopoverButton>
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.production_standards.update'> <PopoverContent
<Button id={popoverId}
href={`/master-data/production-standard/detail/edit/?productionStandardId=${props.row.original.id}`} anchorName={popoverAnchorName}
variant='ghost' position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
color='warning' className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
className='justify-start text-sm' >
> <div className='flex flex-col bg-base-100 rounded-xl'>
<Icon icon='material-symbols:edit-outline' width={16} height={16} /> <RequirePermission permissions='lti.master.production_standards.detail'>
Edit <Button
</Button> href={`/master-data/production-standard/detail/?productionStandardId=${props.row.original.id}`}
</RequirePermission> variant='ghost'
color='none'
<RequirePermission permissions='lti.master.production_standards.delete'> className='p-3 justify-start text-sm font-semibold w-full'
<Button onClick={closePopover}
onClick={deleteClickHandler} >
variant='ghost' <Icon icon='heroicons:eye' width={20} height={20} />
color='error' Detail
className='text-error hover:text-inherit' </Button>
> </RequirePermission>
<Icon <RequirePermission permissions='lti.master.production_standards.update'>
icon='material-symbols:delete-outline-rounded' <Button
width={16} href={`/master-data/production-standard/detail/edit/?productionStandardId=${props.row.original.id}`}
height={16} variant='ghost'
className='justify-start text-sm' color='none'
/> className='p-3 justify-start text-sm font-semibold w-full'
Delete onClick={closePopover}
</Button> >
</RequirePermission> <Icon icon='heroicons:pencil-square' width={20} height={20} />
</RowOptionsMenuWrapper> Edit
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.production_standards.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
>
<Icon icon='heroicons:trash' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
); );
}; };
const ProductionStandardTable = () => { const ProductionStandardTable = () => {
const deleteModal = useModal(); const [sorting, setSorting] = useState<SortingState>([]);
const {
data: productionStandards,
isLoading,
mutate: refreshProductionStandards,
} = useSWR(
`${ProductionStandardApi.basePath}`,
ProductionStandardApi.getAllFetcher
);
const deleteModal = useModal();
const [selectedProductionStandard, setSelectedProductionStandard] = useState< const [selectedProductionStandard, setSelectedProductionStandard] = useState<
ProductionStandard | undefined ProductionStandard | undefined
>(undefined); >(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const { data: productionStandards, mutate: refreshProductionStandards } =
useSWR(
`${ProductionStandardApi.basePath}`,
ProductionStandardApi.getAllFetcher
);
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
@@ -107,112 +136,120 @@ const ProductionStandardTable = () => {
setIsDeleteLoading(false); setIsDeleteLoading(false);
}; };
const productionStandardColumns: ColumnDef<ProductionStandard>[] = useMemo(
() => [
{
header: 'No',
cell: (props) => props.row.index + 1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorFn: (row) => row.project_category ?? '-',
header: 'Kategori',
},
{
header: 'Aksi',
cell: (props: CellContext<ProductionStandard, unknown>) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedProductionStandard(props.row.original);
deleteModal.openModal();
};
return (
<RowOptionsMenu
props={props}
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
deleteClickHandler={deleteClickHandler}
/>
);
},
},
],
[deleteModal]
);
return ( return (
<> <>
<div className='flex flex-col gap-6 p-6'> <div className='w-full'>
<div className='flex flex-row gap-6 justify-start'> {/* Header Section */}
<RequirePermission permissions='lti.master.production_standards.create'> <div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
<Button {/* Action Buttons */}
href='/master-data/production-standard/add' <div className='w-fit flex flex-row gap-3 flex-wrap'>
variant='outline' <RequirePermission permissions='lti.master.production_standards.create'>
> <Button
<Icon icon='mdi:plus' /> Tambah href='/master-data/production-standard/add'
</Button> color='primary'
</RequirePermission> 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 Standard Production
</Button>
</RequirePermission>
</div>
</div> </div>
<RequirePermission permissions='lti.master.production_standards.list'>
<Table<ProductionStandard>
data={
isResponseSuccess(productionStandards)
? productionStandards.data
: []
}
columns={[
{
header: 'No',
accessorFn: (row, index) => index + 1,
},
{
header: 'Nama',
accessorKey: 'name',
},
{
header: 'Kategori',
accessorFn: (row) => row.project_category,
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows =
props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = {/* Table Section */}
currentRowRelativeIndex > currentPageSize - 2; <div className='flex flex-col mb-4'>
{isLoading ? (
const deleteClickHandler = () => { <div className='w-full flex flex-row justify-center items-center p-4'>
setSelectedProductionStandard(props.row.original); <span className='loading loading-spinner loading-xl' />
deleteModal.openModal(); </div>
}; ) : !isResponseSuccess(productionStandards) ||
productionStandards.data?.length === 0 ? (
return ( <div className='p-3'>
<> <ProductionStandardTableSkeleton
{currentPageSize > 2 && ( columns={productionStandardColumns}
<RowDropdownOptions isLast2Rows={isLast2Rows}> icon={
<RowOptionsMenu <Icon
type='dropdown' icon='heroicons:document-text'
props={props} className='text-white'
deleteClickHandler={deleteClickHandler} width={20}
/> height={20}
</RowDropdownOptions> />
)} }
/>
{currentPageSize <= 2 && ( </div>
<RowCollapseOptions> ) : (
<RowOptionsMenu <Table<ProductionStandard>
type='collapse' data={productionStandards.data}
props={props} columns={productionStandardColumns}
deleteClickHandler={deleteClickHandler} isLoading={false}
/> sorting={sorting}
</RowCollapseOptions> setSorting={setSorting}
)} className={{
</> containerClassName: 'p-3 mb-0',
); headerColumnClassName: 'text-nowrap',
}, }}
}, />
]} )}
className={{ </div>
headerColumnClassName: cn(
TABLE_DEFAULT_STYLING.headerColumnClassName,
'last:flex last:flex-row last:justify-end'
),
bodyColumnClassName: cn(
TABLE_DEFAULT_STYLING.bodyColumnClassName,
'last:flex last:flex-row last:justify-end'
),
}}
/>
</RequirePermission>
</div> </div>
<RequirePermission permissions='lti.master.production_standards.delete'>
<ConfirmationModal <ConfirmationModal
ref={deleteModal.ref} ref={deleteModal.ref}
type='error' type='error'
text={`Apakah anda yakin ingin menghapus data Production Standard ini (${selectedProductionStandard?.name})?`} text={`Apakah anda yakin ingin menghapus data Production Standard ini (${selectedProductionStandard?.name})?`}
secondaryButton={{ secondaryButton={{
text: 'Tidak', text: 'Tidak',
}} }}
primaryButton={{ primaryButton={{
text: 'Ya', text: 'Ya',
color: 'error', color: 'error',
isLoading: isDeleteLoading, isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler, onClick: confirmationModalDeleteClickHandler,
}} }}
/> />
</RequirePermission>
</> </>
); );
}; };
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { ProductionStandard } from '@/types/api/master-data/production-standard';
import { ColumnDef } from '@tanstack/react-table';
const ProductionStandardTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no production standard data displayed. Enter production standard data to get started.',
}: {
columns: ColumnDef<ProductionStandard>[];
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 ProductionStandardTableSkeleton;
@@ -1,87 +1,102 @@
'use client'; 'use client';
import Button from '@/components/Button'; import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import Button from '@/components/Button';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import Table from '@/components/Table';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import { ROWS_OPTIONS } from '@/config/constant'; import PopoverButton from '@/components/popover/PopoverButton';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import PopoverContent from '@/components/popover/PopoverContent';
import { cn } from '@/lib/helper'; import SupplierTableSkeleton from '@/components/pages/master-data/supplier/skeleton/SupplierTableSkeleton';
import { SupplierApi } from '@/services/api/master-data';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { Supplier } from '@/types/api/master-data/supplier';
import { Icon } from '@iconify/react';
import { CellContext, ColumnDef } from '@tanstack/react-table';
import { useState } from 'react';
import toast from 'react-hot-toast';
import useSWR from 'swr';
const RowOptions = ({ import { Supplier } from '@/types/api/master-data/supplier';
type = 'dropdown', import { SupplierApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
props, props,
deleteClickHandler, deleteClickHandler,
}: { }: {
type: 'dropdown' | 'collapse'; popoverPosition: 'bottom' | 'top';
props: CellContext<Supplier, unknown>; props: CellContext<Supplier, unknown>;
deleteClickHandler: () => void; deleteClickHandler: () => void;
}) => { }) => {
const popoverId = `supplier#${props.row.original.id}`;
const popoverAnchorName = `--anchor-supplier#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
return ( return (
<RowOptionsMenuWrapper type={type}> <div className='relative'>
<RequirePermission permissions='lti.master.suppliers.detail'> <PopoverButton
<Button tabIndex={0}
href={`/master-data/supplier/detail/?supplierId=${props.row.original.id}`} variant='ghost'
variant='ghost' color='none'
color='primary' popoverTarget={popoverId}
className='justify-start text-sm' anchorName={popoverAnchorName}
> >
<Icon <Icon icon='material-symbols:more-vert' width={16} height={16} />
icon='mdi:eye-outline' </PopoverButton>
width={16}
height={16} <PopoverContent
className='justify-start text-sm' id={popoverId}
/> anchorName={popoverAnchorName}
Detail position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
</Button> className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
</RequirePermission> >
<RequirePermission permissions='lti.master.suppliers.update'> <div className='flex flex-col bg-base-100 rounded-xl'>
<Button <RequirePermission permissions='lti.master.suppliers.detail'>
href={`/master-data/supplier/detail/edit/?supplierId=${props.row.original.id}`} <Button
variant='ghost' href={`/master-data/supplier/detail/?supplierId=${props.row.original.id}`}
color='warning' variant='ghost'
className='justify-start text-sm' color='none'
> className='p-3 justify-start text-sm font-semibold w-full'
<Icon onClick={closePopover}
icon='material-symbols:edit-outline' >
width={16} <Icon icon='heroicons:eye' width={20} height={20} />
height={16} Detail
className='justify-start text-sm' </Button>
/> </RequirePermission>
Edit <RequirePermission permissions='lti.master.suppliers.update'>
</Button> <Button
</RequirePermission> href={`/master-data/supplier/detail/edit/?supplierId=${props.row.original.id}`}
<RequirePermission permissions='lti.master.suppliers.delete'> variant='ghost'
<Button color='none'
onClick={deleteClickHandler} className='p-3 justify-start text-sm font-semibold w-full'
variant='ghost' onClick={closePopover}
color='error' >
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content' <Icon icon='heroicons:pencil-square' width={20} height={20} />
> Edit
<Icon </Button>
icon='material-symbols:delete-outline-rounded' </RequirePermission>
width={16} <RequirePermission permissions='lti.master.suppliers.delete'>
height={16} <Button
className='justify-start text-sm' onClick={() => {
/> deleteClickHandler();
Delete closePopover();
</Button> }}
</RequirePermission> variant='ghost'
</RowOptionsMenuWrapper> color='none'
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
>
<Icon icon='heroicons:trash' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
); );
}; };
@@ -93,15 +108,17 @@ const SuppliersTable = () => {
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter({
initial: { search: '', nameSort: '' }, initial: {
search: '',
},
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
nameSort: 'sort_name',
}, },
}); });
// Fetch Data const [sorting, setSorting] = useState<SortingState>([]);
const { const {
data: suppliers, data: suppliers,
isLoading, isLoading,
@@ -111,97 +128,16 @@ const SuppliersTable = () => {
SupplierApi.getAllFetcher SupplierApi.getAllFetcher
); );
// State
const deleteModal = useModal(); const deleteModal = useModal();
const [selectedSupplier, setSelectedSupplier] = useState< const [selectedSupplier, setSelectedSupplier] = useState<
Supplier | undefined Supplier | undefined
>(undefined); >(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
// Columns Definition const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
const suppliersColumns: ColumnDef<Supplier>[] = [ updateFilter('search', e.target.value);
{ };
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'alias',
header: 'Alias',
},
{
accessorKey: 'pic',
header: 'Nama PIC',
},
{
accessorKey: 'category',
header: 'Kategori',
},
{
accessorKey: 'type',
header: 'Tipe',
},
{
accessorKey: 'phone',
header: 'No. Telp',
},
{
accessorKey: 'email',
header: 'Email',
},
{
accessorKey: 'address',
header: 'Alamat',
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedSupplier(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptions
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptions
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
// Handler
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
@@ -221,82 +157,161 @@ const SuppliersTable = () => {
toast.success('Successfully delete Supplier!'); toast.success('Successfully delete Supplier!');
setIsDeleteLoading(false); setIsDeleteLoading(false);
}; };
const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
updateFilter('search', e.target.value); const suppliersColumns: ColumnDef<Supplier>[] = useMemo(
}; () => [
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { {
const newVal = val as OptionType; header: 'No',
setPageSize(newVal.value as number); cell: (props) =>
}; tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'alias',
header: 'Alias',
},
{
accessorKey: 'pic',
header: 'Nama PIC',
},
{
accessorKey: 'category',
header: 'Kategori',
},
{
accessorKey: 'type',
header: 'Tipe',
},
{
accessorKey: 'phone',
header: 'No. Telp',
},
{
accessorKey: 'email',
header: 'Email',
},
{
accessorKey: 'address',
header: 'Alamat',
},
{
header: 'Aksi',
cell: (props: CellContext<Supplier, unknown>) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedSupplier(props.row.original);
deleteModal.openModal();
};
return (
<RowOptionsMenu
props={props}
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
deleteClickHandler={deleteClickHandler}
/>
);
},
},
],
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
);
return ( return (
<> <>
<div className='w-full p-0 sm:p-4'> <div className='w-full'>
<div className='flex flex-col gap-2 mb-4'> {/* Header Section */}
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'> <div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
<div className='w-full flex flex-row'> {/* Action Buttons */}
<RequirePermission permissions='lti.master.suppliers.create'> <div className='w-fit flex flex-row gap-3 flex-wrap'>
<Button <RequirePermission permissions='lti.master.suppliers.create'>
href='/master-data/supplier/add' <Button
variant='outline' href='/master-data/supplier/add'
color='primary' color='primary'
className='w-full sm:w-fit' className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
> >
<Icon icon='ic:round-plus' width={24} height={24} /> <Icon icon='heroicons:plus' width={20} height={20} />
Tambah Add Supplier
</Button> </Button>
</RequirePermission> </RequirePermission>
</div> </div>
{/* Search */}
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<DebouncedTextInput <DebouncedTextInput
name='search' name='search'
placeholder='Cari Supplier' placeholder='Cari Supplier'
value={tableFilterState.search} value={tableFilterState.search ?? ''}
onChange={searchChangeHandler} onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }} startAdornment={
/> <Icon
</div> icon='heroicons:magnifying-glass'
width={20}
<div className='flex flex-row justify-end'> height={20}
<SelectInput />
label='Baris' }
options={ROWS_OPTIONS} className={{
value={{ wrapper: 'w-full min-w-24 max-w-3xs',
label: String(tableFilterState.pageSize), inputWrapper: 'rounded-xl! shadow-button-soft',
value: tableFilterState.pageSize, input:
'placeholder:font-semibold placeholder:text-base-content/50',
}} }}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/> />
</div> </div>
</div> </div>
<Table<Supplier> {/* Table Section */}
data={isResponseSuccess(suppliers) ? suppliers?.data : []} <div className='flex flex-col mb-4'>
columns={suppliersColumns} {isLoading ? (
pageSize={tableFilterState.pageSize} <div className='w-full flex flex-row justify-center items-center p-4'>
page={isResponseSuccess(suppliers) ? suppliers?.meta?.page : 0} <span className='loading loading-spinner loading-xl' />
totalItems={ </div>
isResponseSuccess(suppliers) ? suppliers?.meta?.total_results : 0 ) : !isResponseSuccess(suppliers) || suppliers.data?.length === 0 ? (
} <div className='p-3'>
onPageChange={setPage} <SupplierTableSkeleton
isLoading={isLoading} columns={suppliersColumns}
className={{ icon={
containerClassName: cn({ <Icon
'mb-20': icon='heroicons:document-text'
isResponseSuccess(suppliers) && suppliers?.data?.length === 0, className='text-white'
}), width={20}
tableWrapperClassName: 'overflow-x-auto min-h-full!', height={20}
tableClassName: 'font-inter w-full table-auto min-h-full!', />
headerRowClassName: 'border-b border-b-gray-200', }
headerColumnClassName: />
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', </div>
bodyRowClassName: 'border-b border-b-gray-200', ) : (
bodyColumnClassName: <Table<Supplier>
'px-6 py-3 last:flex last:flex-row last:justify-end', data={suppliers?.data}
}} columns={suppliersColumns}
/> pageSize={tableFilterState.pageSize}
page={suppliers?.meta?.page ?? 0}
totalItems={suppliers?.meta?.total_results ?? 0}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={false}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: 'p-3 mb-0',
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div>
</div> </div>
<ConfirmationModal <ConfirmationModal
ref={deleteModal.ref} ref={deleteModal.ref}
type='error' type='error'
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { Supplier } from '@/types/api/master-data/supplier';
import { ColumnDef } from '@tanstack/react-table';
const SupplierTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no supplier data displayed. Enter supplier data to get started.',
}: {
columns: ColumnDef<Supplier>[];
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 SupplierTableSkeleton;
+191 -179
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { ChangeEventHandler, useEffect, useState } from 'react'; import { ChangeEventHandler, useMemo, useState } 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';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -11,71 +11,92 @@ import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import UomTableSkeleton from '@/components/pages/master-data/uom/skeleton/UomTableSkeleton';
import { Uom } from '@/types/api/master-data/uom'; import { Uom } from '@/types/api/master-data/uom';
import { UomApi } from '@/services/api/master-data'; import { UomApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
type = 'dropdown', popoverPosition = 'bottom',
props, props,
deleteClickHandler, deleteClickHandler,
}: { }: {
type: 'dropdown' | 'collapse'; popoverPosition: 'bottom' | 'top';
props: CellContext<Uom, unknown>; props: CellContext<Uom, unknown>;
deleteClickHandler: () => void; deleteClickHandler: () => void;
}) => { }) => {
const popoverId = `uom#${props.row.original.id}`;
const popoverAnchorName = `--anchor-uom#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
return ( return (
<RowOptionsMenuWrapper type={type}> <div className='relative'>
<RequirePermission permissions='lti.master.uoms.detail'> <PopoverButton
<Button tabIndex={0}
href={`/master-data/uom/detail/?uomId=${props.row.original.id}`} variant='ghost'
variant='ghost' color='none'
color='primary' popoverTarget={popoverId}
className='justify-start text-sm' anchorName={popoverAnchorName}
> >
<Icon icon='mdi:eye-outline' width={16} height={16} /> <Icon icon='material-symbols:more-vert' width={16} height={16} />
Detail </PopoverButton>
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.uoms.update'> <PopoverContent
<Button id={popoverId}
href={`/master-data/uom/detail/edit/?uomId=${props.row.original.id}`} anchorName={popoverAnchorName}
variant='ghost' position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
color='warning' className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
className='justify-start text-sm' >
> <div className='flex flex-col bg-base-100 rounded-xl'>
<Icon icon='material-symbols:edit-outline' width={16} height={16} /> <RequirePermission permissions='lti.master.uoms.detail'>
Edit <Button
</Button> href={`/master-data/uom/detail/?uomId=${props.row.original.id}`}
</RequirePermission> variant='ghost'
color='none'
<RequirePermission permissions='lti.master.uoms.delete'> className='p-3 justify-start text-sm font-semibold w-full'
<Button onClick={closePopover}
onClick={deleteClickHandler} >
variant='ghost' <Icon icon='heroicons:eye' width={20} height={20} />
color='error' Detail
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content' </Button>
> </RequirePermission>
<Icon <RequirePermission permissions='lti.master.uoms.update'>
icon='material-symbols:delete-outline-rounded' <Button
width={16} href={`/master-data/uom/detail/edit/?uomId=${props.row.original.id}`}
height={16} variant='ghost'
className='justify-start text-sm' color='none'
/> className='p-3 justify-start text-sm font-semibold w-full'
Delete onClick={closePopover}
</Button> >
</RequirePermission> <Icon icon='heroicons:pencil-square' width={20} height={20} />
</RowOptionsMenuWrapper> Edit
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.uoms.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
>
<Icon icon='heroicons:trash' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
); );
}; };
@@ -87,10 +108,17 @@ const UomsTable = () => {
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter({
initial: { search: '', nameSort: '' }, initial: {
paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' }, search: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
}); });
const [sorting, setSorting] = useState<SortingState>([]);
const { const {
data: uoms, data: uoms,
isLoading, isLoading,
@@ -101,65 +129,12 @@ const UomsTable = () => {
); );
const deleteModal = useModal(); const deleteModal = useModal();
const [selectedUom, setSelectedUom] = useState<Uom | undefined>(undefined); const [selectedUom, setSelectedUom] = useState<Uom | undefined>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]); const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
const uomsColumns: ColumnDef<Uom>[] = [ };
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedUom(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
@@ -179,93 +154,130 @@ const UomsTable = () => {
setIsDeleteLoading(false); setIsDeleteLoading(false);
}; };
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const uomsColumns: ColumnDef<Uom>[] = useMemo(
updateFilter('search', e.target.value); () => [
}; {
header: 'No',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
header: 'Aksi',
cell: (props: CellContext<Uom, unknown>) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const newVal = val as OptionType;
setPageSize(newVal.value as number); const deleteClickHandler = () => {
}; setSelectedUom(props.row.original);
deleteModal.openModal();
};
// track sorting return (
useEffect(() => { <RowOptionsMenu
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name'); props={props}
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
if (!isNameSorted) { deleteClickHandler={deleteClickHandler}
updateFilter('nameSort', ''); />
} else { );
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc'); },
} },
}, [sorting, updateFilter]); ],
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
);
return ( return (
<> <>
<div className='w-full p-0 sm:p-4'> <div className='w-full'>
<div className='flex flex-col gap-2 mb-4'> {/* Header Section */}
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'> <div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
<div className='w-full flex flex-row'> {/* Action Buttons */}
<RequirePermission permissions='lti.master.uoms.create'> <div className='w-fit flex flex-row gap-3 flex-wrap'>
<Button <RequirePermission permissions='lti.master.uoms.create'>
href='/master-data/uom/add' <Button
variant='outline' href='/master-data/uom/add'
color='primary' color='primary'
className='w-full sm:w-fit' className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
> >
<Icon icon='ic:round-plus' width={24} height={24} /> <Icon icon='heroicons:plus' width={20} height={20} />
Tambah Add UOM
</Button> </Button>
</RequirePermission> </RequirePermission>
</div> </div>
{/* Search */}
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<DebouncedTextInput <DebouncedTextInput
name='search' name='search'
placeholder='Cari UOM' placeholder='Cari UOM'
value={tableFilterState.search} value={tableFilterState.search ?? ''}
onChange={searchChangeHandler} onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }} startAdornment={
/> <Icon
</div> icon='heroicons:magnifying-glass'
width={20}
<div className='flex flex-row justify-end'> height={20}
<SelectInput />
label='Baris' }
options={ROWS_OPTIONS} className={{
value={{ wrapper: 'w-full min-w-24 max-w-3xs',
label: String(tableFilterState.pageSize), inputWrapper: 'rounded-xl! shadow-button-soft',
value: tableFilterState.pageSize, input:
'placeholder:font-semibold placeholder:text-base-content/50',
}} }}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/> />
</div> </div>
</div> </div>
<Table<Uom> {/* Table Section */}
data={isResponseSuccess(uoms) ? uoms?.data : []} <div className='flex flex-col mb-4'>
columns={uomsColumns} {isLoading ? (
pageSize={tableFilterState.pageSize} <div className='w-full flex flex-row justify-center items-center p-4'>
page={isResponseSuccess(uoms) ? uoms?.meta?.page : 0} <span className='loading loading-spinner loading-xl' />
totalItems={isResponseSuccess(uoms) ? uoms?.meta?.total_results : 0} </div>
onPageChange={setPage} ) : !isResponseSuccess(uoms) || uoms.data?.length === 0 ? (
isLoading={isLoading} <div className='p-3'>
sorting={sorting} <UomTableSkeleton
setSorting={setSorting} columns={uomsColumns}
className={{ icon={
containerClassName: cn({ <Icon
'mb-20': isResponseSuccess(uoms) && uoms?.data?.length === 0, icon='heroicons:document-text'
}), className='text-white'
tableWrapperClassName: 'overflow-x-auto min-h-full!', width={20}
tableClassName: 'font-inter w-full table-auto min-h-full!', height={20}
headerRowClassName: 'border-b border-b-gray-200', />
headerColumnClassName: }
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', />
bodyRowClassName: 'border-b border-b-gray-200', </div>
bodyColumnClassName: ) : (
'px-6 py-3 last:flex last:flex-row last:justify-end', <Table<Uom>
}} data={uoms?.data}
/> columns={uomsColumns}
pageSize={tableFilterState.pageSize}
page={uoms?.meta?.page ?? 0}
totalItems={uoms?.meta?.total_results ?? 0}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={false}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: 'p-3 mb-0',
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div>
</div> </div>
<ConfirmationModal <ConfirmationModal
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { Uom } from '@/types/api/master-data/uom';
import { ColumnDef } from '@tanstack/react-table';
const UomTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no uom data displayed. Enter uom data to get started.',
}: {
columns: ColumnDef<Uom>[];
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 UomTableSkeleton;
@@ -1,13 +1,8 @@
'use client'; 'use client';
import { ChangeEventHandler, useCallback, useEffect, useState } from 'react'; import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
CellContext,
ColumnDef,
ColumnSort,
SortingState,
} from '@tanstack/react-table';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
@@ -16,71 +11,92 @@ import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import WarehouseTableSkeleton from '@/components/pages/master-data/warehouse/skeleton/WarehouseTableSkeleton';
import { Warehouse } from '@/types/api/master-data/warehouse'; import { Warehouse } from '@/types/api/master-data/warehouse';
import { WarehouseApi } from '@/services/api/master-data'; import { WarehouseApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
type = 'dropdown', popoverPosition = 'bottom',
props, props,
deleteClickHandler, deleteClickHandler,
}: { }: {
type: 'dropdown' | 'collapse'; popoverPosition: 'bottom' | 'top';
props: CellContext<Warehouse, unknown>; props: CellContext<Warehouse, unknown>;
deleteClickHandler: () => void; deleteClickHandler: () => void;
}) => { }) => {
const popoverId = `warehouse#${props.row.original.id}`;
const popoverAnchorName = `--anchor-warehouse#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
return ( return (
<RowOptionsMenuWrapper type={type}> <div className='relative'>
<RequirePermission permissions='lti.master.warehouses.detail'> <PopoverButton
<Button tabIndex={0}
href={`/master-data/warehouse/detail/?warehouseId=${props.row.original.id}`} variant='ghost'
variant='ghost' color='none'
color='primary' popoverTarget={popoverId}
className='justify-start text-sm' anchorName={popoverAnchorName}
> >
<Icon icon='mdi:eye-outline' width={16} height={16} /> <Icon icon='material-symbols:more-vert' width={16} height={16} />
Detail </PopoverButton>
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.warehouses.update'> <PopoverContent
<Button id={popoverId}
href={`/master-data/warehouse/detail/edit/?warehouseId=${props.row.original.id}`} anchorName={popoverAnchorName}
variant='ghost' position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
color='warning' className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
className='justify-start text-sm' >
> <div className='flex flex-col bg-base-100 rounded-xl'>
<Icon icon='material-symbols:edit-outline' width={16} height={16} /> <RequirePermission permissions='lti.master.warehouses.detail'>
Edit <Button
</Button> href={`/master-data/warehouse/detail/?warehouseId=${props.row.original.id}`}
</RequirePermission> variant='ghost'
color='none'
<RequirePermission permissions='lti.master.warehouses.delete'> className='p-3 justify-start text-sm font-semibold w-full'
<Button onClick={closePopover}
onClick={deleteClickHandler} >
variant='ghost' <Icon icon='heroicons:eye' width={20} height={20} />
color='error' Detail
className='text-error hover:text-inherit' </Button>
> </RequirePermission>
<Icon <RequirePermission permissions='lti.master.warehouses.update'>
icon='material-symbols:delete-outline-rounded' <Button
width={16} href={`/master-data/warehouse/detail/edit/?warehouseId=${props.row.original.id}`}
height={16} variant='ghost'
className='justify-start text-sm' color='none'
/> className='p-3 justify-start text-sm font-semibold w-full'
Delete onClick={closePopover}
</Button> >
</RequirePermission> <Icon icon='heroicons:pencil-square' width={20} height={20} />
</RowOptionsMenuWrapper> Edit
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.warehouses.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
>
<Icon icon='heroicons:trash' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
); );
}; };
@@ -94,23 +110,15 @@ const WarehousesTable = () => {
} = useTableFilter({ } = useTableFilter({
initial: { initial: {
search: '', search: '',
nameSort: '',
typeSort: '',
areaSort: '',
locationSort: '',
kandangSort: '',
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
nameSort: 'sort_name',
typeSort: 'sort_type',
areaSort: ' sort_area',
locationSort: ' sort_location',
kandangSort: ' sort_kandang',
}, },
}); });
const [sorting, setSorting] = useState<SortingState>([]);
const { const {
data: warehouses, data: warehouses,
isLoading, isLoading,
@@ -121,101 +129,14 @@ const WarehousesTable = () => {
); );
const deleteModal = useModal(); const deleteModal = useModal();
const [selectedWarehouse, setSelectedWarehouse] = useState< const [selectedWarehouse, setSelectedWarehouse] = useState<
Warehouse | undefined Warehouse | undefined
>(undefined); >(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]); const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
const warehousesColumns: ColumnDef<Warehouse>[] = [ };
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'type',
header: 'Tipe',
},
{
accessorKey: 'area',
header: 'Area',
cell: (props) => props.row.original.area.name,
},
{
accessorKey: 'location',
header: 'Lokasi',
cell: (props) => {
if (
props.row.original.type === 'LOKASI' ||
props.row.original.type === 'KANDANG'
) {
return props.row.original.location.name;
} else {
return '-';
}
},
},
{
accessorKey: 'kandang',
header: 'Kandang',
cell: (props) => {
if (props.row.original.type === 'KANDANG') {
return props.row.original.kandang.name;
} else {
return '-';
}
},
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedWarehouse(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
@@ -237,118 +158,162 @@ const WarehousesTable = () => {
setIsDeleteLoading(false); setIsDeleteLoading(false);
}; };
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const warehousesColumns: ColumnDef<Warehouse>[] = useMemo(
updateFilter('search', e.target.value); () => [
}; {
header: 'No',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'type',
header: 'Tipe',
},
{
accessorFn: (row) => row.area?.name ?? '-',
header: 'Area',
},
{
accessorKey: 'location',
header: 'Lokasi',
cell: (props) => {
if (
props.row.original.type === 'LOKASI' ||
props.row.original.type === 'KANDANG'
) {
return props.row.original.location?.name ?? '-';
}
return '-';
},
},
{
accessorKey: 'kandang',
header: 'Kandang',
cell: (props) => {
if (props.row.original.type === 'KANDANG') {
return props.row.original.kandang?.name ?? '-';
}
return '-';
},
},
{
header: 'Aksi',
cell: (props: CellContext<Warehouse, unknown>) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const newVal = val as OptionType;
setPageSize(newVal.value as number); const deleteClickHandler = () => {
}; setSelectedWarehouse(props.row.original);
deleteModal.openModal();
};
const updateSortingFilter = useCallback( return (
( <RowOptionsMenu
sortName: Exclude<keyof typeof tableFilterState, 'page' | 'pageSize'>, props={props}
sortFilter: ColumnSort | undefined popoverPosition={isLast2Rows ? 'top' : 'bottom'}
) => { deleteClickHandler={deleteClickHandler}
if (!sortFilter) { />
updateFilter(sortName, ''); );
} else { },
updateFilter(sortName, sortFilter.desc ? 'desc' : 'asc'); },
} ],
}, [tableFilterState.pageSize, tableFilterState.page, deleteModal]
[updateFilter]
); );
// track sorting
useEffect(() => {
const nameSortFilter = sorting.find((sortItem) => sortItem.id === 'name');
const typeSortFilter = sorting.find((sortItem) => sortItem.id === 'type');
const areaSortFilter = sorting.find((sortItem) => sortItem.id === 'area');
const locationSortFilter = sorting.find(
(sortItem) => sortItem.id === 'location'
);
const kandangSortFilter = sorting.find(
(sortItem) => sortItem.id === 'kandang'
);
updateSortingFilter('nameSort', nameSortFilter);
updateSortingFilter('typeSort', typeSortFilter);
updateSortingFilter('areaSort', areaSortFilter);
updateSortingFilter('locationSort', locationSortFilter);
updateSortingFilter('kandangSort', kandangSortFilter);
}, [sorting, updateSortingFilter]);
return ( return (
<> <>
<div className='w-full p-0 sm:p-4'> <div className='w-full'>
<div className='flex flex-col gap-2 mb-4'> {/* Header Section */}
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'> <div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
<div className='w-full flex flex-row'> {/* Action Buttons */}
<RequirePermission permissions='lti.master.warehouses.create'> <div className='w-fit flex flex-row gap-3 flex-wrap'>
<Button <RequirePermission permissions='lti.master.warehouses.create'>
href='/master-data/warehouse/add' <Button
variant='outline' href='/master-data/warehouse/add'
color='primary' color='primary'
className='w-full sm:w-fit' className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
> >
<Icon icon='ic:round-plus' width={24} height={24} /> <Icon icon='heroicons:plus' width={20} height={20} />
Tambah Add Warehouse
</Button> </Button>
</RequirePermission> </RequirePermission>
</div> </div>
{/* Search */}
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<DebouncedTextInput <DebouncedTextInput
name='search' name='search'
placeholder='Cari Warehouse' placeholder='Cari Warehouse'
value={tableFilterState.search} value={tableFilterState.search ?? ''}
onChange={searchChangeHandler} onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }} startAdornment={
/> <Icon
</div> icon='heroicons:magnifying-glass'
width={20}
<div className='flex flex-row justify-end'> height={20}
<SelectInput />
label='Baris' }
options={ROWS_OPTIONS} className={{
value={{ wrapper: 'w-full min-w-24 max-w-3xs',
label: String(tableFilterState.pageSize), inputWrapper: 'rounded-xl! shadow-button-soft',
value: tableFilterState.pageSize, input:
'placeholder:font-semibold placeholder:text-base-content/50',
}} }}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/> />
</div> </div>
</div> </div>
<Table<Warehouse> {/* Table Section */}
data={isResponseSuccess(warehouses) ? warehouses?.data : []} <div className='flex flex-col mb-4'>
columns={warehousesColumns} {isLoading ? (
pageSize={tableFilterState.pageSize} <div className='w-full flex flex-row justify-center items-center p-4'>
page={isResponseSuccess(warehouses) ? warehouses?.meta?.page : 0} <span className='loading loading-spinner loading-xl' />
totalItems={ </div>
isResponseSuccess(warehouses) ? warehouses?.meta?.total_results : 0 ) : !isResponseSuccess(warehouses) ||
} warehouses.data?.length === 0 ? (
onPageChange={setPage} <div className='p-3'>
isLoading={isLoading} <WarehouseTableSkeleton
sorting={sorting} columns={warehousesColumns}
setSorting={setSorting} icon={
className={{ <Icon
containerClassName: cn({ icon='heroicons:document-text'
'mb-20': className='text-white'
isResponseSuccess(warehouses) && warehouses?.data?.length === 0, width={20}
}), height={20}
tableWrapperClassName: 'overflow-x-auto min-h-full!', />
tableClassName: 'font-inter w-full table-auto min-h-full!', }
headerRowClassName: 'border-b border-b-gray-200', />
headerColumnClassName: </div>
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', ) : (
bodyRowClassName: 'border-b border-b-gray-200', <Table<Warehouse>
bodyColumnClassName: data={warehouses?.data}
'px-6 py-3 last:flex last:flex-row last:justify-end', columns={warehousesColumns}
}} pageSize={tableFilterState.pageSize}
/> page={warehouses?.meta?.page ?? 0}
totalItems={warehouses?.meta?.total_results ?? 0}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={false}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: 'p-3 mb-0',
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div>
</div> </div>
<ConfirmationModal <ConfirmationModal
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { Warehouse } from '@/types/api/master-data/warehouse';
import { ColumnDef } from '@tanstack/react-table';
const WarehouseTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no warehouse data displayed. Enter warehouse data to get started.',
}: {
columns: ColumnDef<Warehouse>[];
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 WarehouseTableSkeleton;
@@ -32,6 +32,7 @@ import StatusBadge from '@/components/helper/StatusBadge';
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 ProjectFlockConfirmationModal from './ProjectFlockConfirmationModal'; import ProjectFlockConfirmationModal from './ProjectFlockConfirmationModal';
import ProjectFlockTableSkeleton from '@/components/pages/production/project-flock/skeleton/ProjectFlockTableSkeleton';
import { useProjectFlockStore } from '@/stores/production/project-flock/project-flock.store'; import { useProjectFlockStore } from '@/stores/production/project-flock/project-flock.store';
import { ProjectFlockFormValues } from './form/ProjectFlockForm.schema'; import { ProjectFlockFormValues } from './form/ProjectFlockForm.schema';
import { useChickinStore } from '@/stores/production/chickin/chickin.store'; import { useChickinStore } from '@/stores/production/chickin/chickin.store';
@@ -997,46 +998,69 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
</div> </div>
</div> </div>
<Table<ProjectFlock> <div className='flex flex-col mb-4'>
data={isResponseSuccess(projectFlocks) ? projectFlocks?.data : []} {isLoading ? (
columns={columns} <div className='w-full flex flex-row justify-center items-center p-4'>
pageSize={tableFilterState.pageSize} <span className='loading loading-spinner loading-xl' />
page={ </div>
isResponseSuccess(projectFlocks) ? projectFlocks?.meta?.page : 0 ) : !isResponseSuccess(projectFlocks) ||
} projectFlocks.data?.length === 0 ? (
totalItems={ <div className='p-3'>
isResponseSuccess(projectFlocks) <ProjectFlockTableSkeleton
? projectFlocks?.meta?.total_results columns={columns}
: 0 icon={
} <Icon
onPageChange={(page) => { icon='heroicons:document-text'
setPage(page); className='text-white'
}} width={20}
onPageSizeChange={(pageSize) => { height={20}
setPageSize(pageSize); />
}} }
isLoading={isLoading} />
sorting={sorting} </div>
setSorting={setSorting} ) : (
rowSelection={rowSelection} <Table<ProjectFlock>
setRowSelection={setRowSelection} data={
enableRowSelection={(row) => { isResponseSuccess(projectFlocks) ? projectFlocks?.data : []
const projectFlock = row.original; }
return ( columns={columns}
projectFlock.approval?.step_number === 1 && pageSize={tableFilterState.pageSize}
projectFlock.approval?.action !== 'REJECTED' page={
); isResponseSuccess(projectFlocks)
}} ? projectFlocks?.meta?.page
withCheckbox : 0
className={{ }
containerClassName: cn('p-3', { totalItems={
'w-full mb-20': isResponseSuccess(projectFlocks)
isResponseSuccess(projectFlocks) && ? projectFlocks?.meta?.total_results
projectFlocks?.data?.length === 0, : 0
}), }
headerColumnClassName: 'text-nowrap', onPageChange={(page) => {
}} setPage(page);
/> }}
onPageSizeChange={(pageSize) => {
setPageSize(pageSize);
}}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
enableRowSelection={(row) => {
const projectFlock = row.original;
return (
projectFlock.approval?.step_number === 1 &&
projectFlock.approval?.action !== 'REJECTED'
);
}}
withCheckbox
className={{
containerClassName: cn('p-3 mb-0'),
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div>
</div> </div>
</div> </div>
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { ColumnDef } from '@tanstack/react-table';
const ProjectFlockTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no project flock data displayed. Enter project flock data to get started.',
}: {
columns: ColumnDef<ProjectFlock>[];
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 ProjectFlockTableSkeleton;
@@ -22,6 +22,7 @@ import Dropdown from '@/components/Dropdown';
import StatusBadge from '@/components/helper/StatusBadge'; import StatusBadge from '@/components/helper/StatusBadge';
import TransferToLayingFilterModal from '@/components/pages/production/transfer-to-laying/TransferToLayingFilterModal'; import TransferToLayingFilterModal from '@/components/pages/production/transfer-to-laying/TransferToLayingFilterModal';
import TransferToLayingConfirmationModal from '@/components/pages/production/transfer-to-laying/TransferToLayingConfirmationModal'; import TransferToLayingConfirmationModal from '@/components/pages/production/transfer-to-laying/TransferToLayingConfirmationModal';
import TransferToLayingTableSkeleton from '@/components/pages/production/transfer-to-laying/skeleton/TransferToLayingTableSkeleton';
import { import {
TransferToLaying, TransferToLaying,
@@ -596,40 +597,61 @@ const TransferToLayingsTable = () => {
</div> </div>
</div> </div>
<Table<TransferToLaying> <div className='flex flex-col mb-4'>
data={ {isLoading ? (
isResponseSuccess(transferToLayings) ? transferToLayings?.data : [] <div className='w-full flex flex-row justify-center items-center p-4'>
} <span className='loading loading-spinner loading-xl' />
columns={transferToLayingsColumns} </div>
pageSize={tableFilterState.pageSize} ) : !isResponseSuccess(transferToLayings) ||
page={ transferToLayings.data?.length === 0 ? (
isResponseSuccess(transferToLayings) <div className='p-3'>
? transferToLayings?.meta?.page <TransferToLayingTableSkeleton
: 0 columns={transferToLayingsColumns}
} icon={
totalItems={ <Icon
isResponseSuccess(transferToLayings) icon='heroicons:document-text'
? transferToLayings?.meta?.total_results className='text-white'
: 0 width={20}
} height={20}
onPageChange={setPage} />
onPageSizeChange={setPageSize} }
isLoading={isLoading} />
sorting={sorting} </div>
setSorting={setSorting} ) : (
rowSelection={rowSelection} <Table<TransferToLaying>
setRowSelection={setRowSelection} data={
enableRowSelection={tableEnableRowSelectionHandler} isResponseSuccess(transferToLayings)
withCheckbox ? transferToLayings?.data
className={{ : []
containerClassName: cn('p-3', { }
'w-full mb-20': columns={transferToLayingsColumns}
isResponseSuccess(transferToLayings) && pageSize={tableFilterState.pageSize}
transferToLayings?.data?.length === 0, page={
}), isResponseSuccess(transferToLayings)
headerColumnClassName: 'text-nowrap', ? transferToLayings?.meta?.page
}} : 0
/> }
totalItems={
isResponseSuccess(transferToLayings)
? transferToLayings?.meta?.total_results
: 0
}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
enableRowSelection={tableEnableRowSelectionHandler}
withCheckbox
className={{
containerClassName: cn('p-3 mb-0'),
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div>
</div> </div>
<TransferToLayingFilterModal <TransferToLayingFilterModal
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { TransferToLaying } from '@/types/api/production/transfer-to-laying';
import { ColumnDef } from '@tanstack/react-table';
const TransferToLayingTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no transfer to laying data displayed. Enter transfer to laying data to get started.',
}: {
columns: ColumnDef<TransferToLaying>[];
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 TransferToLayingTableSkeleton;
@@ -53,7 +53,7 @@ import { useFormik } from 'formik';
import { import {
UniformityTableFilterSchema, UniformityTableFilterSchema,
type UniformityTableFilterValues, type UniformityTableFilterValues,
} from '@/components/pages/production/uniformity/UniformityTableFilter.schema'; } from '@/components/pages/production/uniformity/filter/UniformityTableFilter';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
@@ -1010,35 +1010,55 @@ const UniformityTable = () => {
</section> </section>
</div> </div>
<Table<Uniformity> <div className='flex flex-col mb-4'>
data={isResponseSuccess(uniformities) ? uniformities?.data : []} {isLoading ? (
columns={uniformityColumns} <div className='w-full flex flex-row justify-center items-center p-4'>
pageSize={tableFilterState.pageSize} <span className='loading loading-spinner loading-xl' />
page={isResponseSuccess(uniformities) ? uniformities?.meta?.page : 0} </div>
totalItems={ ) : !isResponseSuccess(uniformities) ||
isResponseSuccess(uniformities) uniformities.data?.length === 0 ? (
? uniformities?.meta?.total_results <div className='p-3 pt-0'>
: 0 <UniformityTableSkeleton
} columns={uniformityColumns}
onPageChange={setPage} icon={
isLoading={isLoading} <Icon
sorting={sorting} icon='heroicons:document-text'
setSorting={setSorting} className='text-white'
rowSelection={rowSelection} width={20}
setRowSelection={setRowSelection} height={20}
className={{ />
containerClassName: cn('p-3 pt-0', { }
'mb-20': />
isResponseSuccess(uniformities) && </div>
uniformities?.data?.length === 0, ) : (
}), <Table<Uniformity>
headerColumnClassName: data={isResponseSuccess(uniformities) ? uniformities?.data : []}
'first:pl-3 first:pr-0 xl:first:pl-3 py-3 text-nowrap', columns={uniformityColumns}
bodyColumnClassName: pageSize={tableFilterState.pageSize}
'first:pl-3 first:pr-0 xl:first:pl-3 py-3 text-nowrap', page={
}} isResponseSuccess(uniformities) ? uniformities?.meta?.page : 0
emptyContent={<UniformityTableSkeleton />} }
/> totalItems={
isResponseSuccess(uniformities)
? uniformities?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn('p-3 pt-0 mb-0'),
headerColumnClassName:
'first:pl-3 first:pr-0 xl:first:pl-3 py-3 text-nowrap',
bodyColumnClassName:
'first:pl-3 first:pr-0 xl:first:pl-3 py-3 text-nowrap',
}}
/>
)}
</div>
<ConfirmationModal <ConfirmationModal
ref={successModal.ref} ref={successModal.ref}
@@ -1,30 +1,35 @@
import { Icon } from '@iconify/react'; import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { Uniformity } from '@/types/api/production/uniformity';
import { ColumnDef } from '@tanstack/react-table';
const UniformityTableSkeleton = () => { const UniformityTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no uniformity data displayed. Enter uniformity check data to get started.',
}: {
columns: ColumnDef<Uniformity>[];
icon: React.ReactNode;
title?: string;
subtitle?: string;
}) => {
return ( return (
<div className='flex flex-col items-center justify-center my-20'> <div className='relative size-full'>
{/* Document icon */} <Table
<div className='mb-2'> data={[]}
<div className='w-12.5 h-12.5 bg-(--main-color-base-100,#FFFFFF) border border-base-content/10 rounded-[0.875rem] shadow-[0px_25px_50px_-12px_#00000040] flex items-center justify-center'> columns={columns}
<div className='w-9.5 h-9.5 bg-primary rounded-lg border border-primary flex items-center justify-center shadow-[inset_0px_4px_4px_0px_#FFFFFF80,inset_0px_2px_0px_0px_#FFFFFF80]'> isLoading={true}
<Icon className={{
icon='heroicons:document-text' skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
className='text-white' headerColumnClassName: 'whitespace-nowrap',
width={20} containerClassName: 'mb-0 overflow-hidden',
height={20} tableWrapperClassName: 'overflow-hidden',
/> }}
</div> />
</div> <div className='absolute inset-0 flex items-center justify-center'>
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
</div> </div>
{/* Empty state text */}
<h3 className='text-base-content/50 font-semibold text-sm mb-1'>
No Data Available
</h3>
<p className='text-base-content/50 text-xs text-center max-w-xs'>
There is no uniformity data displayed. Enter uniformity check data to
get started.
</p>
</div> </div>
); );
}; };
+50 -30
View File
@@ -17,6 +17,7 @@ import PopoverContent from '@/components/popover/PopoverContent';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import SelectInput, { OptionType } from '@/components/input/SelectInput';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import StatusBadge from '@/components/helper/StatusBadge'; import StatusBadge from '@/components/helper/StatusBadge';
import PurchaseTableSkeleton from '@/components/pages/purchase/skeleton/PurchaseTableSkeleton';
import { cn, formatDate } from '@/lib/helper'; import { cn, formatDate } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
@@ -441,36 +442,55 @@ const PurchaseTable = () => {
{/* Table Section */} {/* Table Section */}
<div className='flex flex-col mb-4'> <div className='flex flex-col mb-4'>
<Table<Purchase> {isLoading ? (
data={ <div className='w-full flex flex-row justify-center items-center p-4'>
isResponseSuccess(purchaseRequests) ? purchaseRequests?.data : [] <span className='loading loading-spinner loading-xl' />
} </div>
columns={purchaseColumns} ) : !isResponseSuccess(purchaseRequests) ||
pageSize={tableFilterState.pageSize} purchaseRequests.data?.length === 0 ? (
page={ <div className='p-3'>
isResponseSuccess(purchaseRequests) <PurchaseTableSkeleton
? purchaseRequests?.meta?.page columns={purchaseColumns}
: 0 icon={
} <Icon
totalItems={ icon='heroicons:document-text'
isResponseSuccess(purchaseRequests) className='text-white'
? purchaseRequests?.meta?.total_results width={20}
: 0 height={20}
} />
onPageChange={setPage} }
onPageSizeChange={setPageSize} />
isLoading={isLoading} </div>
sorting={sorting} ) : (
setSorting={setSorting} <Table<Purchase>
className={{ data={
containerClassName: cn('p-3', { isResponseSuccess(purchaseRequests)
'w-full mb-20': ? purchaseRequests?.data
isResponseSuccess(purchaseRequests) && : []
purchaseRequests?.data?.length === 0, }
}), columns={purchaseColumns}
headerColumnClassName: 'text-nowrap', pageSize={tableFilterState.pageSize}
}} page={
/> isResponseSuccess(purchaseRequests)
? purchaseRequests?.meta?.page
: 0
}
totalItems={
isResponseSuccess(purchaseRequests)
? purchaseRequests?.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>
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { Purchase } from '@/types/api/purchase/purchase';
import { ColumnDef } from '@tanstack/react-table';
const PurchaseTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no purchase data displayed. Enter purchase data to get started.',
}: {
columns: ColumnDef<Purchase>[];
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 PurchaseTableSkeleton;
@@ -36,7 +36,6 @@ import {
} from '@/components/pages/report/marketing/filter/DailyMarketingFilter'; } from '@/components/pages/report/marketing/filter/DailyMarketingFilter';
import SelectInput from '@/components/input/SelectInput'; import SelectInput from '@/components/input/SelectInput';
import Modal, { useModal } from '@/components/Modal'; import Modal, { useModal } from '@/components/Modal';
import { cn } from '@/lib/helper';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store'; import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import DailyMarketingReportSkeleton from '@/components/pages/report/marketing/skeleton/DailyMarketingSkeleton'; import DailyMarketingReportSkeleton from '@/components/pages/report/marketing/skeleton/DailyMarketingSkeleton';
import { useEffect as useEffectHook } from 'react'; import { useEffect as useEffectHook } from 'react';
@@ -46,8 +45,8 @@ import {
MARKETING_DATE_FILTER_TYPE_OPTIONS, MARKETING_DATE_FILTER_TYPE_OPTIONS,
MARKETING_TYPE_OPTIONS, MARKETING_TYPE_OPTIONS,
} from '@/config/constant'; } from '@/config/constant';
import Badge from '@/components/Badge';
import ButtonFilter from '@/components/helper/ButtonFilter'; import ButtonFilter from '@/components/helper/ButtonFilter';
import SelectInputRadio from '@/components/input/SelectInputRadio';
interface DailyMarketingTabProps { interface DailyMarketingTabProps {
tabId: string; tabId: string;
@@ -733,6 +732,88 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
</div> </div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}> <form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<div className='p-4 flex flex-col gap-3'> <div className='p-4 flex flex-col gap-3'>
{/* Date Range Filter */}
<div className='flex flex-col'>
<span className='py-2 text-xs font-semibold'>Tanggal</span>
<div className='flex flex-row items-center gap-1.5'>
<DateInput
name='start_date'
placeholder='Tanggal Awal'
value={formik.values.start_date || ''}
onChange={(e) => {
const value = e.target.value;
formik.setFieldValue('start_date', value || null);
if (value && formik.values.end_date) {
const startDate = new Date(value);
const endDateObj = new Date(formik.values.end_date);
if (endDateObj < startDate) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
} else {
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}
} else {
setHasDateError(false);
}
}}
className={{ wrapper: 'w-full' }}
errorMessage={formik.errors.start_date}
isError={
!!formik.errors.start_date && formik.touched.start_date
}
/>
<hr className='w-full max-w-3 h-px border-base-content/10' />
<DateInput
name='end_date'
placeholder='Tanggal Akhir'
value={formik.values.end_date || ''}
onChange={(e) => {
const value = e.target.value;
formik.setFieldValue('end_date', value || null);
if (value && formik.values.start_date) {
const startDateObj = new Date(formik.values.start_date);
const endDate = new Date(value);
if (endDate < startDateObj) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
return;
}
}
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}}
className={{ wrapper: 'w-full' }}
errorMessage={formik.errors.end_date}
isError={
(formik.errors.end_date && formik.touched.end_date) ||
hasDateError
}
/>
</div>
</div>
{/* Area Filter */} {/* Area Filter */}
<SelectInput <SelectInput
label='Area' label='Area'
@@ -801,93 +882,8 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
/> />
{/* Date Range Filter */}
<div>
<label className='block text-xs font-semibold text-base-content py-2'>
Rentang Tanggal
</label>
<div className='flex flex-col gap-2'>
<DateInput
name='start_date'
label='Tanggal Awal'
placeholder='Pilih Tanggal Awal'
value={formik.values.start_date || ''}
onChange={(e) => {
const value = e.target.value;
formik.setFieldValue('start_date', value || null);
if (value && formik.values.end_date) {
const startDate = new Date(value);
const endDateObj = new Date(formik.values.end_date);
if (endDateObj < startDate) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
} else {
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}
} else {
setHasDateError(false);
}
}}
className={{ wrapper: 'w-full' }}
errorMessage={formik.errors.start_date}
isError={
!!formik.errors.start_date && formik.touched.start_date
}
/>
<DateInput
name='end_date'
label='Tanggal Akhir'
placeholder='Pilih Tanggal Akhir'
value={formik.values.end_date || ''}
onChange={(e) => {
const value = e.target.value;
formik.setFieldValue('end_date', value || null);
if (value && formik.values.start_date) {
const startDateObj = new Date(formik.values.start_date);
const endDate = new Date(value);
if (endDate < startDateObj) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
return;
}
}
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}}
className={{ wrapper: 'w-full' }}
errorMessage={formik.errors.end_date}
isError={
(formik.errors.end_date && formik.touched.end_date) ||
hasDateError
}
/>
</div>
</div>
{/* Filter By Date Type */} {/* Filter By Date Type */}
<SelectInput <SelectInputRadio
label='Filter Tanggal' label='Filter Tanggal'
placeholder='Pilih Filter Tanggal' placeholder='Pilih Filter Tanggal'
options={MARKETING_DATE_FILTER_TYPE_OPTIONS} options={MARKETING_DATE_FILTER_TYPE_OPTIONS}
@@ -903,7 +899,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
/> />
{/* Marketing Type Filter */} {/* Marketing Type Filter */}
<SelectInput <SelectInputRadio
label='Tipe Marketing' label='Tipe Marketing'
placeholder='Pilih Tipe Marketing' placeholder='Pilih Tipe Marketing'
options={MARKETING_TYPE_OPTIONS} options={MARKETING_TYPE_OPTIONS}
+67
View File
@@ -383,6 +383,73 @@ export const PRODUCT_FLAG_OPTIONS = [
{ label: 'VITAMIN', value: 'VITAMIN' }, { label: 'VITAMIN', value: 'VITAMIN' },
]; ];
export const PRODUCT_FLAG_MAPPING = {
flags: [
{ label: 'Ayam', value: 'AYAM' },
{ label: 'Pakan', value: 'PAKAN' },
{ label: 'OVK', value: 'OVK' },
{ label: 'Telur', value: 'TELUR' },
],
options: [
{
flag: { label: 'Ayam', value: 'AYAM' },
sub_flags: [
{ label: 'Ayam Afkir', value: 'AYAM-AFKIR' },
{ label: 'Ayam Culling', value: 'AYAM-CULLING' },
{ label: 'Ayam Mati', value: 'AYAM-MATI' },
],
allow_without_sub_flag: true,
},
{
flag: { label: 'Pakan', value: 'PAKAN' },
sub_flags: [
{ label: 'Pre Starter', value: 'PRE-STARTER' },
{ label: 'Starter', value: 'STARTER' },
{ label: 'Finisher', value: 'FINISHER' },
],
allow_without_sub_flag: false,
},
{
flag: { label: 'OVK', value: 'OVK' },
sub_flags: [
{ label: 'Obat', value: 'OBAT' },
{ label: 'Vitamin', value: 'VITAMIN' },
{ label: 'Kimia', value: 'KIMIA' },
],
allow_without_sub_flag: false,
},
{
flag: { label: 'Telur', value: 'TELUR' },
sub_flags: [
{ label: 'Telur Utuh', value: 'TELUR-UTUH' },
{ label: 'Telur Putih', value: 'TELUR-PUTIH' },
{ label: 'Telur Retak', value: 'TELUR-RETAK' },
{ label: 'Telur Pecah', value: 'TELUR-PECAH' },
{ label: 'Telur Papacal', value: 'TELUR-PAPACAL' },
{ label: 'Telur Jumbo', value: 'TELUR-JUMBO' },
],
allow_without_sub_flag: false,
},
],
sub_flag_to_flag: {
'TELUR-RETAK': 'TELUR',
STARTER: 'PAKAN',
FINISHER: 'PAKAN',
VITAMIN: 'OVK',
KIMIA: 'OVK',
'TELUR-UTUH': 'TELUR',
'AYAM-MATI': 'AYAM',
'PRE-STARTER': 'PAKAN',
'TELUR-JUMBO': 'TELUR',
'TELUR-PAPACAL': 'TELUR',
'AYAM-AFKIR': 'AYAM',
'AYAM-CULLING': 'AYAM',
OBAT: 'OVK',
'TELUR-PUTIH': 'TELUR',
'TELUR-PECAH': 'TELUR',
},
} as const;
export const SUPPLIER_FLAG_OPTIONS = [ export const SUPPLIER_FLAG_OPTIONS = [
{ label: 'EKSPEDISI', value: 'EKSPEDISI' }, { label: 'EKSPEDISI', value: 'EKSPEDISI' },
]; ];
+126
View File
@@ -3,6 +3,12 @@ import 'moment/locale/id';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import clsx, { ClassValue } from 'clsx'; import clsx, { ClassValue } from 'clsx';
import { SidebarMenuItem } from '@/components/molecules/SidebarMenu'; import { SidebarMenuItem } from '@/components/molecules/SidebarMenu';
import { OptionType } from '@/components/input/SelectInput';
import {
ConstantsApiResponse,
ProductFlagMapping,
TransformedConstants,
} from '@/types/api/constants/constants';
// set locale globally // set locale globally
moment.locale('id'); moment.locale('id');
@@ -179,3 +185,123 @@ export function findMenuPath(
return null; return null;
} }
/**
* Transform a string value to OptionType with formatted label
* Example: "AYAM-AFKIR" -> { label: "Ayam Afkir", value: "AYAM-AFKIR" }
*/
export function toOption(value: string): OptionType {
return {
value,
label: formatConstantLabel(value),
};
}
/**
* Format constant label by:
* 1. Replacing underscores/hyphens with spaces
* 2. Converting to title case
* 3. Handling special cases
*/
export function formatConstantLabel(value: string): string {
const specialCases: Record<string, string> = {
'PRE-STARTER': 'Pre Starter',
BOP: 'BOP',
SAPRONAK: 'SAPRONAK',
OVK: 'OVK',
DOC: 'DOC',
};
if (specialCases[value]) {
return specialCases[value];
}
const withSpaces = value.replace(/[-_]/g, ' ');
return formatTitleCase(withSpaces);
}
/**
* Transform product_flag_mapping from API format to UI format
*/
export function transformProductFlagMapping(
mapping: ConstantsApiResponse['product_flag_mapping']
): ProductFlagMapping {
return {
flags: mapping.flags.map(toOption),
options: mapping.options.map((opt) => ({
flag: toOption(opt.flag),
sub_flags: opt.sub_flags.map(toOption),
allow_without_sub_flag: opt.allow_without_sub_flag,
})),
sub_flag_to_flag: mapping.sub_flag_to_flag,
};
}
/**
* Transform approval workflows from API format to UI format
*/
export function transformApprovalWorkflows(
workflows: ConstantsApiResponse['approval_workflows']
) {
return workflows.map((workflow) => ({
key: workflow.key,
steps: workflow.steps.map((step) => ({
value: String(step.step_number),
label: step.step_name,
})),
}));
}
/**
* Transform adjustment transaction subtypes from API format to UI format
*/
export function transformAdjustmentSubtypes(
subtypes: ConstantsApiResponse['adjustment']['transaction_subtypes']
) {
return {
RECORDING: subtypes.RECORDING.map(toOption),
PENJUALAN: subtypes.PENJUALAN.map(toOption),
PEMBELIAN: subtypes.PEMBELIAN.map(toOption),
};
}
/**
* Transform legacy flag aliases from API format to UI format
*/
export function transformLegacyFlagAliases(
aliases: ConstantsApiResponse['legacy_flag_aliases']
): OptionType[] {
return Object.entries(aliases).map(([key, value]) => ({
value: key,
label: formatConstantLabel(key),
}));
}
/**
* Transform the entire constants API response to UI format
*/
export function transformConstants(
data: ConstantsApiResponse
): TransformedConstants {
return {
warehouse_types: data.warehouse_types.map(toOption),
supplier_categories: data.supplier_categories.map(toOption),
customer_supplier_types: data.customer_supplier_types.map(toOption),
adjustment: {
transaction_subtypes: transformAdjustmentSubtypes(
data.adjustment.transaction_subtypes
),
},
approval_workflows: transformApprovalWorkflows(data.approval_workflows),
flags: data.flags.map(toOption),
product_flag_mapping: transformProductFlagMapping(
data.product_flag_mapping
),
legacy_flag_aliases: transformLegacyFlagAliases(data.legacy_flag_aliases),
stock_log: {
log_types: data.stock_log.log_types.map(toOption),
transaction_types: data.stock_log.transaction_types.map(toOption),
},
};
}
+25
View File
@@ -0,0 +1,25 @@
import { httpClient } from '@/services/http/client';
import {
ConstantsApiResponse,
TransformedConstants,
} from '@/types/api/constants/constants';
import { transformConstants } from '@/lib/helper';
class ConstantsApiService {
async fetchConstants(): Promise<ConstantsApiResponse | undefined> {
try {
const response = await httpClient<ConstantsApiResponse>('/constants');
return response;
} catch {
return undefined;
}
}
async fetchTransformedConstants(): Promise<TransformedConstants | undefined> {
const data = await this.fetchConstants();
if (!data) return undefined;
return transformConstants(data);
}
}
export const ConstantsApi = new ConstantsApiService();
+86
View File
@@ -0,0 +1,86 @@
import { OptionType } from '@/components/input/SelectInput';
export type ApprovalWorkflowStep = {
step_number: number;
step_name: string;
};
export type ApprovalWorkflow = {
key: string;
steps: ApprovalWorkflowStep[];
};
export type ProductFlagMappingOption = {
flag: string;
sub_flags: string[];
allow_without_sub_flag: boolean;
};
export type ProductFlagMappingApiResponse = {
flags: string[];
options: ProductFlagMappingOption[];
sub_flag_to_flag: Record<string, string>;
};
export type AdjustmentTransactionSubtypes = {
RECORDING: string[];
PENJUALAN: string[];
PEMBELIAN: string[];
};
export type StockLogConfig = {
log_types: string[];
transaction_types: string[];
};
export type ConstantsApiResponse = {
warehouse_types: string[];
supplier_categories: string[];
customer_supplier_types: string[];
adjustment: {
transaction_subtypes: AdjustmentTransactionSubtypes;
};
approval_workflows: ApprovalWorkflow[];
flags: string[];
product_flag_mapping: ProductFlagMappingApiResponse;
legacy_flag_aliases: Record<string, string>;
stock_log: StockLogConfig;
};
export type ProductFlagMappingItem = {
flag: OptionType;
sub_flags: OptionType[];
allow_without_sub_flag: boolean;
};
export type ProductFlagMapping = {
flags: OptionType[];
options: ProductFlagMappingItem[];
sub_flag_to_flag: Record<string, string>;
};
export type TransformedApprovalWorkflow = {
key: string;
steps: OptionType[];
};
export type TransformedConstants = {
warehouse_types: OptionType[];
supplier_categories: OptionType[];
customer_supplier_types: OptionType[];
adjustment: {
transaction_subtypes: {
RECORDING: OptionType[];
PENJUALAN: OptionType[];
PEMBELIAN: OptionType[];
};
};
approval_workflows: TransformedApprovalWorkflow[];
flags: OptionType[];
product_flag_mapping: ProductFlagMapping;
legacy_flag_aliases: OptionType[];
stock_log: {
log_types: OptionType[];
transaction_types: OptionType[];
};
};
+6 -2
View File
@@ -15,7 +15,10 @@ export type BaseProduct = {
uom: Uom; uom: Uom;
product_category: ProductCategory; product_category: ProductCategory;
suppliers: (BaseSupplier & { price: number })[]; suppliers: (BaseSupplier & { price: number })[];
flags: string[]; flag: string;
sub_flag?: string;
sub_flags?: string[];
flags?: string[];
}; };
export type Product = BaseMetadata & BaseProduct; export type Product = BaseMetadata & BaseProduct;
@@ -34,7 +37,8 @@ export type CreateProductPayload = {
supplier_id: number; supplier_id: number;
price: number; price: number;
}[]; }[];
flags: string[]; flag: string;
sub_flags?: string[];
}; };
export type UpdateProductPayload = CreateProductPayload; export type UpdateProductPayload = CreateProductPayload;