feat(FE-355,356,357): Add PDF export for HPP per kandang report

This commit is contained in:
rstubryan
2025-12-17 11:42:45 +07:00
parent 4cc41c0167
commit c70cfbd450
2 changed files with 540 additions and 6 deletions
@@ -0,0 +1,490 @@
'use client';
import {
Page,
Text,
View,
Document,
StyleSheet,
Font,
pdf,
} from '@react-pdf/renderer';
import { HppPerKandangReport } from '@/types/api/report/hpp-per-kandang';
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',
},
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',
},
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,
},
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',
},
tableBorderBottom: {
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
},
supplierSection: {
marginBottom: 10,
},
supplierSectionBreak: {
marginBottom: 15,
},
parameterBadge: {
backgroundColor: '#F5F5F5',
color: '#333333',
padding: 4,
borderRadius: 4,
fontSize: 8,
marginRight: 8,
marginBottom: 4,
},
parameterContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
marginBottom: 8,
},
});
interface HppPerKandangExportParams {
data: HppPerKandangReport;
params: {
area_name?: string;
location_name?: string;
kandang_name?: string;
period?: string;
weight_min?: string;
weight_max?: string;
show_unrecorded?: string;
sort_by?: string;
};
}
interface WeightRangeGroup {
weight_min: number;
weight_max: number;
items: HppPerKandangReport['rows'];
totals: {
total_remaining_chicken_birds: number;
total_remaining_chicken_weight_kg: number;
average_weight_kg: number;
total_hpp_rp: number;
total_remaining_value_rp: number;
all_feed_suppliers: string[];
all_doc_suppliers: string[];
average_doc_price_rp: number;
};
}
const groupDataByWeightRange = (
data: HppPerKandangReport['rows']
): WeightRangeGroup[] => {
const groups: {
[key: string]: WeightRangeGroup;
} = {};
data.forEach((item) => {
const key = `${item.weight_range.weight_min}-${item.weight_range.weight_max}`;
if (!groups[key]) {
groups[key] = {
weight_min: item.weight_range.weight_min,
weight_max: item.weight_range.weight_max,
items: [],
totals: {
total_remaining_chicken_birds: 0,
total_remaining_chicken_weight_kg: 0,
average_weight_kg: 0,
total_hpp_rp: 0,
total_remaining_value_rp: 0,
all_feed_suppliers: [],
all_doc_suppliers: [],
average_doc_price_rp: 0,
},
};
}
groups[key].items.push(item);
groups[key].totals.total_remaining_chicken_birds +=
item.remaining_chicken_birds;
groups[key].totals.total_remaining_chicken_weight_kg +=
item.remaining_chicken_weight_kg;
groups[key].totals.total_hpp_rp += item.hpp_rp;
groups[key].totals.total_remaining_value_rp += item.remaining_value_rp;
item.feed_suppliers?.forEach((supplier) => {
const alias = supplier.alias || supplier.name;
if (!groups[key].totals.all_feed_suppliers.includes(alias)) {
groups[key].totals.all_feed_suppliers.push(alias);
}
});
item.doc_suppliers?.forEach((supplier) => {
const alias = supplier.alias || supplier.name;
if (!groups[key].totals.all_doc_suppliers.includes(alias)) {
groups[key].totals.all_doc_suppliers.push(alias);
}
});
});
Object.values(groups).forEach((group) => {
group.totals.average_weight_kg =
group.totals.total_remaining_chicken_weight_kg /
group.totals.total_remaining_chicken_birds;
group.totals.average_doc_price_rp =
group.items.reduce((sum, item) => sum + item.average_doc_price_rp, 0) /
group.items.length;
});
return Object.values(groups).sort((a, b) => a.weight_min - b.weight_min);
};
const getParameterText = (params: HppPerKandangExportParams['params']) => {
const paramsText = [];
if (params.period) {
const formattedDate = formatDate(params.period, 'DD MMM YYYY');
paramsText.push(`Tanggal: ${formattedDate}`);
}
const currentDate = formatDate(new Date().toISOString(), 'DD MMM YYYY HH:mm');
paramsText.push(`Dicetak: ${currentDate}`);
return paramsText;
};
const createPDFDocument = (
data: HppPerKandangExportParams['data'],
params: HppPerKandangExportParams['params']
) => {
const groupedByWeightRange = groupDataByWeightRange(data.rows);
return (
<Document>
<Page size='A3' orientation='landscape' style={pdfStyles.page}>
{/* Title and Parameters */}
<View style={pdfStyles.titleSection}>
<Text style={pdfStyles.mainTitle}>
Laporan &gt; HPP Harian Kandang
</Text>
<View style={pdfStyles.parameterContainer}>
{getParameterText(params).map((param, index) => (
<View key={index} style={pdfStyles.parameterBadge}>
<Text>{param}</Text>
</View>
))}
</View>
</View>
{/* Rekapitulasi Section */}
<View style={pdfStyles.supplierSection}>
<Text style={pdfStyles.supplierTitle}>Rekapitulasi</Text>
<View style={pdfStyles.table}>
{/* Table Header */}
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>Rentang BW</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>Sisa Ekor</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>Sisa Kg</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Rata-Rata Bobot (Kg)</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.5 }]}>
<Text>Feed (Supplier)</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>DOC (Supplier)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Rata-Rata Harga DOC</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>HPP</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Nominal Sisa</Text>
</View>
</View>
{/* Table Body - Rekapitulasi */}
{groupedByWeightRange.map((group, index) => (
<View
key={index}
style={[
pdfStyles.tableRow,
index < groupedByWeightRange.length - 1
? pdfStyles.tableBorderBottom
: {},
]}
>
<View style={[pdfStyles.tableCellCenter, { flex: 1.2 }]}>
<Text>
{group.weight_min.toFixed(2)} -{' '}
{group.weight_max.toFixed(2)}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>
{formatNumber(group.totals.total_remaining_chicken_birds)}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>
{formatNumber(
group.totals.total_remaining_chicken_weight_kg
)}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatNumber(group.totals.average_weight_kg)}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text>{group.totals.all_feed_suppliers.join(' | ')}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
<Text>{group.totals.all_doc_suppliers.join(' | ')}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatNumber(group.totals.average_doc_price_rp)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>
{
(group.totals.total_remaining_chicken_birds > 0
? group.totals.total_hpp_rp /
group.totals.total_remaining_chicken_birds
: 0,
2)
}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>
{formatNumber(group.totals.total_remaining_value_rp)}
</Text>
</View>
</View>
))}
</View>
</View>
{/* Detail Per Kandang Section */}
<View style={pdfStyles.supplierSectionBreak}>
<Text style={pdfStyles.supplierTitle}>Detail Per Kandang</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, { flex: 1.5 }]}>
<Text>Kandang</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<Text>Rentang BW</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>Sisa Ekor</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>Sisa Kg</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>Rata-Rata Bobot (Kg)</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>Feed (Supplier)</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<Text>DOC (Supplier)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Rata-Rata Harga DOC</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>HPP</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Nominal Sisa</Text>
</View>
</View>
{/* Table Body - Detail Per Kandang */}
{data.rows.map((item, index) => (
<View
key={index}
style={[
pdfStyles.tableRow,
index < data.rows.length - 1
? pdfStyles.tableBorderBottom
: {},
]}
>
<View style={[pdfStyles.tableCellCenter, { flex: 0.5 }]}>
<Text>{index + 1}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text>{item.kandang?.name || '-'}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text>
{item.weight_range.weight_min.toFixed(2)} -{' '}
{item.weight_range.weight_max.toFixed(2)}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(item.remaining_chicken_birds)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(item.remaining_chicken_weight_kg)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatNumber(item.avg_weight_kg)}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
<Text>
{item.feed_suppliers
?.map((s) => s.alias || s.name)
.join(' | ')}
</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text>
{item.doc_suppliers
?.map((s) => s.alias || s.name)
.join(' | ')}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatNumber(item.average_doc_price_rp)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(item.hpp_rp)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatNumber(item.remaining_value_rp)}</Text>
</View>
</View>
))}
</View>
</View>
</Page>
</Document>
);
};
export const generateHppPerKandangPDF = async (
data: HppPerKandangExportParams['data'],
params: HppPerKandangExportParams['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-hpp-harian-kandang-${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;
}
};
@@ -23,6 +23,7 @@ import Button from '@/components/Button';
import Dropdown from '@/components/dropdown/Dropdown'; import Dropdown from '@/components/dropdown/Dropdown';
import MenuItem from '@/components/menu/MenuItem'; import MenuItem from '@/components/menu/MenuItem';
import Menu from '@/components/menu/Menu'; import Menu from '@/components/menu/Menu';
import { generateHppPerKandangPDF } from '../export/HppPerkandangExport';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
@@ -380,6 +381,54 @@ const HppPerKandangTab = () => {
kandangOptions, kandangOptions,
]); ]);
const handleExportPDF = useCallback(async () => {
if (!hppPerKandang || !isResponseSuccess(hppPerKandang)) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
try {
const areaName = tableFilterState.area_id
? areaOptions.find(
(opt) => opt.value === Number(tableFilterState.area_id)
)?.label || 'Semua Area'
: 'Semua Area';
const locationName = tableFilterState.location_id
? locationOptions.find(
(opt) => opt.value === Number(tableFilterState.location_id)
)?.label || 'Semua Lokasi'
: 'Semua Lokasi';
const kandangName = tableFilterState.kandang_id
? kandangOptions.find(
(opt) => opt.value === Number(tableFilterState.kandang_id)
)?.label || 'Semua Kandang'
: 'Semua Kandang';
await generateHppPerKandangPDF(hppPerKandang.data, {
area_name: areaName,
location_name: locationName,
kandang_name: kandangName,
period: tableFilterState.period,
weight_min: tableFilterState.weight_min,
weight_max: tableFilterState.weight_max,
show_unrecorded: tableFilterState.show_unrecorded.toString(),
sort_by: tableFilterState.sort_by,
});
toast.success('PDF berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat PDF. Silakan coba lagi.');
}
}, [
hppPerKandang,
tableFilterState,
areaOptions,
locationOptions,
kandangOptions,
]);
// ===== TABLE COLUMNS DEFINITION ===== // ===== TABLE COLUMNS DEFINITION =====
const totals: Totals = useMemo(() => { const totals: Totals = useMemo(() => {
return { return {
@@ -592,12 +641,7 @@ const HppPerKandangTab = () => {
> >
<Menu className='w-32'> <Menu className='w-32'>
<MenuItem title='Excel' onClick={handleExportExcel} /> <MenuItem title='Excel' onClick={handleExportExcel} />
<MenuItem <MenuItem title='PDF' onClick={handleExportPDF} />
title='PDF'
onClick={() => {
alert('Fitur belum tersedia');
}}
/>
</Menu> </Menu>
</Dropdown> </Dropdown>
</div> </div>