Merge branch 'feat/expense-export' into 'development'

[FEAT/FE] Expense Export

See merge request mbugroup/lti-web-client!426
This commit is contained in:
Rivaldi A N S
2026-04-23 03:06:07 +00:00
8 changed files with 184 additions and 203 deletions
+29 -39
View File
@@ -41,7 +41,7 @@ import Dropdown from '@/components/dropdown/Dropdown';
import { Expense } from '@/types/api/expense'; import { Expense } from '@/types/api/expense';
import { ExpenseApi } from '@/services/api/expense'; import { ExpenseApi } from '@/services/api/expense';
import { cn, formatCurrency, formatDate } from '@/lib/helper'; import { cn, formatCurrency, formatDate } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper'; import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { BaseApiResponse } from '@/types/api/api-general'; import { BaseApiResponse } from '@/types/api/api-general';
@@ -84,43 +84,6 @@ type ApprovalStatusValue =
const isApprovalDateRequired = (status?: ApprovalStatusValue) => const isApprovalDateRequired = (status?: ApprovalStatusValue) =>
status === 'REALISASI' || status === 'SELESAI'; 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 = ({ const RowOptionsMenu = ({
popoverPosition = 'bottom', popoverPosition = 'bottom',
props, props,
@@ -314,6 +277,8 @@ const ExpensesTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false); const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isRejectLoading, setIsRejectLoading] = useState(false); const [isRejectLoading, setIsRejectLoading] = useState(false);
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false);
const [isExportProgressLoading, setIsExportProgressLoading] = useState(false); const [isExportProgressLoading, setIsExportProgressLoading] = useState(false);
const [, setApprovalNotes] = useState(''); const [, setApprovalNotes] = useState('');
const [bulkApprovalStatus, setBulkApprovalStatus] = const [bulkApprovalStatus, setBulkApprovalStatus] =
@@ -603,7 +568,7 @@ const ExpensesTable = () => {
toast.success('Ekspor berhasil'); toast.success('Ekspor berhasil');
} catch (error) { } catch (error) {
toast.error( toast.error(
await getExportErrorMessage(error, 'Gagal mengekspor input progress') await getErrorMessage(error, 'Gagal mengekspor input progress')
); );
} finally { } finally {
setIsExportProgressLoading(false); setIsExportProgressLoading(false);
@@ -818,6 +783,20 @@ const ExpensesTable = () => {
resetFilter(); resetFilter();
}; };
const exportToExcel = useCallback(async () => {
setIsLoadingExportingToExcel(true);
try {
await ExpenseApi.exportToExcel(getTableFilterQueryString());
} catch (error) {
toast.error(
await getErrorMessage(error, 'Gagal mengekspor data pengeluaran')
);
} finally {
setIsLoadingExportingToExcel(false);
}
}, [getTableFilterQueryString]);
// track sorting // track sorting
useEffect(() => { useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name'); const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
@@ -1031,6 +1010,17 @@ const ExpensesTable = () => {
</Button> </Button>
} }
> >
<Button
variant='ghost'
color='none'
onClick={exportToExcel}
isLoading={isLoadingExportingToExcel}
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 ke Excel
</Button>
<Button <Button
variant='ghost' variant='ghost'
color='none' color='none'
@@ -9,7 +9,11 @@ import Modal, { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import {
getErrorMessage,
isResponseError,
isResponseSuccess,
} from '@/lib/api-helper';
import { cn, formatCurrency, formatDate, formatTitleCase } from '@/lib/helper'; import { cn, formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
import { import {
MarketingApi, MarketingApi,
@@ -37,43 +41,6 @@ import MarketingFilterModal from '@/components/pages/marketing/MarketingFilter';
import ButtonFilter from '@/components/helper/ButtonFilter'; import ButtonFilter from '@/components/helper/ButtonFilter';
import MarketingTableSkeleton from '@/components/pages/marketing/skeleton/MarketingTableSkeleton'; 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 = ({ const RowsOptionsMenu = ({
props, props,
deleteClickHandler, deleteClickHandler,
@@ -537,7 +504,7 @@ const MarketingTable = () => {
toast.success('Ekspor berhasil'); toast.success('Ekspor berhasil');
} catch (error) { } catch (error) {
toast.error( toast.error(
await getExportErrorMessage(error, 'Gagal mengekspor input progress') await getErrorMessage(error, 'Gagal mengekspor input progress')
); );
} finally { } finally {
setIsExportProgressLoading(false); setIsExportProgressLoading(false);
@@ -41,7 +41,7 @@ import Table from '@/components/Table';
import { type Recording } from '@/types/api/production/recording'; import { type Recording } from '@/types/api/production/recording';
import { getRecordingRestriction } from './recording-utils'; import { getRecordingRestriction } from './recording-utils';
import { RecordingApi } from '@/services/api/production'; import { RecordingApi } from '@/services/api/production';
import { isResponseSuccess } from '@/lib/api-helper'; import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import StatusBadge from '@/components/helper/StatusBadge'; import StatusBadge from '@/components/helper/StatusBadge';
@@ -52,43 +52,6 @@ import { Color } from '@/types/theme';
import ButtonFilter from '@/components/helper/ButtonFilter'; import ButtonFilter from '@/components/helper/ButtonFilter';
import Dropdown from '@/components/Dropdown'; 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 ===== // ===== STATUS BADGE UTILITIES =====
const statusTextMap: Record<string, string> = { const statusTextMap: Record<string, string> = {
APPROVED: 'Disetujui', APPROVED: 'Disetujui',
@@ -839,7 +802,7 @@ const RecordingTable = () => {
toast.success('Ekspor berhasil'); toast.success('Ekspor berhasil');
} catch (error) { } catch (error) {
toast.error( toast.error(
await getExportErrorMessage(error, 'Gagal mengekspor input progress') await getErrorMessage(error, 'Gagal mengekspor input progress')
); );
} finally { } finally {
setIsExportProgressLoading(false); setIsExportProgressLoading(false);
@@ -33,7 +33,7 @@ import PurchaseFilterModal from '@/components/pages/purchase/PurchaseFilterModal
import Dropdown from '@/components/dropdown/Dropdown'; import Dropdown from '@/components/dropdown/Dropdown';
import { cn, formatDate } from '@/lib/helper'; import { cn, formatDate } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper'; import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper';
import { BaseApiResponse } from '@/types/api/api-general'; import { BaseApiResponse } from '@/types/api/api-general';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
@@ -43,43 +43,6 @@ import { ExpenseApi } from '@/services/api/expense';
import { Expense } from '@/types/api/expense'; import { Expense } from '@/types/api/expense';
import { Color } from '@/types/theme'; 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 ===== // ===== STATUS BADGE UTILITIES =====
const statusTextMap: Record<string, string> = { const statusTextMap: Record<string, string> = {
APPROVED: 'Disetujui', APPROVED: 'Disetujui',
@@ -518,7 +481,7 @@ const PurchaseTable = () => {
await PurchaseApi.exportToExcel(getTableFilterQueryString()); await PurchaseApi.exportToExcel(getTableFilterQueryString());
} catch (error) { } catch (error) {
toast.error( toast.error(
await getExportErrorMessage(error, 'Gagal mengekspor data pembelian') await getErrorMessage(error, 'Gagal mengekspor data pembelian')
); );
} finally { } finally {
setIsLoadingExportingToExcel(false); setIsLoadingExportingToExcel(false);
@@ -563,7 +526,7 @@ const PurchaseTable = () => {
toast.success('Ekspor berhasil'); toast.success('Ekspor berhasil');
} catch (error) { } catch (error) {
toast.error( toast.error(
await getExportErrorMessage(error, 'Gagal mengekspor input progress') await getErrorMessage(error, 'Gagal mengekspor input progress')
); );
} finally { } finally {
setIsExportProgressLoading(false); setIsExportProgressLoading(false);
@@ -24,7 +24,7 @@ import Table from '@/components/Table';
import { formatCurrency, formatDate } from '@/lib/helper'; import { formatCurrency, formatDate } from '@/lib/helper';
import { ReportExpense } from '@/types/api/report/report-expense'; import { ReportExpense } from '@/types/api/report/report-expense';
import { ReportExpenseApi } from '@/services/api/report/expense-report'; import { ReportExpenseApi } from '@/services/api/report/expense-report';
import { isResponseSuccess } from '@/lib/api-helper'; import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store'; import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import Modal, { useModal } from '@/components/Modal'; import Modal, { useModal } from '@/components/Modal';
import Pagination from '@/components/Pagination'; import Pagination from '@/components/Pagination';
@@ -189,26 +189,47 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
[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);
}
Object.entries(extraParams ?? {}).forEach(([key, value]) => {
params.set(key, value);
});
return params.toString();
},
[filterParams]
);
// ===== DATA FETCHING ===== // ===== DATA FETCHING =====
const { data: reportExpenseResponse, isLoading } = useSWR( const { data: reportExpenseResponse, isLoading } = useSWR(
() => { () => {
const params = new URLSearchParams(); const queryString = buildReportExpenseQueryString({
if (filterParams.location_id) page: String(page),
params.append('location_id', filterParams.location_id); limit: String(pageSize),
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);
params.append('page', String(page));
params.append('limit', String(pageSize));
return [`${ReportExpenseApi.basePath}?${params.toString()}`]; return [`${ReportExpenseApi.basePath}?${queryString}`];
}, },
([url]: string[]) => httpClient<BaseApiResponse<ReportExpense[]>>(url) ([url]: string[]) => httpClient<BaseApiResponse<ReportExpense[]>>(url)
); );
@@ -233,47 +254,31 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
const reportExpenseExport = useCallback(async (): Promise< const reportExpenseExport = useCallback(async (): Promise<
ReportExpense[] | null ReportExpense[] | null
> => { > => {
const params = new URLSearchParams(); const queryString = buildReportExpenseQueryString({
if (filterParams.location_id) page: '1',
params.append('location_id', filterParams.location_id); limit: '100',
if (filterParams.supplier_id) });
params.append('supplier_id', filterParams.supplier_id);
if (filterParams.kandang_id)
params.append('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);
params.append('limit', '100');
params.append('page', '1');
const response = await httpClient<BaseApiResponse<ReportExpense[]>>( const response = await httpClient<BaseApiResponse<ReportExpense[]>>(
`${ReportExpenseApi.basePath}?${params.toString()}` `${ReportExpenseApi.basePath}?${queryString}`
); );
return isResponseSuccess(response) ? response.data : null; return isResponseSuccess(response) ? response.data : null;
}, [filterParams]); }, [buildReportExpenseQueryString]);
// ===== EXPORT HANDLERS ===== // ===== EXPORT HANDLERS =====
const handleExportExcel = useCallback(async () => { const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true); setIsExcelExportLoading(true);
try { try {
const allDataForExport = await reportExpenseExport(); await ReportExpenseApi.exportToExcel(buildReportExpenseQueryString());
} catch (error) {
if (!allDataForExport || allDataForExport.length === 0) { toast.error(
toast.error('Tidak ada data untuk diekspor.'); await getErrorMessage(error, 'Gagal mengekspor data pengeluaran')
return; );
}
await generateReportExpenseExcel(allDataForExport);
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally { } finally {
setIsExcelExportLoading(false); setIsExcelExportLoading(false);
} }
}, [reportExpenseExport]); }, [buildReportExpenseQueryString]);
const handleExportPDF = useCallback(async () => { const handleExportPDF = useCallback(async () => {
setIsPdfExportLoading(true); setIsPdfExportLoading(true);
+38
View File
@@ -1,3 +1,4 @@
import axios from 'axios';
import { import {
BaseApiResponse, BaseApiResponse,
ErrorApiResponse, ErrorApiResponse,
@@ -15,3 +16,40 @@ export const isResponseError = <T>(
): res is ErrorApiResponse => { ): res is ErrorApiResponse => {
return res?.status === 'error'; return res?.status === 'error';
}; };
export const getErrorMessage = 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;
};
+27
View File
@@ -708,6 +708,33 @@ export class ExpenseApiService extends BaseApiService<
return formData; return formData;
}; };
async exportToExcel(initialQueryString: string) {
const params = new URLSearchParams(initialQueryString);
params.set('export', 'excel');
params.set('type', 'all');
params.set('page', '1');
params.set('limit', '99999999999');
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 = `BOP-${formatDate(Date.now(), 'DD-MM-YYYY')}.xlsx`;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
}
async exportInputProgressToExcel(startDate: string, endDate: string) { async exportInputProgressToExcel(startDate: string, endDate: string) {
const params = new URLSearchParams(); const params = new URLSearchParams();
+29 -1
View File
@@ -1,5 +1,6 @@
import { formatDate } from '@/lib/helper';
import { BaseApiService } from '@/services/api/base'; import { BaseApiService } from '@/services/api/base';
import { httpClientFetcher } from '@/services/http/client'; import { httpClient, httpClientFetcher } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general'; import { BaseApiResponse } from '@/types/api/api-general';
import { import {
ReportDepreciation, ReportDepreciation,
@@ -20,6 +21,33 @@ export class ReportExpenseApiService extends BaseApiService<
): Promise<BaseApiResponse<ReportExpense[]>> { ): Promise<BaseApiResponse<ReportExpense[]>> {
return await httpClientFetcher<BaseApiResponse<ReportExpense[]>>(endpoint); return await httpClientFetcher<BaseApiResponse<ReportExpense[]>>(endpoint);
} }
async exportToExcel(initialQueryString: string) {
const params = new URLSearchParams(initialQueryString);
params.set('export', 'excel');
params.set('type', 'all');
params.set('page', '1');
params.set('limit', '99999999999');
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 = `Laporan-BOP-${formatDate(Date.now(), 'DD-MM-YYYY')}.xlsx`;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
}
} }
export const ReportExpenseApi = new ReportExpenseApiService('/reports/expense'); export const ReportExpenseApi = new ReportExpenseApiService('/reports/expense');