feat: implement bulk approval for SO DO

This commit is contained in:
ValdiANS
2026-04-22 00:10:22 +07:00
parent e43a25307f
commit 50e0ccd9e4
2 changed files with 287 additions and 51 deletions
+260 -47
View File
@@ -2,6 +2,8 @@
import Button from '@/components/Button'; import Button from '@/components/Button';
import CheckboxInput from '@/components/input/CheckboxInput'; import CheckboxInput from '@/components/input/CheckboxInput';
import DateInput from '@/components/input/DateInput';
import TextArea from '@/components/input/TextArea';
import Modal, { useModal } from '@/components/Modal'; import Modal, { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
@@ -13,6 +15,7 @@ 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 { BaseApiResponse } from '@/types/api/api-general';
import { import {
BaseSalesOrder, BaseSalesOrder,
Marketing, Marketing,
@@ -21,7 +24,7 @@ import {
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { CellContext, ColumnDef, Row } from '@tanstack/react-table'; import { CellContext, ColumnDef, Row } from '@tanstack/react-table';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useMemo, useState } from 'react'; import { ChangeEventHandler, 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';
@@ -154,12 +157,17 @@ const MarketingTable = () => {
); );
const [selectedItem, setSelectedItem] = useState<Marketing | null>(null); const [selectedItem, setSelectedItem] = useState<Marketing | null>(null);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({}); const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const [bulkDeliveryDate, setBulkDeliveryDate] = useState('');
const [bulkDeliveryNotes, setBulkDeliveryNotes] = useState('');
const [isSubmittingBulkDelivery, setIsSubmittingBulkDelivery] =
useState(false);
const router = useRouter(); const router = useRouter();
const deleteModal = useModal(); const deleteModal = useModal();
const confirmationModal = useModal(); const confirmationModal = useModal();
const productsModal = useModal(); const productsModal = useModal();
const deliveryModal = useModal(); const deliveryModal = useModal();
const bulkDeliveryModal = useModal();
const filterModal = useModal(); const filterModal = useModal();
const { const {
@@ -182,6 +190,9 @@ const MarketingTable = () => {
status: 'status', status: 'status',
customer_id: 'customer_id', customer_id: 'customer_id',
}, },
persist: true,
storeName: 'marketing-table',
}); });
// ===== FETCH DATA ===== // ===== FETCH DATA =====
@@ -198,12 +209,14 @@ const MarketingTable = () => {
const filterSubmitHandler = (values: MarketingFilter) => { const filterSubmitHandler = (values: MarketingFilter) => {
updateFilter( updateFilter(
'product_ids', 'product_ids',
values.product_ids?.map((item) => item.toString()).join(',') values.product_ids?.map((item) => item.toString()).join(','),
true
); );
updateFilter('status', values.status ? values.status.toString() : ''); updateFilter('status', values.status ? values.status.toString() : '', true);
updateFilter( updateFilter(
'customer_id', 'customer_id',
values.customer_id ? values.customer_id.toString() : '' values.customer_id ? values.customer_id.toString() : '',
true
); );
}; };
@@ -211,13 +224,19 @@ const MarketingTable = () => {
useState(false); useState(false);
const filterResetHandler = () => { const filterResetHandler = () => {
updateFilter('product_ids', ''); updateFilter('product_ids', '', true);
updateFilter('status', ''); updateFilter('status', '', true);
updateFilter('customer_id', ''); updateFilter('customer_id', '', true);
}; };
const approveClickHandler = () => { const approveClickHandler = () => {
setApproveAction('APPROVED'); setApproveAction('APPROVED');
if (selectedApprovalStep === 2) {
bulkDeliveryModal.openModal();
return;
}
confirmationModal.openModal(); confirmationModal.openModal();
}; };
@@ -226,10 +245,13 @@ const MarketingTable = () => {
confirmationModal.openModal(); confirmationModal.openModal();
}; };
const productsClickHandler = (item: Marketing) => { const productsClickHandler = useCallback(
(item: Marketing) => {
setSelectedItem(item); setSelectedItem(item);
productsModal.openModal(); productsModal.openModal();
}; },
[productsModal]
);
const deleteMarketingHandler = async () => { const deleteMarketingHandler = async () => {
const deleteMarketingRes = await MarketingApi.delete( const deleteMarketingRes = await MarketingApi.delete(
@@ -251,61 +273,135 @@ const MarketingTable = () => {
const selectedRowsData = allData.filter( const selectedRowsData = allData.filter(
(row) => rowSelection[row.id.toString()] (row) => rowSelection[row.id.toString()]
); );
const selectedApprovalStep =
selectedRowsData.length > 0
? selectedRowsData[0].latest_approval.step_number
: null;
const hasApprovable = selectedRowsData.some( const eligibleSelectedRows = selectedRowsData.filter((row) => {
(row) => const approval = row.latest_approval;
row.latest_approval.step_number === 1 &&
row.latest_approval.action !== 'REJECTED' if (approval.action === 'REJECTED') {
); return false;
const hasRejectable = selectedRowsData.some( }
(row) =>
row.latest_approval.step_number === 1 && if (selectedApprovalStep === null) {
row.latest_approval.action !== 'REJECTED' return approval.step_number === 1 || approval.step_number === 2;
); }
return approval.step_number === selectedApprovalStep;
});
const hasApprovable = eligibleSelectedRows.length > 0;
const hasRejectable = eligibleSelectedRows.length > 0;
const disableApprove = !hasApprovable; const disableApprove = !hasApprovable;
const disableReject = !hasRejectable; const disableReject = !hasRejectable;
const idsToProcess = const idsToProcess = eligibleSelectedRows.map((row) => row.id);
approveAction === 'APPROVED' const nextApprovalStatus =
? selectedRowsData selectedApprovalStep === 1
.filter((row) => row.latest_approval.step_number === 1) ? 'SALES_ORDER'
.map((row) => row.id) : selectedApprovalStep === 2
: selectedRowsData ? 'DELIVERY_ORDER'
.filter((row) => row.latest_approval.step_number === 2) : null;
.map((row) => row.id);
const approveMarketingHandler = async (notes: string) => { const approveMarketingHandler = async (notes: string) => {
let idsToProcess: number[] = [];
idsToProcess = selectedRowsData
.filter((row) => row.latest_approval.step_number === 1)
.map((row) => row.id);
if (idsToProcess.length === 0) { if (idsToProcess.length === 0) {
toast.error(`Tidak ada data yang valid untuk di ${approveAction}.`); toast.error(`Tidak ada data yang valid untuk di ${approveAction}.`);
confirmationModal.closeModal(); confirmationModal.closeModal();
return; return;
} }
const approveMarketingRes = await SalesOrderApi.bulkApprovals( if (approveAction === 'APPROVED' && selectedApprovalStep !== 1) {
toast.error('Approve tahap ini harus menggunakan tanggal pengiriman.');
confirmationModal.closeModal();
return;
}
if (approveAction === 'APPROVED' && !nextApprovalStatus) {
toast.error('Status approval berikutnya tidak valid.');
confirmationModal.closeModal();
return;
}
const approveMarketingRes: BaseApiResponse<unknown> | undefined =
approveAction === 'APPROVED'
? await MarketingApi.bulkApprovals(
idsToProcess, idsToProcess,
approveAction, nextApprovalStatus as 'SALES_ORDER' | 'DELIVERY_ORDER',
notes '',
); notes || `APPROVED marketing ${idsToProcess.join(', ')}`
)
: await SalesOrderApi.bulkApprovals(idsToProcess, approveAction, notes);
if (isResponseSuccess(approveMarketingRes)) { if (isResponseSuccess(approveMarketingRes)) {
confirmationModal.closeModal(); confirmationModal.closeModal();
toast.success(approveMarketingRes?.message as string); toast.success(approveMarketingRes?.message as string);
setRowSelection({}); setRowSelection({});
} }
if (isResponseError(approveMarketingRes)) {
confirmationModal.closeModal();
toast.error(approveMarketingRes?.message as string);
}
refreshMarketing(); refreshMarketing();
}; };
const bulkDeliveryDateChangeHandler: ChangeEventHandler<HTMLInputElement> = (
e
) => {
setBulkDeliveryDate(e.target.value);
};
const bulkDeliveryNotesChangeHandler: ChangeEventHandler<
HTMLTextAreaElement
> = (e) => {
setBulkDeliveryNotes(e.target.value);
};
const submitBulkDeliveryApprovalHandler = async (
selectedIds: number[],
deliveryDate: string,
notes: string
) => {
if (selectedIds.length === 0) {
toast.error('Tidak ada data yang valid untuk diproses.');
return;
}
if (!deliveryDate) {
toast.error('Tanggal pengiriman wajib diisi.');
return;
}
setIsSubmittingBulkDelivery(true);
try {
const bulkDeliveryApprovalRes = await MarketingApi.bulkApprovals(
selectedIds,
'DELIVERY_ORDER',
deliveryDate,
notes || `APPROVED delivery marketing ${selectedIds.join(', ')}`
);
if (isResponseError(bulkDeliveryApprovalRes)) {
toast.error(bulkDeliveryApprovalRes?.message as string);
return;
}
if (!isResponseSuccess(bulkDeliveryApprovalRes)) {
toast.error('Gagal memproses bulk approve delivery.');
return;
}
toast.success(bulkDeliveryApprovalRes?.message as string);
bulkDeliveryModal.closeModal();
setBulkDeliveryDate('');
setBulkDeliveryNotes('');
setRowSelection({});
refreshMarketing();
} finally {
setIsSubmittingBulkDelivery(false);
}
};
const confirmationModalDeliveryClickHandler = async (notes: string) => { const confirmationModalDeliveryClickHandler = async (notes: string) => {
const res = await SalesOrderApi.delivery(selectedItem?.id as number, notes); const res = await SalesOrderApi.delivery(selectedItem?.id as number, notes);
deliveryModal.closeModal(); deliveryModal.closeModal();
@@ -316,10 +412,24 @@ const MarketingTable = () => {
); );
}; };
const getRowCanSelect = (row: Row<Marketing>): boolean => { const getRowCanSelect = useCallback(
(row: Row<Marketing>): boolean => {
const approval = row.original.latest_approval; const approval = row.original.latest_approval;
return approval?.step_number === 1 && approval?.action !== 'REJECTED'; const isSelectableStep =
}; approval?.step_number === 1 || approval?.step_number === 2;
if (!isSelectableStep || approval?.action === 'REJECTED') {
return false;
}
if (selectedApprovalStep === null) {
return true;
}
return approval?.step_number === selectedApprovalStep;
},
[selectedApprovalStep]
);
const exportToExcelHandler = async () => { const exportToExcelHandler = async () => {
setIsLoadingExportingToExcel(true); setIsLoadingExportingToExcel(true);
@@ -336,7 +446,22 @@ const MarketingTable = () => {
size: 1, size: 1,
header: ({ table }) => { header: ({ table }) => {
const allRows = table.getRowModel().rows; const allRows = table.getRowModel().rows;
const selectableRows = allRows.filter(getRowCanSelect); const stepForBulkSelection =
selectedApprovalStep ??
allRows.find(getRowCanSelect)?.original.latest_approval.step_number;
const selectableRows = allRows.filter((row) => {
if (!getRowCanSelect(row)) {
return false;
}
if (!stepForBulkSelection) {
return false;
}
return (
row.original.latest_approval.step_number === stepForBulkSelection
);
});
const allSelected = const allSelected =
selectableRows.length > 0 && selectableRows.length > 0 &&
@@ -504,7 +629,13 @@ const MarketingTable = () => {
}, },
}, },
]; ];
}, []); }, [
deleteModal,
deliveryModal,
getRowCanSelect,
productsClickHandler,
selectedApprovalStep,
]);
return ( return (
<> <>
@@ -677,7 +808,7 @@ const MarketingTable = () => {
<ConfirmationModalWithNotes <ConfirmationModalWithNotes
ref={confirmationModal.ref} ref={confirmationModal.ref}
type={approveAction === 'APPROVED' ? 'success' : 'error'} type={approveAction === 'APPROVED' ? 'success' : 'error'}
text={`Apakah anda yakin ingin ${approveAction == 'APPROVED' ? 'approve' : 'reject'} data penjualan (${idsToProcess.length} data)?`} text={`Apakah anda yakin ingin ${approveAction == 'APPROVED' ? 'approve' : 'reject'} data penjualan tahap ${selectedApprovalStep ?? '-'} (${idsToProcess.length} data)?`}
secondaryButton={{ secondaryButton={{
text: 'Tidak', text: 'Tidak',
onClick: confirmationModal.closeModal, onClick: confirmationModal.closeModal,
@@ -716,6 +847,88 @@ const MarketingTable = () => {
}} }}
/> />
<Modal
ref={bulkDeliveryModal.ref}
className={{
modalBox: 'max-w-lg rounded-lg p-0',
}}
closeOnBackdrop
>
<div className='flex flex-col'>
<div className='flex items-center justify-between border-b border-base-content/10 p-4'>
<h4 className='text-sm font-semibold text-base-content'>
Bulk Approve Delivery
</h4>
<Button
variant='ghost'
color='none'
onClick={() => {
bulkDeliveryModal.closeModal();
setBulkDeliveryDate('');
setBulkDeliveryNotes('');
}}
className='p-1'
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div className='flex flex-col gap-4 p-4'>
<p className='text-sm text-base-content/70'>
Pilih tanggal pengiriman untuk approve {idsToProcess.length} data
penjualan tahap 2.
</p>
<DateInput
name='bulk_delivery_date'
label='Tanggal Pengiriman'
value={bulkDeliveryDate}
onChange={bulkDeliveryDateChangeHandler}
isNestedModal
required
/>
<TextArea
name='bulk_delivery_notes'
label='Catatan'
placeholder='Masukkan catatan approval...'
value={bulkDeliveryNotes}
onChange={bulkDeliveryNotesChangeHandler}
rows={4}
/>
</div>
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
<Button
variant='outline'
color='none'
onClick={() => {
bulkDeliveryModal.closeModal();
setBulkDeliveryDate('');
setBulkDeliveryNotes('');
}}
className='px-3 py-2.5'
>
Batal
</Button>
<Button
color='success'
isLoading={isSubmittingBulkDelivery}
onClick={() =>
submitBulkDeliveryApprovalHandler(
idsToProcess,
bulkDeliveryDate,
bulkDeliveryNotes
)
}
className='px-3 py-2.5'
>
Submit
</Button>
</div>
</div>
</Modal>
<Modal <Modal
ref={productsModal.ref} ref={productsModal.ref}
className={{ className={{
+23
View File
@@ -104,6 +104,29 @@ class MarketingExportService extends BaseApiService<
super(basePath); super(basePath);
} }
async bulkApprovals(
ids: number[],
status: 'SALES_ORDER' | 'DELIVERY_ORDER',
date: string, // YYYY-MM-DD
notes: string
): Promise<BaseApiResponse<Marketing[] | Marketing> | undefined> {
try {
const path = `${this.basePath}/approvals/bulk`;
return await httpClient<BaseApiResponse<Marketing[] | Marketing>>(path, {
method: 'POST',
body: {
approvable_ids: ids,
status: status,
date: date,
notes: notes,
},
});
} catch (error) {
throw error;
}
}
/** /**
* Export to Excel * Export to Excel
*/ */