mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
Merge branch 'development' into 'production'
Development See merge request mbugroup/lti-web-client!420
This commit is contained in:
@@ -523,7 +523,7 @@ const useSelect = <T,>(
|
||||
|
||||
const qs = new URLSearchParams({
|
||||
...(params ?? {}),
|
||||
[searchKey]: inputValue ?? '',
|
||||
[searchKey ? searchKey : 'search']: inputValue ?? '',
|
||||
[pageKey]: String(pageIndex + 1),
|
||||
[limitKey]: String(limit),
|
||||
}).toString();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import axios from 'axios';
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
useCallback,
|
||||
@@ -7,8 +8,6 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import useSWR from 'swr';
|
||||
import {
|
||||
CellContext,
|
||||
@@ -21,8 +20,11 @@ import toast from 'react-hot-toast';
|
||||
import { Icon } from '@iconify/react';
|
||||
import Table from '@/components/Table';
|
||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import SelectInput, { OptionType } from '@/components/input/SelectInput';
|
||||
import TextArea from '@/components/input/TextArea';
|
||||
import Button from '@/components/Button';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import Modal, { useModal } from '@/components/Modal';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import PopoverButton from '@/components/popover/PopoverButton';
|
||||
import PopoverContent from '@/components/popover/PopoverContent';
|
||||
@@ -34,10 +36,10 @@ import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||
import ExpensesFilterModal from '@/components/pages/expense/filter/ExpensesFilterModal';
|
||||
import ExpenseTableSkeleton from '@/components/pages/expense/skeleton/ExpenseTableSkeleton';
|
||||
import Dropdown from '@/components/dropdown/Dropdown';
|
||||
|
||||
import { Expense } from '@/types/api/expense';
|
||||
import { ExpenseApi } from '@/services/api/expense';
|
||||
import { buildExpenseActionHref } from '@/lib/expense-list-navigation';
|
||||
import { cn, formatCurrency, formatDate } from '@/lib/helper';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
@@ -53,16 +55,71 @@ type ExpenseTableFilters = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
const approvalStatusOptions = [
|
||||
{ value: 'HEAD_AREA', label: 'Approval Head Area' },
|
||||
{ value: 'UNIT_VICE_PRESIDENT', label: 'Approval Unit Vice President' },
|
||||
{ value: 'FINANCE', label: 'Approval Finance' },
|
||||
{ value: 'REALISASI', label: 'Realisasi' },
|
||||
{ value: 'SELESAI', label: 'Selesai' },
|
||||
] as const satisfies OptionType<
|
||||
'HEAD_AREA' | 'UNIT_VICE_PRESIDENT' | 'FINANCE' | 'REALISASI' | 'SELESAI'
|
||||
>[];
|
||||
|
||||
type ApprovalStatusValue =
|
||||
| 'HEAD_AREA'
|
||||
| 'UNIT_VICE_PRESIDENT'
|
||||
| 'FINANCE'
|
||||
| 'REALISASI'
|
||||
| 'SELESAI';
|
||||
|
||||
const isApprovalDateRequired = (status?: ApprovalStatusValue) =>
|
||||
status === 'REALISASI' || status === 'SELESAI';
|
||||
|
||||
const getExportErrorMessage = async (
|
||||
error: unknown,
|
||||
fallbackMessage: string
|
||||
) => {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const responseData = error.response?.data;
|
||||
|
||||
if (responseData instanceof Blob) {
|
||||
try {
|
||||
const parsed = JSON.parse(await responseData.text()) as {
|
||||
message?: string;
|
||||
};
|
||||
return parsed.message || fallbackMessage;
|
||||
} catch {
|
||||
return fallbackMessage;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
responseData &&
|
||||
typeof responseData === 'object' &&
|
||||
'message' in responseData &&
|
||||
typeof responseData.message === 'string'
|
||||
) {
|
||||
return responseData.message;
|
||||
}
|
||||
|
||||
return error.message || fallbackMessage;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallbackMessage;
|
||||
};
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
popoverPosition = 'bottom',
|
||||
props,
|
||||
deleteClickHandler,
|
||||
returnToSearchParams,
|
||||
}: {
|
||||
popoverPosition: 'bottom' | 'top';
|
||||
props: CellContext<Expense, unknown>;
|
||||
deleteClickHandler: () => void;
|
||||
returnToSearchParams: URLSearchParams;
|
||||
}) => {
|
||||
const popoverId = `expense#${props.row.original.id}`;
|
||||
const popoverAnchorName = `--anchor-expense#${props.row.original.id}`;
|
||||
@@ -105,11 +162,7 @@ const RowOptionsMenu = ({
|
||||
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||
<RequirePermission permissions='lti.expense.detail'>
|
||||
<Button
|
||||
href={buildExpenseActionHref(
|
||||
'/expense/detail/',
|
||||
props.row.original.id,
|
||||
returnToSearchParams
|
||||
)}
|
||||
href={`/expense/detail/?expenseId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
@@ -123,11 +176,7 @@ const RowOptionsMenu = ({
|
||||
{showEditButton && (
|
||||
<RequirePermission permissions='lti.expense.update'>
|
||||
<Button
|
||||
href={buildExpenseActionHref(
|
||||
'/expense/detail/edit/',
|
||||
props.row.original.id,
|
||||
returnToSearchParams
|
||||
)}
|
||||
href={`/expense/detail/edit/?expenseId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
@@ -142,11 +191,7 @@ const RowOptionsMenu = ({
|
||||
{showRealizationButton && (
|
||||
<RequirePermission permissions='lti.expense.create.realization'>
|
||||
<Button
|
||||
href={buildExpenseActionHref(
|
||||
'/expense/realization/',
|
||||
props.row.original.id,
|
||||
returnToSearchParams
|
||||
)}
|
||||
href={`/expense/realization/?expenseId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
@@ -184,11 +229,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const ExpensesTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -197,9 +237,9 @@ const ExpensesTable = () => {
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter<ExpenseTableFilters>({
|
||||
initial: {
|
||||
page: Number(searchParams.get('page')) || 1,
|
||||
pageSize: Number(searchParams.get('limit')) || 10,
|
||||
search: searchValue,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
search: '',
|
||||
nameSort: '',
|
||||
transactionDate: '',
|
||||
realizationDate: '',
|
||||
@@ -217,6 +257,9 @@ const ExpensesTable = () => {
|
||||
vendorId: 'vendor_id',
|
||||
userId: 'user_id',
|
||||
},
|
||||
|
||||
persist: true,
|
||||
storeName: 'expense-table',
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -228,57 +271,11 @@ const ExpensesTable = () => {
|
||||
ExpenseApi.getAllFetcher
|
||||
);
|
||||
|
||||
const syncPaginationToUrl = useCallback(
|
||||
(page: number, pageSize: number) => {
|
||||
const nextQueryString = new URLSearchParams({
|
||||
page: String(page),
|
||||
limit: String(pageSize),
|
||||
}).toString();
|
||||
|
||||
router.replace(
|
||||
nextQueryString ? `${pathname}?${nextQueryString}` : pathname,
|
||||
{
|
||||
scroll: false,
|
||||
}
|
||||
);
|
||||
},
|
||||
[pathname, router]
|
||||
);
|
||||
|
||||
const pageChangeHandler = useCallback(
|
||||
(page: number) => {
|
||||
setPage(page);
|
||||
syncPaginationToUrl(page, tableFilterState.pageSize);
|
||||
},
|
||||
[setPage, syncPaginationToUrl, tableFilterState.pageSize]
|
||||
);
|
||||
|
||||
const pageSizeChangeHandler = useCallback(
|
||||
(pageSize: number) => {
|
||||
setPageSize(pageSize);
|
||||
syncPaginationToUrl(1, pageSize);
|
||||
},
|
||||
[setPageSize, syncPaginationToUrl]
|
||||
);
|
||||
|
||||
const returnToSearchParams = useMemo(() => {
|
||||
const returnToParams = new URLSearchParams();
|
||||
const queryString = new URLSearchParams({
|
||||
page: String(tableFilterState.page),
|
||||
limit: String(tableFilterState.pageSize),
|
||||
}).toString();
|
||||
|
||||
returnToParams.set(
|
||||
'returnTo',
|
||||
queryString ? `${pathname}?${queryString}` : pathname
|
||||
);
|
||||
|
||||
return returnToParams;
|
||||
}, [pathname, tableFilterState.page, tableFilterState.pageSize]);
|
||||
|
||||
const deleteModal = useModal();
|
||||
const approveModal = useModal();
|
||||
const rejectModal = useModal();
|
||||
const bulkApproveFormModal = useModal();
|
||||
const exportProgressInputModal = useModal();
|
||||
|
||||
// ===== FILTER MODAL STATE =====
|
||||
const filterModal = useModal();
|
||||
@@ -289,7 +286,14 @@ const ExpensesTable = () => {
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
const [isApproveLoading, setIsApproveLoading] = useState(false);
|
||||
const [isRejectLoading, setIsRejectLoading] = useState(false);
|
||||
const [isExportProgressLoading, setIsExportProgressLoading] = useState(false);
|
||||
const [, setApprovalNotes] = useState('');
|
||||
const [bulkApprovalStatus, setBulkApprovalStatus] =
|
||||
useState<OptionType<ApprovalStatusValue> | null>(null);
|
||||
const [bulkApprovalDate, setBulkApprovalDate] = useState('');
|
||||
const [bulkApprovalNotes, setBulkApprovalNotes] = useState('');
|
||||
const [exportProgressStartDate, setExportProgressStartDate] = useState('');
|
||||
const [exportProgressEndDate, setExportProgressEndDate] = useState('');
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||
@@ -456,7 +460,6 @@ const ExpensesTable = () => {
|
||||
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
|
||||
props={props}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
returnToSearchParams={returnToSearchParams}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -474,17 +477,45 @@ const ExpensesTable = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// const bulkApproveClickHandler = () => {
|
||||
// approveModal.openModal();
|
||||
// };
|
||||
const resetBulkApproveForm = useCallback(() => {
|
||||
setBulkApprovalStatus(null);
|
||||
setBulkApprovalDate('');
|
||||
setBulkApprovalNotes('');
|
||||
}, []);
|
||||
|
||||
// const bulkRejectClickHandler = () => {
|
||||
// rejectModal.openModal();
|
||||
// };
|
||||
const openBulkApproveForm = useCallback(
|
||||
(presetStatus?: ApprovalStatusValue) => {
|
||||
resetBulkApproveForm();
|
||||
|
||||
if (presetStatus) {
|
||||
const selectedStatus = approvalStatusOptions.find(
|
||||
(option) => option.value === presetStatus
|
||||
);
|
||||
|
||||
if (selectedStatus) {
|
||||
setBulkApprovalStatus(selectedStatus);
|
||||
}
|
||||
}
|
||||
|
||||
bulkApproveFormModal.openModal();
|
||||
},
|
||||
[bulkApproveFormModal, resetBulkApproveForm]
|
||||
);
|
||||
|
||||
const bulkApproveClickHandler = () => {
|
||||
setApprovalNotes('');
|
||||
approveModal.openModal();
|
||||
openBulkApproveForm();
|
||||
};
|
||||
|
||||
const bulkApproveHeadAreaClickHandler = () => {
|
||||
openBulkApproveForm('HEAD_AREA');
|
||||
};
|
||||
|
||||
const bulkApproveUnitVicePresidentClickHandler = () => {
|
||||
openBulkApproveForm('UNIT_VICE_PRESIDENT');
|
||||
};
|
||||
|
||||
const bulkApproveFinanceClickHandler = () => {
|
||||
openBulkApproveForm('FINANCE');
|
||||
};
|
||||
|
||||
const bulkRejectClickHandler = () => {
|
||||
@@ -492,6 +523,65 @@ const ExpensesTable = () => {
|
||||
rejectModal.openModal();
|
||||
};
|
||||
|
||||
const bulkApprovalDateChangeHandler: ChangeEventHandler<HTMLInputElement> = (
|
||||
e
|
||||
) => {
|
||||
setBulkApprovalDate(e.target.value);
|
||||
};
|
||||
|
||||
const bulkApprovalNotesChangeHandler: ChangeEventHandler<
|
||||
HTMLTextAreaElement
|
||||
> = (e) => {
|
||||
setBulkApprovalNotes(e.target.value);
|
||||
};
|
||||
|
||||
const resetExportProgressForm = useCallback(() => {
|
||||
setExportProgressStartDate('');
|
||||
setExportProgressEndDate('');
|
||||
}, []);
|
||||
|
||||
const exportProgressStartDateChangeHandler: ChangeEventHandler<
|
||||
HTMLInputElement
|
||||
> = (e) => {
|
||||
setExportProgressStartDate(e.target.value);
|
||||
};
|
||||
|
||||
const exportProgressEndDateChangeHandler: ChangeEventHandler<
|
||||
HTMLInputElement
|
||||
> = (e) => {
|
||||
setExportProgressEndDate(e.target.value);
|
||||
};
|
||||
|
||||
const exportProgressInputToExcelClickHandler = () => {
|
||||
resetExportProgressForm();
|
||||
exportProgressInputModal.openModal();
|
||||
};
|
||||
|
||||
const submitExportProgressInputHandler = async () => {
|
||||
if (!exportProgressStartDate || !exportProgressEndDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExportProgressLoading(true);
|
||||
|
||||
try {
|
||||
await ExpenseApi.exportInputProgressToExcel(
|
||||
exportProgressStartDate,
|
||||
exportProgressEndDate
|
||||
);
|
||||
|
||||
exportProgressInputModal.closeModal();
|
||||
resetExportProgressForm();
|
||||
toast.success('Ekspor berhasil');
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
await getExportErrorMessage(error, 'Gagal mengekspor input progress')
|
||||
);
|
||||
} finally {
|
||||
setIsExportProgressLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
@@ -554,6 +644,48 @@ const ExpensesTable = () => {
|
||||
setIsApproveLoading(false);
|
||||
};
|
||||
|
||||
const bulkApproveSubmitHandler = async () => {
|
||||
if (!bulkApprovalStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isApprovalDateRequired(bulkApprovalStatus.value) && !bulkApprovalDate) {
|
||||
toast.error('Tanggal realisasi wajib diisi.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bulkApprovalNotes.trim()) {
|
||||
toast.error('Catatan wajib diisi.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsApproveLoading(true);
|
||||
|
||||
const bulkApproveResponse = await ExpenseApi.bulkApprovals(
|
||||
selectedRowIds,
|
||||
bulkApprovalStatus.value,
|
||||
isApprovalDateRequired(bulkApprovalStatus.value) ? bulkApprovalDate : '',
|
||||
bulkApprovalNotes
|
||||
);
|
||||
|
||||
if (isResponseSuccess(bulkApproveResponse)) {
|
||||
refreshExpenses();
|
||||
bulkApproveFormModal.closeModal();
|
||||
toast.success(
|
||||
`Berhasil approve ${selectedRowIds.length} data biaya operasional!`
|
||||
);
|
||||
resetBulkApproveForm();
|
||||
setRowSelection({});
|
||||
} else {
|
||||
toast.error(
|
||||
bulkApproveResponse?.message ??
|
||||
`Gagal approve ${selectedRowIds.length} data biaya operasional!`
|
||||
);
|
||||
}
|
||||
|
||||
setIsApproveLoading(false);
|
||||
};
|
||||
|
||||
const confirmationModalRejectClickHandler = async (notes: string) => {
|
||||
setIsRejectLoading(true);
|
||||
|
||||
@@ -596,12 +728,7 @@ const ExpensesTable = () => {
|
||||
setIsRejectLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('expense-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
};
|
||||
|
||||
@@ -645,7 +772,7 @@ const ExpensesTable = () => {
|
||||
<div className='w-full'>
|
||||
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
|
||||
{/* Action Buttons */}
|
||||
<div className='w-fit flex flex-row gap-3 flex-wrap'>
|
||||
<div className='w-fit flex flex-col gap-3 flex-wrap'>
|
||||
<RequirePermission permissions='lti.expense.create'>
|
||||
<Button
|
||||
href='/expense/add'
|
||||
@@ -658,14 +785,36 @@ const ExpensesTable = () => {
|
||||
</RequirePermission>
|
||||
|
||||
{selectedRowIds.length > 0 && (
|
||||
<>
|
||||
<hr className='w-px h-full border-none bg-base-content/10 sm:block hidden' />
|
||||
<div className='flex flex-row gap-3 flex-wrap'>
|
||||
<RequirePermission
|
||||
permissions={[
|
||||
'lti.expense.approve.head_area',
|
||||
'lti.expense.approve.unit_vice_president',
|
||||
'lti.expense.approve.finance',
|
||||
'lti.expense.create.realization',
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
onClick={bulkApproveClickHandler}
|
||||
className='px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||
>
|
||||
<Icon
|
||||
icon='lucide-lab:farm'
|
||||
width={20}
|
||||
height={20}
|
||||
className='text-success'
|
||||
/>
|
||||
Bulk Approve
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
|
||||
<RequirePermission permissions='lti.expense.approve.head_area'>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
onClick={bulkApproveClickHandler}
|
||||
onClick={bulkApproveHeadAreaClickHandler}
|
||||
disabled={!isAllSelectedRowLatestApprovalOnHeadArea}
|
||||
className='px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||
>
|
||||
@@ -683,7 +832,7 @@ const ExpensesTable = () => {
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
onClick={bulkApproveClickHandler}
|
||||
onClick={bulkApproveUnitVicePresidentClickHandler}
|
||||
disabled={
|
||||
!isAllSelectedRowLatestApprovalOnUnitVicePresident
|
||||
}
|
||||
@@ -703,7 +852,7 @@ const ExpensesTable = () => {
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
onClick={bulkApproveClickHandler}
|
||||
onClick={bulkApproveFinanceClickHandler}
|
||||
disabled={!isAllSelectedRowLatestApprovalOnFinance}
|
||||
className='px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||
>
|
||||
@@ -744,12 +893,12 @@ const ExpensesTable = () => {
|
||||
Reject
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search and Filter */}
|
||||
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
|
||||
<div className='flex flex-1 flex-row justify-start sm:justify-end items-start gap-3 flex-wrap'>
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Search'
|
||||
@@ -782,6 +931,50 @@ const ExpensesTable = () => {
|
||||
onClick={handleFilterModalOpen}
|
||||
className='px-3 py-2.5'
|
||||
/>
|
||||
|
||||
<Dropdown
|
||||
align='end'
|
||||
direction='bottom'
|
||||
className={{
|
||||
content:
|
||||
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
|
||||
}}
|
||||
trigger={
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||
>
|
||||
<div className='flex flex-row items-center gap-1.5'>
|
||||
<Icon
|
||||
icon='heroicons:cloud-arrow-down'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
|
||||
<span>Export</span>
|
||||
|
||||
<div className='w-px self-stretch bg-base-content/10' />
|
||||
|
||||
<Icon
|
||||
icon='heroicons:chevron-down'
|
||||
width={14}
|
||||
height={14}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={exportProgressInputToExcelClickHandler}
|
||||
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} />
|
||||
Ekspor Input Progress (Excel)
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -814,8 +1007,8 @@ const ExpensesTable = () => {
|
||||
totalItems={
|
||||
isResponseSuccess(expenses) ? expenses?.meta?.total_results : 0
|
||||
}
|
||||
onPageChange={pageChangeHandler}
|
||||
onPageSizeChange={pageSizeChangeHandler}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
isLoading={isLoading}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
@@ -884,6 +1077,170 @@ const ExpensesTable = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
ref={bulkApproveFormModal.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 Expense
|
||||
</h4>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={() => {
|
||||
bulkApproveFormModal.closeModal();
|
||||
resetBulkApproveForm();
|
||||
}}
|
||||
className='p-1'
|
||||
>
|
||||
<Icon icon='mdi:close' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-4 p-4'>
|
||||
<SelectInput
|
||||
label='Status Approval'
|
||||
options={approvalStatusOptions as OptionType[]}
|
||||
value={bulkApprovalStatus}
|
||||
onChange={(val) => {
|
||||
const nextValue = val as OptionType<ApprovalStatusValue> | null;
|
||||
setBulkApprovalStatus(nextValue);
|
||||
|
||||
if (!isApprovalDateRequired(nextValue?.value)) {
|
||||
setBulkApprovalDate('');
|
||||
}
|
||||
}}
|
||||
placeholder='Pilih status approval'
|
||||
isClearable
|
||||
/>
|
||||
|
||||
{isApprovalDateRequired(bulkApprovalStatus?.value) && (
|
||||
<DateInput
|
||||
name='bulk_approval_date'
|
||||
label='Tanggal Realisasi'
|
||||
value={bulkApprovalDate}
|
||||
onChange={bulkApprovalDateChangeHandler}
|
||||
isNestedModal
|
||||
required
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextArea
|
||||
name='bulk_approval_notes'
|
||||
label='Catatan'
|
||||
value={bulkApprovalNotes}
|
||||
onChange={bulkApprovalNotesChangeHandler}
|
||||
placeholder='Masukkan catatan approval...'
|
||||
rows={4}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
onClick={() => {
|
||||
bulkApproveFormModal.closeModal();
|
||||
resetBulkApproveForm();
|
||||
}}
|
||||
className='px-3 py-2.5'
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
color='success'
|
||||
onClick={bulkApproveSubmitHandler}
|
||||
isLoading={isApproveLoading}
|
||||
disabled={
|
||||
!bulkApprovalStatus ||
|
||||
!bulkApprovalNotes.trim() ||
|
||||
(isApprovalDateRequired(bulkApprovalStatus.value) &&
|
||||
!bulkApprovalDate)
|
||||
}
|
||||
className='px-3 py-2.5'
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
ref={exportProgressInputModal.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'>
|
||||
Ekspor Input Progress
|
||||
</h4>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={() => {
|
||||
exportProgressInputModal.closeModal();
|
||||
resetExportProgressForm();
|
||||
}}
|
||||
className='p-1'
|
||||
>
|
||||
<Icon icon='mdi:close' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-4 p-4'>
|
||||
<DateInput
|
||||
name='export_progress_start_date'
|
||||
label='Tanggal Mulai'
|
||||
value={exportProgressStartDate}
|
||||
onChange={exportProgressStartDateChangeHandler}
|
||||
isNestedModal
|
||||
required
|
||||
/>
|
||||
|
||||
<DateInput
|
||||
name='export_progress_end_date'
|
||||
label='Tanggal Selesai'
|
||||
value={exportProgressEndDate}
|
||||
onChange={exportProgressEndDateChangeHandler}
|
||||
isNestedModal
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
onClick={() => {
|
||||
exportProgressInputModal.closeModal();
|
||||
resetExportProgressForm();
|
||||
}}
|
||||
className='px-3 py-2.5'
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
color='success'
|
||||
onClick={submitExportProgressInputHandler}
|
||||
isLoading={isExportProgressLoading}
|
||||
disabled={!exportProgressStartDate || !exportProgressEndDate}
|
||||
className='px-3 py-2.5'
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ExpensesFilterModal
|
||||
ref={filterModal.ref}
|
||||
onSubmit={handleFilterSubmit}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import axios from 'axios';
|
||||
import Button from '@/components/Button';
|
||||
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 ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
|
||||
@@ -13,6 +16,7 @@ import {
|
||||
SalesOrderApi,
|
||||
} from '@/services/api/marketing/marketing';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { BaseApiResponse } from '@/types/api/api-general';
|
||||
import {
|
||||
BaseSalesOrder,
|
||||
Marketing,
|
||||
@@ -21,7 +25,7 @@ import {
|
||||
import { Icon } from '@iconify/react';
|
||||
import { CellContext, ColumnDef, Row } from '@tanstack/react-table';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChangeEventHandler, useCallback, useMemo, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import useSWR from 'swr';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
@@ -33,6 +37,43 @@ import MarketingFilterModal from '@/components/pages/marketing/MarketingFilter';
|
||||
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||
import MarketingTableSkeleton from '@/components/pages/marketing/skeleton/MarketingTableSkeleton';
|
||||
|
||||
const getExportErrorMessage = async (
|
||||
error: unknown,
|
||||
fallbackMessage: string
|
||||
) => {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const responseData = error.response?.data;
|
||||
|
||||
if (responseData instanceof Blob) {
|
||||
try {
|
||||
const parsed = JSON.parse(await responseData.text()) as {
|
||||
message?: string;
|
||||
};
|
||||
return parsed.message || fallbackMessage;
|
||||
} catch {
|
||||
return fallbackMessage;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
responseData &&
|
||||
typeof responseData === 'object' &&
|
||||
'message' in responseData &&
|
||||
typeof responseData.message === 'string'
|
||||
) {
|
||||
return responseData.message;
|
||||
}
|
||||
|
||||
return error.message || fallbackMessage;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallbackMessage;
|
||||
};
|
||||
|
||||
const RowsOptionsMenu = ({
|
||||
props,
|
||||
deleteClickHandler,
|
||||
@@ -154,12 +195,21 @@ const MarketingTable = () => {
|
||||
);
|
||||
const [selectedItem, setSelectedItem] = useState<Marketing | null>(null);
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||
const [bulkDeliveryDate, setBulkDeliveryDate] = useState('');
|
||||
const [bulkDeliveryNotes, setBulkDeliveryNotes] = useState('');
|
||||
const [isSubmittingBulkDelivery, setIsSubmittingBulkDelivery] =
|
||||
useState(false);
|
||||
const [isExportProgressLoading, setIsExportProgressLoading] = useState(false);
|
||||
const [exportProgressStartDate, setExportProgressStartDate] = useState('');
|
||||
const [exportProgressEndDate, setExportProgressEndDate] = useState('');
|
||||
|
||||
const router = useRouter();
|
||||
const deleteModal = useModal();
|
||||
const confirmationModal = useModal();
|
||||
const productsModal = useModal();
|
||||
const deliveryModal = useModal();
|
||||
const bulkDeliveryModal = useModal();
|
||||
const exportProgressInputModal = useModal();
|
||||
const filterModal = useModal();
|
||||
|
||||
const {
|
||||
@@ -182,6 +232,9 @@ const MarketingTable = () => {
|
||||
status: 'status',
|
||||
customer_id: 'customer_id',
|
||||
},
|
||||
|
||||
persist: true,
|
||||
storeName: 'marketing-table',
|
||||
});
|
||||
|
||||
// ===== FETCH DATA =====
|
||||
@@ -198,12 +251,14 @@ const MarketingTable = () => {
|
||||
const filterSubmitHandler = (values: MarketingFilter) => {
|
||||
updateFilter(
|
||||
'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(
|
||||
'customer_id',
|
||||
values.customer_id ? values.customer_id.toString() : ''
|
||||
values.customer_id ? values.customer_id.toString() : '',
|
||||
true
|
||||
);
|
||||
};
|
||||
|
||||
@@ -211,13 +266,19 @@ const MarketingTable = () => {
|
||||
useState(false);
|
||||
|
||||
const filterResetHandler = () => {
|
||||
updateFilter('product_ids', '');
|
||||
updateFilter('status', '');
|
||||
updateFilter('customer_id', '');
|
||||
updateFilter('product_ids', '', true);
|
||||
updateFilter('status', '', true);
|
||||
updateFilter('customer_id', '', true);
|
||||
};
|
||||
|
||||
const approveClickHandler = () => {
|
||||
setApproveAction('APPROVED');
|
||||
|
||||
if (selectedApprovalStep === 2) {
|
||||
bulkDeliveryModal.openModal();
|
||||
return;
|
||||
}
|
||||
|
||||
confirmationModal.openModal();
|
||||
};
|
||||
|
||||
@@ -226,10 +287,13 @@ const MarketingTable = () => {
|
||||
confirmationModal.openModal();
|
||||
};
|
||||
|
||||
const productsClickHandler = (item: Marketing) => {
|
||||
setSelectedItem(item);
|
||||
productsModal.openModal();
|
||||
};
|
||||
const productsClickHandler = useCallback(
|
||||
(item: Marketing) => {
|
||||
setSelectedItem(item);
|
||||
productsModal.openModal();
|
||||
},
|
||||
[productsModal]
|
||||
);
|
||||
|
||||
const deleteMarketingHandler = async () => {
|
||||
const deleteMarketingRes = await MarketingApi.delete(
|
||||
@@ -251,61 +315,135 @@ const MarketingTable = () => {
|
||||
const selectedRowsData = allData.filter(
|
||||
(row) => rowSelection[row.id.toString()]
|
||||
);
|
||||
const selectedApprovalStep =
|
||||
selectedRowsData.length > 0
|
||||
? selectedRowsData[0].latest_approval.step_number
|
||||
: null;
|
||||
|
||||
const hasApprovable = selectedRowsData.some(
|
||||
(row) =>
|
||||
row.latest_approval.step_number === 1 &&
|
||||
row.latest_approval.action !== 'REJECTED'
|
||||
);
|
||||
const hasRejectable = selectedRowsData.some(
|
||||
(row) =>
|
||||
row.latest_approval.step_number === 1 &&
|
||||
row.latest_approval.action !== 'REJECTED'
|
||||
);
|
||||
const eligibleSelectedRows = selectedRowsData.filter((row) => {
|
||||
const approval = row.latest_approval;
|
||||
|
||||
if (approval.action === 'REJECTED') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (selectedApprovalStep === null) {
|
||||
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 disableReject = !hasRejectable;
|
||||
|
||||
const idsToProcess =
|
||||
approveAction === 'APPROVED'
|
||||
? selectedRowsData
|
||||
.filter((row) => row.latest_approval.step_number === 1)
|
||||
.map((row) => row.id)
|
||||
: selectedRowsData
|
||||
.filter((row) => row.latest_approval.step_number === 2)
|
||||
.map((row) => row.id);
|
||||
const idsToProcess = eligibleSelectedRows.map((row) => row.id);
|
||||
const nextApprovalStatus =
|
||||
selectedApprovalStep === 1
|
||||
? 'SALES_ORDER'
|
||||
: selectedApprovalStep === 2
|
||||
? 'DELIVERY_ORDER'
|
||||
: null;
|
||||
|
||||
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) {
|
||||
toast.error(`Tidak ada data yang valid untuk di ${approveAction}.`);
|
||||
confirmationModal.closeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
const approveMarketingRes = await SalesOrderApi.bulkApprovals(
|
||||
idsToProcess,
|
||||
approveAction,
|
||||
notes
|
||||
);
|
||||
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,
|
||||
nextApprovalStatus as 'SALES_ORDER' | 'DELIVERY_ORDER',
|
||||
'',
|
||||
notes || `APPROVED marketing ${idsToProcess.join(', ')}`
|
||||
)
|
||||
: await SalesOrderApi.bulkApprovals(idsToProcess, approveAction, notes);
|
||||
|
||||
if (isResponseSuccess(approveMarketingRes)) {
|
||||
confirmationModal.closeModal();
|
||||
toast.success(approveMarketingRes?.message as string);
|
||||
setRowSelection({});
|
||||
}
|
||||
if (isResponseError(approveMarketingRes)) {
|
||||
confirmationModal.closeModal();
|
||||
toast.error(approveMarketingRes?.message as string);
|
||||
}
|
||||
|
||||
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 res = await SalesOrderApi.delivery(selectedItem?.id as number, notes);
|
||||
deliveryModal.closeModal();
|
||||
@@ -316,10 +454,24 @@ const MarketingTable = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const getRowCanSelect = (row: Row<Marketing>): boolean => {
|
||||
const approval = row.original.latest_approval;
|
||||
return approval?.step_number === 1 && approval?.action !== 'REJECTED';
|
||||
};
|
||||
const getRowCanSelect = useCallback(
|
||||
(row: Row<Marketing>): boolean => {
|
||||
const approval = row.original.latest_approval;
|
||||
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 () => {
|
||||
setIsLoadingExportingToExcel(true);
|
||||
@@ -329,6 +481,56 @@ const MarketingTable = () => {
|
||||
setIsLoadingExportingToExcel(false);
|
||||
};
|
||||
|
||||
const resetExportProgressForm = useCallback(() => {
|
||||
setExportProgressStartDate('');
|
||||
setExportProgressEndDate('');
|
||||
}, []);
|
||||
|
||||
const exportProgressStartDateChangeHandler: ChangeEventHandler<HTMLInputElement> =
|
||||
useCallback((e) => {
|
||||
setExportProgressStartDate(e.target.value);
|
||||
}, []);
|
||||
|
||||
const exportProgressEndDateChangeHandler: ChangeEventHandler<HTMLInputElement> =
|
||||
useCallback((e) => {
|
||||
setExportProgressEndDate(e.target.value);
|
||||
}, []);
|
||||
|
||||
const exportProgressInputToExcelClickHandler = useCallback(() => {
|
||||
resetExportProgressForm();
|
||||
exportProgressInputModal.openModal();
|
||||
}, [exportProgressInputModal, resetExportProgressForm]);
|
||||
|
||||
const submitExportProgressInputHandler = useCallback(async () => {
|
||||
if (!exportProgressStartDate || !exportProgressEndDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExportProgressLoading(true);
|
||||
|
||||
try {
|
||||
await MarketingApi.exportInputProgressToExcel(
|
||||
exportProgressStartDate,
|
||||
exportProgressEndDate
|
||||
);
|
||||
|
||||
exportProgressInputModal.closeModal();
|
||||
resetExportProgressForm();
|
||||
toast.success('Ekspor berhasil');
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
await getExportErrorMessage(error, 'Gagal mengekspor input progress')
|
||||
);
|
||||
} finally {
|
||||
setIsExportProgressLoading(false);
|
||||
}
|
||||
}, [
|
||||
exportProgressEndDate,
|
||||
exportProgressInputModal,
|
||||
exportProgressStartDate,
|
||||
resetExportProgressForm,
|
||||
]);
|
||||
|
||||
const columns = useMemo<ColumnDef<Marketing>[]>(() => {
|
||||
return [
|
||||
{
|
||||
@@ -336,7 +538,22 @@ const MarketingTable = () => {
|
||||
size: 1,
|
||||
header: ({ table }) => {
|
||||
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 =
|
||||
selectableRows.length > 0 &&
|
||||
@@ -504,7 +721,13 @@ const MarketingTable = () => {
|
||||
},
|
||||
},
|
||||
];
|
||||
}, []);
|
||||
}, [
|
||||
deleteModal,
|
||||
deliveryModal,
|
||||
getRowCanSelect,
|
||||
productsClickHandler,
|
||||
selectedApprovalStep,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -614,6 +837,16 @@ const MarketingTable = () => {
|
||||
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||
Export to Excel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={exportProgressInputToExcelClickHandler}
|
||||
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} />
|
||||
Ekspor Input Progress (Excel)
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
@@ -677,7 +910,7 @@ const MarketingTable = () => {
|
||||
<ConfirmationModalWithNotes
|
||||
ref={confirmationModal.ref}
|
||||
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={{
|
||||
text: 'Tidak',
|
||||
onClick: confirmationModal.closeModal,
|
||||
@@ -716,6 +949,157 @@ 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
|
||||
ref={exportProgressInputModal.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'>
|
||||
Ekspor Input Progress
|
||||
</h4>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={() => {
|
||||
exportProgressInputModal.closeModal();
|
||||
resetExportProgressForm();
|
||||
}}
|
||||
className='p-1'
|
||||
>
|
||||
<Icon icon='mdi:close' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-4 p-4'>
|
||||
<DateInput
|
||||
name='export_progress_start_date'
|
||||
label='Tanggal Mulai'
|
||||
value={exportProgressStartDate}
|
||||
onChange={exportProgressStartDateChangeHandler}
|
||||
isNestedModal
|
||||
required
|
||||
/>
|
||||
|
||||
<DateInput
|
||||
name='export_progress_end_date'
|
||||
label='Tanggal Selesai'
|
||||
value={exportProgressEndDate}
|
||||
onChange={exportProgressEndDateChangeHandler}
|
||||
isNestedModal
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
onClick={() => {
|
||||
exportProgressInputModal.closeModal();
|
||||
resetExportProgressForm();
|
||||
}}
|
||||
className='px-3 py-2.5'
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
color='success'
|
||||
onClick={submitExportProgressInputHandler}
|
||||
isLoading={isExportProgressLoading}
|
||||
disabled={!exportProgressStartDate || !exportProgressEndDate}
|
||||
className='px-3 py-2.5'
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
ref={productsModal.ref}
|
||||
className={{
|
||||
|
||||
@@ -144,9 +144,11 @@ export const DeliveryProductToFieldValues = (
|
||||
delivery: BaseDeliveryOrder
|
||||
): DeliveryOrderProductFormValues[] => {
|
||||
const data = delivery.deliveries.map((item) => {
|
||||
const salesOrder = salesOrders.find(
|
||||
(so) => so.product_warehouse.id === item.product_warehouse.id
|
||||
);
|
||||
const salesOrder =
|
||||
salesOrders.find((so) => so.id === item.marketing_product_id) ??
|
||||
salesOrders.find(
|
||||
(so) => so.product_warehouse.id === item.product_warehouse.id
|
||||
);
|
||||
const warehouseOption = {
|
||||
value: item.product_warehouse.warehouse.id,
|
||||
label: item.product_warehouse.warehouse.name,
|
||||
@@ -180,7 +182,7 @@ export const DeliveryProductToFieldValues = (
|
||||
vehicle_number: item.vehicle_number,
|
||||
delivery_date: formatDate(delivery.delivery_date, 'yyyy-MM-DD'),
|
||||
do_number: delivery.do_number,
|
||||
marketing_product_id: salesOrder?.id,
|
||||
marketing_product_id: item.marketing_product_id ?? salesOrder?.id,
|
||||
marketing_type: salesOrder?.marketing_type
|
||||
? {
|
||||
value: salesOrder?.marketing_type,
|
||||
@@ -194,7 +196,7 @@ export const DeliveryProductToFieldValues = (
|
||||
}
|
||||
: null,
|
||||
marketing_product: {
|
||||
id: salesOrder?.id,
|
||||
id: item.marketing_product_id ?? salesOrder?.id,
|
||||
vehicle_number: item.vehicle_number,
|
||||
warehouse_id: item.product_warehouse.warehouse.id,
|
||||
warehouse: warehouseOption,
|
||||
|
||||
+17
-7
@@ -190,12 +190,19 @@ const DeliveryOrderProductForm = ({
|
||||
const deliveryOrder = useMemo(() => {
|
||||
if (!hasDeliveryOrder || !deliveryOrders) return null;
|
||||
|
||||
const marketingProductId =
|
||||
initialValues?.marketing_product_id ?? initialValues?.id;
|
||||
|
||||
for (const doItem of deliveryOrders) {
|
||||
const found = doItem.deliveries.find(
|
||||
(d) =>
|
||||
d.product_warehouse.id ===
|
||||
initialValues?.marketing_product?.product_warehouse_id
|
||||
);
|
||||
const found =
|
||||
doItem.deliveries.find(
|
||||
(d) => d.marketing_product_id === marketingProductId
|
||||
) ??
|
||||
doItem.deliveries.find(
|
||||
(d) =>
|
||||
d.product_warehouse.id ===
|
||||
initialValues?.marketing_product?.product_warehouse_id
|
||||
);
|
||||
if (found) {
|
||||
return {
|
||||
...found,
|
||||
@@ -403,7 +410,10 @@ const DeliveryOrderProductForm = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (initialValues) {
|
||||
if (!Boolean(initialValues.qty)) {
|
||||
if (
|
||||
!Boolean(initialValues.qty) &&
|
||||
!Boolean(initialValues.marketing_product_id)
|
||||
) {
|
||||
handleResetForm();
|
||||
} else {
|
||||
setFormikValues({
|
||||
@@ -413,7 +423,7 @@ const DeliveryOrderProductForm = ({
|
||||
});
|
||||
if (initialValues?.marketing_product_id) {
|
||||
setSelectedProduct({
|
||||
value: initialValues?.id,
|
||||
value: initialValues?.marketing_product_id,
|
||||
label: `${initialValues?.marketing_product?.product_warehouse?.label} - ${initialValues?.marketing_product?.warehouse?.label ?? initialValues?.marketing_product?.kandang?.label}`,
|
||||
} as OptionType);
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ const DeliveryOrderProductTable = ({
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Qty</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
{item.qty
|
||||
{item.qty !== undefined && item.qty !== null && item.qty !== ''
|
||||
? `${formatNumber(parseFloat(item.qty as string))} ${item.marketing_product?.uom ?? ''}`
|
||||
: '-'}
|
||||
</td>
|
||||
@@ -273,7 +273,7 @@ const DeliveryOrderProductTable = ({
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Qty</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
{item.qty
|
||||
{item.qty !== undefined && item.qty !== null && item.qty !== ''
|
||||
? `${formatNumber(Number(item.qty))} ${item.marketing_product?.product_warehouse_data?.product.uom.name ?? ''}`
|
||||
: '-'}
|
||||
</td>
|
||||
|
||||
@@ -23,7 +23,6 @@ import { Icon } from '@iconify/react';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import toast from 'react-hot-toast';
|
||||
import useSWR from 'swr';
|
||||
import { useFormik } from 'formik';
|
||||
@@ -148,7 +147,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const isSuccess = useProjectFlockStore((s) => s.isSuccess);
|
||||
@@ -185,7 +183,11 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
category: 'category',
|
||||
period: 'period',
|
||||
},
|
||||
|
||||
persist: true,
|
||||
storeName: 'project-flock-table',
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// ===== State =====
|
||||
@@ -425,18 +427,11 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
setIsDeleteLoading(false);
|
||||
setRowSelection({});
|
||||
};
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('project-flock-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
};
|
||||
|
||||
const confirmApprovalHandler = async (
|
||||
notes: string,
|
||||
approvalAction: 'APPROVED' | 'REJECTED'
|
||||
|
||||
@@ -261,7 +261,7 @@ const ProjectFlockForm = ({
|
||||
isLoadingOptions: isLoadingFlocks,
|
||||
options: optionsFlock,
|
||||
loadMore: loadMoreFlock,
|
||||
} = useSelect(FlockApi.basePath, 'id', 'name', '', {
|
||||
} = useSelect(FlockApi.basePath, 'id', 'name', 'search', {
|
||||
project_category: selectedCategory,
|
||||
location_id: selectedLocation,
|
||||
area_id: selectedArea,
|
||||
@@ -279,7 +279,7 @@ const ProjectFlockForm = ({
|
||||
isLoadingOptions: isLoadingLocations,
|
||||
setInputValue: setInputValueLocation,
|
||||
loadMore: loadMoreLocation,
|
||||
} = useSelect(LocationApi.basePath, 'id', 'name', '', {
|
||||
} = useSelect(LocationApi.basePath, 'id', 'name', 'search', {
|
||||
area_id:
|
||||
selectedArea != ''
|
||||
? selectedArea
|
||||
@@ -291,7 +291,7 @@ const ProjectFlockForm = ({
|
||||
isLoadingOptions: isLoadingProductionStandards,
|
||||
setInputValue: setInputValueProductionStandard,
|
||||
loadMore: loadMoreProductionStandard,
|
||||
} = useSelect(ProductionStandardApi.basePath, 'id', 'name', '', {
|
||||
} = useSelect(ProductionStandardApi.basePath, 'id', 'name', 'search', {
|
||||
project_category: selectedCategory,
|
||||
});
|
||||
|
||||
@@ -307,7 +307,7 @@ const ProjectFlockForm = ({
|
||||
} = useSWR(kandangUrl, KandangApi.getAllFetcher);
|
||||
|
||||
const { data: periodFlocks, mutate: refreshPeriodFlocks } = useSWR(
|
||||
`${selectedFlock?.toString()}/periods`,
|
||||
selectedFlock ? `${selectedFlock?.toString()}/periods` : undefined,
|
||||
() => ProjectFlockApi.getNextPeriod(parseInt(selectedLocation as string))
|
||||
);
|
||||
|
||||
@@ -793,6 +793,7 @@ const ProjectFlockForm = ({
|
||||
formik.values.kandang_ids?.includes(kandang.id)
|
||||
)?.period
|
||||
: undefined;
|
||||
|
||||
const inputPeriod =
|
||||
(initialValues?.period ?? selectedPeriod == 0) ? 1 : selectedPeriod;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import axios from 'axios';
|
||||
import React, {
|
||||
useCallback,
|
||||
useState,
|
||||
@@ -18,6 +19,7 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import SelectInput, { useSelect } from '@/components/input/SelectInput';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||
import PopoverButton from '@/components/popover/PopoverButton';
|
||||
import PopoverContent from '@/components/popover/PopoverContent';
|
||||
@@ -50,6 +52,43 @@ import { Color } from '@/types/theme';
|
||||
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
|
||||
const getExportErrorMessage = async (
|
||||
error: unknown,
|
||||
fallbackMessage: string
|
||||
) => {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const responseData = error.response?.data;
|
||||
|
||||
if (responseData instanceof Blob) {
|
||||
try {
|
||||
const parsed = JSON.parse(await responseData.text()) as {
|
||||
message?: string;
|
||||
};
|
||||
return parsed.message || fallbackMessage;
|
||||
} catch {
|
||||
return fallbackMessage;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
responseData &&
|
||||
typeof responseData === 'object' &&
|
||||
'message' in responseData &&
|
||||
typeof responseData.message === 'string'
|
||||
) {
|
||||
return responseData.message;
|
||||
}
|
||||
|
||||
return error.message || fallbackMessage;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallbackMessage;
|
||||
};
|
||||
|
||||
// ===== STATUS BADGE UTILITIES =====
|
||||
const statusTextMap: Record<string, string> = {
|
||||
APPROVED: 'Disetujui',
|
||||
@@ -355,10 +394,14 @@ const RecordingTable = () => {
|
||||
|
||||
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
|
||||
useState(false);
|
||||
const [isExportProgressLoading, setIsExportProgressLoading] = useState(false);
|
||||
const [exportProgressStartDate, setExportProgressStartDate] = useState('');
|
||||
const [exportProgressEndDate, setExportProgressEndDate] = useState('');
|
||||
|
||||
const singleDeleteModal = useModal();
|
||||
const approveModal = useModal();
|
||||
const rejectModal = useModal();
|
||||
const exportProgressInputModal = useModal();
|
||||
|
||||
const {
|
||||
data: recordings,
|
||||
@@ -698,6 +741,60 @@ const RecordingTable = () => {
|
||||
setIsLoadingExportingToExcel(false);
|
||||
};
|
||||
|
||||
const resetExportProgressForm = useCallback(() => {
|
||||
setExportProgressStartDate('');
|
||||
setExportProgressEndDate('');
|
||||
}, []);
|
||||
|
||||
const exportProgressStartDateChangeHandler = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setExportProgressStartDate(e.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const exportProgressEndDateChangeHandler = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setExportProgressEndDate(e.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const exportProgressInputToExcelClickHandler = useCallback(() => {
|
||||
resetExportProgressForm();
|
||||
exportProgressInputModal.openModal();
|
||||
}, [exportProgressInputModal, resetExportProgressForm]);
|
||||
|
||||
const submitExportProgressInputHandler = useCallback(async () => {
|
||||
if (!exportProgressStartDate || !exportProgressEndDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExportProgressLoading(true);
|
||||
|
||||
try {
|
||||
await RecordingApi.exportInputProgressToExcel(
|
||||
exportProgressStartDate,
|
||||
exportProgressEndDate
|
||||
);
|
||||
|
||||
exportProgressInputModal.closeModal();
|
||||
resetExportProgressForm();
|
||||
toast.success('Ekspor berhasil');
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
await getExportErrorMessage(error, 'Gagal mengekspor input progress')
|
||||
);
|
||||
} finally {
|
||||
setIsExportProgressLoading(false);
|
||||
}
|
||||
}, [
|
||||
exportProgressEndDate,
|
||||
exportProgressInputModal,
|
||||
exportProgressStartDate,
|
||||
resetExportProgressForm,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isResponseSuccess(recordings) && recordings.data) {
|
||||
const newSelection: Record<string, boolean> = {};
|
||||
@@ -1368,6 +1465,16 @@ const RecordingTable = () => {
|
||||
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||
Export to Excel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={exportProgressInputToExcelClickHandler}
|
||||
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} />
|
||||
Ekspor Input Progress (Excel)
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1551,6 +1658,76 @@ const RecordingTable = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
ref={exportProgressInputModal.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'>
|
||||
Ekspor Input Progress
|
||||
</h4>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={() => {
|
||||
exportProgressInputModal.closeModal();
|
||||
resetExportProgressForm();
|
||||
}}
|
||||
className='p-1'
|
||||
>
|
||||
<Icon icon='mdi:close' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-4 p-4'>
|
||||
<DateInput
|
||||
name='export_progress_start_date'
|
||||
label='Tanggal Mulai'
|
||||
value={exportProgressStartDate}
|
||||
onChange={exportProgressStartDateChangeHandler}
|
||||
isNestedModal
|
||||
required
|
||||
/>
|
||||
|
||||
<DateInput
|
||||
name='export_progress_end_date'
|
||||
label='Tanggal Selesai'
|
||||
value={exportProgressEndDate}
|
||||
onChange={exportProgressEndDateChangeHandler}
|
||||
isNestedModal
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
onClick={() => {
|
||||
exportProgressInputModal.closeModal();
|
||||
resetExportProgressForm();
|
||||
}}
|
||||
className='px-3 py-2.5'
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
color='success'
|
||||
onClick={submitExportProgressInputHandler}
|
||||
isLoading={isExportProgressLoading}
|
||||
disabled={!exportProgressStartDate || !exportProgressEndDate}
|
||||
className='px-3 py-2.5'
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmationModalWithNotes
|
||||
ref={approveModal.ref}
|
||||
type='success'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import axios from 'axios';
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
useCallback,
|
||||
@@ -18,8 +19,9 @@ import Link from 'next/link';
|
||||
import { Icon } from '@iconify/react';
|
||||
import Table from '@/components/Table';
|
||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import Button from '@/components/Button';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import Modal, { useModal } from '@/components/Modal';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import PopoverButton from '@/components/popover/PopoverButton';
|
||||
import PopoverContent from '@/components/popover/PopoverContent';
|
||||
@@ -28,6 +30,7 @@ import StatusBadge from '@/components/helper/StatusBadge';
|
||||
import PurchaseTableSkeleton from '@/components/pages/purchase/skeleton/PurchaseTableSkeleton';
|
||||
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||
import PurchaseFilterModal from '@/components/pages/purchase/PurchaseFilterModal';
|
||||
import Dropdown from '@/components/dropdown/Dropdown';
|
||||
|
||||
import { cn, formatDate } from '@/lib/helper';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
@@ -40,6 +43,43 @@ import { ExpenseApi } from '@/services/api/expense';
|
||||
import { Expense } from '@/types/api/expense';
|
||||
import { Color } from '@/types/theme';
|
||||
|
||||
const getExportErrorMessage = async (
|
||||
error: unknown,
|
||||
fallbackMessage: string
|
||||
) => {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const responseData = error.response?.data;
|
||||
|
||||
if (responseData instanceof Blob) {
|
||||
try {
|
||||
const parsed = JSON.parse(await responseData.text()) as {
|
||||
message?: string;
|
||||
};
|
||||
return parsed.message || fallbackMessage;
|
||||
} catch {
|
||||
return fallbackMessage;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
responseData &&
|
||||
typeof responseData === 'object' &&
|
||||
'message' in responseData &&
|
||||
typeof responseData.message === 'string'
|
||||
) {
|
||||
return responseData.message;
|
||||
}
|
||||
|
||||
return error.message || fallbackMessage;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallbackMessage;
|
||||
};
|
||||
|
||||
// ===== STATUS BADGE UTILITIES =====
|
||||
const statusTextMap: Record<string, string> = {
|
||||
APPROVED: 'Disetujui',
|
||||
@@ -152,9 +192,12 @@ const PurchaseTable = () => {
|
||||
|
||||
// ===== STATE MANAGEMENT =====
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
const [isExportProgressLoading, setIsExportProgressLoading] = useState(false);
|
||||
const [selectedPurchase, setSelectedPurchase] = useState<Purchase | null>(
|
||||
null
|
||||
);
|
||||
const [exportProgressStartDate, setExportProgressStartDate] = useState('');
|
||||
const [exportProgressEndDate, setExportProgressEndDate] = useState('');
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
// ===== TABLE FILTER STATE =====
|
||||
@@ -183,6 +226,7 @@ const PurchaseTable = () => {
|
||||
// ===== MODAL HOOKS =====
|
||||
const filterModal = useModal();
|
||||
const deleteModal = useModal();
|
||||
const exportProgressInputModal = useModal();
|
||||
|
||||
// ===== API DATA FETCHING =====
|
||||
const {
|
||||
@@ -431,6 +475,56 @@ const PurchaseTable = () => {
|
||||
updateFilter('approval_status', '');
|
||||
};
|
||||
|
||||
const resetExportProgressForm = useCallback(() => {
|
||||
setExportProgressStartDate('');
|
||||
setExportProgressEndDate('');
|
||||
}, []);
|
||||
|
||||
const exportProgressStartDateChangeHandler: ChangeEventHandler<HTMLInputElement> =
|
||||
useCallback((e) => {
|
||||
setExportProgressStartDate(e.target.value);
|
||||
}, []);
|
||||
|
||||
const exportProgressEndDateChangeHandler: ChangeEventHandler<HTMLInputElement> =
|
||||
useCallback((e) => {
|
||||
setExportProgressEndDate(e.target.value);
|
||||
}, []);
|
||||
|
||||
const exportProgressInputToExcelClickHandler = useCallback(() => {
|
||||
resetExportProgressForm();
|
||||
exportProgressInputModal.openModal();
|
||||
}, [exportProgressInputModal, resetExportProgressForm]);
|
||||
|
||||
const submitExportProgressInputHandler = useCallback(async () => {
|
||||
if (!exportProgressStartDate || !exportProgressEndDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExportProgressLoading(true);
|
||||
|
||||
try {
|
||||
await PurchaseApi.exportInputProgressToExcel(
|
||||
exportProgressStartDate,
|
||||
exportProgressEndDate
|
||||
);
|
||||
|
||||
exportProgressInputModal.closeModal();
|
||||
resetExportProgressForm();
|
||||
toast.success('Ekspor berhasil');
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
await getExportErrorMessage(error, 'Gagal mengekspor input progress')
|
||||
);
|
||||
} finally {
|
||||
setIsExportProgressLoading(false);
|
||||
}
|
||||
}, [
|
||||
exportProgressEndDate,
|
||||
exportProgressInputModal,
|
||||
exportProgressStartDate,
|
||||
resetExportProgressForm,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='w-full'>
|
||||
@@ -482,6 +576,50 @@ const PurchaseTable = () => {
|
||||
onClick={filterModal.openModal}
|
||||
className='px-3 py-2.5'
|
||||
/>
|
||||
|
||||
<Dropdown
|
||||
align='end'
|
||||
direction='bottom'
|
||||
className={{
|
||||
content:
|
||||
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
|
||||
}}
|
||||
trigger={
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||
>
|
||||
<div className='flex flex-row items-center gap-1.5'>
|
||||
<Icon
|
||||
icon='heroicons:cloud-arrow-down'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
|
||||
<span>Export</span>
|
||||
|
||||
<div className='w-px self-stretch bg-base-content/10' />
|
||||
|
||||
<Icon
|
||||
icon='heroicons:chevron-down'
|
||||
width={14}
|
||||
height={14}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={exportProgressInputToExcelClickHandler}
|
||||
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} />
|
||||
Ekspor Input Progress (Excel)
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -562,6 +700,76 @@ const PurchaseTable = () => {
|
||||
onClick: confirmationModalDeleteClickHandler,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
ref={exportProgressInputModal.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'>
|
||||
Ekspor Input Progress
|
||||
</h4>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={() => {
|
||||
exportProgressInputModal.closeModal();
|
||||
resetExportProgressForm();
|
||||
}}
|
||||
className='p-1'
|
||||
>
|
||||
<Icon icon='mdi:close' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-4 p-4'>
|
||||
<DateInput
|
||||
name='export_progress_start_date'
|
||||
label='Tanggal Mulai'
|
||||
value={exportProgressStartDate}
|
||||
onChange={exportProgressStartDateChangeHandler}
|
||||
isNestedModal
|
||||
required
|
||||
/>
|
||||
|
||||
<DateInput
|
||||
name='export_progress_end_date'
|
||||
label='Tanggal Selesai'
|
||||
value={exportProgressEndDate}
|
||||
onChange={exportProgressEndDateChangeHandler}
|
||||
isNestedModal
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
onClick={() => {
|
||||
exportProgressInputModal.closeModal();
|
||||
resetExportProgressForm();
|
||||
}}
|
||||
className='px-3 py-2.5'
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
color='success'
|
||||
onClick={submitExportProgressInputHandler}
|
||||
isLoading={isExportProgressLoading}
|
||||
disabled={!exportProgressStartDate || !exportProgressEndDate}
|
||||
className='px-3 py-2.5'
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+314
-29
@@ -40,11 +40,12 @@ import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import Table from '@/components/Table';
|
||||
import { DailyChecklist } from '@/types/api/daily-checklist/daily-checklist';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { ColumnDef, Row } from '@tanstack/react-table';
|
||||
import { useSelect } from '@/components/input/SelectInput';
|
||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang';
|
||||
import CheckboxInput from '@/components/input/CheckboxInput';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'ALL', label: 'Semua Status' },
|
||||
@@ -87,6 +88,9 @@ export function ListDailyChecklistContent() {
|
||||
date_from: 'date_from',
|
||||
date_to: 'date_to',
|
||||
},
|
||||
|
||||
persist: true,
|
||||
storeName: 'list-daily-checklist-content-table',
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -122,12 +126,29 @@ export function ListDailyChecklistContent() {
|
||||
|
||||
// Modals
|
||||
const [showApproveModal, setShowApproveModal] = useState(false);
|
||||
const [showBulkApproveModal, setShowBulkApproveModal] = useState(false);
|
||||
const [showRejectModal, setShowRejectModal] = useState(false);
|
||||
const [showBulkRejectModal, setShowBulkRejectModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<DailyChecklist | null>(null);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||
const selectedRowIds = Object.keys(rowSelection);
|
||||
|
||||
const selectedRowItems = selectedRowIds.map((itemId) =>
|
||||
checklistList.find((item) => item.id === parseInt(itemId))
|
||||
);
|
||||
|
||||
const tableEnableRowSelectionHandler: (
|
||||
row: Row<DailyChecklist>
|
||||
) => boolean = (row) => {
|
||||
return (
|
||||
row.original.status !== 'APPROVED' && row.original.status !== 'REJECTED'
|
||||
);
|
||||
};
|
||||
|
||||
const handleDetail = (item: DailyChecklist) => {
|
||||
router.push(
|
||||
`/daily-checklist/list-daily-checklist/detail?checklistId=${item.id}`
|
||||
@@ -149,21 +170,22 @@ export function ListDailyChecklistContent() {
|
||||
setShowApproveModal(true);
|
||||
};
|
||||
|
||||
const handleBulkApprove = () => {
|
||||
setShowBulkApproveModal(true);
|
||||
};
|
||||
|
||||
const handleReject = (item: DailyChecklist) => {
|
||||
setSelectedItem(item);
|
||||
setRejectReason('');
|
||||
setShowRejectModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = (item: DailyChecklist) => {
|
||||
// ✅ VALIDATION: Only DRAFT can be deleted
|
||||
if (item.status !== 'DRAFT') {
|
||||
toast.error('Hanya checklist dengan status DRAFT yang bisa dihapus', {
|
||||
description: `Status saat ini: ${item.status}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const handleBulkReject = () => {
|
||||
setRejectReason('');
|
||||
setShowBulkRejectModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = (item: DailyChecklist) => {
|
||||
setSelectedItem(item);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
@@ -195,6 +217,31 @@ export function ListDailyChecklistContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const confirmBulkApprove = async () => {
|
||||
if (!selectedRowIds.length) return;
|
||||
|
||||
try {
|
||||
setActionLoading(true);
|
||||
|
||||
const approveRes = await DailyChecklistApi.bulkApprove(selectedRowIds);
|
||||
|
||||
if (isResponseError(approveRes)) {
|
||||
toast.error('Gagal approve checklist: ' + approveRes.message);
|
||||
return;
|
||||
}
|
||||
|
||||
refreshChecklistList();
|
||||
toast.success('Checklist berhasil di-approve');
|
||||
setShowBulkApproveModal(false);
|
||||
setRowSelection({});
|
||||
} catch (error) {
|
||||
console.error('Error approving checklist:', error);
|
||||
toast.error('Terjadi kesalahan');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmReject = async () => {
|
||||
if (!selectedItem) return;
|
||||
|
||||
@@ -229,6 +276,40 @@ export function ListDailyChecklistContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const confirmBulkReject = async () => {
|
||||
if (!selectedRowIds.length) return;
|
||||
|
||||
if (!rejectReason.trim()) {
|
||||
toast.error('Alasan reject harus diisi');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setActionLoading(true);
|
||||
|
||||
const rejectRes = await DailyChecklistApi.bulkReject(
|
||||
selectedRowIds,
|
||||
rejectReason
|
||||
);
|
||||
|
||||
if (isResponseError(rejectRes)) {
|
||||
toast.error('Gagal reject checklist: ' + rejectRes.message);
|
||||
return;
|
||||
}
|
||||
|
||||
refreshChecklistList();
|
||||
toast.success('Checklist berhasil di-reject');
|
||||
setShowBulkRejectModal(false);
|
||||
setRowSelection({});
|
||||
setRejectReason('');
|
||||
} catch (error) {
|
||||
console.error('Error rejecting checklist:', error);
|
||||
toast.error('Terjadi kesalahan');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!selectedItem) return;
|
||||
|
||||
@@ -325,6 +406,37 @@ export function ListDailyChecklistContent() {
|
||||
};
|
||||
|
||||
const checklistListColumns: ColumnDef<DailyChecklist>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<div className='w-full flex flex-row justify-center'>
|
||||
<CheckboxInput
|
||||
name='allRow'
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
indeterminate={table.getIsSomeRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const isCheckboxDisabled =
|
||||
!row.getCanSelect() ||
|
||||
row.original.status === 'APPROVED' ||
|
||||
row.original.status === 'REJECTED';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CheckboxInput
|
||||
name='row'
|
||||
checked={row.getIsSelected()}
|
||||
disabled={isCheckboxDisabled}
|
||||
indeterminate={row.getIsSomeSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'date',
|
||||
header: 'Tanggal',
|
||||
@@ -437,19 +549,17 @@ export function ListDailyChecklistContent() {
|
||||
</RequirePermission>
|
||||
)}
|
||||
|
||||
{row.original.status === 'DRAFT' && (
|
||||
<RequirePermission permissions='lti.daily_checklist.create'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='destructive'
|
||||
onClick={() => handleDelete(row.original)}
|
||||
className='bg-red-600 hover:bg-red-700 text-white'
|
||||
>
|
||||
<Trash2 className='w-4 h-4 mr-1' />
|
||||
Hapus
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
<RequirePermission permissions='lti.daily_checklist.create'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='destructive'
|
||||
onClick={() => handleDelete(row.original)}
|
||||
className='bg-red-600 hover:bg-red-700 text-white'
|
||||
>
|
||||
<Trash2 className='w-4 h-4 mr-1' />
|
||||
Hapus
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -459,13 +569,39 @@ export function ListDailyChecklistContent() {
|
||||
<div className='min-h-screen'>
|
||||
<div className='p-6'>
|
||||
{/* Page Title */}
|
||||
<div className='mb-6'>
|
||||
<h1 className='text-2xl font-semibold text-gray-900'>
|
||||
List Daily Checklist
|
||||
</h1>
|
||||
<p className='text-sm text-gray-600 mt-1'>
|
||||
Daftar semua checklist harian
|
||||
</p>
|
||||
<div className='mb-6 flex flex-row justify-between items-center gap-3'>
|
||||
<div>
|
||||
<h1 className='text-2xl font-semibold text-gray-900'>
|
||||
List Daily Checklist
|
||||
</h1>
|
||||
<p className='text-sm text-gray-600 mt-1'>
|
||||
Daftar semua checklist harian
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RequirePermission permissions='lti.daily_checklist.create'>
|
||||
{selectedRowIds.length > 0 && (
|
||||
<div className='flex flex-row items-center gap-3'>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={handleBulkApprove}
|
||||
className='bg-green-600 hover:bg-green-700 text-white'
|
||||
>
|
||||
<CheckCircle className='w-4 h-4 mr-1' />
|
||||
Bulk Approve {`(${selectedRowIds.length}) item`}
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='destructive'
|
||||
onClick={handleBulkReject}
|
||||
className='bg-red-600 hover:bg-red-700 text-white'
|
||||
>
|
||||
<XCircle className='w-4 h-4 mr-1' />
|
||||
Bulk Reject {`(${selectedRowIds.length}) item`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</RequirePermission>
|
||||
</div>
|
||||
|
||||
{/* Main Card */}
|
||||
@@ -588,6 +724,10 @@ export function ListDailyChecklistContent() {
|
||||
}
|
||||
onPageChange={setPage}
|
||||
isLoading={isLoadingChecklistList}
|
||||
rowSelection={rowSelection}
|
||||
setRowSelection={setRowSelection}
|
||||
enableRowSelection={tableEnableRowSelectionHandler}
|
||||
withCheckbox
|
||||
className={{
|
||||
containerClassName: cn({
|
||||
'w-full mb-20':
|
||||
@@ -666,6 +806,76 @@ export function ListDailyChecklistContent() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Bulk Approve Modal */}
|
||||
<Dialog
|
||||
open={showBulkApproveModal}
|
||||
onOpenChange={setShowBulkApproveModal}
|
||||
>
|
||||
<DialogContent className='sm:max-w-md max-h-[80vh] overflow-y-auto bg-white rounded-xl shadow-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Approve Checklist</DialogTitle>
|
||||
<DialogDescription>
|
||||
Apakah Anda yakin ingin approve {selectedRowIds.length} checklist
|
||||
ini?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='max-h-[60vh] overflow-y-auto flex flex-col gap-3'>
|
||||
{selectedRowItems.map((item) => (
|
||||
<div
|
||||
key={item?.id ?? 0}
|
||||
className='bg-gray-50 rounded-lg p-4 space-y-2'
|
||||
>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<span className='text-gray-600'>Tanggal:</span>
|
||||
<span className='font-medium text-gray-900'>
|
||||
{formatDate(item?.date ?? '')}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<span className='text-gray-600'>Kandang:</span>
|
||||
<span className='font-medium text-gray-900'>
|
||||
{item?.kandang?.name ?? '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<span className='text-gray-600'>Kategori:</span>
|
||||
<span className='font-medium text-gray-900'>
|
||||
{item?.category
|
||||
? (CATEGORY_LABELS[item.category] ?? item?.category)
|
||||
: item?.category}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<span className='text-gray-600'>Progress:</span>
|
||||
<span className='font-medium text-gray-900'>
|
||||
{item?.progress}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DialogFooter className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => setShowBulkApproveModal(false)}
|
||||
disabled={actionLoading}
|
||||
className='border-gray-200'
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
onClick={confirmBulkApprove}
|
||||
disabled={actionLoading}
|
||||
className='bg-green-600 hover:bg-green-700 text-white'
|
||||
>
|
||||
{actionLoading ? 'Memproses...' : 'Ya, Approve'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Reject Modal */}
|
||||
<Dialog open={showRejectModal} onOpenChange={setShowRejectModal}>
|
||||
<DialogContent className='sm:max-w-md bg-white rounded-xl shadow-lg'>
|
||||
@@ -735,6 +945,81 @@ export function ListDailyChecklistContent() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Bulk Reject Modal */}
|
||||
<Dialog open={showBulkRejectModal} onOpenChange={setShowBulkRejectModal}>
|
||||
<DialogContent className='sm:max-w-md bg-white rounded-xl shadow-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reject Checklist</DialogTitle>
|
||||
<DialogDescription>
|
||||
Berikan alasan reject untuk checklist ini
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='max-h-[60vh] overflow-y-auto flex flex-col gap-3'>
|
||||
{selectedRowItems.map((item) => (
|
||||
<div
|
||||
key={item?.id ?? 0}
|
||||
className='bg-gray-50 rounded-lg p-4 space-y-2 mb-4'
|
||||
>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<span className='text-gray-600'>Tanggal:</span>
|
||||
<span className='font-medium text-gray-900'>
|
||||
{formatDate(item?.date ?? '')}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<span className='text-gray-600'>Kandang:</span>
|
||||
<span className='font-medium text-gray-900'>
|
||||
{item?.kandang?.name ?? '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<span className='text-gray-600'>Kategori:</span>
|
||||
<span className='font-medium text-gray-900'>
|
||||
{item?.category
|
||||
? CATEGORY_LABELS[item.category] || item?.category
|
||||
: item?.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor='reject-reason'>
|
||||
Alasan Reject <span className='text-red-500'>*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id='reject-reason'
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
placeholder='Tuliskan alasan reject...'
|
||||
className='mt-1.5 border-gray-200 min-h-[100px]'
|
||||
disabled={actionLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => setShowBulkRejectModal(false)}
|
||||
disabled={actionLoading}
|
||||
className='border-gray-200'
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
onClick={confirmBulkReject}
|
||||
disabled={actionLoading}
|
||||
variant='destructive'
|
||||
className='bg-red-600 hover:bg-red-700 text-white'
|
||||
>
|
||||
{actionLoading ? 'Memproses...' : 'Ya, Reject'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Modal */}
|
||||
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
|
||||
<DialogContent className='sm:max-w-md bg-white rounded-xl shadow-lg'>
|
||||
|
||||
@@ -192,6 +192,29 @@ export class DailyChecklistApiService extends BaseApiService<
|
||||
}
|
||||
}
|
||||
|
||||
async bulkApprove(ids: string[]) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('ids', ids.join(','));
|
||||
formData.append('status', 'APPROVED');
|
||||
formData.append('reject_reason', '');
|
||||
|
||||
const approvePath = `${this.basePath}/bulk-update`;
|
||||
const approveRes = await httpClient<BaseApiResponse>(approvePath, {
|
||||
method: 'PATCH',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
return approveRes;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError<BaseApiResponse>(error)) {
|
||||
return error.response?.data;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async reject(id: string, rejectReason: string) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
@@ -215,6 +238,29 @@ export class DailyChecklistApiService extends BaseApiService<
|
||||
}
|
||||
}
|
||||
|
||||
async bulkReject(ids: string[], rejectReason: string) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('ids', ids.join(','));
|
||||
formData.append('status', 'REJECTED');
|
||||
formData.append('reject_reason', rejectReason);
|
||||
|
||||
const rejectPath = `${this.basePath}/bulk-update`;
|
||||
const rejectRes = await httpClient<BaseApiResponse>(rejectPath, {
|
||||
method: 'PATCH',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
return rejectRes;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError<BaseApiResponse>(error)) {
|
||||
return error.response?.data;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async uploadImage(
|
||||
id: number,
|
||||
status: string,
|
||||
|
||||
@@ -2,12 +2,14 @@ import axios from 'axios';
|
||||
import { BaseApiService } from '@/services/api/base';
|
||||
import { BaseApiResponse, GroupedApprovals } from '@/types/api/api-general';
|
||||
import {
|
||||
BulkApproveExpensePayload,
|
||||
CreateExpensePayload,
|
||||
CreateExpenseRealizationPayload,
|
||||
Expense,
|
||||
UpdateExpensePayload,
|
||||
} from '@/types/api/expense';
|
||||
import { httpClient } from '@/services/http/client';
|
||||
import { formatDate } from '@/lib/helper';
|
||||
|
||||
export class ExpenseApiService extends BaseApiService<
|
||||
Expense,
|
||||
@@ -330,6 +332,65 @@ export class ExpenseApiService extends BaseApiService<
|
||||
}
|
||||
}
|
||||
|
||||
async bulkApproveToStatus(
|
||||
payload: BulkApproveExpensePayload
|
||||
): Promise<BaseApiResponse<Expense | Expense[]> | undefined> {
|
||||
try {
|
||||
return await httpClient<BaseApiResponse<Expense | Expense[]>>(
|
||||
`${this.basePath}/approvals/bulk`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError<BaseApiResponse<Expense | Expense[]>>(error)) {
|
||||
return error.response?.data;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async bulkApprovals(
|
||||
ids: number[],
|
||||
status: BulkApproveExpensePayload['status'] | 'SELESAI',
|
||||
date?: string,
|
||||
notes?: string
|
||||
): Promise<BaseApiResponse<Expense | Expense[]> | undefined> {
|
||||
if (status === 'SELESAI') {
|
||||
const responses = await Promise.all(ids.map((id) => this.complete(id)));
|
||||
const failedResponse = responses.find(
|
||||
(response) => response?.status !== 'success'
|
||||
);
|
||||
|
||||
if (failedResponse) {
|
||||
return failedResponse;
|
||||
}
|
||||
|
||||
const completedExpenses = responses.flatMap((response) =>
|
||||
response?.status === 'success' ? [response.data] : []
|
||||
);
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
status: 'success',
|
||||
message:
|
||||
completedExpenses.length === 1
|
||||
? 'Submit expense approval successfully'
|
||||
: 'Submit expense approvals successfully',
|
||||
data: completedExpenses,
|
||||
};
|
||||
}
|
||||
|
||||
return this.bulkApproveToStatus({
|
||||
approvable_ids: ids,
|
||||
status,
|
||||
date: date || undefined,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async rejectHeadArea(
|
||||
id: number,
|
||||
notes?: string
|
||||
@@ -646,6 +707,33 @@ export class ExpenseApiService extends BaseApiService<
|
||||
|
||||
return formData;
|
||||
};
|
||||
|
||||
async exportInputProgressToExcel(startDate: string, endDate: string) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
params.set('export', 'excel');
|
||||
params.set('type', 'progress');
|
||||
params.set('start_date', formatDate(startDate, 'YYYY-MM-DD'));
|
||||
params.set('end_date', formatDate(endDate, 'YYYY-MM-DD'));
|
||||
|
||||
const queryString = `?${params.toString()}`;
|
||||
|
||||
const res = await httpClient<Blob>(`${this.basePath}${queryString}`, {
|
||||
method: 'GET',
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([res]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
|
||||
const fileName = `input-progres-BOP-${formatDate(startDate, 'DD-MM-YYYY')}-ke-${formatDate(endDate, 'DD-MM-YYYY')}.xlsx`;
|
||||
link.setAttribute('download', fileName);
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export const ExpenseApi = new ExpenseApiService('/expenses');
|
||||
|
||||
@@ -2,7 +2,9 @@ import { isResponseError } from '@/lib/api-helper';
|
||||
import { BaseApiService } from '@/services/api/base';
|
||||
import { httpClient, httpClientFetcher } from '@/services/http/client';
|
||||
import { BaseApiResponse } from '@/types/api/api-general';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
BulkApproveMarketingPayload,
|
||||
Marketing,
|
||||
CreateSalesOrderPayload,
|
||||
UpdateSalesOrderPayload,
|
||||
@@ -73,6 +75,26 @@ export class SalesOrderService extends BaseApiService<
|
||||
}
|
||||
}
|
||||
|
||||
async bulkApproveToStatus(
|
||||
payload: BulkApproveMarketingPayload
|
||||
): Promise<BaseApiResponse<Marketing | Marketing[]> | undefined> {
|
||||
try {
|
||||
return await httpClient<BaseApiResponse<Marketing | Marketing[]>>(
|
||||
'/marketing/approvals/bulk',
|
||||
{
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError<BaseApiResponse<Marketing | Marketing[]>>(error)) {
|
||||
return error.response?.data;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivery
|
||||
*/
|
||||
@@ -104,6 +126,29 @@ class MarketingExportService extends BaseApiService<
|
||||
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
|
||||
*/
|
||||
@@ -166,6 +211,33 @@ class MarketingExportService extends BaseApiService<
|
||||
toast.error('Gagal melakukan export marketing! Coba lagi.');
|
||||
}
|
||||
}
|
||||
|
||||
async exportInputProgressToExcel(startDate: string, endDate: string) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
params.set('export', 'excel');
|
||||
params.set('type', 'progress');
|
||||
params.set('start_date', formatDate(startDate, 'YYYY-MM-DD'));
|
||||
params.set('end_date', formatDate(endDate, 'YYYY-MM-DD'));
|
||||
|
||||
const queryString = `?${params.toString()}`;
|
||||
|
||||
const res = await httpClient<Blob>(`${this.basePath}${queryString}`, {
|
||||
method: 'GET',
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([res]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
|
||||
const fileName = `input-progres-penjualan-${formatDate(startDate, 'DD-MM-YYYY')}-ke-${formatDate(endDate, 'DD-MM-YYYY')}.xlsx`;
|
||||
link.setAttribute('download', fileName);
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export const SalesOrderApi = new SalesOrderService('/marketing/sales-orders');
|
||||
|
||||
@@ -116,6 +116,33 @@ export class RecordingService extends BaseApiService<
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
|
||||
async exportInputProgressToExcel(startDate: string, endDate: string) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
params.set('export', 'excel');
|
||||
params.set('type', 'progress');
|
||||
params.set('start_date', formatDate(startDate, 'YYYY-MM-DD'));
|
||||
params.set('end_date', formatDate(endDate, 'YYYY-MM-DD'));
|
||||
|
||||
const queryString = `?${params.toString()}`;
|
||||
|
||||
const res = await httpClient<Blob>(`${this.basePath}${queryString}`, {
|
||||
method: 'GET',
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([res]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
|
||||
const fileName = `input-progres-recording-${formatDate(startDate, 'DD-MM-YYYY')}-ke-${formatDate(endDate, 'DD-MM-YYYY')}.xlsx`;
|
||||
link.setAttribute('download', fileName);
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export const RecordingApi = new RecordingService('/production/recordings');
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
} from '@/types/api/purchase/purchase';
|
||||
import { BaseApiService } from '@/services/api/base';
|
||||
import { BaseApiResponse } from '@/types/api/api-general';
|
||||
import { formatDate } from '@/lib/helper';
|
||||
import { httpClient } from '../http/client';
|
||||
|
||||
const basePurchaseApi = new BaseApiService<
|
||||
Purchase,
|
||||
@@ -112,4 +114,34 @@ export const PurchaseApi = {
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
async exportInputProgressToExcel(startDate: string, endDate: string) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
params.set('export', 'excel');
|
||||
params.set('type', 'progress');
|
||||
params.set('start_date', formatDate(startDate, 'YYYY-MM-DD'));
|
||||
params.set('end_date', formatDate(endDate, 'YYYY-MM-DD'));
|
||||
|
||||
const queryString = `?${params.toString()}`;
|
||||
|
||||
const res = await httpClient<Blob>(
|
||||
`${basePurchaseApi.basePath}${queryString}`,
|
||||
{
|
||||
method: 'GET',
|
||||
responseType: 'blob',
|
||||
}
|
||||
);
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([res]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
|
||||
const fileName = `input-progres-pembelian-${formatDate(startDate, 'DD-MM-YYYY')}-ke-${formatDate(endDate, 'DD-MM-YYYY')}.xlsx`;
|
||||
link.setAttribute('download', fileName);
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo, useReducer } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useReducer } from 'react';
|
||||
import { useTableFilterStore } from '@/stores/table/table-filter.store';
|
||||
|
||||
/** Core filter shape (page + pageSize) extended by your custom fields */
|
||||
export type TableFilterState<TExtra extends Record<string, unknown>> = {
|
||||
@@ -30,6 +31,9 @@ export type UseTableFilterOptions<TExtra extends Record<string, unknown>> = {
|
||||
paramMap?: Partial<Record<keyof TableFilterState<TExtra>, string>>;
|
||||
/** If true, `toSearchParams`/`toQueryString` will omit values equal to defaults */
|
||||
omitDefaultsInUrl?: boolean;
|
||||
|
||||
persist?: boolean;
|
||||
storeName?: string;
|
||||
};
|
||||
|
||||
function clampToInt(n: number, min = 1) {
|
||||
@@ -90,9 +94,37 @@ function shallowEqual<T extends Record<string, unknown>>(
|
||||
export function useTableFilter<TExtra extends Record<string, unknown>>(
|
||||
options?: UseTableFilterOptions<TExtra>
|
||||
) {
|
||||
const defaults = useMemo(
|
||||
() => createInitialState<TExtra>(options),
|
||||
[options]
|
||||
if (options?.persist && !options?.storeName) {
|
||||
throw new Error(
|
||||
'storeName is required if persist is true in useTableFilter!'
|
||||
);
|
||||
}
|
||||
|
||||
const storeName = options?.storeName ?? '';
|
||||
const persistedState = useTableFilterStore(
|
||||
useCallback(
|
||||
(storeState) =>
|
||||
storeName
|
||||
? (storeState.data[storeName] as Partial<TableFilterState<TExtra>>)
|
||||
: undefined,
|
||||
[storeName]
|
||||
)
|
||||
);
|
||||
const setTableData = useTableFilterStore(
|
||||
(storeState) => storeState.setTableData
|
||||
);
|
||||
|
||||
const defaults = useMemo(() => {
|
||||
return createInitialState<TExtra>(options);
|
||||
}, [options]);
|
||||
|
||||
const initialState = useMemo(
|
||||
() =>
|
||||
({
|
||||
...defaults,
|
||||
...(persistedState as object),
|
||||
}) as TableFilterState<TExtra>,
|
||||
[defaults, persistedState]
|
||||
);
|
||||
|
||||
const [state, dispatch] = useReducer(
|
||||
@@ -106,15 +138,22 @@ export function useTableFilter<TExtra extends Record<string, unknown>>(
|
||||
case 'SET_PAGE_SIZE': {
|
||||
const pageSize = clampToInt(a.pageSize);
|
||||
const page = a.resetPage ? 1 : s.page;
|
||||
|
||||
return { ...s, pageSize, page };
|
||||
}
|
||||
case 'SET_FILTERS': {
|
||||
const page = a.resetPage ? 1 : s.page;
|
||||
|
||||
return { ...s, ...a.filters, page };
|
||||
}
|
||||
case 'UPDATE_FILTER': {
|
||||
const page = a.resetPage ? 1 : s.page;
|
||||
return { ...s, [a.key]: a.value, page } as TableFilterState<TExtra>;
|
||||
|
||||
return {
|
||||
...s,
|
||||
[a.key]: a.value,
|
||||
page,
|
||||
} as TableFilterState<TExtra>;
|
||||
}
|
||||
case 'REPLACE_ALL':
|
||||
return {
|
||||
@@ -128,12 +167,19 @@ export function useTableFilter<TExtra extends Record<string, unknown>>(
|
||||
return s;
|
||||
}
|
||||
},
|
||||
defaults
|
||||
initialState
|
||||
);
|
||||
|
||||
// Notify consumer on change (stable ref)
|
||||
useEffect(() => {
|
||||
if (!options?.persist || !storeName) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTableData(storeName, state);
|
||||
}, [options?.persist, setTableData, state, storeName]);
|
||||
|
||||
const onChange = options?.onChange;
|
||||
useMemo(() => {
|
||||
useEffect(() => {
|
||||
if (onChange) onChange(state);
|
||||
}, [state, onChange]);
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { create } from 'zustand';
|
||||
import { createJSONStorage, devtools, persist } from 'zustand/middleware';
|
||||
import { TableFilterStore } from '@/types/stores';
|
||||
|
||||
type TableFilterStoreState = TableFilterStore<
|
||||
Record<string, Record<string, unknown>>
|
||||
>;
|
||||
|
||||
export const useTableFilterStore = create<TableFilterStoreState>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set) => ({
|
||||
data: {},
|
||||
|
||||
setData: (newData) => {
|
||||
set({ data: newData });
|
||||
},
|
||||
|
||||
setTableData: (key, tableData) => {
|
||||
set((state) => ({
|
||||
data: {
|
||||
...state.data,
|
||||
[key]: tableData,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
setTableDataField: (key, field, value) => {
|
||||
set((state) => ({
|
||||
data: {
|
||||
...state.data,
|
||||
[key]: {
|
||||
...state.data[key],
|
||||
[field]: value,
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
setSearchValue: (key, searchValue) => {
|
||||
set((state) => ({
|
||||
data: {
|
||||
...state.data,
|
||||
[key]: {
|
||||
...state.data[key],
|
||||
|
||||
// search key
|
||||
search: searchValue,
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'table-filter-store',
|
||||
storage: createJSONStorage(() => sessionStorage),
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import { createJSONStorage, devtools, persist } from 'zustand/middleware';
|
||||
|
||||
import { UIStore } from '@/types/stores';
|
||||
import { createMainUiSlice } from '@/stores/ui/slices/main.slice';
|
||||
@@ -20,6 +20,7 @@ export const useUiStore = create<UIStore>()(
|
||||
}),
|
||||
{
|
||||
name: 'search-store',
|
||||
storage: createJSONStorage(() => sessionStorage),
|
||||
partialize: (state) => ({
|
||||
key: state.key,
|
||||
path: state.path,
|
||||
|
||||
Vendored
+13
@@ -99,3 +99,16 @@ export type CreateExpenseRealizationPayload = {
|
||||
};
|
||||
|
||||
export type UpdateExpenseRealizationPayload = CreateExpenseRealizationPayload;
|
||||
|
||||
export type ExpenseBulkApprovalStatus =
|
||||
| 'HEAD_AREA'
|
||||
| 'UNIT_VICE_PRESIDENT'
|
||||
| 'FINANCE'
|
||||
| 'REALISASI';
|
||||
|
||||
export type BulkApproveExpensePayload = {
|
||||
approvable_ids: number[];
|
||||
status: ExpenseBulkApprovalStatus;
|
||||
date?: string;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
+10
@@ -54,6 +54,7 @@ export type BaseDeliveryOrder = {
|
||||
};
|
||||
|
||||
export type BaseDelivery = {
|
||||
marketing_product_id: number;
|
||||
product_warehouse: ProductWarehouse;
|
||||
qty: number;
|
||||
unit_price: number;
|
||||
@@ -162,3 +163,12 @@ export type UpdateDeliveryOrderProductPayload =
|
||||
export type UpdateSalesOrderPayload = CreateSalesOrderPayload;
|
||||
|
||||
export type UpdateDeliveryOrderPayload = CreateDeliveryOrderPayload;
|
||||
|
||||
export type MarketingBulkApprovalStatus = 'SALES_ORDER' | 'DELIVERY';
|
||||
|
||||
export type BulkApproveMarketingPayload = {
|
||||
approvable_ids: number[];
|
||||
status: MarketingBulkApprovalStatus;
|
||||
date?: string;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
Vendored
+8
@@ -117,3 +117,11 @@ export type ProjectFlockSlice = {
|
||||
setCreatedProjectFlock: (data: ProjectFlock | null) => void;
|
||||
resetProjectFlock: () => void;
|
||||
};
|
||||
|
||||
export type TableFilterStore<T = Record<string, Record<string, unknown>>> = {
|
||||
data: T;
|
||||
setData: (newData: T) => void;
|
||||
setTableData: (key: string, tableData: Record<string, unknown>) => void;
|
||||
setTableDataField: (key: string, field: string, value: unknown) => void;
|
||||
setSearchValue: (key: string, searchValue: string) => void;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user