Files
lti-web-client/src/components/pages/report/expense/tab/ReportExpenseTab.tsx
T

816 lines
26 KiB
TypeScript

'use client';
import React, {
useState,
useCallback,
useEffect,
useMemo,
useRef,
} from 'react';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import Dropdown from '@/components/dropdown/Dropdown';
import SelectInput, { useSelect } from '@/components/input/SelectInput';
import DateInput from '@/components/input/DateInput';
import { useFormik } from 'formik';
import {
ReportExpenseFilterSchema,
type ReportExpenseFilterValues,
} from '@/components/pages/report/expense/filter/ReportExpenseFilter';
import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge';
import RealizationStatusBadge from '@/components/pages/expense/RealizationStatusBadge';
import Table from '@/components/Table';
import { formatCurrency, formatDate } from '@/lib/helper';
import { ReportExpense } from '@/types/api/report/report-expense';
import { ReportExpenseApi } from '@/services/api/report/expense-report';
import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import Modal, { useModal } from '@/components/Modal';
import Pagination from '@/components/Pagination';
import ReportExpenseSkeleton from '@/components/pages/report/expense/skeleton/ReportExpenseSkeleton';
import { generateReportExpensePDF } from '../export/ReportExpenseExportPDF';
import { generateReportExpenseExcel } from '../export/ReportExpenseExportXLSX';
import toast from 'react-hot-toast';
import {
LocationApi,
NonstockApi,
SupplierApi,
} from '@/services/api/master-data';
import { Supplier } from '@/types/api/master-data/supplier';
import { Nonstock } from '@/types/api/master-data/nonstock';
import { ColumnDef, SortingState, Updater } from '@tanstack/react-table';
import { httpClient } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
import ButtonFilter from '@/components/helper/ButtonFilter';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { ProjectFlockKandangApi } from '@/services/api/production/project-flock-kandang';
interface ReportExpenseTabProps {
tabId: string;
}
interface FilterParams {
location_id?: string;
supplier_id?: string;
kandang_id?: string;
nonstock_id?: string;
realization_date?: string;
category?: string;
search?: string;
}
const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
// ===== STATE MANAGEMENT =====
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
// ===== SUBMISSION STATE =====
const [filterParams, setFilterParams] = useState<FilterParams>({});
// ===== PAGINATION STATE =====
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
// ===== SORTING STATE =====
const [sortBy, setSortBy] = useState('');
const [orderBy, setOrderBy] = useState('');
const sorting: SortingState = sortBy
? [{ id: sortBy, desc: orderBy === 'desc' }]
: [];
const handleSortingChange = (updater: Updater<SortingState>) => {
const next = typeof updater === 'function' ? updater(sorting) : updater;
if (next.length > 0) {
setSortBy(next[0].id);
setOrderBy(next[0].desc ? 'desc' : 'asc');
} else {
setSortBy('');
setOrderBy('');
}
};
const handleFilterModalOpenRef = useRef(() => {});
const filterModal = useModal();
const categoryOptions = useMemo(
() => [
{ value: 'BOP', label: 'BOP' },
{ value: 'NON-BOP', label: 'Non BOP' },
],
[]
);
// ===== FORMIK SETUP =====
const formik = useFormik<ReportExpenseFilterValues>({
initialValues: {
location_id: null,
supplier_id: null,
kandang_id: null,
nonstock_id: null,
realization_date: null,
category: null,
},
validationSchema: ReportExpenseFilterSchema,
onSubmit: (values) => {
setFilterParams({
location_id: values.location_id?.value
? String(values.location_id.value)
: undefined,
supplier_id: values.supplier_id?.value
? String(values.supplier_id.value)
: undefined,
kandang_id: values.kandang_id?.value
? String(values.kandang_id.value)
: undefined,
nonstock_id: values.nonstock_id?.value
? String(values.nonstock_id.value)
: undefined,
realization_date: values.realization_date || undefined,
category: values.category?.value
? String(values.category.value)
: undefined,
});
filterModal.closeModal();
setPage(1);
},
onReset: () => {
setFilterParams({});
setPage(1);
filterModal.closeModal();
},
});
handleFilterModalOpenRef.current = () => {
const restoredLocation = filterParams.location_id
? locationOptions.find(
(opt) => String(opt.value) === filterParams.location_id
) || {
value: filterParams.location_id,
label: filterParams.location_id,
}
: null;
const restoredSupplier = filterParams.supplier_id
? supplierOptions.find(
(opt) => String(opt.value) === filterParams.supplier_id
) || {
value: filterParams.supplier_id,
label: filterParams.supplier_id,
}
: null;
const restoredKandang = filterParams.kandang_id
? projectFlockKandangOptions.find(
(opt) => String(opt.value) === filterParams.kandang_id
) || { value: filterParams.kandang_id, label: filterParams.kandang_id }
: null;
const restoredNonstock = filterParams.nonstock_id
? nonstockOptions.find(
(opt) => String(opt.value) === filterParams.nonstock_id
) || {
value: filterParams.nonstock_id,
label: filterParams.nonstock_id,
}
: null;
const restoredCategory = filterParams.category
? categoryOptions.find((opt) => opt.value === filterParams.category) ||
null
: null;
formik.setValues({
location_id: restoredLocation,
supplier_id: restoredSupplier,
kandang_id: restoredKandang,
nonstock_id: restoredNonstock,
realization_date: filterParams.realization_date || null,
category: restoredCategory,
});
filterModal.openModal();
};
// ===== OPTIONS =====
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocations,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setSupplierInputValue,
options: supplierOptions,
isLoadingOptions: isLoadingSuppliers,
loadMore: loadMoreSuppliers,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setProjectFlockKandangInputValue,
options: projectFlockKandangOptions,
isLoadingOptions: isLoadingProjectFlockKandangs,
loadMore: loadMoreProjectFlockKandangs,
} = useSelect<ProjectFlockKandang>(
ProjectFlockKandangApi.basePath,
'id',
'name_with_period',
'search',
formik.values.location_id?.value
? { location_id: String(formik.values.location_id.value) }
: undefined
);
const {
setInputValue: setNonstockInputValue,
options: nonstockOptions,
isLoadingOptions: isLoadingNonstocks,
loadMore: loadMoreNonstocks,
} = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name', 'search');
// ===== FILTER VALUES =====
const locationValue = useMemo(
() => formik.values.location_id,
[formik.values.location_id]
);
const supplierValue = useMemo(
() => formik.values.supplier_id,
[formik.values.supplier_id]
);
const kandangValue = useMemo(
() => formik.values.kandang_id,
[formik.values.kandang_id]
);
const nonstockValue = useMemo(
() => formik.values.nonstock_id,
[formik.values.nonstock_id]
);
const categoryValue = useMemo(
() => formik.values.category,
[formik.values.category]
);
const buildReportExpenseQueryString = useCallback(
(extraParams?: Record<string, string>) => {
const params = new URLSearchParams();
if (filterParams.location_id) {
params.append('location_id', filterParams.location_id);
}
if (filterParams.supplier_id) {
params.append('supplier_id', filterParams.supplier_id);
}
if (filterParams.kandang_id) {
params.append('project_flock_kandang_id', filterParams.kandang_id);
}
if (filterParams.nonstock_id) {
params.append('nonstock_id', filterParams.nonstock_id);
}
if (filterParams.realization_date) {
params.append('realization_date', filterParams.realization_date);
}
if (filterParams.category) {
params.append('category', filterParams.category);
}
if (sortBy) params.append('sort_by', sortBy);
if (orderBy) params.append('sort_order', orderBy);
Object.entries(extraParams ?? {}).forEach(([key, value]) => {
params.set(key, value);
});
return params.toString();
},
[filterParams, sortBy, orderBy]
);
// ===== DATA FETCHING =====
const { data: reportExpenseResponse, isLoading } = useSWR(
() => {
const queryString = buildReportExpenseQueryString({
page: String(page),
limit: String(pageSize),
});
return [`${ReportExpenseApi.basePath}?${queryString}`];
},
([url]: string[]) => httpClient<BaseApiResponse<ReportExpense[]>>(url)
);
const data: ReportExpense[] = useMemo(
() =>
isResponseSuccess(reportExpenseResponse)
? (reportExpenseResponse.data as ReportExpense[]) || []
: [],
[reportExpenseResponse]
);
const meta = useMemo(
() =>
isResponseSuccess(reportExpenseResponse) && reportExpenseResponse.meta
? reportExpenseResponse.meta
: null,
[reportExpenseResponse]
);
// ===== EXPORT DATA FETCHER =====
const reportExpenseExport = useCallback(async (): Promise<
ReportExpense[] | null
> => {
const queryString = buildReportExpenseQueryString({
page: '1',
limit: '100',
});
const response = await httpClient<BaseApiResponse<ReportExpense[]>>(
`${ReportExpenseApi.basePath}?${queryString}`
);
return isResponseSuccess(response) ? response.data : null;
}, [buildReportExpenseQueryString]);
// ===== EXPORT HANDLERS =====
const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true);
try {
await ReportExpenseApi.exportToExcel(buildReportExpenseQueryString());
} catch (error) {
toast.error(
await getErrorMessage(error, 'Gagal mengekspor data pengeluaran')
);
} finally {
setIsExcelExportLoading(false);
}
}, [buildReportExpenseQueryString]);
const handleExportPDF = useCallback(async () => {
setIsPdfExportLoading(true);
try {
const allData = await reportExpenseExport();
if (!allData || allData.length === 0) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
await generateReportExpensePDF(allData);
toast.success('PDF berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat PDF. Silakan coba lagi.');
} finally {
setIsPdfExportLoading(false);
}
}, [reportExpenseExport]);
// ===== TAB ACTIONS COMPONENT =====
const TabActions = useMemo(() => {
return function TabActionsComponent() {
const setTabActions = useTabActionsStore((state) => state.setTabActions);
const clearTabActions = useTabActionsStore(
(state) => state.clearTabActions
);
useEffect(() => {
setTabActions(
tabId,
<div className='flex flex-row gap-3'>
<ButtonFilter
values={filterParams}
onClick={() => handleFilterModalOpenRef.current()}
variant='outline'
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'
isLoading={isAnyExportLoading}
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={handleExportExcel}
isLoading={isExcelExportLoading}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Export to Excel
</Button>
<Button
variant='ghost'
color='none'
onClick={handleExportPDF}
isLoading={isPdfExportLoading}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:document' width={20} height={20} />
Export to PDF
</Button>
</Dropdown>
</div>
);
}, [setTabActions]);
useEffect(() => {
return () => {
clearTabActions(tabId);
};
}, [clearTabActions]);
return null;
};
}, [
tabId,
filterParams,
isAnyExportLoading,
handleExportExcel,
handleExportPDF,
isExcelExportLoading,
isPdfExportLoading,
]);
const TabActionsElement = useMemo(() => <TabActions />, [TabActions]);
// ===== TABLE COLUMNS DEFINITION =====
const columns = useMemo((): ColumnDef<ReportExpense>[] => {
return [
{
header: 'No',
enableSorting: false,
cell: (props) => (page - 1) * pageSize + props.row.index + 1,
},
{
header: 'No. PO',
accessorKey: 'po_number',
enableSorting: true,
},
{
header: 'No. Referensi',
accessorKey: 'reference_number',
enableSorting: true,
},
{
header: 'Tanggal Realisasi',
accessorKey: 'realization_date',
enableSorting: true,
cell: ({ row }) => {
return formatDate(row.original?.realization_date, 'DD MMM, YYYY');
},
},
{
header: 'Tanggal Transaksi',
accessorKey: 'transaction_date',
enableSorting: true,
cell: ({ row }) => {
return formatDate(row.original?.transaction_date, 'DD MMM, YYYY');
},
},
{
header: 'Kategori',
accessorKey: 'category',
enableSorting: true,
},
{
header: 'Produk',
accessorKey: 'product',
enableSorting: true,
accessorFn: (row) => row.pengajuan?.nonstock?.name,
},
{
header: 'Supplier',
accessorKey: 'supplier',
enableSorting: true,
accessorFn: (row) => row.supplier?.name,
},
{
header: 'Lokasi',
accessorKey: 'location',
enableSorting: true,
accessorFn: (row) => row.kandang?.location?.name,
},
{
header: 'Kandang',
accessorKey: 'kandang',
enableSorting: true,
accessorFn: (row) => row.kandang?.name,
},
{
header: 'Pengajuan',
columns: [
{
header: 'Qty',
accessorKey: 'qty_pengajuan',
cell: ({ row }) =>
row.original.pengajuan?.qty?.toLocaleString('id-ID') || '0',
},
{
header: 'Harga',
accessorKey: 'price_pengajuan',
cell: ({ row }) =>
formatCurrency(row.original.pengajuan?.price || 0),
},
{
header: 'Total',
accessorKey: 'total_pengajuan',
cell: ({ row }) => {
const total =
(row.original.pengajuan?.qty || 0) *
(row.original.pengajuan?.price || 0);
return formatCurrency(total);
},
},
],
},
{
header: 'Realisasi',
columns: [
{
header: 'Qty',
accessorKey: 'qty_realisasi',
cell: ({ row }) =>
row.original.realisasi?.qty?.toLocaleString('id-ID') || '0',
},
{
header: 'Harga',
accessorKey: 'price_realisasi',
cell: ({ row }) =>
formatCurrency(row.original.realisasi?.price || 0),
},
{
header: 'Total',
accessorKey: 'total_realisasi',
cell: ({ row }) => {
const total =
(row.original.realisasi?.qty || 0) *
(row.original.realisasi?.price || 0);
return formatCurrency(total);
},
},
],
},
{
id: 'realization_status',
header: 'Status Pencairan',
cell: (props) => (
<RealizationStatusBadge
approval={props.row.original?.latest_approval}
/>
),
},
{
id: 'bop_status',
header: 'Status BOP',
cell: (props) => (
<ExpenseStatusBadge approval={props.row.original?.latest_approval} />
),
},
];
}, [page, pageSize]);
return (
<>
{TabActionsElement}
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
{isLoading && (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
)}
{!isLoading && (!data || data.length === 0) && (
<ReportExpenseSkeleton
columns={columns}
icon={
<Icon
icon='heroicons:chart-bar'
className='text-white'
width={20}
height={20}
/>
}
title='Data Not Yet Available'
subtitle='Please change your filters to get the data.'
/>
)}
{!isLoading && data.length > 0 && (
<>
<Table
data={data}
columns={columns}
pageSize={pageSize}
page={meta?.page || 1}
totalItems={meta?.total_results || 0}
onPageChange={setPage}
onPageSizeChange={setPageSize}
sorting={sorting}
setSorting={handleSortingChange}
manualSorting
className={{
containerClassName: 'w-full mb-0!',
tableWrapperClassName: 'overflow-x-auto',
tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
headerColumnClassName:
'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200 text-nowrap',
bodyRowClassName:
'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200',
bodyColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
paginationClassName: 'hidden',
}}
/>
{meta && (
<div className='max-w-sm ml-auto'>
<Pagination
totalItems={meta.total_results || 0}
itemsPerPage={meta.limit || 0}
currentPage={meta.page || 0}
onPrevPage={() =>
setPage((currPage) =>
currPage > 1 ? currPage - 1 : currPage
)
}
onNextPage={() =>
setPage((currPage) =>
meta && meta.total_pages && currPage < meta.total_pages
? currPage + 1
: currPage
)
}
onPageChange={(pageNumber) => setPage(pageNumber)}
rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize}
/>
</div>
)}
</>
)}
</div>
{/* Filter Modal */}
<Modal
ref={filterModal.ref}
className={{
modal: 'p-0',
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
}}
>
{/* Modal Header */}
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
<div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='font-medium text-sm'>Filter Data</h3>
</div>
<Button
variant='link'
onClick={filterModal.closeModal}
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
{/* Modal Body */}
<div className='p-4 flex flex-col gap-3'>
<SelectInput
label='Lokasi'
placeholder='Pilih Lokasi'
options={locationOptions}
isLoading={isLoadingLocations}
value={locationValue}
onChange={(val) => {
formik.setFieldValue('location_id', val);
formik.setFieldValue('kandang_id', null);
}}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isClearable
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Kandang'
placeholder='Pilih Kandang'
options={projectFlockKandangOptions}
isLoading={isLoadingProjectFlockKandangs}
value={kandangValue}
onChange={(val) => {
formik.setFieldValue('kandang_id', val);
}}
onInputChange={setProjectFlockKandangInputValue}
onMenuScrollToBottom={loadMoreProjectFlockKandangs}
isClearable
isDisabled={!formik.values.location_id}
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Supplier'
placeholder='Pilih Supplier'
options={supplierOptions}
isLoading={isLoadingSuppliers}
value={supplierValue}
onChange={(val) => {
formik.setFieldValue('supplier_id', val);
}}
onInputChange={setSupplierInputValue}
onMenuScrollToBottom={loadMoreSuppliers}
isClearable
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Produk'
placeholder='Pilih Produk'
options={nonstockOptions}
isLoading={isLoadingNonstocks}
value={nonstockValue}
onChange={(val) => {
formik.setFieldValue('nonstock_id', val);
}}
onInputChange={setNonstockInputValue}
onMenuScrollToBottom={loadMoreNonstocks}
isClearable
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Kategori'
placeholder='Pilih Kategori'
options={categoryOptions}
value={categoryValue}
onChange={(val) => {
formik.setFieldValue('category', val);
}}
isClearable
className={{ wrapper: 'w-full' }}
/>
<DateInput
label='Tanggal Realisasi'
name='realization_date'
value={formik.values.realization_date || ''}
onChange={(e) => {
formik.setFieldValue(
'realization_date',
e.target.value || null
);
}}
className={{ wrapper: 'w-full' }}
/>
</div>
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='reset'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
>
Reset Filter
</Button>
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
>
Apply Filter
</Button>
</div>
</form>
</Modal>
</>
);
};
export default ReportExpenseTab;