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 { ExpenseApi } from '@/services/api/expense';
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 { BaseApiResponse } from '@/types/api/api-general';
@@ -84,43 +84,6 @@ type ApprovalStatusValue =
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,
@@ -314,6 +277,8 @@ const ExpensesTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isRejectLoading, setIsRejectLoading] = useState(false);
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false);
const [isExportProgressLoading, setIsExportProgressLoading] = useState(false);
const [, setApprovalNotes] = useState('');
const [bulkApprovalStatus, setBulkApprovalStatus] =
@@ -603,7 +568,7 @@ const ExpensesTable = () => {
toast.success('Ekspor berhasil');
} catch (error) {
toast.error(
await getExportErrorMessage(error, 'Gagal mengekspor input progress')
await getErrorMessage(error, 'Gagal mengekspor input progress')
);
} finally {
setIsExportProgressLoading(false);
@@ -818,6 +783,20 @@ const ExpensesTable = () => {
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
useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
@@ -1031,6 +1010,17 @@ const ExpensesTable = () => {
</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
variant='ghost'
color='none'
@@ -9,7 +9,11 @@ import Modal, { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
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 {
MarketingApi,
@@ -37,43 +41,6 @@ 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,
@@ -537,7 +504,7 @@ const MarketingTable = () => {
toast.success('Ekspor berhasil');
} catch (error) {
toast.error(
await getExportErrorMessage(error, 'Gagal mengekspor input progress')
await getErrorMessage(error, 'Gagal mengekspor input progress')
);
} finally {
setIsExportProgressLoading(false);
@@ -41,7 +41,7 @@ import Table from '@/components/Table';
import { type Recording } from '@/types/api/production/recording';
import { getRecordingRestriction } from './recording-utils';
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 toast from 'react-hot-toast';
import StatusBadge from '@/components/helper/StatusBadge';
@@ -52,43 +52,6 @@ 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',
@@ -839,7 +802,7 @@ const RecordingTable = () => {
toast.success('Ekspor berhasil');
} catch (error) {
toast.error(
await getExportErrorMessage(error, 'Gagal mengekspor input progress')
await getErrorMessage(error, 'Gagal mengekspor input progress')
);
} finally {
setIsExportProgressLoading(false);
@@ -33,7 +33,7 @@ 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';
import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper';
import { BaseApiResponse } from '@/types/api/api-general';
import { useTableFilter } from '@/services/hooks/useTableFilter';
@@ -43,43 +43,6 @@ 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',
@@ -518,7 +481,7 @@ const PurchaseTable = () => {
await PurchaseApi.exportToExcel(getTableFilterQueryString());
} catch (error) {
toast.error(
await getExportErrorMessage(error, 'Gagal mengekspor data pembelian')
await getErrorMessage(error, 'Gagal mengekspor data pembelian')
);
} finally {
setIsLoadingExportingToExcel(false);
@@ -563,7 +526,7 @@ const PurchaseTable = () => {
toast.success('Ekspor berhasil');
} catch (error) {
toast.error(
await getExportErrorMessage(error, 'Gagal mengekspor input progress')
await getErrorMessage(error, 'Gagal mengekspor input progress')
);
} finally {
setIsExportProgressLoading(false);
@@ -24,7 +24,7 @@ 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 { isResponseSuccess } from '@/lib/api-helper';
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';
@@ -189,26 +189,47 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
[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 =====
const { data: reportExpenseResponse, isLoading } = useSWR(
() => {
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);
params.append('page', String(page));
params.append('limit', String(pageSize));
const queryString = buildReportExpenseQueryString({
page: String(page),
limit: String(pageSize),
});
return [`${ReportExpenseApi.basePath}?${params.toString()}`];
return [`${ReportExpenseApi.basePath}?${queryString}`];
},
([url]: string[]) => httpClient<BaseApiResponse<ReportExpense[]>>(url)
);
@@ -233,47 +254,31 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
const reportExpenseExport = useCallback(async (): Promise<
ReportExpense[] | null
> => {
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('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 queryString = buildReportExpenseQueryString({
page: '1',
limit: '100',
});
const response = await httpClient<BaseApiResponse<ReportExpense[]>>(
`${ReportExpenseApi.basePath}?${params.toString()}`
`${ReportExpenseApi.basePath}?${queryString}`
);
return isResponseSuccess(response) ? response.data : null;
}, [filterParams]);
}, [buildReportExpenseQueryString]);
// ===== EXPORT HANDLERS =====
const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true);
try {
const allDataForExport = await reportExpenseExport();
if (!allDataForExport || allDataForExport.length === 0) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
await generateReportExpenseExcel(allDataForExport);
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.');
await ReportExpenseApi.exportToExcel(buildReportExpenseQueryString());
} catch (error) {
toast.error(
await getErrorMessage(error, 'Gagal mengekspor data pengeluaran')
);
} finally {
setIsExcelExportLoading(false);
}
}, [reportExpenseExport]);
}, [buildReportExpenseQueryString]);
const handleExportPDF = useCallback(async () => {
setIsPdfExportLoading(true);
+38
View File
@@ -1,3 +1,4 @@
import axios from 'axios';
import {
BaseApiResponse,
ErrorApiResponse,
@@ -15,3 +16,40 @@ export const isResponseError = <T>(
): res is ErrorApiResponse => {
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;
};
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) {
const params = new URLSearchParams();
+29 -1
View File
@@ -1,5 +1,6 @@
import { formatDate } from '@/lib/helper';
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 {
ReportDepreciation,
@@ -20,6 +21,33 @@ export class ReportExpenseApiService extends BaseApiService<
): Promise<BaseApiResponse<ReportExpense[]>> {
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');