mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-25 07:45:47 +00:00
Merge branch 'development' into 'production'
refactor(FE): Refactor RowOptionsMenu to use PopoverButton and See merge request mbugroup/lti-web-client!344
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import InventoryAdjustmentTable from '@/components/pages/inventory/adjustment/In
|
|||||||
|
|
||||||
const InventoryAdjustment = () => {
|
const InventoryAdjustment = () => {
|
||||||
return (
|
return (
|
||||||
<section className='w-full p-4'>
|
<section className='w-full'>
|
||||||
<InventoryAdjustmentTable />
|
<InventoryAdjustmentTable />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,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,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,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,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,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,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,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,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,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,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;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import RecordingTable from '@/components/pages/production/recording/RecordingTab
|
|||||||
|
|
||||||
const Recording = () => {
|
const Recording = () => {
|
||||||
return (
|
return (
|
||||||
<section className='w-full p-4 sm:p-0'>
|
<section className='w-full'>
|
||||||
<RecordingTable />
|
<RecordingTable />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -108,7 +108,9 @@ const Drawer = ({
|
|||||||
if (closeOnBackdropClick) {
|
if (closeOnBackdropClick) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
onBackdropClick && onBackdropClick();
|
if (onBackdropClick) {
|
||||||
|
onBackdropClick();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ export const useModal = (isNestingModal = false) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggle = useCallback(() => {
|
const toggle = useCallback(() => {
|
||||||
open ? closeModal() : openModal();
|
if (open) {
|
||||||
|
closeModal();
|
||||||
|
} else {
|
||||||
|
openModal();
|
||||||
|
}
|
||||||
}, [open, closeModal, openModal]);
|
}, [open, closeModal, openModal]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -26,13 +26,17 @@ const Navbar = ({ toggleSidebar }: NavbarProps) => {
|
|||||||
|
|
||||||
const logoutClickHandler = async () => {
|
const logoutClickHandler = async () => {
|
||||||
const logoutRes = await AuthApi.logout();
|
const logoutRes = await AuthApi.logout();
|
||||||
|
|
||||||
if (isResponseError(logoutRes)) {
|
if (isResponseError(logoutRes)) {
|
||||||
toast.error('Gagal logout! Coba lagi!');
|
toast.error('Gagal logout! Coba lagi!');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setUser(undefined);
|
setUser(undefined);
|
||||||
|
const redirect = (logoutRes as { redirect?: string })?.redirect;
|
||||||
|
if (redirect) {
|
||||||
|
window.location.href = redirect;
|
||||||
|
return;
|
||||||
|
}
|
||||||
router.replace(process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string);
|
router.replace(process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,51 @@ import { getFilledFormikValuesCount } from '@/lib/formik-helper';
|
|||||||
import { cn } from '@/lib/helper';
|
import { cn } from '@/lib/helper';
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import { FormikValues } from 'formik';
|
import { FormikValues } from 'formik';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
export type ButtonFilterProps = ButtonProps & {
|
export type ButtonFilterProps = ButtonProps & {
|
||||||
values: FormikValues;
|
values: FormikValues;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
excludeFields?: string[];
|
||||||
|
fieldGroups?: string[][];
|
||||||
};
|
};
|
||||||
|
|
||||||
// 'bg-gradient-to-t from-blue-50 to-blue-100 border-blue-500 text-blue-600 hover:from-blue-100 hover:to-blue-200
|
// 'bg-gradient-to-t from-blue-50 to-blue-100 border-blue-500 text-blue-600 hover:from-blue-100 hover:to-blue-200
|
||||||
|
|
||||||
const ButtonFilter = ({ values, onClick, ...props }: ButtonFilterProps) => {
|
const ButtonFilter = ({
|
||||||
|
values,
|
||||||
|
onClick,
|
||||||
|
excludeFields = [],
|
||||||
|
fieldGroups = [],
|
||||||
|
...props
|
||||||
|
}: ButtonFilterProps) => {
|
||||||
|
const activeCount = useMemo(() => {
|
||||||
|
const filteredValues: FormikValues = {};
|
||||||
|
Object.keys(values).forEach((key) => {
|
||||||
|
if (!excludeFields.includes(key)) {
|
||||||
|
filteredValues[key] = values[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let count = getFilledFormikValuesCount(filteredValues);
|
||||||
|
|
||||||
|
fieldGroups.forEach((group) => {
|
||||||
|
const groupFields = group.filter(
|
||||||
|
(field) => !excludeFields.includes(field)
|
||||||
|
);
|
||||||
|
const filledGroupFields = groupFields.filter(
|
||||||
|
(field) => filteredValues[field]
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
filledGroupFields.length === groupFields.length &&
|
||||||
|
groupFields.length > 1
|
||||||
|
) {
|
||||||
|
count -= groupFields.length - 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}, [values, excludeFields, fieldGroups]);
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
{...props}
|
{...props}
|
||||||
@@ -21,7 +57,7 @@ const ButtonFilter = ({ values, onClick, ...props }: ButtonFilterProps) => {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'rounded-lg max-h-10 font-semibold text-sm gap-1.5',
|
'rounded-lg max-h-10 font-semibold text-sm gap-1.5',
|
||||||
'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft',
|
'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft',
|
||||||
getFilledFormikValuesCount(values) > 0
|
activeCount > 0
|
||||||
? 'border-primary-gradient text-primary rounded-lg!'
|
? 'border-primary-gradient text-primary rounded-lg!'
|
||||||
: 'rounded-lg',
|
: 'rounded-lg',
|
||||||
props.className
|
props.className
|
||||||
@@ -31,14 +67,12 @@ const ButtonFilter = ({ values, onClick, ...props }: ButtonFilterProps) => {
|
|||||||
icon='heroicons:funnel'
|
icon='heroicons:funnel'
|
||||||
width={20}
|
width={20}
|
||||||
height={20}
|
height={20}
|
||||||
className={
|
className={activeCount > 0 ? 'text-blue-600' : ''}
|
||||||
getFilledFormikValuesCount(values) > 0 ? 'text-blue-600' : ''
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
Filter
|
Filter
|
||||||
{getFilledFormikValuesCount(values) > 0 && (
|
{activeCount > 0 && (
|
||||||
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
|
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
|
||||||
{getFilledFormikValuesCount(values)}
|
{activeCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -134,14 +134,20 @@ const DropFileInput: React.FC<DropFileInputProps> = ({
|
|||||||
|
|
||||||
{!isError && bottomLabel && (
|
{!isError && bottomLabel && (
|
||||||
<p
|
<p
|
||||||
className={cn('w-full text-sm opacity-60', className?.bottomLabel)}
|
className={cn(
|
||||||
|
'w-full mt-1.5 text-xs opacity-60',
|
||||||
|
className?.bottomLabel
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{bottomLabel}
|
{bottomLabel}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{isError && (
|
{isError && (
|
||||||
<p
|
<p
|
||||||
className={cn('w-full text-sm text-error', className?.errorMessage)}
|
className={cn(
|
||||||
|
'w-full mt-1.5 text-xs text-error',
|
||||||
|
className?.errorMessage
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -144,12 +144,12 @@ export const RadioGroup = ({
|
|||||||
|
|
||||||
{/* Label bawah */}
|
{/* Label bawah */}
|
||||||
{!isError && bottomLabel && (
|
{!isError && bottomLabel && (
|
||||||
<p className='text-sm opacity-60'>{bottomLabel}</p>
|
<p className='mt-1.5 text-xs opacity-60'>{bottomLabel}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pesan error */}
|
{/* Pesan error */}
|
||||||
{isError && errorMessage && (
|
{isError && errorMessage && (
|
||||||
<p className='text-sm text-error'>{errorMessage}</p>
|
<p className='mt-1.5 text-xs text-error'>{errorMessage}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</RadioGroupContext.Provider>
|
</RadioGroupContext.Provider>
|
||||||
|
|||||||
@@ -488,9 +488,11 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
|
{isError && (
|
||||||
|
<p className='w-full mt-1.5 text-xs text-error'>{errorMessage}</p>
|
||||||
|
)}
|
||||||
{!isError && bottomLabel && (
|
{!isError && bottomLabel && (
|
||||||
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
|
<p className='w-full mt-1.5 text-xs opacity-60'>{bottomLabel}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -159,9 +159,11 @@ const TagInput: React.FC<TagInputProps> = ({
|
|||||||
|
|
||||||
{/* Bottom label or error message */}
|
{/* Bottom label or error message */}
|
||||||
{!isError && bottomLabel && (
|
{!isError && bottomLabel && (
|
||||||
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
|
<p className='w-full mt-1.5 text-xs opacity-60'>{bottomLabel}</p>
|
||||||
|
)}
|
||||||
|
{isError && (
|
||||||
|
<p className='w-full mt-1.5 text-xs text-error'>{errorMessage}</p>
|
||||||
)}
|
)}
|
||||||
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import HppExpeditionClosingTab from '@/components/pages/closing/tab/HppExpeditio
|
|||||||
import ClosingKandangList from '@/components/pages/closing/ClosingKandangList';
|
import ClosingKandangList from '@/components/pages/closing/ClosingKandangList';
|
||||||
import { ProjectFlock } from '@/types/api/production/project-flock';
|
import { ProjectFlock } from '@/types/api/production/project-flock';
|
||||||
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
|
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
|
||||||
import { useClosingTabStore } from '@/stores/closing/closing-tab.store';
|
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
|
||||||
interface ClosingDetailProps {
|
interface ClosingDetailProps {
|
||||||
id: number;
|
id: number;
|
||||||
initialValue?: ClosingGeneralInformation;
|
initialValue?: ClosingGeneralInformation;
|
||||||
@@ -33,7 +33,7 @@ const ClosingDetail: React.FC<ClosingDetailProps> = ({
|
|||||||
kandangData,
|
kandangData,
|
||||||
}) => {
|
}) => {
|
||||||
const [activeTabId, setActiveTabId] = useState<string>('sapronak');
|
const [activeTabId, setActiveTabId] = useState<string>('sapronak');
|
||||||
const tabActions = useClosingTabStore((state) => state.tabActions);
|
const tabActions = useTabActionsStore((state) => state.tabActions);
|
||||||
|
|
||||||
const closingDetailTabs = useMemo(() => {
|
const closingDetailTabs = useMemo(() => {
|
||||||
const validTabs = [
|
const validTabs = [
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ChangeEventHandler, useEffect, useState, useMemo } from 'react';
|
import { ChangeEventHandler, useEffect, useState, useMemo } from 'react';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useUiStore } from '@/stores/ui/ui.store';
|
||||||
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 { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
@@ -31,6 +33,7 @@ import {
|
|||||||
ClosingFilterType,
|
ClosingFilterType,
|
||||||
} from '@/components/pages/closing/filter/ClosingFilter';
|
} from '@/components/pages/closing/filter/ClosingFilter';
|
||||||
import ClosingTableSkeleton from '@/components/pages/closing/skeleton/ClosingTableSkeleton';
|
import ClosingTableSkeleton from '@/components/pages/closing/skeleton/ClosingTableSkeleton';
|
||||||
|
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||||
|
|
||||||
const RowOptionsMenu = ({
|
const RowOptionsMenu = ({
|
||||||
props,
|
props,
|
||||||
@@ -90,6 +93,9 @@ const RowOptionsMenu = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ClosingsTable = () => {
|
const ClosingsTable = () => {
|
||||||
|
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
// ===== ROUTER =====
|
// ===== ROUTER =====
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -158,6 +164,7 @@ const ClosingsTable = () => {
|
|||||||
onReset: () => {
|
onReset: () => {
|
||||||
updateFilter('location_id', '');
|
updateFilter('location_id', '');
|
||||||
updateFilter('project_status', '');
|
updateFilter('project_status', '');
|
||||||
|
filterModal.closeModal();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -287,25 +294,17 @@ const ClosingsTable = () => {
|
|||||||
);
|
);
|
||||||
}, [formik.values.project_status, projectStatusOptions]);
|
}, [formik.values.project_status, projectStatusOptions]);
|
||||||
|
|
||||||
// ===== ACTIVE FILTERS COUNT =====
|
useEffect(() => {
|
||||||
const activeFiltersCount = useMemo(() => {
|
updateFilter('search', searchValue);
|
||||||
let count = 0;
|
}, [searchValue, updateFilter]);
|
||||||
|
|
||||||
if (tableFilterState.location_id) {
|
useEffect(() => {
|
||||||
count += 1;
|
setTableState('closing-table', pathname);
|
||||||
}
|
}, [pathname, setTableState]);
|
||||||
|
|
||||||
if (tableFilterState.project_status) {
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return count;
|
|
||||||
}, [tableFilterState.location_id, tableFilterState.project_status]);
|
|
||||||
|
|
||||||
const hasFilters = activeFiltersCount > 0;
|
|
||||||
|
|
||||||
// ===== SEARCH CHANGE HANDLER =====
|
// ===== SEARCH CHANGE HANDLER =====
|
||||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
|
setSearchValue(e.target.value);
|
||||||
updateFilter('search', e.target.value);
|
updateFilter('search', e.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -352,25 +351,12 @@ const ClosingsTable = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<ButtonFilter
|
||||||
variant='outline'
|
values={tableFilterState}
|
||||||
color='none'
|
excludeFields={['page', 'pageSize', 'search']}
|
||||||
onClick={handleFilterModalOpen}
|
onClick={handleFilterModalOpen}
|
||||||
className={cn(
|
className='px-3 py-2.5'
|
||||||
'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,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon icon='heroicons:funnel' width={20} height={20} />
|
|
||||||
Filter
|
|
||||||
{hasFilters && (
|
|
||||||
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
|
|
||||||
{activeFiltersCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -379,19 +365,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 ? (
|
||||||
|
<div className='mt-3'>
|
||||||
<ClosingTableSkeleton
|
<ClosingTableSkeleton
|
||||||
columns={closingsColumns}
|
columns={closingsColumns}
|
||||||
icon={
|
icon={
|
||||||
<Icon
|
<Icon
|
||||||
icon='heroicons:chart-bar'
|
icon='heroicons:document-text'
|
||||||
className='text-white'
|
className='text-white'
|
||||||
width={20}
|
width={20}
|
||||||
height={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 : []}
|
||||||
@@ -410,10 +396,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'>
|
||||||
|
|||||||
@@ -308,7 +308,16 @@ const SalesClosingTable = ({ projectFlockId }: SalesClosingTableProps) => {
|
|||||||
// },
|
// },
|
||||||
// },
|
// },
|
||||||
],
|
],
|
||||||
[]
|
[
|
||||||
|
summary,
|
||||||
|
totals.avgActualPrice,
|
||||||
|
totals.avgSalesPrice,
|
||||||
|
totals.avgWeight,
|
||||||
|
totals.totalActualPrice,
|
||||||
|
totals.totalQuantity,
|
||||||
|
totals.totalSalesPrice,
|
||||||
|
totals.totalWeight,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -150,15 +150,18 @@ const DashboardProduction = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { resetForm } = formik;
|
||||||
|
|
||||||
const handleResetFilter = useCallback(() => {
|
const handleResetFilter = useCallback(() => {
|
||||||
formik.resetForm();
|
resetForm();
|
||||||
resetFilterValues(); // Clear stored filter values
|
resetFilterValues(); // Clear stored filter values
|
||||||
setAnalysisMode('OVERVIEW');
|
setAnalysisMode('OVERVIEW');
|
||||||
setEndpointUrl('/dashboards');
|
setEndpointUrl('/dashboards');
|
||||||
setSelectedLocationIds([]);
|
setSelectedLocationIds([]);
|
||||||
}, [resetFilterValues, filterValues, selectedLocationIds]);
|
}, [resetForm, resetFilterValues]);
|
||||||
|
|
||||||
const handleApplyFilter = (values: DashboardFilter) => {
|
const handleApplyFilter = useCallback(
|
||||||
|
(values: DashboardFilter) => {
|
||||||
// Build query params object, only include non-empty values
|
// Build query params object, only include non-empty values
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
|
|
||||||
@@ -171,12 +174,15 @@ const DashboardProduction = () => {
|
|||||||
params.flock_ids = values.flock_ids.toString();
|
params.flock_ids = values.flock_ids.toString();
|
||||||
if (values.kandang_ids.length > 0)
|
if (values.kandang_ids.length > 0)
|
||||||
params.kandang_ids = values.kandang_ids.toString();
|
params.kandang_ids = values.kandang_ids.toString();
|
||||||
if (values.comparison_type) params.comparison_type = values.comparison_type;
|
if (values.comparison_type)
|
||||||
|
params.comparison_type = values.comparison_type;
|
||||||
|
|
||||||
setEndpointUrl(`/dashboards?${new URLSearchParams(params).toString()}`);
|
setEndpointUrl(`/dashboards?${new URLSearchParams(params).toString()}`);
|
||||||
filterModal.closeModal();
|
filterModal.closeModal();
|
||||||
refreshDashboardProductionData();
|
refreshDashboardProductionData();
|
||||||
};
|
},
|
||||||
|
[filterModal, refreshDashboardProductionData]
|
||||||
|
);
|
||||||
|
|
||||||
// ===== Load filter from store on mount =====
|
// ===== Load filter from store on mount =====
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -190,20 +196,20 @@ const DashboardProduction = () => {
|
|||||||
kandang_ids: normalizeToArray(filterValues.kandang),
|
kandang_ids: normalizeToArray(filterValues.kandang),
|
||||||
comparison_type: filterValues.comparisonType,
|
comparison_type: filterValues.comparisonType,
|
||||||
});
|
});
|
||||||
}, [filterValues]);
|
}, [filterValues, handleApplyFilter]);
|
||||||
|
|
||||||
// ===== Formik Error List =====
|
// ===== Formik Error List =====
|
||||||
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
|
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
|
||||||
|
|
||||||
// ===== Export PDF =====
|
// ===== Export PDF =====
|
||||||
const handleExportPDF = async () => {
|
const handleExportPDF = useCallback(async () => {
|
||||||
await generateDashboardPDF({
|
await generateDashboardPDF({
|
||||||
filterValues: formik.values,
|
filterValues: formik.values,
|
||||||
allStatsRef,
|
allStatsRef,
|
||||||
allChartsRef,
|
allChartsRef,
|
||||||
setExporting,
|
setExporting,
|
||||||
});
|
});
|
||||||
};
|
}, [formik.values]);
|
||||||
|
|
||||||
// ===== Register Navbar Actions =====
|
// ===== Register Navbar Actions =====
|
||||||
const openFilterModalRef = useRef(filterModal.openModal);
|
const openFilterModalRef = useRef(filterModal.openModal);
|
||||||
@@ -253,7 +259,7 @@ const DashboardProduction = () => {
|
|||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, [formik.values, exporting, setNavbarActions]);
|
}, [formik.values, exporting, setNavbarActions, handleExportPDF]);
|
||||||
|
|
||||||
// Cleanup only on unmount
|
// Cleanup only on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -409,14 +409,14 @@ const DashboardLineChart = ({
|
|||||||
axisLine={{ stroke: '#C1C1C180', opacity: 0.5 }}
|
axisLine={{ stroke: '#C1C1C180', opacity: 0.5 }}
|
||||||
domain={(() => {
|
domain={(() => {
|
||||||
// Calculate dynamic domain based on visible data
|
// Calculate dynamic domain based on visible data
|
||||||
let seriesData: DashboardChartsSeries[] = [];
|
// let seriesData: DashboardChartsSeries[] = [];
|
||||||
let dataset: DashboardChartsDataset[] = [];
|
let dataset: DashboardChartsDataset[] = [];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
analysisMode === 'OVERVIEW' &&
|
analysisMode === 'OVERVIEW' &&
|
||||||
isOverviewCharts(data.charts)
|
isOverviewCharts(data.charts)
|
||||||
) {
|
) {
|
||||||
seriesData = data.charts[chartData]?.series || [];
|
// seriesData = data.charts[chartData]?.series || [];
|
||||||
dataset = data.charts[chartData]?.dataset || [];
|
dataset = data.charts[chartData]?.dataset || [];
|
||||||
} else if (
|
} else if (
|
||||||
analysisMode === 'COMPARISON' &&
|
analysisMode === 'COMPARISON' &&
|
||||||
@@ -426,7 +426,7 @@ const DashboardLineChart = ({
|
|||||||
data.charts.farm ||
|
data.charts.farm ||
|
||||||
data.charts.flock ||
|
data.charts.flock ||
|
||||||
data.charts.kandang;
|
data.charts.kandang;
|
||||||
seriesData = comparisonChart?.series || [];
|
// seriesData = comparisonChart?.series || [];
|
||||||
dataset = comparisonChart?.dataset || [];
|
dataset = comparisonChart?.dataset || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useUiStore } from '@/stores/ui/ui.store';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import {
|
import {
|
||||||
CellContext,
|
CellContext,
|
||||||
@@ -16,41 +18,40 @@ 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, {
|
import PopoverButton from '@/components/popover/PopoverButton';
|
||||||
OptionType,
|
import PopoverContent from '@/components/popover/PopoverContent';
|
||||||
useSelect,
|
|
||||||
} from '@/components/input/SelectInput';
|
|
||||||
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
|
|
||||||
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
|
|
||||||
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
|
|
||||||
import RealizationStatusBadge from '@/components/pages/expense/RealizationStatusBadge';
|
import RealizationStatusBadge from '@/components/pages/expense/RealizationStatusBadge';
|
||||||
import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge';
|
import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge';
|
||||||
import CheckboxInput from '@/components/input/CheckboxInput';
|
import CheckboxInput from '@/components/input/CheckboxInput';
|
||||||
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
|
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
|
||||||
import DateInput from '@/components/input/DateInput';
|
|
||||||
import RequirePermission from '@/components/helper/RequirePermission';
|
import RequirePermission from '@/components/helper/RequirePermission';
|
||||||
|
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||||
|
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';
|
||||||
import { cn, formatCurrency, formatDate } from '@/lib/helper';
|
import { cn, formatCurrency, formatDate } from '@/lib/helper';
|
||||||
import { isResponseSuccess } from '@/lib/api-helper';
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||||
import { LocationApi, SupplierApi } from '@/services/api/master-data';
|
|
||||||
import { Location } from '@/types/api/master-data/location';
|
|
||||||
import { Supplier } from '@/types/api/master-data/supplier';
|
|
||||||
import { BaseApiResponse } from '@/types/api/api-general';
|
import { BaseApiResponse } from '@/types/api/api-general';
|
||||||
|
|
||||||
const RowOptionsMenu = ({
|
const RowOptionsMenu = ({
|
||||||
type = 'dropdown',
|
popoverPosition = 'bottom',
|
||||||
props,
|
props,
|
||||||
deleteClickHandler,
|
deleteClickHandler,
|
||||||
}: {
|
}: {
|
||||||
type: 'dropdown' | 'collapse';
|
popoverPosition: 'bottom' | 'top';
|
||||||
props: CellContext<Expense, unknown>;
|
props: CellContext<Expense, unknown>;
|
||||||
approveClickHandler: () => void;
|
|
||||||
rejectClickHandler: () => void;
|
|
||||||
deleteClickHandler: () => void;
|
deleteClickHandler: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
|
const popoverId = `expense#${props.row.original.id}`;
|
||||||
|
const popoverAnchorName = `--anchor-expense#${props.row.original.id}`;
|
||||||
|
|
||||||
|
const closePopover = () => {
|
||||||
|
document.getElementById(popoverId)?.hidePopover();
|
||||||
|
};
|
||||||
|
|
||||||
const showEditButton = props.row.original.latest_approval
|
const showEditButton = props.row.original.latest_approval
|
||||||
? props.row.original.latest_approval.step_number !== 6 &&
|
? props.row.original.latest_approval.step_number !== 6 &&
|
||||||
(props.row.original.latest_approval.step_number === 1 ||
|
(props.row.original.latest_approval.step_number === 1 ||
|
||||||
@@ -59,23 +60,39 @@ const RowOptionsMenu = ({
|
|||||||
props.row.original.latest_approval.step_number === 4)
|
props.row.original.latest_approval.step_number === 4)
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
// TODO: apply RBAC
|
|
||||||
const showRealizationButton = props.row.original.latest_approval
|
const showRealizationButton = props.row.original.latest_approval
|
||||||
? props.row.original.latest_approval.action !== 'REJECTED' &&
|
? props.row.original.latest_approval.action !== 'REJECTED' &&
|
||||||
props.row.original.latest_approval.step_number === 4
|
props.row.original.latest_approval.step_number === 4
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RowOptionsMenuWrapper type={type}>
|
<div className='relative'>
|
||||||
<div className='w-full max-h-40 overflow-auto flex flex-col gap-1'>
|
<PopoverButton
|
||||||
|
tabIndex={0}
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
popoverTarget={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
>
|
||||||
|
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||||
|
</PopoverButton>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
id={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
|
||||||
|
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
|
||||||
|
>
|
||||||
|
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||||
<RequirePermission permissions='lti.expense.detail'>
|
<RequirePermission permissions='lti.expense.detail'>
|
||||||
<Button
|
<Button
|
||||||
href={`/expense/detail/?expenseId=${props.row.original.id}`}
|
href={`/expense/detail/?expenseId=${props.row.original.id}`}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='primary'
|
color='none'
|
||||||
className='justify-start text-sm'
|
className='p-3 justify-start text-sm font-semibold w-full'
|
||||||
|
onClick={closePopover}
|
||||||
>
|
>
|
||||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
<Icon icon='heroicons:eye' width={20} height={20} />
|
||||||
Detail
|
Detail
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
@@ -85,14 +102,11 @@ const RowOptionsMenu = ({
|
|||||||
<Button
|
<Button
|
||||||
href={`/expense/detail/edit/?expenseId=${props.row.original.id}`}
|
href={`/expense/detail/edit/?expenseId=${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 icon='mdi:pencil-outline' width={20} height={20} />
|
||||||
icon='material-symbols:edit-outline'
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
@@ -103,13 +117,15 @@ const RowOptionsMenu = ({
|
|||||||
<Button
|
<Button
|
||||||
href={`/expense/realization/?expenseId=${props.row.original.id}`}
|
href={`/expense/realization/?expenseId=${props.row.original.id}`}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='info'
|
color='none'
|
||||||
className='justify-start text-sm text-info focus-visible:text-info-content hover:text-info-content'
|
className='p-3 justify-start text-sm font-semibold w-full'
|
||||||
|
onClick={closePopover}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
icon='material-symbols:money-bag-rounded'
|
icon='material-symbols:money-bag-rounded'
|
||||||
width={16}
|
width={20}
|
||||||
height={16}
|
height={20}
|
||||||
|
className='text-info'
|
||||||
/>
|
/>
|
||||||
Realisasi
|
Realisasi
|
||||||
</Button>
|
</Button>
|
||||||
@@ -118,26 +134,28 @@ const RowOptionsMenu = ({
|
|||||||
|
|
||||||
<RequirePermission permissions='lti.expense.delete'>
|
<RequirePermission permissions='lti.expense.delete'>
|
||||||
<Button
|
<Button
|
||||||
onClick={deleteClickHandler}
|
onClick={() => {
|
||||||
|
deleteClickHandler();
|
||||||
|
closePopover();
|
||||||
|
}}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='error'
|
color='error'
|
||||||
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
|
className='p-3 justify-start text-sm font-semibold w-full focus-visible:text-error-content hover:text-error-content'
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon icon='mdi:delete-outline' width={20} height={20} />
|
||||||
icon='material-symbols:delete-outline-rounded'
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
className='justify-start text-sm'
|
|
||||||
/>
|
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
</div>
|
</div>
|
||||||
</RowOptionsMenuWrapper>
|
</PopoverContent>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ExpensesTable = () => {
|
const ExpensesTable = () => {
|
||||||
|
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
state: tableFilterState,
|
state: tableFilterState,
|
||||||
updateFilter,
|
updateFilter,
|
||||||
@@ -179,6 +197,9 @@ const ExpensesTable = () => {
|
|||||||
const approveModal = useModal();
|
const approveModal = useModal();
|
||||||
const rejectModal = useModal();
|
const rejectModal = useModal();
|
||||||
|
|
||||||
|
// ===== FILTER MODAL STATE =====
|
||||||
|
const filterModal = useModal();
|
||||||
|
|
||||||
const [selectedExpense, setSelectedExpense] = useState<Expense | undefined>(
|
const [selectedExpense, setSelectedExpense] = useState<Expense | undefined>(
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
@@ -340,31 +361,7 @@ const ExpensesTable = () => {
|
|||||||
const currentRowRelativeIndex =
|
const currentRowRelativeIndex =
|
||||||
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
|
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
|
||||||
|
|
||||||
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 3;
|
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||||
|
|
||||||
const approveClickHandler = () => {
|
|
||||||
setSelectedExpense(props.row.original);
|
|
||||||
|
|
||||||
// Set row selection
|
|
||||||
setRowSelection({
|
|
||||||
[String(props.row.original.id)]: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
setApprovalNotes('');
|
|
||||||
approveModal.openModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
const rejectClickHandler = () => {
|
|
||||||
setSelectedExpense(props.row.original);
|
|
||||||
|
|
||||||
// Set row selection
|
|
||||||
setRowSelection({
|
|
||||||
[String(props.row.original.id)]: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
setApprovalNotes('');
|
|
||||||
rejectModal.openModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteClickHandler = () => {
|
const deleteClickHandler = () => {
|
||||||
setSelectedExpense(props.row.original);
|
setSelectedExpense(props.row.original);
|
||||||
@@ -372,31 +369,11 @@ const ExpensesTable = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
{currentPageSize > 3 && (
|
|
||||||
<RowDropdownOptions isLast2Rows={isLast2Rows}>
|
|
||||||
<RowOptionsMenu
|
<RowOptionsMenu
|
||||||
type='dropdown'
|
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
|
||||||
props={props}
|
props={props}
|
||||||
approveClickHandler={approveClickHandler}
|
|
||||||
rejectClickHandler={rejectClickHandler}
|
|
||||||
deleteClickHandler={deleteClickHandler}
|
deleteClickHandler={deleteClickHandler}
|
||||||
/>
|
/>
|
||||||
</RowDropdownOptions>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentPageSize <= 3 && (
|
|
||||||
<RowCollapseOptions>
|
|
||||||
<RowOptionsMenu
|
|
||||||
type='collapse'
|
|
||||||
props={props}
|
|
||||||
approveClickHandler={approveClickHandler}
|
|
||||||
rejectClickHandler={rejectClickHandler}
|
|
||||||
deleteClickHandler={deleteClickHandler}
|
|
||||||
/>
|
|
||||||
</RowCollapseOptions>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -535,51 +512,41 @@ const ExpensesTable = () => {
|
|||||||
setIsRejectLoading(false);
|
setIsRejectLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const {
|
useEffect(() => {
|
||||||
setInputValue: setLocationInputValue,
|
updateFilter('search', searchValue);
|
||||||
options: locationOptions,
|
}, [searchValue, updateFilter]);
|
||||||
isLoadingOptions: isLoadingLocationOptions,
|
|
||||||
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
|
|
||||||
|
|
||||||
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
|
useEffect(() => {
|
||||||
null
|
setTableState('expense-table', pathname);
|
||||||
);
|
}, [pathname, setTableState]);
|
||||||
|
|
||||||
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
|
|
||||||
setSelectedLocation(val as OptionType);
|
|
||||||
updateFilter(
|
|
||||||
'locationId',
|
|
||||||
val ? ((val as OptionType).value as string) : ''
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const {
|
|
||||||
setInputValue: setVendorInputValue,
|
|
||||||
options: vendorOptions,
|
|
||||||
isLoadingOptions: isLoadingVendorOptions,
|
|
||||||
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
|
|
||||||
|
|
||||||
const [selectedVendor, setSelectedVendor] = useState<OptionType | null>(null);
|
|
||||||
|
|
||||||
const vendorChangeHandler = (val: OptionType | OptionType[] | null) => {
|
|
||||||
setSelectedVendor(val as OptionType);
|
|
||||||
updateFilter('vendorId', val ? ((val as OptionType).value as string) : '');
|
|
||||||
};
|
|
||||||
|
|
||||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
|
setSearchValue(e.target.value);
|
||||||
updateFilter('search', e.target.value);
|
updateFilter('search', e.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const transactionDateChangeHandler: ChangeEventHandler<HTMLInputElement> = (
|
// ===== FILTER MODAL HANDLERS =====
|
||||||
e
|
const handleFilterModalOpen = () => {
|
||||||
) => {
|
filterModal.openModal();
|
||||||
updateFilter('transactionDate', e.target.value);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const realizationDateChangeHandler: ChangeEventHandler<HTMLInputElement> = (
|
const handleFilterSubmit = (values: {
|
||||||
e
|
transaction_date?: string | null;
|
||||||
) => {
|
realization_date?: string | null;
|
||||||
updateFilter('realizationDate', e.target.value);
|
location_id?: string | null;
|
||||||
|
vendor_id?: string | null;
|
||||||
|
}) => {
|
||||||
|
updateFilter('transactionDate', values.transaction_date || '');
|
||||||
|
updateFilter('realizationDate', values.realization_date || '');
|
||||||
|
updateFilter('locationId', values.location_id || '');
|
||||||
|
updateFilter('vendorId', values.vendor_id || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilterReset = () => {
|
||||||
|
updateFilter('transactionDate', '');
|
||||||
|
updateFilter('realizationDate', '');
|
||||||
|
updateFilter('locationId', '');
|
||||||
|
updateFilter('vendorId', '');
|
||||||
};
|
};
|
||||||
|
|
||||||
// track sorting
|
// track sorting
|
||||||
@@ -595,16 +562,15 @@ const ExpensesTable = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='w-full p-0 sm:p-4'>
|
<div className='w-full'>
|
||||||
<div className='flex flex-col gap-2 mb-4'>
|
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
|
||||||
<div className='flex flex-col gap-2 mb-4'>
|
{/* Action Buttons */}
|
||||||
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-4'>
|
<div className='w-fit flex flex-row gap-3 flex-wrap'>
|
||||||
<div className='w-full sm:w-fit flex flex-col sm:flex-row self-start gap-2'>
|
|
||||||
<RequirePermission permissions='lti.expense.create'>
|
<RequirePermission permissions='lti.expense.create'>
|
||||||
<Button
|
<Button
|
||||||
href='/expense/add'
|
href='/expense/add'
|
||||||
color='primary'
|
color='primary'
|
||||||
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
|
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-xl shadow-button-soft'
|
||||||
>
|
>
|
||||||
<Icon icon='heroicons:plus' width={20} height={20} />
|
<Icon icon='heroicons:plus' width={20} height={20} />
|
||||||
Add Expense
|
Add Expense
|
||||||
@@ -613,15 +579,22 @@ const ExpensesTable = () => {
|
|||||||
|
|
||||||
{selectedRowIds.length > 0 && (
|
{selectedRowIds.length > 0 && (
|
||||||
<>
|
<>
|
||||||
|
<hr className='w-px h-full border-none bg-base-content/10 sm:block hidden' />
|
||||||
|
|
||||||
<RequirePermission permissions='lti.expense.approve.head_area'>
|
<RequirePermission permissions='lti.expense.approve.head_area'>
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
color='info'
|
color='none'
|
||||||
onClick={bulkApproveClickHandler}
|
onClick={bulkApproveClickHandler}
|
||||||
disabled={!isAllSelectedRowLatestApprovalOnHeadArea}
|
disabled={!isAllSelectedRowLatestApprovalOnHeadArea}
|
||||||
className='w-full sm:w-fit'
|
className='px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||||
>
|
>
|
||||||
<Icon icon='lucide-lab:farm' width={24} height={24} />
|
<Icon
|
||||||
|
icon='lucide-lab:farm'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
className='text-success'
|
||||||
|
/>
|
||||||
Approve Head Area
|
Approve Head Area
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
@@ -629,14 +602,19 @@ const ExpensesTable = () => {
|
|||||||
<RequirePermission permissions='lti.expense.approve.unit_vice_president'>
|
<RequirePermission permissions='lti.expense.approve.unit_vice_president'>
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
color='success'
|
color='none'
|
||||||
onClick={bulkApproveClickHandler}
|
onClick={bulkApproveClickHandler}
|
||||||
disabled={
|
disabled={
|
||||||
!isAllSelectedRowLatestApprovalOnUnitVicePresident
|
!isAllSelectedRowLatestApprovalOnUnitVicePresident
|
||||||
}
|
}
|
||||||
className='w-full sm:w-fit'
|
className='px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||||
>
|
>
|
||||||
<Icon icon='tdesign:money' width={24} height={24} />
|
<Icon
|
||||||
|
icon='tdesign:money'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
className='text-success'
|
||||||
|
/>
|
||||||
Approve Unit Vice President
|
Approve Unit Vice President
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
@@ -644,12 +622,17 @@ const ExpensesTable = () => {
|
|||||||
<RequirePermission permissions='lti.expense.approve.finance'>
|
<RequirePermission permissions='lti.expense.approve.finance'>
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
color='success'
|
color='none'
|
||||||
onClick={bulkApproveClickHandler}
|
onClick={bulkApproveClickHandler}
|
||||||
disabled={!isAllSelectedRowLatestApprovalOnFinance}
|
disabled={!isAllSelectedRowLatestApprovalOnFinance}
|
||||||
className='w-full sm:w-fit'
|
className='px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||||
>
|
>
|
||||||
<Icon icon='tdesign:money' width={24} height={24} />
|
<Icon
|
||||||
|
icon='tdesign:money'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
className='text-success'
|
||||||
|
/>
|
||||||
Approve Finance
|
Approve Finance
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
@@ -663,19 +646,20 @@ const ExpensesTable = () => {
|
|||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
color='error'
|
color='none'
|
||||||
onClick={bulkRejectClickHandler}
|
onClick={bulkRejectClickHandler}
|
||||||
disabled={
|
disabled={
|
||||||
!isAllSelectedRowLatestApprovalOnHeadArea &&
|
!isAllSelectedRowLatestApprovalOnHeadArea &&
|
||||||
!isAllSelectedRowLatestApprovalOnUnitVicePresident &&
|
!isAllSelectedRowLatestApprovalOnUnitVicePresident &&
|
||||||
!isAllSelectedRowLatestApprovalOnFinance
|
!isAllSelectedRowLatestApprovalOnFinance
|
||||||
}
|
}
|
||||||
className='w-full sm:w-fit'
|
className='px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
icon='material-symbols:close'
|
icon='material-symbols:close'
|
||||||
width={24}
|
width={20}
|
||||||
height={24}
|
height={20}
|
||||||
|
className='text-error'
|
||||||
/>
|
/>
|
||||||
Reject
|
Reject
|
||||||
</Button>
|
</Button>
|
||||||
@@ -683,70 +667,65 @@ const ExpensesTable = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='grid grid-cols-12 justify-end gap-2'>
|
|
||||||
<DateInput
|
|
||||||
required
|
|
||||||
label='Tanggal Transaksi'
|
|
||||||
name='transaction_date'
|
|
||||||
placeholder='Masukkan tanggal transaksi'
|
|
||||||
value={tableFilterState.transactionDate}
|
|
||||||
onChange={transactionDateChangeHandler}
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12 sm:col-span-3',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DateInput
|
|
||||||
required
|
|
||||||
label='Tanggal Realisasi'
|
|
||||||
name='realization_date'
|
|
||||||
placeholder='Masukkan tanggal realisasi'
|
|
||||||
value={tableFilterState.realizationDate}
|
|
||||||
onChange={realizationDateChangeHandler}
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12 sm:col-span-3',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SelectInput
|
|
||||||
label='Lokasi'
|
|
||||||
options={locationOptions}
|
|
||||||
isLoading={isLoadingLocationOptions}
|
|
||||||
value={selectedLocation}
|
|
||||||
onChange={locationChangeHandler}
|
|
||||||
onInputChange={setLocationInputValue}
|
|
||||||
isClearable
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12 sm:col-span-3',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SelectInput
|
|
||||||
label='Vendor'
|
|
||||||
options={vendorOptions}
|
|
||||||
isLoading={isLoadingVendorOptions}
|
|
||||||
value={selectedVendor}
|
|
||||||
onChange={vendorChangeHandler}
|
|
||||||
onInputChange={setVendorInputValue}
|
|
||||||
isClearable
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12 sm:col-span-3',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
{/* Search and Filter */}
|
||||||
|
<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 Biaya Operasional'
|
placeholder='Search'
|
||||||
value={tableFilterState.search}
|
value={tableFilterState.search ?? ''}
|
||||||
onChange={searchChangeHandler}
|
onChange={searchChangeHandler}
|
||||||
className={{ wrapper: 'col-span-12 max-w-52 justify-self-end' }}
|
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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ButtonFilter
|
||||||
|
values={tableFilterState}
|
||||||
|
excludeFields={[
|
||||||
|
'page',
|
||||||
|
'pageSize',
|
||||||
|
'search',
|
||||||
|
'nameSort',
|
||||||
|
'userId',
|
||||||
|
]}
|
||||||
|
onClick={handleFilterModalOpen}
|
||||||
|
className='px-3 py-2.5'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{/* Table Section */}
|
||||||
|
<div className='flex flex-col mb-4'>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
) : !isResponseSuccess(expenses) || expenses.data?.length === 0 ? (
|
||||||
|
<div className='p-3'>
|
||||||
|
<ExpenseTableSkeleton
|
||||||
|
columns={expensesColumns}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:document-text'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Table<Expense>
|
<Table<Expense>
|
||||||
data={isResponseSuccess(expenses) ? expenses?.data : []}
|
data={isResponseSuccess(expenses) ? expenses?.data : []}
|
||||||
columns={expensesColumns}
|
columns={expensesColumns}
|
||||||
@@ -756,6 +735,7 @@ const ExpensesTable = () => {
|
|||||||
isResponseSuccess(expenses) ? expenses?.meta?.total_results : 0
|
isResponseSuccess(expenses) ? expenses?.meta?.total_results : 0
|
||||||
}
|
}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={setPageSize}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
sorting={sorting}
|
sorting={sorting}
|
||||||
setSorting={setSorting}
|
setSorting={setSorting}
|
||||||
@@ -763,20 +743,12 @@ const ExpensesTable = () => {
|
|||||||
setRowSelection={setRowSelection}
|
setRowSelection={setRowSelection}
|
||||||
enableRowSelection={tableEnableRowSelectionHandler}
|
enableRowSelection={tableEnableRowSelectionHandler}
|
||||||
className={{
|
className={{
|
||||||
containerClassName: cn({
|
containerClassName: cn('p-3 mb-0'),
|
||||||
'mb-20':
|
headerColumnClassName: 'text-nowrap',
|
||||||
isResponseSuccess(expenses) && expenses?.data?.length === 0,
|
|
||||||
}),
|
|
||||||
tableWrapperClassName: 'overflow-x-auto min-h-full!',
|
|
||||||
tableClassName: 'font-inter w-full table-auto min-h-full!',
|
|
||||||
headerRowClassName: 'border-b border-b-gray-200',
|
|
||||||
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',
|
|
||||||
bodyColumnClassName:
|
|
||||||
'px-6 py-3 last:flex last:flex-row last:justify-end',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
@@ -831,6 +803,12 @@ const ExpensesTable = () => {
|
|||||||
onClick: confirmationModalRejectClickHandler,
|
onClick: confirmationModalRejectClickHandler,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ExpensesFilterModal
|
||||||
|
ref={filterModal.ref}
|
||||||
|
onSubmit={handleFilterSubmit}
|
||||||
|
onReset={handleFilterReset}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import * as yup from 'yup';
|
||||||
|
|
||||||
|
export type ExpensesFilterType = {
|
||||||
|
transaction_date: string | null;
|
||||||
|
realization_date: string | null;
|
||||||
|
location_id: string | null;
|
||||||
|
vendor_id: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ExpensesFilterSchema = yup.object({
|
||||||
|
transaction_date: yup.string().nullable(),
|
||||||
|
realization_date: yup
|
||||||
|
.string()
|
||||||
|
.nullable()
|
||||||
|
.test(
|
||||||
|
'is-greater-or-equal-transaction',
|
||||||
|
'Tanggal realisasi tidak boleh sebelum tanggal transaksi',
|
||||||
|
function (value) {
|
||||||
|
const { transaction_date } = this.parent;
|
||||||
|
if (!transaction_date || !value) return true;
|
||||||
|
return new Date(value) >= new Date(transaction_date);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
location_id: yup.string().nullable(),
|
||||||
|
vendor_id: yup.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ExpensesFilterValues = yup.InferType<typeof ExpensesFilterSchema>;
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { RefObject } from 'react';
|
||||||
|
import { useFormik } from 'formik';
|
||||||
|
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import Modal from '@/components/Modal';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import DateInput from '@/components/input/DateInput';
|
||||||
|
import SelectInput from '@/components/input/SelectInput';
|
||||||
|
|
||||||
|
import { OptionType, useSelect } from '@/components/input/SelectInput';
|
||||||
|
import { LocationApi, SupplierApi } from '@/services/api/master-data';
|
||||||
|
import { Location } from '@/types/api/master-data/location';
|
||||||
|
import { Supplier } from '@/types/api/master-data/supplier';
|
||||||
|
import {
|
||||||
|
ExpensesFilterSchema,
|
||||||
|
ExpensesFilterValues,
|
||||||
|
} from '@/components/pages/expense/filter/ExpensesFilter';
|
||||||
|
|
||||||
|
interface ExpensesFilterModalProps {
|
||||||
|
ref: RefObject<HTMLDialogElement | null>;
|
||||||
|
initialValues?: ExpensesFilterValues;
|
||||||
|
onSubmit?: (values: Partial<ExpensesFilterValues>) => void;
|
||||||
|
onReset?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExpensesFilterModal = ({
|
||||||
|
ref,
|
||||||
|
initialValues,
|
||||||
|
onSubmit,
|
||||||
|
onReset,
|
||||||
|
}: ExpensesFilterModalProps) => {
|
||||||
|
const closeModalHandler = () => {
|
||||||
|
ref.current?.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
setInputValue: setLocationInputValue,
|
||||||
|
options: locationOptions,
|
||||||
|
isLoadingOptions: isLoadingLocationOptions,
|
||||||
|
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
|
||||||
|
|
||||||
|
const {
|
||||||
|
setInputValue: setVendorInputValue,
|
||||||
|
options: vendorOptions,
|
||||||
|
isLoadingOptions: isLoadingVendorOptions,
|
||||||
|
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
|
||||||
|
|
||||||
|
const formik = useFormik<ExpensesFilterValues>({
|
||||||
|
initialValues: initialValues || {
|
||||||
|
transaction_date: null,
|
||||||
|
realization_date: null,
|
||||||
|
location_id: null,
|
||||||
|
vendor_id: null,
|
||||||
|
},
|
||||||
|
validationSchema: ExpensesFilterSchema,
|
||||||
|
onSubmit: async (values) => {
|
||||||
|
onSubmit?.(values);
|
||||||
|
closeModalHandler();
|
||||||
|
},
|
||||||
|
onReset: () => {
|
||||||
|
onReset?.();
|
||||||
|
closeModalHandler();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const locationValue = formik.values.location_id
|
||||||
|
? locationOptions.find(
|
||||||
|
(opt) => String(opt.value) === formik.values.location_id
|
||||||
|
) || null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const vendorValue = formik.values.vendor_id
|
||||||
|
? vendorOptions.find(
|
||||||
|
(opt) => String(opt.value) === formik.values.vendor_id
|
||||||
|
) || null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||||
|
const locationId =
|
||||||
|
val && !Array.isArray(val) ? (String(val.value) as string) : null;
|
||||||
|
formik.setFieldValue('location_id', locationId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const vendorChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||||
|
const vendorId =
|
||||||
|
val && !Array.isArray(val) ? (String(val.value) as string) : null;
|
||||||
|
formik.setFieldValue('vendor_id', vendorId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
ref={ref}
|
||||||
|
className={{
|
||||||
|
modalBox: 'p-0 rounded-xl xl:max-w-4/12 max-w-sm',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
onSubmit={formik.handleSubmit}
|
||||||
|
onReset={formik.handleReset}
|
||||||
|
className='w-full flex flex-col'
|
||||||
|
>
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className='p-4 flex items-center justify-between gap-2 border-b border-base-content/10'>
|
||||||
|
<div className='flex items-center gap-2 text-primary'>
|
||||||
|
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||||
|
<h3 className='text-sm font-medium'>Filter Data</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
onClick={closeModalHandler}
|
||||||
|
className='p-0 text-base-content/50 hover:text-base-content'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Body */}
|
||||||
|
<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='transaction_date'
|
||||||
|
placeholder='Tanggal Transaksi'
|
||||||
|
value={formik.values.transaction_date || ''}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
isError={
|
||||||
|
formik.touched.transaction_date &&
|
||||||
|
!!formik.errors.transaction_date
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<hr className='w-full max-w-3 h-px border-base-content/10' />
|
||||||
|
<DateInput
|
||||||
|
name='realization_date'
|
||||||
|
placeholder='Tanggal Realisasi'
|
||||||
|
value={formik.values.realization_date || ''}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
isError={
|
||||||
|
formik.touched.realization_date &&
|
||||||
|
!!formik.errors.realization_date
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{formik.touched.realization_date &&
|
||||||
|
formik.errors.realization_date && (
|
||||||
|
<span className='text-xs text-error'>
|
||||||
|
{formik.errors.realization_date}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SelectInput
|
||||||
|
label='Lokasi'
|
||||||
|
placeholder='Pilih Lokasi'
|
||||||
|
options={locationOptions}
|
||||||
|
value={locationValue}
|
||||||
|
onChange={locationChangeHandler}
|
||||||
|
onInputChange={setLocationInputValue}
|
||||||
|
isLoading={isLoadingLocationOptions}
|
||||||
|
isClearable
|
||||||
|
isSearchable={true}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectInput
|
||||||
|
label='Vendor'
|
||||||
|
placeholder='Pilih Vendor'
|
||||||
|
options={vendorOptions}
|
||||||
|
value={vendorValue}
|
||||||
|
onChange={vendorChangeHandler}
|
||||||
|
onInputChange={setVendorInputValue}
|
||||||
|
isLoading={isLoadingVendorOptions}
|
||||||
|
isClearable
|
||||||
|
isSearchable={true}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className='p-4 flex justify-between gap-4 border-t border-base-content/10 bg-gray-50'>
|
||||||
|
<Button
|
||||||
|
type='reset'
|
||||||
|
variant='soft'
|
||||||
|
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||||
|
>
|
||||||
|
Reset Filter
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||||
|
disabled={!formik.isValid || formik.isSubmitting}
|
||||||
|
>
|
||||||
|
Apply Filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExpensesFilterModal;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
@@ -75,6 +75,12 @@ const ExpenseKandangsTable = ({
|
|||||||
.filter((id): id is number => id !== undefined)
|
.filter((id): id is number => id !== undefined)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
const rowSelectionRef = useRef(rowSelection);
|
||||||
|
const prevRowSelectionRef = useRef<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
rowSelectionRef.current = rowSelection;
|
||||||
|
}, [rowSelection]);
|
||||||
|
|
||||||
const kandangsColumns: ColumnDef<Kandang>[] = [
|
const kandangsColumns: ColumnDef<Kandang>[] = [
|
||||||
{
|
{
|
||||||
@@ -133,10 +139,19 @@ const ExpenseKandangsTable = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOpen(isResponseSuccess(kandangs) ? kandangs.data.length > 0 : false);
|
setOpen(isResponseSuccess(kandangs) ? kandangs.data.length > 0 : false);
|
||||||
}, [kandangs, isResponseSuccess]);
|
}, [kandangs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Object.keys(rowSelection).length !== 0 && isResponseSuccess(kandangs)) {
|
const currentKeys = Object.keys(rowSelection).sort().join(',');
|
||||||
|
const prevKeys = Object.keys(prevRowSelectionRef.current).sort().join(',');
|
||||||
|
|
||||||
|
if (currentKeys !== prevKeys) {
|
||||||
|
prevRowSelectionRef.current = { ...rowSelection };
|
||||||
|
|
||||||
|
if (
|
||||||
|
Object.keys(rowSelection).length !== 0 &&
|
||||||
|
isResponseSuccess(kandangs)
|
||||||
|
) {
|
||||||
const formattedSelectedKandangs = Object.keys(rowSelection).map(
|
const formattedSelectedKandangs = Object.keys(rowSelection).map(
|
||||||
(item) => {
|
(item) => {
|
||||||
const selectedKandang = kandangs.data.find(
|
const selectedKandang = kandangs.data.find(
|
||||||
@@ -151,15 +166,16 @@ const ExpenseKandangsTable = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
onChange(formattedSelectedKandangs);
|
onChange(formattedSelectedKandangs);
|
||||||
} else {
|
} else if (Object.keys(rowSelection).length === 0) {
|
||||||
onChange([]);
|
onChange([]);
|
||||||
}
|
}
|
||||||
}, [rowSelection]);
|
}
|
||||||
|
}, [rowSelection, kandangs, onChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
selectedKandangs.length === 0 &&
|
selectedKandangs.length === 0 &&
|
||||||
Object.keys(rowSelection).length !== 0
|
Object.keys(rowSelectionRef.current).length !== 0
|
||||||
) {
|
) {
|
||||||
setRowSelection({});
|
setRowSelection({});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useFormik } from 'formik';
|
import { useFormik } from 'formik';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
@@ -90,6 +90,7 @@ const ExpenseRealizationForm = ({
|
|||||||
|
|
||||||
const formik = useFormik<ExpenseRealizationFormValues>({
|
const formik = useFormik<ExpenseRealizationFormValues>({
|
||||||
initialValues: getExpenseRealizationFormInitialValues(initialValues),
|
initialValues: getExpenseRealizationFormInitialValues(initialValues),
|
||||||
|
enableReinitialize: true,
|
||||||
validationSchema:
|
validationSchema:
|
||||||
type === 'edit'
|
type === 'edit'
|
||||||
? UpdateExpenseRealizationFormSchema
|
? UpdateExpenseRealizationFormSchema
|
||||||
@@ -143,7 +144,6 @@ const ExpenseRealizationForm = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { setValues: formikSetValues } = formik;
|
|
||||||
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
|
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -254,10 +254,6 @@ const ExpenseRealizationForm = ({
|
|||||||
formik.setFieldValue('documents', newRequestDocuments);
|
formik.setFieldValue('documents', newRequestDocuments);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
formikSetValues(getExpenseRealizationFormInitialValues(initialValues));
|
|
||||||
}, [formikSetValues, getExpenseRealizationFormInitialValues, initialValues]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className='w-full'>
|
<section className='w-full'>
|
||||||
<header className='flex flex-col gap-4'>
|
<header className='flex flex-col gap-4'>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useFormik } from 'formik';
|
import { useFormik } from 'formik';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
@@ -102,6 +102,7 @@ const ExpenseRequestForm = ({
|
|||||||
|
|
||||||
const formik = useFormik<ExpenseRequestFormValues>({
|
const formik = useFormik<ExpenseRequestFormValues>({
|
||||||
initialValues: getExpenseFormInitialValues(initialValues),
|
initialValues: getExpenseFormInitialValues(initialValues),
|
||||||
|
enableReinitialize: true,
|
||||||
validationSchema:
|
validationSchema:
|
||||||
type === 'edit'
|
type === 'edit'
|
||||||
? UpdateExpenseRequestFormSchema
|
? UpdateExpenseRequestFormSchema
|
||||||
@@ -171,7 +172,7 @@ const ExpenseRequestForm = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { setValues: formikSetValues } = formik;
|
const { setFieldValue, setFieldTouched } = formik;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
setInputValue: setLocationInputValue,
|
setInputValue: setLocationInputValue,
|
||||||
@@ -186,8 +187,8 @@ const ExpenseRequestForm = ({
|
|||||||
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
|
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
|
||||||
|
|
||||||
const categoryChangeHandler = (val: OptionType | OptionType[] | null) => {
|
const categoryChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||||
formik.setFieldTouched('category', true);
|
setFieldTouched('category', true);
|
||||||
formik.setFieldValue('category', val);
|
setFieldValue('category', val);
|
||||||
};
|
};
|
||||||
|
|
||||||
const locationChangeHandler = useCallback(
|
const locationChangeHandler = useCallback(
|
||||||
@@ -195,12 +196,12 @@ const ExpenseRequestForm = ({
|
|||||||
const location = val as OptionType | null;
|
const location = val as OptionType | null;
|
||||||
const locationId = location ? Number(location.value) : 0;
|
const locationId = location ? Number(location.value) : 0;
|
||||||
|
|
||||||
formik.setFieldTouched('location', true);
|
setFieldTouched('location', true);
|
||||||
formik.setFieldValue('location', location);
|
setFieldValue('location', location);
|
||||||
formik.setFieldTouched('location_id', true);
|
setFieldTouched('location_id', true);
|
||||||
formik.setFieldValue('location_id', locationId);
|
setFieldValue('location_id', locationId);
|
||||||
},
|
},
|
||||||
[]
|
[setFieldTouched, setFieldValue]
|
||||||
);
|
);
|
||||||
|
|
||||||
const kandangsChangeHandler = (
|
const kandangsChangeHandler = (
|
||||||
@@ -343,10 +344,6 @@ const ExpenseRequestForm = ({
|
|||||||
formik.handleSubmit(e);
|
formik.handleSubmit(e);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
formikSetValues(getExpenseFormInitialValues(initialValues));
|
|
||||||
}, [formikSetValues, getExpenseFormInitialValues, initialValues]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className='w-full'>
|
<section className='w-full'>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -2,7 +2,6 @@ import Button from '@/components/Button';
|
|||||||
import Card from '@/components/Card';
|
import Card from '@/components/Card';
|
||||||
import { FormHeader } from '@/components/helper/form/FormHeader';
|
import { FormHeader } from '@/components/helper/form/FormHeader';
|
||||||
import RequirePermission from '@/components/helper/RequirePermission';
|
import RequirePermission from '@/components/helper/RequirePermission';
|
||||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
|
||||||
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 Table from '@/components/Table';
|
||||||
@@ -26,11 +25,13 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
|
|||||||
const informasiUmum = [
|
const informasiUmum = [
|
||||||
{
|
{
|
||||||
label: 'ID',
|
label: 'ID',
|
||||||
value: finance.payment_code,
|
value: finance.payment_code || '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Jenis Transaksi',
|
label: 'Jenis Transaksi',
|
||||||
value: formatTitleCase(finance.transaction_type.split('_').join(' ')),
|
value: formatTitleCase(
|
||||||
|
(finance.transaction_type || '').split('_').join(' ')
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Pihak',
|
label: 'Pihak',
|
||||||
@@ -38,11 +39,13 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Tanggal',
|
label: 'Tanggal',
|
||||||
value: formatDate(finance.payment_date, 'DD MMM yyyy'),
|
value: finance.payment_date
|
||||||
|
? formatDate(finance.payment_date, 'DD MMM yyyy')
|
||||||
|
: '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Metode Pembayaran',
|
label: 'Metode Pembayaran',
|
||||||
value: finance.payment_method,
|
value: finance.payment_method || '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Catatan',
|
label: 'Catatan',
|
||||||
@@ -61,22 +64,22 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
|
|||||||
: '-',
|
: '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: `Rekening ${formatTitleCase(finance.party?.type)}`,
|
label: `Rekening ${formatTitleCase(finance.party?.type || '')}`,
|
||||||
value: finance.party?.account_number,
|
value: finance.party?.account_number || '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Nominal',
|
label: 'Nominal',
|
||||||
value: formatCurrency(
|
value: formatCurrency(
|
||||||
finance.transaction_type === 'INJECTION'
|
finance.transaction_type === 'INJECTION'
|
||||||
? finance.nominal
|
? finance.nominal || 0
|
||||||
: Math.abs(finance.nominal)
|
: Math.abs(finance.nominal || 0)
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
].filter((item) => {
|
].filter((item) => {
|
||||||
// Hide party account number row if transaction type is INJECTION
|
// Hide party account number row if transaction type is INJECTION
|
||||||
if (
|
if (
|
||||||
FINANCE_INJECTION_STATUS.includes(finance.transaction_type) &&
|
FINANCE_INJECTION_STATUS.includes(finance.transaction_type || '') &&
|
||||||
item.label === `Rekening ${formatTitleCase(finance.party?.type)}`
|
item.label === `Rekening ${formatTitleCase(finance.party?.type || '')}`
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -150,7 +153,7 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className='flex flex-row gap-2 justify-end'>
|
<div className='flex flex-row gap-2 justify-end'>
|
||||||
{FINANCE_TRANSACTION_STATUS.includes(finance.transaction_type) &&
|
{FINANCE_TRANSACTION_STATUS.includes(finance.transaction_type || '') &&
|
||||||
finance.party?.type !== 'SUPPLIER' && (
|
finance.party?.type !== 'SUPPLIER' && (
|
||||||
<RequirePermission permissions='lti.finance.payments.update'>
|
<RequirePermission permissions='lti.finance.payments.update'>
|
||||||
<Button
|
<Button
|
||||||
@@ -163,7 +166,9 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
)}
|
)}
|
||||||
{FINANCE_INITIAL_BALANCE_STATUS.includes(finance.transaction_type) && (
|
{FINANCE_INITIAL_BALANCE_STATUS.includes(
|
||||||
|
finance.transaction_type || ''
|
||||||
|
) && (
|
||||||
<RequirePermission permissions='lti.finance.initial_balances.update'>
|
<RequirePermission permissions='lti.finance.initial_balances.update'>
|
||||||
<Button
|
<Button
|
||||||
color='warning'
|
color='warning'
|
||||||
@@ -175,7 +180,7 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
)}
|
)}
|
||||||
{FINANCE_INJECTION_STATUS.includes(finance.transaction_type) && (
|
{FINANCE_INJECTION_STATUS.includes(finance.transaction_type || '') && (
|
||||||
<RequirePermission permissions='lti.finance.injections.update'>
|
<RequirePermission permissions='lti.finance.injections.update'>
|
||||||
<Button
|
<Button
|
||||||
color='warning'
|
color='warning'
|
||||||
@@ -201,7 +206,7 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
|
|||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
ref={deleteModal.ref}
|
ref={deleteModal.ref}
|
||||||
type='error'
|
type='error'
|
||||||
text={`Apakah anda yakin ingin menghapus data Finance ini (${finance?.payment_code})?`}
|
text={`Apakah anda yakin ingin menghapus data Finance ini (${finance?.payment_code || ''})?`}
|
||||||
secondaryButton={{
|
secondaryButton={{
|
||||||
text: 'Tidak',
|
text: 'Tidak',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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,31 +33,57 @@ 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'>
|
||||||
|
<PopoverButton
|
||||||
|
tabIndex={0}
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
popoverTarget={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
>
|
||||||
|
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||||
|
</PopoverButton>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
id={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
|
||||||
|
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
|
||||||
|
>
|
||||||
|
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||||
<RequirePermission
|
<RequirePermission
|
||||||
permissions={[
|
permissions={[
|
||||||
'lti.finance.transactions.detail',
|
'lti.finance.transactions.detail',
|
||||||
@@ -61,10 +95,11 @@ const RowOptionsMenu = ({
|
|||||||
<Button
|
<Button
|
||||||
href={`/finance/detail?financeId=${props.row.original.id}`}
|
href={`/finance/detail?financeId=${props.row.original.id}`}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='primary'
|
color='none'
|
||||||
className='justify-start text-sm'
|
className='p-3 justify-start text-sm font-semibold w-full'
|
||||||
|
onClick={closePopover}
|
||||||
>
|
>
|
||||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
<Icon icon='heroicons:eye' width={20} height={20} />
|
||||||
Detail
|
Detail
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
@@ -76,10 +111,11 @@ const RowOptionsMenu = ({
|
|||||||
<Button
|
<Button
|
||||||
href={`/finance/detail/edit?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} />
|
<Icon icon='mdi:pencil-outline' width={20} height={20} />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
@@ -92,10 +128,11 @@ const RowOptionsMenu = ({
|
|||||||
<Button
|
<Button
|
||||||
href={`/finance/detail/edit/initial-balance?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} />
|
<Icon icon='mdi:pencil-outline' width={20} height={20} />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
@@ -108,10 +145,11 @@ const RowOptionsMenu = ({
|
|||||||
<Button
|
<Button
|
||||||
href={`/finance/detail/edit/injection?financeId=${props.row.original.id}`}
|
href={`/finance/detail/edit/injection?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} />
|
<Icon icon='mdi:pencil-outline' width={20} height={20} />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
@@ -119,21 +157,21 @@ const RowOptionsMenu = ({
|
|||||||
|
|
||||||
<RequirePermission permissions='lti.finance.transactions.delete'>
|
<RequirePermission permissions='lti.finance.transactions.delete'>
|
||||||
<Button
|
<Button
|
||||||
onClick={deleteClickHandler}
|
onClick={() => {
|
||||||
|
deleteClickHandler();
|
||||||
|
closePopover();
|
||||||
|
}}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='error'
|
color='error'
|
||||||
className='text-error hover:text-inherit'
|
className='p-3 justify-start text-sm font-semibold w-full focus-visible:text-error-content hover:text-error-content'
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon icon='mdi:delete-outline' width={20} height={20} />
|
||||||
icon='material-symbols:delete-outline-rounded'
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
className='justify-start text-sm'
|
|
||||||
/>
|
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
</RowOptionsMenuWrapper>
|
</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<
|
||||||
@@ -189,6 +230,7 @@ const FinanceTable = () => {
|
|||||||
const [selectedFinance, setSelectedFinance] = useState<Finance | null>(null);
|
const [selectedFinance, setSelectedFinance] = useState<Finance | null>(null);
|
||||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||||
const [dateErrorShown, setDateErrorShown] = useState(false);
|
const [dateErrorShown, setDateErrorShown] = useState(false);
|
||||||
|
const [hasDateError, setHasDateError] = useState(false);
|
||||||
|
|
||||||
// ===== Formik for Filter =====
|
// ===== Formik for Filter =====
|
||||||
const filterFormik = useFormik<FinanceTableFilterValues>({
|
const filterFormik = useFormik<FinanceTableFilterValues>({
|
||||||
@@ -214,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', '');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -266,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
|
||||||
) => {
|
) => {
|
||||||
@@ -335,10 +420,7 @@ const FinanceTable = () => {
|
|||||||
const endDateObj = new Date(endDate);
|
const endDateObj = new Date(endDate);
|
||||||
|
|
||||||
if (endDateObj < startDate) {
|
if (endDateObj < startDate) {
|
||||||
filterFormik.setFieldError(
|
setHasDateError(true);
|
||||||
'end_date',
|
|
||||||
'Tanggal akhir tidak boleh masa lampau'
|
|
||||||
);
|
|
||||||
if (!dateErrorShown) {
|
if (!dateErrorShown) {
|
||||||
toast.error('Tanggal akhir tidak boleh masa lampau', {
|
toast.error('Tanggal akhir tidak boleh masa lampau', {
|
||||||
duration: Infinity,
|
duration: Infinity,
|
||||||
@@ -346,12 +428,14 @@ const FinanceTable = () => {
|
|||||||
setDateErrorShown(true);
|
setDateErrorShown(true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
filterFormik.setFieldError('end_date', undefined);
|
setHasDateError(false);
|
||||||
if (dateErrorShown) {
|
if (dateErrorShown) {
|
||||||
toast.dismiss();
|
toast.dismiss();
|
||||||
setDateErrorShown(false);
|
setDateErrorShown(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
setHasDateError(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -366,10 +450,7 @@ const FinanceTable = () => {
|
|||||||
const endDate = new Date(value);
|
const endDate = new Date(value);
|
||||||
|
|
||||||
if (endDate < startDateObj) {
|
if (endDate < startDateObj) {
|
||||||
filterFormik.setFieldError(
|
setHasDateError(true);
|
||||||
'end_date',
|
|
||||||
'Tanggal akhir tidak boleh masa lampau'
|
|
||||||
);
|
|
||||||
if (!dateErrorShown) {
|
if (!dateErrorShown) {
|
||||||
toast.error('Tanggal akhir tidak boleh masa lampau', {
|
toast.error('Tanggal akhir tidak boleh masa lampau', {
|
||||||
duration: Infinity,
|
duration: Infinity,
|
||||||
@@ -380,13 +461,18 @@ const FinanceTable = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
filterFormik.setFieldError('end_date', undefined);
|
setHasDateError(false);
|
||||||
if (dateErrorShown) {
|
if (dateErrorShown) {
|
||||||
toast.dismiss();
|
toast.dismiss();
|
||||||
setDateErrorShown(false);
|
setDateErrorShown(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFilterModalOpen = () => {
|
||||||
|
filterModal.openModal();
|
||||||
|
filterFormik.validateForm();
|
||||||
|
};
|
||||||
|
|
||||||
const resetFilterHandler = () => {
|
const resetFilterHandler = () => {
|
||||||
setSelectedTransactionType(null);
|
setSelectedTransactionType(null);
|
||||||
setSelectedBank(null);
|
setSelectedBank(null);
|
||||||
@@ -406,6 +492,7 @@ const FinanceTable = () => {
|
|||||||
updateFilter('startDate', '');
|
updateFilter('startDate', '');
|
||||||
updateFilter('endDate', '');
|
updateFilter('endDate', '');
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmationModalDeleteClickHandler = async () => {
|
const confirmationModalDeleteClickHandler = async () => {
|
||||||
setIsDeleteLoading(true);
|
setIsDeleteLoading(true);
|
||||||
|
|
||||||
@@ -417,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',
|
||||||
@@ -498,32 +585,17 @@ const FinanceTable = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
{currentPageSize > 2 && (
|
|
||||||
<RowDropdownOptions isLast2Rows={isLast2Rows}>
|
|
||||||
<RowOptionsMenu
|
<RowOptionsMenu
|
||||||
type='dropdown'
|
|
||||||
props={props}
|
props={props}
|
||||||
deleteClickHandler={deleteClickHandler}
|
deleteClickHandler={deleteClickHandler}
|
||||||
|
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
|
||||||
/>
|
/>
|
||||||
</RowDropdownOptions>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentPageSize <= 2 && (
|
|
||||||
<RowCollapseOptions>
|
|
||||||
<RowOptionsMenu
|
|
||||||
type='collapse'
|
|
||||||
props={props}
|
|
||||||
deleteClickHandler={deleteClickHandler}
|
|
||||||
/>
|
|
||||||
</RowCollapseOptions>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
],
|
||||||
}, []);
|
[deleteModal]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -555,69 +627,202 @@ 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'>
|
||||||
|
{/* Header Section */}
|
||||||
|
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className='w-fit flex flex-row gap-3 flex-wrap'>
|
||||||
<RequirePermission permissions='lti.finance.injections.create'>
|
<RequirePermission permissions='lti.finance.injections.create'>
|
||||||
<Button
|
<Button
|
||||||
color='warning'
|
|
||||||
className='min-w-24'
|
|
||||||
href='/finance/add/injection'
|
href='/finance/add/injection'
|
||||||
|
color='warning'
|
||||||
|
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
|
||||||
>
|
>
|
||||||
Injection Saldo Bank
|
<Icon icon='mdi:bank-transfer-in' width={20} height={20} />
|
||||||
|
Add Injection (Saldo Bank)
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
<RequirePermission permissions='lti.finance.initial_balances.create'>
|
<RequirePermission permissions='lti.finance.initial_balances.create'>
|
||||||
<Button
|
<Button
|
||||||
color='info'
|
|
||||||
className='text-white min-w-24'
|
|
||||||
href='/finance/add/initial-balance'
|
href='/finance/add/initial-balance'
|
||||||
|
color='info'
|
||||||
|
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
|
||||||
>
|
>
|
||||||
Saldo Awal
|
<Icon icon='mdi:cash-register' width={20} height={20} />
|
||||||
|
Add Initial Balance
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
<RequirePermission permissions='lti.finance.payments.create'>
|
<RequirePermission permissions='lti.finance.payments.create'>
|
||||||
<Button color='primary' className='min-w-24' href='/finance/add'>
|
<Button
|
||||||
Tambah
|
href='/finance/add'
|
||||||
|
color='primary'
|
||||||
|
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:plus' width={20} height={20} />
|
||||||
|
Add Finance
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
</div>
|
</div>
|
||||||
<Card
|
|
||||||
variant='bordered'
|
{/* 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={{
|
className={{
|
||||||
wrapper: 'w-full',
|
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||||
|
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||||
|
input:
|
||||||
|
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||||
}}
|
}}
|
||||||
footer={
|
/>
|
||||||
<div className='flex justify-end gap-2'>
|
|
||||||
<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>
|
||||||
|
|
||||||
|
{/* Table Section */}
|
||||||
|
<div className='flex flex-col mb-4'>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
) : !isResponseSuccess(finances) || finances.data?.length === 0 ? (
|
||||||
|
<div className='p-3'>
|
||||||
|
<FinanceTableSkeleton
|
||||||
|
columns={columns}
|
||||||
|
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',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className='grid grid-cols-4 gap-6'>
|
{/* 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
|
<SelectInput
|
||||||
options={FINANCE_TRANSACTION_TYPE_OPTIONS}
|
options={FINANCE_TRANSACTION_TYPE_OPTIONS}
|
||||||
label='Jenis Transaksi'
|
label='Jenis Transaksi'
|
||||||
|
placeholder='Pilih Jenis Transaksi'
|
||||||
value={selectedTransactionType}
|
value={selectedTransactionType}
|
||||||
onChange={transactionTypeChangeHandler}
|
onChange={transactionTypeChangeHandler}
|
||||||
|
onInputChange={() => {}}
|
||||||
closeMenuOnSelect={false}
|
closeMenuOnSelect={false}
|
||||||
isClearable
|
isClearable
|
||||||
isMulti
|
isMulti
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
/>
|
/>
|
||||||
<SelectInput
|
<SelectInput
|
||||||
options={customerOptions}
|
options={customerOptions}
|
||||||
label={'Customer'}
|
label='Customer'
|
||||||
|
placeholder='Pilih Customer'
|
||||||
value={selectedCustomerId}
|
value={selectedCustomerId}
|
||||||
onChange={customerIdChangeHandler}
|
onChange={customerIdChangeHandler}
|
||||||
onInputChange={customerInputValue}
|
onInputChange={customerInputValue}
|
||||||
@@ -626,10 +831,12 @@ const FinanceTable = () => {
|
|||||||
closeMenuOnSelect={false}
|
closeMenuOnSelect={false}
|
||||||
isClearable
|
isClearable
|
||||||
isMulti
|
isMulti
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
/>
|
/>
|
||||||
<SelectInput
|
<SelectInput
|
||||||
options={supplierOptions}
|
options={supplierOptions}
|
||||||
label={'Supplier'}
|
label='Supplier'
|
||||||
|
placeholder='Pilih Supplier'
|
||||||
value={selectedSupplierId}
|
value={selectedSupplierId}
|
||||||
onChange={supplierIdChangeHandler}
|
onChange={supplierIdChangeHandler}
|
||||||
onInputChange={supplierInputValue}
|
onInputChange={supplierInputValue}
|
||||||
@@ -638,10 +845,12 @@ const FinanceTable = () => {
|
|||||||
closeMenuOnSelect={false}
|
closeMenuOnSelect={false}
|
||||||
isClearable
|
isClearable
|
||||||
isMulti
|
isMulti
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
/>
|
/>
|
||||||
<SelectInput
|
<SelectInput
|
||||||
options={bankSelectOptions}
|
options={bankSelectOptions}
|
||||||
label='Bank'
|
label='Bank'
|
||||||
|
placeholder='Pilih Bank'
|
||||||
value={selectedBank}
|
value={selectedBank}
|
||||||
onChange={bankChangeHandler}
|
onChange={bankChangeHandler}
|
||||||
onInputChange={bankInputValue}
|
onInputChange={bankInputValue}
|
||||||
@@ -649,57 +858,49 @@ const FinanceTable = () => {
|
|||||||
closeMenuOnSelect={false}
|
closeMenuOnSelect={false}
|
||||||
isClearable
|
isClearable
|
||||||
isMulti
|
isMulti
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
/>
|
/>
|
||||||
<SelectInput
|
<SelectInput
|
||||||
options={sortByOptions}
|
options={sortByOptions}
|
||||||
label='Urutkan Berdasarkan'
|
label='Urutkan Berdasarkan'
|
||||||
|
placeholder='Pilih Urutan'
|
||||||
value={selectedSortBy}
|
value={selectedSortBy}
|
||||||
onChange={sortByChangeHandler}
|
onChange={sortByChangeHandler}
|
||||||
isClearable
|
isClearable
|
||||||
/>
|
className={{ wrapper: 'w-full' }}
|
||||||
<DateInput
|
|
||||||
name='start_date'
|
|
||||||
label='Periode Tanggal (Mulai)'
|
|
||||||
value={filterFormik.values.start_date}
|
|
||||||
onChange={startDateChangeHandler}
|
|
||||||
errorMessage={
|
|
||||||
filterFormik.errors.end_date
|
|
||||||
? filterFormik.errors.end_date
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DateInput
|
|
||||||
name='end_date'
|
|
||||||
label='Periode Tanggal (Akhir)'
|
|
||||||
value={filterFormik.values.end_date}
|
|
||||||
onChange={endDateChangeHandler}
|
|
||||||
errorMessage={
|
|
||||||
filterFormik.errors.end_date
|
|
||||||
? filterFormik.errors.end_date
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DebouncedTextInput
|
|
||||||
name='search'
|
|
||||||
label='Cari'
|
|
||||||
placeholder='Cari'
|
|
||||||
value={filterFormik.values.search}
|
|
||||||
onChange={searchChangeHandler}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
<Table<Finance>
|
{/* Modal Footer */}
|
||||||
data={isResponseSuccess(finances) ? finances.data : []}
|
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||||
columns={columns}
|
<Button
|
||||||
pageSize={tableFilterState.pageSize}
|
type='button'
|
||||||
page={tableFilterState.page}
|
variant='soft'
|
||||||
onPageChange={setPage}
|
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'
|
||||||
onPageSizeChange={setPageSize}
|
onClick={() => {
|
||||||
totalItems={
|
filterFormik.resetForm();
|
||||||
isResponseSuccess(finances) ? finances.meta?.total_results : 0
|
setSelectedTransactionType(null);
|
||||||
}
|
setSelectedBank(null);
|
||||||
isLoading={isLoading}
|
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'
|
||||||
@@ -714,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;
|
||||||
@@ -1,22 +1,48 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Badge from '@/components/Badge';
|
import {
|
||||||
import Button from '@/components/Button';
|
ChangeEventHandler,
|
||||||
import SelectInput, { OptionType } from '@/components/input/SelectInput';
|
useCallback,
|
||||||
import Table from '@/components/Table';
|
useEffect,
|
||||||
import RequirePermission from '@/components/helper/RequirePermission';
|
useMemo,
|
||||||
import { ROWS_OPTIONS } from '@/config/constant';
|
useState,
|
||||||
import { isResponseSuccess } from '@/lib/api-helper';
|
} from 'react';
|
||||||
import { cn } from '@/lib/helper';
|
import { usePathname } from 'next/navigation';
|
||||||
import { InventoryAdjustmentApi } from '@/services/api/inventory';
|
import useSWR from 'swr';
|
||||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
|
||||||
import { InventoryAdjustment } from '@/types/api/inventory/adjustment';
|
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import { ColumnDef, ColumnSort, SortingState } from '@tanstack/react-table';
|
import { ColumnDef, ColumnSort, SortingState } from '@tanstack/react-table';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useFormik } from 'formik';
|
||||||
import useSWR from 'swr';
|
import Button from '@/components/Button';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import RequirePermission from '@/components/helper/RequirePermission';
|
||||||
|
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||||
|
import SelectInput, { useSelect } from '@/components/input/SelectInput';
|
||||||
|
import { OptionType } from '@/components/input/SelectInput';
|
||||||
|
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||||
|
import Modal, { useModal } from '@/components/Modal';
|
||||||
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import { cn, formatNumber, formatDate, formatCurrency } from '@/lib/helper';
|
||||||
|
import { InventoryAdjustmentApi } from '@/services/api/inventory';
|
||||||
|
import { WarehouseApi, ProductApi } from '@/services/api/master-data';
|
||||||
|
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||||
|
import { useUiStore } from '@/stores/ui/ui.store';
|
||||||
|
import { InventoryAdjustment } from '@/types/api/inventory/adjustment';
|
||||||
|
import { Warehouse } from '@/types/api/master-data/warehouse';
|
||||||
|
import { TRANSACTION_SUBTYPE_OPTIONS } from '@/config/constant';
|
||||||
|
import { Product } from '@/types/api/master-data/product';
|
||||||
|
import StatusBadge from '@/components/helper/StatusBadge';
|
||||||
|
import InventoryAdjustmentTableSkeleton from '@/components/pages/inventory/adjustment/skeleton/InventoryAdjustmentTableSkeleton';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AdjustmentFilterSchema,
|
||||||
|
AdjustmentFilterType,
|
||||||
|
} from '@/components/pages/inventory/adjustment/filter/AdjustmentFilter';
|
||||||
|
import SelectInputRadio from '@/components/input/SelectInputRadio';
|
||||||
|
|
||||||
const InventoryAdjustmentTable = () => {
|
const InventoryAdjustmentTable = () => {
|
||||||
|
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
state: tableFilterState,
|
state: tableFilterState,
|
||||||
updateFilter,
|
updateFilter,
|
||||||
@@ -30,6 +56,9 @@ const InventoryAdjustmentTable = () => {
|
|||||||
productSort: '',
|
productSort: '',
|
||||||
warehouseSort: '',
|
warehouseSort: '',
|
||||||
stockSort: '',
|
stockSort: '',
|
||||||
|
productFilter: '',
|
||||||
|
warehouseFilter: '',
|
||||||
|
transactionTypeFilter: '',
|
||||||
},
|
},
|
||||||
paramMap: {
|
paramMap: {
|
||||||
page: 'page',
|
page: 'page',
|
||||||
@@ -38,31 +67,169 @@ const InventoryAdjustmentTable = () => {
|
|||||||
productSort: 'sort_product',
|
productSort: 'sort_product',
|
||||||
warehouseSort: 'sort_warehouse',
|
warehouseSort: 'sort_warehouse',
|
||||||
stockSort: 'sort_stock',
|
stockSort: 'sort_stock',
|
||||||
|
productFilter: 'product_id',
|
||||||
|
warehouseFilter: 'warehouse_id',
|
||||||
|
transactionTypeFilter: 'transaction_type',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch Data
|
// ===== FILTER MODAL STATE =====
|
||||||
|
const filterModal = useModal();
|
||||||
|
|
||||||
|
// ===== FORMIK SETUP =====
|
||||||
|
const formik = useFormik<AdjustmentFilterType>({
|
||||||
|
initialValues: {
|
||||||
|
product_id: null,
|
||||||
|
warehouse_id: null,
|
||||||
|
transaction_type: null,
|
||||||
|
},
|
||||||
|
validationSchema: AdjustmentFilterSchema,
|
||||||
|
onSubmit: (values, { setSubmitting }) => {
|
||||||
|
updateFilter('productFilter', values.product_id || '');
|
||||||
|
updateFilter('warehouseFilter', values.warehouse_id || '');
|
||||||
|
updateFilter('transactionTypeFilter', values.transaction_type || '');
|
||||||
|
filterModal.closeModal();
|
||||||
|
setSubmitting(false);
|
||||||
|
},
|
||||||
|
onReset: () => {
|
||||||
|
updateFilter('productFilter', '');
|
||||||
|
updateFilter('warehouseFilter', '');
|
||||||
|
updateFilter('transactionTypeFilter', '');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== PRODUCT OPTIONS =====
|
||||||
|
const {
|
||||||
|
setInputValue: setProductInputValue,
|
||||||
|
options: productOptions,
|
||||||
|
isLoadingOptions: isLoadingProductOptions,
|
||||||
|
loadMore: loadMoreProducts,
|
||||||
|
} = useSelect<Product>(
|
||||||
|
filterModal.open ? ProductApi.basePath : null,
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'search'
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== WAREHOUSE OPTIONS =====
|
||||||
|
const {
|
||||||
|
setInputValue: setWarehouseInputValue,
|
||||||
|
options: warehouseOptions,
|
||||||
|
isLoadingOptions: isLoadingWarehouseOptions,
|
||||||
|
loadMore: loadMoreWarehouses,
|
||||||
|
} = useSelect<Warehouse>(
|
||||||
|
filterModal.open ? WarehouseApi.basePath : null,
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'search'
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== TRANSACTION TYPE OPTIONS =====
|
||||||
|
const transactionTypeOptions = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{ value: 'increase', label: 'Increase' },
|
||||||
|
{ value: 'decrease', label: 'Decrease' },
|
||||||
|
];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ===== FILTER HANDLERS =====
|
||||||
|
const handleFilterProductChange = useCallback(
|
||||||
|
(val: OptionType | OptionType[] | null) => {
|
||||||
|
const product = val as OptionType | null;
|
||||||
|
const productId = product?.value ? String(product.value) : null;
|
||||||
|
formik.setFieldValue('product_id', productId);
|
||||||
|
},
|
||||||
|
[formik]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFilterWarehouseChange = useCallback(
|
||||||
|
(val: OptionType | OptionType[] | null) => {
|
||||||
|
const warehouse = val as OptionType | null;
|
||||||
|
const warehouseId = warehouse?.value ? String(warehouse.value) : null;
|
||||||
|
formik.setFieldValue('warehouse_id', warehouseId);
|
||||||
|
},
|
||||||
|
[formik]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFilterTransactionTypeChange = useCallback(
|
||||||
|
(val: OptionType | OptionType[] | null) => {
|
||||||
|
const type = val as OptionType | null;
|
||||||
|
const typeValue = type?.value ? String(type.value) : null;
|
||||||
|
formik.setFieldValue('transaction_type', typeValue);
|
||||||
|
},
|
||||||
|
[formik]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== FILTER HELPERS =====
|
||||||
|
const productIdValue = useMemo(() => {
|
||||||
|
if (!formik.values.product_id) return null;
|
||||||
|
return (
|
||||||
|
productOptions.find(
|
||||||
|
(opt) => String(opt.value) === formik.values.product_id
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
}, [formik.values.product_id, productOptions]);
|
||||||
|
|
||||||
|
const warehouseIdValue = useMemo(() => {
|
||||||
|
if (!formik.values.warehouse_id) return null;
|
||||||
|
return (
|
||||||
|
warehouseOptions.find(
|
||||||
|
(opt) => String(opt.value) === formik.values.warehouse_id
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
}, [formik.values.warehouse_id, warehouseOptions]);
|
||||||
|
|
||||||
|
const transactionTypeValue = useMemo(() => {
|
||||||
|
if (!formik.values.transaction_type) return null;
|
||||||
|
return (
|
||||||
|
transactionTypeOptions.find(
|
||||||
|
(opt) => String(opt.value) === formik.values.transaction_type
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
}, [formik.values.transaction_type, transactionTypeOptions]);
|
||||||
|
|
||||||
|
// ===== HANDLE FILTER MODAL OPEN =====
|
||||||
|
const handleFilterModalOpen = () => {
|
||||||
|
filterModal.openModal();
|
||||||
|
formik.validateForm();
|
||||||
|
};
|
||||||
|
|
||||||
const { data: inventoryAdjustments, isLoading } = useSWR(
|
const { data: inventoryAdjustments, isLoading } = useSWR(
|
||||||
`${InventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`,
|
`${InventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`,
|
||||||
InventoryAdjustmentApi.getAllFetcher
|
InventoryAdjustmentApi.getAllFetcher
|
||||||
);
|
);
|
||||||
|
|
||||||
// State
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
|
||||||
// Columns
|
useEffect(() => {
|
||||||
const inventoryAdjustmentsColumns: ColumnDef<InventoryAdjustment>[] = [
|
updateFilter('search', searchValue);
|
||||||
|
}, [searchValue, updateFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTableState('inventory-adjustment-table', pathname);
|
||||||
|
}, [pathname, setTableState]);
|
||||||
|
|
||||||
|
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
|
setSearchValue(e.target.value);
|
||||||
|
updateFilter('search', e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const inventoryAdjustmentsColumns: ColumnDef<InventoryAdjustment>[] = useMemo(
|
||||||
|
() => [
|
||||||
{
|
{
|
||||||
header: '#',
|
id: 'adj_number',
|
||||||
cell: (props) =>
|
header: 'No. Referensi',
|
||||||
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
accessorFn: (row) => row.adj_number ?? '-',
|
||||||
props.row.index +
|
|
||||||
1,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'product_name',
|
id: 'location',
|
||||||
header: 'Nama Produk',
|
header: 'Lokasi',
|
||||||
accessorFn: (row) => row.product_warehouse?.product?.name ?? '-',
|
accessorFn: (row) => row.location?.name ?? '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'project_flock',
|
||||||
|
header: 'Flock',
|
||||||
|
accessorFn: (row) => row.project_flock?.flock_name ?? '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'warehouse_name',
|
id: 'warehouse_name',
|
||||||
@@ -70,51 +237,86 @@ const InventoryAdjustmentTable = () => {
|
|||||||
accessorFn: (row) => row.product_warehouse?.warehouse?.name ?? '-',
|
accessorFn: (row) => row.product_warehouse?.warehouse?.name ?? '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'created_at',
|
id: 'product_name',
|
||||||
header: 'Tanggal',
|
header: 'Nama Produk',
|
||||||
accessorFn: (row) =>
|
accessorFn: (row) => row.product_warehouse?.product?.name ?? '-',
|
||||||
new Date(row.created_at).toLocaleDateString('id-ID', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: 'short',
|
|
||||||
year: 'numeric',
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'quantity',
|
id: 'quantity',
|
||||||
header: 'Kuantitas',
|
header: 'Kuantitas',
|
||||||
accessorFn: (row) => formatNumber(String(row.increase + row.decrease)),
|
accessorFn: (row) => row.qty ?? '-',
|
||||||
|
cell: (row) => {
|
||||||
|
const value = row.row.original.increase + row.row.original.decrease;
|
||||||
|
return <div className='text-center'>{formatNumber(value)}</div>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'price',
|
||||||
|
header: 'Harga',
|
||||||
|
accessorFn: (row) => (row.price ? formatCurrency(row.price) : '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'grand_total',
|
||||||
|
header: 'Grand Total',
|
||||||
|
accessorFn: (row) =>
|
||||||
|
row.grand_total ? formatCurrency(row.grand_total) : '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'transaction_type',
|
id: 'transaction_type',
|
||||||
header: 'Tipe Transaksi',
|
header: 'Tipe Transaksi',
|
||||||
accessorFn: (row) => {
|
accessorFn: (row) => row.transaction_subtype ?? '-',
|
||||||
if (row.increase > 0) return 'Peningkatan';
|
cell: (row) => {
|
||||||
if (row.decrease > 0) return 'Penurunan';
|
const subtype = row.row.original.transaction_subtype;
|
||||||
return '-';
|
const increase = row.row.original.increase;
|
||||||
},
|
|
||||||
cell: (props) => {
|
const getSubtypeLabel = (subtypeValue: string): string => {
|
||||||
const type = props.row.original.increase;
|
if (subtypeValue === TRANSACTION_SUBTYPE_OPTIONS.PEMBELIAN.value) {
|
||||||
const label = type > 0 ? 'Peningkatan' : type <= 0 ? 'Penurunan' : '-';
|
return TRANSACTION_SUBTYPE_OPTIONS.PEMBELIAN.label;
|
||||||
|
}
|
||||||
|
if (subtypeValue === TRANSACTION_SUBTYPE_OPTIONS.PENJUALAN.value) {
|
||||||
|
return TRANSACTION_SUBTYPE_OPTIONS.PENJUALAN.label;
|
||||||
|
}
|
||||||
|
const recordingOption = TRANSACTION_SUBTYPE_OPTIONS.RECORDING.find(
|
||||||
|
(opt) => opt.value === subtypeValue
|
||||||
|
);
|
||||||
|
if (recordingOption) {
|
||||||
|
return recordingOption.label;
|
||||||
|
}
|
||||||
|
if (subtypeValue === 'RECORDING_DEPLETION_OUT') {
|
||||||
|
return 'Recording Depletion';
|
||||||
|
}
|
||||||
|
return subtypeValue || '-';
|
||||||
|
};
|
||||||
|
|
||||||
|
const label = getSubtypeLabel(subtype);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge variant='soft' color={type > 0 ? 'success' : 'error'}>
|
<StatusBadge
|
||||||
{label}
|
color={
|
||||||
</Badge>
|
increase > 0 ? 'success' : increase <= 0 ? 'error' : 'neutral'
|
||||||
|
}
|
||||||
|
text={label}
|
||||||
|
className={{
|
||||||
|
badge: 'whitespace-nowrap',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'created_at',
|
||||||
|
header: 'Tanggal',
|
||||||
|
accessorFn: (row) =>
|
||||||
|
row.created_at ? formatDate(row.created_at, 'DD MMM YYYY') : '-',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'created_by',
|
id: 'created_by',
|
||||||
header: 'Oleh',
|
header: 'Oleh',
|
||||||
accessorFn: (row) => row.created_user?.name ?? '-',
|
accessorFn: (row) => row.created_user?.name ?? '-',
|
||||||
},
|
},
|
||||||
];
|
],
|
||||||
|
[tableFilterState.pageSize, tableFilterState.page]
|
||||||
// Handler
|
);
|
||||||
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
|
||||||
const newVal = val as OptionType;
|
|
||||||
setPageSize(newVal.value as number);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateSortingFilter = useCallback(
|
const updateSortingFilter = useCallback(
|
||||||
(
|
(
|
||||||
@@ -130,7 +332,6 @@ const InventoryAdjustmentTable = () => {
|
|||||||
[updateFilter]
|
[updateFilter]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Effect
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const productCategorySortFilter = sorting.find(
|
const productCategorySortFilter = sorting.find(
|
||||||
(sortItem) => sortItem.id === 'productCategory'
|
(sortItem) => sortItem.id === 'productCategory'
|
||||||
@@ -149,48 +350,86 @@ const InventoryAdjustmentTable = () => {
|
|||||||
updateSortingFilter('stockSort', stockSortFilter);
|
updateSortingFilter('stockSort', stockSortFilter);
|
||||||
}, [sorting, updateSortingFilter]);
|
}, [sorting, updateSortingFilter]);
|
||||||
|
|
||||||
// Utils Function
|
|
||||||
const formatNumber = (value: string) => {
|
|
||||||
const numericValue = value.replace(/[^0-9.]/g, '');
|
|
||||||
const [integer, decimal] = numericValue.split('.');
|
|
||||||
const formattedInteger = integer.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
||||||
return decimal ? `${formattedInteger}.${decimal}` : formattedInteger;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render
|
|
||||||
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-fit flex flex-row gap-3 flex-wrap'>
|
||||||
<RequirePermission permissions='lti.inventory.create'>
|
<RequirePermission permissions='lti.inventory.create'>
|
||||||
<Button
|
<Button
|
||||||
href='/inventory/adjustment/add'
|
href='/inventory/adjustment/add'
|
||||||
variant='outline'
|
|
||||||
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 Adjustment
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex flex-row justify-end'>
|
{/* Search and Filter */}
|
||||||
<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='Search'
|
||||||
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: 'min-w-28' }}
|
|
||||||
|
<ButtonFilter
|
||||||
|
values={tableFilterState}
|
||||||
|
excludeFields={[
|
||||||
|
'page',
|
||||||
|
'pageSize',
|
||||||
|
'search',
|
||||||
|
'productCategorySort',
|
||||||
|
'productSort',
|
||||||
|
'warehouseSort',
|
||||||
|
'stockSort',
|
||||||
|
]}
|
||||||
|
onClick={handleFilterModalOpen}
|
||||||
|
className='px-3 py-2.5'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Table Section */}
|
||||||
|
<div className='flex flex-col mb-4'>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
) : !isResponseSuccess(inventoryAdjustments) ||
|
||||||
|
inventoryAdjustments.data?.length === 0 ? (
|
||||||
|
<div className='p-3'>
|
||||||
|
<InventoryAdjustmentTableSkeleton
|
||||||
|
columns={inventoryAdjustmentsColumns}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:document-text'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Table<InventoryAdjustment>
|
<Table<InventoryAdjustment>
|
||||||
data={
|
data={
|
||||||
isResponseSuccess(inventoryAdjustments)
|
isResponseSuccess(inventoryAdjustments)
|
||||||
@@ -210,27 +449,101 @@ const InventoryAdjustmentTable = () => {
|
|||||||
: 0
|
: 0
|
||||||
}
|
}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={setPageSize}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
sorting={sorting}
|
sorting={sorting}
|
||||||
setSorting={setSorting}
|
setSorting={setSorting}
|
||||||
className={{
|
className={{
|
||||||
containerClassName: cn({
|
containerClassName: cn('p-3 mb-0'),
|
||||||
'mb-20':
|
headerColumnClassName: 'text-nowrap',
|
||||||
isResponseSuccess(inventoryAdjustments) &&
|
|
||||||
inventoryAdjustments?.data?.length === 0,
|
|
||||||
}),
|
|
||||||
tableWrapperClassName: 'overflow-x-auto min-h-full!',
|
|
||||||
tableClassName: 'font-inter w-full table-auto min-h-full!',
|
|
||||||
headerRowClassName: 'border-b border-b-gray-200',
|
|
||||||
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',
|
|
||||||
bodyColumnClassName:
|
|
||||||
'px-6 py-3 last:flex last:flex-row last:justify-end',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Modal */}
|
||||||
|
<Modal
|
||||||
|
ref={filterModal.ref}
|
||||||
|
className={{
|
||||||
|
modal: 'p-0',
|
||||||
|
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
|
||||||
|
<div className='flex items-center gap-2 text-primary'>
|
||||||
|
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||||
|
<h3 className='font-medium text-sm'>Filter Data</h3>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant='link'
|
||||||
|
onClick={filterModal.closeModal}
|
||||||
|
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||||
|
<div className='p-4 flex flex-col gap-1.5'>
|
||||||
|
<SelectInput
|
||||||
|
label='Produk'
|
||||||
|
placeholder='Pilih Produk'
|
||||||
|
options={productOptions}
|
||||||
|
value={productIdValue}
|
||||||
|
onChange={handleFilterProductChange}
|
||||||
|
onInputChange={setProductInputValue}
|
||||||
|
isLoading={isLoadingProductOptions}
|
||||||
|
isClearable
|
||||||
|
onMenuScrollToBottom={loadMoreProducts}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
<SelectInput
|
||||||
|
label='Gudang'
|
||||||
|
placeholder='Pilih Gudang'
|
||||||
|
options={warehouseOptions}
|
||||||
|
value={warehouseIdValue}
|
||||||
|
onChange={handleFilterWarehouseChange}
|
||||||
|
onInputChange={setWarehouseInputValue}
|
||||||
|
isLoading={isLoadingWarehouseOptions}
|
||||||
|
isClearable
|
||||||
|
onMenuScrollToBottom={loadMoreWarehouses}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
<SelectInputRadio
|
||||||
|
label='Tipe Transaksi'
|
||||||
|
placeholder='Pilih Tipe Transaksi'
|
||||||
|
options={transactionTypeOptions}
|
||||||
|
value={transactionTypeValue}
|
||||||
|
onChange={handleFilterTransactionTypeChange}
|
||||||
|
isClearable
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='soft'
|
||||||
|
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||||
|
onClick={() => {
|
||||||
|
formik.resetForm();
|
||||||
|
filterModal.closeModal();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset Filter
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||||
|
disabled={!formik.isValid || formik.isSubmitting}
|
||||||
|
>
|
||||||
|
Apply Filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { string, object } from 'yup';
|
||||||
|
|
||||||
|
export const AdjustmentFilterSchema = object().shape({
|
||||||
|
product_id: string().nullable(),
|
||||||
|
warehouse_id: string().nullable(),
|
||||||
|
transaction_type: string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AdjustmentFilterType = {
|
||||||
|
product_id: string | null;
|
||||||
|
warehouse_id: string | null;
|
||||||
|
transaction_type: string | null;
|
||||||
|
};
|
||||||
@@ -1,54 +1,117 @@
|
|||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
import { OptionType } from '@/components/input/SelectInput';
|
|
||||||
|
|
||||||
export const InventoryAdjustmentFormSchema = Yup.object({
|
export type InventoryAdjustmentFormSchemaType = {
|
||||||
product_category: Yup.mixed<OptionType>()
|
location: {
|
||||||
.nullable()
|
value: number;
|
||||||
|
label: string;
|
||||||
|
} | null;
|
||||||
|
location_id: number;
|
||||||
|
project_flock: {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
} | null;
|
||||||
|
project_flock_id: number;
|
||||||
|
kandang: {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
} | null;
|
||||||
|
kandang_id: number;
|
||||||
|
project_flock_kandang: {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
} | null;
|
||||||
|
project_flock_kandang_id: number;
|
||||||
|
product: {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
} | null;
|
||||||
|
product_id: number;
|
||||||
|
depletion_product: {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
} | null;
|
||||||
|
depletion_product_id: number;
|
||||||
|
transaction_type: string;
|
||||||
|
transaction_subtype: string;
|
||||||
|
qty: number | string;
|
||||||
|
price: number | string;
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InventoryAdjustmentFormSchema: Yup.ObjectSchema<InventoryAdjustmentFormSchemaType> =
|
||||||
|
Yup.object({
|
||||||
|
location: Yup.object({
|
||||||
|
value: Yup.number().min(1).required(),
|
||||||
|
label: Yup.string().required(),
|
||||||
|
}).nullable(),
|
||||||
|
location_id: Yup.number()
|
||||||
|
.min(1, 'Lokasi wajib diisi!')
|
||||||
|
.required('Lokasi wajib diisi!')
|
||||||
|
.typeError('Lokasi wajib diisi!'),
|
||||||
|
project_flock: Yup.object({
|
||||||
|
value: Yup.number().min(1).required(),
|
||||||
|
label: Yup.string().required(),
|
||||||
|
}).nullable(),
|
||||||
|
project_flock_id: Yup.number()
|
||||||
|
.min(1, 'Project flock wajib diisi!')
|
||||||
|
.required('Project flock wajib diisi!')
|
||||||
|
.typeError('Project flock wajib diisi!'),
|
||||||
|
kandang: Yup.object({
|
||||||
|
value: Yup.number().min(1).required(),
|
||||||
|
label: Yup.string().required(),
|
||||||
|
}).nullable(),
|
||||||
|
kandang_id: Yup.number()
|
||||||
|
.min(1, 'Kandang wajib diisi!')
|
||||||
|
.required('Kandang wajib diisi!')
|
||||||
|
.typeError('Kandang wajib diisi!'),
|
||||||
|
project_flock_kandang: Yup.object({
|
||||||
|
value: Yup.number().min(1).required(),
|
||||||
|
label: Yup.string().required(),
|
||||||
|
}).nullable(),
|
||||||
|
project_flock_kandang_id: Yup.number()
|
||||||
|
.default(0)
|
||||||
|
.typeError('Project Flock Kandang wajib diisi!')
|
||||||
.test(
|
.test(
|
||||||
'is-valid-option',
|
'is-valid-project-flock-kandang',
|
||||||
'Kategori Produk wajib diisi!',
|
'Project Flock Kandang wajib diisi!',
|
||||||
(value) => value !== null && value !== undefined
|
(value) => value !== undefined && value !== null && value > 0
|
||||||
),
|
)
|
||||||
|
.required('Project Flock Kandang wajib diisi!'),
|
||||||
product_category_id: Yup.number().nullable(),
|
product: Yup.object({
|
||||||
|
value: Yup.number().min(1).required(),
|
||||||
product: Yup.mixed<OptionType>()
|
label: Yup.string().required(),
|
||||||
.nullable()
|
}).nullable(),
|
||||||
.test(
|
|
||||||
'is-valid-option',
|
|
||||||
'Produk wajib diisi!',
|
|
||||||
(value) => value !== null && value !== undefined
|
|
||||||
),
|
|
||||||
|
|
||||||
product_id: Yup.number()
|
product_id: Yup.number()
|
||||||
.nullable()
|
.min(1, 'Produk wajib diisi!')
|
||||||
.required('Produk wajib diisi!')
|
.required('Produk wajib diisi!')
|
||||||
.min(1, 'Produk wajib diisi!'),
|
.typeError('Produk wajib diisi!'),
|
||||||
|
depletion_product: Yup.object({
|
||||||
warehouse: Yup.mixed<OptionType>()
|
value: Yup.number().min(1).required(),
|
||||||
.nullable()
|
label: Yup.string().required(),
|
||||||
.test(
|
}).nullable(),
|
||||||
'is-valid-option',
|
depletion_product_id: Yup.number()
|
||||||
'Warehouse wajib diisi!',
|
.default(0)
|
||||||
(value) => value !== null && value !== undefined
|
.typeError('Jenis deplesi harus berupa angka'),
|
||||||
),
|
|
||||||
|
|
||||||
warehouse_id: Yup.number()
|
|
||||||
.nullable()
|
|
||||||
.required('Warehouse wajib diisi!')
|
|
||||||
.min(1, 'Warehouse wajib diisi!'),
|
|
||||||
|
|
||||||
transaction_type: Yup.string()
|
transaction_type: Yup.string()
|
||||||
.oneOf(['increase', 'decrease'], 'Tipe transaksi tidak valid')
|
.min(1, 'Tipe transaksi wajib diisi!')
|
||||||
.nullable()
|
.oneOf(
|
||||||
.required('Tipe transaksi wajib diisi'),
|
['PEMBELIAN', 'PENJUALAN', 'RECORDING'],
|
||||||
|
'Tipe transaksi tidak valid'
|
||||||
quantity: Yup.number()
|
)
|
||||||
|
.required('Tipe transaksi wajib diisi')
|
||||||
|
.typeError('Tipe transaksi wajib diisi!'),
|
||||||
|
transaction_subtype: Yup.string().required(
|
||||||
|
'Sub tipe transaksi wajib diisi'
|
||||||
|
),
|
||||||
|
qty: Yup.number()
|
||||||
.typeError('Kuantitas harus berupa angka')
|
.typeError('Kuantitas harus berupa angka')
|
||||||
.min(1, 'Minimal kuantitas adalah 1')
|
.min(1, 'Minimal kuantitas adalah 1')
|
||||||
.required('Kuantitas wajib diisi'),
|
.required('Kuantitas wajib diisi'),
|
||||||
|
price: Yup.number()
|
||||||
note: Yup.string().required('Catatan wajib diisi!'),
|
.typeError('Harga harus berupa angka')
|
||||||
|
.min(0, 'Minimal harga adalah 0')
|
||||||
|
.required('Harga wajib diisi'),
|
||||||
|
notes: Yup.string().required('Catatan wajib diisi!'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type InventoryAdjustmentFormValues = Yup.InferType<
|
export type InventoryAdjustmentFormValues = Yup.InferType<
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+37
@@ -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;
|
||||||
@@ -1,49 +1,98 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ChangeEventHandler, useState } from 'react';
|
import {
|
||||||
|
ChangeEventHandler,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table';
|
import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { useFormik } from 'formik';
|
||||||
|
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import { Movement } from '@/types/api/inventory/movement';
|
import { Movement } from '@/types/api/inventory/movement';
|
||||||
import { MovementApi } from '@/services/api/inventory';
|
import { MovementApi } from '@/services/api/inventory';
|
||||||
|
import { WarehouseApi, ProductApi } from '@/services/api/master-data';
|
||||||
import { cn } from '@/lib/helper';
|
import { cn } from '@/lib/helper';
|
||||||
import { isResponseSuccess } from '@/lib/api-helper';
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||||
import { ROWS_OPTIONS } from '@/config/constant';
|
import { useUiStore } from '@/stores/ui/ui.store';
|
||||||
import { OptionType } from '@/components/input/SelectInput';
|
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||||
import SelectInput from '@/components/input/SelectInput';
|
import SelectInput, { useSelect } from '@/components/input/SelectInput';
|
||||||
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
|
import { OptionType } from '@/components/input/SelectInput';
|
||||||
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
|
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||||
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
|
import Modal, { useModal } from '@/components/Modal';
|
||||||
import RequirePermission from '@/components/helper/RequirePermission';
|
import RequirePermission from '@/components/helper/RequirePermission';
|
||||||
|
import PopoverButton from '@/components/popover/PopoverButton';
|
||||||
|
import PopoverContent from '@/components/popover/PopoverContent';
|
||||||
|
import MovementTableSkeleton from '@/components/pages/inventory/movement/skeleton/MovementTableSkeleton';
|
||||||
|
import { Warehouse } from '@/types/api/master-data/warehouse';
|
||||||
|
import { Product } from '@/types/api/master-data/product';
|
||||||
|
import {
|
||||||
|
MovementFilterSchema,
|
||||||
|
MovementFilterType,
|
||||||
|
} from '@/components/pages/inventory/movement/filter/MovementFilter';
|
||||||
|
|
||||||
const RowOptionsMenu = ({
|
const RowOptionsMenu = ({
|
||||||
type = 'dropdown',
|
popoverPosition = 'bottom',
|
||||||
props,
|
props,
|
||||||
}: {
|
}: {
|
||||||
type: 'dropdown' | 'collapse';
|
popoverPosition: 'bottom' | 'top';
|
||||||
props: CellContext<Movement, unknown>;
|
props: CellContext<Movement, unknown>;
|
||||||
}) => (
|
}) => {
|
||||||
<RowOptionsMenuWrapper type={type}>
|
const popoverId = `movement#${props.row.original.id}`;
|
||||||
|
const popoverAnchorName = `--anchor-movement#${props.row.original.id}`;
|
||||||
|
|
||||||
|
const closePopover = () => {
|
||||||
|
document.getElementById(popoverId)?.hidePopover();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='relative'>
|
||||||
|
<PopoverButton
|
||||||
|
tabIndex={0}
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
popoverTarget={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
>
|
||||||
|
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||||
|
</PopoverButton>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
id={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
|
||||||
|
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
|
||||||
|
>
|
||||||
|
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||||
<RequirePermission permissions='lti.inventory.transfer.detail'>
|
<RequirePermission permissions='lti.inventory.transfer.detail'>
|
||||||
<Button
|
<Button
|
||||||
href={`/inventory/movement/detail/?movementId=${props.row.original.id}`}
|
href={`/inventory/movement/detail/?movementId=${props.row.original.id}`}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='primary'
|
color='none'
|
||||||
className='justify-start text-sm'
|
className='p-3 justify-start text-sm font-semibold w-full'
|
||||||
|
onClick={closePopover}
|
||||||
>
|
>
|
||||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
<Icon icon='heroicons:eye' width={20} height={20} />
|
||||||
Detail
|
Detail
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
</RowOptionsMenuWrapper>
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const MovementTable = () => {
|
const MovementTable = () => {
|
||||||
|
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
state: tableFilterState,
|
state: tableFilterState,
|
||||||
updateFilter,
|
updateFilter,
|
||||||
@@ -53,13 +102,109 @@ const MovementTable = () => {
|
|||||||
} = useTableFilter({
|
} = useTableFilter({
|
||||||
initial: {
|
initial: {
|
||||||
search: '',
|
search: '',
|
||||||
|
productFilter: '',
|
||||||
|
warehouseFilter: '',
|
||||||
},
|
},
|
||||||
paramMap: {
|
paramMap: {
|
||||||
page: 'page',
|
page: 'page',
|
||||||
pageSize: 'limit',
|
pageSize: 'limit',
|
||||||
|
productFilter: 'product_id',
|
||||||
|
warehouseFilter: 'warehouse_id',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== FILTER MODAL STATE =====
|
||||||
|
const filterModal = useModal();
|
||||||
|
|
||||||
|
// ===== FORMIK SETUP =====
|
||||||
|
const formik = useFormik<MovementFilterType>({
|
||||||
|
initialValues: {
|
||||||
|
product_id: null,
|
||||||
|
warehouse_id: null,
|
||||||
|
},
|
||||||
|
validationSchema: MovementFilterSchema,
|
||||||
|
onSubmit: (values, { setSubmitting }) => {
|
||||||
|
updateFilter('productFilter', values.product_id || '');
|
||||||
|
updateFilter('warehouseFilter', values.warehouse_id || '');
|
||||||
|
filterModal.closeModal();
|
||||||
|
setSubmitting(false);
|
||||||
|
},
|
||||||
|
onReset: () => {
|
||||||
|
updateFilter('productFilter', '');
|
||||||
|
updateFilter('warehouseFilter', '');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== PRODUCT OPTIONS =====
|
||||||
|
const {
|
||||||
|
setInputValue: setProductInputValue,
|
||||||
|
options: productOptions,
|
||||||
|
isLoadingOptions: isLoadingProductOptions,
|
||||||
|
loadMore: loadMoreProducts,
|
||||||
|
} = useSelect<Product>(
|
||||||
|
filterModal.open ? ProductApi.basePath : null,
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'search'
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== WAREHOUSE OPTIONS =====
|
||||||
|
const {
|
||||||
|
setInputValue: setWarehouseInputValue,
|
||||||
|
options: warehouseOptions,
|
||||||
|
isLoadingOptions: isLoadingWarehouseOptions,
|
||||||
|
loadMore: loadMoreWarehouses,
|
||||||
|
} = useSelect<Warehouse>(
|
||||||
|
filterModal.open ? WarehouseApi.basePath : null,
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'search'
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== FILTER HANDLERS =====
|
||||||
|
const handleFilterProductChange = useCallback(
|
||||||
|
(val: OptionType | OptionType[] | null) => {
|
||||||
|
const product = val as OptionType | null;
|
||||||
|
const productId = product?.value ? String(product.value) : null;
|
||||||
|
formik.setFieldValue('product_id', productId);
|
||||||
|
},
|
||||||
|
[formik]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFilterWarehouseChange = useCallback(
|
||||||
|
(val: OptionType | OptionType[] | null) => {
|
||||||
|
const warehouse = val as OptionType | null;
|
||||||
|
const warehouseId = warehouse?.value ? String(warehouse.value) : null;
|
||||||
|
formik.setFieldValue('warehouse_id', warehouseId);
|
||||||
|
},
|
||||||
|
[formik]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== FILTER HELPERS =====
|
||||||
|
const productIdValue = useMemo(() => {
|
||||||
|
if (!formik.values.product_id) return null;
|
||||||
|
return (
|
||||||
|
productOptions.find(
|
||||||
|
(opt) => String(opt.value) === formik.values.product_id
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
}, [formik.values.product_id, productOptions]);
|
||||||
|
|
||||||
|
const warehouseIdValue = useMemo(() => {
|
||||||
|
if (!formik.values.warehouse_id) return null;
|
||||||
|
return (
|
||||||
|
warehouseOptions.find(
|
||||||
|
(opt) => String(opt.value) === formik.values.warehouse_id
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
}, [formik.values.warehouse_id, warehouseOptions]);
|
||||||
|
|
||||||
|
// ===== HANDLE FILTER MODAL OPEN =====
|
||||||
|
const handleFilterModalOpen = () => {
|
||||||
|
filterModal.openModal();
|
||||||
|
formik.validateForm();
|
||||||
|
};
|
||||||
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
|
||||||
const { data: movements, isLoading } = useSWR(
|
const { data: movements, isLoading } = useSWR(
|
||||||
@@ -67,19 +212,23 @@ const MovementTable = () => {
|
|||||||
MovementApi.getAllFetcher
|
MovementApi.getAllFetcher
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateFilter('search', searchValue);
|
||||||
|
}, [searchValue, updateFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTableState('movement-table', pathname);
|
||||||
|
}, [pathname, setTableState]);
|
||||||
|
|
||||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
|
setSearchValue(e.target.value);
|
||||||
updateFilter('search', e.target.value);
|
updateFilter('search', e.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
const movementColumns: ColumnDef<Movement>[] = useMemo(
|
||||||
const newVal = val as OptionType;
|
() => [
|
||||||
setPageSize(newVal.value as number);
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const movementColumns: ColumnDef<Movement>[] = [
|
|
||||||
{
|
{
|
||||||
header: '#',
|
header: 'No',
|
||||||
cell: (props) =>
|
cell: (props) =>
|
||||||
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||||
props.row.index +
|
props.row.index +
|
||||||
@@ -101,7 +250,9 @@ const MovementTable = () => {
|
|||||||
accessorKey: 'transfer_date',
|
accessorKey: 'transfer_date',
|
||||||
header: 'Tanggal',
|
header: 'Tanggal',
|
||||||
cell: (props) =>
|
cell: (props) =>
|
||||||
new Date(props.row.original.transfer_date).toLocaleDateString('id-ID'),
|
new Date(props.row.original.transfer_date).toLocaleDateString(
|
||||||
|
'id-ID'
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorFn: (row) => {
|
accessorFn: (row) => {
|
||||||
@@ -115,8 +266,9 @@ const MovementTable = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Aksi',
|
header: 'Aksi',
|
||||||
cell: (props) => {
|
cell: (props: CellContext<Movement, unknown>) => {
|
||||||
const currentPageSize = props.table.getPaginationRowModel().rows.length;
|
const currentPageSize =
|
||||||
|
props.table.getPaginationRowModel().rows.length;
|
||||||
const currentPageRows = props.table.getPaginationRowModel().flatRows;
|
const currentPageRows = props.table.getPaginationRowModel().flatRows;
|
||||||
const currentRowRelativeIndex =
|
const currentRowRelativeIndex =
|
||||||
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
|
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
|
||||||
@@ -124,30 +276,24 @@ const MovementTable = () => {
|
|||||||
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<RowOptionsMenu
|
||||||
{currentPageSize > 2 && (
|
props={props}
|
||||||
<RowDropdownOptions isLast2Rows={isLast2Rows}>
|
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
|
||||||
<RowOptionsMenu type='dropdown' props={props} />
|
/>
|
||||||
</RowDropdownOptions>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentPageSize <= 2 && (
|
|
||||||
<RowCollapseOptions>
|
|
||||||
<RowOptionsMenu type='collapse' props={props} />
|
|
||||||
</RowCollapseOptions>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
],
|
||||||
|
[tableFilterState.pageSize, tableFilterState.page]
|
||||||
|
);
|
||||||
|
|
||||||
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 gap-2'>
|
{/* Action Buttons */}
|
||||||
|
<div className='w-fit flex flex-row gap-3 flex-wrap'>
|
||||||
<RequirePermission permissions='lti.inventory.transfer.create'>
|
<RequirePermission permissions='lti.inventory.transfer.create'>
|
||||||
<Button
|
<Button
|
||||||
href='/inventory/movement/add'
|
href='/inventory/movement/add'
|
||||||
@@ -160,60 +306,155 @@ const MovementTable = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Filter */}
|
||||||
|
<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 Movement'
|
placeholder='Search'
|
||||||
value={tableFilterState.search}
|
value={tableFilterState.search ?? ''}
|
||||||
onChange={searchChangeHandler}
|
onChange={searchChangeHandler}
|
||||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
startAdornment={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:magnifying-glass'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
/>
|
/>
|
||||||
</div>
|
}
|
||||||
|
|
||||||
<div className='flex justify-end gap-4'>
|
|
||||||
<SelectInput
|
|
||||||
label='Baris'
|
|
||||||
options={ROWS_OPTIONS}
|
|
||||||
value={{
|
|
||||||
label: String(tableFilterState.pageSize),
|
|
||||||
value: tableFilterState.pageSize,
|
|
||||||
}}
|
|
||||||
onChange={pageSizeChangeHandler}
|
|
||||||
className={{
|
className={{
|
||||||
wrapper:
|
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||||
'col-span-6 sm:col-span-4 max-w-28 sm:justify-self-end',
|
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||||
|
input:
|
||||||
|
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ButtonFilter
|
||||||
|
values={tableFilterState}
|
||||||
|
excludeFields={['page', 'pageSize', 'search']}
|
||||||
|
onClick={handleFilterModalOpen}
|
||||||
|
className='px-3 py-2.5'
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Table Section */}
|
||||||
|
<div className='flex flex-col mb-4'>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
) : !isResponseSuccess(movements) || movements.data?.length === 0 ? (
|
||||||
|
<div className='p-3'>
|
||||||
|
<MovementTableSkeleton
|
||||||
|
columns={movementColumns}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:document-text'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Table<Movement>
|
<Table<Movement>
|
||||||
data={isResponseSuccess(movements) ? movements?.data : []}
|
data={isResponseSuccess(movements) ? movements?.data : []}
|
||||||
columns={movementColumns}
|
columns={movementColumns}
|
||||||
pageSize={tableFilterState.pageSize}
|
pageSize={tableFilterState.pageSize}
|
||||||
page={isResponseSuccess(movements) ? movements?.meta?.page : 0}
|
page={isResponseSuccess(movements) ? movements?.meta?.page : 0}
|
||||||
totalItems={
|
totalItems={
|
||||||
isResponseSuccess(movements) ? movements?.meta?.total_results : 0
|
isResponseSuccess(movements)
|
||||||
|
? movements?.meta?.total_results
|
||||||
|
: 0
|
||||||
}
|
}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={setPageSize}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
sorting={sorting}
|
sorting={sorting}
|
||||||
setSorting={setSorting}
|
setSorting={setSorting}
|
||||||
className={{
|
className={{
|
||||||
containerClassName: cn({
|
containerClassName: cn('p-3 mb-0'),
|
||||||
'mb-20':
|
headerColumnClassName: 'text-nowrap',
|
||||||
isResponseSuccess(movements) && movements?.data?.length === 0,
|
|
||||||
}),
|
|
||||||
tableWrapperClassName: 'overflow-x-auto min-h-full!',
|
|
||||||
tableClassName: 'font-inter w-full table-auto min-h-full!',
|
|
||||||
headerRowClassName: 'border-b border-b-gray-200',
|
|
||||||
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',
|
|
||||||
bodyColumnClassName:
|
|
||||||
'px-6 py-3 last:flex last:flex-row last:justify-end',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Modal */}
|
||||||
|
<Modal
|
||||||
|
ref={filterModal.ref}
|
||||||
|
className={{
|
||||||
|
modal: 'p-0',
|
||||||
|
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
|
||||||
|
<div className='flex items-center gap-2 text-primary'>
|
||||||
|
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||||
|
<h3 className='font-medium text-sm'>Filter Data</h3>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant='link'
|
||||||
|
onClick={filterModal.closeModal}
|
||||||
|
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||||
|
<div className='p-4 flex flex-col gap-1.5'>
|
||||||
|
<SelectInput
|
||||||
|
label='Produk'
|
||||||
|
placeholder='Pilih Produk'
|
||||||
|
options={productOptions}
|
||||||
|
value={productIdValue}
|
||||||
|
onChange={handleFilterProductChange}
|
||||||
|
onInputChange={setProductInputValue}
|
||||||
|
isLoading={isLoadingProductOptions}
|
||||||
|
isClearable
|
||||||
|
onMenuScrollToBottom={loadMoreProducts}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
<SelectInput
|
||||||
|
label='Gudang'
|
||||||
|
placeholder='Pilih Gudang'
|
||||||
|
options={warehouseOptions}
|
||||||
|
value={warehouseIdValue}
|
||||||
|
onChange={handleFilterWarehouseChange}
|
||||||
|
onInputChange={setWarehouseInputValue}
|
||||||
|
isLoading={isLoadingWarehouseOptions}
|
||||||
|
isClearable
|
||||||
|
onMenuScrollToBottom={loadMoreWarehouses}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='soft'
|
||||||
|
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||||
|
onClick={() => {
|
||||||
|
formik.resetForm();
|
||||||
|
filterModal.closeModal();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset Filter
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||||
|
disabled={!formik.isValid || formik.isSubmitting}
|
||||||
|
>
|
||||||
|
Apply Filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { string, object } from 'yup';
|
||||||
|
|
||||||
|
export const MovementFilterSchema = object().shape({
|
||||||
|
product_id: string().nullable(),
|
||||||
|
warehouse_id: string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type MovementFilterType = {
|
||||||
|
product_id: string | null;
|
||||||
|
warehouse_id: string | null;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -2,46 +2,78 @@
|
|||||||
|
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||||
import SelectInput, { OptionType } from '@/components/input/SelectInput';
|
|
||||||
import Table from '@/components/Table';
|
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 { isResponseSuccess } from '@/lib/api-helper';
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
import { cn, formatCurrency, formatNumber } from '@/lib/helper';
|
import { cn, formatCurrency, formatNumber } from '@/lib/helper';
|
||||||
import { InventoryProductApi } from '@/services/api/inventory';
|
import { InventoryProductApi } from '@/services/api/inventory';
|
||||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||||
|
import { useUiStore } from '@/stores/ui/ui.store';
|
||||||
import { InventoryProduct } from '@/types/api/inventory/product';
|
import { InventoryProduct } from '@/types/api/inventory/product';
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
import PopoverButton from '@/components/popover/PopoverButton';
|
||||||
|
import PopoverContent from '@/components/popover/PopoverContent';
|
||||||
|
import InventoryProductTableSkeleton from '@/components/pages/inventory/product/skeleton/InventoryProductTableSkeleton';
|
||||||
|
|
||||||
const RowOptionsMenu = ({
|
const RowOptionsMenu = ({
|
||||||
type = 'dropdown',
|
popoverPosition = 'bottom',
|
||||||
props,
|
props,
|
||||||
}: {
|
}: {
|
||||||
type: 'dropdown' | 'collapse';
|
popoverPosition: 'bottom' | 'top';
|
||||||
props: CellContext<InventoryProduct, unknown>;
|
props: CellContext<InventoryProduct, unknown>;
|
||||||
}) => (
|
}) => {
|
||||||
<RowOptionsMenuWrapper type={type}>
|
const popoverId = `product#${props.row.original.id}`;
|
||||||
|
const popoverAnchorName = `--anchor-product#${props.row.original.id}`;
|
||||||
|
|
||||||
|
const closePopover = () => {
|
||||||
|
document.getElementById(popoverId)?.hidePopover();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='relative'>
|
||||||
|
<PopoverButton
|
||||||
|
tabIndex={0}
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
popoverTarget={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
>
|
||||||
|
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||||
|
</PopoverButton>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
id={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
|
||||||
|
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
|
||||||
|
>
|
||||||
|
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||||
<RequirePermission permissions='lti.inventory.product_stock.detail'>
|
<RequirePermission permissions='lti.inventory.product_stock.detail'>
|
||||||
<Button
|
<Button
|
||||||
href={`/inventory/product/detail?inventoryProductId=${props.row.original.id}`}
|
href={`/inventory/product/detail?inventoryProductId=${props.row.original.id}`}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='primary'
|
color='none'
|
||||||
className='justify-start text-sm'
|
className='p-3 justify-start text-sm font-semibold w-full'
|
||||||
|
onClick={closePopover}
|
||||||
>
|
>
|
||||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
<Icon icon='heroicons:eye' width={20} height={20} />
|
||||||
Detail
|
Detail
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
</RowOptionsMenuWrapper>
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const InventoryProductTable = () => {
|
const InventoryProductTable = () => {
|
||||||
|
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
state: tableFilterState,
|
state: tableFilterState,
|
||||||
updateFilter,
|
updateFilter,
|
||||||
@@ -65,20 +97,23 @@ const InventoryProductTable = () => {
|
|||||||
InventoryProductApi.getAllFetcher
|
InventoryProductApi.getAllFetcher
|
||||||
);
|
);
|
||||||
|
|
||||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
useEffect(() => {
|
||||||
updateFilter('search', e.target.value);
|
updateFilter('search', searchValue);
|
||||||
};
|
}, [searchValue, updateFilter]);
|
||||||
|
|
||||||
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
useEffect(() => {
|
||||||
const newVal = val as OptionType;
|
setTableState('inventory-product-table', pathname);
|
||||||
setPageSize(newVal.value as number);
|
}, [pathname, setTableState]);
|
||||||
setPage(1);
|
|
||||||
|
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
|
setSearchValue(e.target.value);
|
||||||
|
updateFilter('search', e.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns: ColumnDef<InventoryProduct>[] = useMemo(
|
const columns: ColumnDef<InventoryProduct>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
header: '#',
|
header: 'No',
|
||||||
cell: (props) =>
|
cell: (props) =>
|
||||||
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||||
props.row.index +
|
props.row.index +
|
||||||
@@ -125,7 +160,7 @@ const InventoryProductTable = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Aksi',
|
header: 'Aksi',
|
||||||
cell: (props) => {
|
cell: (props: CellContext<InventoryProduct, unknown>) => {
|
||||||
const currentPageSize =
|
const currentPageSize =
|
||||||
props.table.getPaginationRowModel().rows.length;
|
props.table.getPaginationRowModel().rows.length;
|
||||||
const currentPageRows = props.table.getPaginationRowModel().flatRows;
|
const currentPageRows = props.table.getPaginationRowModel().flatRows;
|
||||||
@@ -135,61 +170,82 @@ const InventoryProductTable = () => {
|
|||||||
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<RowOptionsMenu
|
||||||
{currentPageSize > 2 && (
|
props={props}
|
||||||
<RowDropdownOptions isLast2Rows={isLast2Rows}>
|
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
|
||||||
<RowOptionsMenu type='dropdown' props={props} />
|
/>
|
||||||
</RowDropdownOptions>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentPageSize <= 2 && (
|
|
||||||
<RowCollapseOptions>
|
|
||||||
<RowOptionsMenu type='collapse' props={props} />
|
|
||||||
</RowCollapseOptions>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[]
|
[tableFilterState.pageSize, tableFilterState.page]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className='w-full'>
|
||||||
<div className='w-full p-0 sm:p-4'>
|
{/* Header Section */}
|
||||||
<div className='flex flex-col gap-2 mb-4'>
|
<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-col sm:flex-row justify-between items-end sm:items-center gap-2'>
|
{/* Action Buttons */}
|
||||||
<div className='w-full flex flex-row gap-2'></div>
|
<div className='w-fit flex flex-row gap-3 flex-wrap'>
|
||||||
|
<RequirePermission permissions='lti.inventory.product_stock.create'>
|
||||||
|
<Button
|
||||||
|
href='/inventory/product/add'
|
||||||
|
color='primary'
|
||||||
|
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:plus' width={20} height={20} />
|
||||||
|
Add Product
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex justify-between items-end gap-4'>
|
{/* 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='Search'
|
||||||
value={tableFilterState.search}
|
value={tableFilterState.search ?? ''}
|
||||||
onChange={searchChangeHandler}
|
onChange={searchChangeHandler}
|
||||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
startAdornment={
|
||||||
/>
|
<Icon icon='heroicons:magnifying-glass' width={20} height={20} />
|
||||||
<SelectInput
|
}
|
||||||
label='Baris'
|
|
||||||
options={ROWS_OPTIONS}
|
|
||||||
value={{
|
|
||||||
label: String(tableFilterState.pageSize),
|
|
||||||
value: tableFilterState.pageSize,
|
|
||||||
}}
|
|
||||||
onChange={pageSizeChangeHandler}
|
|
||||||
className={{
|
className={{
|
||||||
wrapper:
|
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||||
'col-span-6 sm:col-span-4 max-w-28 sm:justify-self-end',
|
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||||
|
input:
|
||||||
|
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Table Section */}
|
||||||
|
<div className='flex flex-col mb-4'>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
) : !isResponseSuccess(inventoryProducts) ||
|
||||||
|
inventoryProducts.data?.length === 0 ? (
|
||||||
|
<div className='p-3'>
|
||||||
|
<InventoryProductTableSkeleton
|
||||||
|
columns={columns}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:document-text'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Table<InventoryProduct>
|
<Table<InventoryProduct>
|
||||||
data={
|
data={
|
||||||
isResponseSuccess(inventoryProducts) ? inventoryProducts?.data : []
|
isResponseSuccess(inventoryProducts)
|
||||||
|
? inventoryProducts?.data
|
||||||
|
: []
|
||||||
}
|
}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
pageSize={tableFilterState.pageSize}
|
pageSize={tableFilterState.pageSize}
|
||||||
@@ -204,27 +260,18 @@ const InventoryProductTable = () => {
|
|||||||
: 0
|
: 0
|
||||||
}
|
}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={setPageSize}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
sorting={sorting}
|
sorting={sorting}
|
||||||
setSorting={setSorting}
|
setSorting={setSorting}
|
||||||
className={{
|
className={{
|
||||||
containerClassName: cn({
|
containerClassName: cn('p-3 mb-0'),
|
||||||
'mb-20':
|
headerColumnClassName: 'text-nowrap',
|
||||||
isResponseSuccess(inventoryProducts) &&
|
|
||||||
inventoryProducts?.data?.length === 0,
|
|
||||||
}),
|
|
||||||
tableWrapperClassName: 'overflow-x-auto min-h-full!',
|
|
||||||
tableClassName: 'font-inter w-full table-auto min-h-full!',
|
|
||||||
headerRowClassName: 'border-b border-b-gray-200',
|
|
||||||
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',
|
|
||||||
bodyColumnClassName:
|
|
||||||
'px-6 py-3 last:flex last:flex-row last:justify-end',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</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;
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import AlertErrorList from '@/components/helper/form/FormErrors';
|
import AlertErrorList from '@/components/helper/form/FormErrors';
|
||||||
import { OptionType } from '@/components/input/SelectInput';
|
|
||||||
import Modal, { useModal } from '@/components/Modal';
|
import Modal, { useModal } from '@/components/Modal';
|
||||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||||
import { DeliveryOrderProductFormValues } from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.schema';
|
import { DeliveryOrderProductFormValues } from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.schema';
|
||||||
@@ -112,16 +111,34 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
|
|||||||
useState<DeliveryOrderProductFormValues | null>(null);
|
useState<DeliveryOrderProductFormValues | null>(null);
|
||||||
const [deliveryOrderValues, setDeliveryOrderValues] = useState<
|
const [deliveryOrderValues, setDeliveryOrderValues] = useState<
|
||||||
DeliveryOrderProductFormValues[]
|
DeliveryOrderProductFormValues[]
|
||||||
>(
|
>([]);
|
||||||
isResponseSuccess(marketing)
|
|
||||||
? mergeSOwithDO(
|
const getDeliveryOrderValues = useCallback(
|
||||||
marketing?.data.sales_order?.map(SalesProductToFieldValues) ?? [],
|
(marketingData: Marketing): DeliveryOrderProductFormValues[] => {
|
||||||
marketing?.data.delivery_order?.flatMap((delivery) =>
|
const hasDeliveryOrder =
|
||||||
DeliveryProductToFieldValues(marketing.data.sales_order, delivery)
|
marketingData.delivery_order &&
|
||||||
|
marketingData.delivery_order.length > 0 &&
|
||||||
|
marketingData.delivery_order.some(
|
||||||
|
(doItem) => doItem.deliveries && doItem.deliveries.length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasDeliveryOrder) {
|
||||||
|
return (
|
||||||
|
marketingData.delivery_order?.flatMap((delivery) =>
|
||||||
|
DeliveryProductToFieldValues(marketingData.sales_order, delivery)
|
||||||
|
) ?? []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergeSOwithDO(
|
||||||
|
marketingData.sales_order?.map(SalesProductToFieldValues) ?? [],
|
||||||
|
marketingData.delivery_order?.flatMap((delivery) =>
|
||||||
|
DeliveryProductToFieldValues(marketingData.sales_order, delivery)
|
||||||
) ?? [],
|
) ?? [],
|
||||||
true
|
true
|
||||||
)
|
);
|
||||||
: []
|
},
|
||||||
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ================== SETUP FORMIK ==================
|
// ================== SETUP FORMIK ==================
|
||||||
@@ -130,14 +147,8 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
|
|||||||
>(() => {
|
>(() => {
|
||||||
if (!isResponseSuccess(marketing))
|
if (!isResponseSuccess(marketing))
|
||||||
return {} as SalesOrderFormValues & DeliveryOrderFormValues;
|
return {} as SalesOrderFormValues & DeliveryOrderFormValues;
|
||||||
const deliveryValues = mergeSOwithDO(
|
|
||||||
marketing?.data.sales_order?.map(SalesProductToFieldValues) ?? [],
|
|
||||||
marketing?.data.delivery_order?.flatMap((delivery) =>
|
|
||||||
DeliveryProductToFieldValues(marketing.data.sales_order, delivery)
|
|
||||||
) ?? [],
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const deliveryValues = getDeliveryOrderValues(marketing.data);
|
||||||
setDeliveryOrderValues(deliveryValues);
|
setDeliveryOrderValues(deliveryValues);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -163,7 +174,7 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
|
|||||||
) ?? [],
|
) ?? [],
|
||||||
delivery_order: deliveryValues,
|
delivery_order: deliveryValues,
|
||||||
};
|
};
|
||||||
}, [marketing]);
|
}, [marketing, getDeliveryOrderValues]);
|
||||||
|
|
||||||
const formik = useFormik<SalesOrderFormValues & DeliveryOrderFormValues>({
|
const formik = useFormik<SalesOrderFormValues & DeliveryOrderFormValues>({
|
||||||
enableReinitialize: true,
|
enableReinitialize: true,
|
||||||
@@ -648,9 +659,8 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
|
|||||||
<tr>
|
<tr>
|
||||||
<td className='text-sm px-4 py-3'>No. Order</td>
|
<td className='text-sm px-4 py-3'>No. Order</td>
|
||||||
<td className='text-sm px-4 py-3'>
|
<td className='text-sm px-4 py-3'>
|
||||||
{marketing.data.do_number
|
{marketing.data.do_number ||
|
||||||
? marketing.data.do_number
|
marketing.data.so_number}
|
||||||
: marketing.data.so_number}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -765,6 +775,7 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
|
|||||||
<MemoizedDeliveryOrderProductForm
|
<MemoizedDeliveryOrderProductForm
|
||||||
formState={'edit'}
|
formState={'edit'}
|
||||||
salesOrders={marketing?.data?.sales_order ?? []}
|
salesOrders={marketing?.data?.sales_order ?? []}
|
||||||
|
deliveryOrders={marketing?.data?.delivery_order ?? []}
|
||||||
exisitingValues={deliveryOrderValues}
|
exisitingValues={deliveryOrderValues}
|
||||||
onSubmitForm={handleAddSubmitDO}
|
onSubmitForm={handleAddSubmitDO}
|
||||||
initialValues={selectedDeliveryProduct ?? undefined}
|
initialValues={selectedDeliveryProduct ?? undefined}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import PopoverButton from '@/components/popover/PopoverButton';
|
|||||||
import PopoverContent from '@/components/popover/PopoverContent';
|
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 MarketingTableSkeleton from '@/components/pages/marketing/skeleton/MarketingTableSkeleton';
|
||||||
|
|
||||||
const RowsOptionsMenu = ({
|
const RowsOptionsMenu = ({
|
||||||
props,
|
props,
|
||||||
@@ -214,32 +216,6 @@ const MarketingTable = () => {
|
|||||||
updateFilter('customer_id', '');
|
updateFilter('customer_id', '');
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===== ACTIVE FILTERS COUNT =====
|
|
||||||
const activeFiltersCount = useMemo(() => {
|
|
||||||
let count = 0;
|
|
||||||
|
|
||||||
// Product filter
|
|
||||||
if (tableFilterState.product_ids) {
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status filter
|
|
||||||
if (tableFilterState.status) {
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Customer filter
|
|
||||||
if (tableFilterState.customer_id) {
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return count;
|
|
||||||
}, [
|
|
||||||
tableFilterState.product_ids,
|
|
||||||
tableFilterState.status,
|
|
||||||
tableFilterState.customer_id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const approveClickHandler = () => {
|
const approveClickHandler = () => {
|
||||||
setApproveAction('APPROVED');
|
setApproveAction('APPROVED');
|
||||||
confirmationModal.openModal();
|
confirmationModal.openModal();
|
||||||
@@ -588,28 +564,14 @@ const MarketingTable = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className='flex flex-row gap-3'>
|
<div className='flex flex-row gap-3'>
|
||||||
<Button
|
<ButtonFilter
|
||||||
variant='outline'
|
values={tableFilterState}
|
||||||
color='none'
|
excludeFields={['page', 'pageSize', 'search']}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
filterModal.openModal();
|
filterModal.openModal();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className='px-3 py-2.5'
|
||||||
'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':
|
|
||||||
activeFiltersCount > 0,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon icon='heroicons:funnel' width={20} height={20} />
|
|
||||||
Filter
|
|
||||||
{activeFiltersCount > 0 && (
|
|
||||||
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
|
|
||||||
{activeFiltersCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
align='end'
|
align='end'
|
||||||
direction='bottom'
|
direction='bottom'
|
||||||
@@ -655,6 +617,26 @@ const MarketingTable = () => {
|
|||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className='flex flex-col mb-4'>
|
||||||
|
{isLoadingMarketing ? (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
) : !isResponseSuccess(marketing) || marketing.data?.length === 0 ? (
|
||||||
|
<div className='p-3'>
|
||||||
|
<MarketingTableSkeleton
|
||||||
|
columns={columns}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:document-text'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Table
|
<Table
|
||||||
rowSelection={rowSelection}
|
rowSelection={rowSelection}
|
||||||
setRowSelection={setRowSelection}
|
setRowSelection={setRowSelection}
|
||||||
@@ -665,18 +647,19 @@ const MarketingTable = () => {
|
|||||||
pageSize={tableFilterState.pageSize}
|
pageSize={tableFilterState.pageSize}
|
||||||
page={isResponseSuccess(marketing) ? marketing?.meta?.page : 1}
|
page={isResponseSuccess(marketing) ? marketing?.meta?.page : 1}
|
||||||
totalItems={
|
totalItems={
|
||||||
isResponseSuccess(marketing) ? marketing?.meta?.total_results : 0
|
isResponseSuccess(marketing)
|
||||||
|
? marketing?.meta?.total_results
|
||||||
|
: 0
|
||||||
}
|
}
|
||||||
isLoading={isLoadingMarketing}
|
isLoading={isLoadingMarketing}
|
||||||
className={{
|
className={{
|
||||||
containerClassName: cn('p-3', {
|
containerClassName: cn('p-3 mb-0'),
|
||||||
'w-full mb-20':
|
|
||||||
isResponseSuccess(marketing) && marketing?.data?.length === 0,
|
|
||||||
}),
|
|
||||||
bodyColumnClassName:
|
bodyColumnClassName:
|
||||||
'last:text-end last:w-17 first:text-start first:w-5',
|
'last:text-end last:w-17 first:text-start first:w-5',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
ref={deleteModal.ref}
|
ref={deleteModal.ref}
|
||||||
|
|||||||
+115
-43
@@ -10,7 +10,10 @@ import NumberInput from '@/components/input/NumberInput';
|
|||||||
import PatternInput from '@/components/input/PatternInput';
|
import PatternInput from '@/components/input/PatternInput';
|
||||||
import { formatTitleCase, formatVechicleNumber } from '@/lib/helper';
|
import { formatTitleCase, formatVechicleNumber } from '@/lib/helper';
|
||||||
import DateInput from '@/components/input/DateInput';
|
import DateInput from '@/components/input/DateInput';
|
||||||
import { BaseSalesOrder } from '@/types/api/marketing/marketing';
|
import {
|
||||||
|
BaseSalesOrder,
|
||||||
|
BaseDeliveryOrder,
|
||||||
|
} from '@/types/api/marketing/marketing';
|
||||||
import { SalesProductToFieldValues } from '@/components/pages/marketing/form/MarketingForm.schema';
|
import { SalesProductToFieldValues } from '@/components/pages/marketing/form/MarketingForm.schema';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
import { isResponseSuccess } from '@/lib/api-helper';
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
@@ -32,6 +35,7 @@ import { handleMarketingCalculation } from '@/lib/marketing-calculation';
|
|||||||
const DeliveryOrderProductForm = ({
|
const DeliveryOrderProductForm = ({
|
||||||
formState,
|
formState,
|
||||||
salesOrders,
|
salesOrders,
|
||||||
|
deliveryOrders,
|
||||||
initialValues,
|
initialValues,
|
||||||
exisitingValues,
|
exisitingValues,
|
||||||
onUpdateForm,
|
onUpdateForm,
|
||||||
@@ -39,6 +43,7 @@ const DeliveryOrderProductForm = ({
|
|||||||
}: {
|
}: {
|
||||||
formState: 'add' | 'edit';
|
formState: 'add' | 'edit';
|
||||||
salesOrders: BaseSalesOrder[];
|
salesOrders: BaseSalesOrder[];
|
||||||
|
deliveryOrders?: BaseDeliveryOrder[];
|
||||||
initialValues?: DeliveryOrderProductFormValues;
|
initialValues?: DeliveryOrderProductFormValues;
|
||||||
exisitingValues?: DeliveryOrderProductFormValues[];
|
exisitingValues?: DeliveryOrderProductFormValues[];
|
||||||
onSubmitForm?: (value: DeliveryOrderProductFormValues) => Promise<void>;
|
onSubmitForm?: (value: DeliveryOrderProductFormValues) => Promise<void>;
|
||||||
@@ -115,6 +120,36 @@ const DeliveryOrderProductForm = ({
|
|||||||
})
|
})
|
||||||
?.filter((item) => item != null) as OptionType[];
|
?.filter((item) => item != null) as OptionType[];
|
||||||
|
|
||||||
|
const hasDeliveryOrder = useMemo(() => {
|
||||||
|
return (
|
||||||
|
deliveryOrders &&
|
||||||
|
deliveryOrders.length > 0 &&
|
||||||
|
deliveryOrders.some(
|
||||||
|
(doItem) => doItem.deliveries && doItem.deliveries.length > 0
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, [deliveryOrders]);
|
||||||
|
|
||||||
|
const deliveryOrder = useMemo(() => {
|
||||||
|
if (!hasDeliveryOrder || !deliveryOrders) return null;
|
||||||
|
|
||||||
|
for (const doItem of deliveryOrders) {
|
||||||
|
const found = doItem.deliveries.find(
|
||||||
|
(d) =>
|
||||||
|
d.product_warehouse.id ===
|
||||||
|
initialValues?.marketing_product?.product_warehouse_id
|
||||||
|
);
|
||||||
|
if (found) {
|
||||||
|
return {
|
||||||
|
...found,
|
||||||
|
delivery_date: doItem.delivery_date,
|
||||||
|
do_number: doItem.do_number,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [deliveryOrders, hasDeliveryOrder, initialValues]);
|
||||||
|
|
||||||
const salesOrder = salesOrders.find(
|
const salesOrder = salesOrders.find(
|
||||||
(item) => item.id === initialValues?.marketing_product_id
|
(item) => item.id === initialValues?.marketing_product_id
|
||||||
);
|
);
|
||||||
@@ -122,15 +157,25 @@ const DeliveryOrderProductForm = ({
|
|||||||
const formik = useFormik<DeliveryOrderProductFormValues>({
|
const formik = useFormik<DeliveryOrderProductFormValues>({
|
||||||
enableReinitialize: true,
|
enableReinitialize: true,
|
||||||
initialValues: {
|
initialValues: {
|
||||||
delivery_date: initialValues?.delivery_date || undefined,
|
delivery_date:
|
||||||
vehicle_number: initialValues?.vehicle_number || undefined,
|
deliveryOrder?.delivery_date ||
|
||||||
|
initialValues?.delivery_date ||
|
||||||
|
undefined,
|
||||||
|
vehicle_number:
|
||||||
|
deliveryOrder?.vehicle_number ||
|
||||||
|
initialValues?.vehicle_number ||
|
||||||
|
undefined,
|
||||||
marketing_product_id:
|
marketing_product_id:
|
||||||
salesOrder?.id || initialValues?.marketing_product_id || undefined,
|
salesOrder?.id || initialValues?.marketing_product_id || undefined,
|
||||||
unit_price: initialValues?.unit_price || undefined,
|
unit_price:
|
||||||
total_weight: initialValues?.total_weight || undefined,
|
deliveryOrder?.unit_price ?? initialValues?.unit_price ?? undefined,
|
||||||
qty: initialValues?.qty || undefined,
|
total_weight:
|
||||||
avg_weight: initialValues?.avg_weight || undefined,
|
deliveryOrder?.total_weight ?? initialValues?.total_weight ?? undefined,
|
||||||
total_price: initialValues?.total_price || undefined,
|
qty: deliveryOrder?.qty ?? initialValues?.qty ?? undefined,
|
||||||
|
avg_weight:
|
||||||
|
deliveryOrder?.avg_weight ?? initialValues?.avg_weight ?? undefined,
|
||||||
|
total_price:
|
||||||
|
deliveryOrder?.total_price ?? initialValues?.total_price ?? undefined,
|
||||||
marketing_product: initialValues?.marketing_product || undefined,
|
marketing_product: initialValues?.marketing_product || undefined,
|
||||||
uom: initialValues?.uom || '',
|
uom: initialValues?.uom || '',
|
||||||
weight_per_convertion:
|
weight_per_convertion:
|
||||||
@@ -236,6 +281,25 @@ const DeliveryOrderProductForm = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handler untuk onChange - auto calculation real-time untuk field yang mempengaruhi total_price (total_peti, weight_per_convertion, price_per_convertion, sisa_berat, price_sisa_berat, price_per_qty, qty)
|
||||||
|
const handleFieldChange = (
|
||||||
|
field: string,
|
||||||
|
value: number | string,
|
||||||
|
callback?: () => void
|
||||||
|
) => {
|
||||||
|
formik.setFieldValue(field, value);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
handleMarketingCalculation(field, {
|
||||||
|
values: { ...formik.values, [field]: value },
|
||||||
|
setFieldValue: formik.setFieldValue,
|
||||||
|
hasSisaBerat,
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
if (callback) callback();
|
||||||
|
};
|
||||||
|
|
||||||
// Handler khusus untuk toggle sisa berat - langsung pakai nilai baru
|
// Handler khusus untuk toggle sisa berat - langsung pakai nilai baru
|
||||||
const handleSisaBeratToggle = (newHasSisaBerat: boolean) => {
|
const handleSisaBeratToggle = (newHasSisaBerat: boolean) => {
|
||||||
setHasSisaBerat(newHasSisaBerat);
|
setHasSisaBerat(newHasSisaBerat);
|
||||||
@@ -520,13 +584,11 @@ const DeliveryOrderProductForm = ({
|
|||||||
} per ${formik.values.convertion_unit?.value}`}
|
} per ${formik.values.convertion_unit?.value}`}
|
||||||
value={formik.values.weight_per_convertion ?? ''}
|
value={formik.values.weight_per_convertion ?? ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
formik.setFieldValue(
|
const value = Number(e.target.value);
|
||||||
'weight_per_convertion',
|
handleFieldChange('weight_per_convertion', value, () =>
|
||||||
Number(e.target.value)
|
setCurrentInput(e.target.name)
|
||||||
);
|
);
|
||||||
setCurrentInput(e.target.name);
|
|
||||||
}}
|
}}
|
||||||
onBlur={() => handleBlurField('weight_per_convertion')}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -564,10 +626,11 @@ const DeliveryOrderProductForm = ({
|
|||||||
name='total_peti'
|
name='total_peti'
|
||||||
value={formik.values.total_peti ?? undefined}
|
value={formik.values.total_peti ?? undefined}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
formik.handleChange(e);
|
const value = Number(e.target.value);
|
||||||
setCurrentInput(e.target.name);
|
handleFieldChange('total_peti', value, () =>
|
||||||
|
setCurrentInput(e.target.name)
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onBlur={() => handleBlurField('total_peti')}
|
|
||||||
isError={
|
isError={
|
||||||
formik.touched.total_peti && Boolean(formik.errors.total_peti)
|
formik.touched.total_peti && Boolean(formik.errors.total_peti)
|
||||||
}
|
}
|
||||||
@@ -592,10 +655,11 @@ const DeliveryOrderProductForm = ({
|
|||||||
name='avg_weight'
|
name='avg_weight'
|
||||||
value={formik.values.avg_weight}
|
value={formik.values.avg_weight}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
formik.handleChange(e);
|
const value = Number(e.target.value);
|
||||||
setCurrentInput(e.target.name);
|
handleFieldChange('avg_weight', value, () =>
|
||||||
|
setCurrentInput('avg_weight')
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onBlur={() => handleBlurField('avg_weight')}
|
|
||||||
isError={
|
isError={
|
||||||
formik.touched.avg_weight &&
|
formik.touched.avg_weight &&
|
||||||
Boolean(formik.errors.avg_weight)
|
Boolean(formik.errors.avg_weight)
|
||||||
@@ -613,10 +677,11 @@ const DeliveryOrderProductForm = ({
|
|||||||
name='total_weight'
|
name='total_weight'
|
||||||
value={formik.values.total_weight}
|
value={formik.values.total_weight}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
formik.handleChange(e);
|
const value = Number(e.target.value);
|
||||||
setCurrentInput(e.target.name);
|
handleFieldChange('total_weight', value, () =>
|
||||||
|
setCurrentInput('total_weight')
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onBlur={() => handleBlurField('total_weight')}
|
|
||||||
isError={
|
isError={
|
||||||
formik.touched.total_weight &&
|
formik.touched.total_weight &&
|
||||||
Boolean(formik.errors.total_weight)
|
Boolean(formik.errors.total_weight)
|
||||||
@@ -638,10 +703,11 @@ const DeliveryOrderProductForm = ({
|
|||||||
name='qty'
|
name='qty'
|
||||||
value={formik.values.qty}
|
value={formik.values.qty}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
formik.handleChange(e);
|
const value = Number(e.target.value);
|
||||||
setCurrentInput(e.target.name);
|
handleFieldChange('qty', value, () =>
|
||||||
|
setCurrentInput(e.target.name)
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onBlur={() => handleBlurField('qty')}
|
|
||||||
isError={Boolean(formik.errors.qty)}
|
isError={Boolean(formik.errors.qty)}
|
||||||
errorMessage={formik.errors.qty}
|
errorMessage={formik.errors.qty}
|
||||||
placeholder='Masukan Kuantitas'
|
placeholder='Masukan Kuantitas'
|
||||||
@@ -677,10 +743,11 @@ const DeliveryOrderProductForm = ({
|
|||||||
name='price_per_convertion'
|
name='price_per_convertion'
|
||||||
value={formik.values.price_per_convertion ?? undefined}
|
value={formik.values.price_per_convertion ?? undefined}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
formik.handleChange(e);
|
const value = Number(e.target.value);
|
||||||
setCurrentInput(e.target.name);
|
handleFieldChange('price_per_convertion', value, () =>
|
||||||
|
setCurrentInput(e.target.name)
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onBlur={() => handleBlurField('price_per_convertion')}
|
|
||||||
isError={
|
isError={
|
||||||
formik.touched.price_per_convertion &&
|
formik.touched.price_per_convertion &&
|
||||||
Boolean(formik.errors.price_per_convertion)
|
Boolean(formik.errors.price_per_convertion)
|
||||||
@@ -699,10 +766,11 @@ const DeliveryOrderProductForm = ({
|
|||||||
name='price_per_qty'
|
name='price_per_qty'
|
||||||
value={formik.values.price_per_qty ?? undefined}
|
value={formik.values.price_per_qty ?? undefined}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
formik.setFieldValue('price_per_qty', Number(e.target.value));
|
const value = Number(e.target.value);
|
||||||
setCurrentInput('price_per_qty');
|
handleFieldChange('price_per_qty', value, () =>
|
||||||
|
setCurrentInput('price_per_qty')
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onBlur={() => handleBlurField('price_per_qty')}
|
|
||||||
isError={
|
isError={
|
||||||
formik.touched.price_per_qty &&
|
formik.touched.price_per_qty &&
|
||||||
Boolean(formik.errors.price_per_qty)
|
Boolean(formik.errors.price_per_qty)
|
||||||
@@ -721,10 +789,11 @@ const DeliveryOrderProductForm = ({
|
|||||||
name='unit_price'
|
name='unit_price'
|
||||||
value={formik.values.unit_price}
|
value={formik.values.unit_price}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
formik.handleChange(e);
|
const value = Number(e.target.value);
|
||||||
setCurrentInput(e.target.name);
|
handleFieldChange('unit_price', value, () =>
|
||||||
|
setCurrentInput(e.target.name)
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onBlur={() => handleBlurField('unit_price')}
|
|
||||||
isError={Boolean(formik.errors.unit_price)}
|
isError={Boolean(formik.errors.unit_price)}
|
||||||
errorMessage={formik.errors.unit_price}
|
errorMessage={formik.errors.unit_price}
|
||||||
placeholder='Masukan Harga Satuan'
|
placeholder='Masukan Harga Satuan'
|
||||||
@@ -760,10 +829,11 @@ const DeliveryOrderProductForm = ({
|
|||||||
name='sisa_berat'
|
name='sisa_berat'
|
||||||
value={formik.values.sisa_berat ?? undefined}
|
value={formik.values.sisa_berat ?? undefined}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
formik.handleChange(e);
|
const value = Number(e.target.value);
|
||||||
setCurrentInput(e.target.name);
|
handleFieldChange('sisa_berat', value, () =>
|
||||||
|
setCurrentInput(e.target.name)
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onBlur={() => handleBlurField('sisa_berat')}
|
|
||||||
isError={
|
isError={
|
||||||
formik.touched.sisa_berat && Boolean(formik.errors.sisa_berat)
|
formik.touched.sisa_berat && Boolean(formik.errors.sisa_berat)
|
||||||
}
|
}
|
||||||
@@ -776,10 +846,11 @@ const DeliveryOrderProductForm = ({
|
|||||||
name='price_sisa_berat'
|
name='price_sisa_berat'
|
||||||
value={formik.values.price_sisa_berat ?? undefined}
|
value={formik.values.price_sisa_berat ?? undefined}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
formik.handleChange(e);
|
const value = Number(e.target.value);
|
||||||
setCurrentInput(e.target.name);
|
handleFieldChange('price_sisa_berat', value, () =>
|
||||||
|
setCurrentInput(e.target.name)
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onBlur={() => handleBlurField('price_sisa_berat')}
|
|
||||||
isError={
|
isError={
|
||||||
formik.touched.price_sisa_berat &&
|
formik.touched.price_sisa_berat &&
|
||||||
Boolean(formik.errors.price_sisa_berat)
|
Boolean(formik.errors.price_sisa_berat)
|
||||||
@@ -797,10 +868,11 @@ const DeliveryOrderProductForm = ({
|
|||||||
name='total_price'
|
name='total_price'
|
||||||
value={formik.values.total_price}
|
value={formik.values.total_price}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
formik.handleChange(e);
|
const value = Number(e.target.value);
|
||||||
setCurrentInput(e.target.name);
|
handleFieldChange('total_price', value, () =>
|
||||||
|
setCurrentInput('total_price')
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onBlur={() => handleBlurField('total_price')}
|
|
||||||
isError={
|
isError={
|
||||||
formik.touched.total_price && Boolean(formik.errors.total_price)
|
formik.touched.total_price && Boolean(formik.errors.total_price)
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-35
@@ -250,6 +250,25 @@ const SalesOrderProductForm = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handler untuk onChange - auto calculation real-time untuk field yang mempengaruhi total_price (total_peti, weight_per_convertion, price_per_convertion, sisa_berat, price_sisa_berat, price_per_qty, qty)
|
||||||
|
const handleFieldChange = (
|
||||||
|
field: string,
|
||||||
|
value: number | string,
|
||||||
|
callback?: () => void
|
||||||
|
) => {
|
||||||
|
formik.setFieldValue(field, value);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
handleMarketingCalculation(field, {
|
||||||
|
values: { ...formik.values, [field]: value },
|
||||||
|
setFieldValue: formik.setFieldValue,
|
||||||
|
hasSisaBerat,
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
if (callback) callback();
|
||||||
|
};
|
||||||
|
|
||||||
// Handler khusus untuk toggle sisa berat - langsung pakai nilai baru
|
// Handler khusus untuk toggle sisa berat - langsung pakai nilai baru
|
||||||
const handleSisaBeratToggle = (newHasSisaBerat: boolean) => {
|
const handleSisaBeratToggle = (newHasSisaBerat: boolean) => {
|
||||||
setHasSisaBerat(newHasSisaBerat);
|
setHasSisaBerat(newHasSisaBerat);
|
||||||
@@ -475,13 +494,11 @@ const SalesOrderProductForm = ({
|
|||||||
} per ${formik.values.convertion_unit?.value}`}
|
} per ${formik.values.convertion_unit?.value}`}
|
||||||
value={formik.values.weight_per_convertion ?? ''}
|
value={formik.values.weight_per_convertion ?? ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
formik.setFieldValue(
|
const value = Number(e.target.value);
|
||||||
'weight_per_convertion',
|
handleFieldChange('weight_per_convertion', value, () =>
|
||||||
Number(e.target.value)
|
setCurrentInput(e.target.name)
|
||||||
);
|
);
|
||||||
setCurrentInput(e.target.name);
|
|
||||||
}}
|
}}
|
||||||
onBlur={() => handleBlurField('weight_per_convertion')}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -519,10 +536,11 @@ const SalesOrderProductForm = ({
|
|||||||
name='total_peti'
|
name='total_peti'
|
||||||
value={formik.values.total_peti ?? undefined}
|
value={formik.values.total_peti ?? undefined}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
formik.handleChange(e);
|
const value = Number(e.target.value);
|
||||||
setCurrentInput(e.target.name);
|
handleFieldChange('total_peti', value, () =>
|
||||||
|
setCurrentInput(e.target.name)
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onBlur={() => handleBlurField('total_peti')}
|
|
||||||
isError={
|
isError={
|
||||||
formik.touched.total_peti && Boolean(formik.errors.total_peti)
|
formik.touched.total_peti && Boolean(formik.errors.total_peti)
|
||||||
}
|
}
|
||||||
@@ -547,10 +565,11 @@ const SalesOrderProductForm = ({
|
|||||||
name='avg_weight'
|
name='avg_weight'
|
||||||
value={formik.values.avg_weight}
|
value={formik.values.avg_weight}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
formik.handleChange(e);
|
const value = Number(e.target.value);
|
||||||
setCurrentInput(e.target.name);
|
handleFieldChange('avg_weight', value, () =>
|
||||||
|
setCurrentInput('avg_weight')
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onBlur={() => handleBlurField('avg_weight')}
|
|
||||||
isError={
|
isError={
|
||||||
formik.touched.avg_weight &&
|
formik.touched.avg_weight &&
|
||||||
Boolean(formik.errors.avg_weight)
|
Boolean(formik.errors.avg_weight)
|
||||||
@@ -568,10 +587,11 @@ const SalesOrderProductForm = ({
|
|||||||
name='total_weight'
|
name='total_weight'
|
||||||
value={formik.values.total_weight}
|
value={formik.values.total_weight}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
formik.handleChange(e);
|
const value = Number(e.target.value);
|
||||||
setCurrentInput(e.target.name);
|
handleFieldChange('total_weight', value, () =>
|
||||||
|
setCurrentInput('total_weight')
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onBlur={() => handleBlurField('total_weight')}
|
|
||||||
isError={
|
isError={
|
||||||
formik.touched.total_weight &&
|
formik.touched.total_weight &&
|
||||||
Boolean(formik.errors.total_weight)
|
Boolean(formik.errors.total_weight)
|
||||||
@@ -593,10 +613,11 @@ const SalesOrderProductForm = ({
|
|||||||
name='qty'
|
name='qty'
|
||||||
value={formik.values.qty}
|
value={formik.values.qty}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
formik.handleChange(e);
|
const value = Number(e.target.value);
|
||||||
setCurrentInput(e.target.name);
|
handleFieldChange('qty', value, () =>
|
||||||
|
setCurrentInput(e.target.name)
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onBlur={() => handleBlurField('qty')}
|
|
||||||
isError={formik.touched.qty && Boolean(formik.errors.qty)}
|
isError={formik.touched.qty && Boolean(formik.errors.qty)}
|
||||||
errorMessage={formik.errors.qty}
|
errorMessage={formik.errors.qty}
|
||||||
placeholder='Masukan Kuantitas'
|
placeholder='Masukan Kuantitas'
|
||||||
@@ -630,10 +651,11 @@ const SalesOrderProductForm = ({
|
|||||||
name='price_per_convertion'
|
name='price_per_convertion'
|
||||||
value={formik.values.price_per_convertion ?? undefined}
|
value={formik.values.price_per_convertion ?? undefined}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
formik.handleChange(e);
|
const value = Number(e.target.value);
|
||||||
setCurrentInput(e.target.name);
|
handleFieldChange('price_per_convertion', value, () =>
|
||||||
|
setCurrentInput(e.target.name)
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onBlur={() => handleBlurField('price_per_convertion')}
|
|
||||||
isError={
|
isError={
|
||||||
formik.touched.price_per_convertion &&
|
formik.touched.price_per_convertion &&
|
||||||
Boolean(formik.errors.price_per_convertion)
|
Boolean(formik.errors.price_per_convertion)
|
||||||
@@ -652,10 +674,11 @@ const SalesOrderProductForm = ({
|
|||||||
name='price_per_qty'
|
name='price_per_qty'
|
||||||
value={formik.values.price_per_qty ?? undefined}
|
value={formik.values.price_per_qty ?? undefined}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
formik.setFieldValue('price_per_qty', Number(e.target.value));
|
const value = Number(e.target.value);
|
||||||
setCurrentInput('price_per_qty');
|
handleFieldChange('price_per_qty', value, () =>
|
||||||
|
setCurrentInput('price_per_qty')
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onBlur={() => handleBlurField('price_per_qty')}
|
|
||||||
isError={
|
isError={
|
||||||
formik.touched.price_per_qty &&
|
formik.touched.price_per_qty &&
|
||||||
Boolean(formik.errors.price_per_qty)
|
Boolean(formik.errors.price_per_qty)
|
||||||
@@ -674,10 +697,11 @@ const SalesOrderProductForm = ({
|
|||||||
name='unit_price'
|
name='unit_price'
|
||||||
value={formik.values.unit_price}
|
value={formik.values.unit_price}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
formik.handleChange(e);
|
const value = Number(e.target.value);
|
||||||
setCurrentInput(e.target.name);
|
handleFieldChange('unit_price', value, () =>
|
||||||
|
setCurrentInput(e.target.name)
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onBlur={() => handleBlurField('unit_price')}
|
|
||||||
isError={
|
isError={
|
||||||
formik.touched.unit_price && Boolean(formik.errors.unit_price)
|
formik.touched.unit_price && Boolean(formik.errors.unit_price)
|
||||||
}
|
}
|
||||||
@@ -715,10 +739,11 @@ const SalesOrderProductForm = ({
|
|||||||
name='sisa_berat'
|
name='sisa_berat'
|
||||||
value={formik.values.sisa_berat ?? undefined}
|
value={formik.values.sisa_berat ?? undefined}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
formik.handleChange(e);
|
const value = Number(e.target.value);
|
||||||
setCurrentInput(e.target.name);
|
handleFieldChange('sisa_berat', value, () =>
|
||||||
|
setCurrentInput(e.target.name)
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onBlur={() => handleBlurField('sisa_berat')}
|
|
||||||
isError={
|
isError={
|
||||||
formik.touched.sisa_berat && Boolean(formik.errors.sisa_berat)
|
formik.touched.sisa_berat && Boolean(formik.errors.sisa_berat)
|
||||||
}
|
}
|
||||||
@@ -731,10 +756,11 @@ const SalesOrderProductForm = ({
|
|||||||
name='price_sisa_berat'
|
name='price_sisa_berat'
|
||||||
value={formik.values.price_sisa_berat ?? undefined}
|
value={formik.values.price_sisa_berat ?? undefined}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
formik.handleChange(e);
|
const value = Number(e.target.value);
|
||||||
setCurrentInput(e.target.name);
|
handleFieldChange('price_sisa_berat', value, () =>
|
||||||
|
setCurrentInput(e.target.name)
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onBlur={() => handleBlurField('price_sisa_berat')}
|
|
||||||
isError={
|
isError={
|
||||||
formik.touched.price_sisa_berat &&
|
formik.touched.price_sisa_berat &&
|
||||||
Boolean(formik.errors.price_sisa_berat)
|
Boolean(formik.errors.price_sisa_berat)
|
||||||
@@ -752,10 +778,11 @@ const SalesOrderProductForm = ({
|
|||||||
name='total_price'
|
name='total_price'
|
||||||
value={formik.values.total_price}
|
value={formik.values.total_price}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
formik.handleChange(e);
|
const value = Number(e.target.value);
|
||||||
setCurrentInput(e.target.name);
|
handleFieldChange('total_price', value, () =>
|
||||||
|
setCurrentInput('total_price')
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onBlur={() => handleBlurField('total_price')}
|
|
||||||
isError={
|
isError={
|
||||||
formik.touched.total_price && Boolean(formik.errors.total_price)
|
formik.touched.total_price && Boolean(formik.errors.total_price)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { DeliveryOrderProductFormValues } from '@/components/pages/marketing/for
|
|||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import Card from '@/components/Card';
|
import Card from '@/components/Card';
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import { useRef } from 'react';
|
import { useRef, useMemo } from 'react';
|
||||||
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
|
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
|
||||||
import DeliveryOrderExport from '@/components/pages/marketing/pdf/DeliveryOrderExport';
|
import DeliveryOrderExport from '@/components/pages/marketing/pdf/DeliveryOrderExport';
|
||||||
import { Marketing } from '@/types/api/marketing/marketing';
|
import { Marketing, BaseDelivery } from '@/types/api/marketing/marketing';
|
||||||
|
import { Warehouse } from '@/types/api/master-data/warehouse';
|
||||||
|
|
||||||
type DeliveryOrderProductTableProps = {
|
type DeliveryOrderProductTableProps = {
|
||||||
data: DeliveryOrderProductFormValues[];
|
data: DeliveryOrderProductFormValues[];
|
||||||
@@ -42,7 +43,31 @@ const DeliveryOrderProductTable = ({
|
|||||||
|
|
||||||
const approvalStepNumber = marketing?.latest_approval?.step_number;
|
const approvalStepNumber = marketing?.latest_approval?.step_number;
|
||||||
|
|
||||||
const renderTableContent = (item: DeliveryOrderProductFormValues) => {
|
const hasDeliveryOrder = useMemo(() => {
|
||||||
|
return (
|
||||||
|
marketing?.delivery_order &&
|
||||||
|
marketing.delivery_order.length > 0 &&
|
||||||
|
marketing.delivery_order.some(
|
||||||
|
(doItem) => doItem.deliveries && doItem.deliveries.length > 0
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, [marketing?.delivery_order]);
|
||||||
|
|
||||||
|
const deliveryItems = useMemo(() => {
|
||||||
|
if (!hasDeliveryOrder) return [];
|
||||||
|
return (
|
||||||
|
marketing?.delivery_order?.flatMap((doItem) =>
|
||||||
|
doItem.deliveries.map((delivery) => ({
|
||||||
|
...delivery,
|
||||||
|
do_number: doItem.do_number,
|
||||||
|
delivery_date: doItem.delivery_date,
|
||||||
|
warehouse: doItem.warehouse,
|
||||||
|
}))
|
||||||
|
) ?? []
|
||||||
|
);
|
||||||
|
}, [marketing?.delivery_order, hasDeliveryOrder]);
|
||||||
|
|
||||||
|
const renderSalesOrderContent = (item: DeliveryOrderProductFormValues) => {
|
||||||
const doItem = marketing?.delivery_order?.find(
|
const doItem = marketing?.delivery_order?.find(
|
||||||
(doItem) => doItem.do_number === item.do_number
|
(doItem) => doItem.do_number === item.do_number
|
||||||
);
|
);
|
||||||
@@ -185,10 +210,172 @@ const DeliveryOrderProductTable = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderDeliveryOrderContent = (
|
||||||
|
item: BaseDelivery & {
|
||||||
|
do_number: string;
|
||||||
|
delivery_date: string;
|
||||||
|
warehouse: Warehouse;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const parentDoItem = marketing?.delivery_order?.find(
|
||||||
|
(doItem) => doItem.do_number === item.do_number
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr className='border-b border-tools-table-outline border-base-content/5'>
|
||||||
|
<th className='w-1/3 text-start not-first:font-medium text-base-content/50 text-sm px-4 py-3'>
|
||||||
|
Label
|
||||||
|
</th>
|
||||||
|
<th className='text-start font-medium text-base-content/50 text-sm px-4 py-3'>
|
||||||
|
<div className='flex w-full flex-row gap-1 items-center justify-between h-full'>
|
||||||
|
<div>Value</div>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
<>
|
||||||
|
<tr>
|
||||||
|
<td className='text-sm px-4 py-3'>Gudang</td>
|
||||||
|
<td className='text-sm px-4 py-3'>{item.warehouse?.name}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className='text-sm px-4 py-3'>Produk</td>
|
||||||
|
<td className='text-sm px-4 py-3'>
|
||||||
|
{item.product_warehouse?.product?.name}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className='text-sm px-4 py-3'>Qty</td>
|
||||||
|
<td className='text-sm px-4 py-3'>
|
||||||
|
{item.qty
|
||||||
|
? `${formatNumber(item.qty)} ${item.product_warehouse?.product?.uom?.name ?? ''}`
|
||||||
|
: '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{Number(item.avg_weight ?? 0) > 0 && (
|
||||||
|
<tr>
|
||||||
|
<td className='text-sm px-4 py-3'>Avg Bobot</td>
|
||||||
|
<td className='text-sm px-4 py-3'>
|
||||||
|
{formatNumber(Number(item.avg_weight))} Kg
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{Number(item.total_weight ?? 0) > 0 && (
|
||||||
|
<tr>
|
||||||
|
<td className='text-sm px-4 py-3'>Total Bobot</td>
|
||||||
|
<td className='text-sm px-4 py-3'>
|
||||||
|
{formatNumber(Number(item.total_weight))}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
<tr>
|
||||||
|
<td className='text-sm px-4 py-3'>Total Harga Satuan</td>
|
||||||
|
<td className='text-sm px-4 py-3'>
|
||||||
|
{formatCurrency(item.unit_price)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className='text-sm px-4 py-3'>Total Penjualan</td>
|
||||||
|
<td className='text-sm px-4 py-3'>
|
||||||
|
{formatCurrency(item.total_price)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</>
|
||||||
|
<tr className='border-b border-t border-tools-table-outline border-base-content/5'>
|
||||||
|
<th className='w-1/3 text-start not-first:font-medium text-base-content/50 text-sm px-4 py-3'>
|
||||||
|
Label
|
||||||
|
</th>
|
||||||
|
<th className='text-start font-medium text-base-content/50 text-sm px-4 py-3'>
|
||||||
|
<div className='flex w-full flex-row gap-1 items-center justify-between h-full'>
|
||||||
|
<div>Value</div>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
<>
|
||||||
|
{approvalStepNumber !== 1 && (
|
||||||
|
<tr>
|
||||||
|
<td className='text-sm px-4 py-3'>Tanggal Pengiriman</td>
|
||||||
|
<td className='text-sm px-4 py-3'>
|
||||||
|
{item.delivery_date
|
||||||
|
? formatDate(item.delivery_date, 'DD MMM YYYY')
|
||||||
|
: '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{item.do_number && (
|
||||||
|
<tr>
|
||||||
|
<td className='text-sm px-4 py-3'>No. Pengiriman</td>
|
||||||
|
<td className='text-sm px-4 py-3'>{item.do_number}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
<tr>
|
||||||
|
<td className='text-sm px-4 py-3'>No. Polisi</td>
|
||||||
|
<td className='text-sm px-4 py-3'>{item.vehicle_number}</td>
|
||||||
|
</tr>
|
||||||
|
{parentDoItem && (
|
||||||
|
<tr>
|
||||||
|
<td className='text-sm px-4 py-3'>Dokumen Pengiriman</td>
|
||||||
|
<td className='text-sm px-4 py-3'>
|
||||||
|
<DeliveryOrderExport
|
||||||
|
data={marketing}
|
||||||
|
deliveryOrder={parentDoItem}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='size-full flex flex-col relative overflow-x-hidden gap-3'>
|
<div className='size-full flex flex-col relative overflow-x-hidden gap-3'>
|
||||||
{data.map((item) => (
|
{hasDeliveryOrder
|
||||||
|
? deliveryItems.map((item, index) => (
|
||||||
|
<div key={`do-table-${item.product_warehouse?.id}-${index}`}>
|
||||||
|
{formType === 'success' ? (
|
||||||
|
<div className='rounded-lg border border-tools-table-outline border-base-content/5'>
|
||||||
|
<table
|
||||||
|
style={{
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
}}
|
||||||
|
className='border-none w-full'
|
||||||
|
>
|
||||||
|
<tbody className='w-full'>
|
||||||
|
{renderDeliveryOrderContent(item)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card
|
||||||
|
key={`do-table-${item.product_warehouse?.id}-${index}`}
|
||||||
|
title={item.product_warehouse?.product?.name || 'Produk'}
|
||||||
|
collapsible={true}
|
||||||
|
defaultCollapsed={false}
|
||||||
|
variant='bordered'
|
||||||
|
className={{
|
||||||
|
wrapper: 'w-full rounded-lg',
|
||||||
|
body: 'p-0',
|
||||||
|
title: 'px-2 py-1.5 font-normal text-sm',
|
||||||
|
collapsible: 'rounded-lg',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
style={{
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
}}
|
||||||
|
className='border-none w-full'
|
||||||
|
>
|
||||||
|
<tbody className='w-full'>
|
||||||
|
{renderDeliveryOrderContent(item)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
: data.map((item) => (
|
||||||
<div key={`table-${item.id}`}>
|
<div key={`table-${item.id}`}>
|
||||||
{formType === 'success' ? (
|
{formType === 'success' ? (
|
||||||
<div className='rounded-lg border border-tools-table-outline border-base-content/5'>
|
<div className='rounded-lg border border-tools-table-outline border-base-content/5'>
|
||||||
@@ -198,14 +385,17 @@ const DeliveryOrderProductTable = ({
|
|||||||
}}
|
}}
|
||||||
className='border-none w-full'
|
className='border-none w-full'
|
||||||
>
|
>
|
||||||
<tbody className='w-full'>{renderTableContent(item)}</tbody>
|
<tbody className='w-full'>
|
||||||
|
{renderSalesOrderContent(item)}
|
||||||
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Card
|
<Card
|
||||||
key={`table-${item.id}`}
|
key={`table-${item.id}`}
|
||||||
title={
|
title={
|
||||||
item.marketing_product?.product_warehouse?.label || 'Produk'
|
item.marketing_product?.product_warehouse?.label ||
|
||||||
|
'Produk'
|
||||||
}
|
}
|
||||||
collapsible={true}
|
collapsible={true}
|
||||||
defaultCollapsed={false}
|
defaultCollapsed={false}
|
||||||
@@ -223,7 +413,9 @@ const DeliveryOrderProductTable = ({
|
|||||||
}}
|
}}
|
||||||
className='border-none w-full'
|
className='border-none w-full'
|
||||||
>
|
>
|
||||||
<tbody className='w-full'>{renderTableContent(item)}</tbody>
|
<tbody className='w-full'>
|
||||||
|
{renderSalesOrderContent(item)}
|
||||||
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import { BaseDeliveryOrder, Marketing } from '@/types/api/marketing/marketing';
|
import { BaseDeliveryOrder, Marketing } from '@/types/api/marketing/marketing';
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import { Document, Image, Page, pdf, Text, View } from '@react-pdf/renderer';
|
import { Document, Page, pdf, Text, View } from '@react-pdf/renderer';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { formatDate, formatNumber, formatVechicleNumber } from '@/lib/helper';
|
import { formatDate, formatNumber, formatVechicleNumber } from '@/lib/helper';
|
||||||
import pdfStyles from '@/components/pages/marketing/pdf/styles/MarketingPDFStyles';
|
import pdfStyles from '@/components/pages/marketing/pdf/styles/MarketingPDFStyles';
|
||||||
|
|||||||
@@ -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, useEffect, 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,75 +11,101 @@ 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';
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useUiStore } from '@/stores/ui/ui.store';
|
||||||
|
|
||||||
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'>
|
||||||
|
<PopoverButton
|
||||||
|
tabIndex={0}
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
popoverTarget={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
>
|
||||||
|
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||||
|
</PopoverButton>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
id={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
|
||||||
|
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
|
||||||
|
>
|
||||||
|
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||||
<RequirePermission permissions='lti.master.area.detail'>
|
<RequirePermission permissions='lti.master.area.detail'>
|
||||||
<Button
|
<Button
|
||||||
href={`/master-data/area/detail/?areaId=${props.row.original.id}`}
|
href={`/master-data/area/detail/?areaId=${props.row.original.id}`}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='primary'
|
color='none'
|
||||||
className='justify-start text-sm'
|
className='p-3 justify-start text-sm font-semibold w-full'
|
||||||
|
onClick={closePopover}
|
||||||
>
|
>
|
||||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
<Icon icon='heroicons:eye' width={20} height={20} />
|
||||||
Detail
|
Detail
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
|
|
||||||
<RequirePermission permissions='lti.master.area.update'>
|
<RequirePermission permissions='lti.master.area.update'>
|
||||||
<Button
|
<Button
|
||||||
href={`/master-data/area/detail/edit/?areaId=${props.row.original.id}`}
|
href={`/master-data/area/detail/edit/?areaId=${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} />
|
<Icon icon='heroicons:pencil-square' width={20} height={20} />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
|
|
||||||
<RequirePermission permissions='lti.master.area.delete'>
|
<RequirePermission permissions='lti.master.area.delete'>
|
||||||
<Button
|
<Button
|
||||||
onClick={deleteClickHandler}
|
onClick={() => {
|
||||||
|
deleteClickHandler();
|
||||||
|
closePopover();
|
||||||
|
}}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='error'
|
color='none'
|
||||||
className='text-error hover:text-inherit'
|
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon icon='heroicons:trash' width={20} height={20} />
|
||||||
icon='material-symbols:delete-outline-rounded'
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
className='justify-start text-sm'
|
|
||||||
/>
|
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
</RowOptionsMenuWrapper>
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const AreasTable = () => {
|
const AreasTable = () => {
|
||||||
|
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
state: tableFilterState,
|
state: tableFilterState,
|
||||||
updateFilter,
|
updateFilter,
|
||||||
@@ -87,10 +113,17 @@ const AreasTable = () => {
|
|||||||
setPageSize,
|
setPageSize,
|
||||||
toQueryString: getTableFilterQueryString,
|
toQueryString: getTableFilterQueryString,
|
||||||
} = useTableFilter({
|
} = useTableFilter({
|
||||||
initial: { search: '', 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: areas,
|
data: areas,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -101,66 +134,22 @@ 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>([]);
|
useEffect(() => {
|
||||||
|
updateFilter('search', searchValue);
|
||||||
|
}, [searchValue, updateFilter]);
|
||||||
|
|
||||||
const areasColumns: ColumnDef<Area>[] = [
|
useEffect(() => {
|
||||||
{
|
setTableState('areas-table', pathname);
|
||||||
header: '#',
|
}, [pathname, setTableState]);
|
||||||
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 searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
|
setSearchValue(e.target.value);
|
||||||
const deleteClickHandler = () => {
|
updateFilter('search', e.target.value);
|
||||||
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 +168,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 isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||||
|
|
||||||
|
const deleteClickHandler = () => {
|
||||||
|
setSelectedArea(props.row.original);
|
||||||
|
deleteModal.openModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
return (
|
||||||
const newVal = val as OptionType;
|
<RowOptionsMenu
|
||||||
|
props={props}
|
||||||
setPageSize(newVal.value as number);
|
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
|
||||||
};
|
deleteClickHandler={deleteClickHandler}
|
||||||
|
/>
|
||||||
// track sorting
|
);
|
||||||
useEffect(() => {
|
},
|
||||||
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
|
},
|
||||||
|
],
|
||||||
if (!isNameSorted) {
|
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
|
||||||
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 */}
|
||||||
<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='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 Area
|
||||||
</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='Search'
|
||||||
value={tableFilterState.search}
|
value={tableFilterState.search ?? ''}
|
||||||
onChange={searchChangeHandler}
|
onChange={searchChangeHandler}
|
||||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
startAdornment={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:magnifying-glass'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
/>
|
/>
|
||||||
</div>
|
}
|
||||||
|
className={{
|
||||||
<div className='flex flex-row justify-end'>
|
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||||
<SelectInput
|
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||||
label='Baris'
|
input:
|
||||||
options={ROWS_OPTIONS}
|
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||||
value={{
|
|
||||||
label: String(tableFilterState.pageSize),
|
|
||||||
value: tableFilterState.pageSize,
|
|
||||||
}}
|
}}
|
||||||
onChange={pageSizeChangeHandler}
|
|
||||||
className={{ wrapper: 'max-w-28' }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Table Section */}
|
||||||
|
<div className='flex flex-col mb-4'>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
) : !isResponseSuccess(areas) || areas.data?.length === 0 ? (
|
||||||
|
<div className='p-3'>
|
||||||
|
<AreaTableSkeleton
|
||||||
|
columns={areasColumns}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:document-text'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Table<Area>
|
<Table<Area>
|
||||||
data={isResponseSuccess(areas) ? areas?.data : []}
|
data={isResponseSuccess(areas) ? areas?.data : []}
|
||||||
columns={areasColumns}
|
columns={areasColumns}
|
||||||
pageSize={tableFilterState.pageSize}
|
pageSize={tableFilterState.pageSize}
|
||||||
page={isResponseSuccess(areas) ? areas?.meta?.page : 0}
|
page={isResponseSuccess(areas) ? areas?.meta?.page : 0}
|
||||||
totalItems={isResponseSuccess(areas) ? areas?.meta?.total_results : 0}
|
totalItems={
|
||||||
|
isResponseSuccess(areas) ? areas?.meta?.total_results : 0
|
||||||
|
}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={setPageSize}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
sorting={sorting}
|
sorting={sorting}
|
||||||
setSorting={setSorting}
|
setSorting={setSorting}
|
||||||
className={{
|
className={{
|
||||||
containerClassName: cn({
|
containerClassName: 'p-3 mb-0',
|
||||||
'mb-20': isResponseSuccess(areas) && areas?.data?.length === 0,
|
headerColumnClassName: 'text-nowrap',
|
||||||
}),
|
|
||||||
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:
|
|
||||||
'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',
|
|
||||||
bodyColumnClassName:
|
|
||||||
'px-6 py-3 last:flex last:flex-row last:justify-end',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</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, useEffect, 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,75 +11,101 @@ 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';
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useUiStore } from '@/stores/ui/ui.store';
|
||||||
|
|
||||||
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'>
|
||||||
|
<PopoverButton
|
||||||
|
tabIndex={0}
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
popoverTarget={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
>
|
||||||
|
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||||
|
</PopoverButton>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
id={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
|
||||||
|
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
|
||||||
|
>
|
||||||
|
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||||
<RequirePermission permissions='lti.master.banks.detail'>
|
<RequirePermission permissions='lti.master.banks.detail'>
|
||||||
<Button
|
<Button
|
||||||
href={`/master-data/bank/detail/?bankId=${props.row.original.id}`}
|
href={`/master-data/bank/detail/?bankId=${props.row.original.id}`}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='primary'
|
color='none'
|
||||||
className='justify-start text-sm'
|
className='p-3 justify-start text-sm font-semibold w-full'
|
||||||
|
onClick={closePopover}
|
||||||
>
|
>
|
||||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
<Icon icon='heroicons:eye' width={20} height={20} />
|
||||||
Detail
|
Detail
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
|
|
||||||
<RequirePermission permissions='lti.master.banks.update'>
|
<RequirePermission permissions='lti.master.banks.update'>
|
||||||
<Button
|
<Button
|
||||||
href={`/master-data/bank/detail/edit/?bankId=${props.row.original.id}`}
|
href={`/master-data/bank/detail/edit/?bankId=${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} />
|
<Icon icon='heroicons:pencil-square' width={20} height={20} />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
|
|
||||||
<RequirePermission permissions='lti.master.banks.delete'>
|
<RequirePermission permissions='lti.master.banks.delete'>
|
||||||
<Button
|
<Button
|
||||||
onClick={deleteClickHandler}
|
onClick={() => {
|
||||||
|
deleteClickHandler();
|
||||||
|
closePopover();
|
||||||
|
}}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='error'
|
color='none'
|
||||||
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
|
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon icon='heroicons:trash' width={20} height={20} />
|
||||||
icon='material-symbols:delete-outline-rounded'
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
className='justify-start text-sm'
|
|
||||||
/>
|
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
</RowOptionsMenuWrapper>
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const BanksTable = () => {
|
const BanksTable = () => {
|
||||||
|
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
state: tableFilterState,
|
state: tableFilterState,
|
||||||
updateFilter,
|
updateFilter,
|
||||||
@@ -87,10 +113,17 @@ const BanksTable = () => {
|
|||||||
setPageSize,
|
setPageSize,
|
||||||
toQueryString: getTableFilterQueryString,
|
toQueryString: getTableFilterQueryString,
|
||||||
} = useTableFilter({
|
} = useTableFilter({
|
||||||
initial: { search: '', 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: banks,
|
data: banks,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -101,15 +134,44 @@ 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>([]);
|
useEffect(() => {
|
||||||
|
updateFilter('search', searchValue);
|
||||||
|
}, [searchValue, updateFilter]);
|
||||||
|
|
||||||
const banksColumns: ColumnDef<Bank>[] = [
|
useEffect(() => {
|
||||||
|
setTableState('banks-table', pathname);
|
||||||
|
}, [pathname, setTableState]);
|
||||||
|
|
||||||
|
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
|
setSearchValue(e.target.value);
|
||||||
|
updateFilter('search', e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmationModalDeleteClickHandler = async () => {
|
||||||
|
setIsDeleteLoading(true);
|
||||||
|
|
||||||
|
const deleteResponse = await BankApi.delete(selectedBank?.id as number);
|
||||||
|
|
||||||
|
if (isResponseError(deleteResponse)) {
|
||||||
|
toast.error(deleteResponse.message);
|
||||||
|
setIsDeleteLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshBanks();
|
||||||
|
|
||||||
|
deleteModal.closeModal();
|
||||||
|
toast.success('Successfully delete Bank!');
|
||||||
|
setIsDeleteLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const banksColumns: ColumnDef<Bank>[] = useMemo(
|
||||||
|
() => [
|
||||||
{
|
{
|
||||||
header: '#',
|
header: 'No',
|
||||||
cell: (props) =>
|
cell: (props) =>
|
||||||
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||||
props.row.index +
|
props.row.index +
|
||||||
@@ -130,12 +192,13 @@ const BanksTable = () => {
|
|||||||
{
|
{
|
||||||
accessorKey: 'owner',
|
accessorKey: 'owner',
|
||||||
header: 'Pemilik',
|
header: 'Pemilik',
|
||||||
cell: (props) => (props.getValue() ? props.getValue() : '-'),
|
cell: (props) => props.getValue() || '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Aksi',
|
header: 'Aksi',
|
||||||
cell: (props) => {
|
cell: (props: CellContext<Bank, unknown>) => {
|
||||||
const currentPageSize = props.table.getPaginationRowModel().rows.length;
|
const currentPageSize =
|
||||||
|
props.table.getPaginationRowModel().rows.length;
|
||||||
const currentPageRows = props.table.getPaginationRowModel().flatRows;
|
const currentPageRows = props.table.getPaginationRowModel().flatRows;
|
||||||
const currentRowRelativeIndex =
|
const currentRowRelativeIndex =
|
||||||
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
|
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
|
||||||
@@ -148,137 +211,102 @@ const BanksTable = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
{currentPageSize > 2 && (
|
|
||||||
<RowDropdownOptions isLast2Rows={isLast2Rows}>
|
|
||||||
<RowOptionsMenu
|
<RowOptionsMenu
|
||||||
type='dropdown'
|
|
||||||
props={props}
|
props={props}
|
||||||
|
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
|
||||||
deleteClickHandler={deleteClickHandler}
|
deleteClickHandler={deleteClickHandler}
|
||||||
/>
|
/>
|
||||||
</RowDropdownOptions>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentPageSize <= 2 && (
|
|
||||||
<RowCollapseOptions>
|
|
||||||
<RowOptionsMenu
|
|
||||||
type='collapse'
|
|
||||||
props={props}
|
|
||||||
deleteClickHandler={deleteClickHandler}
|
|
||||||
/>
|
|
||||||
</RowCollapseOptions>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
],
|
||||||
|
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
|
||||||
const confirmationModalDeleteClickHandler = async () => {
|
);
|
||||||
setIsDeleteLoading(true);
|
|
||||||
|
|
||||||
const deleteResponse = await BankApi.delete(selectedBank?.id as number);
|
|
||||||
|
|
||||||
if (isResponseError(deleteResponse)) {
|
|
||||||
toast.error(deleteResponse.message);
|
|
||||||
setIsDeleteLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshBanks();
|
|
||||||
|
|
||||||
deleteModal.closeModal();
|
|
||||||
toast.success('Successfully delete Bank!');
|
|
||||||
setIsDeleteLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
|
||||||
updateFilter('search', e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
|
||||||
const newVal = val as OptionType;
|
|
||||||
|
|
||||||
setPageSize(newVal.value as number);
|
|
||||||
};
|
|
||||||
|
|
||||||
// track sorting
|
|
||||||
useEffect(() => {
|
|
||||||
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
|
|
||||||
|
|
||||||
if (!isNameSorted) {
|
|
||||||
updateFilter('nameSort', '');
|
|
||||||
} else {
|
|
||||||
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
|
|
||||||
}
|
|
||||||
}, [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 */}
|
||||||
|
<div className='w-fit flex flex-row gap-3 flex-wrap'>
|
||||||
<RequirePermission permissions='lti.master.banks.create'>
|
<RequirePermission permissions='lti.master.banks.create'>
|
||||||
<Button
|
<Button
|
||||||
href='/master-data/bank/add'
|
href='/master-data/bank/add'
|
||||||
variant='outline'
|
|
||||||
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='Search'
|
||||||
value={tableFilterState.search}
|
value={tableFilterState.search ?? ''}
|
||||||
onChange={searchChangeHandler}
|
onChange={searchChangeHandler}
|
||||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
startAdornment={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:magnifying-glass'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
/>
|
/>
|
||||||
</div>
|
}
|
||||||
|
className={{
|
||||||
<div className='flex flex-row justify-end'>
|
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||||
<SelectInput
|
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||||
label='Baris'
|
input:
|
||||||
options={ROWS_OPTIONS}
|
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||||
value={{
|
|
||||||
label: String(tableFilterState.pageSize),
|
|
||||||
value: tableFilterState.pageSize,
|
|
||||||
}}
|
}}
|
||||||
onChange={pageSizeChangeHandler}
|
|
||||||
className={{ wrapper: 'max-w-28' }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Table Section */}
|
||||||
|
<div className='flex flex-col mb-4'>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
) : !isResponseSuccess(banks) || banks.data?.length === 0 ? (
|
||||||
|
<div className='p-3'>
|
||||||
|
<BankTableSkeleton
|
||||||
|
columns={banksColumns}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:document-text'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Table<Bank>
|
<Table<Bank>
|
||||||
data={isResponseSuccess(banks) ? banks?.data : []}
|
data={isResponseSuccess(banks) ? banks?.data : []}
|
||||||
columns={banksColumns}
|
columns={banksColumns}
|
||||||
pageSize={tableFilterState.pageSize}
|
pageSize={tableFilterState.pageSize}
|
||||||
page={isResponseSuccess(banks) ? banks?.meta?.page : 0}
|
page={isResponseSuccess(banks) ? banks?.meta?.page : 0}
|
||||||
totalItems={isResponseSuccess(banks) ? banks?.meta?.total_results : 0}
|
totalItems={
|
||||||
|
isResponseSuccess(banks) ? banks?.meta?.total_results : 0
|
||||||
|
}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={setPageSize}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
sorting={sorting}
|
sorting={sorting}
|
||||||
setSorting={setSorting}
|
setSorting={setSorting}
|
||||||
className={{
|
className={{
|
||||||
containerClassName: cn({
|
containerClassName: 'p-3 mb-0',
|
||||||
'mb-20': isResponseSuccess(banks) && banks?.data?.length === 0,
|
headerColumnClassName: 'text-nowrap',
|
||||||
}),
|
|
||||||
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:
|
|
||||||
'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',
|
|
||||||
bodyColumnClassName:
|
|
||||||
'px-6 py-3 last:flex last:flex-row last:justify-end',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</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,46 +1,72 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Button from '@/components/Button';
|
import { ChangeEventHandler, useEffect, 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 { usePathname } from 'next/navigation';
|
||||||
import useSWR from 'swr';
|
import { useUiStore } from '@/stores/ui/ui.store';
|
||||||
|
|
||||||
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'>
|
||||||
|
<PopoverButton
|
||||||
|
tabIndex={0}
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
popoverTarget={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
>
|
||||||
|
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||||
|
</PopoverButton>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
id={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
|
||||||
|
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
|
||||||
|
>
|
||||||
|
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||||
<RequirePermission permissions='lti.master.customer.detail'>
|
<RequirePermission permissions='lti.master.customer.detail'>
|
||||||
<Button
|
<Button
|
||||||
href={`/master-data/customer/detail/?customerId=${props.row.original.id}`}
|
href={`/master-data/customer/detail/?customerId=${props.row.original.id}`}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='primary'
|
color='none'
|
||||||
className='justify-start text-sm'
|
className='p-3 justify-start text-sm font-semibold w-full'
|
||||||
|
onClick={closePopover}
|
||||||
>
|
>
|
||||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
<Icon icon='heroicons:eye' width={20} height={20} />
|
||||||
Detail
|
Detail
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
@@ -48,34 +74,38 @@ const RowOptionsMenu = ({
|
|||||||
<Button
|
<Button
|
||||||
href={`/master-data/customer/detail/edit/?customerId=${props.row.original.id}`}
|
href={`/master-data/customer/detail/edit/?customerId=${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} />
|
<Icon icon='heroicons:pencil-square' width={20} height={20} />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
<RequirePermission permissions='lti.master.customer.delete'>
|
<RequirePermission permissions='lti.master.customer.delete'>
|
||||||
<Button
|
<Button
|
||||||
onClick={deleteClickHandler}
|
onClick={() => {
|
||||||
|
deleteClickHandler();
|
||||||
|
closePopover();
|
||||||
|
}}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='error'
|
color='none'
|
||||||
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
|
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon icon='heroicons:trash' width={20} height={20} />
|
||||||
icon='material-symbols:delete-outline-rounded'
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
className='justify-start text-sm'
|
|
||||||
/>
|
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
</RowOptionsMenuWrapper>
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const CustomersTable = () => {
|
const CustomersTable = () => {
|
||||||
|
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
state: tableFilterState,
|
state: tableFilterState,
|
||||||
updateFilter,
|
updateFilter,
|
||||||
@@ -83,16 +113,17 @@ const CustomersTable = () => {
|
|||||||
setPageSize,
|
setPageSize,
|
||||||
toQueryString: getTableFilterQueryString,
|
toQueryString: getTableFilterQueryString,
|
||||||
} = useTableFilter({
|
} = useTableFilter({
|
||||||
initial: { search: '', nameSort: '', picSort: '' },
|
initial: {
|
||||||
|
search: searchValue,
|
||||||
|
},
|
||||||
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 +133,25 @@ 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
|
useEffect(() => {
|
||||||
const customersColumns: ColumnDef<Customer>[] = [
|
updateFilter('search', searchValue);
|
||||||
{
|
}, [searchValue, updateFilter]);
|
||||||
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;
|
useEffect(() => {
|
||||||
|
setTableState('customers-table', pathname);
|
||||||
|
}, [pathname, setTableState]);
|
||||||
|
|
||||||
const deleteClickHandler = () => {
|
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
setSelectedCustomer(props.row.original);
|
setSearchValue(e.target.value);
|
||||||
deleteModal.openModal();
|
updateFilter('search', e.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
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 +171,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 */}
|
||||||
|
<div className='w-fit flex flex-row gap-3 flex-wrap'>
|
||||||
<RequirePermission permissions='lti.master.customer.create'>
|
<RequirePermission permissions='lti.master.customer.create'>
|
||||||
<Button
|
<Button
|
||||||
href='/master-data/customer/add'
|
href='/master-data/customer/add'
|
||||||
variant='outline'
|
|
||||||
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>
|
</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='Search'
|
||||||
value={tableFilterState.search}
|
value={tableFilterState.search ?? ''}
|
||||||
onChange={searchChangeHandler}
|
onChange={searchChangeHandler}
|
||||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
startAdornment={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:magnifying-glass'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
/>
|
/>
|
||||||
</div>
|
}
|
||||||
|
className={{
|
||||||
<div className='flex flex-row justify-end'>
|
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||||
<SelectInput
|
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||||
label='Baris'
|
input:
|
||||||
options={ROWS_OPTIONS}
|
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||||
value={{
|
|
||||||
label: String(tableFilterState.pageSize),
|
|
||||||
value: tableFilterState.pageSize,
|
|
||||||
}}
|
}}
|
||||||
onChange={pageSizeChangeHandler}
|
|
||||||
className={{ wrapper: 'max-w-28' }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Table Section */}
|
||||||
|
<div className='flex flex-col mb-4'>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
) : !isResponseSuccess(customers) || customers.data?.length === 0 ? (
|
||||||
|
<div className='p-3'>
|
||||||
|
<CustomerTableSkeleton
|
||||||
|
columns={customersColumns}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:document-text'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Table<Customer>
|
<Table<Customer>
|
||||||
data={isResponseSuccess(customers) ? customers?.data : []}
|
data={customers?.data}
|
||||||
columns={customersColumns}
|
columns={customersColumns}
|
||||||
pageSize={tableFilterState.pageSize}
|
pageSize={tableFilterState.pageSize}
|
||||||
page={isResponseSuccess(customers) ? customers?.meta?.page : 0}
|
page={customers?.meta?.page ?? 0}
|
||||||
totalItems={
|
totalItems={customers?.meta?.total_results ?? 0}
|
||||||
isResponseSuccess(customers) ? customers?.meta?.total_results : 0
|
|
||||||
}
|
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
isLoading={isLoading}
|
onPageSizeChange={setPageSize}
|
||||||
|
isLoading={false}
|
||||||
|
sorting={sorting}
|
||||||
|
setSorting={setSorting}
|
||||||
className={{
|
className={{
|
||||||
containerClassName: cn({
|
containerClassName: 'p-3 mb-0',
|
||||||
'mb-20':
|
headerColumnClassName: 'text-nowrap',
|
||||||
isResponseSuccess(customers) && customers?.data?.length === 0,
|
|
||||||
}),
|
|
||||||
tableWrapperClassName: 'overflow-x-auto min-h-full!',
|
|
||||||
tableClassName: 'font-inter w-full table-auto min-h-full!',
|
|
||||||
headerRowClassName: 'border-b border-b-gray-200',
|
|
||||||
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',
|
|
||||||
bodyColumnClassName:
|
|
||||||
'px-6 py-3 last:flex last:flex-row last:justify-end',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</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,51 +1,72 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
import { ChangeEventHandler, useEffect, 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';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useUiStore } from '@/stores/ui/ui.store';
|
||||||
|
|
||||||
|
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'>
|
||||||
|
<PopoverButton
|
||||||
|
tabIndex={0}
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
popoverTarget={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
>
|
||||||
|
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||||
|
</PopoverButton>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
id={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
|
||||||
|
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
|
||||||
|
>
|
||||||
|
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||||
<RequirePermission permissions='lti.master.flocks.detail'>
|
<RequirePermission permissions='lti.master.flocks.detail'>
|
||||||
<Button
|
<Button
|
||||||
href={`/master-data/flock/detail/?flockId=${props.row.original.id}`}
|
href={`/master-data/flock/detail/?flockId=${props.row.original.id}`}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='primary'
|
color='none'
|
||||||
className='justify-start text-sm'
|
className='p-3 justify-start text-sm font-semibold w-full'
|
||||||
|
onClick={closePopover}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon icon='heroicons:eye' width={20} height={20} />
|
||||||
icon='mdi:eye-outline'
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
className='justify-start text-sm'
|
|
||||||
/>
|
|
||||||
Detail
|
Detail
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
@@ -53,39 +74,38 @@ const RowsOptions = ({
|
|||||||
<Button
|
<Button
|
||||||
href={`/master-data/flock/detail/edit/?flockId=${props.row.original.id}`}
|
href={`/master-data/flock/detail/edit/?flockId=${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 icon='heroicons:pencil-square' width={20} height={20} />
|
||||||
icon='material-symbols:edit-outline'
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
className='justify-start text-sm'
|
|
||||||
/>
|
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
<RequirePermission permissions='lti.master.flocks.delete'>
|
<RequirePermission permissions='lti.master.flocks.delete'>
|
||||||
<Button
|
<Button
|
||||||
onClick={deleteClickHandler}
|
onClick={() => {
|
||||||
|
deleteClickHandler();
|
||||||
|
closePopover();
|
||||||
|
}}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='error'
|
color='none'
|
||||||
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
|
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon icon='heroicons:trash' width={20} height={20} />
|
||||||
icon='material-symbols:delete-outline-rounded'
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
className='justify-start text-sm'
|
|
||||||
/>
|
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
</RowOptionsMenuWrapper>
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const FlockTable = () => {
|
const FlockTable = () => {
|
||||||
|
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
state: tableFilterState,
|
state: tableFilterState,
|
||||||
updateFilter,
|
updateFilter,
|
||||||
@@ -93,15 +113,17 @@ const FlockTable = () => {
|
|||||||
setPageSize,
|
setPageSize,
|
||||||
toQueryString: getTableFilterQueryString,
|
toQueryString: getTableFilterQueryString,
|
||||||
} = useTableFilter({
|
} = useTableFilter({
|
||||||
initial: { search: '', nameSort: '' },
|
initial: {
|
||||||
|
search: searchValue,
|
||||||
|
},
|
||||||
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 +133,25 @@ 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
|
useEffect(() => {
|
||||||
const flocksColumns: ColumnDef<Flock>[] = [
|
updateFilter('search', searchValue);
|
||||||
{
|
}, [searchValue, updateFilter]);
|
||||||
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;
|
useEffect(() => {
|
||||||
|
setTableState('flocks-table', pathname);
|
||||||
|
}, [pathname, setTableState]);
|
||||||
|
|
||||||
const deleteClickHandler = () => {
|
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
setSelectedFlock(props.row.original);
|
setSearchValue(e.target.value);
|
||||||
deleteModal.openModal();
|
updateFilter('search', e.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
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 +169,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 */}
|
||||||
|
<div className='w-fit flex flex-row gap-3 flex-wrap'>
|
||||||
<RequirePermission permissions='lti.master.flocks.create'>
|
<RequirePermission permissions='lti.master.flocks.create'>
|
||||||
<Button
|
<Button
|
||||||
href='/master-data/flock/add'
|
href='/master-data/flock/add'
|
||||||
variant='outline'
|
|
||||||
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='Search'
|
||||||
value={tableFilterState.search}
|
value={tableFilterState.search ?? ''}
|
||||||
onChange={searchChangeHandler}
|
onChange={searchChangeHandler}
|
||||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
startAdornment={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:magnifying-glass'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
/>
|
/>
|
||||||
</div>
|
}
|
||||||
|
className={{
|
||||||
<div className='flex flex-row justify-end'>
|
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||||
<SelectInput
|
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||||
label='Baris'
|
input:
|
||||||
options={ROWS_OPTIONS}
|
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||||
value={{
|
|
||||||
label: String(tableFilterState.pageSize),
|
|
||||||
value: tableFilterState.pageSize,
|
|
||||||
}}
|
}}
|
||||||
onChange={pageSizeChangeHandler}
|
|
||||||
className={{ wrapper: 'max-w-28' }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Table Section */}
|
||||||
|
<div className='flex flex-col mb-4'>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
) : !isResponseSuccess(flocks) || flocks.data?.length === 0 ? (
|
||||||
|
<div className='p-3'>
|
||||||
|
<FlockTableSkeleton
|
||||||
|
columns={flocksColumns}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:document-text'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Table<Flock>
|
<Table<Flock>
|
||||||
data={isResponseSuccess(flocks) ? flocks?.data : []}
|
data={flocks?.data}
|
||||||
columns={flocksColumns}
|
columns={flocksColumns}
|
||||||
pageSize={tableFilterState.pageSize}
|
pageSize={tableFilterState.pageSize}
|
||||||
page={isResponseSuccess(flocks) ? flocks?.meta?.page : 0}
|
page={flocks?.meta?.page ?? 0}
|
||||||
totalItems={
|
totalItems={flocks?.meta?.total_results ?? 0}
|
||||||
isResponseSuccess(flocks) ? flocks?.meta?.total_results : 0
|
|
||||||
}
|
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
isLoading={isLoading}
|
onPageSizeChange={setPageSize}
|
||||||
|
isLoading={false}
|
||||||
|
sorting={sorting}
|
||||||
|
setSorting={setSorting}
|
||||||
className={{
|
className={{
|
||||||
containerClassName: cn({
|
containerClassName: 'p-3 mb-0',
|
||||||
'mb-20': isResponseSuccess(flocks) && flocks?.data?.length === 0,
|
headerColumnClassName: 'text-nowrap',
|
||||||
}),
|
|
||||||
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:
|
|
||||||
'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',
|
|
||||||
bodyColumnClassName:
|
|
||||||
'px-6 py-3 last:flex last:flex-row last:justify-end',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</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,90 +1,130 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ChangeEventHandler, useCallback, useEffect, useState } from 'react';
|
|
||||||
import useSWR from 'swr';
|
|
||||||
import {
|
import {
|
||||||
CellContext,
|
ChangeEventHandler,
|
||||||
ColumnDef,
|
useCallback,
|
||||||
ColumnSort,
|
useEffect,
|
||||||
SortingState,
|
useMemo,
|
||||||
} from '@tanstack/react-table';
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import { useFormik } from 'formik';
|
||||||
|
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import { useModal } from '@/components/Modal';
|
import Modal, { 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 SelectInput, { useSelect } from '@/components/input/SelectInput';
|
||||||
|
import { OptionType } from '@/components/input/SelectInput';
|
||||||
|
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||||
|
|
||||||
import { Kandang } from '@/types/api/master-data/kandang';
|
import { Kandang } from '@/types/api/master-data/kandang';
|
||||||
import { KandangApi } from '@/services/api/master-data';
|
import { Location } from '@/types/api/master-data/location';
|
||||||
import { cn, formatNumber } from '@/lib/helper';
|
import { KandangApi, LocationApi } from '@/services/api/master-data';
|
||||||
|
import { UserApi } from '@/services/api/user';
|
||||||
|
import { User } from '@/types/api/api-general';
|
||||||
|
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';
|
import { useUiStore } from '@/stores/ui/ui.store';
|
||||||
|
import {
|
||||||
|
KandangFilterSchema,
|
||||||
|
KandangFilterType,
|
||||||
|
} from '@/components/pages/master-data/kandang/filter/KandangFilter';
|
||||||
|
import SelectInputRadio from '@/components/input/SelectInputRadio';
|
||||||
|
|
||||||
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'>
|
||||||
|
<PopoverButton
|
||||||
|
tabIndex={0}
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
popoverTarget={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
>
|
||||||
|
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||||
|
</PopoverButton>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
id={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
|
||||||
|
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
|
||||||
|
>
|
||||||
|
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||||
<RequirePermission permissions='lti.master.kandangs.detail'>
|
<RequirePermission permissions='lti.master.kandangs.detail'>
|
||||||
<Button
|
<Button
|
||||||
href={`/master-data/kandang/detail/?kandangId=${props.row.original.id}`}
|
href={`/master-data/kandang/detail/?kandangId=${props.row.original.id}`}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='primary'
|
color='none'
|
||||||
className='justify-start text-sm'
|
className='p-3 justify-start text-sm font-semibold w-full'
|
||||||
|
onClick={closePopover}
|
||||||
>
|
>
|
||||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
<Icon icon='heroicons:eye' width={20} height={20} />
|
||||||
Detail
|
Detail
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
|
|
||||||
<RequirePermission permissions='lti.master.kandangs.update'>
|
<RequirePermission permissions='lti.master.kandangs.update'>
|
||||||
<Button
|
<Button
|
||||||
href={`/master-data/kandang/detail/edit/?kandangId=${props.row.original.id}`}
|
href={`/master-data/kandang/detail/edit/?kandangId=${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} />
|
<Icon icon='heroicons:pencil-square' width={20} height={20} />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
|
|
||||||
<RequirePermission permissions='lti.master.kandangs.delete'>
|
<RequirePermission permissions='lti.master.kandangs.delete'>
|
||||||
<Button
|
<Button
|
||||||
onClick={deleteClickHandler}
|
onClick={() => {
|
||||||
|
deleteClickHandler();
|
||||||
|
closePopover();
|
||||||
|
}}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='error'
|
color='none'
|
||||||
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
|
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon icon='heroicons:trash' width={20} height={20} />
|
||||||
icon='material-symbols:delete-outline-rounded'
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
className='justify-start text-sm'
|
|
||||||
/>
|
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
</RowOptionsMenuWrapper>
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const KandangsTable = () => {
|
const KandangsTable = () => {
|
||||||
|
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
state: tableFilterState,
|
state: tableFilterState,
|
||||||
updateFilter,
|
updateFilter,
|
||||||
@@ -94,21 +134,112 @@ const KandangsTable = () => {
|
|||||||
} = useTableFilter({
|
} = useTableFilter({
|
||||||
initial: {
|
initial: {
|
||||||
search: '',
|
search: '',
|
||||||
nameSort: '',
|
locationFilter: '',
|
||||||
locationSort: '',
|
picFilter: '',
|
||||||
capacitySort: '',
|
|
||||||
picSort: '',
|
|
||||||
},
|
},
|
||||||
paramMap: {
|
paramMap: {
|
||||||
page: 'page',
|
page: 'page',
|
||||||
pageSize: 'limit',
|
pageSize: 'limit',
|
||||||
nameSort: 'sort_name',
|
locationFilter: 'location_id',
|
||||||
locationSort: 'sort_location',
|
picFilter: 'pic_id',
|
||||||
capacitySort: 'sort_capacity',
|
|
||||||
picSort: ' sort_pic',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== FILTER MODAL STATE =====
|
||||||
|
const filterModal = useModal();
|
||||||
|
|
||||||
|
// ===== FORMIK SETUP =====
|
||||||
|
const formik = useFormik<KandangFilterType>({
|
||||||
|
initialValues: {
|
||||||
|
location_id: null,
|
||||||
|
pic_id: null,
|
||||||
|
},
|
||||||
|
validationSchema: KandangFilterSchema,
|
||||||
|
onSubmit: (values, { setSubmitting }) => {
|
||||||
|
updateFilter('locationFilter', values.location_id || '');
|
||||||
|
updateFilter('picFilter', values.pic_id || '');
|
||||||
|
filterModal.closeModal();
|
||||||
|
setSubmitting(false);
|
||||||
|
},
|
||||||
|
onReset: () => {
|
||||||
|
updateFilter('locationFilter', '');
|
||||||
|
updateFilter('picFilter', '');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== LOCATION OPTIONS =====
|
||||||
|
const {
|
||||||
|
setInputValue: setLocationInputValue,
|
||||||
|
options: locationOptions,
|
||||||
|
isLoadingOptions: isLoadingLocationOptions,
|
||||||
|
loadMore: loadMoreLocations,
|
||||||
|
} = useSelect<Location>(
|
||||||
|
filterModal.open ? LocationApi.basePath : null,
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'search'
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== PIC OPTIONS =====
|
||||||
|
const {
|
||||||
|
setInputValue: setPicInputValue,
|
||||||
|
options: picOptions,
|
||||||
|
isLoadingOptions: isLoadingPicOptions,
|
||||||
|
loadMore: loadMorePics,
|
||||||
|
} = useSelect<User>(
|
||||||
|
filterModal.open ? UserApi.basePath : null,
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'search'
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== FILTER HANDLERS =====
|
||||||
|
const handleFilterLocationChange = useCallback(
|
||||||
|
(val: OptionType | OptionType[] | null) => {
|
||||||
|
const location = val as OptionType | null;
|
||||||
|
const locationId = location?.value ? String(location.value) : null;
|
||||||
|
|
||||||
|
formik.setFieldValue('location_id', locationId);
|
||||||
|
},
|
||||||
|
[formik]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFilterPicChange = useCallback(
|
||||||
|
(val: OptionType | OptionType[] | null) => {
|
||||||
|
const pic = val as OptionType | null;
|
||||||
|
const picId = pic?.value ? String(pic.value) : null;
|
||||||
|
|
||||||
|
formik.setFieldValue('pic_id', picId);
|
||||||
|
},
|
||||||
|
[formik]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== FILTER HELPERS =====
|
||||||
|
const locationIdValue = useMemo(() => {
|
||||||
|
if (!formik.values.location_id) return null;
|
||||||
|
return (
|
||||||
|
locationOptions.find(
|
||||||
|
(opt) => String(opt.value) === formik.values.location_id
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
}, [formik.values.location_id, locationOptions]);
|
||||||
|
|
||||||
|
const picIdValue = useMemo(() => {
|
||||||
|
if (!formik.values.pic_id) return null;
|
||||||
|
return (
|
||||||
|
picOptions.find((opt) => String(opt.value) === formik.values.pic_id) ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}, [formik.values.pic_id, picOptions]);
|
||||||
|
|
||||||
|
// ===== HANDLE FILTER MODAL OPEN =====
|
||||||
|
const handleFilterModalOpen = () => {
|
||||||
|
filterModal.openModal();
|
||||||
|
formik.validateForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: kandangs,
|
data: kandangs,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -119,83 +250,24 @@ 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>([]);
|
useEffect(() => {
|
||||||
|
updateFilter('search', searchValue);
|
||||||
|
}, [searchValue, updateFilter]);
|
||||||
|
|
||||||
const kandangsColumns: ColumnDef<Kandang>[] = [
|
useEffect(() => {
|
||||||
{
|
setTableState('kandangs-table', pathname);
|
||||||
header: '#',
|
}, [pathname, setTableState]);
|
||||||
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 searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
|
setSearchValue(e.target.value);
|
||||||
const deleteClickHandler = () => {
|
updateFilter('search', e.target.value);
|
||||||
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 +288,150 @@ const KandangsTable = () => {
|
|||||||
setIsDeleteLoading(false);
|
setIsDeleteLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
const kandangsColumns: ColumnDef<Kandang>[] = useMemo(
|
||||||
updateFilter('search', e.target.value);
|
() => [
|
||||||
};
|
{
|
||||||
|
header: 'No',
|
||||||
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
cell: (props) =>
|
||||||
const newVal = val as OptionType;
|
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||||
|
props.row.index +
|
||||||
setPageSize(newVal.value as number);
|
1,
|
||||||
};
|
|
||||||
|
|
||||||
const updateSortingFilter = useCallback(
|
|
||||||
(
|
|
||||||
sortName: Exclude<keyof typeof tableFilterState, 'page' | 'pageSize'>,
|
|
||||||
sortFilter: ColumnSort | undefined
|
|
||||||
) => {
|
|
||||||
if (!sortFilter) {
|
|
||||||
updateFilter(sortName, '');
|
|
||||||
} else {
|
|
||||||
updateFilter(sortName, sortFilter.desc ? 'desc' : 'asc');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[updateFilter]
|
{
|
||||||
);
|
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;
|
||||||
|
|
||||||
// track sorting
|
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||||
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);
|
const deleteClickHandler = () => {
|
||||||
updateSortingFilter('locationSort', locationSortFilter);
|
setSelectedKandang(props.row.original);
|
||||||
updateSortingFilter('picSort', picSortFilter);
|
deleteModal.openModal();
|
||||||
}, [sorting, updateSortingFilter]);
|
};
|
||||||
|
|
||||||
|
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 */}
|
||||||
<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='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 Kandang
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{/* Search and Filter */}
|
||||||
|
<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='Search'
|
||||||
value={tableFilterState.search}
|
value={tableFilterState.search ?? ''}
|
||||||
onChange={searchChangeHandler}
|
onChange={searchChangeHandler}
|
||||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
startAdornment={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:magnifying-glass'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
/>
|
/>
|
||||||
</div>
|
}
|
||||||
|
className={{
|
||||||
<div className='flex flex-row justify-end'>
|
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||||
<SelectInput
|
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||||
label='Baris'
|
input:
|
||||||
options={ROWS_OPTIONS}
|
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||||
value={{
|
|
||||||
label: String(tableFilterState.pageSize),
|
|
||||||
value: tableFilterState.pageSize,
|
|
||||||
}}
|
}}
|
||||||
onChange={pageSizeChangeHandler}
|
/>
|
||||||
className={{ wrapper: 'max-w-28' }}
|
|
||||||
|
<ButtonFilter
|
||||||
|
values={tableFilterState}
|
||||||
|
excludeFields={['page', 'pageSize', 'search']}
|
||||||
|
onClick={handleFilterModalOpen}
|
||||||
|
className='px-3 py-2.5'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Table Section */}
|
||||||
|
<div className='flex flex-col mb-4'>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
) : !isResponseSuccess(kandangs) || kandangs.data?.length === 0 ? (
|
||||||
|
<div className='p-3'>
|
||||||
|
<KandangTableSkeleton
|
||||||
|
columns={kandangsColumns}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:document-text'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Table<Kandang>
|
<Table<Kandang>
|
||||||
data={isResponseSuccess(kandangs) ? kandangs?.data : []}
|
data={kandangs?.data}
|
||||||
columns={kandangsColumns}
|
columns={kandangsColumns}
|
||||||
pageSize={tableFilterState.pageSize}
|
pageSize={tableFilterState.pageSize}
|
||||||
page={isResponseSuccess(kandangs) ? kandangs?.meta?.page : 0}
|
page={kandangs?.meta?.page ?? 0}
|
||||||
totalItems={
|
totalItems={kandangs?.meta?.total_results ?? 0}
|
||||||
isResponseSuccess(kandangs) ? kandangs?.meta?.total_results : 0
|
|
||||||
}
|
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
isLoading={isLoading}
|
onPageSizeChange={setPageSize}
|
||||||
|
isLoading={false}
|
||||||
sorting={sorting}
|
sorting={sorting}
|
||||||
setSorting={setSorting}
|
setSorting={setSorting}
|
||||||
className={{
|
className={{
|
||||||
containerClassName: cn({
|
containerClassName: 'p-3 mb-0',
|
||||||
'mb-20':
|
headerColumnClassName: 'text-nowrap',
|
||||||
isResponseSuccess(kandangs) && kandangs?.data?.length === 0,
|
|
||||||
}),
|
|
||||||
tableWrapperClassName: 'overflow-x-auto min-h-full!',
|
|
||||||
tableClassName: 'font-inter w-full table-auto min-h-full!',
|
|
||||||
headerRowClassName: 'border-b border-b-gray-200',
|
|
||||||
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',
|
|
||||||
bodyColumnClassName:
|
|
||||||
'px-6 py-3 last:flex last:flex-row last:justify-end',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
@@ -340,6 +448,81 @@ const KandangsTable = () => {
|
|||||||
onClick: confirmationModalDeleteClickHandler,
|
onClick: confirmationModalDeleteClickHandler,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Filter Modal */}
|
||||||
|
<Modal
|
||||||
|
ref={filterModal.ref}
|
||||||
|
className={{
|
||||||
|
modal: 'p-0',
|
||||||
|
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
|
||||||
|
<div className='flex items-center gap-2 text-primary'>
|
||||||
|
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||||
|
<h3 className='font-medium text-sm'>Filter Data</h3>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant='link'
|
||||||
|
onClick={filterModal.closeModal}
|
||||||
|
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||||
|
<div className='p-4 flex flex-col gap-1.5'>
|
||||||
|
<SelectInput
|
||||||
|
label='Lokasi'
|
||||||
|
placeholder='Pilih Lokasi'
|
||||||
|
options={locationOptions}
|
||||||
|
value={locationIdValue}
|
||||||
|
onChange={handleFilterLocationChange}
|
||||||
|
onInputChange={setLocationInputValue}
|
||||||
|
isLoading={isLoadingLocationOptions}
|
||||||
|
isClearable
|
||||||
|
onMenuScrollToBottom={loadMoreLocations}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectInputRadio
|
||||||
|
label='PIC'
|
||||||
|
placeholder='Pilih PIC'
|
||||||
|
options={picOptions}
|
||||||
|
value={picIdValue}
|
||||||
|
onChange={handleFilterPicChange}
|
||||||
|
onInputChange={setPicInputValue}
|
||||||
|
isLoading={isLoadingPicOptions}
|
||||||
|
isClearable
|
||||||
|
onMenuScrollToBottom={loadMorePics}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='soft'
|
||||||
|
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||||
|
onClick={() => {
|
||||||
|
formik.resetForm();
|
||||||
|
filterModal.closeModal();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset Filter
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||||
|
disabled={!formik.isValid || formik.isSubmitting}
|
||||||
|
>
|
||||||
|
Apply Filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { string, object } from 'yup';
|
||||||
|
|
||||||
|
export const KandangFilterSchema = object().shape({
|
||||||
|
location_id: string().nullable(),
|
||||||
|
pic_id: string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type KandangFilterType = {
|
||||||
|
location_id: string | null;
|
||||||
|
pic_id: string | null;
|
||||||
|
};
|
||||||
@@ -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,90 +1,126 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ChangeEventHandler, useCallback, useEffect, useState } from 'react';
|
|
||||||
import useSWR from 'swr';
|
|
||||||
import {
|
import {
|
||||||
CellContext,
|
ChangeEventHandler,
|
||||||
ColumnDef,
|
useCallback,
|
||||||
ColumnSort,
|
useEffect,
|
||||||
SortingState,
|
useMemo,
|
||||||
} from '@tanstack/react-table';
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import { useFormik } from 'formik';
|
||||||
|
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import { useModal } from '@/components/Modal';
|
import Modal, { 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 SelectInput, { useSelect } from '@/components/input/SelectInput';
|
||||||
|
import { OptionType } from '@/components/input/SelectInput';
|
||||||
|
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||||
|
|
||||||
import { Location } from '@/types/api/master-data/location';
|
import { Location } from '@/types/api/master-data/location';
|
||||||
import { LocationApi } from '@/services/api/master-data';
|
import { Area } from '@/types/api/master-data/area';
|
||||||
import { cn } from '@/lib/helper';
|
import { LocationApi, AreaApi } from '@/services/api/master-data';
|
||||||
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';
|
import { useUiStore } from '@/stores/ui/ui.store';
|
||||||
|
import {
|
||||||
|
LocationFilterSchema,
|
||||||
|
LocationFilterType,
|
||||||
|
} from '@/components/pages/master-data/location/filter/LocationFilter';
|
||||||
|
|
||||||
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'>
|
||||||
|
<PopoverButton
|
||||||
|
tabIndex={0}
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
popoverTarget={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
>
|
||||||
|
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||||
|
</PopoverButton>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
id={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
|
||||||
|
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
|
||||||
|
>
|
||||||
|
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||||
<RequirePermission permissions='lti.master.locations.detail'>
|
<RequirePermission permissions='lti.master.locations.detail'>
|
||||||
<Button
|
<Button
|
||||||
href={`/master-data/location/detail/?locationId=${props.row.original.id}`}
|
href={`/master-data/location/detail/?locationId=${props.row.original.id}`}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='primary'
|
color='none'
|
||||||
className='justify-start text-sm'
|
className='p-3 justify-start text-sm font-semibold w-full'
|
||||||
|
onClick={closePopover}
|
||||||
>
|
>
|
||||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
<Icon icon='heroicons:eye' width={20} height={20} />
|
||||||
Detail
|
Detail
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
|
|
||||||
<RequirePermission permissions='lti.master.locations.update'>
|
<RequirePermission permissions='lti.master.locations.update'>
|
||||||
<Button
|
<Button
|
||||||
href={`/master-data/location/detail/edit/?locationId=${props.row.original.id}`}
|
href={`/master-data/location/detail/edit/?locationId=${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} />
|
<Icon icon='heroicons:pencil-square' width={20} height={20} />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
|
|
||||||
<RequirePermission permissions='lti.master.locations.delete'>
|
<RequirePermission permissions='lti.master.locations.delete'>
|
||||||
<Button
|
<Button
|
||||||
onClick={deleteClickHandler}
|
onClick={() => {
|
||||||
|
deleteClickHandler();
|
||||||
|
closePopover();
|
||||||
|
}}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='error'
|
color='none'
|
||||||
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
|
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon icon='heroicons:trash' width={20} height={20} />
|
||||||
icon='material-symbols:delete-outline-rounded'
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
className='justify-start text-sm'
|
|
||||||
/>
|
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
</RowOptionsMenuWrapper>
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const LocationsTable = () => {
|
const LocationsTable = () => {
|
||||||
|
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
state: tableFilterState,
|
state: tableFilterState,
|
||||||
updateFilter,
|
updateFilter,
|
||||||
@@ -92,16 +128,75 @@ const LocationsTable = () => {
|
|||||||
setPageSize,
|
setPageSize,
|
||||||
toQueryString: getTableFilterQueryString,
|
toQueryString: getTableFilterQueryString,
|
||||||
} = useTableFilter({
|
} = useTableFilter({
|
||||||
initial: { search: '', nameSort: '', addressSort: '', areaSort: '' },
|
initial: {
|
||||||
|
search: '',
|
||||||
|
areaFilter: '',
|
||||||
|
},
|
||||||
paramMap: {
|
paramMap: {
|
||||||
page: 'page',
|
page: 'page',
|
||||||
pageSize: 'limit',
|
pageSize: 'limit',
|
||||||
nameSort: 'sort_name',
|
areaFilter: 'area_id',
|
||||||
addressSort: 'sort_address',
|
|
||||||
areaSort: ' sort_area',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== FILTER MODAL STATE =====
|
||||||
|
const filterModal = useModal();
|
||||||
|
|
||||||
|
// ===== FORMIK SETUP =====
|
||||||
|
const formik = useFormik<LocationFilterType>({
|
||||||
|
initialValues: {
|
||||||
|
area_id: null,
|
||||||
|
},
|
||||||
|
validationSchema: LocationFilterSchema,
|
||||||
|
onSubmit: (values, { setSubmitting }) => {
|
||||||
|
updateFilter('areaFilter', values.area_id || '');
|
||||||
|
filterModal.closeModal();
|
||||||
|
setSubmitting(false);
|
||||||
|
},
|
||||||
|
onReset: () => {
|
||||||
|
updateFilter('areaFilter', '');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== AREA OPTIONS =====
|
||||||
|
const {
|
||||||
|
setInputValue: setAreaInputValue,
|
||||||
|
options: areaOptions,
|
||||||
|
isLoadingOptions: isLoadingAreaOptions,
|
||||||
|
loadMore: loadMoreAreas,
|
||||||
|
} = useSelect<Area>(
|
||||||
|
filterModal.open ? AreaApi.basePath : null,
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'search'
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== FILTER HANDLERS =====
|
||||||
|
const handleFilterAreaChange = useCallback(
|
||||||
|
(val: OptionType | OptionType[] | null) => {
|
||||||
|
const area = val as OptionType | null;
|
||||||
|
const areaId = area?.value ? String(area.value) : null;
|
||||||
|
|
||||||
|
formik.setFieldValue('area_id', areaId);
|
||||||
|
},
|
||||||
|
[formik]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== FILTER HELPERS =====
|
||||||
|
const areaIdValue = useMemo(() => {
|
||||||
|
if (!formik.values.area_id) return null;
|
||||||
|
return (
|
||||||
|
areaOptions.find((opt) => String(opt.value) === formik.values.area_id) ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}, [formik.values.area_id, areaOptions]);
|
||||||
|
|
||||||
|
// ===== HANDLE FILTER MODAL OPEN =====
|
||||||
|
const handleFilterModalOpen = () => {
|
||||||
|
filterModal.openModal();
|
||||||
|
formik.validateForm();
|
||||||
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: locations,
|
data: locations,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -112,77 +207,26 @@ 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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateFilter('search', searchValue);
|
||||||
|
}, [searchValue, updateFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTableState('locations-table', pathname);
|
||||||
|
}, [pathname, setTableState]);
|
||||||
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
|
||||||
const locationsColumns: ColumnDef<Location>[] = [
|
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
{
|
setSearchValue(e.target.value);
|
||||||
header: '#',
|
updateFilter('search', e.target.value);
|
||||||
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 +247,145 @@ const LocationsTable = () => {
|
|||||||
setIsDeleteLoading(false);
|
setIsDeleteLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
const locationsColumns: ColumnDef<Location>[] = useMemo(
|
||||||
updateFilter('search', e.target.value);
|
() => [
|
||||||
};
|
{
|
||||||
|
header: 'No',
|
||||||
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
cell: (props) =>
|
||||||
const newVal = val as OptionType;
|
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||||
|
props.row.index +
|
||||||
setPageSize(newVal.value as number);
|
1,
|
||||||
};
|
|
||||||
|
|
||||||
const updateSortingFilter = useCallback(
|
|
||||||
(
|
|
||||||
sortName: Exclude<keyof typeof tableFilterState, 'page' | 'pageSize'>,
|
|
||||||
sortFilter: ColumnSort | undefined
|
|
||||||
) => {
|
|
||||||
if (!sortFilter) {
|
|
||||||
updateFilter(sortName, '');
|
|
||||||
} else {
|
|
||||||
updateFilter(sortName, sortFilter.desc ? 'desc' : 'asc');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[updateFilter]
|
{
|
||||||
);
|
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;
|
||||||
|
|
||||||
// track sorting
|
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||||
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);
|
const deleteClickHandler = () => {
|
||||||
updateSortingFilter('addressSort', addressSortFilter);
|
setSelectedLocation(props.row.original);
|
||||||
updateSortingFilter('areaSort', areaSortFilter);
|
deleteModal.openModal();
|
||||||
}, [sorting, updateSortingFilter]);
|
};
|
||||||
|
|
||||||
|
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 */}
|
||||||
<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='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 Location
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{/* Search and Filter */}
|
||||||
|
<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='Search'
|
||||||
value={tableFilterState.search}
|
value={tableFilterState.search ?? ''}
|
||||||
onChange={searchChangeHandler}
|
onChange={searchChangeHandler}
|
||||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
startAdornment={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:magnifying-glass'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
/>
|
/>
|
||||||
</div>
|
}
|
||||||
|
className={{
|
||||||
<div className='flex flex-row justify-end'>
|
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||||
<SelectInput
|
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||||
label='Baris'
|
input:
|
||||||
options={ROWS_OPTIONS}
|
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||||
value={{
|
|
||||||
label: String(tableFilterState.pageSize),
|
|
||||||
value: tableFilterState.pageSize,
|
|
||||||
}}
|
}}
|
||||||
onChange={pageSizeChangeHandler}
|
/>
|
||||||
className={{ wrapper: 'max-w-28' }}
|
|
||||||
|
<ButtonFilter
|
||||||
|
values={tableFilterState}
|
||||||
|
excludeFields={['page', 'pageSize', 'search']}
|
||||||
|
onClick={handleFilterModalOpen}
|
||||||
|
className='px-3 py-2.5'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Table Section */}
|
||||||
|
<div className='flex flex-col mb-4'>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
) : !isResponseSuccess(locations) || locations.data?.length === 0 ? (
|
||||||
|
<div className='p-3'>
|
||||||
|
<LocationTableSkeleton
|
||||||
|
columns={locationsColumns}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:document-text'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Table<Location>
|
<Table<Location>
|
||||||
data={isResponseSuccess(locations) ? locations?.data : []}
|
data={locations?.data}
|
||||||
columns={locationsColumns}
|
columns={locationsColumns}
|
||||||
pageSize={tableFilterState.pageSize}
|
pageSize={tableFilterState.pageSize}
|
||||||
page={isResponseSuccess(locations) ? locations?.meta?.page : 0}
|
page={locations?.meta?.page ?? 0}
|
||||||
totalItems={
|
totalItems={locations?.meta?.total_results ?? 0}
|
||||||
isResponseSuccess(locations) ? locations?.meta?.total_results : 0
|
|
||||||
}
|
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
isLoading={isLoading}
|
onPageSizeChange={setPageSize}
|
||||||
|
isLoading={false}
|
||||||
sorting={sorting}
|
sorting={sorting}
|
||||||
setSorting={setSorting}
|
setSorting={setSorting}
|
||||||
className={{
|
className={{
|
||||||
containerClassName: cn({
|
containerClassName: 'p-3 mb-0',
|
||||||
'mb-20':
|
headerColumnClassName: 'text-nowrap',
|
||||||
isResponseSuccess(locations) && locations?.data?.length === 0,
|
|
||||||
}),
|
|
||||||
tableWrapperClassName: 'overflow-x-auto min-h-full!',
|
|
||||||
tableClassName: 'font-inter w-full table-auto min-h-full!',
|
|
||||||
headerRowClassName: 'border-b border-b-gray-200',
|
|
||||||
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',
|
|
||||||
bodyColumnClassName:
|
|
||||||
'px-6 py-3 last:flex last:flex-row last:justify-end',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
@@ -327,6 +402,68 @@ const LocationsTable = () => {
|
|||||||
onClick: confirmationModalDeleteClickHandler,
|
onClick: confirmationModalDeleteClickHandler,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Filter Modal */}
|
||||||
|
<Modal
|
||||||
|
ref={filterModal.ref}
|
||||||
|
className={{
|
||||||
|
modal: 'p-0',
|
||||||
|
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
|
||||||
|
<div className='flex items-center gap-2 text-primary'>
|
||||||
|
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||||
|
<h3 className='font-medium text-sm'>Filter Data</h3>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant='link'
|
||||||
|
onClick={filterModal.closeModal}
|
||||||
|
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||||
|
<div className='p-4 flex flex-col gap-1.5'>
|
||||||
|
<SelectInput
|
||||||
|
label='Area'
|
||||||
|
placeholder='Pilih Area'
|
||||||
|
options={areaOptions}
|
||||||
|
value={areaIdValue}
|
||||||
|
onChange={handleFilterAreaChange}
|
||||||
|
onInputChange={setAreaInputValue}
|
||||||
|
isLoading={isLoadingAreaOptions}
|
||||||
|
isClearable
|
||||||
|
onMenuScrollToBottom={loadMoreAreas}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='soft'
|
||||||
|
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||||
|
onClick={() => {
|
||||||
|
formik.resetForm();
|
||||||
|
filterModal.closeModal();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset Filter
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||||
|
disabled={!formik.isValid || formik.isSubmitting}
|
||||||
|
>
|
||||||
|
Apply Filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { string, object } from 'yup';
|
||||||
|
|
||||||
|
export const LocationFilterSchema = object().shape({
|
||||||
|
area_id: string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type LocationFilterType = {
|
||||||
|
area_id: string | null;
|
||||||
|
};
|
||||||
@@ -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, useEffect, 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,75 +11,101 @@ 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';
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useUiStore } from '@/stores/ui/ui.store';
|
||||||
|
|
||||||
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'>
|
||||||
|
<PopoverButton
|
||||||
|
tabIndex={0}
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
popoverTarget={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
>
|
||||||
|
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||||
|
</PopoverButton>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
id={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
|
||||||
|
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
|
||||||
|
>
|
||||||
|
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||||
<RequirePermission permissions='lti.master.nonstocks.detail'>
|
<RequirePermission permissions='lti.master.nonstocks.detail'>
|
||||||
<Button
|
<Button
|
||||||
href={`/master-data/nonstock/detail/?nonstockId=${props.row.original.id}`}
|
href={`/master-data/nonstock/detail/?nonstockId=${props.row.original.id}`}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='primary'
|
color='none'
|
||||||
className='justify-start text-sm'
|
className='p-3 justify-start text-sm font-semibold w-full'
|
||||||
|
onClick={closePopover}
|
||||||
>
|
>
|
||||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
<Icon icon='heroicons:eye' width={20} height={20} />
|
||||||
Detail
|
Detail
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
|
|
||||||
<RequirePermission permissions='lti.master.nonstocks.update'>
|
<RequirePermission permissions='lti.master.nonstocks.update'>
|
||||||
<Button
|
<Button
|
||||||
href={`/master-data/nonstock/detail/edit/?nonstockId=${props.row.original.id}`}
|
href={`/master-data/nonstock/detail/edit/?nonstockId=${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} />
|
<Icon icon='heroicons:pencil-square' width={20} height={20} />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
|
|
||||||
<RequirePermission permissions='lti.master.nonstocks.delete'>
|
<RequirePermission permissions='lti.master.nonstocks.delete'>
|
||||||
<Button
|
<Button
|
||||||
onClick={deleteClickHandler}
|
onClick={() => {
|
||||||
|
deleteClickHandler();
|
||||||
|
closePopover();
|
||||||
|
}}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='error'
|
color='none'
|
||||||
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
|
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon icon='heroicons:trash' width={20} height={20} />
|
||||||
icon='material-symbols:delete-outline-rounded'
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
className='justify-start text-sm'
|
|
||||||
/>
|
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
</RowOptionsMenuWrapper>
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const NonstocksTable = () => {
|
const NonstocksTable = () => {
|
||||||
|
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
state: tableFilterState,
|
state: tableFilterState,
|
||||||
updateFilter,
|
updateFilter,
|
||||||
@@ -92,16 +113,25 @@ const NonstocksTable = () => {
|
|||||||
setPageSize,
|
setPageSize,
|
||||||
toQueryString: getTableFilterQueryString,
|
toQueryString: getTableFilterQueryString,
|
||||||
} = useTableFilter({
|
} = useTableFilter({
|
||||||
initial: { search: '', nameSort: '', locationSort: '', picSort: '' },
|
initial: {
|
||||||
|
search: searchValue,
|
||||||
|
},
|
||||||
paramMap: {
|
paramMap: {
|
||||||
page: 'page',
|
page: 'page',
|
||||||
pageSize: 'limit',
|
pageSize: 'limit',
|
||||||
nameSort: 'sort_name',
|
|
||||||
locationSort: 'sort_location',
|
|
||||||
picSort: ' sort_pic',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateFilter('search', searchValue);
|
||||||
|
}, [searchValue, updateFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTableState('nonstocks-table', pathname);
|
||||||
|
}, [pathname, setTableState]);
|
||||||
|
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: nonstocks,
|
data: nonstocks,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -112,89 +142,16 @@ 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) => {
|
||||||
|
setSearchValue(e.target.value);
|
||||||
const nonstocksColumns: ColumnDef<Nonstock>[] = [
|
updateFilter('search', e.target.value);
|
||||||
{
|
|
||||||
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 +172,143 @@ const NonstocksTable = () => {
|
|||||||
setIsDeleteLoading(false);
|
setIsDeleteLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
const nonstocksColumns: ColumnDef<Nonstock>[] = useMemo(
|
||||||
updateFilter('search', e.target.value);
|
() => [
|
||||||
};
|
{
|
||||||
|
header: 'No',
|
||||||
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
cell: (props) =>
|
||||||
const newVal = val as OptionType;
|
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||||
|
props.row.index +
|
||||||
setPageSize(newVal.value as number);
|
1,
|
||||||
};
|
|
||||||
|
|
||||||
const updateSortingFilter = useCallback(
|
|
||||||
(
|
|
||||||
sortName: Exclude<keyof typeof tableFilterState, 'page' | 'pageSize'>,
|
|
||||||
sortFilter: ColumnSort | undefined
|
|
||||||
) => {
|
|
||||||
if (!sortFilter) {
|
|
||||||
updateFilter(sortName, '');
|
|
||||||
} else {
|
|
||||||
updateFilter(sortName, sortFilter.desc ? 'desc' : 'asc');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[updateFilter]
|
{
|
||||||
);
|
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;
|
||||||
|
|
||||||
// track sorting
|
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||||
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);
|
const deleteClickHandler = () => {
|
||||||
updateSortingFilter('locationSort', locationSortFilter);
|
setSelectedNonstock(props.row.original);
|
||||||
updateSortingFilter('picSort', picSortFilter);
|
deleteModal.openModal();
|
||||||
}, [sorting]);
|
};
|
||||||
|
|
||||||
|
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 */}
|
||||||
|
<div className='w-fit flex flex-row gap-3 flex-wrap'>
|
||||||
<RequirePermission permissions='lti.master.nonstocks.create'>
|
<RequirePermission permissions='lti.master.nonstocks.create'>
|
||||||
<Button
|
<Button
|
||||||
href='/master-data/nonstock/add'
|
href='/master-data/nonstock/add'
|
||||||
variant='outline'
|
|
||||||
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='Search'
|
||||||
value={tableFilterState.search}
|
value={tableFilterState.search ?? ''}
|
||||||
onChange={searchChangeHandler}
|
onChange={searchChangeHandler}
|
||||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
startAdornment={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:magnifying-glass'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
/>
|
/>
|
||||||
</div>
|
}
|
||||||
|
className={{
|
||||||
<div className='flex flex-row justify-end'>
|
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||||
<SelectInput
|
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||||
label='Baris'
|
input:
|
||||||
options={ROWS_OPTIONS}
|
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||||
value={{
|
|
||||||
label: String(tableFilterState.pageSize),
|
|
||||||
value: tableFilterState.pageSize,
|
|
||||||
}}
|
}}
|
||||||
onChange={pageSizeChangeHandler}
|
|
||||||
className={{ wrapper: 'max-w-28' }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Table Section */}
|
||||||
|
<div className='flex flex-col mb-4'>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
) : !isResponseSuccess(nonstocks) || nonstocks.data?.length === 0 ? (
|
||||||
|
<div className='p-3'>
|
||||||
|
<NonstockTableSkeleton
|
||||||
|
columns={nonstocksColumns}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:document-text'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Table<Nonstock>
|
<Table<Nonstock>
|
||||||
data={isResponseSuccess(nonstocks) ? nonstocks?.data : []}
|
data={nonstocks?.data}
|
||||||
columns={nonstocksColumns}
|
columns={nonstocksColumns}
|
||||||
pageSize={tableFilterState.pageSize}
|
pageSize={tableFilterState.pageSize}
|
||||||
page={isResponseSuccess(nonstocks) ? nonstocks?.meta?.page : 0}
|
page={nonstocks?.meta?.page ?? 0}
|
||||||
totalItems={
|
totalItems={nonstocks?.meta?.total_results ?? 0}
|
||||||
isResponseSuccess(nonstocks) ? nonstocks?.meta?.total_results : 0
|
|
||||||
}
|
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
isLoading={isLoading}
|
onPageSizeChange={setPageSize}
|
||||||
|
isLoading={false}
|
||||||
sorting={sorting}
|
sorting={sorting}
|
||||||
setSorting={setSorting}
|
setSorting={setSorting}
|
||||||
className={{
|
className={{
|
||||||
containerClassName: cn({
|
containerClassName: 'p-3 mb-0',
|
||||||
'mb-20':
|
headerColumnClassName: 'text-nowrap',
|
||||||
isResponseSuccess(nonstocks) && nonstocks?.data?.length === 0,
|
|
||||||
}),
|
|
||||||
tableWrapperClassName: 'overflow-x-auto min-h-full!',
|
|
||||||
tableClassName: 'font-inter w-full table-auto min-h-full!',
|
|
||||||
headerRowClassName: 'border-b border-b-gray-200',
|
|
||||||
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',
|
|
||||||
bodyColumnClassName:
|
|
||||||
'px-6 py-3 last:flex last:flex-row last:justify-end',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</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,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ChangeEventHandler, useEffect, useRef, useState } from 'react';
|
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
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,78 +12,99 @@ 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'>
|
||||||
|
<PopoverButton
|
||||||
|
tabIndex={0}
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
popoverTarget={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
>
|
||||||
|
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||||
|
</PopoverButton>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
id={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
|
||||||
|
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
|
||||||
|
>
|
||||||
|
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||||
<RequirePermission permissions='lti.master.product_categories.detail'>
|
<RequirePermission permissions='lti.master.product_categories.detail'>
|
||||||
<Button
|
<Button
|
||||||
href={`/master-data/product-category/detail/?productCategoryId=${props.row.original.id}`}
|
href={`/master-data/product-category/detail/?productCategoryId=${props.row.original.id}`}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='primary'
|
color='none'
|
||||||
className='justify-start text-sm'
|
className='p-3 justify-start text-sm font-semibold w-full'
|
||||||
|
onClick={closePopover}
|
||||||
>
|
>
|
||||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
<Icon icon='heroicons:eye' width={20} height={20} />
|
||||||
Detail
|
Detail
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
|
|
||||||
<RequirePermission permissions='lti.master.product_categories.update'>
|
<RequirePermission permissions='lti.master.product_categories.update'>
|
||||||
<Button
|
<Button
|
||||||
href={`/master-data/product-category/detail/edit/?productCategoryId=${props.row.original.id}`}
|
href={`/master-data/product-category/detail/edit/?productCategoryId=${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='mdi:pencil-outline' width={16} height={16} />
|
<Icon icon='heroicons:pencil-square' width={20} height={20} />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
|
|
||||||
<RequirePermission permissions='lti.master.product_categories.delete'>
|
<RequirePermission permissions='lti.master.product_categories.delete'>
|
||||||
<Button
|
<Button
|
||||||
onClick={deleteClickHandler}
|
onClick={() => {
|
||||||
|
deleteClickHandler();
|
||||||
|
closePopover();
|
||||||
|
}}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='error'
|
color='none'
|
||||||
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
|
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon icon='heroicons:trash' width={20} height={20} />
|
||||||
icon='mdi:delete-outline'
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
className='justify-start text-sm'
|
|
||||||
/>
|
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
</RowOptionsMenuWrapper>
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ProductCategoryTable = () => {
|
const ProductCategoryTable = () => {
|
||||||
const { searchValue, setSearchValue, resetSearchValue } = useUiStore();
|
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||||
const previousPathRef = useRef<string | null>(null);
|
const pathname = usePathname();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
state: tableFilterState,
|
state: tableFilterState,
|
||||||
@@ -91,10 +113,21 @@ const ProductCategoryTable = () => {
|
|||||||
setPageSize,
|
setPageSize,
|
||||||
toQueryString: getTableFilterQueryString,
|
toQueryString: getTableFilterQueryString,
|
||||||
} = useTableFilter({
|
} = useTableFilter({
|
||||||
initial: { search: searchValue, nameSort: '' },
|
initial: {
|
||||||
paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' },
|
search: '',
|
||||||
|
},
|
||||||
|
paramMap: {
|
||||||
|
page: 'page',
|
||||||
|
pageSize: 'limit',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateFilter('search', searchValue);
|
||||||
|
}, [searchValue, updateFilter]);
|
||||||
|
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: productCategories,
|
data: productCategories,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -105,72 +138,16 @@ 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,123 +168,141 @@ 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 isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||||
|
|
||||||
|
const deleteClickHandler = () => {
|
||||||
|
setSelectedProductCategory(props.row.original);
|
||||||
|
deleteModal.openModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
return (
|
||||||
const newVal = val as OptionType;
|
<RowOptionsMenu
|
||||||
setPageSize(newVal.value as number);
|
props={props}
|
||||||
};
|
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
|
||||||
|
deleteClickHandler={deleteClickHandler}
|
||||||
useEffect(() => {
|
/>
|
||||||
// Store current path on mount
|
|
||||||
previousPathRef.current = window.location.pathname;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
const currentPath = window.location.pathname;
|
|
||||||
|
|
||||||
// if both paths are within /master-data/product-category module
|
|
||||||
const isCurrentPathProductCategory = currentPath.includes(
|
|
||||||
'/master-data/product-category'
|
|
||||||
);
|
);
|
||||||
const isPreviousPathProductCategory = previousPathRef.current?.includes(
|
},
|
||||||
'/master-data/product-category'
|
},
|
||||||
|
],
|
||||||
|
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
|
||||||
);
|
);
|
||||||
|
|
||||||
// reset if we outside product category module entirely
|
|
||||||
if (isPreviousPathProductCategory && !isCurrentPathProductCategory) {
|
|
||||||
resetSearchValue();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [resetSearchValue]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
|
setTableState('product-category-table', pathname);
|
||||||
if (!isNameSorted) {
|
}, [pathname, setTableState]);
|
||||||
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 */}
|
||||||
|
<div className='w-fit flex flex-row gap-3 flex-wrap'>
|
||||||
<RequirePermission permissions='lti.master.product_categories.create'>
|
<RequirePermission permissions='lti.master.product_categories.create'>
|
||||||
<Button
|
<Button
|
||||||
href='/master-data/product-category/add'
|
href='/master-data/product-category/add'
|
||||||
variant='outline'
|
|
||||||
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='Search'
|
||||||
value={tableFilterState.search}
|
value={tableFilterState.search ?? ''}
|
||||||
onChange={searchChangeHandler}
|
onChange={searchChangeHandler}
|
||||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
startAdornment={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:magnifying-glass'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div className='flex flex-row justify-end'>
|
|
||||||
<SelectInput
|
|
||||||
label='Baris'
|
|
||||||
options={ROWS_OPTIONS}
|
|
||||||
value={{
|
|
||||||
label: String(tableFilterState.pageSize),
|
|
||||||
value: tableFilterState.pageSize,
|
|
||||||
}}
|
|
||||||
onChange={pageSizeChangeHandler}
|
|
||||||
className={{ wrapper: 'max-w-28' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Table<ProductCategory>
|
|
||||||
data={
|
|
||||||
isResponseSuccess(productCategories) ? productCategories?.data : []
|
|
||||||
}
|
}
|
||||||
|
className={{
|
||||||
|
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||||
|
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||||
|
input:
|
||||||
|
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table Section */}
|
||||||
|
<div className='flex flex-col mb-4'>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
) : !isResponseSuccess(productCategories) ||
|
||||||
|
productCategories.data?.length === 0 ? (
|
||||||
|
<div className='p-3'>
|
||||||
|
<ProductCategoryTableSkeleton
|
||||||
|
columns={productCategoryColumns}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:document-text'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table<ProductCategory>
|
||||||
|
data={productCategories?.data}
|
||||||
columns={productCategoryColumns}
|
columns={productCategoryColumns}
|
||||||
pageSize={tableFilterState.pageSize}
|
pageSize={tableFilterState.pageSize}
|
||||||
page={
|
page={productCategories?.meta?.page ?? 0}
|
||||||
isResponseSuccess(productCategories)
|
totalItems={productCategories?.meta?.total_results ?? 0}
|
||||||
? productCategories?.meta?.page
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
totalItems={
|
|
||||||
isResponseSuccess(productCategories)
|
|
||||||
? productCategories?.meta?.total_results
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
isLoading={isLoading}
|
onPageSizeChange={setPageSize}
|
||||||
|
isLoading={false}
|
||||||
sorting={sorting}
|
sorting={sorting}
|
||||||
setSorting={setSorting}
|
setSorting={setSorting}
|
||||||
className={{
|
className={{
|
||||||
containerClassName: cn({
|
containerClassName: 'p-3 mb-0',
|
||||||
'mb-20':
|
headerColumnClassName: 'text-nowrap',
|
||||||
isResponseSuccess(productCategories) &&
|
|
||||||
productCategories?.data?.length === 0,
|
|
||||||
}),
|
|
||||||
tableWrapperClassName: 'overflow-x-auto min-h-full!',
|
|
||||||
tableClassName: 'font-inter w-full table-auto min-h-full!',
|
|
||||||
headerRowClassName: 'border-b border-b-gray-200',
|
|
||||||
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',
|
|
||||||
bodyColumnClassName:
|
|
||||||
'px-6 py-3 last:flex last:flex-row last:justify-end',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
ref={deleteModal.ref}
|
ref={deleteModal.ref}
|
||||||
type='error'
|
type='error'
|
||||||
|
|||||||
+37
@@ -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,52 +1,88 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ChangeEventHandler, useCallback, useEffect, useState } from 'react';
|
|
||||||
import useSWR from 'swr';
|
|
||||||
import {
|
import {
|
||||||
CellContext,
|
ChangeEventHandler,
|
||||||
ColumnDef,
|
useCallback,
|
||||||
ColumnSort,
|
useEffect,
|
||||||
SortingState,
|
useMemo,
|
||||||
} from '@tanstack/react-table';
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import { useFormik } from 'formik';
|
||||||
|
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import { useModal } from '@/components/Modal';
|
import Modal, { 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 SelectInput, { useSelect } from '@/components/input/SelectInput';
|
||||||
|
import { OptionType } from '@/components/input/SelectInput';
|
||||||
|
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||||
|
|
||||||
import { Product } from '@/types/api/master-data/product';
|
import { Product } from '@/types/api/master-data/product';
|
||||||
import { ProductApi } from '@/services/api/master-data';
|
import { ProductCategory } from '@/types/api/master-data/product-category';
|
||||||
import { cn } from '@/lib/helper';
|
import { ProductApi, ProductCategoryApi } from '@/services/api/master-data';
|
||||||
|
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';
|
import { useUiStore } from '@/stores/ui/ui.store';
|
||||||
|
import {
|
||||||
|
ProductFilterSchema,
|
||||||
|
ProductFilterType,
|
||||||
|
} from '@/components/pages/master-data/product/filter/ProductFilter';
|
||||||
|
|
||||||
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}`;
|
||||||
|
const popoverAnchorName = `--anchor-product#${props.row.original.id}`;
|
||||||
|
|
||||||
|
const closePopover = () => {
|
||||||
|
document.getElementById(popoverId)?.hidePopover();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='relative'>
|
||||||
|
<PopoverButton
|
||||||
|
tabIndex={0}
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
popoverTarget={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
>
|
||||||
|
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||||
|
</PopoverButton>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
id={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
|
||||||
|
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
|
||||||
|
>
|
||||||
|
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||||
<RequirePermission permissions='lti.master.products.detail'>
|
<RequirePermission permissions='lti.master.products.detail'>
|
||||||
<Button
|
<Button
|
||||||
href={`/master-data/product/detail/?productId=${props.row.original.id}`}
|
href={`/master-data/product/detail/?productId=${props.row.original.id}`}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='primary'
|
color='none'
|
||||||
className='justify-start text-sm'
|
className='p-3 justify-start text-sm font-semibold w-full'
|
||||||
|
onClick={closePopover}
|
||||||
>
|
>
|
||||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
<Icon icon='heroicons:eye' width={20} height={20} />
|
||||||
Detail
|
Detail
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
@@ -54,33 +90,38 @@ const RowOptionsMenu = ({
|
|||||||
<Button
|
<Button
|
||||||
href={`/master-data/product/detail/edit/?productId=${props.row.original.id}`}
|
href={`/master-data/product/detail/edit/?productId=${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} />
|
<Icon icon='heroicons:pencil-square' width={20} height={20} />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
<RequirePermission permissions='lti.master.products.delete'>
|
<RequirePermission permissions='lti.master.products.delete'>
|
||||||
<Button
|
<Button
|
||||||
onClick={deleteClickHandler}
|
onClick={() => {
|
||||||
|
deleteClickHandler();
|
||||||
|
closePopover();
|
||||||
|
}}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='error'
|
color='none'
|
||||||
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
|
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon icon='heroicons:trash' width={20} height={20} />
|
||||||
icon='material-symbols:delete-outline-rounded'
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
className='justify-start text-sm'
|
|
||||||
/>
|
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
</RowOptionsMenuWrapper>
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const ProductsTable = () => {
|
const ProductsTable = () => {
|
||||||
|
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
state: tableFilterState,
|
state: tableFilterState,
|
||||||
updateFilter,
|
updateFilter,
|
||||||
@@ -90,21 +131,80 @@ const ProductsTable = () => {
|
|||||||
} = useTableFilter({
|
} = useTableFilter({
|
||||||
initial: {
|
initial: {
|
||||||
search: '',
|
search: '',
|
||||||
nameSort: '',
|
productCategoryFilter: '',
|
||||||
skuSort: '',
|
|
||||||
brandSort: '',
|
|
||||||
categorySort: '',
|
|
||||||
},
|
},
|
||||||
paramMap: {
|
paramMap: {
|
||||||
page: 'page',
|
page: 'page',
|
||||||
pageSize: 'limit',
|
pageSize: 'limit',
|
||||||
nameSort: 'sort_name',
|
productCategoryFilter: 'product_category_id',
|
||||||
skuSort: 'sort_sku',
|
|
||||||
brandSort: 'sort_brand',
|
|
||||||
categorySort: 'sort_category',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== FILTER MODAL STATE =====
|
||||||
|
const filterModal = useModal();
|
||||||
|
|
||||||
|
// ===== FORMIK SETUP =====
|
||||||
|
const formik = useFormik<ProductFilterType>({
|
||||||
|
initialValues: {
|
||||||
|
product_category_id: null,
|
||||||
|
},
|
||||||
|
validationSchema: ProductFilterSchema,
|
||||||
|
onSubmit: (values, { setSubmitting }) => {
|
||||||
|
updateFilter('productCategoryFilter', values.product_category_id || '');
|
||||||
|
filterModal.closeModal();
|
||||||
|
setSubmitting(false);
|
||||||
|
},
|
||||||
|
onReset: () => {
|
||||||
|
updateFilter('productCategoryFilter', '');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== PRODUCT CATEGORY OPTIONS =====
|
||||||
|
const {
|
||||||
|
setInputValue: setProductCategoryInputValue,
|
||||||
|
options: productCategoryOptions,
|
||||||
|
isLoadingOptions: isLoadingProductCategoryOptions,
|
||||||
|
loadMore: loadMoreProductCategories,
|
||||||
|
} = useSelect<ProductCategory>(
|
||||||
|
filterModal.open ? ProductCategoryApi.basePath : null,
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'search'
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== FILTER HANDLERS =====
|
||||||
|
const handleFilterProductCategoryChange = useCallback(
|
||||||
|
(val: OptionType | OptionType[] | null) => {
|
||||||
|
const category = val as OptionType | null;
|
||||||
|
const categoryId = category?.value ? String(category.value) : null;
|
||||||
|
|
||||||
|
formik.setFieldValue('product_category_id', categoryId);
|
||||||
|
},
|
||||||
|
[formik]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== FILTER HELPERS =====
|
||||||
|
const productCategoryIdValue = useMemo(() => {
|
||||||
|
if (!formik.values.product_category_id) return null;
|
||||||
|
return (
|
||||||
|
productCategoryOptions.find(
|
||||||
|
(opt) => String(opt.value) === formik.values.product_category_id
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
}, [formik.values.product_category_id, productCategoryOptions]);
|
||||||
|
|
||||||
|
// ===== HANDLE FILTER MODAL OPEN =====
|
||||||
|
const handleFilterModalOpen = () => {
|
||||||
|
filterModal.openModal();
|
||||||
|
formik.validateForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateFilter('search', searchValue);
|
||||||
|
}, [searchValue, updateFilter]);
|
||||||
|
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: products,
|
data: products,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -119,115 +219,16 @@ const ProductsTable = () => {
|
|||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
|
||||||
|
|
||||||
const productsColumns: ColumnDef<Product>[] = [
|
useEffect(() => {
|
||||||
{
|
setTableState('product-table', pathname);
|
||||||
header: '#',
|
}, [pathname, setTableState]);
|
||||||
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 searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
|
setSearchValue(e.target.value);
|
||||||
const deleteClickHandler = () => {
|
updateFilter('search', e.target.value);
|
||||||
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 +249,197 @@ const ProductsTable = () => {
|
|||||||
setIsDeleteLoading(false);
|
setIsDeleteLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
const productsColumns: ColumnDef<Product>[] = useMemo(
|
||||||
updateFilter('search', e.target.value);
|
() => [
|
||||||
};
|
{
|
||||||
|
header: 'No',
|
||||||
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
cell: (props) =>
|
||||||
const newVal = val as OptionType;
|
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||||
setPageSize(newVal.value as number);
|
props.row.index +
|
||||||
};
|
1,
|
||||||
|
|
||||||
const updateSortingFilter = useCallback(
|
|
||||||
(
|
|
||||||
sortName: Exclude<keyof typeof tableFilterState, 'page' | 'pageSize'>,
|
|
||||||
sortFilter: ColumnSort | undefined
|
|
||||||
) => {
|
|
||||||
if (!sortFilter) {
|
|
||||||
updateFilter(sortName, '');
|
|
||||||
} else {
|
|
||||||
updateFilter(sortName, sortFilter.desc ? 'desc' : 'asc');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[updateFilter]
|
{
|
||||||
);
|
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;
|
||||||
|
|
||||||
useEffect(() => {
|
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||||
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);
|
const deleteClickHandler = () => {
|
||||||
updateSortingFilter('skuSort', skuSortFilter);
|
setSelectedProduct(props.row.original);
|
||||||
updateSortingFilter('brandSort', brandSortFilter);
|
deleteModal.openModal();
|
||||||
updateSortingFilter('categorySort', categorySortFilter);
|
};
|
||||||
}, [sorting, updateSortingFilter]);
|
|
||||||
|
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 */}
|
||||||
|
<div className='w-fit flex flex-row gap-3 flex-wrap'>
|
||||||
<RequirePermission permissions='lti.master.products.create'>
|
<RequirePermission permissions='lti.master.products.create'>
|
||||||
<Button
|
<Button
|
||||||
href='/master-data/product/add'
|
href='/master-data/product/add'
|
||||||
variant='outline'
|
|
||||||
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 and Filter */}
|
||||||
|
<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='Search'
|
||||||
value={tableFilterState.search}
|
value={tableFilterState.search ?? ''}
|
||||||
onChange={searchChangeHandler}
|
onChange={searchChangeHandler}
|
||||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
startAdornment={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:magnifying-glass'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
/>
|
/>
|
||||||
</div>
|
}
|
||||||
<div className='flex flex-row justify-end'>
|
className={{
|
||||||
<SelectInput
|
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||||
label='Baris'
|
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||||
options={ROWS_OPTIONS}
|
input:
|
||||||
value={{
|
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||||
label: String(tableFilterState.pageSize),
|
|
||||||
value: tableFilterState.pageSize,
|
|
||||||
}}
|
}}
|
||||||
onChange={pageSizeChangeHandler}
|
/>
|
||||||
className={{ wrapper: 'max-w-28' }}
|
|
||||||
|
<ButtonFilter
|
||||||
|
values={tableFilterState}
|
||||||
|
excludeFields={['page', 'pageSize', 'search']}
|
||||||
|
onClick={handleFilterModalOpen}
|
||||||
|
className='px-3 py-2.5'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Table Section */}
|
||||||
|
<div className='flex flex-col mb-4'>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
) : !isResponseSuccess(products) || products.data?.length === 0 ? (
|
||||||
|
<div className='p-3'>
|
||||||
|
<ProductTableSkeleton
|
||||||
|
columns={productsColumns}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:document-text'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Table<Product>
|
<Table<Product>
|
||||||
data={isResponseSuccess(products) ? products?.data : []}
|
data={products?.data}
|
||||||
columns={productsColumns}
|
columns={productsColumns}
|
||||||
pageSize={tableFilterState.pageSize}
|
pageSize={tableFilterState.pageSize}
|
||||||
page={isResponseSuccess(products) ? products?.meta?.page : 0}
|
page={products?.meta?.page ?? 0}
|
||||||
totalItems={
|
totalItems={products?.meta?.total_results ?? 0}
|
||||||
isResponseSuccess(products) ? products?.meta?.total_results : 0
|
|
||||||
}
|
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
isLoading={isLoading}
|
onPageSizeChange={setPageSize}
|
||||||
|
isLoading={false}
|
||||||
sorting={sorting}
|
sorting={sorting}
|
||||||
setSorting={setSorting}
|
setSorting={setSorting}
|
||||||
className={{
|
className={{
|
||||||
containerClassName: cn({
|
containerClassName: 'p-3 mb-0',
|
||||||
'mb-20':
|
headerColumnClassName: 'text-nowrap',
|
||||||
isResponseSuccess(products) && products?.data?.length === 0,
|
|
||||||
}),
|
|
||||||
tableWrapperClassName: 'overflow-x-auto min-h-full!',
|
|
||||||
tableClassName: 'font-inter w-full table-auto min-h-full!',
|
|
||||||
headerRowClassName: 'border-b border-b-gray-200',
|
|
||||||
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',
|
|
||||||
bodyColumnClassName:
|
|
||||||
'px-6 py-3 last:flex last:flex-row last:justify-end',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
ref={deleteModal.ref}
|
ref={deleteModal.ref}
|
||||||
type='error'
|
type='error'
|
||||||
@@ -366,6 +454,68 @@ const ProductsTable = () => {
|
|||||||
onClick: confirmationModalDeleteClickHandler,
|
onClick: confirmationModalDeleteClickHandler,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Filter Modal */}
|
||||||
|
<Modal
|
||||||
|
ref={filterModal.ref}
|
||||||
|
className={{
|
||||||
|
modal: 'p-0',
|
||||||
|
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
|
||||||
|
<div className='flex items-center gap-2 text-primary'>
|
||||||
|
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||||
|
<h3 className='font-medium text-sm'>Filter Data</h3>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant='link'
|
||||||
|
onClick={filterModal.closeModal}
|
||||||
|
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||||
|
<div className='p-4 flex flex-col gap-1.5'>
|
||||||
|
<SelectInput
|
||||||
|
label='Kategori Produk'
|
||||||
|
placeholder='Pilih Kategori Produk'
|
||||||
|
options={productCategoryOptions}
|
||||||
|
value={productCategoryIdValue}
|
||||||
|
onChange={handleFilterProductCategoryChange}
|
||||||
|
onInputChange={setProductCategoryInputValue}
|
||||||
|
isLoading={isLoadingProductCategoryOptions}
|
||||||
|
isClearable
|
||||||
|
onMenuScrollToBottom={loadMoreProductCategories}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='soft'
|
||||||
|
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||||
|
onClick={() => {
|
||||||
|
formik.resetForm();
|
||||||
|
filterModal.closeModal();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset Filter
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||||
|
disabled={!formik.isValid || formik.isSubmitting}
|
||||||
|
>
|
||||||
|
Apply Filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { string, object } from 'yup';
|
||||||
|
|
||||||
|
export const ProductFilterSchema = object().shape({
|
||||||
|
product_category_id: string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ProductFilterType = {
|
||||||
|
product_category_id: string | null;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
+306
-102
@@ -1,92 +1,211 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Button from '@/components/Button';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
|
import { usePathname } from 'next/navigation';
|
||||||
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 { useFormik } from 'formik';
|
||||||
|
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import Modal, { 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 { OptionType } from '@/components/input/SelectInput';
|
||||||
|
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||||
|
|
||||||
|
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 { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||||
import { CellContext } from '@tanstack/react-table';
|
import { useUiStore } from '@/stores/ui/ui.store';
|
||||||
import { useModal } from '@/components/Modal';
|
import {
|
||||||
import { useState } from 'react';
|
ProductionStandardFilterSchema,
|
||||||
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
|
ProductionStandardFilterType,
|
||||||
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
|
} from '@/components/pages/master-data/production-standard/filter/ProductionStandardFilter';
|
||||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
import SelectInputRadio from '@/components/input/SelectInputRadio';
|
||||||
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'>
|
||||||
|
<PopoverButton
|
||||||
|
tabIndex={0}
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
popoverTarget={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
>
|
||||||
|
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||||
|
</PopoverButton>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
id={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
|
||||||
|
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
|
||||||
|
>
|
||||||
|
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||||
<RequirePermission permissions='lti.master.production_standards.detail'>
|
<RequirePermission permissions='lti.master.production_standards.detail'>
|
||||||
<Button
|
<Button
|
||||||
href={`/master-data/production-standard/detail/?productionStandardId=${props.row.original.id}`}
|
href={`/master-data/production-standard/detail/?productionStandardId=${props.row.original.id}`}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='primary'
|
color='none'
|
||||||
className='justify-start text-sm'
|
className='p-3 justify-start text-sm font-semibold w-full'
|
||||||
|
onClick={closePopover}
|
||||||
>
|
>
|
||||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
<Icon icon='heroicons:eye' width={20} height={20} />
|
||||||
Detail
|
Detail
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
|
|
||||||
<RequirePermission permissions='lti.master.production_standards.update'>
|
<RequirePermission permissions='lti.master.production_standards.update'>
|
||||||
<Button
|
<Button
|
||||||
href={`/master-data/production-standard/detail/edit/?productionStandardId=${props.row.original.id}`}
|
href={`/master-data/production-standard/detail/edit/?productionStandardId=${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} />
|
<Icon icon='heroicons:pencil-square' width={20} height={20} />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
|
|
||||||
<RequirePermission permissions='lti.master.production_standards.delete'>
|
<RequirePermission permissions='lti.master.production_standards.delete'>
|
||||||
<Button
|
<Button
|
||||||
onClick={deleteClickHandler}
|
onClick={() => {
|
||||||
|
deleteClickHandler();
|
||||||
|
closePopover();
|
||||||
|
}}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='error'
|
color='none'
|
||||||
className='text-error hover:text-inherit'
|
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon icon='heroicons:trash' width={20} height={20} />
|
||||||
icon='material-symbols:delete-outline-rounded'
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
className='justify-start text-sm'
|
|
||||||
/>
|
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
</RowOptionsMenuWrapper>
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ProductionStandardTable = () => {
|
const ProductionStandardTable = () => {
|
||||||
const deleteModal = useModal();
|
const { setTableState } = useUiStore();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const {
|
||||||
|
state: tableFilterState,
|
||||||
|
updateFilter,
|
||||||
|
setPage,
|
||||||
|
setPageSize,
|
||||||
|
toQueryString: getTableFilterQueryString,
|
||||||
|
} = useTableFilter({
|
||||||
|
initial: {
|
||||||
|
projectCategoryFilter: '',
|
||||||
|
},
|
||||||
|
paramMap: {
|
||||||
|
page: 'page',
|
||||||
|
pageSize: 'limit',
|
||||||
|
projectCategoryFilter: 'project_category',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== FILTER MODAL STATE =====
|
||||||
|
const filterModal = useModal();
|
||||||
|
|
||||||
|
// ===== FORMIK SETUP =====
|
||||||
|
const formik = useFormik<ProductionStandardFilterType>({
|
||||||
|
initialValues: {
|
||||||
|
project_category: null,
|
||||||
|
},
|
||||||
|
validationSchema: ProductionStandardFilterSchema,
|
||||||
|
onSubmit: (values, { setSubmitting }) => {
|
||||||
|
updateFilter('projectCategoryFilter', values.project_category || '');
|
||||||
|
filterModal.closeModal();
|
||||||
|
setSubmitting(false);
|
||||||
|
},
|
||||||
|
onReset: () => {
|
||||||
|
updateFilter('projectCategoryFilter', '');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== PROJECT CATEGORY OPTIONS (GROWING or LAYING) =====
|
||||||
|
const projectCategoryOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: 'GROWING', label: 'Growing' },
|
||||||
|
{ value: 'LAYING', label: 'Laying' },
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== FILTER HANDLERS =====
|
||||||
|
const handleFilterProjectCategoryChange = useCallback(
|
||||||
|
(val: OptionType | OptionType[] | null) => {
|
||||||
|
const option = val as OptionType | null;
|
||||||
|
const category = option?.value ? String(option.value) : null;
|
||||||
|
|
||||||
|
formik.setFieldValue('project_category', category);
|
||||||
|
},
|
||||||
|
[formik]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== FILTER HELPERS =====
|
||||||
|
const projectCategoryValue = useMemo(() => {
|
||||||
|
if (!formik.values.project_category) return null;
|
||||||
|
return (
|
||||||
|
projectCategoryOptions.find(
|
||||||
|
(opt) => opt.value === formik.values.project_category
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
}, [formik.values.project_category, projectCategoryOptions]);
|
||||||
|
|
||||||
|
// ===== HANDLE FILTER MODAL OPEN =====
|
||||||
|
const handleFilterModalOpen = () => {
|
||||||
|
filterModal.openModal();
|
||||||
|
formik.validateForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTableState('production-standard-table', pathname);
|
||||||
|
}, [pathname, setTableState]);
|
||||||
|
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: productionStandards,
|
||||||
|
isLoading,
|
||||||
|
mutate: refreshProductionStandards,
|
||||||
|
} = useSWR(
|
||||||
|
`${ProductionStandardApi.basePath}${getTableFilterQueryString()}`,
|
||||||
|
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,51 +226,33 @@ const ProductionStandardTable = () => {
|
|||||||
setIsDeleteLoading(false);
|
setIsDeleteLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const productionStandardColumns: ColumnDef<ProductionStandard>[] = useMemo(
|
||||||
<>
|
() => [
|
||||||
<div className='flex flex-col gap-6 p-6'>
|
|
||||||
<div className='flex flex-row gap-6 justify-start'>
|
|
||||||
<RequirePermission permissions='lti.master.production_standards.create'>
|
|
||||||
<Button
|
|
||||||
href='/master-data/production-standard/add'
|
|
||||||
variant='outline'
|
|
||||||
>
|
|
||||||
<Icon icon='mdi:plus' /> Tambah
|
|
||||||
</Button>
|
|
||||||
</RequirePermission>
|
|
||||||
</div>
|
|
||||||
<RequirePermission permissions='lti.master.production_standards.list'>
|
|
||||||
<Table<ProductionStandard>
|
|
||||||
data={
|
|
||||||
isResponseSuccess(productionStandards)
|
|
||||||
? productionStandards.data
|
|
||||||
: []
|
|
||||||
}
|
|
||||||
columns={[
|
|
||||||
{
|
{
|
||||||
header: 'No',
|
header: 'No',
|
||||||
accessorFn: (row, index) => index + 1,
|
cell: (props) =>
|
||||||
|
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||||
|
props.row.index +
|
||||||
|
1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Nama',
|
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
|
header: 'Nama',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
accessorFn: (row) => row.project_category ?? '-',
|
||||||
header: 'Kategori',
|
header: 'Kategori',
|
||||||
accessorFn: (row) => row.project_category,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Aksi',
|
header: 'Aksi',
|
||||||
cell: (props) => {
|
cell: (props: CellContext<ProductionStandard, unknown>) => {
|
||||||
const currentPageSize =
|
const currentPageSize =
|
||||||
props.table.getPaginationRowModel().rows.length;
|
props.table.getPaginationRowModel().rows.length;
|
||||||
const currentPageRows =
|
const currentPageRows = props.table.getPaginationRowModel().flatRows;
|
||||||
props.table.getPaginationRowModel().flatRows;
|
|
||||||
const currentRowRelativeIndex =
|
const currentRowRelativeIndex =
|
||||||
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
|
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
|
||||||
|
|
||||||
const isLast2Rows =
|
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||||
currentRowRelativeIndex > currentPageSize - 2;
|
|
||||||
|
|
||||||
const deleteClickHandler = () => {
|
const deleteClickHandler = () => {
|
||||||
setSelectedProductionStandard(props.row.original);
|
setSelectedProductionStandard(props.row.original);
|
||||||
@@ -159,45 +260,90 @@ const ProductionStandardTable = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
{currentPageSize > 2 && (
|
|
||||||
<RowDropdownOptions isLast2Rows={isLast2Rows}>
|
|
||||||
<RowOptionsMenu
|
<RowOptionsMenu
|
||||||
type='dropdown'
|
|
||||||
props={props}
|
props={props}
|
||||||
|
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
|
||||||
deleteClickHandler={deleteClickHandler}
|
deleteClickHandler={deleteClickHandler}
|
||||||
/>
|
/>
|
||||||
</RowDropdownOptions>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentPageSize <= 2 && (
|
|
||||||
<RowCollapseOptions>
|
|
||||||
<RowOptionsMenu
|
|
||||||
type='collapse'
|
|
||||||
props={props}
|
|
||||||
deleteClickHandler={deleteClickHandler}
|
|
||||||
/>
|
|
||||||
</RowCollapseOptions>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]}
|
],
|
||||||
className={{
|
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
|
||||||
headerColumnClassName: cn(
|
);
|
||||||
TABLE_DEFAULT_STYLING.headerColumnClassName,
|
|
||||||
'last:flex last:flex-row last:justify-end'
|
return (
|
||||||
),
|
<>
|
||||||
bodyColumnClassName: cn(
|
<div className='w-full'>
|
||||||
TABLE_DEFAULT_STYLING.bodyColumnClassName,
|
{/* Header Section */}
|
||||||
'last:flex last:flex-row last:justify-end'
|
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
|
||||||
),
|
{/* Action Buttons */}
|
||||||
}}
|
<div className='w-fit flex flex-row gap-3 flex-wrap'>
|
||||||
/>
|
<RequirePermission permissions='lti.master.production_standards.create'>
|
||||||
|
<Button
|
||||||
|
href='/master-data/production-standard/add'
|
||||||
|
color='primary'
|
||||||
|
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:plus' width={20} height={20} />
|
||||||
|
Add Standard Production
|
||||||
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
</div>
|
</div>
|
||||||
<RequirePermission permissions='lti.master.production_standards.delete'>
|
|
||||||
|
{/* Filter */}
|
||||||
|
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
|
||||||
|
<ButtonFilter
|
||||||
|
values={tableFilterState}
|
||||||
|
excludeFields={['page', 'pageSize']}
|
||||||
|
onClick={handleFilterModalOpen}
|
||||||
|
className='px-3 py-2.5'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table Section */}
|
||||||
|
<div className='flex flex-col mb-4'>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
) : !isResponseSuccess(productionStandards) ||
|
||||||
|
productionStandards.data?.length === 0 ? (
|
||||||
|
<div className='p-3'>
|
||||||
|
<ProductionStandardTableSkeleton
|
||||||
|
columns={productionStandardColumns}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:document-text'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table<ProductionStandard>
|
||||||
|
data={productionStandards.data}
|
||||||
|
columns={productionStandardColumns}
|
||||||
|
pageSize={tableFilterState.pageSize}
|
||||||
|
page={productionStandards?.meta?.page ?? 0}
|
||||||
|
totalItems={productionStandards?.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>
|
||||||
|
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
ref={deleteModal.ref}
|
ref={deleteModal.ref}
|
||||||
type='error'
|
type='error'
|
||||||
@@ -212,7 +358,65 @@ const ProductionStandardTable = () => {
|
|||||||
onClick: confirmationModalDeleteClickHandler,
|
onClick: confirmationModalDeleteClickHandler,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</RequirePermission>
|
|
||||||
|
{/* Filter Modal */}
|
||||||
|
<Modal
|
||||||
|
ref={filterModal.ref}
|
||||||
|
className={{
|
||||||
|
modal: 'p-0',
|
||||||
|
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
|
||||||
|
<div className='flex items-center gap-2 text-primary'>
|
||||||
|
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||||
|
<h3 className='font-medium text-sm'>Filter Data</h3>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant='link'
|
||||||
|
onClick={filterModal.closeModal}
|
||||||
|
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||||
|
<div className='p-4 flex flex-col gap-1.5'>
|
||||||
|
<SelectInputRadio
|
||||||
|
label='Kategori'
|
||||||
|
placeholder='Pilih Kategori'
|
||||||
|
options={projectCategoryOptions}
|
||||||
|
value={projectCategoryValue}
|
||||||
|
onChange={handleFilterProjectCategoryChange}
|
||||||
|
isClearable
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='soft'
|
||||||
|
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||||
|
onClick={() => {
|
||||||
|
formik.resetForm();
|
||||||
|
filterModal.closeModal();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset Filter
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||||
|
disabled={!formik.isValid || formik.isSubmitting}
|
||||||
|
>
|
||||||
|
Apply Filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
import { string, object } from 'yup';
|
||||||
|
|
||||||
|
export const ProductionStandardFilterSchema = object().shape({
|
||||||
|
project_category: string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ProductionStandardFilterType = {
|
||||||
|
project_category: string | null;
|
||||||
|
};
|
||||||
+1
-8
@@ -178,14 +178,7 @@ const ProductionStandardForm = ({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// ===== Store =====
|
// ===== Store =====
|
||||||
const {
|
const { formData, setFormData, clearCache } = useFormStore();
|
||||||
formData,
|
|
||||||
setFormData,
|
|
||||||
addDetail,
|
|
||||||
updateDetail,
|
|
||||||
deleteDetail,
|
|
||||||
clearCache,
|
|
||||||
} = useFormStore();
|
|
||||||
|
|
||||||
// ===== Formik =====
|
// ===== Formik =====
|
||||||
// Initial values - only recalculate when initialValue changes (for edit/detail mode)
|
// Initial values - only recalculate when initialValue changes (for edit/detail mode)
|
||||||
|
|||||||
+37
@@ -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,51 +1,86 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Button from '@/components/Button';
|
import {
|
||||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
ChangeEventHandler,
|
||||||
import SelectInput, { OptionType } from '@/components/input/SelectInput';
|
useCallback,
|
||||||
import { useModal } from '@/components/Modal';
|
useEffect,
|
||||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
useMemo,
|
||||||
import Table from '@/components/Table';
|
useState,
|
||||||
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
|
} from 'react';
|
||||||
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
|
import { usePathname } from 'next/navigation';
|
||||||
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
|
|
||||||
import RequirePermission from '@/components/helper/RequirePermission';
|
|
||||||
import { ROWS_OPTIONS } from '@/config/constant';
|
|
||||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
|
||||||
import { cn } from '@/lib/helper';
|
|
||||||
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';
|
import useSWR from 'swr';
|
||||||
|
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { useFormik } from 'formik';
|
||||||
|
|
||||||
const RowOptions = ({
|
import { Icon } from '@iconify/react';
|
||||||
type = 'dropdown',
|
import Table from '@/components/Table';
|
||||||
|
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import Modal, { 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 SupplierTableSkeleton from '@/components/pages/master-data/supplier/skeleton/SupplierTableSkeleton';
|
||||||
|
import { OptionType } from '@/components/input/SelectInput';
|
||||||
|
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||||
|
|
||||||
|
import { Supplier } from '@/types/api/master-data/supplier';
|
||||||
|
import { SupplierApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||||
|
import { useUiStore } from '@/stores/ui/ui.store';
|
||||||
|
import {
|
||||||
|
SupplierFilterSchema,
|
||||||
|
SupplierFilterType,
|
||||||
|
} from '@/components/pages/master-data/supplier/filter/SupplierFilter';
|
||||||
|
import SelectInputRadio from '@/components/input/SelectInputRadio';
|
||||||
|
|
||||||
|
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'>
|
||||||
|
<PopoverButton
|
||||||
|
tabIndex={0}
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
popoverTarget={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
>
|
||||||
|
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||||
|
</PopoverButton>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
id={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
|
||||||
|
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
|
||||||
|
>
|
||||||
|
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||||
<RequirePermission permissions='lti.master.suppliers.detail'>
|
<RequirePermission permissions='lti.master.suppliers.detail'>
|
||||||
<Button
|
<Button
|
||||||
href={`/master-data/supplier/detail/?supplierId=${props.row.original.id}`}
|
href={`/master-data/supplier/detail/?supplierId=${props.row.original.id}`}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='primary'
|
color='none'
|
||||||
className='justify-start text-sm'
|
className='p-3 justify-start text-sm font-semibold w-full'
|
||||||
|
onClick={closePopover}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon icon='heroicons:eye' width={20} height={20} />
|
||||||
icon='mdi:eye-outline'
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
className='justify-start text-sm'
|
|
||||||
/>
|
|
||||||
Detail
|
Detail
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
@@ -53,39 +88,38 @@ const RowOptions = ({
|
|||||||
<Button
|
<Button
|
||||||
href={`/master-data/supplier/detail/edit/?supplierId=${props.row.original.id}`}
|
href={`/master-data/supplier/detail/edit/?supplierId=${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 icon='heroicons:pencil-square' width={20} height={20} />
|
||||||
icon='material-symbols:edit-outline'
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
className='justify-start text-sm'
|
|
||||||
/>
|
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
<RequirePermission permissions='lti.master.suppliers.delete'>
|
<RequirePermission permissions='lti.master.suppliers.delete'>
|
||||||
<Button
|
<Button
|
||||||
onClick={deleteClickHandler}
|
onClick={() => {
|
||||||
|
deleteClickHandler();
|
||||||
|
closePopover();
|
||||||
|
}}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='error'
|
color='none'
|
||||||
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
|
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon icon='heroicons:trash' width={20} height={20} />
|
||||||
icon='material-symbols:delete-outline-rounded'
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
className='justify-start text-sm'
|
|
||||||
/>
|
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
</RowOptionsMenuWrapper>
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SuppliersTable = () => {
|
const SuppliersTable = () => {
|
||||||
|
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
state: tableFilterState,
|
state: tableFilterState,
|
||||||
updateFilter,
|
updateFilter,
|
||||||
@@ -93,15 +127,132 @@ const SuppliersTable = () => {
|
|||||||
setPageSize,
|
setPageSize,
|
||||||
toQueryString: getTableFilterQueryString,
|
toQueryString: getTableFilterQueryString,
|
||||||
} = useTableFilter({
|
} = useTableFilter({
|
||||||
initial: { search: '', nameSort: '' },
|
initial: {
|
||||||
|
search: '',
|
||||||
|
categoryFilter: '',
|
||||||
|
flagFilter: '',
|
||||||
|
},
|
||||||
paramMap: {
|
paramMap: {
|
||||||
page: 'page',
|
page: 'page',
|
||||||
pageSize: 'limit',
|
pageSize: 'limit',
|
||||||
nameSort: 'sort_name',
|
categoryFilter: 'category_id',
|
||||||
|
flagFilter: 'flag',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch Data
|
// ===== FILTER MODAL STATE =====
|
||||||
|
const filterModal = useModal();
|
||||||
|
|
||||||
|
// ===== FORMIK SETUP =====
|
||||||
|
const formik = useFormik<SupplierFilterType>({
|
||||||
|
initialValues: {
|
||||||
|
category_id: null,
|
||||||
|
flag: false,
|
||||||
|
},
|
||||||
|
validationSchema: SupplierFilterSchema,
|
||||||
|
onSubmit: (values, { setSubmitting }) => {
|
||||||
|
updateFilter('categoryFilter', values.category_id || '');
|
||||||
|
updateFilter(
|
||||||
|
'flagFilter',
|
||||||
|
values.flag === true ? 'EKSPEDISI' : values.flag === false ? '' : ''
|
||||||
|
);
|
||||||
|
filterModal.closeModal();
|
||||||
|
setSubmitting(false);
|
||||||
|
},
|
||||||
|
onReset: () => {
|
||||||
|
updateFilter('categoryFilter', '');
|
||||||
|
updateFilter('flagFilter', '');
|
||||||
|
formik.setFieldValue('flag', false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { setFieldValue } = formik;
|
||||||
|
|
||||||
|
// ===== CATEGORY OPTIONS (SAPRONAK or BOP) =====
|
||||||
|
const categoryOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: 'SAPRONAK', label: 'SAPRONAK' },
|
||||||
|
{ value: 'BOP', label: 'BOP' },
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== FLAG OPTIONS (EKSPEDISI) =====
|
||||||
|
const flagOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: 'true', label: 'Ya' },
|
||||||
|
{ value: 'false', label: 'Tidak' },
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== FILTER HANDLERS =====
|
||||||
|
const handleFilterCategoryChange = useCallback(
|
||||||
|
(val: OptionType | OptionType[] | null) => {
|
||||||
|
const option = val as OptionType | null;
|
||||||
|
const categoryId = option?.value ? String(option.value) : null;
|
||||||
|
|
||||||
|
setFieldValue('category_id', categoryId);
|
||||||
|
},
|
||||||
|
[setFieldValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFilterFlagChange = useCallback(
|
||||||
|
(val: OptionType | OptionType[] | null) => {
|
||||||
|
const option = val as OptionType | null;
|
||||||
|
const boolValue =
|
||||||
|
option?.value === 'true'
|
||||||
|
? true
|
||||||
|
: option?.value === 'false'
|
||||||
|
? false
|
||||||
|
: null;
|
||||||
|
|
||||||
|
setFieldValue('flag', boolValue);
|
||||||
|
},
|
||||||
|
[setFieldValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== FILTER HELPERS =====
|
||||||
|
const categoryIdValue = useMemo(() => {
|
||||||
|
if (!formik.values.category_id) return null;
|
||||||
|
return (
|
||||||
|
categoryOptions.find((opt) => opt.value === formik.values.category_id) ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}, [formik.values.category_id, categoryOptions]);
|
||||||
|
|
||||||
|
const flagValue = useMemo(() => {
|
||||||
|
if (formik.values.flag === null) return null;
|
||||||
|
return (
|
||||||
|
flagOptions.find((opt) => opt.value === String(formik.values.flag)) ||
|
||||||
|
flagOptions[1]
|
||||||
|
);
|
||||||
|
}, [formik.values.flag, flagOptions]);
|
||||||
|
|
||||||
|
// ===== HANDLE FILTER MODAL OPEN =====
|
||||||
|
const handleFilterModalOpen = () => {
|
||||||
|
filterModal.openModal();
|
||||||
|
formik.validateForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (filterModal.open) {
|
||||||
|
const flagBoolValue =
|
||||||
|
tableFilterState.flagFilter === 'EKSPEDISI' ? true : false;
|
||||||
|
setFieldValue('flag', flagBoolValue);
|
||||||
|
}
|
||||||
|
}, [filterModal.open, tableFilterState.flagFilter, setFieldValue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateFilter('search', searchValue);
|
||||||
|
}, [searchValue, updateFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTableState('suppliers-table', pathname);
|
||||||
|
}, [pathname, setTableState]);
|
||||||
|
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: suppliers,
|
data: suppliers,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -111,17 +262,41 @@ 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>[] = [
|
setSearchValue(e.target.value);
|
||||||
|
updateFilter('search', e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmationModalDeleteClickHandler = async () => {
|
||||||
|
setIsDeleteLoading(true);
|
||||||
|
|
||||||
|
const deleteResponse = await SupplierApi.delete(
|
||||||
|
selectedSupplier?.id as number
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isResponseError(deleteResponse)) {
|
||||||
|
toast.error(deleteResponse.message);
|
||||||
|
setIsDeleteLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshSuppliers();
|
||||||
|
|
||||||
|
deleteModal.closeModal();
|
||||||
|
toast.success('Successfully delete Supplier!');
|
||||||
|
setIsDeleteLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const suppliersColumns: ColumnDef<Supplier>[] = useMemo(
|
||||||
|
() => [
|
||||||
{
|
{
|
||||||
header: '#',
|
header: 'No',
|
||||||
cell: (props) =>
|
cell: (props) =>
|
||||||
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||||
props.row.index +
|
props.row.index +
|
||||||
@@ -161,8 +336,9 @@ const SuppliersTable = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Aksi',
|
header: 'Aksi',
|
||||||
cell: (props) => {
|
cell: (props: CellContext<Supplier, unknown>) => {
|
||||||
const currentPageSize = props.table.getPaginationRowModel().rows.length;
|
const currentPageSize =
|
||||||
|
props.table.getPaginationRowModel().rows.length;
|
||||||
const currentPageRows = props.table.getPaginationRowModel().flatRows;
|
const currentPageRows = props.table.getPaginationRowModel().flatRows;
|
||||||
const currentRowRelativeIndex =
|
const currentRowRelativeIndex =
|
||||||
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
|
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
|
||||||
@@ -175,128 +351,109 @@ const SuppliersTable = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<RowOptionsMenu
|
||||||
{currentPageSize > 2 && (
|
|
||||||
<RowDropdownOptions isLast2Rows={isLast2Rows}>
|
|
||||||
<RowOptions
|
|
||||||
type='dropdown'
|
|
||||||
props={props}
|
props={props}
|
||||||
|
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
|
||||||
deleteClickHandler={deleteClickHandler}
|
deleteClickHandler={deleteClickHandler}
|
||||||
/>
|
/>
|
||||||
</RowDropdownOptions>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentPageSize <= 2 && (
|
|
||||||
<RowCollapseOptions>
|
|
||||||
<RowOptions
|
|
||||||
type='collapse'
|
|
||||||
props={props}
|
|
||||||
deleteClickHandler={deleteClickHandler}
|
|
||||||
/>
|
|
||||||
</RowCollapseOptions>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
],
|
||||||
|
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
|
||||||
// Handler
|
|
||||||
const confirmationModalDeleteClickHandler = async () => {
|
|
||||||
setIsDeleteLoading(true);
|
|
||||||
|
|
||||||
const deleteResponse = await SupplierApi.delete(
|
|
||||||
selectedSupplier?.id as number
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isResponseError(deleteResponse)) {
|
|
||||||
toast.error(deleteResponse.message);
|
|
||||||
setIsDeleteLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshSuppliers();
|
|
||||||
|
|
||||||
deleteModal.closeModal();
|
|
||||||
toast.success('Successfully delete Supplier!');
|
|
||||||
setIsDeleteLoading(false);
|
|
||||||
};
|
|
||||||
const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
updateFilter('search', e.target.value);
|
|
||||||
};
|
|
||||||
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
|
||||||
const newVal = val as OptionType;
|
|
||||||
setPageSize(newVal.value as number);
|
|
||||||
};
|
|
||||||
|
|
||||||
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-fit flex flex-row gap-3 flex-wrap'>
|
||||||
<RequirePermission permissions='lti.master.suppliers.create'>
|
<RequirePermission permissions='lti.master.suppliers.create'>
|
||||||
<Button
|
<Button
|
||||||
href='/master-data/supplier/add'
|
href='/master-data/supplier/add'
|
||||||
variant='outline'
|
|
||||||
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 and Filter */}
|
||||||
|
<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='Search'
|
||||||
value={tableFilterState.search}
|
value={tableFilterState.search ?? ''}
|
||||||
onChange={searchChangeHandler}
|
onChange={searchChangeHandler}
|
||||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
startAdornment={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:magnifying-glass'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
/>
|
/>
|
||||||
</div>
|
}
|
||||||
|
className={{
|
||||||
<div className='flex flex-row justify-end'>
|
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||||
<SelectInput
|
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||||
label='Baris'
|
input:
|
||||||
options={ROWS_OPTIONS}
|
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||||
value={{
|
|
||||||
label: String(tableFilterState.pageSize),
|
|
||||||
value: tableFilterState.pageSize,
|
|
||||||
}}
|
}}
|
||||||
onChange={pageSizeChangeHandler}
|
/>
|
||||||
className={{ wrapper: 'max-w-28' }}
|
|
||||||
|
<ButtonFilter
|
||||||
|
values={tableFilterState}
|
||||||
|
excludeFields={['page', 'pageSize', 'search']}
|
||||||
|
onClick={handleFilterModalOpen}
|
||||||
|
className='px-3 py-2.5'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Table Section */}
|
||||||
|
<div className='flex flex-col mb-4'>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
) : !isResponseSuccess(suppliers) || suppliers.data?.length === 0 ? (
|
||||||
|
<div className='p-3'>
|
||||||
|
<SupplierTableSkeleton
|
||||||
|
columns={suppliersColumns}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:document-text'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Table<Supplier>
|
<Table<Supplier>
|
||||||
data={isResponseSuccess(suppliers) ? suppliers?.data : []}
|
data={suppliers?.data}
|
||||||
columns={suppliersColumns}
|
columns={suppliersColumns}
|
||||||
pageSize={tableFilterState.pageSize}
|
pageSize={tableFilterState.pageSize}
|
||||||
page={isResponseSuccess(suppliers) ? suppliers?.meta?.page : 0}
|
page={suppliers?.meta?.page ?? 0}
|
||||||
totalItems={
|
totalItems={suppliers?.meta?.total_results ?? 0}
|
||||||
isResponseSuccess(suppliers) ? suppliers?.meta?.total_results : 0
|
|
||||||
}
|
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
isLoading={isLoading}
|
onPageSizeChange={setPageSize}
|
||||||
|
isLoading={false}
|
||||||
|
sorting={sorting}
|
||||||
|
setSorting={setSorting}
|
||||||
className={{
|
className={{
|
||||||
containerClassName: cn({
|
containerClassName: 'p-3 mb-0',
|
||||||
'mb-20':
|
headerColumnClassName: 'text-nowrap',
|
||||||
isResponseSuccess(suppliers) && suppliers?.data?.length === 0,
|
|
||||||
}),
|
|
||||||
tableWrapperClassName: 'overflow-x-auto min-h-full!',
|
|
||||||
tableClassName: 'font-inter w-full table-auto min-h-full!',
|
|
||||||
headerRowClassName: 'border-b border-b-gray-200',
|
|
||||||
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',
|
|
||||||
bodyColumnClassName:
|
|
||||||
'px-6 py-3 last:flex last:flex-row last:justify-end',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
ref={deleteModal.ref}
|
ref={deleteModal.ref}
|
||||||
type='error'
|
type='error'
|
||||||
@@ -311,6 +468,75 @@ const SuppliersTable = () => {
|
|||||||
onClick: confirmationModalDeleteClickHandler,
|
onClick: confirmationModalDeleteClickHandler,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Filter Modal */}
|
||||||
|
<Modal
|
||||||
|
ref={filterModal.ref}
|
||||||
|
className={{
|
||||||
|
modal: 'p-0',
|
||||||
|
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
|
||||||
|
<div className='flex items-center gap-2 text-primary'>
|
||||||
|
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||||
|
<h3 className='font-medium text-sm'>Filter Data</h3>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant='link'
|
||||||
|
onClick={filterModal.closeModal}
|
||||||
|
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||||
|
<div className='p-4 flex flex-col gap-1.5'>
|
||||||
|
<SelectInputRadio
|
||||||
|
label='Kategori'
|
||||||
|
placeholder='Pilih Kategori'
|
||||||
|
options={categoryOptions}
|
||||||
|
value={categoryIdValue}
|
||||||
|
onChange={handleFilterCategoryChange}
|
||||||
|
isClearable
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectInputRadio
|
||||||
|
label='Flag Ekspedisi'
|
||||||
|
placeholder='Pilih'
|
||||||
|
options={flagOptions}
|
||||||
|
value={flagValue}
|
||||||
|
onChange={handleFilterFlagChange}
|
||||||
|
isClearable
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='soft'
|
||||||
|
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||||
|
onClick={() => {
|
||||||
|
formik.resetForm();
|
||||||
|
filterModal.closeModal();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset Filter
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||||
|
disabled={!formik.isValid || formik.isSubmitting}
|
||||||
|
>
|
||||||
|
Apply Filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { string, boolean, object } from 'yup';
|
||||||
|
|
||||||
|
export const SupplierFilterSchema = object().shape({
|
||||||
|
category_id: string().nullable(),
|
||||||
|
flag: boolean().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SupplierFilterType = {
|
||||||
|
category_id: string | null;
|
||||||
|
flag: boolean | null;
|
||||||
|
};
|
||||||
@@ -167,29 +167,26 @@ const SupplierForm = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { setValues: formikSetValues } = formik;
|
const { setFieldValue } = formik;
|
||||||
|
|
||||||
// Initialize Formik
|
// Initialize Formik
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
formikSetValues(formikInitialValues);
|
if (formType !== 'add' && initialValues?.hatchery) {
|
||||||
if (formType != 'add') {
|
const hatcheryArrays = initialValues.hatchery.split(',');
|
||||||
const hatcheryArrays = formikInitialValues.hatchery?.split(',');
|
const hatcheryCreatedOptions = hatcheryArrays.map((item) => ({
|
||||||
const hatcheryCreatedOptions = hatcheryArrays?.map((item) => ({
|
|
||||||
value: item,
|
value: item,
|
||||||
label: item,
|
label: item,
|
||||||
}));
|
}));
|
||||||
setHatcheryOptionValues(hatcheryCreatedOptions ?? []);
|
setHatcheryOptionValues(hatcheryCreatedOptions);
|
||||||
}
|
}
|
||||||
}, [formikSetValues, formikInitialValues, setHatcheryOptionValues]);
|
}, [formType, initialValues?.hatchery]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const commaSeparatedValues = hatcheryOptionsValues
|
const commaSeparatedValues = hatcheryOptionsValues
|
||||||
.map((item) => item.value)
|
.map((item) => item.value)
|
||||||
.join(',');
|
.join(',');
|
||||||
formikSetValues({
|
setFieldValue('hatchery', commaSeparatedValues);
|
||||||
...formik.values,
|
}, [hatcheryOptionsValues, setFieldValue]);
|
||||||
hatchery: commaSeparatedValues,
|
|
||||||
});
|
|
||||||
}, [hatcheryOptionsValues, formikSetValues]);
|
|
||||||
|
|
||||||
// Option Handler
|
// Option Handler
|
||||||
const typeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
const typeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ChangeEventHandler, useEffect, useState } from 'react';
|
import { ChangeEventHandler, useEffect, 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,75 +11,101 @@ 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';
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useUiStore } from '@/stores/ui/ui.store';
|
||||||
|
|
||||||
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'>
|
||||||
|
<PopoverButton
|
||||||
|
tabIndex={0}
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
popoverTarget={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
>
|
||||||
|
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||||
|
</PopoverButton>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
id={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
|
||||||
|
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
|
||||||
|
>
|
||||||
|
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||||
<RequirePermission permissions='lti.master.uoms.detail'>
|
<RequirePermission permissions='lti.master.uoms.detail'>
|
||||||
<Button
|
<Button
|
||||||
href={`/master-data/uom/detail/?uomId=${props.row.original.id}`}
|
href={`/master-data/uom/detail/?uomId=${props.row.original.id}`}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='primary'
|
color='none'
|
||||||
className='justify-start text-sm'
|
className='p-3 justify-start text-sm font-semibold w-full'
|
||||||
|
onClick={closePopover}
|
||||||
>
|
>
|
||||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
<Icon icon='heroicons:eye' width={20} height={20} />
|
||||||
Detail
|
Detail
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
|
|
||||||
<RequirePermission permissions='lti.master.uoms.update'>
|
<RequirePermission permissions='lti.master.uoms.update'>
|
||||||
<Button
|
<Button
|
||||||
href={`/master-data/uom/detail/edit/?uomId=${props.row.original.id}`}
|
href={`/master-data/uom/detail/edit/?uomId=${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} />
|
<Icon icon='heroicons:pencil-square' width={20} height={20} />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
|
|
||||||
<RequirePermission permissions='lti.master.uoms.delete'>
|
<RequirePermission permissions='lti.master.uoms.delete'>
|
||||||
<Button
|
<Button
|
||||||
onClick={deleteClickHandler}
|
onClick={() => {
|
||||||
|
deleteClickHandler();
|
||||||
|
closePopover();
|
||||||
|
}}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='error'
|
color='none'
|
||||||
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
|
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon icon='heroicons:trash' width={20} height={20} />
|
||||||
icon='material-symbols:delete-outline-rounded'
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
className='justify-start text-sm'
|
|
||||||
/>
|
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
</RowOptionsMenuWrapper>
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const UomsTable = () => {
|
const UomsTable = () => {
|
||||||
|
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
state: tableFilterState,
|
state: tableFilterState,
|
||||||
updateFilter,
|
updateFilter,
|
||||||
@@ -87,10 +113,25 @@ const UomsTable = () => {
|
|||||||
setPageSize,
|
setPageSize,
|
||||||
toQueryString: getTableFilterQueryString,
|
toQueryString: getTableFilterQueryString,
|
||||||
} = useTableFilter({
|
} = useTableFilter({
|
||||||
initial: { search: '', nameSort: '' },
|
initial: {
|
||||||
paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' },
|
search: searchValue,
|
||||||
|
},
|
||||||
|
paramMap: {
|
||||||
|
page: 'page',
|
||||||
|
pageSize: 'limit',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateFilter('search', searchValue);
|
||||||
|
}, [searchValue, updateFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTableState('uoms-table', pathname);
|
||||||
|
}, [pathname, setTableState]);
|
||||||
|
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: uoms,
|
data: uoms,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -101,66 +142,14 @@ 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) => {
|
||||||
|
setSearchValue(e.target.value);
|
||||||
const uomsColumns: ColumnDef<Uom>[] = [
|
updateFilter('search', e.target.value);
|
||||||
{
|
|
||||||
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 +168,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 isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||||
|
|
||||||
|
const deleteClickHandler = () => {
|
||||||
|
setSelectedUom(props.row.original);
|
||||||
|
deleteModal.openModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
return (
|
||||||
const newVal = val as OptionType;
|
<RowOptionsMenu
|
||||||
|
props={props}
|
||||||
setPageSize(newVal.value as number);
|
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
|
||||||
};
|
deleteClickHandler={deleteClickHandler}
|
||||||
|
/>
|
||||||
// track sorting
|
);
|
||||||
useEffect(() => {
|
},
|
||||||
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
|
},
|
||||||
|
],
|
||||||
if (!isNameSorted) {
|
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
|
||||||
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 */}
|
||||||
|
<div className='w-fit flex flex-row gap-3 flex-wrap'>
|
||||||
<RequirePermission permissions='lti.master.uoms.create'>
|
<RequirePermission permissions='lti.master.uoms.create'>
|
||||||
<Button
|
<Button
|
||||||
href='/master-data/uom/add'
|
href='/master-data/uom/add'
|
||||||
variant='outline'
|
|
||||||
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='Search'
|
||||||
value={tableFilterState.search}
|
value={tableFilterState.search ?? ''}
|
||||||
onChange={searchChangeHandler}
|
onChange={searchChangeHandler}
|
||||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
startAdornment={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:magnifying-glass'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
/>
|
/>
|
||||||
</div>
|
}
|
||||||
|
className={{
|
||||||
<div className='flex flex-row justify-end'>
|
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||||
<SelectInput
|
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||||
label='Baris'
|
input:
|
||||||
options={ROWS_OPTIONS}
|
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||||
value={{
|
|
||||||
label: String(tableFilterState.pageSize),
|
|
||||||
value: tableFilterState.pageSize,
|
|
||||||
}}
|
}}
|
||||||
onChange={pageSizeChangeHandler}
|
|
||||||
className={{ wrapper: 'max-w-28' }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Table Section */}
|
||||||
|
<div className='flex flex-col mb-4'>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
) : !isResponseSuccess(uoms) || uoms.data?.length === 0 ? (
|
||||||
|
<div className='p-3'>
|
||||||
|
<UomTableSkeleton
|
||||||
|
columns={uomsColumns}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:document-text'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Table<Uom>
|
<Table<Uom>
|
||||||
data={isResponseSuccess(uoms) ? uoms?.data : []}
|
data={uoms?.data}
|
||||||
columns={uomsColumns}
|
columns={uomsColumns}
|
||||||
pageSize={tableFilterState.pageSize}
|
pageSize={tableFilterState.pageSize}
|
||||||
page={isResponseSuccess(uoms) ? uoms?.meta?.page : 0}
|
page={uoms?.meta?.page ?? 0}
|
||||||
totalItems={isResponseSuccess(uoms) ? uoms?.meta?.total_results : 0}
|
totalItems={uoms?.meta?.total_results ?? 0}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
isLoading={isLoading}
|
onPageSizeChange={setPageSize}
|
||||||
|
isLoading={false}
|
||||||
sorting={sorting}
|
sorting={sorting}
|
||||||
setSorting={setSorting}
|
setSorting={setSorting}
|
||||||
className={{
|
className={{
|
||||||
containerClassName: cn({
|
containerClassName: 'p-3 mb-0',
|
||||||
'mb-20': isResponseSuccess(uoms) && uoms?.data?.length === 0,
|
headerColumnClassName: 'text-nowrap',
|
||||||
}),
|
|
||||||
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:
|
|
||||||
'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',
|
|
||||||
bodyColumnClassName:
|
|
||||||
'px-6 py-3 last:flex last:flex-row last:justify-end',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</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,90 +1,128 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ChangeEventHandler, useCallback, useEffect, useState } from 'react';
|
|
||||||
import useSWR from 'swr';
|
|
||||||
import {
|
import {
|
||||||
CellContext,
|
ChangeEventHandler,
|
||||||
ColumnDef,
|
useCallback,
|
||||||
ColumnSort,
|
useEffect,
|
||||||
SortingState,
|
useMemo,
|
||||||
} from '@tanstack/react-table';
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import { useFormik } from 'formik';
|
||||||
|
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import { useModal } from '@/components/Modal';
|
import Modal, { 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 { OptionType } from '@/components/input/SelectInput';
|
||||||
|
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||||
|
|
||||||
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, 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';
|
import { useUiStore } from '@/stores/ui/ui.store';
|
||||||
|
import {
|
||||||
|
WarehouseFilterSchema,
|
||||||
|
WarehouseFilterType,
|
||||||
|
} from '@/components/pages/master-data/warehouse/filter/WarehouseFilter';
|
||||||
|
import { Area } from '@/types/api/master-data/area';
|
||||||
|
import SelectInput, { useSelect } from '@/components/input/SelectInput';
|
||||||
|
import SelectInputRadio from '@/components/input/SelectInputRadio';
|
||||||
|
|
||||||
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'>
|
||||||
|
<PopoverButton
|
||||||
|
tabIndex={0}
|
||||||
|
variant='ghost'
|
||||||
|
color='none'
|
||||||
|
popoverTarget={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
>
|
||||||
|
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||||
|
</PopoverButton>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
id={popoverId}
|
||||||
|
anchorName={popoverAnchorName}
|
||||||
|
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
|
||||||
|
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
|
||||||
|
>
|
||||||
|
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||||
<RequirePermission permissions='lti.master.warehouses.detail'>
|
<RequirePermission permissions='lti.master.warehouses.detail'>
|
||||||
<Button
|
<Button
|
||||||
href={`/master-data/warehouse/detail/?warehouseId=${props.row.original.id}`}
|
href={`/master-data/warehouse/detail/?warehouseId=${props.row.original.id}`}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='primary'
|
color='none'
|
||||||
className='justify-start text-sm'
|
className='p-3 justify-start text-sm font-semibold w-full'
|
||||||
|
onClick={closePopover}
|
||||||
>
|
>
|
||||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
<Icon icon='heroicons:eye' width={20} height={20} />
|
||||||
Detail
|
Detail
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
|
|
||||||
<RequirePermission permissions='lti.master.warehouses.update'>
|
<RequirePermission permissions='lti.master.warehouses.update'>
|
||||||
<Button
|
<Button
|
||||||
href={`/master-data/warehouse/detail/edit/?warehouseId=${props.row.original.id}`}
|
href={`/master-data/warehouse/detail/edit/?warehouseId=${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} />
|
<Icon icon='heroicons:pencil-square' width={20} height={20} />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
|
|
||||||
<RequirePermission permissions='lti.master.warehouses.delete'>
|
<RequirePermission permissions='lti.master.warehouses.delete'>
|
||||||
<Button
|
<Button
|
||||||
onClick={deleteClickHandler}
|
onClick={() => {
|
||||||
|
deleteClickHandler();
|
||||||
|
closePopover();
|
||||||
|
}}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='error'
|
color='none'
|
||||||
className='text-error hover:text-inherit'
|
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon icon='heroicons:trash' width={20} height={20} />
|
||||||
icon='material-symbols:delete-outline-rounded'
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
className='justify-start text-sm'
|
|
||||||
/>
|
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
</RowOptionsMenuWrapper>
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const WarehousesTable = () => {
|
const WarehousesTable = () => {
|
||||||
|
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
state: tableFilterState,
|
state: tableFilterState,
|
||||||
updateFilter,
|
updateFilter,
|
||||||
@@ -94,23 +132,139 @@ const WarehousesTable = () => {
|
|||||||
} = useTableFilter({
|
} = useTableFilter({
|
||||||
initial: {
|
initial: {
|
||||||
search: '',
|
search: '',
|
||||||
nameSort: '',
|
areaFilter: '',
|
||||||
typeSort: '',
|
activeProjectFlockFilter: '',
|
||||||
areaSort: '',
|
|
||||||
locationSort: '',
|
|
||||||
kandangSort: '',
|
|
||||||
},
|
},
|
||||||
paramMap: {
|
paramMap: {
|
||||||
page: 'page',
|
page: 'page',
|
||||||
pageSize: 'limit',
|
pageSize: 'limit',
|
||||||
nameSort: 'sort_name',
|
areaFilter: 'area_id',
|
||||||
typeSort: 'sort_type',
|
activeProjectFlockFilter: 'active_project_flock',
|
||||||
areaSort: ' sort_area',
|
|
||||||
locationSort: ' sort_location',
|
|
||||||
kandangSort: ' sort_kandang',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== FILTER MODAL STATE =====
|
||||||
|
const filterModal = useModal();
|
||||||
|
|
||||||
|
// ===== FORMIK SETUP =====
|
||||||
|
const formik = useFormik<WarehouseFilterType>({
|
||||||
|
initialValues: {
|
||||||
|
area_id: null,
|
||||||
|
active_project_flock: false,
|
||||||
|
},
|
||||||
|
validationSchema: WarehouseFilterSchema,
|
||||||
|
onSubmit: (values, { setSubmitting }) => {
|
||||||
|
updateFilter('areaFilter', values.area_id || '');
|
||||||
|
updateFilter(
|
||||||
|
'activeProjectFlockFilter',
|
||||||
|
values.active_project_flock === true ? 'true' : ''
|
||||||
|
);
|
||||||
|
filterModal.closeModal();
|
||||||
|
setSubmitting(false);
|
||||||
|
},
|
||||||
|
onReset: () => {
|
||||||
|
updateFilter('areaFilter', '');
|
||||||
|
updateFilter('activeProjectFlockFilter', '');
|
||||||
|
formik.setFieldValue('active_project_flock', false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { setFieldValue } = formik;
|
||||||
|
|
||||||
|
// ===== AREA OPTIONS =====
|
||||||
|
const {
|
||||||
|
setInputValue: setAreaInputValue,
|
||||||
|
options: areaOptions,
|
||||||
|
isLoadingOptions: isLoadingAreaOptions,
|
||||||
|
loadMore: loadMoreAreas,
|
||||||
|
} = useSelect<Area>(
|
||||||
|
filterModal.open ? AreaApi.basePath : null,
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'search'
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== ACTIVE PROJECT FLOCK OPTIONS =====
|
||||||
|
const activeProjectFlockOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: 'true', label: 'Kandang Aktif' },
|
||||||
|
{ value: 'false', label: 'Semua Kandang' },
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== FILTER HANDLERS =====
|
||||||
|
const handleFilterAreaChange = useCallback(
|
||||||
|
(val: OptionType | OptionType[] | null) => {
|
||||||
|
const area = val as OptionType | null;
|
||||||
|
const areaId = area?.value ? String(area.value) : null;
|
||||||
|
|
||||||
|
formik.setFieldValue('area_id', areaId);
|
||||||
|
},
|
||||||
|
[formik]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFilterActiveProjectFlockChange = useCallback(
|
||||||
|
(val: OptionType | OptionType[] | null) => {
|
||||||
|
const option = val as OptionType | null;
|
||||||
|
const boolValue =
|
||||||
|
option?.value === 'true'
|
||||||
|
? true
|
||||||
|
: option?.value === 'false'
|
||||||
|
? false
|
||||||
|
: null;
|
||||||
|
|
||||||
|
formik.setFieldValue('active_project_flock', boolValue);
|
||||||
|
},
|
||||||
|
[formik]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== FILTER HELPERS =====
|
||||||
|
const areaIdValue = useMemo(() => {
|
||||||
|
if (!formik.values.area_id) return null;
|
||||||
|
return (
|
||||||
|
areaOptions.find((opt) => String(opt.value) === formik.values.area_id) ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}, [formik.values.area_id, areaOptions]);
|
||||||
|
|
||||||
|
const activeProjectFlockValue = useMemo(() => {
|
||||||
|
if (formik.values.active_project_flock === null) return null;
|
||||||
|
return (
|
||||||
|
activeProjectFlockOptions.find(
|
||||||
|
(opt) => opt.value === String(formik.values.active_project_flock)
|
||||||
|
) || activeProjectFlockOptions[1]
|
||||||
|
);
|
||||||
|
}, [formik.values.active_project_flock, activeProjectFlockOptions]);
|
||||||
|
|
||||||
|
// ===== HANDLE FILTER MODAL OPEN =====
|
||||||
|
const handleFilterModalOpen = () => {
|
||||||
|
filterModal.openModal();
|
||||||
|
formik.validateForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (filterModal.open) {
|
||||||
|
const activeProjectFlockValue =
|
||||||
|
tableFilterState.activeProjectFlockFilter === 'true' ? true : false; // Default ke false (Semua Kandang)
|
||||||
|
setFieldValue('active_project_flock', activeProjectFlockValue);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
filterModal.open,
|
||||||
|
tableFilterState.activeProjectFlockFilter,
|
||||||
|
setFieldValue,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateFilter('search', searchValue);
|
||||||
|
}, [searchValue, updateFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTableState('warehouses-table', pathname);
|
||||||
|
}, [pathname, setTableState]);
|
||||||
|
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: warehouses,
|
data: warehouses,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -121,102 +275,16 @@ 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) => {
|
||||||
|
setSearchValue(e.target.value);
|
||||||
const warehousesColumns: ColumnDef<Warehouse>[] = [
|
updateFilter('search', e.target.value);
|
||||||
{
|
|
||||||
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 +305,169 @@ const WarehousesTable = () => {
|
|||||||
setIsDeleteLoading(false);
|
setIsDeleteLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
const warehousesColumns: ColumnDef<Warehouse>[] = useMemo(
|
||||||
updateFilter('search', e.target.value);
|
() => [
|
||||||
};
|
{
|
||||||
|
header: 'No',
|
||||||
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
cell: (props) =>
|
||||||
const newVal = val as OptionType;
|
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||||
|
props.row.index +
|
||||||
setPageSize(newVal.value as number);
|
1,
|
||||||
};
|
|
||||||
|
|
||||||
const updateSortingFilter = useCallback(
|
|
||||||
(
|
|
||||||
sortName: Exclude<keyof typeof tableFilterState, 'page' | 'pageSize'>,
|
|
||||||
sortFilter: ColumnSort | undefined
|
|
||||||
) => {
|
|
||||||
if (!sortFilter) {
|
|
||||||
updateFilter(sortName, '');
|
|
||||||
} else {
|
|
||||||
updateFilter(sortName, sortFilter.desc ? 'desc' : 'asc');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[updateFilter]
|
{
|
||||||
);
|
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;
|
||||||
|
|
||||||
// track sorting
|
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||||
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);
|
const deleteClickHandler = () => {
|
||||||
updateSortingFilter('typeSort', typeSortFilter);
|
setSelectedWarehouse(props.row.original);
|
||||||
updateSortingFilter('areaSort', areaSortFilter);
|
deleteModal.openModal();
|
||||||
updateSortingFilter('locationSort', locationSortFilter);
|
};
|
||||||
updateSortingFilter('kandangSort', kandangSortFilter);
|
|
||||||
}, [sorting, updateSortingFilter]);
|
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 */}
|
||||||
|
<div className='w-fit flex flex-row gap-3 flex-wrap'>
|
||||||
<RequirePermission permissions='lti.master.warehouses.create'>
|
<RequirePermission permissions='lti.master.warehouses.create'>
|
||||||
<Button
|
<Button
|
||||||
href='/master-data/warehouse/add'
|
href='/master-data/warehouse/add'
|
||||||
variant='outline'
|
|
||||||
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 and Filter */}
|
||||||
|
<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='Search'
|
||||||
value={tableFilterState.search}
|
value={tableFilterState.search ?? ''}
|
||||||
onChange={searchChangeHandler}
|
onChange={searchChangeHandler}
|
||||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
startAdornment={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:magnifying-glass'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
/>
|
/>
|
||||||
</div>
|
}
|
||||||
|
className={{
|
||||||
<div className='flex flex-row justify-end'>
|
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||||
<SelectInput
|
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||||
label='Baris'
|
input:
|
||||||
options={ROWS_OPTIONS}
|
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||||
value={{
|
|
||||||
label: String(tableFilterState.pageSize),
|
|
||||||
value: tableFilterState.pageSize,
|
|
||||||
}}
|
}}
|
||||||
onChange={pageSizeChangeHandler}
|
/>
|
||||||
className={{ wrapper: 'max-w-28' }}
|
|
||||||
|
<ButtonFilter
|
||||||
|
values={tableFilterState}
|
||||||
|
excludeFields={['page', 'pageSize', 'search']}
|
||||||
|
onClick={handleFilterModalOpen}
|
||||||
|
className='px-3 py-2.5'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Table Section */}
|
||||||
|
<div className='flex flex-col mb-4'>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
) : !isResponseSuccess(warehouses) ||
|
||||||
|
warehouses.data?.length === 0 ? (
|
||||||
|
<div className='p-3'>
|
||||||
|
<WarehouseTableSkeleton
|
||||||
|
columns={warehousesColumns}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:document-text'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Table<Warehouse>
|
<Table<Warehouse>
|
||||||
data={isResponseSuccess(warehouses) ? warehouses?.data : []}
|
data={warehouses?.data}
|
||||||
columns={warehousesColumns}
|
columns={warehousesColumns}
|
||||||
pageSize={tableFilterState.pageSize}
|
pageSize={tableFilterState.pageSize}
|
||||||
page={isResponseSuccess(warehouses) ? warehouses?.meta?.page : 0}
|
page={warehouses?.meta?.page ?? 0}
|
||||||
totalItems={
|
totalItems={warehouses?.meta?.total_results ?? 0}
|
||||||
isResponseSuccess(warehouses) ? warehouses?.meta?.total_results : 0
|
|
||||||
}
|
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
isLoading={isLoading}
|
onPageSizeChange={setPageSize}
|
||||||
|
isLoading={false}
|
||||||
sorting={sorting}
|
sorting={sorting}
|
||||||
setSorting={setSorting}
|
setSorting={setSorting}
|
||||||
className={{
|
className={{
|
||||||
containerClassName: cn({
|
containerClassName: 'p-3 mb-0',
|
||||||
'mb-20':
|
headerColumnClassName: 'text-nowrap',
|
||||||
isResponseSuccess(warehouses) && warehouses?.data?.length === 0,
|
|
||||||
}),
|
|
||||||
tableWrapperClassName: 'overflow-x-auto min-h-full!',
|
|
||||||
tableClassName: 'font-inter w-full table-auto min-h-full!',
|
|
||||||
headerRowClassName: 'border-b border-b-gray-200',
|
|
||||||
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',
|
|
||||||
bodyColumnClassName:
|
|
||||||
'px-6 py-3 last:flex last:flex-row last:justify-end',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
@@ -365,6 +484,77 @@ const WarehousesTable = () => {
|
|||||||
onClick: confirmationModalDeleteClickHandler,
|
onClick: confirmationModalDeleteClickHandler,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Filter Modal */}
|
||||||
|
<Modal
|
||||||
|
ref={filterModal.ref}
|
||||||
|
className={{
|
||||||
|
modal: 'p-0',
|
||||||
|
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
|
||||||
|
<div className='flex items-center gap-2 text-primary'>
|
||||||
|
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||||
|
<h3 className='font-medium text-sm'>Filter Data</h3>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant='link'
|
||||||
|
onClick={filterModal.closeModal}
|
||||||
|
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||||
|
<div className='p-4 flex flex-col gap-1.5'>
|
||||||
|
<SelectInput
|
||||||
|
label='Area'
|
||||||
|
placeholder='Pilih Area'
|
||||||
|
options={areaOptions}
|
||||||
|
value={areaIdValue}
|
||||||
|
onChange={handleFilterAreaChange}
|
||||||
|
onInputChange={setAreaInputValue}
|
||||||
|
isLoading={isLoadingAreaOptions}
|
||||||
|
isClearable
|
||||||
|
onMenuScrollToBottom={loadMoreAreas}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectInputRadio
|
||||||
|
label='Filter Berdasarkan'
|
||||||
|
placeholder='Pilih'
|
||||||
|
options={activeProjectFlockOptions}
|
||||||
|
value={activeProjectFlockValue}
|
||||||
|
onChange={handleFilterActiveProjectFlockChange}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='soft'
|
||||||
|
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||||
|
onClick={() => {
|
||||||
|
formik.resetForm();
|
||||||
|
filterModal.closeModal();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset Filter
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||||
|
disabled={!formik.isValid || formik.isSubmitting}
|
||||||
|
>
|
||||||
|
Apply Filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { string, boolean, object } from 'yup';
|
||||||
|
|
||||||
|
export const WarehouseFilterSchema = object().shape({
|
||||||
|
area_id: string().nullable(),
|
||||||
|
active_project_flock: boolean().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WarehouseFilterType = {
|
||||||
|
area_id: string | null;
|
||||||
|
active_project_flock: boolean | null;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -23,7 +23,7 @@ const ChickinLogsView = ({
|
|||||||
rawDataApprovals: BaseApproval[];
|
rawDataApprovals: BaseApproval[];
|
||||||
}) => {
|
}) => {
|
||||||
const [chickinErrorMessage, setChickinErrorMessage] = useState('');
|
const [chickinErrorMessage, setChickinErrorMessage] = useState('');
|
||||||
const { openChickinApproveModal } = useChickinStore();
|
const { openChickinApproveModal, openChickinDeleteModal } = useChickinStore();
|
||||||
|
|
||||||
const handleClickApprove = () => {
|
const handleClickApprove = () => {
|
||||||
openChickinApproveModal(initialValues, async (notes?: string) => {
|
openChickinApproveModal(initialValues, async (notes?: string) => {
|
||||||
@@ -44,6 +44,21 @@ const ChickinLogsView = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteChickin = (chickinId: number) => {
|
||||||
|
openChickinDeleteModal(chickinId, async () => {
|
||||||
|
const deleteRes = await ChickinApi.delete(chickinId);
|
||||||
|
|
||||||
|
if (isResponseSuccess(deleteRes)) {
|
||||||
|
toast.success(deleteRes?.message || 'Chickin berhasil dihapus');
|
||||||
|
afterSubmit && afterSubmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isResponseError(deleteRes)) {
|
||||||
|
toast.error(deleteRes?.message || 'Gagal menghapus chickin');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='px-4 pb-4 flex flex-col gap-4'>
|
<div className='px-4 pb-4 flex flex-col gap-4'>
|
||||||
@@ -86,6 +101,7 @@ const ChickinLogsView = ({
|
|||||||
<div className='text-lg font-semibold'>
|
<div className='text-lg font-semibold'>
|
||||||
Chick In #{index + 1} - {latestApproval?.step_number}
|
Chick In #{index + 1} - {latestApproval?.step_number}
|
||||||
</div>
|
</div>
|
||||||
|
<div className='flex flex-row gap-2 items-center'>
|
||||||
<PillBadge
|
<PillBadge
|
||||||
content={
|
content={
|
||||||
isApproved ? 'Disetujui' : isPending ? 'Pending' : '-'
|
isApproved ? 'Disetujui' : isPending ? 'Pending' : '-'
|
||||||
@@ -94,6 +110,21 @@ const ChickinLogsView = ({
|
|||||||
isApproved ? 'green' : isPending ? 'yellow' : 'gray'
|
isApproved ? 'green' : isPending ? 'yellow' : 'gray'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{isApproved && (
|
||||||
|
<Button
|
||||||
|
color='error'
|
||||||
|
className='w-fit text-sm text-base-100 rounded-lg shadow-sm btn-xs!'
|
||||||
|
onClick={() => handleDeleteChickin(chickin.id)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:trash-solid'
|
||||||
|
width={10}
|
||||||
|
height={10}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tanggal Chick In */}
|
{/* Tanggal Chick In */}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import CheckboxInput from '@/components/input/CheckboxInput';
|
import CheckboxInput from '@/components/input/CheckboxInput';
|
||||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||||
import { OptionType, useSelect } from '@/components/input/SelectInput';
|
import SelectInput, {
|
||||||
|
OptionType,
|
||||||
|
useSelect,
|
||||||
|
} from '@/components/input/SelectInput';
|
||||||
import { useModal } from '@/components/Modal';
|
import { useModal } from '@/components/Modal';
|
||||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||||
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
|
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
|
||||||
@@ -18,20 +21,30 @@ import { Kandang } from '@/types/api/master-data/kandang';
|
|||||||
import { ProjectFlock } from '@/types/api/production/project-flock';
|
import { ProjectFlock } from '@/types/api/production/project-flock';
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useUiStore } from '@/stores/ui/ui.store';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
import { useFormik } from 'formik';
|
||||||
|
|
||||||
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 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';
|
||||||
import { useProjectFlockClosingStore } from '@/stores/production/project-flock-closing/project-flock-closing.store';
|
import { useProjectFlockClosingStore } from '@/stores/production/project-flock-closing/project-flock-closing.store';
|
||||||
|
import {
|
||||||
|
ProjectFlockFilterSchema,
|
||||||
|
ProjectFlockFilterType,
|
||||||
|
} from './filter/ProjectFlockFilter';
|
||||||
|
import Modal from '@/components/Modal';
|
||||||
|
import SelectInputRadio from '@/components/input/SelectInputRadio';
|
||||||
|
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||||
|
|
||||||
const RowOptionsMenu = ({
|
const RowOptionsMenu = ({
|
||||||
props,
|
props,
|
||||||
@@ -136,6 +149,9 @@ const RowOptionsMenu = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||||
|
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
const isSuccess = useProjectFlockStore((s) => s.isSuccess);
|
const isSuccess = useProjectFlockStore((s) => s.isSuccess);
|
||||||
const setIsSuccess = useProjectFlockStore((s) => s.setIsSuccess);
|
const setIsSuccess = useProjectFlockStore((s) => s.setIsSuccess);
|
||||||
const createdProjectFlock = useProjectFlockStore(
|
const createdProjectFlock = useProjectFlockStore(
|
||||||
@@ -154,19 +170,21 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
|||||||
} = useTableFilter({
|
} = useTableFilter({
|
||||||
initial: {
|
initial: {
|
||||||
search: '',
|
search: '',
|
||||||
areaFilter: '',
|
area_id: '',
|
||||||
locationFilter: '',
|
location_id: '',
|
||||||
kandangFilter: '',
|
kandang_id: '',
|
||||||
periodFilter: '',
|
category: '',
|
||||||
|
period: '',
|
||||||
},
|
},
|
||||||
paramMap: {
|
paramMap: {
|
||||||
page: 'page',
|
page: 'page',
|
||||||
pageSize: 'limit',
|
pageSize: 'limit',
|
||||||
search: 'search',
|
search: 'search',
|
||||||
areaFilter: 'area_id',
|
area_id: 'area_id',
|
||||||
locationFilter: 'location_id',
|
location_id: 'location_id',
|
||||||
kandangFilter: 'kandang_id',
|
kandang_id: 'kandang_id',
|
||||||
periodFilter: 'period',
|
category: 'category',
|
||||||
|
period: 'period',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -182,6 +200,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
|||||||
const confirmModal = useModal();
|
const confirmModal = useModal();
|
||||||
const successModal = useModal();
|
const successModal = useModal();
|
||||||
const chickinApproveModal = useModal();
|
const chickinApproveModal = useModal();
|
||||||
|
const chickinDeleteModal = useModal();
|
||||||
const closingModal = useModal();
|
const closingModal = useModal();
|
||||||
const [approvalAction, setApprovalAction] = useState<'APPROVED' | 'REJECTED'>(
|
const [approvalAction, setApprovalAction] = useState<'APPROVED' | 'REJECTED'>(
|
||||||
'APPROVED'
|
'APPROVED'
|
||||||
@@ -196,6 +215,11 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
|||||||
chickinApproveCallback,
|
chickinApproveCallback,
|
||||||
closeChickinApproveModal,
|
closeChickinApproveModal,
|
||||||
setChickinApproveLoading,
|
setChickinApproveLoading,
|
||||||
|
isChickinDeleteModalOpen,
|
||||||
|
isChickinDeleteLoading,
|
||||||
|
chickinDeleteCallback,
|
||||||
|
closeChickinDeleteModal,
|
||||||
|
setChickinDeleteLoading,
|
||||||
} = useChickinStore();
|
} = useChickinStore();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -207,6 +231,171 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
|||||||
setClosingLoading,
|
setClosingLoading,
|
||||||
} = useProjectFlockClosingStore();
|
} = useProjectFlockClosingStore();
|
||||||
|
|
||||||
|
// ===== FILTER MODAL STATE =====
|
||||||
|
const filterModal = useModal();
|
||||||
|
|
||||||
|
// ===== FILTER DEPENDENCIES STATE =====
|
||||||
|
const [filterAreaId, setFilterAreaId] = useState<string | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
const [filterLocationId, setFilterLocationId] = useState<string | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== FORMIK SETUP FOR FILTER =====
|
||||||
|
const formik = useFormik<ProjectFlockFilterType>({
|
||||||
|
initialValues: {
|
||||||
|
area_id: null,
|
||||||
|
location_id: null,
|
||||||
|
kandang_id: null,
|
||||||
|
category: null,
|
||||||
|
period: null,
|
||||||
|
},
|
||||||
|
validationSchema: ProjectFlockFilterSchema,
|
||||||
|
onSubmit: (values, { setSubmitting }) => {
|
||||||
|
updateFilter('area_id', values.area_id || '');
|
||||||
|
updateFilter('location_id', values.location_id || '');
|
||||||
|
updateFilter('kandang_id', values.kandang_id || '');
|
||||||
|
updateFilter('category', values.category || '');
|
||||||
|
updateFilter('period', values.period || '');
|
||||||
|
filterModal.closeModal();
|
||||||
|
setSubmitting(false);
|
||||||
|
},
|
||||||
|
onReset: () => {
|
||||||
|
updateFilter('area_id', '');
|
||||||
|
updateFilter('location_id', '');
|
||||||
|
updateFilter('kandang_id', '');
|
||||||
|
updateFilter('category', '');
|
||||||
|
updateFilter('period', '');
|
||||||
|
setFilterAreaId(undefined);
|
||||||
|
setFilterLocationId(undefined);
|
||||||
|
filterModal.closeModal();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== FILTER OPTIONS =====
|
||||||
|
const {
|
||||||
|
setInputValue: setAreaInputValue,
|
||||||
|
options: areaOptions,
|
||||||
|
isLoadingOptions: isLoadingAreaOptions,
|
||||||
|
loadMore: loadMoreAreas,
|
||||||
|
} = useSelect(AreaApi.basePath, 'id', 'name');
|
||||||
|
|
||||||
|
const {
|
||||||
|
setInputValue: setLocationInputValue,
|
||||||
|
options: locationOptions,
|
||||||
|
isLoadingOptions: isLoadingLocationOptions,
|
||||||
|
loadMore: loadMoreLocations,
|
||||||
|
} = useSelect(LocationApi.basePath, 'id', 'name', 'search', {
|
||||||
|
area_id: filterAreaId || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
setInputValue: setKandangInputValue,
|
||||||
|
options: kandangOptions,
|
||||||
|
isLoadingOptions: isLoadingKandangOptions,
|
||||||
|
loadMore: loadMoreKandangs,
|
||||||
|
} = useSelect(KandangApi.basePath, 'id', 'name', 'search', {
|
||||||
|
area_id: filterAreaId || '',
|
||||||
|
location_id: filterLocationId || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const categoryOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: 'GROWING', label: 'Growing' },
|
||||||
|
{ value: 'LAYING', label: 'Laying' },
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const periodOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: '1', label: 'Periode 1' },
|
||||||
|
{ value: '2', label: 'Periode 2' },
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== FILTER HELPERS =====
|
||||||
|
const areaValue = useMemo(() => {
|
||||||
|
if (!formik.values.area_id) return null;
|
||||||
|
return (
|
||||||
|
areaOptions.find((opt) => String(opt.value) === formik.values.area_id) ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}, [formik.values.area_id, areaOptions]);
|
||||||
|
|
||||||
|
const locationValue = useMemo(() => {
|
||||||
|
if (!formik.values.location_id) return null;
|
||||||
|
return (
|
||||||
|
locationOptions.find(
|
||||||
|
(opt) => String(opt.value) === formik.values.location_id
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
}, [formik.values.location_id, locationOptions]);
|
||||||
|
|
||||||
|
const kandangValue = useMemo(() => {
|
||||||
|
if (!formik.values.kandang_id) return null;
|
||||||
|
return (
|
||||||
|
kandangOptions.find(
|
||||||
|
(opt) => String(opt.value) === formik.values.kandang_id
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
}, [formik.values.kandang_id, kandangOptions]);
|
||||||
|
|
||||||
|
const categoryValue = useMemo(() => {
|
||||||
|
if (!formik.values.category) return null;
|
||||||
|
return (
|
||||||
|
categoryOptions.find((opt) => opt.value === formik.values.category) ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}, [formik.values.category, categoryOptions]);
|
||||||
|
|
||||||
|
const periodValue = useMemo(() => {
|
||||||
|
if (!formik.values.period) return null;
|
||||||
|
return (
|
||||||
|
periodOptions.find((opt) => opt.value === formik.values.period) || null
|
||||||
|
);
|
||||||
|
}, [formik.values.period, periodOptions]);
|
||||||
|
|
||||||
|
// ===== FILTER DEPENDENCY HANDLERS =====
|
||||||
|
const handleFilterAreaChange = (area: OptionType | null) => {
|
||||||
|
const areaId = area?.value ? String(area.value) : undefined;
|
||||||
|
setFilterAreaId(areaId);
|
||||||
|
if (!areaId) {
|
||||||
|
setFilterLocationId(undefined);
|
||||||
|
formik.setFieldValue('location_id', null);
|
||||||
|
formik.setFieldValue('kandang_id', null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilterLocationChange = (location: OptionType | null) => {
|
||||||
|
const locationId = location?.value ? String(location.value) : undefined;
|
||||||
|
setFilterLocationId(locationId);
|
||||||
|
if (!locationId) {
|
||||||
|
formik.setFieldValue('kandang_id', null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== HANDLE FILTER MODAL OPEN =====
|
||||||
|
const handleFilterModalOpen = () => {
|
||||||
|
const areaId = tableFilterState.area_id || null;
|
||||||
|
const locationId = tableFilterState.location_id || null;
|
||||||
|
|
||||||
|
formik.setValues({
|
||||||
|
area_id: areaId,
|
||||||
|
location_id: locationId,
|
||||||
|
kandang_id: tableFilterState.kandang_id || null,
|
||||||
|
category: tableFilterState.category || null,
|
||||||
|
period: tableFilterState.period || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
setFilterAreaId(areaId || undefined);
|
||||||
|
setFilterLocationId(locationId || undefined);
|
||||||
|
|
||||||
|
filterModal.openModal();
|
||||||
|
};
|
||||||
|
|
||||||
// ===== Fetch Data =====
|
// ===== Fetch Data =====
|
||||||
const {
|
const {
|
||||||
data: projectFlocks,
|
data: projectFlocks,
|
||||||
@@ -237,7 +426,16 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
|||||||
setIsDeleteLoading(false);
|
setIsDeleteLoading(false);
|
||||||
setRowSelection({});
|
setRowSelection({});
|
||||||
};
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
updateFilter('search', searchValue);
|
||||||
|
}, [searchValue, updateFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTableState('project-flock-table', pathname);
|
||||||
|
}, [pathname, setTableState]);
|
||||||
|
|
||||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
|
setSearchValue(e.target.value);
|
||||||
updateFilter('search', e.target.value);
|
updateFilter('search', e.target.value);
|
||||||
};
|
};
|
||||||
const confirmApprovalHandler = async (
|
const confirmApprovalHandler = async (
|
||||||
@@ -286,6 +484,14 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
|||||||
}
|
}
|
||||||
}, [isChickinApproveModalOpen, chickinApproveModal]);
|
}, [isChickinApproveModalOpen, chickinApproveModal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isChickinDeleteModalOpen) {
|
||||||
|
chickinDeleteModal.openModal();
|
||||||
|
} else {
|
||||||
|
chickinDeleteModal.closeModal();
|
||||||
|
}
|
||||||
|
}, [isChickinDeleteModalOpen, chickinDeleteModal]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isClosingModalOpen) {
|
if (isClosingModalOpen) {
|
||||||
closingModal.openModal();
|
closingModal.openModal();
|
||||||
@@ -765,30 +971,12 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<ButtonFilter
|
||||||
variant='outline'
|
values={tableFilterState}
|
||||||
color='none'
|
excludeFields={['page', 'pageSize', 'search']}
|
||||||
// onClick={filterModal.openModal}
|
onClick={handleFilterModalOpen}
|
||||||
className={cn(
|
className='px-3 py-2.5'
|
||||||
'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': isFilterActive,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon icon='heroicons:funnel' width={20} height={20} />
|
|
||||||
Filter
|
|
||||||
{/* {isFilterActive && (
|
|
||||||
<Badge
|
|
||||||
className={{
|
|
||||||
badge:
|
|
||||||
'p-1.5 bg-[#FF3535] text-xs text-base-100 border border-base-300 rounded-lg',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{filterCount}
|
|
||||||
</Badge>
|
|
||||||
)} */}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
align='end'
|
align='end'
|
||||||
@@ -837,12 +1025,37 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-col mb-4'>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
) : !isResponseSuccess(projectFlocks) ||
|
||||||
|
projectFlocks.data?.length === 0 ? (
|
||||||
|
<div className='p-3'>
|
||||||
|
<ProjectFlockTableSkeleton
|
||||||
|
columns={columns}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
icon='heroicons:document-text'
|
||||||
|
className='text-white'
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Table<ProjectFlock>
|
<Table<ProjectFlock>
|
||||||
data={isResponseSuccess(projectFlocks) ? projectFlocks?.data : []}
|
data={
|
||||||
|
isResponseSuccess(projectFlocks) ? projectFlocks?.data : []
|
||||||
|
}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
pageSize={tableFilterState.pageSize}
|
pageSize={tableFilterState.pageSize}
|
||||||
page={
|
page={
|
||||||
isResponseSuccess(projectFlocks) ? projectFlocks?.meta?.page : 0
|
isResponseSuccess(projectFlocks)
|
||||||
|
? projectFlocks?.meta?.page
|
||||||
|
: 0
|
||||||
}
|
}
|
||||||
totalItems={
|
totalItems={
|
||||||
isResponseSuccess(projectFlocks)
|
isResponseSuccess(projectFlocks)
|
||||||
@@ -869,14 +1082,12 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
|||||||
}}
|
}}
|
||||||
withCheckbox
|
withCheckbox
|
||||||
className={{
|
className={{
|
||||||
containerClassName: cn('p-3', {
|
containerClassName: cn('p-3 mb-0'),
|
||||||
'w-full mb-20':
|
|
||||||
isResponseSuccess(projectFlocks) &&
|
|
||||||
projectFlocks?.data?.length === 0,
|
|
||||||
}),
|
|
||||||
headerColumnClassName: 'text-nowrap',
|
headerColumnClassName: 'text-nowrap',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1011,6 +1222,169 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Chickin Delete Modal */}
|
||||||
|
<ConfirmationModal
|
||||||
|
ref={chickinDeleteModal.ref}
|
||||||
|
type='error'
|
||||||
|
text='Apakah anda yakin ingin menghapus data chick in ini?'
|
||||||
|
secondaryButton={{
|
||||||
|
text: 'Tidak',
|
||||||
|
onClick: () => {
|
||||||
|
closeChickinDeleteModal();
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
className={{
|
||||||
|
modal: 'z-9999',
|
||||||
|
}}
|
||||||
|
primaryButton={{
|
||||||
|
text: 'Ya',
|
||||||
|
color: 'error',
|
||||||
|
isLoading: isChickinDeleteLoading,
|
||||||
|
onClick: async () => {
|
||||||
|
if (chickinDeleteCallback) {
|
||||||
|
setChickinDeleteLoading(true);
|
||||||
|
try {
|
||||||
|
await chickinDeleteCallback();
|
||||||
|
} finally {
|
||||||
|
setChickinDeleteLoading(false);
|
||||||
|
closeChickinDeleteModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Filter Modal */}
|
||||||
|
<Modal
|
||||||
|
ref={filterModal.ref}
|
||||||
|
className={{
|
||||||
|
modal: 'p-0',
|
||||||
|
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
|
||||||
|
<div className='flex items-center gap-2 text-primary'>
|
||||||
|
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||||
|
<h3 className='font-medium text-sm'>Filter Data</h3>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant='link'
|
||||||
|
onClick={filterModal.closeModal}
|
||||||
|
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
|
||||||
|
>
|
||||||
|
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||||
|
<div className='p-4 flex flex-col gap-1.5'>
|
||||||
|
<SelectInput
|
||||||
|
label='Area'
|
||||||
|
placeholder='Pilih Area'
|
||||||
|
options={areaOptions}
|
||||||
|
value={areaValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
if (!Array.isArray(val)) {
|
||||||
|
const areaValue = val?.value ? String(val.value) : null;
|
||||||
|
formik.setFieldValue('area_id', areaValue);
|
||||||
|
handleFilterAreaChange(val || null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onInputChange={setAreaInputValue}
|
||||||
|
isLoading={isLoadingAreaOptions}
|
||||||
|
isClearable
|
||||||
|
onMenuScrollToBottom={loadMoreAreas}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectInput
|
||||||
|
label='Lokasi'
|
||||||
|
placeholder='Pilih Lokasi'
|
||||||
|
options={locationOptions}
|
||||||
|
value={locationValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
if (!Array.isArray(val)) {
|
||||||
|
const locationValue = val?.value ? String(val.value) : null;
|
||||||
|
formik.setFieldValue('location_id', locationValue);
|
||||||
|
handleFilterLocationChange(val || null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onInputChange={setLocationInputValue}
|
||||||
|
isLoading={isLoadingLocationOptions}
|
||||||
|
isClearable
|
||||||
|
onMenuScrollToBottom={loadMoreLocations}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectInput
|
||||||
|
label='Kandang'
|
||||||
|
placeholder='Pilih Kandang'
|
||||||
|
options={kandangOptions}
|
||||||
|
value={kandangValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
if (!Array.isArray(val)) {
|
||||||
|
formik.setFieldValue(
|
||||||
|
'kandang_id',
|
||||||
|
val?.value ? String(val.value) : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onInputChange={setKandangInputValue}
|
||||||
|
isLoading={isLoadingKandangOptions}
|
||||||
|
isClearable
|
||||||
|
onMenuScrollToBottom={loadMoreKandangs}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectInputRadio
|
||||||
|
label='Kategori'
|
||||||
|
placeholder='Pilih Kategori'
|
||||||
|
options={categoryOptions}
|
||||||
|
value={categoryValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
if (!Array.isArray(val)) {
|
||||||
|
formik.setFieldValue('category', val?.value || null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
isClearable={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectInputRadio
|
||||||
|
label='Periode'
|
||||||
|
placeholder='Pilih Periode'
|
||||||
|
options={periodOptions}
|
||||||
|
value={periodValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
if (!Array.isArray(val)) {
|
||||||
|
formik.setFieldValue('period', val?.value || null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
isClearable
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||||
|
<Button
|
||||||
|
type='reset'
|
||||||
|
variant='soft'
|
||||||
|
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||||
|
>
|
||||||
|
Reset Filter
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||||
|
disabled={!formik.isValid || formik.isSubmitting}
|
||||||
|
>
|
||||||
|
Apply Filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Project Flock Closing Modal */}
|
{/* Project Flock Closing Modal */}
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
ref={closingModal.ref}
|
ref={closingModal.ref}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import Card from '@/components/Card';
|
import Card from '@/components/Card';
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/input/RadioInput';
|
import { RadioGroup, RadioGroupItem } from '@/components/input/RadioInput';
|
||||||
|
import { useSelect } from '@/components/input/SelectInput';
|
||||||
import Tooltip from '@/components/Tooltip';
|
import Tooltip from '@/components/Tooltip';
|
||||||
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
|
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
|
||||||
import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper';
|
import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper';
|
||||||
@@ -37,10 +38,37 @@ const ProjectFlockDetail = ({
|
|||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { rawData: projectFlockRawData } = useSelect<ProjectFlock>(
|
||||||
|
ProjectFlockApi.basePath,
|
||||||
|
'id',
|
||||||
|
'flock_name',
|
||||||
|
'search',
|
||||||
|
selectedKandangId ? { kandang_id: `[${selectedKandangId}]` } : undefined
|
||||||
|
);
|
||||||
|
|
||||||
const selectedKandang = projectFlock.kandangs?.find(
|
const selectedKandang = projectFlock.kandangs?.find(
|
||||||
(kandang) => kandang.id === Number(selectedKandangId)
|
(kandang) => kandang.id === Number(selectedKandangId)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Cek apakah ada project aktif di kandang yang sama (selain project saat ini)
|
||||||
|
// Hanya boleh 1 kandang aktif, jadi jika ada project lain yang aktif, tidak bisa di-unclose baik kategori growing maupun laying
|
||||||
|
const hasActiveProjectWithSameKandang = isResponseSuccess(projectFlockRawData)
|
||||||
|
? projectFlockRawData.data.some((pf) =>
|
||||||
|
pf.kandangs?.some(
|
||||||
|
(k) =>
|
||||||
|
k.id === Number(selectedKandangId) &&
|
||||||
|
pf.id !== projectFlock.id &&
|
||||||
|
k.status !== 'NON_ACTIVE'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const isCloseButtonDisabled =
|
||||||
|
!selectedKandangId ||
|
||||||
|
projectFlock?.approval?.step_number == 1 ||
|
||||||
|
(selectedKandang?.status === 'NON_ACTIVE' &&
|
||||||
|
hasActiveProjectWithSameKandang);
|
||||||
|
|
||||||
const { data: projectFlockApprovalResponse } = useSWR(
|
const { data: projectFlockApprovalResponse } = useSWR(
|
||||||
projectFlock.id ? ['approval-project-flock', projectFlock.id] : undefined,
|
projectFlock.id ? ['approval-project-flock', projectFlock.id] : undefined,
|
||||||
([, id]) => ProjectFlockApi.getApprovalLineHistory(Number(id))
|
([, id]) => ProjectFlockApi.getApprovalLineHistory(Number(id))
|
||||||
@@ -419,10 +447,7 @@ const ProjectFlockDetail = ({
|
|||||||
className='w-full px-2 py-1 text-sm'
|
className='w-full px-2 py-1 text-sm'
|
||||||
variant='outline'
|
variant='outline'
|
||||||
color='error'
|
color='error'
|
||||||
disabled={
|
disabled={isCloseButtonDisabled}
|
||||||
!selectedKandangId ||
|
|
||||||
projectFlock?.approval?.step_number == 1
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{selectedKandang?.status === 'NON_ACTIVE' ? (
|
{selectedKandang?.status === 'NON_ACTIVE' ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import * as yup from 'yup';
|
||||||
|
|
||||||
|
export type ProjectFlockFilterType = {
|
||||||
|
area_id: string | null;
|
||||||
|
location_id: string | null;
|
||||||
|
kandang_id: string | null;
|
||||||
|
category: string | null;
|
||||||
|
period: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProjectFlockFilterSchema = yup.object({
|
||||||
|
area_id: yup.string().nullable(),
|
||||||
|
location_id: yup.string().nullable(),
|
||||||
|
kandang_id: yup.string().nullable(),
|
||||||
|
category: yup.string().nullable(),
|
||||||
|
period: yup.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ProjectFlockFilterValues = yup.InferType<
|
||||||
|
typeof ProjectFlockFilterSchema
|
||||||
|
>;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user