fix(FE): fixing pixel perfect marketing table

This commit is contained in:
randy-ar
2026-01-30 15:53:51 +07:00
parent 8bf0f067bb
commit 73319bbdfa
5 changed files with 694 additions and 459 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ import MarketingTable from '@/components/pages/marketing/MarketingTable';
const Marketing = () => { const Marketing = () => {
return ( return (
<div className='w-full p-4'> <div className='w-full'>
<MarketingTable /> <MarketingTable />
</div> </div>
); );
@@ -0,0 +1,186 @@
'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 SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { CustomerApi, ProductApi } from '@/services/api/master-data';
import { MARKETING_APPROVAL_LINE } from '@/config/approval-line';
import { MarketingFilter } from '@/types/api/marketing/marketing';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
interface MarketingFilterModal {
ref: RefObject<HTMLDialogElement | null>;
onSubmit?: (values: MarketingFilter) => void;
onReset?: () => void;
}
const MarketingFilterModal = ({
ref,
onSubmit,
onReset,
}: MarketingFilterModal) => {
const closeModalHandler = () => {
ref.current?.close();
};
// ===== OPTIONS =====
const {
options: productsOptions,
isLoadingOptions: isLoadingProductsOptions,
setInputValue: setProductsInputValue,
loadMore: loadMoreProducts,
} = useSelect(ProductApi.basePath, 'id', 'name', '', {
limit: 'limit',
});
const {
options: customersOptions,
isLoadingOptions: isLoadingCustomersOptions,
setInputValue: setCustomersInputValue,
loadMore: loadMoreCustomers,
} = useSelect(CustomerApi.basePath, 'id', 'name', '', {
limit: 'limit',
});
const statusOptions = MARKETING_APPROVAL_LINE.map((item) => ({
value: item.step_name.split(' ').join('_').toUpperCase(),
label: item.step_name,
}));
const formik = useFormik<{
product_ids: OptionType[];
status: OptionType | null;
customer_id: OptionType | null;
}>({
initialValues: {
product_ids: [],
status: null,
customer_id: null,
},
onSubmit: async (values) => {
const formattedValues = {
...values,
product_ids: values.product_ids.map((item) => Number(item.value)),
status: values.status?.value.toString() || '',
customer_id: Number(values.customer_id?.value),
};
onSubmit?.(formattedValues);
closeModalHandler();
},
onReset: () => {
onReset?.();
closeModalHandler();
},
});
const productChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('product_ids', val as OptionType[]);
};
const customerChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('customer_id', val as OptionType);
};
const statusChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('status', val as OptionType);
};
return (
<Modal
ref={ref}
className={{
modalBox: 'p-0 rounded-xl',
}}
>
<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-gray-300'>
<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'>
{/* select multiple product */}
<SelectInputCheckbox
label='Product'
isClearable
placeholder='Pilih product'
options={productsOptions}
isLoading={isLoadingProductsOptions}
value={formik.values.product_ids}
onChange={productChangeHandler}
onInputChange={setProductsInputValue}
onMenuScrollToBottom={loadMoreProducts}
isMulti
/>
{/* select status */}
<SelectInput
label='Status'
isClearable
placeholder='Pilih status'
options={statusOptions}
value={formik.values.status}
onChange={statusChangeHandler}
/>
{/* select customer */}
<SelectInput
label='Customer'
isClearable
placeholder='Pilih customer'
options={customersOptions}
isLoading={isLoadingCustomersOptions}
value={formik.values.customer_id}
onChange={customerChangeHandler}
onInputChange={setCustomersInputValue}
onMenuScrollToBottom={loadMoreCustomers}
/>
</div>
{/* Modal Footer */}
<div className='p-4 flex justify-between gap-4 border-t border-gray-300 bg-gray-100'>
<Button
type='reset'
variant='ghost'
color='none'
className='p-3 rounded-lg text-base-content/65'
>
Reset Filter
</Button>
<Button
type='submit'
className='p-3 rounded-lg w-fit sm:w-full max-w-40 text-base-100 text-sm'
>
Apply Filter
</Button>
</div>
</form>
</Modal>
);
};
export default MarketingFilterModal;
+420 -454
View File
@@ -22,11 +22,15 @@ import {
SalesOrderApi, SalesOrderApi,
} from '@/services/api/marketing/marketing'; } from '@/services/api/marketing/marketing';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { BaseSalesOrder, Marketing } from '@/types/api/marketing/marketing'; import {
BaseSalesOrder,
Marketing,
MarketingFilter,
} from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { CellContext, Row } from '@tanstack/react-table'; import { CellContext, ColumnDef, Row } from '@tanstack/react-table';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useCallback, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import useSWR from 'swr'; import useSWR from 'swr';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
@@ -34,100 +38,125 @@ import { useAuth } from '@/services/hooks/useAuth';
import { CustomerApi, ProductApi } from '@/services/api/master-data'; import { CustomerApi, ProductApi } from '@/services/api/master-data';
import { MARKETING_APPROVAL_LINE } from '@/config/approval-line'; import { MARKETING_APPROVAL_LINE } from '@/config/approval-line';
import Badge from '@/components/Badge'; import Badge from '@/components/Badge';
import ButtonFilter from '@/components/helper/ButtonFilter';
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
import Dropdown from '@/components/Dropdown';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import StatusBadge from '@/components/helper/StatusBadge';
import MarketingFilterModal from '@/components/pages/marketing/MarketingFilter';
const RowsOptionsMenu = ({ const RowsOptionsMenu = ({
type = 'dropdown',
props, props,
deleteClickHandler, deleteClickHandler,
deliveryClickHandler, deliveryClickHandler,
popoverPosition,
}: { }: {
type: 'dropdown' | 'collapse'; type: 'dropdown' | 'collapse';
props: CellContext<Marketing, unknown>; props: CellContext<Marketing, unknown>;
deleteClickHandler: () => void; deleteClickHandler: () => void;
deliveryClickHandler?: () => void; deliveryClickHandler?: () => void;
popoverPosition?: 'top' | 'bottom';
}) => { }) => {
const showEditButton =
props.row.original.latest_approval.action !== 'APPROVED' &&
props.row.original.latest_approval.action !== 'REJECTED';
const showDeleteButton = showEditButton;
const popoverId = `marketing#${props.row.original.id}`;
const popoverAnchorName = `--anchor-marketing#${props.row.original.id}`;
return ( return (
<div <div className='relative'>
tabIndex={type === 'dropdown' ? 0 : undefined} <PopoverButton
className={cn( tabIndex={0}
{ variant='ghost'
'dropdown-content': type === 'dropdown', color='none'
'mt-2': type === 'collapse', popoverTarget={popoverId}
}, anchorName={popoverAnchorName}
'p-2.5 mr-2 bg-base-100 rounded-box z-10 border border-black/10 shadow' >
)} <Icon icon='material-symbols:more-vert' width={16} height={16} />
> </PopoverButton>
<div className='flex flex-col gap-1'> <PopoverContent
<RequirePermission permissions='lti.marketing.delivery_order.detail'> id={popoverId}
<Button anchorName={popoverAnchorName}
href={`/marketing/detail?marketingId=${props.row.original.id}`} position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
variant='ghost' className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
color='primary' >
className='justify-start text-sm' <div className='flex flex-col bg-base-100 rounded-xl'>
> <RequirePermission permissions='lti.marketing.delivery_order.detail'>
<Icon icon='mdi:eye-outline' width={16} height={16} /> <Button
Detail href={`/marketing/detail?marketingId=${props.row.original.id}`}
</Button> variant='ghost'
</RequirePermission> color='none'
{props.row.original.latest_approval.step_number != 1 && ( className='p-3 justify-start text-sm font-semibold w-full'
<>
<RequirePermission
permissions={
props.row.original.latest_approval.step_number == 3
? 'lti.marketing.delivery_order.update'
: 'lti.marketing.delivery_order.create'
}
> >
<Button <Icon icon='heroicons:eye' width={20} height={20} />
href={ View Details
</Button>
</RequirePermission>
{props.row.original.latest_approval.step_number != 1 && (
<>
<RequirePermission
permissions={
props.row.original.latest_approval.step_number == 3 props.row.original.latest_approval.step_number == 3
? `/marketing/detail/delivery-orders/edit?marketingId=${props.row.original.id}` ? 'lti.marketing.delivery_order.update'
: props.row.original.latest_approval.step_number == 2 : 'lti.marketing.delivery_order.create'
? `/marketing/add/delivery-orders?marketingId=${props.row.original.id}`
: undefined
} }
onClick={() => { >
if (props.row.original.latest_approval.step_number == 2) { <Button
deliveryClickHandler?.(); href={
props.row.original.latest_approval.step_number == 3
? `/marketing/detail/delivery-orders/edit?marketingId=${props.row.original.id}`
: props.row.original.latest_approval.step_number == 2
? `/marketing/add/delivery-orders?marketingId=${props.row.original.id}`
: undefined
} }
}} onClick={() => {
variant='ghost' if (props.row.original.latest_approval.step_number == 2) {
color='success' deliveryClickHandler?.();
className='justify-start text-sm' }
> }}
<Icon icon='mdi:truck' width={16} height={16} /> variant='ghost'
Deliver color='none'
</Button> className='p-3 justify-start text-sm font-semibold w-full'
</RequirePermission> >
</> <Icon icon='heroicons:truck' width={20} height={20} />
)} Deliver Item
{props.row.original.latest_approval.step_number != 3 && ( </Button>
<> </RequirePermission>
<RequirePermission permissions='lti.marketing.sales_order.update'> </>
<Button )}
href={`/marketing/detail/sales-orders/edit?marketingId=${props.row.original.id}`} {props.row.original.latest_approval.step_number != 3 && (
variant='ghost' <>
color='warning' <RequirePermission permissions='lti.marketing.sales_order.update'>
className='justify-start text-sm' <Button
> href={`/marketing/detail/sales-orders/edit?marketingId=${props.row.original.id}`}
<Icon icon='mdi:pencil-outline' width={16} height={16} /> variant='ghost'
Edit color='none'
</Button> className='p-3 justify-start text-sm font-semibold w-full'
</RequirePermission> >
</> <Icon icon='heroicons:pencil-square' width={20} height={20} />
)} Edit Item
<RequirePermission permissions='lti.marketing.sales_order.delete'> </Button>
<Button </RequirePermission>
onClick={deleteClickHandler} </>
variant='ghost' )}
color='error' <RequirePermission permissions='lti.marketing.sales_order.delete'>
className='text-error hover:text-inherit justify-start text-sm' <Button
> onClick={deleteClickHandler}
<Icon icon='mdi:delete-outline' width={16} height={16} /> variant='ghost'
Delete color='none'
</Button> className='relative p-3 overflow-hidden justify-start text-sm font-semibold w-full text-error before:content-[""] before:absolute before:h-0.25 before:p-3 before:top-0 before:left-0 before:right-0 before:border-t before:border-base-content/5'
</RequirePermission> >
</div> <Icon icon='heroicons:trash' width={20} height={20} />
Delete Item
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div> </div>
); );
}; };
@@ -147,6 +176,8 @@ const MarketingTable = () => {
const confirmationModal = useModal(); const confirmationModal = useModal();
const productsModal = useModal(); const productsModal = useModal();
const deliveryModal = useModal(); const deliveryModal = useModal();
const filterModal = useModal();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -159,8 +190,6 @@ const MarketingTable = () => {
product_ids: '', product_ids: '',
status: '', status: '',
customer_id: '', customer_id: '',
page: 1,
limit: 10,
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
@@ -181,46 +210,27 @@ const MarketingTable = () => {
MarketingApi.getAllFetcher MarketingApi.getAllFetcher
); );
// ===== OPTIONS =====
const {
options: productsOptions,
isLoadingOptions: isLoadingProductsOptions,
setInputValue: setProductsInputValue,
loadMore: loadMoreProducts,
} = useSelect(ProductApi.basePath, 'id', 'name', '', {
limit: 'limit',
});
const {
options: customersOptions,
isLoadingOptions: isLoadingCustomersOptions,
setInputValue: setCustomersInputValue,
loadMore: loadMoreCustomers,
} = useSelect(CustomerApi.basePath, 'id', 'name', '', {
limit: 'limit',
});
const statusOptions = MARKETING_APPROVAL_LINE.map((item) => ({
value: item.step_number,
label: item.step_name,
}));
// ===== HANDLER ===== // ===== HANDLER =====
const searchChangeHandler = useCallback( const filterSubmitHandler = (values: MarketingFilter) => {
(e: React.ChangeEvent<HTMLInputElement>) => { updateFilter(
setSearch(e.target.value); 'product_ids',
updateFilter('page', 1); values.product_ids?.map((item) => item.toString()).join(',')
updateFilter('search', e.target.value); );
}, updateFilter('status', values.status ? values.status.toString() : '');
[] updateFilter(
); 'customer_id',
const pageSizeChangeHandler = useCallback( values.customer_id ? values.customer_id.toString() : ''
(val: OptionType | OptionType[] | null) => { );
const newVal = val as OptionType; };
setPageSize(newVal.value as number);
updateFilter('page', 1); const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
updateFilter('limit', newVal.value as number); useState(false);
},
[] const filterResetHandler = () => {
); updateFilter('product_ids', '');
updateFilter('status', '');
updateFilter('customer_id', '');
};
const approveClickHandler = () => { const approveClickHandler = () => {
setApproveAction('APPROVED'); setApproveAction('APPROVED');
@@ -327,354 +337,305 @@ const MarketingTable = () => {
return approval?.step_number === 1 && approval?.action !== 'REJECTED'; return approval?.step_number === 1 && approval?.action !== 'REJECTED';
}; };
const exportToExcelHandler = async () => {
setIsLoadingExportingToExcel(true);
await MarketingApi.exportToExcel(getTableFilterToQueryString());
setIsLoadingExportingToExcel(false);
};
const columns = useMemo<ColumnDef<Marketing>[]>(() => {
return [
{
id: 'select',
size: 1,
header: ({ table }) => {
const allRows = table.getRowModel().rows;
const selectableRows = allRows.filter(getRowCanSelect);
const allSelected =
selectableRows.length > 0 &&
selectableRows.every((row) => row.getIsSelected());
const someSelected =
selectableRows.some((row) => row.getIsSelected()) && !allSelected;
const toggleSelectableRows = () => {
const shouldSelect = !allSelected;
selectableRows.forEach((row) => row.toggleSelected(shouldSelect));
};
return (
<div className='w-fit flex flex-row justify-start'>
<CheckboxInput
name='allRow'
checked={allSelected}
indeterminate={someSelected}
onChange={toggleSelectableRows}
disabled={selectableRows.length === 0}
/>
</div>
);
},
cell: ({ row }) => {
const canSelect = getRowCanSelect(row);
return (
<div className='w-fit flex flex-row justify-start'>
<CheckboxInput
name='row'
checked={row.getIsSelected()}
disabled={!canSelect}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
/>
</div>
);
},
},
{
accessorKey: 'so_number',
header: 'No. Order',
},
{
accessorKey: 'so_date',
header: 'Tanggal',
cell: (props) => {
return formatDate(props.row.original.so_date, 'DD MMM yyyy');
},
},
{
accessorKey: 'approval.step_name',
header: 'Status',
cell: (props) => {
const approval = props.row.original.latest_approval;
const isRejected = approval?.action == 'REJECTED';
const isApproved = approval?.action == 'APPROVED';
return (
<StatusBadge
color={
isRejected
? 'error'
: isApproved
? approval?.step_number == 1
? 'neutral'
: approval?.step_number == 2
? 'primary'
: approval?.step_number == 3
? 'success'
: 'neutral'
: 'neutral'
}
text={
isRejected
? 'Ditolak'
: formatTitleCase(approval?.step_name || '')
}
/>
);
},
},
{
accessorKey: 'customer.name',
header: 'Customer',
},
{
accessorFn: (row) =>
row.sales_order
?.map((product) => product.total_price)
.reduce((a, b) => a + b, 0) ?? 0,
header: 'Grand Total',
cell: (props) => {
return formatCurrency(
props.row.original?.sales_order
?.map((product) => product.total_price)
.reduce((a, b) => a + b, 0) ?? 0
);
},
},
{
accessorKey: 'marketing_products.length',
header: 'Product Details',
cell: (props) => {
if (props?.row?.original?.sales_order?.length) {
if (props?.row?.original?.sales_order?.length > 1) {
return (
<Button
variant='link'
color='success'
className='p-0 text-none'
onClick={() => {
productsClickHandler(props?.row?.original);
}}
>
Lihat {props?.row?.original?.sales_order?.length} Produk
</Button>
);
} else {
const product = props?.row?.original?.sales_order[0];
return <>{product?.product_warehouse?.product?.name}</>;
}
}
},
},
{
id: 'actions',
maxSize: 80,
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 = () => {
setSelectedItem(props.row.original);
deleteModal.openModal();
};
const deliveryClickHandler = () => {
setSelectedItem(props.row.original);
deliveryModal.openModal();
};
return (
<RowsOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
deliveryClickHandler={deliveryClickHandler}
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
/>
);
},
},
];
}, []);
return ( return (
<> <>
<div className='flex flex-col gap-4'> <div className='flex flex-col'>
<div className='flex flex-col gap-2 mb-4'> <div className='flex flex-row justify-between p-3 border-b border-base-content/10'>
<TableToolbar <div className='flex flex-row gap-3'>
addButton={ <RequirePermission permissions='lti.marketing.sales_order.create'>
permissionCheck('lti.marketing.sales_order.create') <Button className='font-semibold text-base-100 text-sm rounded-lg shadow-button-soft px-3 py-2.5'>
? { <Icon icon='heroicons:plus' width={20} height={20} />
href: '/marketing/add/sales-orders', Add Sales Order
label: 'Tambah Sales Order',
}
: undefined
}
search={{
value: search,
onChange: searchChangeHandler,
placeholder: 'Cari Sales Order',
}}
/>
<div className='flex flex-row gap-2'>
<RequirePermission permissions='lti.marketing.sales_order.approve'>
<Button
color='success'
onClick={approveClickHandler}
className='justify-start text-sm'
disabled={disableApprove}
>
<Icon icon='material-symbols:check' width={24} height={24} />
Approve
</Button>
</RequirePermission>
<RequirePermission permissions='lti.marketing.sales_order.approve'>
<Button
color='error'
onClick={rejectClickHandler}
className='justify-start text-sm'
disabled={disableReject}
>
<Icon icon='material-symbols:close' width={24} height={24} />
Reject
</Button> </Button>
</RequirePermission> </RequirePermission>
{idsToProcess.length > 0 && (
<>
<div className='divider divider-horizontal w-0.25 p-0 m-0 bg-base-content/10 text-base-content/10 before:bg-base-content/10 before:w-0.25 after:bg-base-content/10 after:w-0.25'></div>
<RequirePermission permissions='lti.marketing.sales_order.approve'>
<Button
color='error'
onClick={rejectClickHandler}
className='justify-start text-sm shadow-button-soft rounded-lg bg-base-100 text-base-content/50 px-3 py-2.5 border border-base-content/10'
disabled={disableReject}
>
<Icon
icon='heroicons:x-mark'
className='text-error'
width={20}
height={20}
/>
Reject
</Button>
</RequirePermission>
<RequirePermission permissions='lti.marketing.sales_order.approve'>
<Button
color='none'
onClick={approveClickHandler}
className='justify-start text-sm shadow-button-soft rounded-lg bg-base-100 text-base-content/50 px-3 py-2.5 border border-base-content/10'
disabled={disableApprove}
>
<Icon
icon='heroicons:check'
className='text-success'
width={20}
height={20}
/>
Approve
</Button>
</RequirePermission>
</>
)}
</div> </div>
<TableRowSizeSelector <div className='flex flex-row gap-3'>
value={tableFilterState.pageSize} <ButtonFilter
onChange={pageSizeChangeHandler} values={(() => {
options={ROWS_OPTIONS} const { page, pageSize, ...rest } = tableFilterState;
className='flex sm:flex-row flex-col gap-3 items-end justify-end' return rest;
> })()}
{/* select multiple product */} onClick={() => {
<SelectInput filterModal.openModal();
label='Product' }}
isClearable className='rounded-lg px-3 py-2.5'
placeholder='Pilih product'
options={productsOptions}
isLoading={isLoadingProductsOptions}
value={
tableFilterState.product_ids
?.split(',')
.map((id) =>
productsOptions.find(
(option) => option.value === Number(id)
)
)
.filter(
(option): option is { value: number; label: string } =>
option !== undefined
) ?? null
}
onChange={(value: OptionType | OptionType[] | null) =>
updateFilter(
'product_ids',
(value as OptionType[])
?.map((item: OptionType) => item.value.toString())
.join(',') || ''
)
}
onInputChange={setProductsInputValue}
onMenuScrollToBottom={loadMoreProducts}
isMulti
/> />
{/* select status */} <Dropdown
<SelectInput align='end'
label='Status' direction='bottom'
isClearable trigger={
placeholder='Pilih status' <Button
options={statusOptions} variant='outline'
value={ color='none'
tableFilterState.status className={cn(
? statusOptions.find( 'px-3 py-2.5 rounded-lg font-semibold text-sm gap-1.5',
(option) => 'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft'
option.value === Number(tableFilterState.status) )}
) >
: null <Icon
width={20}
height={20}
icon='heroicons:cloud-arrow-down'
/>
Export
<div className='w-6.5 h-5 flex items-center justify-center border-l border-base-content/10'>
<Icon
width={14}
height={14}
icon='heroicons:chevron-down'
/>
</div>
</Button>
} }
onChange={(value: OptionType | OptionType[] | null) => className={{
updateFilter( content:
'status', 'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
(value as OptionType)?.value.toString() || '' }}
) >
} <Button
/> variant='ghost'
{/* select customer */} color='none'
<SelectInput onClick={exportToExcelHandler}
label='Customer' isLoading={isLoadingExportingToExcel}
isClearable className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
placeholder='Pilih customer' >
options={customersOptions} <Icon icon='heroicons:table-cells' width={20} height={20} />
isLoading={isLoadingCustomersOptions} Export to Excel
value={ </Button>
tableFilterState.customer_id </Dropdown>
? customersOptions.find( </div>
(option) =>
option.value === Number(tableFilterState.customer_id)
)
: null
}
onChange={(value: OptionType | OptionType[] | null) =>
updateFilter(
'customer_id',
(value as OptionType)?.value.toString() || ''
)
}
onInputChange={setCustomersInputValue}
onMenuScrollToBottom={loadMoreCustomers}
/>
</TableRowSizeSelector>
</div> </div>
<Table <Table
rowSelection={rowSelection} rowSelection={rowSelection}
setRowSelection={setRowSelection} setRowSelection={setRowSelection}
onPageChange={setPage}
onPageSizeChange={setPageSize}
data={allData} data={allData}
columns={[ columns={columns}
{
id: 'select',
header: ({ table }) => {
const allRows = table.getRowModel().rows;
const selectableRows = allRows.filter(getRowCanSelect);
const allSelected =
selectableRows.length > 0 &&
selectableRows.every((row) => row.getIsSelected());
const someSelected =
selectableRows.some((row) => row.getIsSelected()) &&
!allSelected;
const toggleSelectableRows = () => {
const shouldSelect = !allSelected;
selectableRows.forEach((row) =>
row.toggleSelected(shouldSelect)
);
};
return (
<div className='w-full flex flex-row justify-center'>
<CheckboxInput
name='allRow'
checked={allSelected}
indeterminate={someSelected}
onChange={toggleSelectableRows}
disabled={selectableRows.length === 0}
/>
</div>
);
},
cell: ({ row }) => {
const canSelect = getRowCanSelect(row);
return (
<div>
<CheckboxInput
name='row'
checked={row.getIsSelected()}
disabled={!canSelect}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
/>
</div>
);
},
},
{
accessorKey: 'so_number',
header: 'No. Order',
},
{
accessorKey: 'so_date',
header: 'Tanggal',
cell: (props) => {
return formatDate(props.row.original.so_date, 'DD MMM yyyy');
},
},
{
accessorKey: 'approval.step_name',
header: 'Status',
cell: (props) => {
const approval = props.row.original.latest_approval;
const isRejected = approval?.action == 'REJECTED';
const isApproved = approval?.action == 'APPROVED';
return (
<Badge
variant='soft'
className={{
badge:
'rounded-lg px-2 w-full flex flex-row justify-start whitespace-nowrap',
}}
color={
isRejected
? 'error'
: isApproved
? approval?.step_number == 1
? 'neutral'
: approval?.step_number == 2
? 'primary'
: approval?.step_number == 3
? 'success'
: 'neutral'
: 'neutral'
}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={
approval?.step_number == 1
? 'neutral'
: approval?.step_number == 2
? 'primary'
: approval?.step_number == 3
? 'success'
: 'neutral'
}
/>
{isRejected
? 'Ditolak'
: formatTitleCase(approval?.step_name || '')}
</Badge>
);
},
},
{
accessorKey: 'customer.name',
header: 'Customer',
},
{
accessorFn: (row) =>
row.sales_order
?.map((product) => product.total_price)
.reduce((a, b) => a + b, 0) ?? 0,
header: 'Grand Total',
cell: (props) => {
return formatCurrency(
props.row.original?.sales_order
?.map((product) => product.total_price)
.reduce((a, b) => a + b, 0) ?? 0
);
},
},
{
accessorKey: 'marketing_products.length',
header: 'Product Details',
cell: (props) => {
if (props?.row?.original?.sales_order?.length) {
if (props?.row?.original?.sales_order?.length > 1) {
return (
<Button
variant='link'
color='success'
className='p-0 text-none'
onClick={() => {
productsClickHandler(props?.row?.original);
}}
>
Lihat {props?.row?.original?.sales_order?.length} Produk
</Button>
);
} else {
const product = props?.row?.original?.sales_order[0];
return <>{product?.product_warehouse?.product?.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 = () => {
setSelectedItem(props.row.original);
deleteModal.openModal();
};
const deliveryClickHandler = () => {
setSelectedItem(props.row.original);
deliveryModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowsOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
deliveryClickHandler={deliveryClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowsOptionsMenu
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
deliveryClickHandler={deliveryClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
]}
pageSize={tableFilterState.pageSize} pageSize={tableFilterState.pageSize}
page={tableFilterState.page} page={tableFilterState.page}
onPageChange={setPage} isLoading={isLoadingMarketing}
className={{ className={{
tableWrapperClassName: 'overflow-x-auto min-h-full!', containerClassName: cn('p-3', {
tableClassName: 'font-inter w-full table-auto min-h-full!', 'w-full mb-20':
headerRowClassName: 'border-b border-b-gray-200', isResponseSuccess(marketing) && marketing?.data?.length === 0,
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: bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end', 'last:text-end last:w-17 first:text-start first:w-5',
}} }}
/> />
</div> </div>
@@ -792,6 +753,11 @@ const MarketingTable = () => {
isLoading={isLoadingMarketing} isLoading={isLoadingMarketing}
/> />
</Modal> </Modal>
<MarketingFilterModal
ref={filterModal.ref}
onSubmit={filterSubmitHandler}
onReset={filterResetHandler}
/>
</> </>
); );
}; };
+78 -4
View File
@@ -1,5 +1,6 @@
import { isResponseError } from '@/lib/api-helper';
import { BaseApiService } from '@/services/api/base'; import { BaseApiService } from '@/services/api/base';
import { httpClient } from '@/services/http/client'; import { httpClient, httpClientFetcher } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general'; import { BaseApiResponse } from '@/types/api/api-general';
import { import {
Marketing, Marketing,
@@ -8,6 +9,9 @@ import {
CreateDeliveryOrderPayload, CreateDeliveryOrderPayload,
UpdateDeliveryOrderPayload, UpdateDeliveryOrderPayload,
} from '@/types/api/marketing/marketing'; } from '@/types/api/marketing/marketing';
import toast from 'react-hot-toast';
import * as XLSX from 'xlsx';
import { formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
/** /**
* 💡 Helper untuk membuat respons dummy * 💡 Helper untuk membuat respons dummy
@@ -97,6 +101,78 @@ export class SalesOrderService extends BaseApiService<
} }
} }
} }
class MarketingExportService extends BaseApiService<
Marketing,
unknown,
unknown
> {
constructor(basePath: string = '/marketing') {
super(basePath);
}
/**
* Export to Excel
*/
async exportToExcel(initialQueryString: string) {
const params = new URLSearchParams(initialQueryString);
const queryString = `?${params.toString()}`;
try {
const marketingData = await httpClientFetcher<
BaseApiResponse<Marketing[]>
>(`${this.basePath}${queryString}`);
if (isResponseError(marketingData)) {
toast.error('Gagal melakukan export marketing! Coba lagi.');
return;
}
const rows = marketingData.data;
const formattedRows = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const approval = row.latest_approval;
const isRejected = approval?.action === 'REJECTED';
// Calculate grand total from sales_order products
const grandTotal =
row.sales_order
?.map((product) => product.total_price)
.reduce((a, b) => a + b, 0) ?? 0;
// Get product names
const products =
row.sales_order
?.map((product) => product.product_warehouse?.product?.name)
.filter(Boolean)
.join(', ') ?? '';
formattedRows.push({
'No. Order': row.so_number,
Tanggal: formatDate(row.so_date, 'DD-MM-YYYY'),
Status: isRejected
? 'Ditolak'
: formatTitleCase(approval?.step_name || ''),
Customer: row.customer?.name || '',
'Grand Total': formatCurrency(grandTotal),
Products: products,
Notes: row.notes || '',
});
}
const ws = XLSX.utils.json_to_sheet(formattedRows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'marketing');
// triggers download in browser
XLSX.writeFile(wb, 'marketing.xlsx');
} catch (error) {
toast.error('Gagal melakukan export marketing! Coba lagi.');
}
}
}
export const SalesOrderApi = new SalesOrderService('/marketing/sales-orders'); export const SalesOrderApi = new SalesOrderService('/marketing/sales-orders');
export const DeliveryOrderApi = new BaseApiService< export const DeliveryOrderApi = new BaseApiService<
@@ -104,6 +180,4 @@ export const DeliveryOrderApi = new BaseApiService<
CreateDeliveryOrderPayload, CreateDeliveryOrderPayload,
UpdateDeliveryOrderPayload UpdateDeliveryOrderPayload
>('/marketing/delivery-orders'); >('/marketing/delivery-orders');
export const MarketingApi = new BaseApiService<Marketing, unknown, unknown>( export const MarketingApi = new MarketingExportService('/marketing');
'/marketing'
);
+9
View File
@@ -82,6 +82,15 @@ export type MarketingDeliveryProducts = {
export type Marketing = BaseMetadata & BaseMarketing; export type Marketing = BaseMetadata & BaseMarketing;
/**
* Filter Data Types
*/
export type MarketingFilter = {
product_ids: number[];
status: string;
customer_id: number;
};
/** /**
* Base Data Payload * Base Data Payload
*/ */