mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
fix(FE): fixing pixel perfect marketing table
This commit is contained in:
@@ -2,7 +2,7 @@ import MarketingTable from '@/components/pages/marketing/MarketingTable';
|
||||
|
||||
const Marketing = () => {
|
||||
return (
|
||||
<div className='w-full p-4'>
|
||||
<div className='w-full'>
|
||||
<MarketingTable />
|
||||
</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;
|
||||
@@ -22,11 +22,15 @@ import {
|
||||
SalesOrderApi,
|
||||
} from '@/services/api/marketing/marketing';
|
||||
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 { CellContext, Row } from '@tanstack/react-table';
|
||||
import { CellContext, ColumnDef, Row } from '@tanstack/react-table';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import useSWR from 'swr';
|
||||
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 { MARKETING_APPROVAL_LINE } from '@/config/approval-line';
|
||||
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 = ({
|
||||
type = 'dropdown',
|
||||
props,
|
||||
deleteClickHandler,
|
||||
deliveryClickHandler,
|
||||
popoverPosition,
|
||||
}: {
|
||||
type: 'dropdown' | 'collapse';
|
||||
props: CellContext<Marketing, unknown>;
|
||||
deleteClickHandler: () => 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 (
|
||||
<div
|
||||
tabIndex={type === 'dropdown' ? 0 : undefined}
|
||||
className={cn(
|
||||
{
|
||||
'dropdown-content': type === 'dropdown',
|
||||
'mt-2': type === 'collapse',
|
||||
},
|
||||
'p-2.5 mr-2 bg-base-100 rounded-box z-10 border border-black/10 shadow'
|
||||
)}
|
||||
>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<RequirePermission permissions='lti.marketing.delivery_order.detail'>
|
||||
<Button
|
||||
href={`/marketing/detail?marketingId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='primary'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
||||
Detail
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
{props.row.original.latest_approval.step_number != 1 && (
|
||||
<>
|
||||
<RequirePermission
|
||||
permissions={
|
||||
props.row.original.latest_approval.step_number == 3
|
||||
? 'lti.marketing.delivery_order.update'
|
||||
: 'lti.marketing.delivery_order.create'
|
||||
}
|
||||
<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.marketing.delivery_order.detail'>
|
||||
<Button
|
||||
href={`/marketing/detail?marketingId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
>
|
||||
<Button
|
||||
href={
|
||||
<Icon icon='heroicons:eye' width={20} height={20} />
|
||||
View Details
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
{props.row.original.latest_approval.step_number != 1 && (
|
||||
<>
|
||||
<RequirePermission
|
||||
permissions={
|
||||
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
|
||||
? 'lti.marketing.delivery_order.update'
|
||||
: 'lti.marketing.delivery_order.create'
|
||||
}
|
||||
onClick={() => {
|
||||
if (props.row.original.latest_approval.step_number == 2) {
|
||||
deliveryClickHandler?.();
|
||||
>
|
||||
<Button
|
||||
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
|
||||
}
|
||||
}}
|
||||
variant='ghost'
|
||||
color='success'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon icon='mdi:truck' width={16} height={16} />
|
||||
Deliver
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</>
|
||||
)}
|
||||
{props.row.original.latest_approval.step_number != 3 && (
|
||||
<>
|
||||
<RequirePermission permissions='lti.marketing.sales_order.update'>
|
||||
<Button
|
||||
href={`/marketing/detail/sales-orders/edit?marketingId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='warning'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon icon='mdi:pencil-outline' width={16} height={16} />
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</>
|
||||
)}
|
||||
<RequirePermission permissions='lti.marketing.sales_order.delete'>
|
||||
<Button
|
||||
onClick={deleteClickHandler}
|
||||
variant='ghost'
|
||||
color='error'
|
||||
className='text-error hover:text-inherit justify-start text-sm'
|
||||
>
|
||||
<Icon icon='mdi:delete-outline' width={16} height={16} />
|
||||
Delete
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
onClick={() => {
|
||||
if (props.row.original.latest_approval.step_number == 2) {
|
||||
deliveryClickHandler?.();
|
||||
}
|
||||
}}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
>
|
||||
<Icon icon='heroicons:truck' width={20} height={20} />
|
||||
Deliver Item
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</>
|
||||
)}
|
||||
{props.row.original.latest_approval.step_number != 3 && (
|
||||
<>
|
||||
<RequirePermission permissions='lti.marketing.sales_order.update'>
|
||||
<Button
|
||||
href={`/marketing/detail/sales-orders/edit?marketingId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
>
|
||||
<Icon icon='heroicons:pencil-square' width={20} height={20} />
|
||||
Edit Item
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</>
|
||||
)}
|
||||
<RequirePermission permissions='lti.marketing.sales_order.delete'>
|
||||
<Button
|
||||
onClick={deleteClickHandler}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
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'
|
||||
>
|
||||
<Icon icon='heroicons:trash' width={20} height={20} />
|
||||
Delete Item
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -147,6 +176,8 @@ const MarketingTable = () => {
|
||||
const confirmationModal = useModal();
|
||||
const productsModal = useModal();
|
||||
const deliveryModal = useModal();
|
||||
const filterModal = useModal();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -159,8 +190,6 @@ const MarketingTable = () => {
|
||||
product_ids: '',
|
||||
status: '',
|
||||
customer_id: '',
|
||||
page: 1,
|
||||
limit: 10,
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
@@ -181,46 +210,27 @@ const MarketingTable = () => {
|
||||
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 =====
|
||||
const searchChangeHandler = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearch(e.target.value);
|
||||
updateFilter('page', 1);
|
||||
updateFilter('search', e.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const pageSizeChangeHandler = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const newVal = val as OptionType;
|
||||
setPageSize(newVal.value as number);
|
||||
updateFilter('page', 1);
|
||||
updateFilter('limit', newVal.value as number);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const filterSubmitHandler = (values: MarketingFilter) => {
|
||||
updateFilter(
|
||||
'product_ids',
|
||||
values.product_ids?.map((item) => item.toString()).join(',')
|
||||
);
|
||||
updateFilter('status', values.status ? values.status.toString() : '');
|
||||
updateFilter(
|
||||
'customer_id',
|
||||
values.customer_id ? values.customer_id.toString() : ''
|
||||
);
|
||||
};
|
||||
|
||||
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
|
||||
useState(false);
|
||||
|
||||
const filterResetHandler = () => {
|
||||
updateFilter('product_ids', '');
|
||||
updateFilter('status', '');
|
||||
updateFilter('customer_id', '');
|
||||
};
|
||||
|
||||
const approveClickHandler = () => {
|
||||
setApproveAction('APPROVED');
|
||||
@@ -327,354 +337,305 @@ const MarketingTable = () => {
|
||||
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 (
|
||||
<>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='flex flex-col gap-2 mb-4'>
|
||||
<TableToolbar
|
||||
addButton={
|
||||
permissionCheck('lti.marketing.sales_order.create')
|
||||
? {
|
||||
href: '/marketing/add/sales-orders',
|
||||
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
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex flex-row justify-between p-3 border-b border-base-content/10'>
|
||||
<div className='flex flex-row gap-3'>
|
||||
<RequirePermission permissions='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} />
|
||||
Add Sales Order
|
||||
</Button>
|
||||
</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>
|
||||
<TableRowSizeSelector
|
||||
value={tableFilterState.pageSize}
|
||||
onChange={pageSizeChangeHandler}
|
||||
options={ROWS_OPTIONS}
|
||||
className='flex sm:flex-row flex-col gap-3 items-end justify-end'
|
||||
>
|
||||
{/* select multiple product */}
|
||||
<SelectInput
|
||||
label='Product'
|
||||
isClearable
|
||||
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
|
||||
<div className='flex flex-row gap-3'>
|
||||
<ButtonFilter
|
||||
values={(() => {
|
||||
const { page, pageSize, ...rest } = tableFilterState;
|
||||
return rest;
|
||||
})()}
|
||||
onClick={() => {
|
||||
filterModal.openModal();
|
||||
}}
|
||||
className='rounded-lg px-3 py-2.5'
|
||||
/>
|
||||
{/* select status */}
|
||||
<SelectInput
|
||||
label='Status'
|
||||
isClearable
|
||||
placeholder='Pilih status'
|
||||
options={statusOptions}
|
||||
value={
|
||||
tableFilterState.status
|
||||
? statusOptions.find(
|
||||
(option) =>
|
||||
option.value === Number(tableFilterState.status)
|
||||
)
|
||||
: null
|
||||
<Dropdown
|
||||
align='end'
|
||||
direction='bottom'
|
||||
trigger={
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
className={cn(
|
||||
'px-3 py-2.5 rounded-lg font-semibold text-sm gap-1.5',
|
||||
'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft'
|
||||
)}
|
||||
>
|
||||
<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) =>
|
||||
updateFilter(
|
||||
'status',
|
||||
(value as OptionType)?.value.toString() || ''
|
||||
)
|
||||
}
|
||||
/>
|
||||
{/* select customer */}
|
||||
<SelectInput
|
||||
label='Customer'
|
||||
isClearable
|
||||
placeholder='Pilih customer'
|
||||
options={customersOptions}
|
||||
isLoading={isLoadingCustomersOptions}
|
||||
value={
|
||||
tableFilterState.customer_id
|
||||
? customersOptions.find(
|
||||
(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>
|
||||
className={{
|
||||
content:
|
||||
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={exportToExcelHandler}
|
||||
isLoading={isLoadingExportingToExcel}
|
||||
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||
>
|
||||
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||
Export to Excel
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<Table
|
||||
rowSelection={rowSelection}
|
||||
setRowSelection={setRowSelection}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
data={allData}
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
columns={columns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={tableFilterState.page}
|
||||
onPageChange={setPage}
|
||||
isLoading={isLoadingMarketing}
|
||||
className={{
|
||||
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',
|
||||
containerClassName: cn('p-3', {
|
||||
'w-full mb-20':
|
||||
isResponseSuccess(marketing) && marketing?.data?.length === 0,
|
||||
}),
|
||||
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>
|
||||
@@ -792,6 +753,11 @@ const MarketingTable = () => {
|
||||
isLoading={isLoadingMarketing}
|
||||
/>
|
||||
</Modal>
|
||||
<MarketingFilterModal
|
||||
ref={filterModal.ref}
|
||||
onSubmit={filterSubmitHandler}
|
||||
onReset={filterResetHandler}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { isResponseError } from '@/lib/api-helper';
|
||||
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 {
|
||||
Marketing,
|
||||
@@ -8,6 +9,9 @@ import {
|
||||
CreateDeliveryOrderPayload,
|
||||
UpdateDeliveryOrderPayload,
|
||||
} 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
|
||||
@@ -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 DeliveryOrderApi = new BaseApiService<
|
||||
@@ -104,6 +180,4 @@ export const DeliveryOrderApi = new BaseApiService<
|
||||
CreateDeliveryOrderPayload,
|
||||
UpdateDeliveryOrderPayload
|
||||
>('/marketing/delivery-orders');
|
||||
export const MarketingApi = new BaseApiService<Marketing, unknown, unknown>(
|
||||
'/marketing'
|
||||
);
|
||||
export const MarketingApi = new MarketingExportService('/marketing');
|
||||
|
||||
+9
@@ -82,6 +82,15 @@ export type MarketingDeliveryProducts = {
|
||||
|
||||
export type Marketing = BaseMetadata & BaseMarketing;
|
||||
|
||||
/**
|
||||
* Filter Data Types
|
||||
*/
|
||||
export type MarketingFilter = {
|
||||
product_ids: number[];
|
||||
status: string;
|
||||
customer_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Base Data Payload
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user