import { useState, useMemo, useCallback } from 'react'; import { ChangeEventHandler } from 'react'; import useSWR from 'swr'; import Button from '@/components/Button'; import Card from '@/components/Card'; import DateInput from '@/components/input/DateInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import SelectInput, { OptionType, useSelect, } from '@/components/input/SelectInput'; import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge'; import RealizationStatusBadge from '@/components/pages/expense/RealizationStatusBadge'; import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table'; import { cn, formatCurrency, formatDate } from '@/lib/helper'; import { ReportExpense } from '@/types/api/report/report-expense'; import { Icon } from '@iconify/react'; import { ColumnDef } from '@tanstack/react-table'; import { ReportExpenseApi } from '@/services/api/report'; import { isResponseSuccess } from '@/lib/api-helper'; import { useTableFilter } from '@/services/hooks/useTableFilter'; import Pagination from '@/components/Pagination'; import Dropdown from '@/components/dropdown/Dropdown'; import Menu from '@/components/menu/Menu'; import MenuItem from '@/components/menu/MenuItem'; import * as XLSX from 'xlsx'; import { generateReportExpensePDF } from './pdf/ReportExpenseExport'; import toast from 'react-hot-toast'; const ReportExpenseTable = () => { // ===== STATE MANAGEMENT ===== const [isPdfExportLoading, setIsPdfExportLoading] = useState(false); const [isExcelExportLoading, setIsExcelExportLoading] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false); const [pdfProgress, setPdfProgress] = useState(0); const [excelProgress, setExcelProgress] = useState(0); const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading; // ===== SUBMISSION STATE ===== const [isSubmitted, setIsSubmitted] = useState(false); // ===== TABLE FILTER STATE ===== const { state: filterState, updateFilter, setPage, setPageSize, reset: resetFilterState, toQueryString, } = useTableFilter({ initial: { location_id: '', supplier_id: '', kandang_id: '', nonstock_id: '', realization_date: '', category: '', search: '', }, paramMap: { page: 'page', pageSize: 'limit', }, }); // ===== SELECT OPTIONS ===== const { options: optionsLocation, isLoadingOptions: isLoadingLocation } = useSelect(`/master-data/locations`, 'id', 'name'); const { options: optionsSupplier, isLoadingOptions: isLoadingSupplier } = useSelect(`/master-data/suppliers`, 'id', 'name'); const { options: optionsKandang, isLoadingOptions: isLoadingKandang } = useSelect(`/master-data/kandangs`, 'id', 'name', '', { location_id: filterState.location_id, }); const { options: optionsNonstock, isLoadingOptions: isLoadingNonstock } = useSelect(`/master-data/nonstocks`, 'id', 'name'); const categoryOptions = useMemo( () => [ { value: 'BOP', label: 'BOP' }, { value: 'NON-BOP', label: 'Non BOP' }, ], [] ); // Mendapatkan value option select dari filter state const selectedLocation = useMemo( () => optionsLocation.find( (opt) => String(opt.value) === filterState.location_id ) || null, [optionsLocation, filterState.location_id] ); const selectedSupplier = useMemo( () => optionsSupplier.find( (opt) => String(opt.value) === filterState.supplier_id ) || null, [optionsSupplier, filterState.supplier_id] ); const selectedKandang = useMemo( () => optionsKandang.find( (opt) => String(opt.value) === filterState.kandang_id ) || null, [optionsKandang, filterState.kandang_id] ); const selectedNonstock = useMemo( () => optionsNonstock.find( (opt) => String(opt.value) === filterState.nonstock_id ) || null, [optionsNonstock, filterState.nonstock_id] ); const selectedCategory = useMemo( () => categoryOptions.find((opt) => opt.value === filterState.category) || null, [categoryOptions, filterState.category] ); // ===== FILTER CHANGE HANDLERS ===== const locationChangeHandler = useCallback( (val: OptionType | OptionType[] | null) => { const option = val as OptionType; updateFilter('location_id', option ? String(option.value) : ''); updateFilter('kandang_id', ''); setIsSubmitted(false); }, [updateFilter] ); const kandangChangeHandler = useCallback( (val: OptionType | OptionType[] | null) => { const option = val as OptionType; updateFilter('kandang_id', option ? String(option.value) : ''); setIsSubmitted(false); }, [updateFilter] ); const supplierChangeHandler = useCallback( (val: OptionType | OptionType[] | null) => { const option = val as OptionType; updateFilter('supplier_id', option ? String(option.value) : ''); setIsSubmitted(false); }, [updateFilter] ); const nonstockChangeHandler = useCallback( (val: OptionType | OptionType[] | null) => { const option = val as OptionType; updateFilter('nonstock_id', option ? String(option.value) : ''); setIsSubmitted(false); }, [updateFilter] ); const categoryChangeHandler = useCallback( (val: OptionType | OptionType[] | null) => { const option = val as OptionType; updateFilter('category', option ? String(option.value) : ''); setIsSubmitted(false); }, [updateFilter] ); const realizationDateChangeHandler = useCallback< ChangeEventHandler >( (e) => { updateFilter('realization_date', e.target.value || ''); setIsSubmitted(false); }, [updateFilter] ); const searchChangeHandler = useCallback( (e: React.ChangeEvent) => { updateFilter('search', e.target.value); setIsSubmitted(false); }, [updateFilter] ); // ===== RESET FILTERS ===== const resetFilters = useCallback(() => { resetFilterState(); setIsSubmitted(false); }, [resetFilterState]); // ===== SUBMIT HANDLER ===== const handleSubmit = useCallback(() => { setIsSubmitted(true); setPage(1); }, [setPage]); // ===== DATA FETCHING FOR TABLE ===== const { data: reportExpenseResponse, isLoading } = useSWR( isSubmitted ? () => { return ['report-expense', toQueryString()]; } : null, ([, query]) => { const endpoint = `${ReportExpenseApi.basePath}${query}`; return ReportExpenseApi.getAllFetcher(endpoint); } ); 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 params = new URLSearchParams(toQueryString().replace('?', '')); params.set('limit', 'limit'); params.set('page', '1'); const endpoint = `${ReportExpenseApi.basePath}?${params.toString()}`; const response = await ReportExpenseApi.getAllFetcher(endpoint); return isResponseSuccess(response) ? response.data : null; }, [toQueryString]); // ===== EXPORT HANDLERS ===== const handleExportPdf = useCallback(async () => { if (isPdfExportLoading) return; setIsPdfExportLoading(true); setPdfProgress(0); await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined)) ); try { // Stage 1: Fetching data (0-20%) setPdfProgress(10); await new Promise((resolve) => setTimeout(resolve, 50)); const allData = await reportExpenseExport(); if (!allData || allData.length === 0) { toast.error('Tidak ada data untuk diekspor.'); setIsPdfExportLoading(false); setPdfProgress(0); return; } // Stage 2: Data fetched - langsung loncat ke progress tinggi setPdfProgress(30); await new Promise((resolve) => setTimeout(resolve, 50)); const progressInterval = setInterval(() => { setPdfProgress((prev) => { // Increment kecil dan random antara 0.5-2% const increment = Math.random() * 1.5 + 0.5; const newProgress = Math.min(prev + increment, 50); return newProgress; }); }, 300); // Update setiap 300ms const pdfParams = { location_name: selectedLocation?.label, supplier_name: selectedSupplier?.label, kandang_name: selectedKandang?.label, nonstock_name: selectedNonstock?.label, category: selectedCategory?.label, realization_date: filterState.realization_date, search: filterState.search, }; setDropdownOpen(false); // Stage 3: Langsung loncat ke 80-85% untuk menghindari stuck const baseProgress = 80 + Math.floor(Math.random() * 16); // Random 80-85% setPdfProgress(baseProgress); await new Promise((resolve) => setTimeout(resolve, 100)); // Stage 4: Berikan jeda untuk UI update await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined)) ); // Proses PDF yang sebenarnya await generateReportExpensePDF(allData, pdfParams); clearInterval(progressInterval); // Stage 5: Finalizing (98-100%) setPdfProgress(99); await new Promise((resolve) => setTimeout(resolve, 100)); setPdfProgress(100); toast.success('PDF berhasil dibuat dan diunduh.'); // Reset progress setelah selesai setTimeout(() => setPdfProgress(0), 500); } catch (error) { console.error('PDF Export Error:', error); toast.error('Gagal membuat PDF. Silakan coba lagi.'); setPdfProgress(0); } finally { setIsPdfExportLoading(false); } }, [ reportExpenseExport, selectedLocation, selectedSupplier, selectedKandang, selectedNonstock, selectedCategory, filterState.realization_date, filterState.search, ]); const handleExportExcel = useCallback(async () => { if (isExcelExportLoading) return; setIsExcelExportLoading(true); setExcelProgress(0); setDropdownOpen(false); await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined)) ); try { // Stage 1: Fetching data (0-20%) setExcelProgress(15); await new Promise((resolve) => setTimeout(resolve, 50)); const allDataForExport = await reportExpenseExport(); if (!allDataForExport || allDataForExport.length === 0) { toast.error('Tidak ada data untuk diekspor.'); setIsExcelExportLoading(false); setExcelProgress(0); return; } // Stage 2: Data fetched (20-40%) setExcelProgress(30); await new Promise((resolve) => setTimeout(resolve, 50)); // Stage 3: Grouping data (40-60%) setExcelProgress(50); const groupedBySupplier: Record = {}; allDataForExport.forEach((item) => { const supplierName = item.supplier?.name || 'Unknown Supplier'; if (!groupedBySupplier[supplierName]) { groupedBySupplier[supplierName] = []; } groupedBySupplier[supplierName].push(item); }); await new Promise((resolve) => setTimeout(resolve, 50)); // Stage 4: Creating workbook (60-80%) setExcelProgress(70); const workbook = XLSX.utils.book_new(); const supplierEntries = Object.entries(groupedBySupplier); const totalSuppliers = supplierEntries.length; for (let i = 0; i < supplierEntries.length; i++) { const [supplierName, supplierData] = supplierEntries[i]; // Update progress per supplier const progressIncrement = (20 / totalSuppliers) * (i + 1); setExcelProgress(70 + progressIncrement); const totals = supplierData.reduce( (acc, item) => ({ qty_pengajuan: acc.qty_pengajuan + (item.pengajuan?.qty || 0), total_pengajuan: acc.total_pengajuan + (item.pengajuan?.qty || 0) * (item.pengajuan?.price || 0), qty_realisasi: acc.qty_realisasi + (item.realisasi?.qty || 0), total_realisasi: acc.total_realisasi + (item.realisasi?.qty || 0) * (item.realisasi?.price || 0), }), { qty_pengajuan: 0, total_pengajuan: 0, qty_realisasi: 0, total_realisasi: 0, } ); const excelData = supplierData.map((item, index) => ({ No: index + 1, 'No. PO': item.po_number || '', 'No. Referensi': item.reference_number || '', 'Tanggal Realisasi': item.realization_date ? formatDate(item.realization_date, 'DD MMM YYYY') : '', 'Tanggal Transaksi': item.transaction_date ? formatDate(item.transaction_date, 'DD MMM YYYY') : '', Kategori: item.category || '', Produk: item.pengajuan?.nonstock?.name || '', Lokasi: item.kandang?.location?.name || '', Kandang: item.kandang?.name || '', 'Qty Pengajuan': item.pengajuan?.qty || 0, 'Harga Pengajuan': item.pengajuan?.price || 0, 'Total Pengajuan': (item.pengajuan?.qty || 0) * (item.pengajuan?.price || 0), 'Qty Realisasi': item.realisasi?.qty || 0, 'Harga Realisasi': item.realisasi?.price || 0, 'Total Realisasi': (item.realisasi?.qty || 0) * (item.realisasi?.price || 0), 'Status Pencairan': item.latest_approval?.step_name || '', })); excelData.push({ No: 'Total' as unknown as number, 'No. PO': '', 'No. Referensi': '', 'Tanggal Realisasi': '', 'Tanggal Transaksi': '', Kategori: '', Produk: '', Lokasi: '', Kandang: '', 'Qty Pengajuan': totals.qty_pengajuan, 'Harga Pengajuan': 0, 'Total Pengajuan': totals.total_pengajuan, 'Qty Realisasi': totals.qty_realisasi, 'Harga Realisasi': 0, 'Total Realisasi': totals.total_realisasi, 'Status Pencairan': '', }); const worksheet = XLSX.utils.json_to_sheet(excelData); const colWidths = [ { wch: 5 }, // No { wch: 20 }, // No. PO { wch: 20 }, // No. Referensi { wch: 15 }, // Tanggal Realisasi { wch: 15 }, // Tanggal Transaksi { wch: 15 }, // Kategori { wch: 30 }, // Produk { wch: 20 }, // Lokasi { wch: 15 }, // Kandang { wch: 15 }, // Qty Pengajuan { wch: 15 }, // Harga Pengajuan { wch: 20 }, // Total Pengajuan { wch: 15 }, // Qty Realisasi { wch: 15 }, // Harga Realisasi { wch: 20 }, // Total Realisasi { wch: 20 }, // Status Pencairan ]; worksheet['!cols'] = colWidths; const sheetName = supplierName.slice(0, 31); XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); // Small delay to allow UI update if (i < supplierEntries.length - 1) { await new Promise((resolve) => setTimeout(resolve, 10)); } } // Stage 5: Writing file (90-100%) setExcelProgress(95); await new Promise((resolve) => setTimeout(resolve, 50)); const filename = `Laporan-BOP-${formatDate(new Date(), 'YYYY-MM-DD-HHmm')}.xlsx`; XLSX.writeFile(workbook, filename); setExcelProgress(100); toast.success('Excel berhasil dibuat dan diunduh.'); // Reset progress setTimeout(() => setExcelProgress(0), 500); } catch (error) { console.error('Excel Export Error:', error); toast.error('Gagal membuat Excel. Silakan coba lagi.'); setExcelProgress(0); } finally { setIsExcelExportLoading(false); } }, [isExcelExportLoading, reportExpenseExport]); // ===== PAGINATION HANDLERS ===== const handlePageChange = (page: number) => { setPage(page); }; const handleRowChange = (pageSize: number) => { setPageSize(pageSize); }; const handleNextPage = () => { if (meta && filterState.page < meta.total_pages) { setPage(filterState.page + 1); } }; const handlePrevPage = () => { if (filterState.page > 1) { setPage(filterState.page - 1); } }; // ===== TABLE COLUMNS DEFINITION ===== const columns = useMemo((): ColumnDef[] => { return [ { header: 'No', accessorFn: (_, index) => (filterState.page - 1) * filterState.pageSize + index + 1, }, { header: 'No. PO', accessorKey: 'po_number', }, { header: 'No. Referensi', accessorKey: 'reference_number', }, { header: 'Tanggal Realisasi', accessorKey: 'realization_date', cell: ({ row }) => { return formatDate(row.original?.realization_date, 'DD MMM, YYYY'); }, }, { header: 'Tanggal Transaksi', accessorKey: 'transaction_date', cell: ({ row }) => { return formatDate(row.original?.transaction_date, 'DD MMM, YYYY'); }, }, { header: 'Kategori', accessorKey: 'category', }, { header: 'Produk', accessorFn: (row) => row.pengajuan?.nonstock?.name, }, { header: 'Supplier', accessorFn: (row) => row.supplier?.name, }, { header: 'Lokasi', accessorFn: (row) => row.kandang?.location?.name, }, { header: 'Kandang', accessorFn: (row) => row.kandang?.name, }, { header: 'Pengajuan', columns: [ { header: 'Qty', id: 'qty_pengajuan', accessorFn: (row) => row.pengajuan?.qty, cell: ({ row }) => row.original.pengajuan?.qty?.toLocaleString('id-ID') || '0', }, { header: 'Harga', id: 'harga_pengajuan', accessorFn: (row) => row.pengajuan?.price, cell: ({ row }) => formatCurrency(row.original.pengajuan?.price || 0), }, { header: 'Total', id: 'total_pengajuan', accessorFn: (row) => (row.pengajuan?.qty || 0) * (row.pengajuan?.price || 0), cell: ({ row }) => { const total = (row.original.pengajuan?.qty || 0) * (row.original.pengajuan?.price || 0); return formatCurrency(total); }, }, ], }, { header: 'Realisasi', columns: [ { header: 'Qty', id: 'qty_realisasi', accessorFn: (row) => row.realisasi?.qty, cell: ({ row }) => row.original.realisasi?.qty?.toLocaleString('id-ID') || '0', }, { header: 'Harga', id: 'harga_realisasi', accessorFn: (row) => row.realisasi?.price, cell: ({ row }) => formatCurrency(row.original.realisasi?.price || 0), }, { header: 'Total', id: 'total_realisasi', accessorFn: (row) => (row.realisasi?.qty || 0) * (row.realisasi?.price || 0), cell: ({ row }) => { const total = (row.original.realisasi?.qty || 0) * (row.original.realisasi?.price || 0); return formatCurrency(total); }, }, ], }, { header: 'Status Pencairan', cell: (props) => ( ), }, { header: 'Status BOP', cell: (props) => ( ), }, ]; }, [filterState.page, filterState.pageSize]); // ===== RENDER ===== return (
{isAnyExportLoading && (
{((isPdfExportLoading && pdfProgress > 0) || (isExcelExportLoading && excelProgress > 0)) && (
{(() => { const currentProgress = isPdfExportLoading ? pdfProgress : excelProgress; const exportType = isPdfExportLoading ? 'PDF' : 'Excel'; if (currentProgress < 20) return 'Mengambil data dari server...'; if (currentProgress < 30) return 'Memproses data laporan...'; if (currentProgress < 40) return `Menyiapkan struktur dokumen ${exportType}...`; if (currentProgress < 50) return 'Mengelompokkan data per supplier...'; if (currentProgress < 70) return 'Merender tabel dan kalkulasi...'; if (currentProgress < 96) return `Memformat dokumen ${exportType}...`; if (currentProgress < 100) return 'Menyelesaikan dan mengunduh...'; return 'Selesai!'; })()}{' '} {Math.round(isPdfExportLoading ? pdfProgress : excelProgress)}%
{((isPdfExportLoading && pdfProgress >= 35 && pdfProgress < 90) || (isExcelExportLoading && excelProgress >= 35 && excelProgress < 90)) && (
{(isPdfExportLoading ? pdfProgress : excelProgress) < 96 ? 'Proses ini membutuhkan waktu lebih lama untuk data dalam jumlah besar. Mohon bersabar...' : 'Sedang memproses baris data. Hampir selesai...'}
)}
)}
)}
{ setDropdownOpen(!dropdownOpen); }} > Export } align='end' direction='bottom' open={dropdownOpen} >
} >
} />
{/* ===== TABLE CONTENT ===== */} {!isSubmitted ? (
Silakan pilih filter dan klik tombol Cari untuk menampilkan data.
) : isLoading ? (
) : data.length === 0 ? (
Tidak ada data yang dapat ditampilkan...
) : ( <> columns={columns} data={data} pageSize={10} className={{ containerClassName: 'mb-0', headerRowClassName: cn( TABLE_DEFAULT_STYLING, 'whitespace-nowrap' ), bodyRowClassName: cn(TABLE_DEFAULT_STYLING, 'whitespace-nowrap'), paginationClassName: 'hidden', }} /> {meta && (
)} )} ); }; export default ReportExpenseTable;