Merge branch 'fix/refactor-native-pdf-renderer' into 'development'

[FIX/FE] Refactor Native PDF Renderer to Component Based

See merge request mbugroup/lti-web-client!321
This commit is contained in:
Rivaldi A N S
2026-02-11 07:28:43 +00:00
31 changed files with 2340 additions and 2309 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
import MarketingReportContent from '@/components/pages/report/MarketingReportContent';
import MarketingReportContent from '@/components/pages/report/marketing/MarketingReportContent';
const MarketingReportPage = () => {
return (
+1 -1
View File
@@ -2,7 +2,7 @@ import ProductionResultContent from '@/components/pages/report/production-result
const ProductionResultReportPage = () => {
return (
<section className='w-full max-w-7xl pb-16'>
<section className='w-full max-w-full'>
<ProductionResultContent />
</section>
);
@@ -1,7 +1,9 @@
import { Text, View, StyleSheet } from '@react-pdf/renderer';
import type { Style } from '@react-pdf/types';
type PdfParamBadgeProps = {
children: React.ReactNode;
style?: Style;
};
const styles = StyleSheet.create({
@@ -16,9 +18,9 @@ const styles = StyleSheet.create({
},
});
export const PdfParamBadge = ({ children }: PdfParamBadgeProps) => {
export const PdfParamBadge = ({ children, style }: PdfParamBadgeProps) => {
return (
<View style={styles.parameterBadge}>
<View style={[styles.parameterBadge, ...(style ? [style] : [])]}>
<Text>{children}</Text>
</View>
);
@@ -1,10 +1,9 @@
import { Text, View, StyleSheet } from '@react-pdf/renderer';
import type { Style } from '@react-pdf/types';
type PdfStatusBadgeProps = {
children: React.ReactNode;
backgroundColor?: string;
textColor?: string;
borderColor?: string;
style?: Style;
};
const styles = StyleSheet.create({
@@ -16,30 +15,38 @@ const styles = StyleSheet.create({
fontWeight: 'bold',
borderWidth: 1,
borderStyle: 'solid',
backgroundColor: '#F5F5F5',
borderColor: '#E5E7EB',
},
statusBadgeText: {
fontSize: 7,
fontWeight: 'bold',
color: '#333333',
},
});
export const PdfStatusBadge = ({
children,
backgroundColor = '#F5F5F5',
textColor = '#333333',
borderColor = '#E5E7EB',
}: PdfStatusBadgeProps) => {
export const PdfStatusBadge = ({ children, style }: PdfStatusBadgeProps) => {
const styleRecord = style as Record<string, unknown>;
const color = styleRecord?.color as string | undefined;
const viewStyle = Object.entries(styleRecord || {}).reduce(
(acc, [key, value]) => {
if (key !== 'color') {
acc[key] = value;
}
return acc;
},
{} as Record<string, unknown>
);
return (
<View
style={[
styles.statusBadge,
{
backgroundColor,
borderColor,
},
...(Object.keys(viewStyle).length > 0 ? [viewStyle as Style] : []),
]}
>
<Text style={[styles.statusBadgeText, { color: textColor }]}>
<Text style={[styles.statusBadgeText, ...(color ? [{ color }] : [])]}>
{children}
</Text>
</View>
@@ -0,0 +1,48 @@
import { Text, View, StyleSheet } from '@react-pdf/renderer';
import type { Style } from '@react-pdf/types';
type PdfPageNumberProps = {
style?: Style;
/**
* Format template for page number.
* Use {pageNumber} and {totalPages} as placeholders.
* Default: "{pageNumber} / {totalPages}"
*/
format?: string;
};
const styles = StyleSheet.create({
footer: {
width: '100%',
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
position: 'absolute',
fontSize: 8,
bottom: 30,
left: 0,
right: 0,
textAlign: 'center',
color: 'grey',
},
});
export const PdfPageNumber = ({
style,
format = '{pageNumber} / {totalPages}',
}: PdfPageNumberProps) => {
return (
<View style={style || styles.footer} fixed>
<Text
render={({ pageNumber, totalPages }) =>
format
.replace('{pageNumber}', String(pageNumber))
.replace('{totalPages}', String(totalPages))
}
fixed
/>
</View>
);
};
+21 -14
View File
@@ -1,9 +1,10 @@
'use client';
import { View, StyleSheet } from '@react-pdf/renderer';
import { PdfThead, PdfColumn } from './PdfThead';
import { PdfTbody, PdfTbodyCell } from './PdfTbody';
import { PdfTfoot, PdfTfootCell } from './PdfTfoot';
import type { PdfColumn } from './types';
import { PdfThead } from './PdfThead';
import { PdfTbody } from './PdfTbody';
import { PdfTfoot } from './PdfTfoot';
const styles = StyleSheet.create({
table: {
@@ -13,10 +14,10 @@ const styles = StyleSheet.create({
},
});
interface PdfTableProps {
columns: PdfColumn[];
data: PdfTbodyCell[][];
footer?: PdfTfootCell[];
interface PdfTableProps<TData = Record<string, unknown>> {
columns: PdfColumn<TData>[];
data: TData[];
showFooter?: boolean;
footerLabel?: string;
firstRow?: {
valueKey: string;
@@ -26,20 +27,26 @@ interface PdfTableProps {
};
}
export const PdfTable = ({
export const PdfTable = <TData = Record<string, unknown>,>({
columns,
data,
footer,
showFooter = false,
footerLabel = 'Total',
firstRow,
}: PdfTableProps) => {
}: PdfTableProps<TData>) => {
// Check if any column has footer defined
const hasFooter =
showFooter || columns.some((col) => col.footer !== undefined);
return (
<View style={styles.table}>
<PdfThead columns={columns} />
<PdfTbody columns={columns} rows={data} firstRow={firstRow} />
{footer && footer.length > 0 && (
<PdfTfoot columns={columns} cells={footer} label={footerLabel} />
<PdfThead columns={columns} data={data} />
<PdfTbody columns={columns} data={data} firstRow={firstRow} />
{hasFooter && data.length > 0 && (
<PdfTfoot columns={columns} data={data} label={footerLabel} />
)}
</View>
);
};
export type { PdfColumn };
+44 -54
View File
@@ -1,22 +1,8 @@
'use client';
import { Text, View, StyleSheet } from '@react-pdf/renderer';
export interface PdfColumn {
key: string;
header: string;
flex: number;
align?: 'left' | 'center' | 'right';
}
export interface PdfTbodyCell {
key: string;
value: string | number | React.ReactNode;
align?: 'left' | 'center' | 'right';
color?: string;
formatAs?: 'text' | 'date' | 'currency' | 'number';
formatDate?: string;
}
import { ReactNode } from 'react';
import type { PdfColumn } from './types';
const styles = StyleSheet.create({
tableRow: {
@@ -71,21 +57,22 @@ const styles = StyleSheet.create({
},
});
interface PdfTbodyProps {
columns: PdfColumn[];
rows: PdfTbodyCell[][];
interface PdfTbodyProps<TData = Record<string, unknown>> {
columns: PdfColumn<TData>[];
data: TData[];
firstRow?: {
valueKey: string;
value: number;
align?: 'right';
color?: string;
};
formatDate?: (date: string, format: string) => string;
formatNumber?: (num: number) => string;
formatCurrency?: (num: number) => string;
}
export const PdfTbody = ({ columns, rows, firstRow }: PdfTbodyProps) => {
export const PdfTbody = <TData = Record<string, unknown>,>({
columns,
data,
firstRow,
}: PdfTbodyProps<TData>) => {
return (
<>
{/* First Row */}
@@ -93,17 +80,17 @@ export const PdfTbody = ({ columns, rows, firstRow }: PdfTbodyProps) => {
<View style={[styles.tableRow, styles.tableBorderBottom]}>
{columns.map((column, index) => {
const isLastColumn = index === columns.length - 1;
const isfirstRowColumn = column.key === firstRow.valueKey;
const align = column.align || 'center';
const isFirstRowColumn = column.key === firstRow.valueKey;
const align = column.align || 'left';
const cellStyle =
column.key === 'no'
? [styles.tableCellNo, { flex: column.flex }]
: isfirstRowColumn
? [styles.tableCellNo, { flex: column.flex || 1 }]
: isFirstRowColumn
? [
styles.tableCellRight,
{
flex: column.flex,
flex: column.flex || 1,
color: firstRow.color || 'black',
borderRightWidth: isLastColumn ? 0 : 1,
},
@@ -112,7 +99,7 @@ export const PdfTbody = ({ columns, rows, firstRow }: PdfTbodyProps) => {
? [
styles.tableCellRight,
{
flex: column.flex,
flex: column.flex || 1,
borderRightWidth: isLastColumn ? 0 : 1,
},
]
@@ -120,7 +107,7 @@ export const PdfTbody = ({ columns, rows, firstRow }: PdfTbodyProps) => {
? [
styles.tableCellCenter,
{
flex: column.flex,
flex: column.flex || 1,
borderRightWidth: isLastColumn ? 0 : 1,
},
]
@@ -128,15 +115,15 @@ export const PdfTbody = ({ columns, rows, firstRow }: PdfTbodyProps) => {
? [
styles.tableCellLast,
{
flex: column.flex,
flex: column.flex || 1,
borderRightWidth: 0,
},
]
: [styles.tableCell, { flex: column.flex }];
: [styles.tableCell, { flex: column.flex || 1 }];
return (
<View key={column.key} style={cellStyle}>
<Text>{isfirstRowColumn ? firstRow.value : ''}</Text>
<Text>{isFirstRowColumn ? firstRow.value : ''}</Text>
</View>
);
})}
@@ -144,8 +131,8 @@ export const PdfTbody = ({ columns, rows, firstRow }: PdfTbodyProps) => {
)}
{/* Data Rows */}
{rows.map((row, rowIndex) => {
const isLastRow = rowIndex === rows.length - 1;
{data.map((row, rowIndex) => {
const isLastRow = rowIndex === data.length - 1;
return (
<View
@@ -156,19 +143,27 @@ export const PdfTbody = ({ columns, rows, firstRow }: PdfTbodyProps) => {
]}
>
{columns.map((column, colIndex) => {
const cell = row.find((c) => c.key === column.key);
const isLastColumn = colIndex === columns.length - 1;
const align = cell?.align || column.align || 'center';
const align = column.align || 'left';
// Get cell content from column.cell function or fallback to row value
let cellContent: ReactNode;
if (column.cell) {
cellContent = column.cell({ row, index: rowIndex });
} else {
cellContent =
((row as Record<string, unknown>)[column.key] as ReactNode) ??
'-';
}
const cellStyle =
column.key === 'no'
? [styles.tableCellNo, { flex: column.flex }]
? [styles.tableCellNo, { flex: column.flex || 1 }]
: align === 'right'
? [
styles.tableCellRight,
{
flex: column.flex,
color: cell?.color || 'black',
flex: column.flex || 1,
borderRightWidth: isLastColumn ? 0 : 1,
},
]
@@ -176,37 +171,30 @@ export const PdfTbody = ({ columns, rows, firstRow }: PdfTbodyProps) => {
? [
styles.tableCellCenter,
{
flex: column.flex,
color: cell?.color || 'black',
flex: column.flex || 1,
borderRightWidth: isLastColumn ? 0 : 1,
},
]
: isLastColumn
? [
styles.tableCellLast,
{ flex: column.flex, borderRightWidth: 0 },
{ flex: column.flex || 1, borderRightWidth: 0 },
]
: [
styles.tableCell,
{
flex: column.flex,
color: cell?.color || 'black',
flex: column.flex || 1,
borderRightWidth: isLastColumn ? 0 : 1,
},
];
return (
<View key={column.key} style={cellStyle}>
{cell?.value !== undefined &&
cell?.value !== null &&
cell?.value !== '' ? (
typeof cell.value === 'object' ? (
cell.value
) : (
<Text>{String(cell.value)}</Text>
)
{typeof cellContent === 'string' ||
typeof cellContent === 'number' ? (
<Text>{String(cellContent)}</Text>
) : (
<Text>-</Text>
cellContent
)}
</View>
);
@@ -217,3 +205,5 @@ export const PdfTbody = ({ columns, rows, firstRow }: PdfTbodyProps) => {
</>
);
};
export type { PdfColumn };
+48 -38
View File
@@ -1,21 +1,8 @@
'use client';
import { Text, View, StyleSheet } from '@react-pdf/renderer';
export interface PdfColumn {
key: string;
header: string;
flex: number;
align?: 'left' | 'center' | 'right';
}
export interface PdfTfootCell {
key: string;
value: string | number;
align?: 'left' | 'center' | 'right';
flex?: number;
color?: string;
}
import { ReactNode } from 'react';
import type { PdfColumn } from './types';
const styles = StyleSheet.create({
tableRow: {
@@ -69,63 +56,86 @@ const styles = StyleSheet.create({
},
});
interface PdfTfootProps {
columns: PdfColumn[];
cells: PdfTfootCell[];
interface PdfTfootProps<TData = Record<string, unknown>> {
columns: PdfColumn<TData>[];
data: TData[];
label?: string;
}
export const PdfTfoot = ({
export const PdfTfoot = <TData = Record<string, unknown>,>({
columns,
cells,
data,
label = 'Total',
}: PdfTfootProps) => {
}: PdfTfootProps<TData>) => {
return (
<View style={[styles.tableRow, styles.summaryRow]}>
{columns.map((column, index) => {
const isLastColumn = index === columns.length - 1;
const cellData = cells.find((c) => c.key === column.key);
// Get footer content from column definition
let footerContent: ReactNode;
if (typeof column.footer === 'function') {
footerContent = column.footer(data);
} else {
footerContent = column.footer;
}
// Use label for first column (usually 'no' column)
const displayContent = column.key === 'no' ? label : footerContent;
// Determine alignment
const align = column.footerAlign || column.align || 'left';
const color = column.footerColor || 'black';
const cellStyle =
column.key === 'no'
? [
styles.tableCellNo,
{ flex: column.flex, borderRightWidth: isLastColumn ? 0 : 1 },
{
flex: column.flex || 1,
borderRightWidth: isLastColumn ? 0 : 1,
color,
},
]
: cellData?.align === 'right'
: align === 'right'
? [
styles.tableCellRight,
{
flex: column.flex,
color: cellData?.color || 'black',
flex: column.flex || 1,
color,
borderRightWidth: isLastColumn ? 0 : 1,
},
]
: cellData?.align === 'center'
: align === 'center'
? [
styles.tableCellCenter,
{
flex: column.flex,
color: cellData?.color || 'black',
flex: column.flex || 1,
color,
borderRightWidth: isLastColumn ? 0 : 1,
},
]
: isLastColumn
? [styles.tableCellLast, { flex: column.flex }]
: [
styles.tableCell,
{
flex: column.flex,
color: cellData?.color || 'black',
},
];
? [styles.tableCellLast, { flex: column.flex || 1, color }]
: [styles.tableCell, { flex: column.flex || 1, color }];
return (
<View key={column.key} style={cellStyle}>
<Text>{column.key === 'no' ? label : cellData?.value || ''}</Text>
{displayContent !== undefined && displayContent !== null ? (
typeof displayContent === 'string' ||
typeof displayContent === 'number' ? (
<Text>{String(displayContent)}</Text>
) : (
displayContent
)
) : (
<Text>-</Text>
)}
</View>
);
})}
</View>
);
};
export type { PdfColumn };
+29 -14
View File
@@ -1,13 +1,8 @@
'use client';
import { Text, View, StyleSheet } from '@react-pdf/renderer';
export interface PdfColumn {
key: string;
header: string;
flex: number;
align?: 'left' | 'center' | 'right';
}
import { ReactNode } from 'react';
import type { PdfColumn } from './types';
const styles = StyleSheet.create({
tableRow: {
@@ -48,23 +43,37 @@ const styles = StyleSheet.create({
},
});
interface PdfTheadProps {
columns: PdfColumn[];
interface PdfTheadProps<TData = Record<string, unknown>> {
columns: PdfColumn<TData>[];
data?: TData[];
}
export const PdfThead = ({ columns }: PdfTheadProps) => {
export const PdfThead = <TData = Record<string, unknown>,>({
columns,
data,
}: PdfTheadProps<TData>) => {
return (
<View style={[styles.tableRow, styles.tableHeader]}>
{columns.map((column, index) => {
const align = column.align || 'center';
const isLastColumn = index === columns.length - 1;
// Get header content from column definition
let headerContent: ReactNode;
if (typeof column.header === 'function') {
headerContent = column.header(data || []);
} else {
headerContent = column.header || column.key;
}
// Determine alignment - columns align right by default for numeric data
const align = column.align || 'left';
const cellStyle =
align === 'right'
? [
styles.tableCellHeaderRight,
{
flex: column.flex,
flex: column.flex || 1,
textAlign: 'right' as const,
borderRightWidth: isLastColumn ? 0 : 1,
},
@@ -72,7 +81,7 @@ export const PdfThead = ({ columns }: PdfTheadProps) => {
: [
styles.tableCellHeader,
{
flex: column.flex,
flex: column.flex || 1,
textAlign: align as 'left' | 'center' | 'right',
borderRightWidth: isLastColumn ? 0 : 1,
},
@@ -80,10 +89,16 @@ export const PdfThead = ({ columns }: PdfTheadProps) => {
return (
<View key={column.key} style={cellStyle}>
<Text>{column.header}</Text>
{typeof headerContent === 'string' ? (
<Text>{headerContent}</Text>
) : (
headerContent
)}
</View>
);
})}
</View>
);
};
export type { PdfColumn };
+1 -3
View File
@@ -2,6 +2,4 @@ export { PdfTable } from './PdfTable';
export { PdfThead } from './PdfThead';
export { PdfTbody } from './PdfTbody';
export { PdfTfoot } from './PdfTfoot';
export type { PdfColumn } from './PdfThead';
export type { PdfTbodyCell } from './PdfTbody';
export type { PdfTfootCell } from './PdfTfoot';
export type { PdfColumn } from './types';
+24
View File
@@ -0,0 +1,24 @@
import { ReactNode } from 'react';
/**
* PdfColumn - Mirip dengan ColumnDef di TanStack Table
* Mengatur header (thead), body (tbody), dan footer (tfoot) dalam satu definisi
*/
export interface PdfColumn<TData = Record<string, unknown>> {
key: string;
flex?: number;
// Header configuration (thead)
header?: string | ((data: TData[]) => ReactNode);
// Body configuration (tbody)
align?: 'left' | 'center' | 'right';
cell?: (props: { row: TData; index: number }) => ReactNode | string | number;
// Footer configuration (tfoot)
footer?: string | number | ((data: TData[]) => ReactNode | string | number);
footerAlign?: 'left' | 'center' | 'right';
footerColor?: string;
}
export type { PdfColumn as default };
@@ -1,5 +1,6 @@
import { Color } from '@/types/theme';
import { Text, StyleSheet } from '@react-pdf/renderer';
import type { Style } from '@react-pdf/types';
type TypographySize = 'h1' | 'h2' | 'h3' | 'h4' | 'p' | 'small' | 'label';
@@ -10,7 +11,7 @@ type PdfTypographyProps = {
size?: TypographySize;
variant?: TypographyVariant;
color?: string;
marginBottom?: number;
style?: Style;
};
const styles = StyleSheet.create({
@@ -66,17 +67,13 @@ export const PdfTypography = ({
size = 'p',
variant = 'default',
color,
marginBottom,
style,
}: PdfTypographyProps) => {
const sizeStyle = styles[size];
const textColor = color || variantColors[variant];
const customStyle = {
...(marginBottom !== undefined && { marginBottom }),
};
return (
<Text style={[sizeStyle, { color: textColor }, customStyle]}>
<Text style={[sizeStyle, { color: textColor }, ...(style ? [style] : [])]}>
{children}
</Text>
);
@@ -0,0 +1,65 @@
export type StatusColor = {
bg: string;
text: string;
border: string;
};
// Due status colors (for debt supplier reports)
export const dueStatusColors: Record<string, StatusColor> = {
'SUDAH JATUH TEMPO': {
bg: '#FEE2E2',
text: '#991B1B',
border: '#F87171',
}, // error/red
'BELUM JATUH TEMPO': {
bg: '#D1FAE5',
text: '#065F46',
border: '#34D399',
}, // success/green
'MENDEKATI JATUH TEMPO': {
bg: '#FEF3C7',
text: '#92400E',
border: '#FBBF24',
}, // warning/yellow
};
// Payment status colors (for customer payment & debt supplier reports)
export const paymentStatusColors: Record<string, StatusColor> = {
'BELUM LUNAS': {
bg: '#FEF3C7',
text: '#92400E',
border: '#FBBF24',
}, // warning/yellow
LUNAS: {
bg: '#DBEAFE',
text: '#1E40AF',
border: '#60A5FA',
}, // primary/blue
'PEMBAYARAN SEBAGIAN': { bg: '#D1FAE5', text: '#065F46', border: '#34D399' }, // success/green
PEMBAYARAN: {
bg: '#D1FAE5',
text: '#065F46',
border: '#34D399',
}, // success/green
};
// Fallback color for unknown statuses
export const fallbackStatusColor: StatusColor = {
bg: '#F3F4F6',
text: '#374151',
border: '#D1D5DB',
}; // neutral
export const getPDFBadgeStyle = (
statusText: string,
type: 'due' | 'payment' = 'payment'
): StatusColor => {
const normalizedStatus = statusText.toUpperCase().trim();
const colors =
type === 'due'
? dueStatusColors[normalizedStatus]
: paymentStatusColors[normalizedStatus];
return colors || fallbackStatusColor;
};
@@ -1,570 +0,0 @@
'use client';
import {
Document,
Image,
Page,
StyleSheet,
Text,
View,
} from '@react-pdf/renderer';
import {
DailyMarketingReport,
SalesSummary,
} from '@/types/api/report/marketing';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
interface DailyMarketingReportPDFProps {
data?: DailyMarketingReport;
total?: SalesSummary;
}
const DailyMarketingReportPDFStyle = StyleSheet.create({
page: {
paddingTop: 24,
paddingBottom: 64,
paddingHorizontal: 16, // Reduce padding to fit more columns
orientation: 'landscape',
},
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,
},
companyName: {
fontSize: 12,
fontWeight: 'bold',
marginBottom: 4,
},
companyAddress: {
fontSize: 8,
maxWidth: 400,
marginBottom: 10,
},
title: {
marginTop: 16,
fontSize: 14,
lineHeight: '150%',
textAlign: 'center',
fontFamily: 'Times-Roman',
fontWeight: 'bold',
},
footer: {
width: '100%',
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
position: 'absolute',
fontSize: 8,
bottom: 30,
left: 0,
right: 0,
textAlign: 'center',
color: 'grey',
},
// Table Styles
table: {
width: '100%',
marginTop: 16,
borderWidth: 1,
borderColor: '#000000',
borderBottomWidth: 0,
fontSize: 7, // Smaller font for report
},
tableRow: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: '#000000',
alignItems: 'center',
minHeight: 20,
},
tableHeader: {
backgroundColor: '#f0f0f0',
fontWeight: 'bold',
},
// Columns definition (Total 100%)
colNo: {
width: '3%',
padding: 2,
textAlign: 'center',
borderRightWidth: 1,
borderRightColor: '#000000',
},
colSoDate: {
width: '6%',
padding: 2,
textAlign: 'left',
borderRightWidth: 1,
borderRightColor: '#000000',
},
colDoDate: {
width: '6%',
padding: 2,
textAlign: 'left',
borderRightWidth: 1,
borderRightColor: '#000000',
},
colAging: {
width: '3%',
padding: 2,
textAlign: 'center',
borderRightWidth: 1,
borderRightColor: '#000000',
},
colWarehouse: {
width: '7%',
padding: 2,
textAlign: 'left',
borderRightWidth: 1,
borderRightColor: '#000000',
},
colCustomer: {
width: '9%',
padding: 2,
textAlign: 'left',
borderRightWidth: 1,
borderRightColor: '#000000',
}, // Reduced slightly
colSales: {
width: '6%',
padding: 2,
textAlign: 'left',
borderRightWidth: 1,
borderRightColor: '#000000',
},
colProduct: {
width: '8%',
padding: 2,
textAlign: 'left',
borderRightWidth: 1,
borderRightColor: '#000000',
}, // Reduced slightly
colDoNumber: {
width: '7%',
padding: 2,
textAlign: 'left',
borderRightWidth: 1,
borderRightColor: '#000000',
},
colVehicle: {
width: '5%',
padding: 2,
textAlign: 'left',
borderRightWidth: 1,
borderRightColor: '#000000',
},
colMarketingType: {
width: '5%',
padding: 2,
textAlign: 'left',
borderRightWidth: 1,
borderRightColor: '#000000',
},
colQty: {
width: '4%',
padding: 2,
textAlign: 'right',
borderRightWidth: 1,
borderRightColor: '#000000',
},
colAvgWeight: {
width: '4%',
padding: 2,
textAlign: 'right',
borderRightWidth: 1,
borderRightColor: '#000000',
},
colTotalWeight: {
width: '5%',
padding: 2,
textAlign: 'right',
borderRightWidth: 1,
borderRightColor: '#000000',
},
colSalesPrice: {
width: '5%',
padding: 2,
textAlign: 'right',
borderRightWidth: 1,
borderRightColor: '#000000',
},
colHppPrice: {
width: '5%',
padding: 2,
textAlign: 'right',
borderRightWidth: 1,
borderRightColor: '#000000',
},
colSalesAmount: {
width: '6%',
padding: 2,
textAlign: 'right',
borderRightWidth: 1,
borderRightColor: '#000000',
},
colHppAmount: { width: '6%', padding: 2, textAlign: 'right' }, // Last column
// Text inside columns
cellText: {
fontSize: 6,
},
headerText: {
fontSize: 7,
fontWeight: 'bold',
textAlign: 'center',
},
// Utils
doubleDivider: {
width: '100%',
height: 6,
borderTop: '2px solid black',
borderBottom: '2px solid black',
},
// Summary
summaryContainer: {
marginTop: 12,
flexDirection: 'row',
justifyContent: 'flex-end',
width: '100%',
},
summaryTable: {
width: '30%',
borderWidth: 1,
borderColor: '#000000',
fontSize: 8,
},
summaryRow: {
flexDirection: 'row',
padding: 2,
borderBottomWidth: 1,
borderBottomColor: '#eee',
},
summaryLabel: {
width: '50%',
fontWeight: 'bold',
},
summaryValue: {
width: '50%',
textAlign: 'right',
},
});
const DailyMarketingReportPDF = ({
data,
total,
}: DailyMarketingReportPDFProps) => {
const rows = data || [];
const summary = total;
return (
<Document>
<Page
style={DailyMarketingReportPDFStyle.page}
orientation='landscape'
size='A4'
>
<View>
<View style={DailyMarketingReportPDFStyle.companyInfoHeader}>
<Image
style={DailyMarketingReportPDFStyle.companyLogo}
src='/assets/img/lti-logo.png'
/>
<Text style={DailyMarketingReportPDFStyle.companyInfoHeaderDate}>
{formatDate(Date.now(), 'DD MMMM YYYY')}
</Text>
</View>
<View>
<Text style={DailyMarketingReportPDFStyle.companyName}>
PT LUMBUNG TELUR INDONESIA
</Text>
<Text style={DailyMarketingReportPDFStyle.companyAddress}>
SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel.
Cipedes, Kec. Sukajadi, Kota Bandung 40162
</Text>
<View style={DailyMarketingReportPDFStyle.doubleDivider} />
</View>
</View>
<Text style={DailyMarketingReportPDFStyle.title}>
Laporan Penjualan Harian
</Text>
{/* Data Table */}
<View style={DailyMarketingReportPDFStyle.table}>
{/* Header */}
<View
style={[
DailyMarketingReportPDFStyle.tableRow,
DailyMarketingReportPDFStyle.tableHeader,
]}
>
<View style={DailyMarketingReportPDFStyle.colNo}>
<Text style={DailyMarketingReportPDFStyle.headerText}>No</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colSoDate}>
<Text style={DailyMarketingReportPDFStyle.headerText}>
Tgl SO
</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colDoDate}>
<Text style={DailyMarketingReportPDFStyle.headerText}>
Tgl DO
</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colAging}>
<Text style={DailyMarketingReportPDFStyle.headerText}>Aging</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colWarehouse}>
<Text style={DailyMarketingReportPDFStyle.headerText}>
Gudang
</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colCustomer}>
<Text style={DailyMarketingReportPDFStyle.headerText}>
Pelanggan
</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colSales}>
<Text style={DailyMarketingReportPDFStyle.headerText}>Sales</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colProduct}>
<Text style={DailyMarketingReportPDFStyle.headerText}>
Produk
</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colDoNumber}>
<Text style={DailyMarketingReportPDFStyle.headerText}>No DO</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colVehicle}>
<Text style={DailyMarketingReportPDFStyle.headerText}>
Plat No
</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colMarketingType}>
<Text style={DailyMarketingReportPDFStyle.headerText}>Tipe</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colQty}>
<Text style={DailyMarketingReportPDFStyle.headerText}>Qty</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colAvgWeight}>
<Text style={DailyMarketingReportPDFStyle.headerText}>
Rerata
</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colTotalWeight}>
<Text style={DailyMarketingReportPDFStyle.headerText}>Berat</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colSalesPrice}>
<Text style={DailyMarketingReportPDFStyle.headerText}>
Hrg Jual
</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colHppPrice}>
<Text style={DailyMarketingReportPDFStyle.headerText}>
HPP/kg
</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colSalesAmount}>
<Text style={DailyMarketingReportPDFStyle.headerText}>
Total Jual
</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colHppAmount}>
<Text style={DailyMarketingReportPDFStyle.headerText}>
Total HPP
</Text>
</View>
</View>
{/* Rows */}
{rows.map((row, index) => (
<View style={DailyMarketingReportPDFStyle.tableRow} key={index}>
<View style={DailyMarketingReportPDFStyle.colNo}>
<Text style={DailyMarketingReportPDFStyle.cellText}>
{index + 1}
</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colSoDate}>
<Text style={DailyMarketingReportPDFStyle.cellText}>
{formatDate(row.so_date, 'DD/MM/YYYY')}
</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colDoDate}>
<Text style={DailyMarketingReportPDFStyle.cellText}>
{formatDate(row.realization_date, 'DD/MM/YYYY')}
</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colAging}>
<Text style={DailyMarketingReportPDFStyle.cellText}>
{row.aging_days}
</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colWarehouse}>
<Text style={DailyMarketingReportPDFStyle.cellText}>
{row.warehouse?.name}
</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colCustomer}>
<Text style={DailyMarketingReportPDFStyle.cellText}>
{row.customer?.name}
</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colSales}>
<Text style={DailyMarketingReportPDFStyle.cellText}>
{row.sales.name}
</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colProduct}>
<Text style={DailyMarketingReportPDFStyle.cellText}>
{row.product?.name}
</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colDoNumber}>
<Text style={DailyMarketingReportPDFStyle.cellText}>
{row.do_number}
</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colVehicle}>
<Text style={DailyMarketingReportPDFStyle.cellText}>
{row.vehicle_number}
</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colMarketingType}>
<Text style={DailyMarketingReportPDFStyle.cellText}>
{row.marketing_type}
</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colQty}>
<Text style={DailyMarketingReportPDFStyle.cellText}>
{formatNumber(row.qty)}
</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colAvgWeight}>
<Text style={DailyMarketingReportPDFStyle.cellText}>
{formatNumber(row.average_weight_kg)}
</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colTotalWeight}>
<Text style={DailyMarketingReportPDFStyle.cellText}>
{formatNumber(row.total_weight_kg)}
</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colSalesPrice}>
<Text style={DailyMarketingReportPDFStyle.cellText}>
{formatCurrency(row.sales_price_per_kg)}
</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colHppPrice}>
<Text style={DailyMarketingReportPDFStyle.cellText}>
{formatCurrency(row.hpp_price_per_kg)}
</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colSalesAmount}>
<Text style={DailyMarketingReportPDFStyle.cellText}>
{formatCurrency(row.sales_amount)}
</Text>
</View>
<View style={DailyMarketingReportPDFStyle.colHppAmount}>
<Text style={DailyMarketingReportPDFStyle.cellText}>
{formatCurrency(row.hpp_amount)}
</Text>
</View>
</View>
))}
</View>
{/* Summary */}
<View style={DailyMarketingReportPDFStyle.summaryContainer}>
<View style={DailyMarketingReportPDFStyle.summaryTable}>
<View style={DailyMarketingReportPDFStyle.summaryRow}>
<Text style={DailyMarketingReportPDFStyle.summaryLabel}>
Total Qty:
</Text>
<Text style={DailyMarketingReportPDFStyle.summaryValue}>
{formatNumber(summary?.total_qty ?? 0)}
</Text>
</View>
<View style={DailyMarketingReportPDFStyle.summaryRow}>
<Text style={DailyMarketingReportPDFStyle.summaryLabel}>
Total Berat (kg):
</Text>
<Text style={DailyMarketingReportPDFStyle.summaryValue}>
{formatNumber(summary?.total_weight_kg ?? 0)}
</Text>
</View>
<View style={DailyMarketingReportPDFStyle.summaryRow}>
<Text style={DailyMarketingReportPDFStyle.summaryLabel}>
Total Penjualan:
</Text>
<Text style={DailyMarketingReportPDFStyle.summaryValue}>
{formatCurrency(summary?.total_sales_amount ?? 0)}
</Text>
</View>
<View
style={[
DailyMarketingReportPDFStyle.summaryRow,
{ borderBottomWidth: 0 },
]}
>
<Text style={DailyMarketingReportPDFStyle.summaryLabel}>
Total HPP Per KG:
</Text>
<Text style={DailyMarketingReportPDFStyle.summaryValue}>
{formatCurrency(summary?.total_hpp_price_per_kg ?? 0)}
</Text>
</View>
<View
style={[
DailyMarketingReportPDFStyle.summaryRow,
{ borderBottomWidth: 0 },
]}
>
<Text style={DailyMarketingReportPDFStyle.summaryLabel}>
Total HPP:
</Text>
<Text style={DailyMarketingReportPDFStyle.summaryValue}>
{formatCurrency(summary?.total_hpp_amount ?? 0)}
</Text>
</View>
</View>
</View>
<View style={DailyMarketingReportPDFStyle.footer} fixed>
<Text
render={({ pageNumber, totalPages }) =>
`${pageNumber} / ${totalPages}`
}
fixed
/>
</View>
</Page>
</Document>
);
};
export default DailyMarketingReportPDF;
@@ -7,19 +7,21 @@ import {
StyleSheet,
Font,
pdf,
Text,
} from '@react-pdf/renderer';
import { formatDate, formatCurrency, formatNumber } from '@/lib/helper';
import { CustomerPaymentReport } from '@/types/api/report/customer-payment';
import {
PdfTable,
PdfColumn,
PdfTbodyCell,
PdfTfootCell,
} from '@/components/helper/pdf/table';
formatDate,
formatCurrency,
formatNumber,
formatTitleCase,
} from '@/lib/helper';
import { CustomerPaymentReport } from '@/types/api/report/customer-payment';
import { PdfTable, PdfColumn } from '@/components/helper/pdf/table';
import { PdfParamBadge } from '@/components/helper/pdf/badge/PdfParamBadge';
import { PdfStatusBadge } from '@/components/helper/pdf/badge/PdfStatusBadge';
import { PdfTypography } from '@/components/helper/pdf/typography/PdfTypography';
import { getPDFBadgeStyle } from '@/components/helper/pdf/utils/pdf-badge';
Font.register({
family: 'Helvetica',
@@ -55,161 +57,183 @@ interface CustomerPaymentExportPDFParams {
};
}
const getTableColumns = (): PdfColumn[] => [
{ key: 'no', header: 'No', flex: 0.5, align: 'center' },
{ key: 'trans_date', header: 'Tanggal DO', flex: 1.2, align: 'center' },
const getTableColumns = (
summary?: CustomerPaymentReport['summary']
): PdfColumn<CustomerPaymentReport['rows'][number]>[] => [
{
key: 'no',
header: 'No',
flex: 0.5,
align: 'center',
cell: ({ row, index }) => index + 1,
footer: 'Total',
},
{
key: 'trans_date',
header: 'Tanggal DO',
flex: 1.2,
align: 'center',
cell: ({ row }) =>
row.trans_date ? formatDate(row.trans_date, 'DD MMM YY') : '-',
footer: '',
},
{
key: 'delivery_date',
header: 'Tanggal Realisasi',
flex: 1.2,
align: 'center',
cell: ({ row }) =>
row.delivery_date ? formatDate(row.delivery_date, 'DD MMM YY') : '-',
footer: '',
},
{ key: 'aging', header: 'Aging', flex: 0.8, align: 'center' },
{ key: 'reference', header: 'Referensi', flex: 1.5, align: 'left' },
{ key: 'vehicle_numbers', header: 'No Polisi', flex: 1.2, align: 'left' },
{ key: 'qty', header: 'Qty', flex: 0.8, align: 'right' },
{ key: 'weight', header: 'Berat', flex: 1, align: 'right' },
{ key: 'average_weight', header: 'Rata-Rata', flex: 0.8, align: 'right' },
{ key: 'unit_price', header: 'Harga/Unit', flex: 1.2, align: 'right' },
{ key: 'final_price', header: 'Harga Akhir', flex: 1.2, align: 'right' },
{ key: 'total_price', header: 'Total', flex: 1.2, align: 'right' },
{ key: 'payment_amount', header: 'Pembayaran', flex: 1.2, align: 'right' },
{ key: 'accounts_receivable', header: 'Saldo', flex: 1.2, align: 'right' },
{ key: 'status', header: 'Keterangan', flex: 1.5, align: 'center' },
{ key: 'pickup_info', header: 'Pengambilan', flex: 1, align: 'left' },
{ key: 'sales_person', header: 'Sales', flex: 1.5, align: 'left' },
];
const getTableData = (
rows: CustomerPaymentReport['rows']
): PdfTbodyCell[][] => {
return rows.map((item, index) => [
{ key: 'no', value: index + 1 },
{
key: 'trans_date',
value: item.trans_date ? formatDate(item.trans_date, 'DD MMM YY') : '-',
},
{
key: 'delivery_date',
value: item.delivery_date
? formatDate(item.delivery_date, 'DD MMM YY')
{
key: 'aging',
header: 'Aging',
flex: 0.8,
align: 'center',
cell: ({ row }) =>
row.aging_day != null ? `${formatNumber(row.aging_day)} hari` : '-',
footer: '',
},
{
key: 'reference',
header: 'Referensi',
flex: 1.5,
align: 'left',
cell: ({ row }) => row.reference || '-',
footer: '',
},
{
key: 'vehicle_numbers',
header: 'No Polisi',
flex: 1.2,
align: 'left',
cell: ({ row }) =>
Array.isArray(row.vehicle_numbers) && row.vehicle_numbers.length > 0
? row.vehicle_numbers.join(', ')
: '-',
},
{
key: 'aging',
value:
item.aging_day != null ? `${formatNumber(item.aging_day)} hari` : '-',
},
{ key: 'reference', value: item.reference || '-' },
{
key: 'vehicle_numbers',
value:
Array.isArray(item.vehicle_numbers) && item.vehicle_numbers.length > 0
? item.vehicle_numbers.join(', ')
: '-',
},
{ key: 'qty', value: formatNumber(item.qty), align: 'right' },
{ key: 'weight', value: formatNumber(item.weight), align: 'right' },
{
key: 'average_weight',
value: formatNumber(item.average_weight),
align: 'right',
},
{
key: 'unit_price',
value: formatCurrency(item.unit_price),
align: 'right',
},
{
key: 'final_price',
value: formatCurrency(item.final_price),
align: 'right',
},
{
key: 'total_price',
value: formatCurrency(item.total_price),
align: 'right',
},
{
key: 'payment_amount',
value: formatCurrency(item.payment_amount),
align: 'right',
},
{
key: 'accounts_receivable',
value: formatCurrency(item.accounts_receivable),
align: 'right',
color: item.accounts_receivable < 0 ? '#DC2626' : undefined,
},
{
key: 'status',
value: item.status ? (
footer: '',
},
{
key: 'qty',
header: 'Qty',
flex: 0.8,
align: 'right',
cell: ({ row }) => formatNumber(row.qty),
footer: summary ? formatNumber(summary.total_qty || 0) : '',
footerAlign: 'right',
},
{
key: 'weight',
header: 'Berat',
flex: 1,
align: 'right',
cell: ({ row }) => formatNumber(row.weight),
footer: summary ? formatNumber(summary.total_weight || 0) : '',
footerAlign: 'right',
},
{
key: 'average_weight',
header: 'Rata-Rata',
flex: 0.8,
align: 'right',
cell: ({ row }) => formatNumber(row.average_weight),
footer: '',
},
{
key: 'unit_price',
header: 'Harga/Unit (Rp)',
flex: 1.2,
align: 'right',
cell: ({ row }) => formatCurrency(row.unit_price),
footer: '',
},
{
key: 'final_price',
header: 'Harga Akhir (Rp)',
flex: 1.2,
align: 'right',
cell: ({ row }) => formatCurrency(row.final_price),
footer: summary ? formatCurrency(summary.total_final_amount || 0) : '',
footerAlign: 'right',
},
{
key: 'total_price',
header: 'Total (Rp)',
flex: 1.2,
align: 'right',
cell: ({ row }) => formatCurrency(row.total_price),
footer: summary ? formatCurrency(summary.total_grand_amount || 0) : '',
footerAlign: 'right',
},
{
key: 'payment_amount',
header: 'Pembayaran (Rp)',
flex: 1.2,
align: 'right',
cell: ({ row }) => formatCurrency(row.payment_amount),
footer: summary ? formatCurrency(summary.total_payment || 0) : '',
footerAlign: 'right',
},
{
key: 'accounts_receivable',
header: 'Saldo (Rp)',
flex: 1.2,
align: 'right',
cell: ({ row }) => (
<Text style={{ color: row.accounts_receivable < 0 ? 'red' : 'black' }}>
{formatCurrency(row.accounts_receivable)}
</Text>
),
footer: summary
? formatCurrency(summary.total_accounts_receivable || 0)
: '',
footerAlign: 'right',
footerColor:
(summary?.total_accounts_receivable || 0) < 0 ? 'red' : undefined,
},
{
key: 'status',
header: 'Keterangan',
flex: 1.5,
align: 'center',
cell: ({ row }) =>
row.status ? (
<View style={{ alignItems: 'center' }}>
<PdfStatusBadge
backgroundColor={item.status === 'LUNAS' ? '#DBEAFE' : '#FEE2E2'}
textColor={item.status === 'LUNAS' ? '#1E40AF' : '#991B1B'}
borderColor={item.status === 'LUNAS' ? '#60A5FA' : '#F87171'}
style={{
backgroundColor: getPDFBadgeStyle(row.status, 'payment').bg,
color: getPDFBadgeStyle(row.status, 'payment').text,
borderColor: getPDFBadgeStyle(row.status, 'payment').border,
}}
>
{item.status === 'LUNAS' ? 'Lunas' : 'Belum Lunas'}
{formatTitleCase(row.status)}
</PdfStatusBadge>
</View>
) : (
'-'
),
},
{
key: 'pickup_info',
value:
Array.isArray(item.pickup_info) && item.pickup_info.length > 0
? item.pickup_info.join(', ')
: '-',
},
{ key: 'sales_person', value: item.sales_person || '-' },
]);
};
const getTableFooter = (
summary: CustomerPaymentReport['summary']
): PdfTfootCell[] => [
{ key: 'no', value: 'Total' },
{ key: 'trans_date', value: '' },
{ key: 'delivery_date', value: '' },
{ key: 'aging', value: '' },
{ key: 'reference', value: '' },
{ key: 'vehicle_numbers', value: '' },
{ key: 'qty', value: formatNumber(summary?.total_qty || 0), align: 'right' },
{
key: 'weight',
value: formatNumber(summary?.total_weight || 0),
align: 'right',
},
{ key: 'average_weight', value: '' },
{ key: 'unit_price', value: '' },
{
key: 'final_price',
value: formatCurrency(summary?.total_final_amount || 0),
align: 'right',
footer: '',
},
{
key: 'total_price',
value: formatCurrency(summary?.total_grand_amount || 0),
align: 'right',
key: 'pickup_info',
header: 'Pengambilan',
flex: 1,
align: 'left',
cell: ({ row }) =>
Array.isArray(row.pickup_info) && row.pickup_info.length > 0
? row.pickup_info.join(', ')
: '-',
footer: '',
},
{
key: 'payment_amount',
value: formatCurrency(summary?.total_payment || 0),
align: 'right',
key: 'sales_person',
header: 'Sales',
flex: 1.5,
align: 'left',
cell: ({ row }) => row.sales_person || '-',
footer: '',
},
{
key: 'accounts_receivable',
value: formatCurrency(summary?.total_accounts_receivable || 0),
align: 'right',
color:
(summary?.total_accounts_receivable || 0) < 0 ? '#DC2626' : undefined,
},
{ key: 'status', value: '' },
{ key: 'pickup_info', value: '' },
{ key: 'sales_person', value: '' },
];
const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
@@ -259,13 +283,9 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
{/* Table */}
<PdfTable
columns={getTableColumns()}
data={getTableData(customerReport.rows)}
footer={
customerReport.summary
? getTableFooter(customerReport.summary)
: undefined
}
columns={getTableColumns(customerReport.summary)}
data={customerReport.rows}
showFooter={!!customerReport.summary}
firstRow={
typeof customerReport.initial_balance === 'number' &&
customerReport.initial_balance !== 0
@@ -273,8 +293,7 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
valueKey: 'accounts_receivable',
value: customerReport.initial_balance,
align: 'right',
color:
customerReport.initial_balance < 0 ? '#DC2626' : 'black',
color: customerReport.initial_balance < 0 ? 'red' : 'black',
}
: undefined
}
@@ -27,11 +27,11 @@ export const generateCustomerPaymentExcel = async (
{ header: 'Ekor/Qty', key: 'qty', width: 10 },
{ header: 'Berat (Kg)', key: 'weight', width: 12 },
{ header: 'AVG', key: 'avgWeight', width: 10 },
{ header: 'Harga/Unit', key: 'unitPrice', width: 15 },
{ header: 'Harga Akhir', key: 'finalPrice', width: 15 },
{ header: 'Total', key: 'totalPrice', width: 15 },
{ header: 'Pembayaran', key: 'paymentAmount', width: 15 },
{ header: 'Saldo Piutang', key: 'accountsReceivable', width: 15 },
{ header: 'Harga/Unit (Rp)', key: 'unitPrice', width: 15 },
{ header: 'Harga Akhir (Rp)', key: 'finalPrice', width: 15 },
{ header: 'Total (Rp)', key: 'totalPrice', width: 15 },
{ header: 'Pembayaran (Rp)', key: 'paymentAmount', width: 15 },
{ header: 'Saldo Piutang (Rp)', key: 'accountsReceivable', width: 15 },
{ header: 'Keterangan', key: 'status', width: 20 },
{ header: 'Pengambilan', key: 'pickupInfo', width: 15 },
{ header: 'Sales/Marketing', key: 'salesPerson', width: 20 },
@@ -14,59 +14,15 @@ import { DebtSupplier } from '@/types/api/report/debt-supplier';
import { PdfParamBadge } from '@/components/helper/pdf/badge/PdfParamBadge';
import { PdfTypography } from '@/components/helper/pdf/typography/PdfTypography';
import { PdfStatusBadge } from '@/components/helper/pdf/badge/PdfStatusBadge';
import {
PdfTable,
PdfColumn,
PdfTbodyCell,
PdfTfootCell,
} from '@/components/helper/pdf/table';
import { PdfTable, PdfColumn } from '@/components/helper/pdf/table';
import { getPDFBadgeStyle } from '@/components/helper/pdf/utils/pdf-badge';
import { Text } from '@react-pdf/renderer';
Font.register({
family: 'Helvetica',
src: 'helvetica',
});
// Status color mappings (same as in DebtSupplierTab)
const dueStatusColors: Record<
string,
{ bg: string; text: string; border: string }
> = {
'Sudah Jatuh Tempo': { bg: '#FEE2E2', text: '#991B1B', border: '#F87171' }, // error/red
'Belum Jatuh Tempo': { bg: '#D1FAE5', text: '#065F46', border: '#34D399' }, // success/green
'Mendekati Jatuh Tempo': {
bg: '#FEF3C7',
text: '#92400E',
border: '#FBBF24',
}, // warning/yellow
};
const paymentStatusColors: Record<
string,
{ bg: string; text: string; border: string }
> = {
'Belum Lunas': { bg: '#FEF3C7', text: '#92400E', border: '#FBBF24' }, // warning/yellow
Lunas: { bg: '#DBEAFE', text: '#1E40AF', border: '#60A5FA' }, // primary/blue
Pembayaran: { bg: '#D1FAE5', text: '#065F46', border: '#34D399' }, // success/green
};
/**
* Get badge style for PDF rendering
* @param statusText - The status text
* @param type - Type of status: 'due' or 'payment'
* @returns Style object with background and text colors
*/
const getPDFBadgeStyle = (
statusText: string,
type: 'due' | 'payment' = 'payment'
) => {
const colors =
type === 'due'
? dueStatusColors[statusText]
: paymentStatusColors[statusText];
return colors || { bg: '#F3F4F6', text: '#374151', border: '#D1D5DB' }; // neutral fallback
};
const pdfStyles = StyleSheet.create({
page: {
fontSize: 10,
@@ -84,153 +40,225 @@ const pdfStyles = StyleSheet.create({
},
});
const getTableColumns = (): PdfColumn[] => [
{ key: 'no', header: 'No', flex: 0.5, align: 'center' },
{ key: 'pr_number', header: 'No. PR', flex: 1, align: 'left' },
{ key: 'po_number', header: 'No. PO', flex: 1, align: 'left' },
{
key: 'received_date',
header: 'Tgl Terima/Bayar',
flex: 0.7,
align: 'center',
},
{ key: 'po_date', header: 'Tgl PO', flex: 0.7, align: 'center' },
{ key: 'aging', header: 'Aging', flex: 0.6, align: 'center' },
{ key: 'area', header: 'Area', flex: 1, align: 'left' },
{ key: 'warehouse', header: 'Gudang', flex: 1, align: 'left' },
{ key: 'due_date', header: 'Jatuh Tempo', flex: 1, align: 'center' },
{ key: 'due_status', header: 'Status Jatuh Tempo', flex: 2, align: 'left' },
{
key: 'total_price',
header: 'Nominal Pembelian (Rp)',
flex: 1.5,
align: 'right',
},
{
key: 'payment_price',
header: 'Pembayaran (Rp)',
flex: 1.5,
align: 'right',
},
{
key: 'balance',
header: 'Sisa Saldo Hutang (Rp)',
flex: 1.5,
align: 'right',
},
{ key: 'status', header: 'Status', flex: 1.2, align: 'center' },
{ key: 'travel_number', header: 'No. Perjalanan', flex: 1, align: 'left' },
];
const getTableColumns = (total?: DebtSupplier['total']): PdfColumn[] => {
type DebtRow = DebtSupplier['rows'][number];
const getTableData = (rows: DebtSupplier['rows']): PdfTbodyCell[][] => {
return rows.map((item, index) => [
{ key: 'no', value: index + 1 },
{ key: 'pr_number', value: item.pr_number || '-' },
{ key: 'po_number', value: item.po_number || '-' },
return [
{
key: 'no',
header: 'No',
flex: 0.5,
align: 'center',
cell: ({ row, index }) => index + 1,
footer: 'Total',
},
{
key: 'pr_number',
header: 'No. PR',
flex: 1,
align: 'left',
cell: ({ row }) => (row as unknown as DebtRow).pr_number || '-',
footer: '',
},
{
key: 'po_number',
header: 'No. PO',
flex: 1,
align: 'left',
cell: ({ row }) => (row as unknown as DebtRow).po_number || '-',
footer: '',
},
{
key: 'received_date',
value: item.received_date
? formatDate(item.received_date, 'DD MMM YY')
: '-',
header: 'Tgl Terima/Bayar',
flex: 0.7,
align: 'center',
cell: ({ row }) =>
(row as unknown as DebtRow).received_date
? formatDate((row as unknown as DebtRow).received_date, 'DD MMM YY')
: '-',
footer: '',
},
{
key: 'po_date',
value: item.po_date ? formatDate(item.po_date, 'DD MMM YY') : '-',
header: 'Tgl PO',
flex: 0.7,
align: 'center',
cell: ({ row }) =>
(row as unknown as DebtRow).po_date
? formatDate((row as unknown as DebtRow).po_date, 'DD MMM YY')
: '-',
footer: '',
},
{
key: 'aging',
value: item.aging != null ? `${formatNumber(item.aging)}` : '-',
header: 'Aging',
flex: 0.6,
align: 'center',
cell: ({ row }) =>
(row as unknown as DebtRow).aging != null
? `${formatNumber((row as unknown as DebtRow).aging)}`
: '-',
footer: total ? formatNumber(total.aging || 0) + ' Hari' : '',
},
{
key: 'area',
header: 'Area',
flex: 1,
align: 'left',
cell: ({ row }) => (row as unknown as DebtRow).area?.name || '-',
footer: '',
},
{
key: 'warehouse',
header: 'Gudang',
flex: 1,
align: 'left',
cell: ({ row }) => (row as unknown as DebtRow).warehouse?.name || '-',
footer: '',
},
{ key: 'area', value: item.area?.name || '-' },
{ key: 'warehouse', value: item.warehouse?.name || '-' },
{
key: 'due_date',
value: item.due_date ? formatDate(item.due_date, 'DD MMM YY') : '-',
header: 'Jatuh Tempo',
flex: 1,
align: 'center',
cell: ({ row }) =>
(row as unknown as DebtRow).due_date
? formatDate((row as unknown as DebtRow).due_date, 'DD MMM YY')
: '-',
footer: '',
},
{
key: 'due_status',
value:
item.due_status && item.due_status !== '-' ? (
<PdfStatusBadge
backgroundColor={getPDFBadgeStyle(item.due_status, 'due').bg}
textColor={getPDFBadgeStyle(item.due_status, 'due').text}
borderColor={getPDFBadgeStyle(item.due_status, 'due').border}
>
{item.due_status}
</PdfStatusBadge>
) : (
'-'
),
},
{
key: 'total_price',
value: formatCurrency(item.total_price),
align: 'right',
color: item.total_price < 0 ? 'red' : undefined,
},
{
key: 'payment_price',
value: formatCurrency(item.payment_price),
align: 'right',
color: item.payment_price < 0 ? 'red' : undefined,
},
{
key: 'balance',
value: formatCurrency(item.balance),
align: 'right',
color: item.balance < 0 ? 'red' : undefined,
},
{
key: 'status',
value:
item.status && item.status !== '-' ? (
header: 'Status Jatuh Tempo',
flex: 2,
align: 'center',
cell: ({ row }) =>
(row as unknown as DebtRow).due_status &&
(row as unknown as DebtRow).due_status !== '-' ? (
<View style={{ alignItems: 'center' }}>
<PdfStatusBadge
backgroundColor={getPDFBadgeStyle(item.status, 'payment').bg}
textColor={getPDFBadgeStyle(item.status, 'payment').text}
borderColor={getPDFBadgeStyle(item.status, 'payment').border}
style={{
backgroundColor: getPDFBadgeStyle(
(row as unknown as DebtRow).due_status,
'due'
).bg,
color: getPDFBadgeStyle(
(row as unknown as DebtRow).due_status,
'due'
).text,
borderColor: getPDFBadgeStyle(
(row as unknown as DebtRow).due_status,
'due'
).border,
}}
>
{item.status}
{(row as unknown as DebtRow).due_status}
</PdfStatusBadge>
</View>
) : (
'-'
),
footer: '',
},
{ key: 'travel_number', value: item.travel_number || '-' },
]);
{
key: 'total_price',
header: 'Nominal Pembelian (Rp)',
flex: 1.5,
align: 'right',
cell: ({ row }) => (
<Text
style={{
color:
(row as unknown as DebtRow).total_price < 0 ? 'red' : 'black',
}}
>
{formatCurrency((row as unknown as DebtRow).total_price)}
</Text>
),
footer: total ? formatCurrency(total.total_price || 0) : '',
footerAlign: 'right',
},
{
key: 'payment_price',
header: 'Pembayaran (Rp)',
flex: 1.5,
align: 'right',
cell: ({ row }) => (
<Text
style={{
color:
(row as unknown as DebtRow).payment_price < 0 ? 'red' : 'black',
}}
>
{formatCurrency((row as unknown as DebtRow).payment_price)}
</Text>
),
footer: total ? formatCurrency(total.payment_price || 0) : '',
footerAlign: 'right',
},
{
key: 'balance',
header: 'Sisa Saldo Hutang (Rp)',
flex: 1.5,
align: 'right',
cell: ({ row }) => (
<Text
style={{
color: (row as unknown as DebtRow).balance < 0 ? 'red' : 'black',
}}
>
{formatCurrency((row as unknown as DebtRow).balance)}
</Text>
),
footer: total ? formatCurrency(total.debt_price || 0) : '',
footerAlign: 'right',
footerColor: (total?.debt_price || 0) < 0 ? 'red' : undefined,
},
{
key: 'status',
header: 'Status',
flex: 1.2,
align: 'center',
cell: ({ row }) =>
(row as unknown as DebtRow).status &&
(row as unknown as DebtRow).status !== '-' ? (
<View style={{ alignItems: 'center' }}>
<PdfStatusBadge
style={{
backgroundColor: getPDFBadgeStyle(
(row as unknown as DebtRow).status,
'payment'
).bg,
color: getPDFBadgeStyle(
(row as unknown as DebtRow).status,
'payment'
).text,
borderColor: getPDFBadgeStyle(
(row as unknown as DebtRow).status,
'payment'
).border,
}}
>
{(row as unknown as DebtRow).status}
</PdfStatusBadge>
</View>
) : (
'-'
),
footer: '',
},
{
key: 'travel_number',
header: 'No. Perjalanan',
flex: 1,
align: 'left',
cell: ({ row }) => (row as unknown as DebtRow).travel_number || '-',
footer: '',
},
];
};
const getTableFooter = (total: DebtSupplier['total']): PdfTfootCell[] => [
{ key: 'no', value: 'Total' },
{ key: 'pr_number', value: '' },
{ key: 'po_number', value: '' },
{ key: 'received_date', value: '' },
{ key: 'po_date', value: '' },
{ key: 'aging', value: formatNumber(total?.aging || 0) + ' Hari' },
{ key: 'area', value: '' },
{ key: 'warehouse', value: '' },
{ key: 'due_date', value: '' },
{ key: 'due_status', value: '' },
{
key: 'total_price',
value: formatCurrency(total?.total_price || 0),
align: 'right',
},
{
key: 'payment_price',
value: formatCurrency(total?.payment_price || 0),
align: 'right',
},
{
key: 'balance',
value: formatCurrency(total?.debt_price || 0),
align: 'right',
},
{ key: 'status', value: '' },
{ key: 'travel_number', value: '' },
];
interface DebtSupplierExportPDFParams {
data: DebtSupplier[];
params?: {
@@ -296,13 +324,9 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
{/* Table */}
<PdfTable
columns={getTableColumns()}
data={getTableData(supplierReport.rows)}
footer={
supplierReport.total
? getTableFooter(supplierReport.total)
: undefined
}
columns={getTableColumns(supplierReport.total)}
data={supplierReport.rows as unknown as Record<string, unknown>[]}
showFooter={!!supplierReport.total}
firstRow={
typeof supplierReport.initial_balance === 'number' &&
supplierReport.initial_balance !== 0
@@ -1,279 +0,0 @@
'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';
import {
PdfTable,
PdfColumn,
PdfTbodyCell,
} from '@/components/helper/pdf/table';
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',
},
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,
},
supplierSection: {
marginBottom: 10,
},
supplierSectionBreak: {
marginBottom: 15,
},
});
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;
};
// Helper functions for PdfTable
const getTableColumns = (): PdfColumn[] => [
{ key: 'no', header: 'No', flex: 0.5, align: 'center' },
{ key: 'receive_date', header: 'Tanggal Terima', flex: 1, align: 'center' },
{ key: 'po_date', header: 'Tanggal PO', flex: 1, align: 'center' },
{ key: 'po_number', header: 'Referensi', flex: 1, align: 'left' },
{ key: 'product', header: 'Produk', flex: 1, align: 'left' },
{ key: 'warehouse', header: 'Tujuan', flex: 1, align: 'left' },
{ key: 'qty', header: 'Qty', flex: 0.8, align: 'right' },
{ key: 'unit_price', header: 'Harga Beli', flex: 1.2, align: 'right' },
{
key: 'purchase_value',
header: 'Nilai Pembelian',
flex: 1.5,
align: 'right',
},
{
key: 'transport_price',
header: 'Biaya Transport',
flex: 1.2,
align: 'right',
},
{ key: 'total_amount', header: 'Total', flex: 1.5, align: 'right' },
{ key: 'expedition', header: 'Armada', flex: 1.2, align: 'center' },
{ key: 'delivery_number', header: 'Surat Jalan', flex: 1, align: 'left' },
];
const getTableData = (
rows: LogisticPurchasePerSupplierReport['rows']
): PdfTbodyCell[][] => {
return rows.map((item, index) => [
{ key: 'no', value: index + 1, align: 'center' },
{
key: 'receive_date',
value: formatDate(item.receive_date, 'DD-MMM-YYYY'),
align: 'center',
},
{
key: 'po_date',
value: formatDate(item.po_date, 'DD-MMM-YYYY'),
align: 'center',
},
{ key: 'po_number', value: item.po_number || '-' },
{ key: 'product', value: item.product?.name || '-' },
{ key: 'warehouse', value: item.warehouse?.name || '-' },
{ key: 'qty', value: formatNumber(item.qty || 0), align: 'right' },
{
key: 'unit_price',
value: formatCurrency(item.unit_price || 0),
align: 'right',
},
{
key: 'purchase_value',
value: formatCurrency(item.purchase_value || 0),
align: 'right',
},
{
key: 'transport_price',
value: formatCurrency(item.transport_unit_price || 0),
align: 'right',
},
{
key: 'total_amount',
value: formatCurrency(item.total_amount || 0),
align: 'right',
},
{
key: 'expedition',
value: (
<View style={pdfStyles.badge}>
<Text>{item.expedition || '-'}</Text>
</View>
),
align: 'center',
},
{ key: 'delivery_number', value: item.delivery_number || '-' },
]);
};
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>
<PdfTable
columns={getTableColumns()}
data={getTableData(supplierReport.rows)}
/>
</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,279 @@
'use client';
import {
Page,
View,
Document,
StyleSheet,
Font,
pdf,
} from '@react-pdf/renderer';
import { LogisticPurchasePerSupplierReport } from '@/types/api/report/logistic-stock';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
import { PdfTable, PdfColumn } from '@/components/helper/pdf/table';
import { PdfParamBadge } from '@/components/helper/pdf/badge/PdfParamBadge';
import { PdfStatusBadge } from '@/components/helper/pdf/badge/PdfStatusBadge';
import { PdfTypography } from '@/components/helper/pdf/typography/PdfTypography';
Font.register({
family: 'Helvetica',
src: 'helvetica',
});
const pdfStyles = StyleSheet.create({
page: {
fontSize: 10,
fontFamily: 'Helvetica',
padding: 20,
backgroundColor: '#FFFFFF',
},
titleSection: {
marginBottom: 10,
},
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 getTableColumns = (
summary?: LogisticPurchasePerSupplierReport['summary']
): PdfColumn<LogisticPurchasePerSupplierReport['rows'][number]>[] => [
{
key: 'no',
header: 'No',
flex: 0.5,
align: 'center',
cell: ({ row, index }) => index + 1,
footer: 'Total',
},
{
key: 'receive_date',
header: 'Tanggal Terima',
flex: 1.2,
align: 'center',
cell: ({ row }) =>
row.receive_date ? formatDate(row.receive_date, 'DD MMM YY') : '-',
footer: '',
},
{
key: 'po_date',
header: 'Tanggal PO',
flex: 1.2,
align: 'center',
cell: ({ row }) =>
row.po_date ? formatDate(row.po_date, 'DD MMM YY') : '-',
footer: '',
},
{
key: 'po_number',
header: 'No. Referensi',
flex: 1.5,
align: 'left',
cell: ({ row }) => row.po_number || '-',
footer: '',
},
{
key: 'product',
header: 'Nama Produk',
flex: 2,
align: 'left',
cell: ({ row }) => row.product?.name || '-',
footer: '',
},
{
key: 'warehouse',
header: 'Tujuan',
flex: 1.5,
align: 'left',
cell: ({ row }) => row.warehouse?.name || '-',
footer: '',
},
{
key: 'qty',
header: 'QTY',
flex: 0.8,
align: 'right',
cell: ({ row }) => formatNumber(row.qty || 0),
footer: summary ? formatNumber(summary.total_qty || 0) : '',
footerAlign: 'right',
},
{
key: 'unit_price',
header: 'Harga Beli (Rp)',
flex: 1.5,
align: 'right',
cell: ({ row }) => formatCurrency(row.unit_price || 0),
footer: '',
},
{
key: 'purchase_value',
header: 'Value Harga Beli (Rp)',
flex: 1.8,
align: 'right',
cell: ({ row }) => formatCurrency(row.purchase_value || 0),
footer: summary ? formatCurrency(summary.total_purchase_value || 0) : '',
footerAlign: 'right',
},
{
key: 'transport_unit_price',
header: 'Transport (Rp)',
flex: 1.3,
align: 'right',
cell: ({ row }) => formatCurrency(row.transport_unit_price || 0),
footer: '',
},
{
key: 'transport_value',
header: 'Value Transport (Rp)',
flex: 1.8,
align: 'right',
cell: ({ row }) => formatCurrency(row.transport_value || 0),
footer: summary ? formatCurrency(summary.total_transport_value || 0) : '',
footerAlign: 'right',
},
{
key: 'total_amount',
header: 'Jumlah (Rp)',
flex: 1.5,
align: 'right',
cell: ({ row }) => formatCurrency(row.total_amount || 0),
footer: summary ? formatCurrency(summary.total_amount || 0) : '',
footerAlign: 'right',
},
{
key: 'expedition',
header: 'Ekspedisi',
flex: 1.2,
align: 'center',
cell: ({ row }) =>
row.expedition ? (
<View style={{ alignItems: 'center' }}>
<PdfStatusBadge
style={{
backgroundColor: '#DBEAFE',
color: '#1E40AF',
borderColor: '#60A5FA',
}}
>
{row.expedition}
</PdfStatusBadge>
</View>
) : (
'-'
),
footer: '',
},
{
key: 'delivery_number',
header: 'Surat Jalan',
flex: 1.2,
align: 'left',
cell: ({ row }) => row.delivery_number || '-',
footer: '',
},
];
const createPDFDocument = (params: PurchasesPerSupplierExportParams) => {
return (
<Document>
{params.data.map((supplierReport, supplierIndex) => (
<Page
key={supplierIndex}
size='A3'
orientation='landscape'
style={pdfStyles.page}
>
{/* Title and Parameters */}
<View style={pdfStyles.titleSection}>
<PdfTypography size='h1' variant='primary'>
Laporan &gt; Rekapitulasi Pembelian Per Supplier
</PdfTypography>
<View style={pdfStyles.parameterContainer}>
<PdfParamBadge>
Jenis Tanggal:{' '}
{params.params?.filter_by === 'received_date'
? 'Tanggal Terima'
: 'Tanggal PO'}
</PdfParamBadge>
<PdfParamBadge>
Periode:{' '}
{params.params?.start_date
? formatDate(params.params.start_date, 'DD MMM YYYY')
: '-'}{' '}
s.d{' '}
{params.params?.end_date
? formatDate(params.params.end_date, 'DD MMM YYYY')
: '-'}
</PdfParamBadge>
<PdfParamBadge>
Supplier: {params.params?.supplier_name || 'Semua Supplier'}
</PdfParamBadge>
<PdfParamBadge>
Area: {params.params?.area_name || 'Semua Area'}
</PdfParamBadge>
<PdfParamBadge>
Produk: {params.params?.product_name || 'Semua Produk'}
</PdfParamBadge>
<PdfParamBadge>
Dicetak: {formatDate(new Date(), 'DD MMM YYYY HH:mm')}
</PdfParamBadge>
</View>
<PdfTypography size='h2' variant='primary'>
{supplierReport.supplier.name}
</PdfTypography>
{supplierReport.supplier.address && (
<PdfTypography size='label'>
Alamat: {supplierReport.supplier.address}
</PdfTypography>
)}
</View>
{/* Table */}
<PdfTable
columns={getTableColumns(supplierReport.summary)}
data={supplierReport.rows}
showFooter={!!supplierReport.summary}
/>
</Page>
))}
</Document>
);
};
export const generatePurchasesPerSupplierPDF = async (
params: PurchasesPerSupplierExportParams
): Promise<void> => {
const PDFDocument = createPDFDocument(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,101 @@
'use client';
import ExcelJS from 'exceljs';
import { formatDate, formatCurrency, formatNumber } from '@/lib/helper';
import { LogisticPurchasePerSupplierReport } from '@/types/api/report/logistic-stock';
interface PurchasesPerSupplierExportExcelParams {
data: LogisticPurchasePerSupplierReport[];
}
export const generatePurchasesPerSupplierExcel = async (
params: PurchasesPerSupplierExportExcelParams
): Promise<void> => {
if (!params.data || params.data.length === 0) {
return;
}
const workbook = new ExcelJS.Workbook();
const columns = [
{ header: 'No', key: 'no', width: 5 },
{ header: 'Tanggal Terima', key: 'receiveDate', width: 15 },
{ header: 'Tanggal PO', key: 'poDate', width: 15 },
{ header: 'No. Referensi', key: 'poNumber', width: 15 },
{ header: 'Nama Produk', key: 'productName', width: 30 },
{ header: 'Tujuan', key: 'warehouse', width: 20 },
{ header: 'QTY', key: 'qty', width: 10 },
{ header: 'Harga Beli (Rp)', key: 'unitPrice', width: 18 },
{ header: 'Value Harga Beli (Rp)', key: 'purchaseValue', width: 20 },
{ header: 'Transport (Rp)', key: 'transportUnitPrice', width: 15 },
{ header: 'Value Transport (Rp)', key: 'transportValue', width: 20 },
{ header: 'Jumlah (Rp)', key: 'totalAmount', width: 18 },
{ header: 'Ekspedisi', key: 'expedition', width: 15 },
{ header: 'Surat Jalan', key: 'deliveryNumber', width: 15 },
];
for (const supplierReport of params.data) {
const supplierData = supplierReport.rows;
const supplierName = supplierReport.supplier?.name || 'Unknown Supplier';
const worksheet = workbook.addWorksheet(supplierName.substring(0, 31));
worksheet.columns = columns;
supplierData.forEach((item, index) => {
worksheet.addRow({
no: index + 1,
receiveDate: item.receive_date
? formatDate(item.receive_date, 'DD MMM YYYY')
: '',
poDate: item.po_date ? formatDate(item.po_date, 'DD MMM YYYY') : '',
poNumber: item.po_number || '',
productName: item.product?.name || '',
warehouse: item.warehouse?.name || '',
qty: formatNumber(item.qty || 0),
unitPrice: formatCurrency(item.unit_price || 0),
purchaseValue: formatCurrency(item.purchase_value || 0),
transportUnitPrice: formatCurrency(item.transport_unit_price || 0),
transportValue: formatCurrency(item.transport_value || 0),
totalAmount: formatCurrency(item.total_amount || 0),
expedition: item.expedition || '',
deliveryNumber: item.delivery_number || '',
});
});
if (supplierReport.summary) {
worksheet.addRow({
no: 'Total',
receiveDate: '',
poDate: '',
poNumber: '',
productName: '',
warehouse: '',
qty: formatNumber(supplierReport.summary.total_qty || 0),
unitPrice: '',
purchaseValue: formatCurrency(
supplierReport.summary.total_purchase_value || 0
),
transportUnitPrice: '',
transportValue: formatCurrency(
supplierReport.summary.total_transport_value || 0
),
totalAmount: formatCurrency(supplierReport.summary.total_amount || 0),
expedition: '',
deliveryNumber: '',
});
}
}
const filename = `laporan-pembelian-per-supplier-dicetak-pada-${formatDate(new Date(), 'YYYY-MM-DD-HHmm')}.xlsx`;
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
window.URL.revokeObjectURL(url);
};
@@ -26,9 +26,9 @@ 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 { generatePurchasesPerSupplierPDF } from '@/components/pages/report/logistic-stock/export/PurchasesPerSupplierExportPDF';
import { generatePurchasesPerSupplierExcel } from '@/components/pages/report/logistic-stock/export/PurchasesPerSupplierExportXLSX';
import toast from 'react-hot-toast';
import * as XLSX from 'xlsx';
import { Icon } from '@iconify/react';
const PurchasesPerSupplierTab = () => {
@@ -355,98 +355,14 @@ const PurchasesPerSupplierTab = () => {
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);
await generatePurchasesPerSupplierExcel({ data: allDataForExport });
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally {
setIsExcelExportLoading(false);
}
}, [
logisticPurchasePerSupplierExport,
tableFilterState,
areaOptions,
supplierOptions,
]);
}, [logisticPurchasePerSupplierExport]);
const handleExportPdf = useCallback(async () => {
setIsPdfExportLoading(true);
@@ -517,7 +433,10 @@ const PurchasesPerSupplierTab = () => {
end_date: tableFilterState.end_date || '',
};
await generatePurchasesPerSupplierPDF(allDataForExport, exportParams);
await generatePurchasesPerSupplierPDF({
data: allDataForExport,
params: exportParams,
});
toast.success('PDF berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat PDF. Silakan coba lagi.');
@@ -3,8 +3,8 @@
import { JSX, useState } from 'react';
import Tabs from '@/components/Tabs';
import DailyMarketingReportContent from '@/components/pages/report/DailyMarketingReportContent';
import HppPerKandangTab from './sale/tab/HppPerKandangTab';
import DailyMarketingReportContent from '@/components/pages/report/marketing/tab/DailyMarketingReportContent';
import HppPerKandangTab from '@/components/pages/report/marketing/tab/HppPerKandangTab';
type MarketingReportTabType =
| 'daily'
@@ -0,0 +1,270 @@
'use client';
import { Page, View, Document, StyleSheet, Font } from '@react-pdf/renderer';
import {
DailyMarketingReport,
SalesSummary,
} from '@/types/api/report/marketing';
import {
formatCurrency,
formatDate,
formatNumber,
formatTitleCase,
} from '@/lib/helper';
import { PdfTable, PdfColumn } from '@/components/helper/pdf/table';
import { PdfParamBadge } from '@/components/helper/pdf/badge/PdfParamBadge';
import { PdfStatusBadge } from '@/components/helper/pdf/badge/PdfStatusBadge';
import { PdfTypography } from '@/components/helper/pdf/typography/PdfTypography';
import { PdfPageNumber } from '@/components/helper/pdf/layout/PdfPageNumber';
Font.register({
family: 'Helvetica',
src: 'helvetica',
});
const pdfStyles = StyleSheet.create({
page: {
fontSize: 10,
fontFamily: 'Helvetica',
padding: 20,
backgroundColor: '#FFFFFF',
},
titleSection: {
marginBottom: 10,
},
parameterContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
marginBottom: 8,
},
});
interface DailyMarketingReportPDFProps {
data?: DailyMarketingReport;
total?: SalesSummary;
}
const getTableColumns = (
summary?: SalesSummary
): PdfColumn<DailyMarketingReport[number]>[] => [
{
key: 'no',
header: 'No',
flex: 0.5,
align: 'center',
cell: ({ row, index }) => index + 1,
},
{
key: 'so_date',
header: 'Tanggal Sales Order',
flex: 1.3,
align: 'center',
cell: ({ row }) =>
row.so_date ? formatDate(row.so_date, 'DD MMM YY') : '-',
},
{
key: 'do_date',
header: 'Tanggal Delivery Order',
flex: 1.3,
align: 'center',
cell: ({ row }) =>
row.realization_date
? formatDate(row.realization_date, 'DD MMM YY')
: '-',
},
{
key: 'aging',
header: 'Aging (Hari)',
flex: 0.7,
align: 'center',
cell: ({ row }) => row.aging_days ?? '-',
},
{
key: 'warehouse',
header: 'Gudang',
flex: 1.2,
align: 'left',
cell: ({ row }) => row.warehouse?.name ?? '-',
},
{
key: 'customer',
header: 'Pelanggan',
flex: 1.5,
align: 'left',
cell: ({ row }) => row.customer?.name ?? '-',
},
{
key: 'sales',
header: 'Sales',
flex: 1,
align: 'left',
cell: ({ row }) => row.sales?.name ?? '-',
},
{
key: 'product',
header: 'Produk',
flex: 1.3,
align: 'left',
cell: ({ row }) => row.product?.name ?? '-',
},
{
key: 'do_number',
header: 'Nomor DO',
flex: 1.2,
align: 'left',
cell: ({ row }) => row.do_number ?? '-',
},
{
key: 'vehicle',
header: 'Nomor Polisi',
flex: 1,
align: 'left',
cell: ({ row }) => row.vehicle_number ?? '-',
},
{
key: 'marketing_type',
header: 'Tipe Marketing',
flex: 1,
align: 'center',
cell: ({ row }) =>
row.marketing_type ? (
<View style={{ alignItems: 'center' }}>
<PdfStatusBadge
style={{
backgroundColor:
row.marketing_type.toLowerCase() === 'ayam'
? '#FEF3C7'
: row.marketing_type.toLowerCase() === 'trading'
? '#DBEAFE'
: row.marketing_type.toLowerCase() === 'telur'
? '#D1FAE5'
: '#F5F5F5',
color:
row.marketing_type.toLowerCase() === 'ayam'
? '#92400E'
: row.marketing_type.toLowerCase() === 'trading'
? '#1E40AF'
: row.marketing_type.toLowerCase() === 'telur'
? '#065F46'
: '#333333',
borderColor:
row.marketing_type.toLowerCase() === 'ayam'
? '#FBBF24'
: row.marketing_type.toLowerCase() === 'trading'
? '#60A5FA'
: row.marketing_type.toLowerCase() === 'telur'
? '#34D399'
: '#E5E7EB',
}}
>
{formatTitleCase(row.marketing_type)}
</PdfStatusBadge>
</View>
) : (
'-'
),
},
{
key: 'qty',
header: 'Quantity',
flex: 0.7,
align: 'right',
cell: ({ row }) => formatNumber(row.qty ?? 0),
footer: summary ? formatNumber(summary.total_qty ?? 0) : '',
footerAlign: 'right',
},
{
key: 'avg_weight',
header: 'Rata-Rata (Kg)',
flex: 0.8,
align: 'right',
cell: ({ row }) => formatNumber(row.average_weight_kg ?? 0),
footer: summary ? formatNumber(summary.total_weight_kg ?? 0) : '',
footerAlign: 'right',
},
{
key: 'total_weight',
header: 'Total Berat (Kg)',
flex: 0.9,
align: 'right',
cell: ({ row }) => formatNumber(row.total_weight_kg ?? 0),
footer: summary ? formatNumber(summary.total_weight_kg ?? 0) : '',
footerAlign: 'right',
},
{
key: 'sales_price',
header: 'Harga Jual (Rp)',
flex: 0.9,
align: 'right',
cell: ({ row }) => formatCurrency(row.sales_price_per_kg ?? 0),
footer: '',
},
{
key: 'hpp_price',
header: 'HPP (Rp)',
flex: 1.3,
align: 'right',
cell: ({ row }) => formatCurrency(row.hpp_price_per_kg ?? 0),
footer: summary ? formatCurrency(summary.total_hpp_price_per_kg ?? 0) : '',
footerAlign: 'right',
},
{
key: 'sales_amount',
header: 'Total Jual (Rp)',
flex: 1,
align: 'right',
cell: ({ row }) => formatCurrency(row.sales_amount ?? 0),
footer: summary ? formatCurrency(summary.total_sales_amount ?? 0) : '',
footerAlign: 'right',
},
{
key: 'hpp_amount',
header: 'Total HPP (Rp)',
flex: 1.3,
align: 'right',
cell: ({ row }) => formatCurrency(row.hpp_amount ?? 0),
footer: summary ? formatCurrency(summary.total_hpp_amount ?? 0) : '',
footerAlign: 'right',
},
];
const DailyMarketingReportPDF = ({
data,
total,
}: DailyMarketingReportPDFProps) => {
const rows = data || [];
const summary = total;
return (
<Document>
<Page size='A3' orientation='landscape' style={pdfStyles.page}>
{/* Title and Parameters */}
<View style={pdfStyles.titleSection}>
<PdfTypography size='h1' variant='primary'>
Laporan &gt; Penjualan Harian
</PdfTypography>
<View style={pdfStyles.parameterContainer}>
<PdfParamBadge>
Tanggal: {formatDate(Date.now(), 'DD MMMM YYYY')}
</PdfParamBadge>
<PdfParamBadge>
Dicetak: {formatDate(new Date(), 'DD MMM YYYY HH:mm')}
</PdfParamBadge>
</View>
</View>
{/* Table */}
<PdfTable
columns={getTableColumns(summary)}
data={rows}
showFooter={!!summary}
footerLabel='TOTAL'
/>
<PdfPageNumber />
</Page>
</Document>
);
};
export default DailyMarketingReportPDF;
@@ -0,0 +1,357 @@
'use client';
import {
Page,
View,
Document,
StyleSheet,
Font,
pdf,
} from '@react-pdf/renderer';
import {
HppPerKandangReport,
HppPerKandangRow,
HppPerKandangPerWeightRange,
} from '@/types/api/report/hpp-per-kandang';
import { formatDate, formatNumber, formatCurrency } from '@/lib/helper';
import { PdfTable, PdfColumn } from '@/components/helper/pdf/table';
import { PdfParamBadge } from '@/components/helper/pdf/badge/PdfParamBadge';
import { PdfTypography } from '@/components/helper/pdf/typography/PdfTypography';
Font.register({
family: 'Helvetica',
src: 'helvetica',
});
const pdfStyles = StyleSheet.create({
page: {
fontSize: 10,
fontFamily: 'Helvetica',
padding: 20,
backgroundColor: '#FFFFFF',
},
titleSection: {
marginBottom: 10,
},
parameterContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
marginBottom: 8,
},
section: {
marginBottom: 15,
},
});
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;
};
}
const formatSuppliers = (
suppliers: { alias?: string; name: string }[] | null | undefined
): string => {
if (!suppliers || suppliers.length === 0) return '-';
return suppliers.map((s) => s.alias || s.name).join(' | ');
};
// Helper functions for PdfTable - Rekapitulasi
const getRekapitulasiColumns = (): PdfColumn<HppPerKandangPerWeightRange>[] => [
{
key: 'rentang_bw',
header: 'Rentang BW',
flex: 1.2,
align: 'center',
cell: ({ row }) => row.label || '-',
},
{
key: 'sisa_butir',
header: 'Sisa Butir',
flex: 1,
align: 'right',
cell: ({ row }) => formatNumber(row.egg_production_pieces || 0),
},
{
key: 'sisa_kg',
header: 'Sisa Kg',
flex: 1,
align: 'right',
cell: ({ row }) => formatNumber(row.egg_production_kg || 0),
},
{
key: 'rata_rata_bobot',
header: 'Rata-Rata Bobot (Kg)',
flex: 1.2,
align: 'right',
cell: ({ row }) => formatNumber(row.avg_weight_kg || 0),
},
{
key: 'feed_supplier',
header: 'Feed (Supplier)',
flex: 1.5,
align: 'left',
cell: ({ row }) => formatSuppliers(row.feed_suppliers),
},
{
key: 'doc_supplier',
header: 'DOC (Supplier)',
flex: 1.2,
align: 'left',
cell: ({ row }) => formatSuppliers(row.doc_suppliers),
},
{
key: 'rata_harga_doc',
header: 'Rata-Rata Harga DOC',
flex: 1.2,
align: 'right',
cell: ({ row }) => formatCurrency(row.average_doc_price_rp || 0),
},
{
key: 'hpp_telur',
header: 'HPP Telur (Rp/Kg)',
flex: 1.2,
align: 'right',
cell: ({ row }) => formatCurrency(row.egg_hpp_rp_per_kg || 0),
},
{
key: 'nominal_sisa',
header: 'Nominal Sisa',
flex: 1.2,
align: 'right',
cell: ({ row }) => formatCurrency(row.egg_value_rp || 0),
},
];
// Helper functions for PdfTable - Detail Per Kandang
const getDetailColumns = (
summary?: HppPerKandangReport['summary'],
allFeedSuppliers?: string,
allDocSuppliers?: string
): PdfColumn<HppPerKandangRow>[] => [
{
key: 'no',
header: 'No',
flex: 0.5,
align: 'center',
cell: ({ row, index }) => index + 1,
footer: 'TOTAL',
},
{
key: 'kandang',
header: 'Kandang',
flex: 1.5,
align: 'left',
cell: ({ row }) => row.kandang?.name || '-',
footer: 'ALL',
},
{
key: 'rentang_bw',
header: 'Rentang BW',
flex: 1,
align: 'left',
cell: ({ row }) =>
`${row.weight_range.weight_min.toFixed(2)} - ${row.weight_range.weight_max.toFixed(2)}`,
footer: '-',
},
{
key: 'rata_rata_bobot',
header: 'Rata-Rata Bobot (Kg)',
flex: 1,
align: 'right',
cell: ({ row }) => formatNumber(row.avg_weight_kg || 0),
footer: summary ? formatNumber(summary.total.average_weight_kg || 0) : '',
footerAlign: 'right',
},
{
key: 'sisa_butir',
header: 'Sisa Butir',
flex: 0.8,
align: 'right',
cell: ({ row }) => formatNumber(row.egg_production_pieces || 0),
footer: summary
? formatNumber(summary.total.total_egg_production_pieces || 0)
: '',
footerAlign: 'right',
},
{
key: 'sisa_kg',
header: 'Sisa Kg (Telur)',
flex: 0.8,
align: 'right',
cell: ({ row }) => formatNumber(row.egg_production_kg || 0),
footer: summary
? formatNumber(summary.total.total_egg_production_kg || 0)
: '',
footerAlign: 'right',
},
{
key: 'feed_supplier',
header: 'Feed (Supplier)',
flex: 1.2,
align: 'left',
cell: ({ row }) => formatSuppliers(row.feed_suppliers),
footer: allFeedSuppliers || '-',
},
{
key: 'doc_supplier',
header: 'DOC (Supplier)',
flex: 1,
align: 'left',
cell: ({ row }) => formatSuppliers(row.doc_suppliers),
footer: allDocSuppliers || '-',
},
{
key: 'rata_harga_doc',
header: 'Rata-Rata Harga DOC',
flex: 1.2,
align: 'right',
cell: ({ row }) => formatCurrency(row.average_doc_price_rp || 0),
footer: summary
? formatCurrency(summary.total.total_average_doc_price_rp || 0)
: '',
footerAlign: 'right',
},
{
key: 'hpp_telur',
header: 'HPP Telur (Rp/Kg)',
flex: 1,
align: 'right',
cell: ({ row }) => formatCurrency(row.egg_hpp_rp_per_kg || 0),
footer: summary
? formatCurrency(summary.total.average_egg_hpp_rp_per_kg || 0)
: '',
footerAlign: 'right',
},
{
key: 'nominal_sisa',
header: 'Nominal Sisa',
flex: 1.2,
align: 'right',
cell: ({ row }) => formatCurrency(row.egg_value_rp || 0),
footer: summary
? formatCurrency(summary.total.total_egg_value_rp || 0)
: '',
footerAlign: 'right',
},
];
const createPDFDocument = (
params: HppPerKandangExportParams,
allFeedSuppliers: string,
allDocSuppliers: string
) => {
const rekapitulasiByWeightRange = params.data.summary?.per_weight_range || [];
const weightRangeText =
params.params?.weight_min || params.params?.weight_max
? params.params.weight_min && params.params.weight_max
? `${params.params.weight_min} - ${params.params.weight_max} kg`
: params.params.weight_min
? `${params.params.weight_min} kg`
: `${params.params.weight_max} kg`
: '-';
return (
<Document>
<Page size='A3' orientation='landscape' style={pdfStyles.page}>
{/* Title and Parameters */}
<View style={pdfStyles.titleSection}>
<PdfTypography size='h1' variant='primary'>
Laporan &gt; HPP Harian Kandang
</PdfTypography>
<View style={pdfStyles.parameterContainer}>
<PdfParamBadge>
Area: {params.params?.area_name || 'Semua Area'}
</PdfParamBadge>
<PdfParamBadge>
Lokasi: {params.params?.location_name || 'Semua Lokasi'}
</PdfParamBadge>
<PdfParamBadge>
Kandang: {params.params?.kandang_name || 'Semua Kandang'}
</PdfParamBadge>
<PdfParamBadge>
Periode:{' '}
{params.params?.period
? formatDate(params.params.period, 'DD MMM YYYY')
: '-'}
</PdfParamBadge>
<PdfParamBadge>Rentang Bobot: {weightRangeText}</PdfParamBadge>
{params.params?.show_unrecorded === 'true' && (
<PdfParamBadge>Tampilkan: Tanpa Recording</PdfParamBadge>
)}
<PdfParamBadge>
Dicetak: {formatDate(new Date(), 'DD MMM YYYY HH:mm')}
</PdfParamBadge>
</View>
</View>
{/* Rekapitulasi Section */}
<View style={pdfStyles.section}>
<PdfTypography size='h2' variant='primary'>
Rekapitulasi
</PdfTypography>
<PdfTable
columns={getRekapitulasiColumns()}
data={rekapitulasiByWeightRange}
/>
</View>
{/* Detail Per Kandang Section */}
<View style={pdfStyles.section}>
<PdfTypography size='h2' variant='primary'>
Detail Per Kandang
</PdfTypography>
<PdfTable
columns={getDetailColumns(
params.data.summary,
allFeedSuppliers,
allDocSuppliers
)}
data={params.data.rows}
showFooter={!!params.data.summary}
footerLabel='TOTAL'
/>
</View>
</Page>
</Document>
);
};
export const generateHppPerKandangPDF = async (
params: HppPerKandangExportParams,
allFeedSuppliers: string,
allDocSuppliers: string
): Promise<void> => {
const PDFDocument = createPDFDocument(
params,
allFeedSuppliers,
allDocSuppliers
);
try {
const blob = await pdf(PDFDocument).toBlob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
const period =
params.params?.period || formatDate(new Date(), 'YYYY-MM-DD');
link.download = `laporan-hpp-harian-kandang-periode-${period}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
throw error;
}
};
@@ -0,0 +1,135 @@
'use client';
import ExcelJS from 'exceljs';
import { formatCurrency, formatNumber } from '@/lib/helper';
import {
HppPerKandangReport,
HppPerKandangRow,
HppPerKandangPerWeightRange,
} from '@/types/api/report/hpp-per-kandang';
interface HppPerKandangExportExcelParams {
data: HppPerKandangReport;
allFeedSuppliers: string;
allDocSuppliers: string;
}
const formatSuppliers = (
suppliers: { alias?: string; name: string }[] | null
): string => {
if (!suppliers || suppliers.length === 0) return '';
return suppliers.map((s) => s.alias || s.name).join(' | ');
};
export const generateHppPerKandangExcel = async (
params: HppPerKandangExportExcelParams
): Promise<void> => {
if (!params.data || !params.data.rows || params.data.rows.length === 0) {
return;
}
const workbook = new ExcelJS.Workbook();
// ===== REKAPITULASI WORKSHEET =====
const rekapitulasiColumns = [
{ header: 'No', key: 'no', width: 5 },
{ header: 'Rentang BW', key: 'weightRange', width: 15 },
{ header: 'Sisa Butir', key: 'eggPieces', width: 15 },
{ header: 'Sisa Kg', key: 'eggKg', width: 12 },
{ header: 'Rata-Rata Bobot (Kg)', key: 'avgWeight', width: 18 },
{ header: 'Feed (Supplier)', key: 'feedSuppliers', width: 20 },
{ header: 'DOC (Supplier)', key: 'docSuppliers', width: 20 },
{ header: 'Rata-Rata Harga DOC', key: 'avgDocPrice', width: 20 },
{ header: 'HPP Telur (Rp/Kg)', key: 'eggHpp', width: 18 },
{ header: 'Nominal Sisa', key: 'eggValue', width: 25 },
];
const rekapitulasiWorksheet = workbook.addWorksheet('Rekapitulasi');
rekapitulasiWorksheet.columns = rekapitulasiColumns;
const perWeightRangeSummary = params.data.summary.per_weight_range || [];
perWeightRangeSummary.forEach(
(item: HppPerKandangPerWeightRange, index: number) => {
rekapitulasiWorksheet.addRow({
no: index + 1,
weightRange: item.label || '',
eggPieces: formatNumber(item.egg_production_pieces || 0),
eggKg: formatNumber(item.egg_production_kg || 0),
avgWeight: formatNumber(item.avg_weight_kg || 0),
feedSuppliers: formatSuppliers(item.feed_suppliers),
docSuppliers: formatSuppliers(item.doc_suppliers),
avgDocPrice: formatCurrency(item.average_doc_price_rp || 0),
eggHpp: formatCurrency(item.egg_hpp_rp_per_kg || 0),
eggValue: formatCurrency(item.egg_value_rp || 0),
});
}
);
// ===== DETAIL PER KANDANG WORKSHEET =====
const detailColumns = [
{ header: 'No', key: 'no', width: 5 },
{ header: 'Kandang', key: 'kandang', width: 30 },
{ header: 'Rentang Bobot', key: 'weightRange', width: 15 },
{ header: 'Rata-Rata Bobot (KG)', key: 'avgWeightKg', width: 18 },
{ header: 'Sisa Telur (Butir)', key: 'eggPieces', width: 15 },
{ header: 'Sisa Telur (KG)', key: 'eggKg', width: 15 },
{ header: 'Feed (Supplier)', key: 'feedSuppliers', width: 20 },
{ header: 'DOC (Supplier)', key: 'docSuppliers', width: 20 },
{ header: 'Rata-Rata Harga DOC (Rp)', key: 'avgDocPrice', width: 20 },
{ header: 'HPP Telur (Rp/Kg)', key: 'eggHpp', width: 18 },
{ header: 'Nilai Nominal Sisa Telur (Rp)', key: 'eggValue', width: 25 },
];
const detailWorksheet = workbook.addWorksheet('Detail Per Kandang');
detailWorksheet.columns = detailColumns;
const allExportData = params.data.rows;
allExportData.forEach((item: HppPerKandangRow, index: number) => {
detailWorksheet.addRow({
no: index + 1,
kandang: item.kandang?.name || '',
weightRange: item.weight_range
? `${formatNumber(item.weight_range.weight_min)} - ${formatNumber(item.weight_range.weight_max)}`
: '',
avgWeightKg: formatNumber(item.avg_weight_kg || 0),
eggPieces: formatNumber(item.egg_production_pieces || 0),
eggKg: formatNumber(item.egg_production_kg || 0),
feedSuppliers: formatSuppliers(item.feed_suppliers),
docSuppliers: formatSuppliers(item.doc_suppliers),
avgDocPrice: formatCurrency(item.average_doc_price_rp || 0),
eggHpp: formatCurrency(item.egg_hpp_rp_per_kg || 0),
eggValue: formatCurrency(item.egg_value_rp || 0),
});
});
// Add TOTAL row
const summaryTotal = params.data.summary.total;
detailWorksheet.addRow({
no: 'TOTAL',
kandang: 'ALL',
weightRange: '-',
avgWeightKg: formatNumber(summaryTotal?.average_weight_kg || 0),
eggPieces: formatNumber(summaryTotal?.total_egg_production_pieces || 0),
eggKg: formatNumber(summaryTotal?.total_egg_production_kg || 0),
feedSuppliers: params.allFeedSuppliers,
docSuppliers: params.allDocSuppliers,
avgDocPrice: formatCurrency(summaryTotal?.total_average_doc_price_rp || 0),
eggHpp: formatCurrency(summaryTotal?.average_egg_hpp_rp_per_kg || 0),
eggValue: formatCurrency(summaryTotal?.total_egg_value_rp || 0),
});
const filename = `laporan-hpp-harian-kandang-periode-${params.data.period}.xlsx`;
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
window.URL.revokeObjectURL(url);
};
@@ -14,9 +14,9 @@ import SelectInput, {
} from '@/components/input/SelectInput';
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
import DailyMarketingsTable from '@/components/pages/report/DailyMarketingsTable';
import DailyMarketingsTable from '@/components/pages/report/marketing/DailyMarketingsTable';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import DailyMarketingReportPDF from '@/components/pages/report/DailyMarketingReportPDF';
import DailyMarketingReportPDF from '@/components/pages/report/marketing/export/DailyMarketingReportPDF';
import { Area } from '@/types/api/master-data/area';
import {
@@ -26,9 +26,9 @@ import Button from '@/components/Button';
import Dropdown from '@/components/Dropdown';
import MenuItem from '@/components/menu/MenuItem';
import Menu from '@/components/menu/Menu';
import { generateHppPerKandangPDF } from '../export/HppPerkandangExport';
import { generateHppPerKandangPDF } from '@/components/pages/report/marketing/export/HppPerkandangExportPDF';
import { generateHppPerKandangExcel } from '@/components/pages/report/marketing/export/HppPerkandangExportXLSX';
import toast from 'react-hot-toast';
import * as XLSX from 'xlsx';
import { Icon } from '@iconify/react';
const HppPerKandangTab = () => {
@@ -346,136 +346,18 @@ const HppPerKandangTab = () => {
return;
}
const allExportData =
allDataForExport.rows as HppPerKandangReport['rows'];
const perWeightRangeSummary =
allDataForExport.summary.per_weight_range || [];
const summaryTotal = allDataForExport.summary.total;
const rekapitulasiData: { [key: string]: string | number }[] =
perWeightRangeSummary.map(
(item: HppPerKandangPerWeightRange, index: number) => ({
No: index + 1,
'Rentang BW': item.label || '',
'Sisa Butir': item.egg_production_pieces || 0,
'Sisa Kg': item.egg_production_kg || 0,
'Rata-Rata Bobot (Kg)': item.avg_weight_kg || 0,
'Feed (Supplier)':
item.feed_suppliers
?.map(
(s: { alias?: string; name: string }) => s.alias || s.name
)
.join(' | ') || '',
'DOC (Supplier)':
item.doc_suppliers
?.map(
(s: { alias?: string; name: string }) => s.alias || s.name
)
.join(' | ') || '',
'Rata-Rata Harga DOC': item.average_doc_price_rp || 0,
'HPP Telur (RP/KG)': item.egg_hpp_rp_per_kg || 0,
'Nominal Sisa': item.egg_value_rp || 0,
})
);
const rekapitulasiWorksheet = XLSX.utils.json_to_sheet(rekapitulasiData);
const rekapitulasiColWidths = [
{ wch: 5 }, // No
{ wch: 15 }, // Rentang BW
{ wch: 15 }, // Sisa Butir
{ wch: 12 }, // Sisa Kg
{ wch: 18 }, // Rata-Rata Bobot (Kg)
{ wch: 20 }, // Feed (Supplier)
{ wch: 20 }, // DOC (Supplier)
{ wch: 20 }, // Rata-Rata Harga DOC
{ wch: 18 }, // HPP Telur (RP/KG)
{ wch: 25 }, // Nominal Sisa
];
rekapitulasiWorksheet['!cols'] = rekapitulasiColWidths;
const excelData: { [key: string]: string | number }[] = allExportData.map(
(item: HppPerKandangRow, index: number) => ({
No: index + 1,
Kandang: item.kandang?.name || '',
'Rentang Bobot': item.weight_range
? `${formatNumber(item.weight_range.weight_min)} - ${formatNumber(item.weight_range.weight_max)}`
: '',
'Rata-Rata Bobot (KG)': item.avg_weight_kg || 0,
'Sisa Telur (Butir)': item.egg_production_pieces || 0,
'Sisa Telur (KG)': item.egg_production_kg || 0,
'Feed (Supplier)':
item.feed_suppliers
?.map((s: { alias?: string; name: string }) => s.alias || s.name)
.join(' | ') || '',
'DOC (Supplier)':
item.doc_suppliers
?.map((s: { alias?: string; name: string }) => s.alias || s.name)
.join(' | ') || '',
'Rata-Rata Harga DOC (RP)': item.average_doc_price_rp || 0,
'HPP Telur (RP/KG)': item.egg_hpp_rp_per_kg || 0,
'Nilai Nominal Sisa Telur (RP)': item.egg_value_rp || 0,
})
);
excelData.push({
No: 'TOTAL',
Kandang: 'ALL',
'Rentang Bobot': '-',
'Rata-Rata Bobot (KG)': summaryTotal?.average_weight_kg || 0,
'Sisa Telur (Butir)': summaryTotal?.total_egg_production_pieces || 0,
'Sisa Telur (KG)': summaryTotal?.total_egg_production_kg || 0,
'Feed (Supplier)': allFeedSuppliers,
'DOC (Supplier)': allDocSuppliers,
'Rata-Rata Harga DOC (RP)':
summaryTotal?.total_average_doc_price_rp || 0,
'HPP Telur (RP/KG)': summaryTotal?.average_egg_hpp_rp_per_kg || 0,
'Nilai Nominal Sisa Telur (RP)': summaryTotal?.total_egg_value_rp || 0,
await generateHppPerKandangExcel({
data: allDataForExport,
allFeedSuppliers,
allDocSuppliers,
});
const worksheet = XLSX.utils.json_to_sheet(excelData);
const colWidths = [
{ wch: 5 }, // No
{ wch: 30 }, // Kandang
{ wch: 15 }, // Rentang Bobot
{ wch: 18 }, // Rata-Rata Bobot (KG)
{ wch: 15 }, // Sisa Telur (Butir)
{ wch: 15 }, // Sisa Telur (KG)
{ wch: 20 }, // Feed (Supplier)
{ wch: 20 }, // DOC (Supplier)
{ wch: 20 }, // Rata-Rata Harga DOC (RP)
{ wch: 18 }, // HPP Telur (RP/KG)
{ wch: 25 }, // Nilai Nominal Sisa Telur (RP)
];
worksheet['!cols'] = colWidths;
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(
workbook,
rekapitulasiWorksheet,
'Rekapitulasi'
);
XLSX.utils.book_append_sheet(workbook, worksheet, 'Detail Per Kandang');
const filename = `laporan-hpp-harian-kandang-periode-${tableFilterState.period}.xlsx`;
XLSX.writeFile(workbook, filename);
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally {
setIsExcelExportLoading(false);
}
}, [
hppPerKandangExport,
tableFilterState,
areaOptions,
locationOptions,
kandangOptions,
]);
}, [hppPerKandangExport, allFeedSuppliers, allDocSuppliers]);
const handleExportPDF = useCallback(async () => {
setIsPdfExportLoading(true);
@@ -524,16 +406,23 @@ const HppPerKandangTab = () => {
.join(', ') || 'Semua Kandang'
: 'Semua Kandang';
await generateHppPerKandangPDF(allDataForExport, {
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,
});
await generateHppPerKandangPDF(
{
data: allDataForExport,
params: {
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,
},
},
allFeedSuppliers,
allDocSuppliers
);
toast.success('PDF berhasil dibuat dan diunduh.');
} catch {
@@ -547,6 +436,8 @@ const HppPerKandangTab = () => {
areaOptions,
locationOptions,
kandangOptions,
allFeedSuppliers,
allDocSuppliers,
]);
const getTableColumns = (): ColumnDef<HppPerKandangReport['rows'][0]>[] => {
@@ -1,18 +1,15 @@
'use client';
import React from 'react';
import {
Document,
Page,
StyleSheet,
Text,
View,
Image,
} from '@react-pdf/renderer';
import { Document, Page, StyleSheet, View, Text } from '@react-pdf/renderer';
import { formatDate, formatNumber } from '@/lib/helper';
import { BaseProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
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 } from '@/components/helper/pdf/table';
type MappedProductionResultsItem = {
projectFlockKandang: BaseProjectFlockKandang;
@@ -25,132 +22,28 @@ interface ProductionResultReportPDFProps {
const styles = StyleSheet.create({
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,
fontFamily: 'Helvetica',
padding: 20,
backgroundColor: '#FFFFFF',
},
companyName: {
fontSize: 12,
fontWeight: 'bold',
marginBottom: 4,
},
companyAddress: {
fontSize: 8,
maxWidth: 420,
titleSection: {
marginBottom: 10,
},
doubleDivider: {
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',
parameterContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
position: 'absolute',
fontSize: 8,
bottom: 22,
left: 0,
right: 0,
textAlign: 'center',
color: 'grey',
flexWrap: 'wrap',
marginBottom: 8,
},
section: {
marginTop: 12,
borderWidth: 1,
borderColor: '#000',
padding: 8,
tableSection: {
marginBottom: 12,
},
sectionHeader: {
marginBottom: 6,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'baseline',
},
sectionTitle: {
tableTitle: {
fontSize: 10,
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: {
fontSize: 8,
color: '#666',
@@ -158,136 +51,347 @@ const styles = StyleSheet.create({
},
});
function safeNum(v: unknown): number {
const n = typeof v === 'number' ? v : Number(v);
return Number.isFinite(n) ? n : 0;
}
function valueText(v: unknown) {
if (v === null || v === undefined) return '-';
if (typeof v === 'number') return formatNumber(v);
return String(v);
}
/**
* Render label/value table for one ProductionResult.
* Uses a compact grid to keep page readable.
*/
function ProductionResultGrid({ pr }: { pr: ProductionResult }) {
const rows: Array<[string, string]> = [
['WOA', valueText(pr.woa)],
// ========================================
// TABLE 1: WOA & BW
// ========================================
const getBwTableColumns = (): PdfColumn<ProductionResult>[] => [
{
key: 'no',
header: 'No',
flex: 0.5,
align: 'center',
cell: ({ row, index }) => index + 1,
},
{
key: 'woa',
header: 'Week of Age',
flex: 0.8,
align: 'center',
cell: ({ row }) => valueText(row.woa),
},
{
key: 'bw',
header: 'Body Weight',
flex: 1,
align: 'right',
cell: ({ row }) => valueText(row.bw),
},
{
key: 'std_bw',
header: 'Std Body Weight',
flex: 1,
align: 'right',
cell: ({ row }) => valueText(row.std_bw),
},
{
key: 'uniformity',
header: 'Uniformity',
flex: 1.2,
align: 'right',
cell: ({ row }) => valueText(row.uniformity),
},
{
key: 'std_uniformity',
header: 'Std Uniformity',
flex: 1.3,
align: 'right',
cell: ({ row }) => valueText(row.std_uniformity),
},
];
// BW
['BW', valueText(pr.bw)],
['Std BW', valueText(pr.std_bw)],
['Uniformity', valueText(pr.uniformity)],
['Std Uniformity', valueText(pr.std_uniformity)],
// ========================================
// TABLE 2: DEPLESI
// ========================================
const getDepTableColumns = (): PdfColumn<ProductionResult>[] => [
{
key: 'no',
header: 'No',
flex: 0.5,
align: 'center',
cell: ({ row, index }) => index + 1,
},
{
key: 'dep_kum',
header: 'Depletion Cummulative',
flex: 1.5,
align: 'right',
cell: ({ row }) => valueText(row.dep_kum),
},
{
key: 'dep_std',
header: 'Depletion Std',
flex: 1.5,
align: 'right',
cell: ({ row }) => valueText(row.dep_std),
},
];
// Dep
['Dep Kum', valueText(pr.dep_kum)],
['Dep Std', valueText(pr.dep_std)],
// ========================================
// TABLE 3: BUTIRAN
// ========================================
const getButiranTableColumns = (): PdfColumn<ProductionResult>[] => [
{
key: 'no',
header: 'No',
flex: 0.5,
align: 'center',
cell: ({ row, index }) => index + 1,
},
{
key: 'butiran_utuh',
header: 'Utuh',
flex: 1.2,
align: 'right',
cell: ({ row }) => valueText(row.butiran_utuh),
},
{
key: 'butiran_putih',
header: 'Putih',
flex: 1.2,
align: 'right',
cell: ({ row }) => valueText(row.butiran_putih),
},
{
key: 'butiran_retak',
header: 'Retak',
flex: 1.2,
align: 'right',
cell: ({ row }) => valueText(row.butiran_retak),
},
{
key: 'butiran_pecah',
header: 'Pecah',
flex: 1.2,
align: 'right',
cell: ({ row }) => valueText(row.butiran_pecah),
},
{
key: 'butiran_jumlah',
header: 'Jumlah',
flex: 1.2,
align: 'right',
cell: ({ row }) => valueText(row.butiran_jumlah),
},
{
key: 'total_butir',
header: 'Total Butir',
flex: 1.3,
align: 'right',
cell: ({ row }) => valueText(row.total_butir),
},
];
// Butiran
['Butiran Utuh', valueText(pr.butiran_utuh)],
['Butiran Putih', valueText(pr.butiran_putih)],
['Butiran Retak', valueText(pr.butiran_retak)],
['Butiran Pecah', valueText(pr.butiran_pecah)],
['Butiran Jumlah', valueText(pr.butiran_jumlah)],
['Total Butir', valueText(pr.total_butir)],
// ========================================
// TABLE 4: BERAT (KG)
// ========================================
const getKgTableColumns = (): PdfColumn<ProductionResult>[] => [
{
key: 'no',
header: 'No',
flex: 0.5,
align: 'center',
cell: ({ row, index }) => index + 1,
},
{
key: 'kg_utuh',
header: 'Utuh (Kg)',
flex: 1.2,
align: 'right',
cell: ({ row }) => valueText(row.kg_utuh),
},
{
key: 'kg_putih',
header: 'Putih (Kg)',
flex: 1.2,
align: 'right',
cell: ({ row }) => valueText(row.kg_putih),
},
{
key: 'kg_retak',
header: 'Retak (Kg)',
flex: 1.2,
align: 'right',
cell: ({ row }) => valueText(row.kg_retak),
},
{
key: 'kg_pecah',
header: 'Pecah (Kg)',
flex: 1.2,
align: 'right',
cell: ({ row }) => valueText(row.kg_pecah),
},
{
key: 'kg_jumlah',
header: 'Jumlah (Kg)',
flex: 1.3,
align: 'right',
cell: ({ row }) => valueText(row.kg_jumlah),
},
{
key: 'total_kg',
header: 'Total (Kg)',
flex: 1.3,
align: 'right',
cell: ({ row }) => valueText(row.total_kg),
},
];
// Kg
['Kg Utuh', valueText(pr.kg_utuh)],
['Kg Putih', valueText(pr.kg_putih)],
['Kg Retak', valueText(pr.kg_retak)],
['Kg Pecah', valueText(pr.kg_pecah)],
['Kg Jumlah', valueText(pr.kg_jumlah)],
['Total Kg', valueText(pr.total_kg)],
// ========================================
// TABLE 5: PERSENTASE
// ========================================
const getPersenTableColumns = (): PdfColumn<ProductionResult>[] => [
{
key: 'no',
header: 'No',
flex: 0.5,
align: 'center',
cell: ({ row, index }) => index + 1,
},
{
key: 'persen_utuh',
header: 'Utuh (%)',
flex: 1.5,
align: 'right',
cell: ({ row }) => valueText(row.persen_utuh),
},
{
key: 'persen_putih',
header: 'Putih (%)',
flex: 1.5,
align: 'right',
cell: ({ row }) => valueText(row.persen_putih),
},
{
key: 'persen_retak',
header: '% Retak (%)',
flex: 1.5,
align: 'right',
cell: ({ row }) => valueText(row.persen_retak),
},
{
key: 'persen_pecah',
header: '% Pecah (%)',
flex: 1.5,
align: 'right',
cell: ({ row }) => valueText(row.persen_pecah),
},
];
// %
['% Utuh', valueText(pr.persen_utuh)],
['% Putih', valueText(pr.persen_putih)],
['% Retak', valueText(pr.persen_retak)],
['% Pecah', valueText(pr.persen_pecah)],
// ========================================
// TABLE 6: PRODUKSI (HD, FI, EM, EW)
// ========================================
const getProduksi1TableColumns = (): PdfColumn<ProductionResult>[] => [
{
key: 'no',
header: 'No',
flex: 0.5,
align: 'center',
cell: ({ row, index }) => index + 1,
},
{
key: 'hd',
header: 'Hen Day',
flex: 0.8,
align: 'right',
cell: ({ row }) => valueText(row.hd),
},
{
key: 'hd_std',
header: 'Hen Day Std',
flex: 1,
align: 'right',
cell: ({ row }) => valueText(row.hd_std),
},
{
key: 'fi',
header: 'Feed Intake',
flex: 0.8,
align: 'right',
cell: ({ row }) => valueText(row.fi),
},
{
key: 'fi_std',
header: 'Feed Intake Std',
flex: 1,
align: 'right',
cell: ({ row }) => valueText(row.fi_std),
},
{
key: 'em',
header: 'Egg Mass',
flex: 0.8,
align: 'right',
cell: ({ row }) => valueText(row.em),
},
{
key: 'em_std',
header: 'Egg Mass Std',
flex: 1,
align: 'right',
cell: ({ row }) => valueText(row.em_std),
},
{
key: 'ew',
header: 'Egg Weight',
flex: 0.8,
align: 'right',
cell: ({ row }) => valueText(row.ew),
},
{
key: 'ew_std',
header: 'Egg Weight Std',
flex: 1,
align: 'right',
cell: ({ row }) => valueText(row.ew_std),
},
];
// Produksi
['HD', valueText(pr.hd)],
['HD Std', valueText(pr.hd_std)],
['FI', valueText(pr.fi)],
['FI Std', valueText(pr.fi_std)],
['EM', valueText(pr.em)],
['EM Std', valueText(pr.em_std)],
['EW', valueText(pr.ew)],
['EW Std', valueText(pr.ew_std)],
['FCR', valueText(pr.fcr)],
['FCR Std', valueText(pr.fcr_std)],
['HH', valueText(pr.hh)],
['HH Std', valueText(pr.hh_std)],
];
return (
<View style={styles.grid}>
{rows.map(([label, value], idx) => {
const isLast = idx === rows.length - 1;
return (
<View
key={label}
style={[styles.gridRow, ...(isLast ? [styles.gridRowLast] : [])]}
>
<Text style={styles.gridCellLabel}>{label}</Text>
<Text style={styles.gridCellValue}>{value}</Text>
</View>
);
})}
</View>
);
}
/**
* If there are multiple ProductionResult entries for a kandang,
* we show them sequentially with a small header per result.
*
* You can later change this to render only the latest WOA, or group by week.
*/
function ProductionResultList({
productionResults,
}: {
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 headerLeft = `Data #${idx + 1}`;
const headerRight =
kandangName && pr.woa !== undefined
? `${kandangName} • WOA ${safeNum(pr.woa)}`
: pr.woa !== undefined
? `WOA ${safeNum(pr.woa)}`
: '';
return (
<View
key={`${pr.project_flock?.id ?? 'pf'}-${idx}`}
style={{ marginTop: idx === 0 ? 0 : 10 }}
wrap={false}
>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{headerLeft}</Text>
<Text style={styles.sectionSubtitle}>{headerRight}</Text>
</View>
<ProductionResultGrid pr={pr} />
</View>
);
})}
</View>
);
}
// ========================================
// TABLE 7: PRODUKSI (FCR, HH)
// ========================================
const getProduksi2TableColumns = (): PdfColumn<ProductionResult>[] => [
{
key: 'no',
header: 'No',
flex: 0.5,
align: 'center',
cell: ({ row, index }) => index + 1,
},
{
key: 'fcr',
header: 'FCR',
flex: 1,
align: 'right',
cell: ({ row }) => valueText(row.fcr),
},
{
key: 'fcr_std',
header: 'FCR Std',
flex: 1.2,
align: 'right',
cell: ({ row }) => valueText(row.fcr_std),
},
{
key: 'hh',
header: 'Hen House',
flex: 1,
align: 'right',
cell: ({ row }) => valueText(row.hh),
},
{
key: 'hh_std',
header: 'Hen House Std',
flex: 1.2,
align: 'right',
cell: ({ row }) => valueText(row.hh_std),
},
];
/**
* ✅ Main PDF Component
@@ -297,90 +401,148 @@ const ProductionResultReportPDF = ({
}: ProductionResultReportPDFProps) => {
return (
<Document>
<Page style={styles.page} size='A4'>
{/* Header */}
<View>
<View style={styles.companyInfoHeader}>
<Image style={styles.companyLogo} src='/assets/img/lti-logo.png' />
<Text style={styles.companyInfoHeaderDate}>
{formatDate(Date.now(), 'DD MMMM YYYY')}
</Text>
{mappedProductionResults.length === 0 ? (
<Page style={styles.page} size='A4'>
{/* Title and Parameters */}
<View style={styles.titleSection}>
<PdfTypography size='h1' variant='primary'>
Laporan &gt; Production Result
</PdfTypography>
<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>
<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 }}>
<Text style={styles.emptyText}>Tidak ada data.</Text>
</View>
) : (
mappedProductionResults.map((item, idx) => {
const pfk = item.projectFlockKandang;
// Try to display meaningful identifiers.
// Adjust these fields based on your real BaseProjectFlockKandang structure.
const kandangName =
pfk?.kandang?.name ?? `Kandang #${pfk?.kandang_id ?? idx + 1}`;
<PdfPageNumber />
</Page>
) : (
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 (
<View
key={`pfk-${pfk?.id ?? idx}`}
style={styles.section}
break={idx > 0} // each kandang starts on a new page for clarity
>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>
{projectName
? `${projectName}${kandangName}`
: kandangName}
</Text>
<Text style={styles.sectionSubtitle}>
{[areaName, locationName].filter(Boolean).join(' • ')}
</Text>
const areaName = pfk?.project_flock?.area?.name ?? '';
const hasData =
item.productionResult && item.productionResult.length > 0;
return (
<Page key={`pfk-${pfk?.id ?? idx}`} style={styles.page} size='A4'>
{/* Title and Parameters */}
<View style={styles.titleSection}>
<PdfTypography size='h1' variant='primary'>
Laporan &gt; Production Result
</PdfTypography>
<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>
{item.productionResult && item.productionResult.length > 0 ? (
<ProductionResultList
productionResults={item.productionResult}
/>
) : (
<Text style={styles.emptyText}>
Tidak ada production result untuk kandang ini.
</Text>
)}
<PdfTypography size='h2' variant='primary'>
{projectName
? `${projectName}${kandangName}`
: kandangName}
</PdfTypography>
<PdfTypography size='label'>
{[areaName, locationName].filter(Boolean).join(' • ')}
</PdfTypography>
</View>
);
})
)}
{/* Footer */}
<View style={styles.footer} fixed>
<Text
render={({ pageNumber, totalPages }) =>
`${pageNumber} / ${totalPages}`
}
fixed
/>
</View>
</Page>
{hasData ? (
<>
{/* Table 1: WOA & BW */}
<View style={styles.tableSection}>
<Text style={styles.tableTitle}>1. WOA & Body Weight</Text>
<PdfTable
columns={getBwTableColumns()}
data={item.productionResult!}
/>
</View>
{/* Table 2: Deplesi */}
<View style={styles.tableSection}>
<Text style={styles.tableTitle}>2. Deplesi</Text>
<PdfTable
columns={getDepTableColumns()}
data={item.productionResult!}
/>
</View>
{/* Table 3: Butiran */}
<View style={styles.tableSection}>
<Text style={styles.tableTitle}>3. Butiran</Text>
<PdfTable
columns={getButiranTableColumns()}
data={item.productionResult!}
/>
</View>
{/* Table 4: Berat (Kg) */}
<View style={styles.tableSection}>
<Text style={styles.tableTitle}>4. Berat (Kg)</Text>
<PdfTable
columns={getKgTableColumns()}
data={item.productionResult!}
/>
</View>
{/* Table 5: Persentase */}
<View style={styles.tableSection}>
<Text style={styles.tableTitle}>5. Persentase</Text>
<PdfTable
columns={getPersenTableColumns()}
data={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={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={item.productionResult!}
/>
</View>
</>
) : (
<Text style={styles.emptyText}>
Tidak ada production result untuk kandang ini.
</Text>
)}
<PdfPageNumber />
</Page>
);
})
)}
</Document>
);
};
@@ -1,37 +0,0 @@
'use client';
import Tabs from '@/components/Tabs';
import HppPerKandangTab from '@/components/pages/report/sale/tab/HppPerKandangTab';
const SaleReportTabs = () => {
const tabs = [
// {
// id: '1',
// label: 'Penjualan Harian',
// content: 'Penjualan Harian Tab',
// },
// {
// id: '2',
// label: 'Transaksi Penjualan DO',
// content: 'Transaksi Penjualan DO Tab',
// },
// {
// id: '3',
// label: 'Perbandingan HPP Per Rentang BW',
// content: 'Perbandingan HPP Per Rentang BW Tab',
// },
{
id: '4',
label: 'HPP Harian Kandang',
content: <HppPerKandangTab />,
},
];
return (
<section className='w-full p-4'>
<Tabs tabs={tabs} variant='lifted' />
</section>
);
};
export default SaleReportTabs;
@@ -1,403 +0,0 @@
'use client';
import {
Page,
Text,
View,
Document,
StyleSheet,
Font,
pdf,
} from '@react-pdf/renderer';
import {
HppPerKandangReport,
HppPerKandangRow,
HppPerKandangPerWeightRange,
} from '@/types/api/report/hpp-per-kandang';
import { formatDate, formatNumber, formatCurrency } from '@/lib/helper';
import {
PdfTable,
PdfColumn,
PdfTbodyCell,
PdfTfootCell,
} from '@/components/helper/pdf/table';
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',
},
parameterBadge: {
backgroundColor: '#F5F5F5',
color: '#333333',
padding: 4,
borderRadius: 4,
fontSize: 8,
marginRight: 8,
marginBottom: 4,
},
parameterContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
marginBottom: 8,
},
section: {
marginBottom: 15,
},
});
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;
};
}
const getParameterText = (params: HppPerKandangExportParams['params']) => {
const paramsText = [];
if (params.area_name && params.area_name !== 'Semua Area') {
paramsText.push(`Area: ${params.area_name}`);
}
if (params.location_name && params.location_name !== 'Semua Lokasi') {
paramsText.push(`Lokasi: ${params.location_name}`);
}
if (params.kandang_name && params.kandang_name !== 'Semua Kandang') {
paramsText.push(`Kandang: ${params.kandang_name}`);
}
if (params.period) {
const formattedDate = formatDate(params.period, 'DD MMM YYYY');
paramsText.push(`Tanggal: ${formattedDate}`);
}
if (params.weight_min || params.weight_max) {
const weightRange =
params.weight_min && params.weight_max
? `${params.weight_min} - ${params.weight_max} kg`
: params.weight_min
? `${params.weight_min} kg`
: `${params.weight_max} kg`;
paramsText.push(`Rentang Bobot: ${weightRange}`);
}
if (params.show_unrecorded === 'true') {
paramsText.push('Tampilkan: Tanpa Recording');
}
const currentDate = formatDate(new Date().toISOString(), 'DD MMM YYYY HH:mm');
paramsText.push(`Dicetak: ${currentDate}`);
return paramsText;
};
// Helper functions for PdfTable - Rekapitulasi
const getRekapitulasiColumns = (): PdfColumn[] => [
{ key: 'rentang_bw', header: 'Rentang BW', flex: 1.2, align: 'center' },
{ key: 'sisa_butir', header: 'Sisa Butir', flex: 1, align: 'right' },
{ key: 'sisa_kg', header: 'Sisa Kg', flex: 1, align: 'right' },
{
key: 'rata_rata_bobot',
header: 'Rata-Rata Bobot (Kg)',
flex: 1.2,
align: 'right',
},
{ key: 'feed_supplier', header: 'Feed (Supplier)', flex: 1.5, align: 'left' },
{ key: 'doc_supplier', header: 'DOC (Supplier)', flex: 1.2, align: 'left' },
{
key: 'rata_harga_doc',
header: 'Rata-Rata Harga DOC',
flex: 1.2,
align: 'right',
},
{ key: 'hpp_telur', header: 'HPP Telur (RP/KG)', flex: 1.2, align: 'right' },
{ key: 'nominal_sisa', header: 'Nominal Sisa', flex: 1.2, align: 'right' },
];
const getRekapitulasiData = (
perWeightRange: HppPerKandangPerWeightRange[]
): PdfTbodyCell[][] => {
return perWeightRange.map((group) => [
{ key: 'rentang_bw', value: group.label, align: 'center' },
{
key: 'sisa_butir',
value: formatNumber(group.egg_production_pieces),
align: 'right',
},
{
key: 'sisa_kg',
value: formatNumber(group.egg_production_kg),
align: 'right',
},
{
key: 'rata_rata_bobot',
value: formatNumber(group.avg_weight_kg),
align: 'right',
},
{
key: 'feed_supplier',
value:
group.feed_suppliers
?.map((s: { alias?: string; name: string }) => s.alias || s.name)
.join(' | ') || '-',
},
{
key: 'doc_supplier',
value:
group.doc_suppliers
?.map((s: { alias?: string; name: string }) => s.alias || s.name)
.join(' | ') || '-',
},
{
key: 'rata_harga_doc',
value: formatCurrency(group.average_doc_price_rp),
align: 'right',
},
{
key: 'hpp_telur',
value: formatCurrency(group.egg_hpp_rp_per_kg),
align: 'right',
},
{
key: 'nominal_sisa',
value: formatCurrency(group.egg_value_rp),
align: 'right',
},
]);
};
// Helper functions for PdfTable - Detail Per Kandang
const getDetailColumns = (): PdfColumn[] => [
{ key: 'no', header: 'No', flex: 0.5, align: 'center' },
{ key: 'kandang', header: 'Kandang', flex: 1.5, align: 'left' },
{ key: 'rentang_bw', header: 'Rentang BW', flex: 1, align: 'left' },
{
key: 'rata_rata_bobot',
header: 'Rata-Rata Bobot (Kg)',
flex: 1,
align: 'right',
},
{ key: 'sisa_butir', header: 'Sisa Butir', flex: 0.8, align: 'right' },
{ key: 'sisa_kg', header: 'Sisa Kg (Telur)', flex: 0.8, align: 'right' },
{ key: 'feed_supplier', header: 'Feed (Supplier)', flex: 1.2, align: 'left' },
{ key: 'doc_supplier', header: 'DOC (Supplier)', flex: 1, align: 'left' },
{
key: 'rata_harga_doc',
header: 'Rata-Rata Harga DOC',
flex: 1.2,
align: 'right',
},
{ key: 'hpp_telur', header: 'HPP Telur (RP/KG)', flex: 1, align: 'right' },
{ key: 'nominal_sisa', header: 'Nominal Sisa', flex: 1.2, align: 'right' },
];
const getDetailData = (rows: HppPerKandangRow[]): PdfTbodyCell[][] => {
return rows.map((item, index) => [
{ key: 'no', value: index + 1, align: 'center' },
{ key: 'kandang', value: item.kandang?.name || '-' },
{
key: 'rentang_bw',
value: `${item.weight_range.weight_min.toFixed(2)} - ${item.weight_range.weight_max.toFixed(2)}`,
},
{
key: 'rata_rata_bobot',
value: formatNumber(item.avg_weight_kg),
align: 'right',
},
{
key: 'sisa_butir',
value: formatNumber(item.egg_production_pieces),
align: 'right',
},
{
key: 'sisa_kg',
value: formatNumber(item.egg_production_kg),
align: 'right',
},
{
key: 'feed_supplier',
value:
item.feed_suppliers
?.map((s: { alias?: string; name: string }) => s.alias || s.name)
.join(' | ') || '-',
},
{
key: 'doc_supplier',
value:
item.doc_suppliers
?.map((s: { alias?: string; name: string }) => s.alias || s.name)
.join(' | ') || '-',
},
{
key: 'rata_harga_doc',
value: formatCurrency(item.average_doc_price_rp),
align: 'right',
},
{
key: 'hpp_telur',
value: formatCurrency(item.egg_hpp_rp_per_kg),
align: 'right',
},
{
key: 'nominal_sisa',
value: formatCurrency(item.egg_value_rp),
align: 'right',
},
]);
};
const getDetailFooter = (
summary: HppPerKandangReport['summary']
): PdfTfootCell[] => {
if (!summary?.total) return [];
const allFeedSuppliers =
summary.total.feed_suppliers
?.map((s: { alias?: string; name: string }) => s.alias || s.name)
.join(' | ') || '-';
const allDocSuppliers =
summary.total.doc_suppliers
?.map((s: { alias?: string; name: string }) => s.alias || s.name)
.join(' | ') || '-';
return [
{ key: 'no', value: 'TOTAL' },
{ key: 'kandang', value: 'ALL' },
{ key: 'rentang_bw', value: '-' },
{
key: 'rata_rata_bobot',
value: formatNumber(summary.total.average_weight_kg),
align: 'right',
},
{
key: 'sisa_butir',
value: formatNumber(summary.total.total_egg_production_pieces),
align: 'right',
},
{
key: 'sisa_kg',
value: formatNumber(summary.total.total_egg_production_kg),
align: 'right',
},
{ key: 'feed_supplier', value: allFeedSuppliers },
{ key: 'doc_supplier', value: allDocSuppliers },
{
key: 'rata_harga_doc',
value: formatCurrency(summary.total.total_average_doc_price_rp),
align: 'right',
},
{
key: 'hpp_telur',
value: formatCurrency(summary.total.average_egg_hpp_rp_per_kg),
align: 'right',
},
{
key: 'nominal_sisa',
value: formatCurrency(summary.total.total_egg_value_rp),
align: 'right',
},
];
};
const createPDFDocument = (
data: HppPerKandangExportParams['data'],
params: HppPerKandangExportParams['params']
) => {
const rekapitulasiByWeightRange = data.summary?.per_weight_range || [];
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.section}>
<Text style={pdfStyles.supplierTitle}>Rekapitulasi</Text>
<PdfTable
columns={getRekapitulasiColumns()}
data={getRekapitulasiData(rekapitulasiByWeightRange)}
/>
</View>
{/* Detail Per Kandang Section */}
<View style={pdfStyles.section}>
<Text style={pdfStyles.supplierTitle}>Detail Per Kandang</Text>
<PdfTable
columns={getDetailColumns()}
data={getDetailData(data.rows)}
footer={data.summary ? getDetailFooter(data.summary) : undefined}
footerLabel='TOTAL'
/>
</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;
const period = params.period || formatDate(new Date(), 'YYYY-MM-DD');
link.download = `laporan-hpp-harian-kandang-periode-${period}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
throw error;
}
};