Merge branch 'development' into 'production'

Development

See merge request mbugroup/lti-web-client!420
This commit is contained in:
Adnan Zahir
2026-04-22 13:12:50 +07:00
22 changed files with 2042 additions and 220 deletions
+1 -1
View File
@@ -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();
+458 -101
View File
@@ -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}
+435 -51
View File
@@ -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,
@@ -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'
+209 -1
View File
@@ -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>
</>
);
};
@@ -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,
+88
View File
@@ -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');
+72
View File
@@ -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');
+27
View File
@@ -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');
+32
View File
@@ -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();
},
};
+54 -8
View File
@@ -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]);
+60
View File
@@ -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),
}
)
)
);
+2 -1
View File
@@ -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,
+13
View File
@@ -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
View File
@@ -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;
};
+8
View File
@@ -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;
};