feat(FE-208,212): add Purchase and PurchaseTable components for managing purchase requests

This commit is contained in:
rstubryan
2025-11-04 10:51:12 +07:00
parent 7c9c7eac10
commit ef339e128d
2 changed files with 457 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
import PurchaseTable from '@/components/pages/purchase/PurchaseTable';
const Purchase = () => {
return (
<section className='w-full p-4'>
<PurchaseTable />
</section>
);
};
export default Purchase;
@@ -0,0 +1,446 @@
'use client';
import { ChangeEventHandler, 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 Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import TextInput from '@/components/input/TextInput';
import CheckboxInput from '@/components/input/CheckboxInput';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import { cn, formatDate, formatCurrency } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
import { Purchase } from '@/types/api/purchase/purchase';
import { PurchaseApi } from '@/services/api/purchase';
const RowOptionsMenu = ({
type = 'dropdown',
props,
deleteClickHandler,
}: {
type: 'dropdown' | 'collapse';
props: CellContext<Purchase, unknown>;
deleteClickHandler: () => void;
}) => {
return (
<RowOptionsMenuWrapper type={type}>
<Button
href={`/purchase/request/detail/?purchaseId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
<Button
href={`/purchase/request/detail/edit/?purchaseId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
Edit
</Button>
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
</RowOptionsMenuWrapper>
);
};
const PurchaseTable = () => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
poDate: '',
supplier: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
poDate: 'po_date',
supplier: 'supplier',
},
});
const {
data: purchaseRequests,
isLoading,
mutate: refreshPurchaseRequests,
} = useSWR(
`${PurchaseApi.basePath}${getTableFilterQueryString()}`,
PurchaseApi.getAllFetcher
);
// Modal hooks
const deleteModal = useModal();
// Supplier modal
const {
setInputValue: setSupplierInputValue,
options: supplierOptions,
isLoadingOptions: isLoadingSupplierOptions,
} = useSelect('/suppliers', 'id', 'name');
// Supplier value
const [selectedSupplier, setSelectedSupplier] = useState<OptionType | null>(
null
);
// Modal loading state
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
// Selected purchase request for operations
const [selectedPurchase, setSelectedPurchase] = useState<Purchase | null>(
null
);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const selectedRowIds = Object.keys(rowSelection).map((item) =>
parseInt(item)
);
const purchaseColumns: ColumnDef<Purchase>[] = [
{
id: 'select',
header: ({ table }) => (
<div className='w-full flex flex-row justify-center'>
<CheckboxInput
name='allRow'
checked={table.getIsAllRowsSelected()}
indeterminate={table.getIsSomeRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
</div>
),
cell: ({ row }) => (
<div>
<CheckboxInput
name='row'
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
/>
</div>
),
},
{
header: 'No. PR/PO',
cell: (props) => {
const { pr_number, po_number } = props.row.original;
return po_number ? po_number : pr_number;
},
},
{
accessorKey: 'supplier',
header: 'Vendor',
cell: (props) => props.row.original.supplier.name,
},
{
accessorKey: 'created_user',
header: 'Nama Pengaju',
cell: (props) => props.row.original.created_user.name,
},
{
accessorKey: 'po_date',
header: 'Tgl. PO',
cell: (props) =>
props.row.original.po_date
? formatDate(props.row.original.po_date, 'DD-MMM-YYYY')
: '-',
},
{
accessorKey: 'due_date',
header: 'Jatuh Tempo',
cell: (props) =>
props.row.original.due_date
? formatDate(props.row.original.due_date, 'DD-MMM-YYYY')
: '-',
},
{
header: 'Aging',
cell: (props) => {
if (!props.row.original.po_date) return '-';
const poDate = new Date(props.row.original.po_date);
const today = new Date();
const diffTime = Math.abs(today.getTime() - poDate.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return `${diffDays} hari`;
},
},
{
accessorKey: 'grand_total',
header: 'Total (Rp.)',
cell: (props) => formatCurrency(props.row.original.grand_total),
},
{
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 = () => {
setSelectedPurchase(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>
)}
</>
);
},
},
];
// Modal confirm click handler
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
try {
await PurchaseApi.delete(selectedPurchase?.id as number);
refreshPurchaseRequests();
deleteModal.closeModal();
toast.success('Berhasil menghapus data permintaan pembelian!');
} catch (error) {
toast.error('Gagal menghapus data permintaan pembelian!');
}
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);
};
const poDateChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('poDate', e.target.value);
};
const supplierChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedSupplier(val as OptionType);
updateFilter('supplier', val ? ((val as OptionType).value as string) : '');
};
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col xl:flex-row justify-between items-end xl:items-center gap-2'>
<div className='w-full sm:w-fit flex flex-col sm:flex-row self-start gap-2'>
<Button
href='/purchase/request/add'
variant='outline'
color='primary'
className='w-full sm:w-fit'
>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah
</Button>
{selectedRowIds.length > 0 && (
<Button
variant='outline'
color='error'
onClick={() => {
setSelectedPurchase(
isResponseSuccess(purchaseRequests)
? purchaseRequests?.data?.find((p: Purchase) =>
selectedRowIds.includes(p.id)
) || null
: null
);
deleteModal.openModal();
}}
disabled={selectedRowIds.length === 0}
className='w-full sm:w-fit'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
/>
Hapus ({selectedRowIds.length})
</Button>
)}
</div>
<DebouncedTextInput
name='search'
placeholder='Cari Pembelian'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{
wrapper: 'sm:max-w-3xs',
}}
/>
</div>
<div className='flex flex-wrap justify-between gap-4'>
<div className='flex flex-wrap gap-4'>
<TextInput
required
type='date'
label='Tanggal PO'
name='po_date'
placeholder='Masukkan tanggal PO'
value={tableFilterState.poDate}
onChange={poDateChangeHandler}
className={{
wrapper: 'w-full sm:w-48',
}}
/>
<SelectInput
label='Vendor'
options={supplierOptions}
isLoading={isLoadingSupplierOptions}
value={selectedSupplier}
onChange={supplierChangeHandler}
onInputChange={setSupplierInputValue}
isClearable
className={{
wrapper: 'w-full sm:w-56',
}}
/>
</div>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{
wrapper: 'w-full sm:w-24',
}}
/>
</div>
</div>
<Table<Purchase>
data={
isResponseSuccess(purchaseRequests) ? purchaseRequests?.data : []
}
columns={purchaseColumns}
pageSize={tableFilterState.pageSize}
page={
isResponseSuccess(purchaseRequests)
? purchaseRequests?.meta?.page
: 0
}
totalItems={
isResponseSuccess(purchaseRequests)
? purchaseRequests?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(purchaseRequests) &&
purchaseRequests?.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>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data permintaan pembelian ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
</>
);
};
export default PurchaseTable;