Merge branch 'feat/FE/US-339/purchase-report' into 'development'

[FEAT/FE][US#339] Slicing and Integrate API Purchase Report Page

See merge request mbugroup/lti-web-client!109
This commit is contained in:
Adnan Zahir
2025-12-23 10:57:46 +07:00
9 changed files with 1500 additions and 2 deletions
+22 -1
View File
@@ -17,7 +17,7 @@
"jspdf": "^3.0.4",
"jspdf-autotable": "^5.0.2",
"moment": "^2.30.1",
"next": "^15.5.9",
"next": "15.5.9",
"react": "19.1.0",
"react-day-picker": "^9.11.1",
"react-dom": "19.1.0",
@@ -2960,6 +2960,26 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -5224,6 +5244,7 @@
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.4.tgz",
"integrity": "sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"fast-png": "^6.2.0",
+1 -1
View File
@@ -20,7 +20,7 @@
"jspdf": "^3.0.4",
"jspdf-autotable": "^5.0.2",
"moment": "^2.30.1",
"next": "^15.5.9",
"next": "15.5.9",
"react": "19.1.0",
"react-day-picker": "^9.11.1",
"react-dom": "19.1.0",
+11
View File
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
+7
View File
@@ -0,0 +1,7 @@
import LogisticStockTabs from '@/components/pages/report/logistic-stock/LogisticStockTabs';
const LogisticStock = () => {
return <LogisticStockTabs />;
};
export default LogisticStock;
@@ -0,0 +1,32 @@
'use client';
import Tabs from '@/components/Tabs';
import PurchasesPerSupplierTab from '@/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab';
const LogisticStockTabs = () => {
const tabs = [
{
id: '1',
label: 'Rekapitulasi Pembelian Per Supplier',
content: <PurchasesPerSupplierTab />,
},
// {
// id: '2',
// label: 'Rekapitulasi Pemakaian Barang',
// content: 'Rekapitulasi Pemakaian Barang Tab',
// },
// {
// id: '3',
// label: 'Rekapitulasi Stock Persediaan Barang',
// content: 'Rekapitulasi Stock Persediaan Barang Tab',
// },
];
return (
<section className='w-full p-4'>
<Tabs tabs={tabs} variant='lifted' />
</section>
);
};
export default LogisticStockTabs;
@@ -0,0 +1,404 @@
'use client';
import {
Page,
Text,
View,
Document,
StyleSheet,
Font,
pdf,
} from '@react-pdf/renderer';
import { LogisticPurchasePerSupplierReport } from '@/types/api/report/logistic-stock';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
Font.register({
family: 'Helvetica',
src: 'helvetica',
});
const pdfStyles = StyleSheet.create({
page: {
fontSize: 10,
fontFamily: 'Helvetica',
padding: 20,
backgroundColor: '#FFFFFF',
},
titleSection: {
marginBottom: 10,
},
mainTitle: {
fontSize: 14,
fontWeight: 'bold',
marginBottom: 5,
color: '#1f74bf',
},
supplierTitle: {
fontSize: 12,
fontWeight: 'bold',
marginBottom: 8,
color: '#1f74bf',
},
table: {
borderWidth: 1,
borderColor: '#000000',
marginBottom: 15,
},
tableRow: {
flexDirection: 'row',
},
tableHeader: {
backgroundColor: '#F5F5F5',
},
tableCell: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
textAlign: 'left',
},
tableCellNo: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
textAlign: 'center',
},
tableCellLast: {
flex: 1,
padding: 4,
fontSize: 8,
},
tableCellHeader: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
paddingVertical: 12,
textAlign: 'center',
},
tableCellHeaderRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
textAlign: 'right',
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
paddingVertical: 12,
},
tableCellHeaderLast: {
flex: 1,
padding: 4,
fontSize: 8,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
paddingVertical: 12,
textAlign: 'center',
},
tableCellRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
textAlign: 'right',
},
tableCellCenter: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
textAlign: 'center',
},
tableCellCenterLast: {
flex: 1,
padding: 4,
fontSize: 8,
textAlign: 'center',
},
tableBorderBottom: {
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
},
supplierSection: {
marginBottom: 10,
},
supplierSectionBreak: {
marginBottom: 15,
},
badge: {
backgroundColor: '#1f74bf',
color: '#FFFFFF',
padding: 2,
borderRadius: 2,
fontSize: 7,
fontWeight: 'bold',
alignSelf: 'center',
marginRight: 4,
},
parameterBadge: {
backgroundColor: '#F5F5F5',
color: '#333333',
padding: 4,
borderRadius: 4,
fontSize: 8,
marginRight: 8,
marginBottom: 4,
},
parameterContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
marginBottom: 8,
},
});
interface PurchasesPerSupplierExportParams {
data: LogisticPurchasePerSupplierReport[];
params: {
area_name?: string;
supplier_name?: string;
product_name?: string;
product_category_name?: string;
received_date?: string;
po_date?: string;
start_date?: string;
end_date?: string;
sort_by?: string;
filter_by?: string;
};
}
const getParameterText = (
params: PurchasesPerSupplierExportParams['params']
) => {
const paramsText = [];
if (params.supplier_name) {
paramsText.push(`Supplier: ${params.supplier_name}`);
} else {
paramsText.push('Semua Supplier');
}
if (params.start_date && params.end_date) {
const startDate = formatDate(params.start_date, 'DD MMM YYYY');
const endDate = formatDate(params.end_date, 'DD MMM YYYY');
paramsText.push(`Periode: ${startDate} - ${endDate}`);
} else if (params.start_date) {
const startDate = formatDate(params.start_date, 'DD MMM YYYY');
paramsText.push(`Tanggal: ${startDate}`);
}
const currentDate = formatDate(new Date(), 'DD MMM YYYY HH:mm');
paramsText.push(`Dicetak: ${currentDate}`);
return paramsText;
};
const createPDFDocument = (
supplierReports: LogisticPurchasePerSupplierReport[],
params: PurchasesPerSupplierExportParams['params']
) => (
<Document>
<Page size='A3' orientation='landscape' style={pdfStyles.page}>
{/* Title and Parameters */}
<View style={pdfStyles.titleSection}>
<Text style={pdfStyles.mainTitle}>
Laporan &gt; Rekapitulasi Pembelian Per Supplier
</Text>
<View style={pdfStyles.parameterContainer}>
<View style={pdfStyles.parameterBadge}>
<Text>
Jenis Tanggal:{' '}
{params.filter_by === 'received_date'
? 'Tanggal Terima'
: 'Tanggal PO'}
</Text>
</View>
{getParameterText(params).map((param, index) => (
<View key={index} style={pdfStyles.parameterBadge}>
<Text>{param}</Text>
</View>
))}
</View>
</View>
{/* Supplier Sections */}
{supplierReports.map(
(
supplierReport: LogisticPurchasePerSupplierReport,
supplierIndex: number
) => {
return (
<View
key={supplierReport.supplier.id}
style={[
pdfStyles.supplierSection,
supplierIndex < supplierReports.length - 1
? pdfStyles.supplierSectionBreak
: {},
]}
>
<Text style={pdfStyles.supplierTitle}>
{supplierReport.supplier.name}
</Text>
<View style={pdfStyles.table}>
{/* Table Header */}
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={[pdfStyles.tableCellHeader, { flex: 0.5 }]}>
<Text>No</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Tanggal Terima</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Tanggal PO</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Referensi</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Produk</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Tujuan</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>Qty</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Harga Beli</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.5 }]}>
<Text>Nilai Pembelian</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Biaya Transport</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.5 }]}>
<Text>Total</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>Armada</Text>
</View>
<View style={pdfStyles.tableCellHeaderLast}>
<Text>Surat Jalan</Text>
</View>
</View>
{/* Table Body */}
{supplierReport.rows.map(
(
item: LogisticPurchasePerSupplierReport['rows'][number],
index: number
) => (
<View
key={index}
style={[
pdfStyles.tableRow,
index < supplierReport.rows.length - 1
? pdfStyles.tableBorderBottom
: {},
]}
>
<View style={[pdfStyles.tableCellNo, { flex: 0.5 }]}>
<Text>{index + 1}</Text>
</View>
<View style={pdfStyles.tableCell}>
<Text>
{formatDate(item.receive_date, 'DD-MMM-YYYY')}
</Text>
</View>
<View style={pdfStyles.tableCell}>
<Text>{formatDate(item.po_date, 'DD-MMM-YYYY')}</Text>
</View>
<View style={pdfStyles.tableCell}>
<Text>{item.po_number || '-'}</Text>
</View>
<View style={pdfStyles.tableCell}>
<Text>{item.product?.name || '-'}</Text>
</View>
<View style={pdfStyles.tableCell}>
<Text>{item.warehouse?.name || '-'}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(item.qty || 0)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.unit_price || 0)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.5 }]}>
<Text>{formatCurrency(item.purchase_value || 0)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>
{formatCurrency(item.transport_unit_price || 0)}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.5 }]}>
<Text>{formatCurrency(item.total_amount || 0)}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
<View style={pdfStyles.badge}>
<Text>{item.expedition || '-'}</Text>
</View>
</View>
<View style={pdfStyles.tableCellLast}>
<Text>{item.delivery_number || '-'}</Text>
</View>
</View>
)
)}
</View>
</View>
);
}
)}
</Page>
</Document>
);
export const generatePurchasesPerSupplierPDF = async (
data: LogisticPurchasePerSupplierReport[],
params: PurchasesPerSupplierExportParams['params']
): Promise<void> => {
const PDFDocument = createPDFDocument(data, params);
try {
const blob = await pdf(PDFDocument).toBlob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `laporan-pembelian-per-supplier-dicetak-pada-${formatDate(new Date(), 'YYYY-MM-DD-HHmm')}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
throw error;
}
};
@@ -0,0 +1,934 @@
import { useState, useMemo, useCallback } from 'react';
import { ChangeEventHandler } from 'react';
import useSWR from 'swr';
import Card from '@/components/Card';
import SelectInput, {
useSelect,
OptionType,
} from '@/components/input/SelectInput';
import DateInput from '@/components/input/DateInput';
import { AreaApi } from '@/services/api/master-data';
import { SupplierApi } from '@/services/api/master-data';
import { ProductApi } from '@/services/api/master-data';
import { ProductCategoryApi } from '@/services/api/master-data';
import { LogisticApi } from '@/services/api/report/logistic-stock';
import Table from '@/components/Table';
import { ColumnDef } from '@tanstack/react-table';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
import {
LogisticPurchasePerSupplierReport,
LogisticPurchasePerSupplierSummary,
} from '@/types/api/report/logistic-stock';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import Pagination from '@/components/Pagination';
import Button from '@/components/Button';
import Dropdown from '@/components/Dropdown';
import MenuItem from '@/components/menu/MenuItem';
import Menu from '@/components/menu/Menu';
import { generatePurchasesPerSupplierPDF } from '@/components/pages/report/logistic-stock/export/PurchasesPerSupplierExport';
import toast from 'react-hot-toast';
import * as XLSX from 'xlsx';
const PurchasesPerSupplierTab = () => {
// ===== STATE MANAGEMENT =====
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
// ===== PAGINATION STATE =====
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
// ===== SUBMISSION STATE =====
const [isSubmitted, setIsSubmitted] = useState(false);
// ===== TABLE FILTER STATE =====
const { state: tableFilterState, updateFilter } = useTableFilter({
initial: {
area_id: [] as string[],
supplier_id: [] as string[],
product_id: [] as string[],
product_category_id: [] as string[],
received_date: '',
po_date: '',
start_date: '',
end_date: '',
sort_by: '',
filter_by: 'received_date',
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
});
const { options: areaOptions, isLoadingOptions: isLoadingAreas } = useSelect(
AreaApi.basePath,
'id',
'name',
'search'
);
const { options: supplierOptions, isLoadingOptions: isLoadingSuppliers } =
useSelect(SupplierApi.basePath, 'id', 'name', 'search', {
category: 'SAPRONAK',
});
const { options: productOptions, isLoadingOptions: isLoadingProducts } =
useSelect(ProductApi.basePath, 'id', 'name', 'search');
const {
options: productCategoryOptions,
isLoadingOptions: isLoadingProductCategories,
} = useSelect(ProductCategoryApi.basePath, 'id', 'name', 'search');
const dataTypeOptions = useMemo(
() => [
{ value: 'received_date', label: 'Tanggal Terima' },
{ value: 'po_date', label: 'Tanggal PO' },
],
[]
);
const sortByOptions = useMemo(
() => [
{ value: 'ASC', label: 'Ascending' },
{ value: 'DESC', label: 'Descending' },
],
[]
);
const areaChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const arr = Array.isArray(val) ? val : val ? [val] : [];
updateFilter(
'area_id',
arr.map((v) => String((v as OptionType).value))
);
setIsSubmitted(false);
},
[updateFilter]
);
const supplierChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const arr = Array.isArray(val) ? val : val ? [val] : [];
updateFilter(
'supplier_id',
arr.map((v) => String((v as OptionType).value))
);
setIsSubmitted(false);
},
[updateFilter]
);
const productChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const arr = Array.isArray(val) ? val : val ? [val] : [];
updateFilter(
'product_id',
arr.map((v) => String((v as OptionType).value))
);
setIsSubmitted(false);
},
[updateFilter]
);
const productCategoryChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const arr = Array.isArray(val) ? val : val ? [val] : [];
updateFilter(
'product_category_id',
arr.map((v) => String((v as OptionType).value))
);
setIsSubmitted(false);
},
[updateFilter]
);
const dataTypeChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
const filterValue =
(newVal?.value as 'received_date' | 'po_date') || 'received_date';
updateFilter('filter_by', filterValue);
updateFilter('received_date', '');
updateFilter('po_date', '');
setIsSubmitted(false);
},
[updateFilter]
);
const sortByHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
const sortValue = (newVal?.value as 'ASC' | 'DESC') || 'ASC';
updateFilter('sort_by', sortValue);
setIsSubmitted(false);
},
[updateFilter]
);
const startDateChangeHandler = useCallback<
ChangeEventHandler<HTMLInputElement>
>(
(e) => {
const val = e.target.value;
updateFilter('start_date', val || '');
setIsSubmitted(false);
},
[updateFilter]
);
const endDateChangeHandler = useCallback<
ChangeEventHandler<HTMLInputElement>
>(
(e) => {
const val = e.target.value;
updateFilter('end_date', val || '');
setIsSubmitted(false);
},
[updateFilter]
);
const resetFilters = useCallback(() => {
updateFilter('area_id', []);
updateFilter('supplier_id', []);
updateFilter('product_id', []);
updateFilter('product_category_id', []);
updateFilter('received_date', '');
updateFilter('po_date', '');
updateFilter('start_date', '');
updateFilter('end_date', '');
updateFilter('sort_by', '');
updateFilter('filter_by', 'received_date');
setIsSubmitted(false);
}, [updateFilter]);
const handleSubmit = useCallback(() => {
setIsSubmitted(true);
setCurrentPage(1);
}, []);
// ===== DATA FETCHING =====
const { data: purchasePerSupplier, isLoading } = useSWR(
isSubmitted
? () => {
const params = {
area_id:
tableFilterState.area_id.length > 0
? tableFilterState.area_id.join(',')
: undefined,
supplier_id:
tableFilterState.supplier_id.length > 0
? tableFilterState.supplier_id.join(',')
: undefined,
product_id:
tableFilterState.product_id.length > 0
? tableFilterState.product_id.join(',')
: undefined,
product_category_id:
tableFilterState.product_category_id.length > 0
? tableFilterState.product_category_id.join(',')
: undefined,
received_date:
tableFilterState.filter_by === 'received_date'
? tableFilterState.start_date || undefined
: undefined,
po_date:
tableFilterState.filter_by === 'po_date'
? tableFilterState.start_date || undefined
: undefined,
start_date: tableFilterState.start_date || undefined,
end_date: tableFilterState.end_date || undefined,
sort_by: tableFilterState.sort_by || undefined,
filter_by: tableFilterState.filter_by || undefined,
page: currentPage,
limit: pageSize,
};
return ['logistic-purchase-report', params];
}
: null,
([, params]) =>
LogisticApi.getLogisticPurchasePerSupplierReport(
params.area_id,
params.supplier_id,
params.product_id,
params.product_category_id,
params.received_date,
params.po_date,
params.start_date,
params.end_date,
params.sort_by,
params.filter_by,
params.page,
params.limit
)
);
const data: LogisticPurchasePerSupplierReport[] = useMemo(
() =>
isResponseSuccess(purchasePerSupplier)
? (purchasePerSupplier?.data as unknown as LogisticPurchasePerSupplierReport[]) ||
[]
: [],
[purchasePerSupplier]
);
const meta =
isResponseSuccess(purchasePerSupplier) && purchasePerSupplier?.meta
? purchasePerSupplier.meta
: null;
// ===== EXPORT DATA FETCHER =====
const logisticPurchasePerSupplierExport = useCallback(async (): Promise<
LogisticPurchasePerSupplierReport[] | null
> => {
const params = {
area_id:
tableFilterState.area_id.length > 0
? tableFilterState.area_id.join(',')
: undefined,
supplier_id:
tableFilterState.supplier_id.length > 0
? tableFilterState.supplier_id.join(',')
: undefined,
product_id:
tableFilterState.product_id.length > 0
? tableFilterState.product_id.join(',')
: undefined,
product_category_id:
tableFilterState.product_category_id.length > 0
? tableFilterState.product_category_id.join(',')
: undefined,
received_date:
tableFilterState.filter_by === 'received_date'
? tableFilterState.start_date || undefined
: undefined,
po_date:
tableFilterState.filter_by === 'po_date'
? tableFilterState.start_date || undefined
: undefined,
start_date: tableFilterState.start_date || undefined,
end_date: tableFilterState.end_date || undefined,
sort_by: tableFilterState.sort_by || undefined,
filter_by: tableFilterState.filter_by || undefined,
limit: 100,
page: 1,
};
const response = await LogisticApi.getLogisticPurchasePerSupplierReport(
params.area_id,
params.supplier_id,
params.product_id,
params.product_category_id,
params.received_date,
params.po_date,
params.start_date,
params.end_date,
params.sort_by,
params.filter_by,
params.page,
params.limit
);
return isResponseSuccess(response)
? (response.data as unknown as LogisticPurchasePerSupplierReport[])
: null;
}, [tableFilterState]);
// ===== EXPORT HANDLERS =====
const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true);
try {
const allDataForExport = await logisticPurchasePerSupplierExport();
if (
!allDataForExport ||
!Array.isArray(allDataForExport) ||
allDataForExport.length === 0
) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
const workbook = XLSX.utils.book_new();
allDataForExport.forEach((supplierReport) => {
const supplierData = supplierReport.rows;
const supplierName =
supplierReport.supplier?.name || 'Unknown Supplier';
const excelData: { [key: string]: string | number }[] =
supplierData.map((item, index) => ({
No: index + 1,
'Tanggal Terima': item.receive_date
? formatDate(item.receive_date, 'DD MMM YYYY')
: '',
'Tanggal PO': item.po_date
? formatDate(item.po_date, 'DD MMM YYYY')
: '',
'No. Referensi': item.po_number || '',
'Nama Produk': item.product?.name || '',
Tujuan: item.warehouse?.name || '',
QTY: item.qty || 0,
'Harga Beli (Rp)': item.unit_price || 0,
'Value Harga Beli (Rp)': item.purchase_value || 0,
'Transport (Rp)': item.transport_unit_price || 0,
'Value Transport (Rp)': item.transport_value || 0,
'Jumlah (Rp)': item.total_amount || 0,
Ekspedisi: item.expedition || '',
'Surat Jalan': item.delivery_number || '',
}));
if (supplierReport.summary) {
excelData.push({
No: 'Total',
'Tanggal Terima': '',
'Tanggal PO': '',
'No. Referensi': '',
'Nama Produk': '',
Tujuan: '',
QTY: supplierReport.summary.total_qty || 0,
'Harga Beli (Rp)': '',
'Value Harga Beli (Rp)':
supplierReport.summary.total_purchase_value || 0,
'Transport (Rp)': '',
'Value Transport (Rp)':
supplierReport.summary.total_transport_value || 0,
'Jumlah (Rp)': supplierReport.summary.total_amount || 0,
Ekspedisi: '',
'Surat Jalan': '',
});
}
const worksheet = XLSX.utils.json_to_sheet(excelData);
const colWidths = [
{ wch: 5 }, // No
{ wch: 15 }, // Tanggal Terima
{ wch: 15 }, // Tanggal PO
{ wch: 15 }, // No. Referensi
{ wch: 30 }, // Nama Produk
{ wch: 20 }, // Tujuan
{ wch: 10 }, // QTY
{ wch: 18 }, // Harga Beli
{ wch: 20 }, // Value Harga Beli
{ wch: 15 }, // Transport
{ wch: 20 }, // Value Transport
{ wch: 18 }, // Jumlah
{ wch: 15 }, // Ekspedisi
{ wch: 15 }, // Surat Jalan
];
worksheet['!cols'] = colWidths;
const sheetName =
supplierName.length > 31
? supplierName.substring(0, 31)
: supplierName;
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
});
const filename = `laporan-pembelian-per-supplier-dicetak-pada-${formatDate(new Date(), 'YYYY-MM-DD-HHmm')}.xlsx`;
XLSX.writeFile(workbook, filename);
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally {
setIsExcelExportLoading(false);
}
}, [
logisticPurchasePerSupplierExport,
tableFilterState,
areaOptions,
supplierOptions,
]);
const handleExportPdf = useCallback(async () => {
setIsPdfExportLoading(true);
try {
const allDataForExport = await logisticPurchasePerSupplierExport();
if (
!allDataForExport ||
!Array.isArray(allDataForExport) ||
allDataForExport.length === 0
) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
const areaName =
tableFilterState.area_id.length > 0
? tableFilterState.area_id
.map(
(id) =>
areaOptions.find((opt) => opt.value === Number(id))?.label
)
.filter(Boolean)
.join(', ') || 'Semua Area'
: 'Semua Area';
const supplierName =
tableFilterState.supplier_id.length > 0
? tableFilterState.supplier_id
.map(
(id) =>
supplierOptions.find((opt) => opt.value === Number(id))?.label
)
.filter(Boolean)
.join(', ') || 'Semua Supplier'
: 'Semua Supplier';
const productName =
tableFilterState.product_id.length > 0
? tableFilterState.product_id
.map(
(id) =>
productOptions.find((opt) => opt.value === Number(id))?.label
)
.filter(Boolean)
.join(', ') || 'Semua Produk'
: 'Semua Produk';
const productCategoryName =
tableFilterState.product_category_id.length > 0
? tableFilterState.product_category_id
.map(
(id) =>
productCategoryOptions.find((opt) => opt.value === Number(id))
?.label
)
.filter(Boolean)
.join(', ') || 'Semua Kategori Produk'
: 'Semua Kategori Produk';
const exportParams = {
area_name: areaName,
supplier_name: supplierName,
product_name: productName,
product_category_name: productCategoryName,
filter_by: tableFilterState.filter_by || 'received_date',
start_date: tableFilterState.start_date || '',
end_date: tableFilterState.end_date || '',
};
await generatePurchasesPerSupplierPDF(allDataForExport, exportParams);
toast.success('PDF berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat PDF. Silakan coba lagi.');
} finally {
setIsPdfExportLoading(false);
}
}, [
logisticPurchasePerSupplierExport,
tableFilterState,
areaOptions,
supplierOptions,
productOptions,
productCategoryOptions,
]);
// ===== PAGINATION HANDLERS =====
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
const handleRowChange = (pageSize: number) => {
setPageSize(pageSize);
};
const handleNextPage = () => {
if (meta && currentPage < meta.total_pages) {
setCurrentPage(currentPage + 1);
}
};
const handlePrevPage = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
};
const getTableColumns = (
summary: LogisticPurchasePerSupplierSummary
): ColumnDef<LogisticPurchasePerSupplierReport['rows'][0]>[] => {
const tableColumns: ColumnDef<
LogisticPurchasePerSupplierReport['rows'][0]
>[] = [
{
id: 'no',
header: 'No',
cell: (props) => props.row.index + 1,
footer: () => <div className='font-semibold text-gray-900'>Total</div>,
},
{
id: 'received_date',
header: 'Tanggal Terima',
accessorKey: 'receive_date',
cell: (props) => {
const value = props.row.original.receive_date;
return formatDate(value, 'DD MMM YYYY');
},
},
{
id: 'po_date',
header: 'Tanggal PO',
accessorKey: 'po_date',
cell: (props) => {
const value = props.row.original.po_date;
return formatDate(value, 'DD MMM YYYY');
},
},
{
id: 'po_number',
header: 'No. Referensi',
accessorKey: 'po_number',
cell: (props) => {
const value = props.row.original.po_number;
return value || '-';
},
},
{
id: 'product_name',
header: 'Nama Produk',
accessorKey: 'product.name',
cell: (props) => {
const product = props.row.original.product;
return product?.name || '-';
},
},
{
id: 'destination_warehouse',
header: 'Tujuan',
accessorKey: 'warehouse.name',
cell: (props) => {
const warehouse = props.row.original.warehouse;
return warehouse?.name || '-';
},
},
{
id: 'qty',
header: 'QTY',
accessorKey: 'qty',
cell: (props) => {
const value = props.row.original.qty;
return <div className='text-right'>{formatNumber(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatNumber(summary.total_qty) || '-'}
</div>
),
},
{
id: 'price',
header: 'Harga Beli (Rp)',
accessorKey: 'unit_price',
cell: (props) => {
const value = props.row.original.unit_price;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summary.total_unit_price) || '-'}
</div>
),
},
{
id: 'purchase_amount',
header: 'Value Harga Beli (Rp)',
accessorKey: 'purchase_value',
cell: (props) => {
const value = props.row.original.purchase_value;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summary.total_purchase_value) || '-'}
</div>
),
},
{
id: 'transport',
header: 'Transport (Rp)',
accessorKey: 'transport_unit_price',
cell: (props) => {
const value = props.row.original.transport_unit_price;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summary.total_transport_unit_price) || '-'}
</div>
),
},
{
id: 'value_transport',
header: 'Value Transport (Rp)',
accessorKey: 'transport_value',
cell: (props) => {
const value = props.row.original.transport_value;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summary.total_transport_value) || '-'}
</div>
),
},
{
id: 'total',
header: 'Jumlah (Rp)',
accessorKey: 'total_amount',
cell: (props) => {
const value = props.row.original.total_amount;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summary.total_amount) || '-'}
</div>
),
},
{
id: 'expedition_vendor_name',
header: 'Ekspedisi',
accessorKey: 'expedition',
cell: (props) => {
const value = props.row.original.expedition;
return value || '-';
},
},
{
id: 'travel_number',
header: 'Surat Jalan',
accessorKey: 'delivery_number',
cell: (props) => {
const value = props.row.original.delivery_number;
return value || '-';
},
},
];
return tableColumns;
};
return (
<div className='w-full p-0 sm:p-4'>
<Card
subtitle='Laporan > Rekapitulasi Pembelian Per Supplier'
className={{ wrapper: 'w-full', body: 'p-1!' }}
>
<div className='mb-4 flex justify-end gap-2 [&_button]:px-4'>
<Button color='primary' onClick={handleSubmit}>
Cari
</Button>
<Button color='warning' onClick={resetFilters}>
Reset
</Button>
<Dropdown
trigger={
<Button color='success' isLoading={isAnyExportLoading}>
Export
</Button>
}
align='end'
>
<Menu className='w-32'>
<MenuItem title='Excel' onClick={handleExportExcel} />
<MenuItem title='PDF' onClick={handleExportPdf} />
</Menu>
</Dropdown>
</div>
<div className='grid md:grid-cols-3 grid-cols-1 gap-4'>
<SelectInput
label='Area'
placeholder='Pilih Area'
isMulti
options={areaOptions}
value={areaOptions.filter((opt) =>
(tableFilterState.area_id || [])
.map(String)
.includes(String(opt.value))
)}
onChange={areaChangeHandler}
isLoading={isLoadingAreas}
isClearable
/>
<SelectInput
label='Supplier'
placeholder='Pilih Supplier'
isMulti
options={supplierOptions}
value={supplierOptions.filter((opt) =>
(tableFilterState.supplier_id || [])
.map(String)
.includes(String(opt.value))
)}
onChange={supplierChangeHandler}
isLoading={isLoadingSuppliers}
isClearable
/>
<SelectInput
label='Produk'
placeholder='Pilih Produk'
isMulti
options={productOptions}
value={productOptions.filter((opt) =>
(tableFilterState.product_id || [])
.map(String)
.includes(String(opt.value))
)}
onChange={productChangeHandler}
isLoading={isLoadingProducts}
isClearable
/>
</div>
<div className='grid md:grid-cols-3 grid-cols-1 gap-4'>
<SelectInput
label='Kategori Produk'
placeholder='Pilih Kategori Produk'
isMulti
options={productCategoryOptions}
value={productCategoryOptions.filter((opt) =>
(tableFilterState.product_category_id || [])
.map(String)
.includes(String(opt.value))
)}
onChange={productCategoryChangeHandler}
isLoading={isLoadingProductCategories}
isClearable
/>
<div className='md:flex md:flex-row grid grid-cols-1 gap-4'>
<SelectInput
label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan'
options={dataTypeOptions}
value={
dataTypeOptions?.find(
(option) => option.value === tableFilterState.filter_by
) || null
}
onChange={dataTypeChangeHandler}
isLoading={false}
isClearable={false}
/>
<SelectInput
label='Urutkan Berdasarkan'
placeholder='Urutkan Berdasarkan'
options={sortByOptions}
value={
sortByOptions?.find(
(option) => option.value === tableFilterState.sort_by
) || null
}
onChange={sortByHandler}
isLoading={false}
isClearable={false}
/>
</div>
<div className='md:flex md:flex-row grid grid-cols-1 gap-4'>
<DateInput
label='Tanggal Awal'
name='start_date'
placeholder='Pilih Tanggal Awal'
value={tableFilterState.start_date}
onChange={startDateChangeHandler}
/>
<DateInput
label='Tanggal Akhir'
name='end_date'
placeholder='Pilih Tanggal Akhir'
value={tableFilterState.end_date}
onChange={endDateChangeHandler}
/>
</div>
</div>
{!isSubmitted ? (
<div className='mt-6 text-center text-gray-500'>
Silakan pilih filter dan klik tombol Submit untuk menampilkan data.
</div>
) : isLoading ? (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
) : data.length === 0 ? (
<div className='mt-6 text-center text-gray-500'>
Tidak ada data yang dapat ditampilkan...
</div>
) : (
data.map((supplierReport) => {
const summary = supplierReport.summary || {
total_qty: 0,
total_unit_price: 0,
total_purchase_value: 0,
total_transport_unit_price: 0,
total_transport_value: 0,
total_amount: 0,
};
const totalPurchase = summary.total_amount;
const tableColumns = getTableColumns(summary);
return (
<Card
key={supplierReport.supplier.id}
title={supplierReport.supplier.name}
subtitle={`Total Pembelian: ${formatCurrency(totalPurchase)}`}
className={{ wrapper: 'w-full' }}
variant='bordered'
collapsible={true}
>
<Table
data={supplierReport.rows}
columns={tableColumns}
pageSize={10}
renderFooter={supplierReport.rows.length > 0}
className={{
containerClassName: 'w-full',
tableWrapperClassName: 'overflow-x-auto mt-4',
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',
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',
tableFooterClassName:
'bg-gray-100 font-semibold border border-gray-200',
footerRowClassName: 'border-t-2 border-gray-300',
footerColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
paginationClassName: 'hidden',
}}
/>
</Card>
);
})
)}
</Card>
{meta && data.length > 0 && (
<div className='mt-6'>
<Pagination
currentPage={meta.page}
totalItems={meta.total_results}
onPageChange={handlePageChange}
onRowChange={handleRowChange}
onNextPage={handleNextPage}
onPrevPage={handlePrevPage}
rowOptions={[10, 25, 50, 100]}
itemsPerPage={meta.limit}
/>
</div>
)}
</div>
);
};
export default PurchasesPerSupplierTab;
+54
View File
@@ -0,0 +1,54 @@
import { BaseApiService } from '@/services/api/base';
import { BaseApiResponse } from '@/types/api/api-general';
import { LogisticPurchasePerSupplierReport } from '@/types/api/report/logistic-stock';
export class LogisticApiService extends BaseApiService<
LogisticPurchasePerSupplierReport,
unknown,
unknown
> {
constructor(basePath: string) {
super(basePath);
}
async getLogisticPurchasePerSupplierReport(
area_id?: string,
supplier_id?: string,
product_id?: string,
product_category_id?: string,
received_date?: string,
po_date?: string,
start_date?: string,
end_date?: string,
sort_by?: string,
filter_by?: string,
page?: number,
limit?: number
): Promise<BaseApiResponse<LogisticPurchasePerSupplierReport> | undefined> {
return await this.customRequest<
BaseApiResponse<LogisticPurchasePerSupplierReport>
>(`purchase-supplier`, {
method: 'GET',
params: {
area_id: area_id,
supplier_id: supplier_id,
product_id: product_id,
product_category_id: product_category_id,
received_date: received_date,
po_date: po_date,
start_date: start_date,
end_date: end_date,
sort_by: sort_by,
filter_by: filter_by,
page: page,
limit: limit,
},
});
}
}
export const LogisticApi = new LogisticApiService('reports');
// export const LogisticApi = new LogisticApiService(
// 'http://localhost:4010/api/reports/logistics'
// );
+35
View File
@@ -0,0 +1,35 @@
import { BaseMetadata } from '@/types/api/api-general';
import { Supplier } from '@/types/api/supplier/supplier';
import { Product } from '@/types/api/product/product';
import { Warehouse } from '@/types/api/warehouse/warehouse';
export type LogisticPurchasePerSupplierReportRow = {
receive_date: string;
po_date: string;
po_number: string;
product: Product;
warehouse: Warehouse;
qty: number;
unit_price: number;
purchase_value: number;
transport_unit_price: number;
transport_value: number;
total_amount: number;
expedition: string;
delivery_number: string;
};
export type LogisticPurchasePerSupplierSummary = {
total_qty: number;
total_unit_price: number;
total_purchase_value: number;
total_transport_unit_price: number;
total_transport_value: number;
total_amount: number;
};
export type LogisticPurchasePerSupplierReport = BaseMetadata & {
supplier: Supplier;
rows: LogisticPurchasePerSupplierReportRow[];
summary: LogisticPurchasePerSupplierSummary;
};