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:
Adnan Zahir
2026-03-09 06:38:03 +07:00
157 changed files with 13052 additions and 7182 deletions
+1 -6
View File
@@ -3,12 +3,7 @@
import FinanceTable from '@/components/pages/finance/FinanceTable'; import FinanceTable from '@/components/pages/finance/FinanceTable';
const Finance = () => { const Finance = () => {
return ( return <FinanceTable />;
<section className='size-full p-6'>
<div className='flex flex-row gap-4'></div>
<FinanceTable />
</section>
);
}; };
export default Finance; export default Finance;
+1 -1
View File
@@ -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 -5
View File
@@ -1,11 +1,7 @@
import AreasTable from '@/components/pages/master-data/area/AreasTable'; import AreasTable from '@/components/pages/master-data/area/AreasTable';
const Nonstock = () => { const Nonstock = () => {
return ( return <AreasTable />;
<section className='w-full p-4'>
<AreasTable />
</section>
);
}; };
export default Nonstock; export default Nonstock;
+1 -5
View File
@@ -1,11 +1,7 @@
import BanksTable from '@/components/pages/master-data/bank/BanksTable'; import BanksTable from '@/components/pages/master-data/bank/BanksTable';
const Bank = () => { const Bank = () => {
return ( return <BanksTable />;
<section className='w-full p-4'>
<BanksTable />
</section>
);
}; };
export default Bank; export default Bank;
+1 -5
View File
@@ -1,11 +1,7 @@
import CustomersTable from '@/components/pages/master-data/customer/CustomersTable'; import CustomersTable from '@/components/pages/master-data/customer/CustomersTable';
const Customer = () => { const Customer = () => {
return ( return <CustomersTable />;
<section className='w-full p-4'>
<CustomersTable />
</section>
);
}; };
export default Customer; export default Customer;
+1 -5
View File
@@ -1,11 +1,7 @@
import FlockTable from '@/components/pages/master-data/flock/FlocksTable'; import FlockTable from '@/components/pages/master-data/flock/FlocksTable';
const Flock = () => { const Flock = () => {
return ( return <FlockTable />;
<section className='w-full p-4'>
<FlockTable />
</section>
);
}; };
export default Flock; export default Flock;
+1 -5
View File
@@ -1,11 +1,7 @@
import KandangsTable from '@/components/pages/master-data/kandang/KandangsTable'; import KandangsTable from '@/components/pages/master-data/kandang/KandangsTable';
const Nonstock = () => { const Nonstock = () => {
return ( return <KandangsTable />;
<section className='w-full p-4'>
<KandangsTable />
</section>
);
}; };
export default Nonstock; export default Nonstock;
+1 -5
View File
@@ -1,11 +1,7 @@
import LocationsTable from '@/components/pages/master-data/location/LocationsTable'; import LocationsTable from '@/components/pages/master-data/location/LocationsTable';
const Nonstock = () => { const Nonstock = () => {
return ( return <LocationsTable />;
<section className='w-full p-4'>
<LocationsTable />
</section>
);
}; };
export default Nonstock; export default Nonstock;
+1 -5
View File
@@ -1,11 +1,7 @@
import NonstocksTable from '@/components/pages/master-data/nonstock/NonstocksTable'; import NonstocksTable from '@/components/pages/master-data/nonstock/NonstocksTable';
const Nonstock = () => { const Nonstock = () => {
return ( return <NonstocksTable />;
<section className='w-full p-4'>
<NonstocksTable />
</section>
);
}; };
export default Nonstock; export default Nonstock;
@@ -1,11 +1,7 @@
import ProductCategoryTable from '@/components/pages/master-data/product-category/ProductCategoryTable'; import ProductCategoryTable from '@/components/pages/master-data/product-category/ProductCategoryTable';
const ProductCategory = () => { const ProductCategory = () => {
return ( return <ProductCategoryTable />;
<section className='w-full p-4'>
<ProductCategoryTable />
</section>
);
}; };
export default ProductCategory; export default ProductCategory;
+1 -5
View File
@@ -1,11 +1,7 @@
import ProductsTable from '@/components/pages/master-data/product/ProductTable'; import ProductsTable from '@/components/pages/master-data/product/ProductTable';
const Product = () => { const Product = () => {
return ( return <ProductsTable />;
<section className='w-full p-4'>
<ProductsTable />
</section>
);
}; };
export default Product; export default Product;
@@ -1,11 +1,7 @@
import ProductionStandardTable from '@/components/pages/master-data/production-standard/ProductionStandardTable'; import ProductionStandardTable from '@/components/pages/master-data/production-standard/ProductionStandardTable';
const ProductionStandardPage = () => { const ProductionStandardPage = () => {
return ( return <ProductionStandardTable />;
<div className='w-full'>
<ProductionStandardTable />
</div>
);
}; };
export default ProductionStandardPage; export default ProductionStandardPage;
+1 -5
View File
@@ -1,11 +1,7 @@
import SuppliersTable from '@/components/pages/master-data/supplier/SupplierTable'; import SuppliersTable from '@/components/pages/master-data/supplier/SupplierTable';
const Supplier = () => { const Supplier = () => {
return ( return <SuppliersTable />;
<section className='w-full p-4'>
<SuppliersTable />
</section>
);
}; };
export default Supplier; export default Supplier;
+1 -5
View File
@@ -1,11 +1,7 @@
import UomsTable from '@/components/pages/master-data/uom/UomsTable'; import UomsTable from '@/components/pages/master-data/uom/UomsTable';
const Nonstock = () => { const Nonstock = () => {
return ( return <UomsTable />;
<section className='w-full p-4'>
<UomsTable />
</section>
);
}; };
export default Nonstock; export default Nonstock;
+1 -5
View File
@@ -1,11 +1,7 @@
import WarehousesTable from '@/components/pages/master-data/warehouse/WarehousesTable'; import WarehousesTable from '@/components/pages/master-data/warehouse/WarehousesTable';
const Warehouse = () => { const Warehouse = () => {
return ( return <WarehousesTable />;
<section className='w-full p-4'>
<WarehousesTable />
</section>
);
}; };
export default Warehouse; export default Warehouse;
+1 -1
View File
@@ -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>
); );
+3 -1
View File
@@ -108,7 +108,9 @@ const Drawer = ({
if (closeOnBackdropClick) { if (closeOnBackdropClick) {
setOpen(false); setOpen(false);
} }
onBackdropClick && onBackdropClick(); if (onBackdropClick) {
onBackdropClick();
}
}; };
return ( return (
+5 -1
View File
@@ -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(() => {
+5 -1
View File
@@ -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);
}; };
+41 -7
View File
@@ -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>
+8 -2
View File
@@ -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>
+2 -2
View File
@@ -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>
+4 -2
View File
@@ -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>
); );
+4 -2
View File
@@ -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 = [
+23 -40
View File
@@ -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 || [];
} }
+189 -211
View File
@@ -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;
+20 -15
View File
@@ -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',
}} }}
+341 -140
View File
@@ -1,10 +1,19 @@
import { useEffect, useMemo, useRef, useState } from 'react'; 'use client';
import { CellContext } from '@tanstack/react-table';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { CellContext, ColumnDef } from '@tanstack/react-table';
import useSWR from 'swr'; import useSWR from 'swr';
import { Icon } from '@iconify/react';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { cn, formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
import Button from '@/components/Button'; import Button from '@/components/Button';
import Card from '@/components/Card';
import DateInput from '@/components/input/DateInput'; import DateInput from '@/components/input/DateInput';
import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { import SelectInput, {
@@ -12,7 +21,6 @@ import SelectInput, {
useSelect, useSelect,
} from '@/components/input/SelectInput'; } from '@/components/input/SelectInput';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { Finance } from '@/types/api/finance/finance'; import { Finance } from '@/types/api/finance/finance';
import { import {
@@ -25,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
@@ -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}
@@ -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)
} }
@@ -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'
@@ -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;
@@ -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>
</> </>
); );
}; };
@@ -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;
};
@@ -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)
@@ -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;
+166 -140
View File
@@ -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