feat(FE-208): add PurchaseOrderInvoice component for PDF generation and update PurchaseOrderDetail to integrate invoice display

This commit is contained in:
rstubryan
2025-11-15 10:36:01 +07:00
parent 57a867f611
commit c45c8601cb
2 changed files with 490 additions and 32 deletions
@@ -14,6 +14,7 @@ import Modal from '@/components/Modal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import PurchaseOrderStaffApprovalForm from '@/components/pages/purchase/form/order/PurchaseOrderStaffApprovalForm';
import PurchaseOrderAcceptApprovalForm from '@/components/pages/purchase/form/order/PurchaseOrderAcceptApprovalForm';
import PurchaseOrderInvoice from '@/components/pages/purchase/order/PurchaseOrderInvoice';
import { BaseGroupedApproval } from '@/types/api/api-general';
import { PURCHASE_ORDER_APPROVAL_LINE } from '@/config/approval-line';
@@ -49,17 +50,17 @@ const dummyPurchaseData: Purchase = {
supplier: {
id: 1,
name: 'PT. CHAROEN POKPHAND JAYA FARM',
alias: '',
pic: '',
type: '',
category: '',
hatchery: '',
phone: '',
email: '',
address: '-',
npwp: '',
account_number: '',
due_date: 0,
alias: 'CP JAYA FARM',
pic: 'Budi Santoso',
type: 'Supplier',
category: 'Feed',
hatchery: 'Jawa Barat',
phone: '+62-22-7563850',
email: 'info@cp.co.id',
address: 'Jl. Raya Bandung - Sumedang Km. 28, Desa Cisantana, Kec. Cigendel, Kabupaten Sumedang, Jawa Barat 45363',
npwp: '01.938.451.6-433.000',
account_number: '123-456-7890',
due_date: 30,
balance: 0,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
@@ -682,27 +683,7 @@ const PurchaseOrderDetail = ({
<div className='ml-3'>
{purchaseData.po_number &&
purchaseData.po_number !== 'Belum dibuat' ? (
<Button
color='primary'
className='w-fit min-w-32 flex items-center justify-start gap-1 px-2 py-1 text-sm font-mono'
disabled={!purchaseData.po_document_path}
href={purchaseData.po_document_path ?? undefined}
target='_blank'
rel='noopener noreferrer'
>
{purchaseData.po_document_path ? (
<>
<Icon
icon='material-symbols:file-open-outline'
width={16}
height={16}
/>
{purchaseData.po_number}
</>
) : (
purchaseData.po_number
)}
</Button>
<PurchaseOrderInvoice data={purchaseData} />
) : (
<Button
color='primary'
@@ -0,0 +1,477 @@
'use client';
import { useMemo, useState } from 'react';
import {
Page,
Text,
View,
Document,
Image,
StyleSheet,
Font,
pdf,
} from '@react-pdf/renderer';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import { Purchase } from '@/types/api/purchase/purchase';
import { formatDate, formatNumber } from '@/lib/helper';
Font.register({
family: 'Helvetica',
src: 'helvetica',
});
const pdfStyles = StyleSheet.create({
page: {
fontSize: 10,
fontFamily: 'Helvetica',
padding: 20,
backgroundColor: '#FFFFFF',
},
header: {
marginBottom: 20,
},
logo: {
width: 120,
height: 30,
marginBottom: 8,
},
companyInfo: {
fontSize: 12,
fontWeight: 'bold',
marginBottom: 4,
},
address: {
fontSize: 8,
color: '#666666',
maxWidth: 300,
marginBottom: 10,
},
divider: {
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
marginBottom: 15,
},
titleSection: {
flexDirection: 'row',
marginBottom: 20,
justifyContent: 'space-between',
alignItems: 'flex-start',
},
title: {
fontSize: 18,
fontWeight: 'bold',
flex: 3,
},
poInfo: {
flex: 1,
fontSize: 9,
textAlign: 'right',
},
sectionTitle: {
fontSize: 12,
fontWeight: 'bold',
marginBottom: 8,
},
table: {
borderWidth: 1,
borderColor: '#000000',
marginBottom: 15,
},
tableRow: {
flexDirection: 'row',
},
tableHeader: {
backgroundColor: '#F5F5F5',
},
tableCell: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 8,
fontSize: 9,
},
tableCellLast: {
flex: 1,
padding: 8,
fontSize: 9,
},
tableCellHeader: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 8,
fontSize: 9,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
},
tableCellHeaderLast: {
flex: 1,
padding: 8,
fontSize: 9,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
},
tableCellRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 8,
fontSize: 9,
textAlign: 'right',
},
tableCellRightLast: {
flex: 1,
padding: 8,
fontSize: 9,
textAlign: 'right',
},
tableBorderBottom: {
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
},
grandTotalRow: {
flexDirection: 'row',
borderTopWidth: 1,
borderTopColor: '#000000',
borderTopStyle: 'solid',
},
grandTotalLabel: {
flex: 3,
padding: 8,
fontSize: 9,
fontWeight: 'bold',
textAlign: 'right',
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
},
grandTotalValue: {
flex: 1,
padding: 8,
fontSize: 9,
fontWeight: 'bold',
textAlign: 'right',
borderRightWidth: 0,
},
allocationSection: {
marginBottom: 15,
},
allocationTable: {
borderWidth: 1,
borderColor: '#000000',
},
innerTable: {
marginTop: 5,
},
innerRow: {
flexDirection: 'row',
borderBottomWidth: 0.5,
borderBottomColor: '#ccc',
borderBottomStyle: 'solid',
},
innerCell: {
flex: 1,
padding: 4,
fontSize: 8,
},
footer: {
position: 'absolute',
bottom: 20,
left: 20,
right: 20,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
},
footerCompany: {
fontSize: 12,
fontWeight: 'bold',
textAlign: 'right',
flex: 1,
},
specialInstructionTable: {
width: '60%',
maxWidth: 300,
borderWidth: 1,
borderColor: '#000000',
flex: 1,
},
});
interface PurchaseOrderInvoiceProps {
data?: Purchase;
className?: string;
}
const PurchaseOrderInvoice = ({ data }: PurchaseOrderInvoiceProps) => {
const [, setIsGeneratingPDF] = useState(false);
const purchaseData = data;
const grandTotal = useMemo(() => {
return (
purchaseData?.items?.reduce(
(sum, item) => sum + (item.total_price || 0),
0
) || 0
);
}, [purchaseData?.items]);
const handleDownloadPDF = async () => {
if (!purchaseData) {
alert('No purchase order data available');
return;
}
setIsGeneratingPDF(true);
try {
const PDFDocument = () => (
<Document>
<Page size='A4' style={pdfStyles.page}>
{/* Header Section */}
<View style={pdfStyles.header}>
<Image
src={'https://placehold.co/120x30/png'}
style={pdfStyles.logo}
id={'mbu-logo'}
/>
<Text style={pdfStyles.companyInfo}>PT MITRA BERLIAN UNGGAS</Text>
<Text style={pdfStyles.address}>
SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel.
Cipedes, Kec. Sukajadi, Kota Bandung 40162
</Text>
<View style={pdfStyles.divider} />
</View>
{/* Purchase Order Title */}
<View style={pdfStyles.titleSection}>
<Text style={pdfStyles.title}>PURCHASE ORDER</Text>
<View style={pdfStyles.poInfo}>
<Text>PO Number: {purchaseData?.po_number || '-'}</Text>
<Text>
Date:{' '}
{purchaseData?.po_date
? formatDate(purchaseData.po_date, 'DD-MMM-YYYY')
: formatDate(new Date(), 'DD-MMM-YYYY')}
</Text>
</View>
</View>
{/* Vendor and Ship To Table */}
<View style={pdfStyles.table}>
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={pdfStyles.tableCellHeader}>
<Text>Vendor</Text>
</View>
<View style={pdfStyles.tableCellHeaderLast}>
<Text>Ship To</Text>
</View>
</View>
<View style={pdfStyles.tableRow}>
<View style={pdfStyles.tableCell}>
<Text style={{ fontWeight: 'bold' }}>
{purchaseData?.supplier?.name || '-'}
</Text>
<Text>{purchaseData?.supplier?.pic || '-'}</Text>
<Text>
{purchaseData?.supplier?.phone || '-'} /{' '}
{purchaseData?.supplier?.email || '-'}
</Text>
<Text>{purchaseData?.supplier?.address || '-'}</Text>
</View>
<View style={pdfStyles.tableCellLast}>
<Text style={{ fontWeight: 'bold' }}>
PT MITRA BERLIAN UNGGAS
</Text>
<Text>{purchaseData?.location?.name || '-'}</Text>
</View>
</View>
</View>
{/* Item Description Table */}
<View>
<View style={pdfStyles.table}>
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={pdfStyles.tableCellHeader}>
<Text>Item Description</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Unit Price</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Total Quantity</Text>
</View>
<View style={pdfStyles.tableCellHeaderLast}>
<Text>Total Amount</Text>
</View>
</View>
{purchaseData?.items?.map((item, index) => {
const isLastItem =
index === (purchaseData?.items?.length || 0) - 1;
return (
<View
key={index}
style={[
pdfStyles.tableRow,
isLastItem ? {} : pdfStyles.tableBorderBottom,
]}
>
<View style={pdfStyles.tableCell}>
<Text>{item.product?.name || '-'}</Text>
</View>
<View style={pdfStyles.tableCellRight}>
<Text>Rp.{formatNumber(item.price || 0)}</Text>
</View>
<View style={pdfStyles.tableCellRight}>
<Text>{formatNumber(item.total_qty || 0)}</Text>
</View>
<View style={pdfStyles.tableCellRightLast}>
<Text>Rp.{formatNumber(item.total_price || 0)}</Text>
</View>
</View>
);
}) || []}
{/* Grand Total Row inside table */}
<View style={pdfStyles.grandTotalRow}>
<View style={[pdfStyles.tableCell, { borderRightWidth: 0 }]}>
<Text></Text>
</View>
<View
style={[pdfStyles.tableCellRight, { borderRightWidth: 0 }]}
>
<Text></Text>
</View>
<View style={pdfStyles.tableCellRight}>
<Text style={{ fontWeight: 'bold' }}>Grand Total</Text>
</View>
<View style={[pdfStyles.tableCellRightLast, { fontWeight: 'bold' }]}>
<Text>Rp.{formatNumber(grandTotal)}</Text>
</View>
</View>
</View>
</View>
{/* Product Allocation Section */}
<View style={pdfStyles.allocationSection}>
<Text style={pdfStyles.sectionTitle}>Product Allocation</Text>
<View style={pdfStyles.allocationTable}>
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={pdfStyles.tableCellHeader}>
<Text>Warehouse Name</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>PIC</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Address Detail</Text>
</View>
<View style={pdfStyles.tableCellHeaderLast}>
<Text>Product Allocation</Text>
</View>
</View>
<View style={pdfStyles.tableRow}>
<View style={pdfStyles.tableCell}>
<Text>{purchaseData?.warehouse?.name || '-'}</Text>
</View>
<View style={pdfStyles.tableCell}>
<Text>{purchaseData?.created_user?.name || '-'}</Text>
<Text style={{ fontSize: 8 }}>
{purchaseData?.created_user?.email || '-'}
</Text>
</View>
<View style={pdfStyles.tableCell}>
<Text>{purchaseData?.location?.name || '-'}</Text>
</View>
<View style={pdfStyles.tableCellLast}>
{/* Inner table for product allocation */}
<View style={pdfStyles.innerTable}>
{purchaseData?.items?.map((item, index) => (
<View key={index} style={pdfStyles.innerRow}>
<Text style={pdfStyles.innerCell}>
{item.product?.name || '-'}
</Text>
<Text
style={[
pdfStyles.innerCell,
{ textAlign: 'right' },
]}
>
{formatNumber(item.total_qty || 0)}
</Text>
</View>
)) || []}
</View>
</View>
</View>
</View>
</View>
{/* Footer with Special Instructions */}
<View style={pdfStyles.footer}>
<View style={pdfStyles.specialInstructionTable}>
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={pdfStyles.tableCellHeaderLast}>
<Text>Special Instruction</Text>
</View>
</View>
<View style={pdfStyles.tableRow}>
<View style={pdfStyles.tableCellLast}>
<Text>{purchaseData?.notes || '-'}</Text>
</View>
</View>
</View>
<View style={pdfStyles.footerCompany}>
<Text>PT MITRA BERLIAN UNGGAS</Text>
</View>
</View>
</Page>
</Document>
);
const blob = await pdf(<PDFDocument />).toBlob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${purchaseData?.po_number || 'purchase-order'}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Error generating PDF:', error);
alert('Failed to generate PDF. Please try again.');
} finally {
setIsGeneratingPDF(false);
}
};
if (!purchaseData) {
return (
<div className='flex items-center justify-center min-h-screen'>
<div className='text-gray-500'>No purchase order data available</div>
</div>
);
}
return purchaseData?.po_number &&
purchaseData.po_number !== 'Belum dibuat' ? (
<Button
color='primary'
className='w-fit min-w-32 flex items-center justify-start gap-1 px-2 py-1 text-sm font-mono'
onClick={handleDownloadPDF}
>
<Icon icon='material-symbols:file-open-outline' width={16} height={16} />
{purchaseData.po_number}
</Button>
) : null;
};
export default PurchaseOrderInvoice;