refactor(FE): Refactor ProductionResultReportPDF to use reusable PDF

components
This commit is contained in:
rstubryan
2026-02-10 16:52:52 +07:00
parent 5593463eab
commit 1227b7639f
@@ -1,18 +1,19 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { import { Document, Page, StyleSheet, View, Text } from '@react-pdf/renderer';
Document,
Page,
StyleSheet,
Text,
View,
Image,
} from '@react-pdf/renderer';
import { formatDate, formatNumber } from '@/lib/helper'; import { formatDate, formatNumber } from '@/lib/helper';
import { BaseProjectFlockKandang } from '@/types/api/production/project-flock-kandang'; import { BaseProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { ProductionResult } from '@/types/api/report/production-result'; import { ProductionResult } from '@/types/api/report/production-result';
import { PdfTypography } from '@/components/helper/pdf/typography/PdfTypography';
import { PdfParamBadge } from '@/components/helper/pdf/badge/PdfParamBadge';
import { PdfPageNumber } from '@/components/helper/pdf/layout/PdfPageNumber';
import {
PdfTable,
PdfColumn,
PdfTbodyCell,
} from '@/components/helper/pdf/table';
type MappedProductionResultsItem = { type MappedProductionResultsItem = {
projectFlockKandang: BaseProjectFlockKandang; projectFlockKandang: BaseProjectFlockKandang;
@@ -25,132 +26,28 @@ interface ProductionResultReportPDFProps {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { page: {
paddingTop: 24,
paddingBottom: 52,
paddingHorizontal: 16,
},
companyInfoHeader: {
width: '100%',
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 8,
},
companyLogo: {
width: 64,
height: 'auto',
},
companyInfoHeaderDate: {
paddingTop: 8,
fontSize: 10, fontSize: 10,
fontFamily: 'Helvetica',
padding: 20,
backgroundColor: '#FFFFFF',
}, },
companyName: { titleSection: {
fontSize: 12,
fontWeight: 'bold',
marginBottom: 4,
},
companyAddress: {
fontSize: 8,
maxWidth: 420,
marginBottom: 10, marginBottom: 10,
}, },
doubleDivider: { parameterContainer: {
width: '100%',
height: 6,
borderTopWidth: 2,
borderTopColor: '#000',
borderBottomWidth: 2,
borderBottomColor: '#000',
},
title: {
marginTop: 14,
fontSize: 14,
lineHeight: '150%',
textAlign: 'center',
fontFamily: 'Times-Roman',
fontWeight: 'bold',
},
footer: {
width: '100%',
display: 'flex',
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', flexWrap: 'wrap',
alignItems: 'center', marginBottom: 8,
paddingHorizontal: 16,
position: 'absolute',
fontSize: 8,
bottom: 22,
left: 0,
right: 0,
textAlign: 'center',
color: 'grey',
}, },
tableSection: {
section: { marginBottom: 12,
marginTop: 12,
borderWidth: 1,
borderColor: '#000',
padding: 8,
}, },
tableTitle: {
sectionHeader: {
marginBottom: 6,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'baseline',
},
sectionTitle: {
fontSize: 10, fontSize: 10,
fontWeight: 'bold', fontWeight: 'bold',
marginBottom: 6,
color: '#333',
}, },
sectionSubtitle: {
fontSize: 8,
color: '#444',
},
// Simple grid table (label/value pairs)
grid: {
width: '100%',
borderWidth: 1,
borderColor: '#000',
},
gridRow: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: '#000',
},
gridRowLast: {
borderBottomWidth: 0,
},
gridCellLabel: {
width: '40%',
paddingVertical: 3,
paddingHorizontal: 6,
fontSize: 8,
borderRightWidth: 1,
borderRightColor: '#000',
fontWeight: 'bold',
},
gridCellValue: {
width: '60%',
paddingVertical: 3,
paddingHorizontal: 6,
fontSize: 8,
textAlign: 'right',
},
// Subsection headings
groupTitle: {
marginTop: 8,
marginBottom: 4,
fontSize: 9,
fontWeight: 'bold',
},
emptyText: { emptyText: {
fontSize: 8, fontSize: 8,
color: '#666', color: '#666',
@@ -169,125 +66,243 @@ function valueText(v: unknown) {
return String(v); return String(v);
} }
/** // ========================================
* Render label/value table for one ProductionResult. // TABLE 1: WOA & BW
* Uses a compact grid to keep page readable. // ========================================
*/ const getBwTableColumns = (): PdfColumn[] => [
function ProductionResultGrid({ pr }: { pr: ProductionResult }) { { key: 'no', header: 'No', flex: 0.5, align: 'center' },
const rows: Array<[string, string]> = [ { key: 'woa', header: 'WOA', flex: 0.8, align: 'center' },
['WOA', valueText(pr.woa)], { key: 'bw', header: 'BW', flex: 1, align: 'right' },
{ key: 'std_bw', header: 'Std BW', flex: 1, align: 'right' },
{ key: 'uniformity', header: 'Uniformity', flex: 1.2, align: 'right' },
{
key: 'std_uniformity',
header: 'Std Uniformity',
flex: 1.3,
align: 'right',
},
];
// BW const getBwTableData = (
['BW', valueText(pr.bw)], productionResults: ProductionResult[]
['Std BW', valueText(pr.std_bw)], ): PdfTbodyCell[][] => {
['Uniformity', valueText(pr.uniformity)], return productionResults.map((pr, index) => {
['Std Uniformity', valueText(pr.std_uniformity)], return [
{ key: 'no', value: index + 1 },
{ key: 'woa', value: valueText(pr.woa) },
{ key: 'bw', value: valueText(pr.bw), align: 'right' },
{ key: 'std_bw', value: valueText(pr.std_bw), align: 'right' },
{ key: 'uniformity', value: valueText(pr.uniformity), align: 'right' },
{
key: 'std_uniformity',
value: valueText(pr.std_uniformity),
align: 'right',
},
];
});
};
// Dep // ========================================
['Dep Kum', valueText(pr.dep_kum)], // TABLE 2: DEPLESI
['Dep Std', valueText(pr.dep_std)], // ========================================
const getDepTableColumns = (): PdfColumn[] => [
{ key: 'no', header: 'No', flex: 0.5, align: 'center' },
{ key: 'dep_kum', header: 'Dep Kum', flex: 1.5, align: 'right' },
{ key: 'dep_std', header: 'Dep Std', flex: 1.5, align: 'right' },
];
// Butiran const getDepTableData = (
['Butiran Utuh', valueText(pr.butiran_utuh)], productionResults: ProductionResult[]
['Butiran Putih', valueText(pr.butiran_putih)], ): PdfTbodyCell[][] => {
['Butiran Retak', valueText(pr.butiran_retak)], return productionResults.map((pr, index) => {
['Butiran Pecah', valueText(pr.butiran_pecah)], return [
['Butiran Jumlah', valueText(pr.butiran_jumlah)], { key: 'no', value: index + 1 },
['Total Butir', valueText(pr.total_butir)], { key: 'dep_kum', value: valueText(pr.dep_kum), align: 'right' },
{ key: 'dep_std', value: valueText(pr.dep_std), align: 'right' },
];
});
};
// Kg // ========================================
['Kg Utuh', valueText(pr.kg_utuh)], // TABLE 3: BUTIRAN
['Kg Putih', valueText(pr.kg_putih)], // ========================================
['Kg Retak', valueText(pr.kg_retak)], const getButiranTableColumns = (): PdfColumn[] => [
['Kg Pecah', valueText(pr.kg_pecah)], { key: 'no', header: 'No', flex: 0.5, align: 'center' },
['Kg Jumlah', valueText(pr.kg_jumlah)], { key: 'butiran_utuh', header: 'Utuh', flex: 1.2, align: 'right' },
['Total Kg', valueText(pr.total_kg)], { key: 'butiran_putih', header: 'Putih', flex: 1.2, align: 'right' },
{ key: 'butiran_retak', header: 'Retak', flex: 1.2, align: 'right' },
{ key: 'butiran_pecah', header: 'Pecah', flex: 1.2, align: 'right' },
{ key: 'butiran_jumlah', header: 'Jumlah', flex: 1.2, align: 'right' },
{ key: 'total_butir', header: 'Total Butir', flex: 1.3, align: 'right' },
];
// % const getButiranTableData = (
['% Utuh', valueText(pr.persen_utuh)], productionResults: ProductionResult[]
['% Putih', valueText(pr.persen_putih)], ): PdfTbodyCell[][] => {
['% Retak', valueText(pr.persen_retak)], return productionResults.map((pr, index) => {
['% Pecah', valueText(pr.persen_pecah)], return [
{ key: 'no', value: index + 1 },
{
key: 'butiran_utuh',
value: valueText(pr.butiran_utuh),
align: 'right',
},
{
key: 'butiran_putih',
value: valueText(pr.butiran_putih),
align: 'right',
},
{
key: 'butiran_retak',
value: valueText(pr.butiran_retak),
align: 'right',
},
{
key: 'butiran_pecah',
value: valueText(pr.butiran_pecah),
align: 'right',
},
{
key: 'butiran_jumlah',
value: valueText(pr.butiran_jumlah),
align: 'right',
},
{
key: 'total_butir',
value: valueText(pr.total_butir),
align: 'right',
},
];
});
};
// Produksi // ========================================
['HD', valueText(pr.hd)], // TABLE 4: BERAT (KG)
['HD Std', valueText(pr.hd_std)], // ========================================
['FI', valueText(pr.fi)], const getKgTableColumns = (): PdfColumn[] => [
['FI Std', valueText(pr.fi_std)], { key: 'no', header: 'No', flex: 0.5, align: 'center' },
['EM', valueText(pr.em)], { key: 'kg_utuh', header: 'Utuh (Kg)', flex: 1.2, align: 'right' },
['EM Std', valueText(pr.em_std)], { key: 'kg_putih', header: 'Putih (Kg)', flex: 1.2, align: 'right' },
['EW', valueText(pr.ew)], { key: 'kg_retak', header: 'Retak (Kg)', flex: 1.2, align: 'right' },
['EW Std', valueText(pr.ew_std)], { key: 'kg_pecah', header: 'Pecah (Kg)', flex: 1.2, align: 'right' },
['FCR', valueText(pr.fcr)], { key: 'kg_jumlah', header: 'Jumlah (Kg)', flex: 1.3, align: 'right' },
['FCR Std', valueText(pr.fcr_std)], { key: 'total_kg', header: 'Total (Kg)', flex: 1.3, align: 'right' },
['HH', valueText(pr.hh)], ];
['HH Std', valueText(pr.hh_std)],
];
return ( const getKgTableData = (
<View style={styles.grid}> productionResults: ProductionResult[]
{rows.map(([label, value], idx) => { ): PdfTbodyCell[][] => {
const isLast = idx === rows.length - 1; return productionResults.map((pr, index) => {
return ( return [
<View { key: 'no', value: index + 1 },
key={label} { key: 'kg_utuh', value: valueText(pr.kg_utuh), align: 'right' },
style={[styles.gridRow, ...(isLast ? [styles.gridRowLast] : [])]} { key: 'kg_putih', value: valueText(pr.kg_putih), align: 'right' },
> { key: 'kg_retak', value: valueText(pr.kg_retak), align: 'right' },
<Text style={styles.gridCellLabel}>{label}</Text> { key: 'kg_pecah', value: valueText(pr.kg_pecah), align: 'right' },
<Text style={styles.gridCellValue}>{value}</Text> { key: 'kg_jumlah', value: valueText(pr.kg_jumlah), align: 'right' },
</View> { key: 'total_kg', value: valueText(pr.total_kg), align: 'right' },
); ];
})} });
</View> };
);
}
/** // ========================================
* If there are multiple ProductionResult entries for a kandang, // TABLE 5: PERSENTASE
* we show them sequentially with a small header per result. // ========================================
* const getPersenTableColumns = (): PdfColumn[] => [
* You can later change this to render only the latest WOA, or group by week. { key: 'no', header: 'No', flex: 0.5, align: 'center' },
*/ { key: 'persen_utuh', header: '% Utuh', flex: 1.5, align: 'right' },
function ProductionResultList({ { key: 'persen_putih', header: '% Putih', flex: 1.5, align: 'right' },
productionResults, { key: 'persen_retak', header: '% Retak', flex: 1.5, align: 'right' },
}: { { key: 'persen_pecah', header: '% Pecah', flex: 1.5, align: 'right' },
productionResults: ProductionResult[]; ];
}) {
return (
<View>
{productionResults.map((pr, idx) => {
const kandangName =
pr.project_flock?.kandang?.name ||
pr.project_flock?.kandang?.id?.toString() ||
'';
// Optional: show a compact subheader const getPersenTableData = (
const headerLeft = `Data #${idx + 1}`; productionResults: ProductionResult[]
const headerRight = ): PdfTbodyCell[][] => {
kandangName && pr.woa !== undefined return productionResults.map((pr, index) => {
? `${kandangName} • WOA ${safeNum(pr.woa)}` return [
: pr.woa !== undefined { key: 'no', value: index + 1 },
? `WOA ${safeNum(pr.woa)}` {
: ''; key: 'persen_utuh',
value: valueText(pr.persen_utuh),
align: 'right',
},
{
key: 'persen_putih',
value: valueText(pr.persen_putih),
align: 'right',
},
{
key: 'persen_retak',
value: valueText(pr.persen_retak),
align: 'right',
},
{
key: 'persen_pecah',
value: valueText(pr.persen_pecah),
align: 'right',
},
];
});
};
return ( // ========================================
<View // TABLE 6: PRODUKSI (HD, FI, EM, EW)
key={`${pr.project_flock?.id ?? 'pf'}-${idx}`} // ========================================
style={{ marginTop: idx === 0 ? 0 : 10 }} const getProduksi1TableColumns = (): PdfColumn[] => [
wrap={false} { key: 'no', header: 'No', flex: 0.5, align: 'center' },
> { key: 'hd', header: 'HD', flex: 0.8, align: 'right' },
<View style={styles.sectionHeader}> { key: 'hd_std', header: 'HD Std', flex: 1, align: 'right' },
<Text style={styles.sectionTitle}>{headerLeft}</Text> { key: 'fi', header: 'FI', flex: 0.8, align: 'right' },
<Text style={styles.sectionSubtitle}>{headerRight}</Text> { key: 'fi_std', header: 'FI Std', flex: 1, align: 'right' },
</View> { key: 'em', header: 'EM', flex: 0.8, align: 'right' },
{ key: 'em_std', header: 'EM Std', flex: 1, align: 'right' },
{ key: 'ew', header: 'EW', flex: 0.8, align: 'right' },
{ key: 'ew_std', header: 'EW Std', flex: 1, align: 'right' },
];
<ProductionResultGrid pr={pr} /> const getProduksi1TableData = (
</View> productionResults: ProductionResult[]
); ): PdfTbodyCell[][] => {
})} return productionResults.map((pr, index) => {
</View> return [
); { key: 'no', value: index + 1 },
} { key: 'hd', value: valueText(pr.hd), align: 'right' },
{ key: 'hd_std', value: valueText(pr.hd_std), align: 'right' },
{ key: 'fi', value: valueText(pr.fi), align: 'right' },
{ key: 'fi_std', value: valueText(pr.fi_std), align: 'right' },
{ key: 'em', value: valueText(pr.em), align: 'right' },
{ key: 'em_std', value: valueText(pr.em_std), align: 'right' },
{ key: 'ew', value: valueText(pr.ew), align: 'right' },
{ key: 'ew_std', value: valueText(pr.ew_std), align: 'right' },
];
});
};
// ========================================
// TABLE 7: PRODUKSI (FCR, HH)
// ========================================
const getProduksi2TableColumns = (): PdfColumn[] => [
{ key: 'no', header: 'No', flex: 0.5, align: 'center' },
{ key: 'fcr', header: 'FCR', flex: 1, align: 'right' },
{ key: 'fcr_std', header: 'FCR Std', flex: 1.2, align: 'right' },
{ key: 'hh', header: 'HH', flex: 1, align: 'right' },
{ key: 'hh_std', header: 'HH Std', flex: 1.2, align: 'right' },
];
const getProduksi2TableData = (
productionResults: ProductionResult[]
): PdfTbodyCell[][] => {
return productionResults.map((pr, index) => {
return [
{ key: 'no', value: index + 1 },
{ key: 'fcr', value: valueText(pr.fcr), align: 'right' },
{ key: 'fcr_std', value: valueText(pr.fcr_std), align: 'right' },
{ key: 'hh', value: valueText(pr.hh), align: 'right' },
{ key: 'hh_std', value: valueText(pr.hh_std), align: 'right' },
];
});
};
/** /**
* ✅ Main PDF Component * ✅ Main PDF Component
@@ -297,90 +312,148 @@ const ProductionResultReportPDF = ({
}: ProductionResultReportPDFProps) => { }: ProductionResultReportPDFProps) => {
return ( return (
<Document> <Document>
<Page style={styles.page} size='A4'> {mappedProductionResults.length === 0 ? (
{/* Header */} <Page style={styles.page} size='A4'>
<View> {/* Title and Parameters */}
<View style={styles.companyInfoHeader}> <View style={styles.titleSection}>
<Image style={styles.companyLogo} src='/assets/img/lti-logo.png' /> <PdfTypography size='h1' variant='primary'>
<Text style={styles.companyInfoHeaderDate}> Laporan &gt; Production Result
{formatDate(Date.now(), 'DD MMMM YYYY')} </PdfTypography>
</Text> <View style={styles.parameterContainer}>
<PdfParamBadge>
Tanggal: {formatDate(Date.now(), 'DD MMMM YYYY')}
</PdfParamBadge>
<PdfParamBadge>
Dicetak: {formatDate(new Date(), 'DD MMM YYYY HH:mm')}
</PdfParamBadge>
</View>
</View> </View>
<View>
<Text style={styles.companyName}>PT LUMBUNG TELUR INDONESIA</Text>
<Text style={styles.companyAddress}>
SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel.
Cipedes, Kec. Sukajadi, Kota Bandung 40162
</Text>
<View style={styles.doubleDivider} />
</View>
</View>
<Text style={styles.title}>Laporan Production Result</Text>
{/* Sections per ProjectFlockKandang */}
{mappedProductionResults.length === 0 ? (
<View style={{ marginTop: 16 }}> <View style={{ marginTop: 16 }}>
<Text style={styles.emptyText}>Tidak ada data.</Text> <Text style={styles.emptyText}>Tidak ada data.</Text>
</View> </View>
) : (
mappedProductionResults.map((item, idx) => {
const pfk = item.projectFlockKandang;
// Try to display meaningful identifiers. <PdfPageNumber />
// Adjust these fields based on your real BaseProjectFlockKandang structure. </Page>
const kandangName = ) : (
pfk?.kandang?.name ?? `Kandang #${pfk?.kandang_id ?? idx + 1}`; mappedProductionResults.map((item, idx) => {
const pfk = item.projectFlockKandang;
const projectName = pfk?.project_flock?.name ?? ''; const kandangName =
pfk?.kandang?.name ?? `Kandang #${pfk?.kandang_id ?? idx + 1}`;
const locationName = pfk?.project_flock?.location?.name ?? ''; const projectName = pfk?.project_flock?.name ?? '';
const areaName = pfk?.project_flock?.area?.name ?? ''; const locationName = pfk?.project_flock?.location?.name ?? '';
return ( const areaName = pfk?.project_flock?.area?.name ?? '';
<View
key={`pfk-${pfk?.id ?? idx}`} const hasData =
style={styles.section} item.productionResult && item.productionResult.length > 0;
break={idx > 0} // each kandang starts on a new page for clarity
> return (
<View style={styles.sectionHeader}> <Page key={`pfk-${pfk?.id ?? idx}`} style={styles.page} size='A4'>
<Text style={styles.sectionTitle}> {/* Title and Parameters */}
{projectName <View style={styles.titleSection}>
? `${projectName}${kandangName}` <PdfTypography size='h1' variant='primary'>
: kandangName} Laporan &gt; Production Result
</Text> </PdfTypography>
<Text style={styles.sectionSubtitle}> <View style={styles.parameterContainer}>
{[areaName, locationName].filter(Boolean).join(' • ')} <PdfParamBadge>
</Text> Tanggal: {formatDate(Date.now(), 'DD MMMM YYYY')}
</PdfParamBadge>
<PdfParamBadge>
Dicetak: {formatDate(new Date(), 'DD MMM YYYY HH:mm')}
</PdfParamBadge>
</View> </View>
<PdfTypography size='h2' variant='primary'>
{item.productionResult && item.productionResult.length > 0 ? ( {projectName
<ProductionResultList ? `${projectName}${kandangName}`
productionResults={item.productionResult} : kandangName}
/> </PdfTypography>
) : ( <PdfTypography size='label'>
<Text style={styles.emptyText}> {[areaName, locationName].filter(Boolean).join(' • ')}
Tidak ada production result untuk kandang ini. </PdfTypography>
</Text>
)}
</View> </View>
);
})
)}
{/* Footer */} {hasData ? (
<View style={styles.footer} fixed> <>
<Text {/* Table 1: WOA & BW */}
render={({ pageNumber, totalPages }) => <View style={styles.tableSection}>
`${pageNumber} / ${totalPages}` <Text style={styles.tableTitle}>1. WOA & Body Weight</Text>
} <PdfTable
fixed columns={getBwTableColumns()}
/> data={getBwTableData(item.productionResult!)}
</View> />
</Page> </View>
{/* Table 2: Deplesi */}
<View style={styles.tableSection}>
<Text style={styles.tableTitle}>2. Deplesi</Text>
<PdfTable
columns={getDepTableColumns()}
data={getDepTableData(item.productionResult!)}
/>
</View>
{/* Table 3: Butiran */}
<View style={styles.tableSection}>
<Text style={styles.tableTitle}>3. Butiran</Text>
<PdfTable
columns={getButiranTableColumns()}
data={getButiranTableData(item.productionResult!)}
/>
</View>
{/* Table 4: Berat (Kg) */}
<View style={styles.tableSection}>
<Text style={styles.tableTitle}>4. Berat (Kg)</Text>
<PdfTable
columns={getKgTableColumns()}
data={getKgTableData(item.productionResult!)}
/>
</View>
{/* Table 5: Persentase */}
<View style={styles.tableSection}>
<Text style={styles.tableTitle}>5. Persentase</Text>
<PdfTable
columns={getPersenTableColumns()}
data={getPersenTableData(item.productionResult!)}
/>
</View>
{/* Table 6: Produksi (HD, FI, EM, EW) */}
<View style={styles.tableSection}>
<Text style={styles.tableTitle}>
6. Produksi (HD, FI, EM, EW)
</Text>
<PdfTable
columns={getProduksi1TableColumns()}
data={getProduksi1TableData(item.productionResult!)}
/>
</View>
{/* Table 7: Produksi (FCR, HH) */}
<View style={styles.tableSection}>
<Text style={styles.tableTitle}>7. Produksi (FCR, HH)</Text>
<PdfTable
columns={getProduksi2TableColumns()}
data={getProduksi2TableData(item.productionResult!)}
/>
</View>
</>
) : (
<Text style={styles.emptyText}>
Tidak ada production result untuk kandang ini.
</Text>
)}
<PdfPageNumber />
</Page>
);
})
)}
</Document> </Document>
); );
}; };