diff --git a/src/app/marketing/page.tsx b/src/app/marketing/page.tsx
index c30ee501..5bdcd48f 100644
--- a/src/app/marketing/page.tsx
+++ b/src/app/marketing/page.tsx
@@ -2,7 +2,7 @@ import MarketingTable from '@/components/pages/marketing/MarketingTable';
const Marketing = () => {
return (
-
+
);
diff --git a/src/components/pages/marketing/MarketingFilter.tsx b/src/components/pages/marketing/MarketingFilter.tsx
new file mode 100644
index 00000000..3c59e07e
--- /dev/null
+++ b/src/components/pages/marketing/MarketingFilter.tsx
@@ -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
;
+ 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 (
+
+
+
+ );
+};
+
+export default MarketingFilterModal;
diff --git a/src/components/pages/marketing/MarketingTable.tsx b/src/components/pages/marketing/MarketingTable.tsx
index 8f1a6cf9..3632ae05 100644
--- a/src/components/pages/marketing/MarketingTable.tsx
+++ b/src/components/pages/marketing/MarketingTable.tsx
@@ -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;
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 (
-
-
-
-
-
- {props.row.original.latest_approval.step_number != 1 && (
- <>
-
+
+
+
+
+
+
+
+
+ {props.row.original.latest_approval.step_number != 1 && (
+ <>
+ {
- if (props.row.original.latest_approval.step_number == 2) {
- deliveryClickHandler?.();
+ >
+
-
- Deliver
-
-
- >
- )}
- {props.row.original.latest_approval.step_number != 3 && (
- <>
-
-
-
- Edit
-
-
- >
- )}
-
-
-
- Delete
-
-
-
+ 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'
+ >
+
+ Deliver Item
+
+
+ >
+ )}
+ {props.row.original.latest_approval.step_number != 3 && (
+ <>
+
+
+
+ Edit Item
+
+
+ >
+ )}
+
+
+
+ Delete Item
+
+
+
+
);
};
@@ -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) => {
- 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[]>(() => {
+ 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 (
+
+
+
+ );
+ },
+ cell: ({ row }) => {
+ const canSelect = getRowCanSelect(row);
+ return (
+
+
+
+ );
+ },
+ },
+ {
+ 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 (
+
+ );
+ },
+ },
+ {
+ 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 (
+ {
+ productsClickHandler(props?.row?.original);
+ }}
+ >
+ Lihat {props?.row?.original?.sales_order?.length} Produk
+
+ );
+ } 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 (
+
+ );
+ },
+ },
+ ];
+ }, []);
+
return (
<>
-
-
-
-
-
-
-
- Approve
-
-
-
-
-
-
- Reject
+
+
+
+
+
+
+ Add Sales Order
+ {idsToProcess.length > 0 && (
+ <>
+
+
+
+
+ Reject
+
+
+
+
+
+ Approve
+
+
+ >
+ )}
-
- {/* select multiple product */}
-
- 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
+
+
{
+ const { page, pageSize, ...rest } = tableFilterState;
+ return rest;
+ })()}
+ onClick={() => {
+ filterModal.openModal();
+ }}
+ className='rounded-lg px-3 py-2.5'
/>
- {/* select status */}
-
- option.value === Number(tableFilterState.status)
- )
- : null
+
+
+ Export
+
+
+
+
}
- onChange={(value: OptionType | OptionType[] | null) =>
- updateFilter(
- 'status',
- (value as OptionType)?.value.toString() || ''
- )
- }
- />
- {/* select customer */}
-
- option.value === Number(tableFilterState.customer_id)
- )
- : null
- }
- onChange={(value: OptionType | OptionType[] | null) =>
- updateFilter(
- 'customer_id',
- (value as OptionType)?.value.toString() || ''
- )
- }
- onInputChange={setCustomersInputValue}
- onMenuScrollToBottom={loadMoreCustomers}
- />
-
+ className={{
+ content:
+ 'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
+ }}
+ >
+
+
+ Export to Excel
+
+
+
{
- 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 (
-
-
-
- );
- },
- cell: ({ row }) => {
- const canSelect = getRowCanSelect(row);
- return (
-
-
-
- );
- },
- },
- {
- 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 (
-
-
- {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 (
- {
- productsClickHandler(props?.row?.original);
- }}
- >
- Lihat {props?.row?.original?.sales_order?.length} Produk
-
- );
- } 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 && (
-
-
-
- )}
-
- {currentPageSize <= 2 && (
-
-
-
- )}
- >
- );
- },
- },
- ]}
+ 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',
}}
/>
@@ -792,6 +753,11 @@ const MarketingTable = () => {
isLoading={isLoadingMarketing}
/>
+
>
);
};
diff --git a/src/services/api/marketing/marketing.ts b/src/services/api/marketing/marketing.ts
index 05afaa30..76f9b2ea 100644
--- a/src/services/api/marketing/marketing.ts
+++ b/src/services/api/marketing/marketing.ts
@@ -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
+ >(`${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'
-);
+export const MarketingApi = new MarketingExportService('/marketing');
diff --git a/src/types/api/marketing/marketing.d.ts b/src/types/api/marketing/marketing.d.ts
index 931184f9..7d0e390c 100644
--- a/src/types/api/marketing/marketing.d.ts
+++ b/src/types/api/marketing/marketing.d.ts
@@ -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
*/