Merge branch 'development' into 'staging'

Development

See merge request mbugroup/lti-web-client!323
This commit is contained in:
Adnan Zahir
2026-02-12 11:06:11 +07:00
75 changed files with 3604 additions and 4638 deletions
-11
View File
@@ -1,11 +0,0 @@
import FcrForm from '@/components/pages/master-data/fcr/form/FcrForm';
const AddFcr = () => {
return (
<div className='w-full p-4 flex flex-row justify-center'>
<FcrForm />
</div>
);
};
export default AddFcr;
@@ -1,52 +0,0 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import FcrForm from '@/components/pages/master-data/fcr/form/FcrForm';
import { FcrApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { BaseApiResponse } from '@/types/api/api-general';
import { FcrWithStandards } from '@/types/api/master-data/fcr';
const FcrEdit = () => {
const router = useRouter();
const searchParams = useSearchParams();
const fcrId = searchParams.get('fcrId');
const { data: fcr, isLoading: isLoadingFcr } = useSWR(
fcrId,
(id: number) =>
FcrApi.getSingle(id) as Promise<
BaseApiResponse<FcrWithStandards> | undefined
>
);
if (!fcrId) {
router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (!isLoadingFcr && (!fcr || isResponseError(fcr))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingFcr && <span className='loading loading-spinner loading-xl' />}
{!isLoadingFcr && isResponseSuccess(fcr) && (
<FcrForm type='edit' initialValues={fcr.data} />
)}
</div>
);
};
export default FcrEdit;
-11
View File
@@ -1,11 +0,0 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
-52
View File
@@ -1,52 +0,0 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import FcrForm from '@/components/pages/master-data/fcr/form/FcrForm';
import { FcrApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { FcrWithStandards } from '@/types/api/master-data/fcr';
import { BaseApiResponse } from '@/types/api/api-general';
const FcrDetail = () => {
const router = useRouter();
const searchParams = useSearchParams();
const fcrId = searchParams.get('fcrId');
const { data: fcr, isLoading: isLoadingFcr } = useSWR(
fcrId,
(id: number) =>
FcrApi.getSingle(id) as Promise<
BaseApiResponse<FcrWithStandards> | undefined
>
);
if (!fcrId) {
router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (!isLoadingFcr && (!fcr || isResponseError(fcr))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingFcr && <span className='loading loading-spinner loading-xl' />}
{!isLoadingFcr && isResponseSuccess(fcr) && (
<FcrForm type='detail' initialValues={fcr.data} />
)}
</div>
);
};
export default FcrDetail;
-11
View File
@@ -1,11 +0,0 @@
import FcrsTable from '@/components/pages/master-data/fcr/FcrsTable';
const Fcr = () => {
return (
<section className='w-full p-4'>
<FcrsTable />
</section>
);
};
export default Fcr;
+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>
);
@@ -0,0 +1,27 @@
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({
parameterBadge: {
backgroundColor: '#F5F5F5',
color: '#333333',
padding: 4,
borderRadius: 4,
fontSize: 8,
marginRight: 8,
marginBottom: 4,
},
});
export const PdfParamBadge = ({ children, style }: PdfParamBadgeProps) => {
return (
<View style={[styles.parameterBadge, ...(style ? [style] : [])]}>
<Text>{children}</Text>
</View>
);
};
@@ -0,0 +1,54 @@
import { Text, View, StyleSheet } from '@react-pdf/renderer';
import type { Style } from '@react-pdf/types';
type PdfStatusBadgeProps = {
children: React.ReactNode;
style?: Style;
};
const styles = StyleSheet.create({
statusBadge: {
paddingVertical: 2,
paddingHorizontal: 4,
borderRadius: 12,
fontSize: 7,
fontWeight: 'bold',
borderWidth: 1,
borderStyle: 'solid',
backgroundColor: '#F5F5F5',
borderColor: '#E5E7EB',
},
statusBadgeText: {
fontSize: 7,
fontWeight: 'bold',
color: '#333333',
},
});
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,
...(Object.keys(viewStyle).length > 0 ? [viewStyle as Style] : []),
]}
>
<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 };
@@ -0,0 +1,80 @@
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';
type TypographyVariant = Color | 'default';
type PdfTypographyProps = {
children: React.ReactNode;
size?: TypographySize;
variant?: TypographyVariant;
color?: string;
style?: Style;
};
const styles = StyleSheet.create({
h1: {
fontSize: 14,
fontWeight: 'bold',
marginBottom: 5,
},
h2: {
fontSize: 12,
fontWeight: 'bold',
marginBottom: 8,
},
h3: {
fontSize: 10,
fontWeight: 'bold',
marginBottom: 4,
},
h4: {
fontSize: 9,
fontWeight: 'bold',
marginBottom: 3,
},
p: {
fontSize: 10,
marginBottom: 4,
},
small: {
fontSize: 8,
marginBottom: 2,
},
label: {
fontSize: 9,
marginBottom: 5,
},
});
const variantColors: Record<TypographyVariant, string> = {
default: '#333333',
primary: '#1f74bf',
secondary: '#6B7280',
accent: '#8B5CF6',
neutral: '#6B7280',
info: '#3B82F6',
success: '#065F46',
warning: '#92400E',
error: '#DC2626',
none: '#333333',
};
export const PdfTypography = ({
children,
size = 'p',
variant = 'default',
color,
style,
}: PdfTypographyProps) => {
const sizeStyle = styles[size];
const textColor = color || variantColors[variant];
return (
<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;
};
+14 -12
View File
@@ -246,8 +246,8 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
className={cn(
'inline-flex items-center px-3 border border-r-0 border-base-content/10 rounded-l-lg transition-all duration-200',
{
'bg-gray-100 border-base-content/10': !isDisabled,
'bg-gray-50 border-base-content/10': isDisabled,
'bg-base-100 border-base-content/10': !isDisabled,
'bg-base-200 border-base-content/10': isDisabled,
'border-error': isError,
},
className?.inputPrefix
@@ -278,15 +278,16 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
className={cn('w-full flex-1', className?.select)}
classNames={{
control: ({ isFocused, isDisabled }) =>
cn('w-full border bg-white transition-shadow', 'rounded-lg!', {
cn('w-full border transition-shadow', 'rounded-lg!', {
'bg-base-100!': !isDisabled && !readOnly,
'bg-base-200! text-gray-400 cursor-not-allowed':
isDisabled && !readOnly,
'bg-transparent! cursor-not-allowed!': readOnly,
'cursor-pointer!': !readOnly && !isDisabled,
'border-red-500! ring-2 ring-red-200': isError,
'border-indigo-500 ring-2 ring-indigo-200':
isFocused && !startAdornment,
'border-base-content/10!': !isError && !isFocused,
'bg-gray-100 text-gray-400 cursor-not-allowed':
isDisabled && !readOnly,
'bg-transparent! cursor-not-allowed!': readOnly,
'rounded-l-none!': inputPrefix && !startAdornment,
'rounded-r-none!': inputSuffix && !startAdornment,
}),
@@ -370,8 +371,8 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
className={cn(
'inline-flex items-center px-3 border border-l-0 border-base-content/10 rounded-r-lg transition-all duration-200',
{
'bg-gray-100 border-base-content/10': !isDisabled,
'bg-gray-50 border-base-content/10': isDisabled,
'bg-base-100 border-base-content/10': !isDisabled,
'bg-base-200 border-base-content/10': isDisabled,
'border-error': isError,
},
className?.inputSuffix
@@ -404,18 +405,19 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
classNames={{
control: ({ isFocused, isDisabled }) =>
cn(
'w-full border bg-white transition-shadow',
'w-full border transition-shadow',
// Gunakan rounded-lg untuk semua kasus
'rounded-lg!',
{
'bg-base-100!': !isDisabled && !readOnly,
'bg-base-200! text-gray-400 cursor-not-allowed':
isDisabled && !readOnly,
'bg-transparent! cursor-not-allowed!': readOnly,
'cursor-pointer!': !readOnly && !isDisabled,
'border-red-500! ring-2 ring-red-200': isError,
'border-indigo-500 ring-2 ring-indigo-200':
isFocused && !startAdornment,
'border-base-content/10!': !isError && !isFocused,
'bg-gray-100 text-gray-400 cursor-not-allowed':
isDisabled && !readOnly,
'bg-transparent! cursor-not-allowed!': readOnly,
}
),
valueContainer: () => cn('flex-1 px-3! pr-2! py-2.5! gap-1'),
+18 -8
View File
@@ -104,8 +104,8 @@ const TextInput = ({
className={cn(
'inline-flex items-center px-3 border border-r-0 border-base-content/10 rounded-l-lg transition-all duration-200',
{
'bg-gray-100 border-base-content/10': !disabled,
'bg-gray-50 border-base-content/10': disabled,
'bg-base-100 border-base-content/10': !disabled,
'bg-base-200 border-base-content/10': disabled,
'border-error': isError,
'border-success!': isValid,
},
@@ -118,7 +118,7 @@ const TextInput = ({
<div
className={cn(
'input h-fit px-3 py-2.5 gap-1.5 text-sm font-normal leading-6 flex-1 rounded-lg! outline-none! transition-all duration-200 flex items-center bg-white border-base-content/10',
'input h-fit px-3 py-2.5 gap-1.5 text-sm font-normal leading-6 flex-1 rounded-lg! outline-none! transition-all duration-200 flex items-center border-base-content/10',
{
'border-error': isError,
'border-success!': isValid,
@@ -126,7 +126,8 @@ const TextInput = ({
'rounded-r-none!': inputSuffix,
'input-disabled': disabled,
'cursor-not-allowed': disabled,
'bg-gray-50': disabled,
'bg-base-100': !disabled,
'bg-base-200': disabled,
},
className?.inputWrapper
)}
@@ -167,8 +168,8 @@ const TextInput = ({
className={cn(
'inline-flex items-center px-3 border border-l-0 border-base-content/10 rounded-r-lg transition-all duration-200',
{
'bg-gray-100 border-base-content/10': !disabled,
'bg-gray-50 border-base-content/10': disabled,
'bg-base-100 border-base-content/10': !disabled,
'bg-base-200 border-base-content/10': disabled,
'border-error': isError,
'border-success!': isValid,
},
@@ -182,10 +183,12 @@ const TextInput = ({
) : (
<div
className={cn(
'input h-fit px-3 py-2.5 gap-1.5 text-sm font-normal leading-6 w-full rounded-lg! outline-none! transition-all duration-200 bg-white border-base-content/10',
'input h-fit px-3 py-2.5 gap-1.5 text-sm font-normal leading-6 w-full rounded-lg! outline-none! transition-all duration-200 flex items-center border-base-content/10',
{
'border-error': isError,
'border-success!': isValid,
'bg-base-100': !disabled,
'bg-base-200': disabled,
},
className?.inputWrapper
)}
@@ -201,7 +204,14 @@ const TextInput = ({
onChange={onChange}
onBlur={onBlur}
disabled={disabled}
className={cn('grow', className?.input)}
className={cn(
'grow bg-transparent outline-none',
{
'cursor-not-allowed': disabled,
'text-gray-500': disabled,
},
className?.input
)}
readOnly={readOnly}
/>
@@ -276,6 +276,13 @@ const ExpensesTable = () => {
);
},
},
{
accessorKey: 'reference_number',
header: 'Nomor Referensi',
cell: (props) => {
return props.row.original.reference_number ?? '-';
},
},
{
accessorKey: 'transaction_date',
header: 'Tanggal Pengajuan',
@@ -59,6 +59,10 @@ const DeliveryOrderFormModal = ({
const modalAction = searchParams.get('action');
const marketingId = searchParams.get('id');
const [currentModalAction, setCurrentModalAction] = useState<string | null>(
modalAction
);
const isModalActionForForm =
modalAction === 'add_delivery' ||
modalAction === 'edit_delivery' ||
@@ -107,6 +111,7 @@ const DeliveryOrderFormModal = ({
const successModal = useModal();
const rejectModal = useModal();
const deleteModal = useModal();
const approveModal = useModal();
const formRef = useRef<HTMLFormElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -329,6 +334,33 @@ const DeliveryOrderFormModal = ({
refreshApproval();
};
const approveMarketingHandler = async (notes: string) => {
if (!marketingId) {
toast.error(`Tidak ada data yang valid untuk di approve.`);
approveModal.closeModal();
return;
}
const approveMarketingRes = await SalesOrderApi.singleApproval(
Number(marketingId),
'APPROVED',
notes
);
if (isResponseSuccess(approveMarketingRes)) {
approveModal.closeModal();
toast.success(approveMarketingRes?.message as string);
closeModalHandler();
router.push('/marketing');
}
if (isResponseError(approveMarketingRes)) {
approveModal.closeModal();
toast.error(approveMarketingRes?.message as string);
}
refreshMarketing();
refreshApproval();
};
const deleteClickHandler = () => {
deleteModal.openModal();
};
@@ -376,7 +408,77 @@ const DeliveryOrderFormModal = ({
},
[prevButtonHandler]
);
const handleUpdateDO = useCallback(
const isApprovalStep3Approved = useMemo(() => {
return (
isResponseSuccess(marketing) &&
marketing.data.latest_approval?.step_number === 3 &&
marketing.data.latest_approval?.action === 'APPROVED'
);
}, [marketing]);
const handleUpdateDOWithAPI = useCallback(
async (id: number, values: DeliveryOrderProductFormValues) => {
if (!marketingId) {
toast.error('Marketing ID tidak ditemukan');
return;
}
setIsLoading(true);
const updatedDeliveryValues = deliveryOrderValues.map((product) =>
product.id === id ? { ...product, ...values } : product
);
const payload = {
marketing_id: Number(marketingId),
delivery_products: updatedDeliveryValues
.map((product) => {
if (Boolean(product.delivery_date)) {
return {
marketing_product_id: product.marketing_product_id as number,
unit_price: parseFloat(product.unit_price as string),
total_weight: parseFloat(product.total_weight as string),
qty: parseFloat(product.qty as string),
avg_weight: parseFloat(product.avg_weight as string),
total_price: parseFloat(product.total_price as string),
delivery_date: formatDate(
product.delivery_date as string,
'yyyy-MM-DD'
),
vehicle_number: product.vehicle_number,
};
}
})
.filter((item) => Boolean(item)),
} as UpdateDeliveryOrderPayload;
const updateDeliveryRes = await DeliveryOrderApi.update(
Number(marketingId),
payload
);
if (isResponseSuccess(updateDeliveryRes)) {
toast.success(updateDeliveryRes?.message as string);
closeModalHandler();
}
if (isResponseError(updateDeliveryRes)) {
setFormErrorMessage(updateDeliveryRes?.message as string);
}
setIsLoading(false);
},
[
marketingId,
deliveryOrderValues,
formik.values.sales_order,
prevButtonHandler,
refreshMarketing,
]
);
const handleUpdateDOLocal = useCallback(
async (id: number, values: DeliveryOrderProductFormValues) => {
setDeliveryOrderValues((prev) =>
prev.map((product) =>
@@ -420,17 +522,7 @@ const DeliveryOrderFormModal = ({
const deliveryRejected = useMemo(() => {
return (
isResponseSuccess(marketing) &&
((marketing.data.latest_approval.step_number === 3 &&
marketing.data.latest_approval.action === 'REJECTED') ||
(marketing.data.latest_approval.step_number === 2 &&
marketing.data.latest_approval.action === 'REJECTED'))
);
}, [marketing]);
const isPending = useMemo(() => {
return (
isResponseSuccess(marketing) &&
marketing.data.latest_approval.step_number === 1
marketing.data.latest_approval.action === 'REJECTED'
);
}, [marketing]);
@@ -441,6 +533,7 @@ const DeliveryOrderFormModal = ({
modalAction === 'edit_delivery' ||
modalAction === 'detail'
) {
setCurrentModalAction(modalAction);
formModal.openModal();
}
}, [modalAction]);
@@ -468,7 +561,26 @@ const DeliveryOrderFormModal = ({
);
formik.setValues(filledInitialValues);
setStep(1);
if (modalAction === 'add_delivery') {
// add delivery
const firstDeliveryItem = filledInitialValues.delivery_order?.[0];
if (firstDeliveryItem) {
setSelectedDeliveryProduct(firstDeliveryItem);
}
setStep(2); // Langsung ke form delivery
} else if (modalAction === 'edit_delivery') {
// edit delivery
const firstDeliveryItem = filledInitialValues.delivery_order?.[0];
if (firstDeliveryItem) {
setSelectedDeliveryProduct(firstDeliveryItem);
setStep(2); // Langsung ke form edit
} else {
setStep(1); // Jika belum ada data, tampilkan detail view
}
} else {
setStep(1); // Detail view
}
}
if (isResponseError(marketing)) {
@@ -479,7 +591,7 @@ const DeliveryOrderFormModal = ({
};
getFilledInitialValues();
}, [marketingId, marketing]);
}, [marketingId, marketing, modalAction]);
// Reset error message when step changes
useEffect(() => {
@@ -562,9 +674,11 @@ const DeliveryOrderFormModal = ({
</th>
</tr>
<tr>
<td className='text-sm px-4 py-3'>No. Sales Order</td>
<td className='text-sm px-4 py-3'>No. Order</td>
<td className='text-sm px-4 py-3'>
{marketing.data.so_number}
{marketing.data.do_number
? marketing.data.do_number
: marketing.data.so_number}
</td>
</tr>
<tr>
@@ -667,13 +781,7 @@ const DeliveryOrderFormModal = ({
<div className='px-4'>
<MemoizedDeliveryOrderProductTable
marketing={marketing.data}
formType={
deliveryRejected
? 'rejected'
: isPending
? 'pending'
: modalAction
}
formType={deliveryRejected ? 'rejected' : modalAction}
data={deliveryOrderValues}
onEdit={handleEditDO}
onDelete={handleDeleteDO}
@@ -688,7 +796,12 @@ const DeliveryOrderFormModal = ({
exisitingValues={deliveryOrderValues}
onSubmitForm={handleAddSubmitDO}
initialValues={selectedDeliveryProduct ?? undefined}
onUpdateForm={handleUpdateDO}
onUpdateForm={
isApprovalStep3Approved
? handleUpdateDOWithAPI
: handleUpdateDOLocal
}
isLoading={isLoading}
/>
)}
</div>
@@ -715,31 +828,40 @@ const DeliveryOrderFormModal = ({
/>
</div>
)}
{step === 1 && (
<div className='w-full px-4 py-3 grid grid-cols-2 items-center justify-between gap-3 border-t border-base-content/10'>
<Button
type='button'
variant='outline'
color='none'
onClick={() => rejectModal.openModal()}
disabled={deliveryRejected || isPending}
className='p-3 border-base-content/10 shadow-button-soft rounded-lg text-sm text-base-content/50 font-semibold'
>
Reject
</Button>
<Button
type='button'
color='primary'
onClick={() => {
formRef.current?.requestSubmit();
}}
className='p-3 shadow-button-soft text-base-100 rounded-lg text-sm font-semibold'
disabled={deliveryRejected || isPending}
>
Approve
</Button>
</div>
)}
{step === 1 &&
marketing?.data?.latest_approval?.step_number !== 3 && (
<div className='w-full px-4 py-3 grid grid-cols-2 items-center justify-between gap-3 border-t border-base-content/10'>
<Button
type='button'
variant='outline'
color='none'
onClick={() => rejectModal.openModal()}
disabled={deliveryRejected}
className='p-3 border-base-content/10 shadow-button-soft rounded-lg text-sm text-base-content/50 font-semibold'
>
Reject
</Button>
<Button
type='button'
color='primary'
onClick={() => {
// Jika masih di step 1 approval, gunakan single approval API
if (
marketing?.data?.latest_approval?.step_number === 1
) {
approveModal.openModal();
} else {
// Jika sudah di step 2/3, gunakan form submit (delivery products)
formRef.current?.requestSubmit();
}
}}
className='p-3 shadow-button-soft text-base-100 rounded-lg text-sm font-semibold'
disabled={deliveryRejected}
>
Approve
</Button>
</div>
)}
</div>
</>
)}
@@ -749,8 +871,8 @@ const DeliveryOrderFormModal = ({
ref={successModal.ref}
iconPosition='left'
type='success'
text={`${modalAction === 'add' ? 'Data Berhasil Disimpan' : 'Data Berhasil Diubah'}`}
subtitleText={`${modalAction === 'add' ? 'Data delivery order telah berhasil disimpan.' : 'Data delivery order telah berhasil diubah.'}`}
text={`${currentModalAction === 'add_delivery' ? 'Data Berhasil Disimpan' : 'Data Berhasil Diubah'}`}
subtitleText={`${currentModalAction === 'add_delivery' ? 'Data delivery order telah berhasil disimpan.' : 'Data delivery order telah berhasil diubah.'}`}
primaryButton={{
text: 'Oke',
color: 'primary',
@@ -760,14 +882,18 @@ const DeliveryOrderFormModal = ({
},
}}
>
<MemoizedDeliveryOrderProductTable
marketing={isResponseSuccess(marketing) ? marketing.data : undefined}
formType={'success'}
data={deliveryOrderValues}
onDelete={handleDeleteDO}
onEdit={handleEditDO}
onAddProductClick={handleAddDOClick}
/>
<div className='max-h-[50vh] overflow-y-auto'>
<MemoizedDeliveryOrderProductTable
marketing={
isResponseSuccess(marketing) ? marketing.data : undefined
}
formType={'success'}
data={deliveryOrderValues}
onDelete={handleDeleteDO}
onEdit={handleEditDO}
onAddProductClick={handleAddDOClick}
/>
</div>
</ConfirmationModal>
<ConfirmationModalWithNotes
@@ -799,6 +925,21 @@ const DeliveryOrderFormModal = ({
onClick: confirmationModalDeleteClickHandler,
}}
/>
<ConfirmationModalWithNotes
ref={approveModal.ref}
type={'success'}
text={`Apakah anda yakin ingin approve data penjualan?`}
secondaryButton={{
text: 'Tidak',
onClick: approveModal.closeModal,
}}
primaryButton={{
text: 'Ya',
color: 'success',
onClick: approveMarketingHandler,
}}
/>
</>
);
};
@@ -1,6 +1,6 @@
'use client';
import { RefObject } from 'react';
import { RefObject, useMemo } from 'react';
import { useFormik } from 'formik';
import { Icon } from '@iconify/react';
import Modal from '@/components/Modal';
@@ -9,10 +9,12 @@ import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { CustomerApi, ProductApi } from '@/services/api/master-data';
import { MARKETING_APPROVAL_LINE } from '@/config/approval-line';
import { MarketingFilter } from '@/types/api/marketing/marketing';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import { MarketingApi } from '@/services/api/marketing/marketing';
import { isResponseSuccess } from '@/lib/api-helper';
import { BaseMarketing, BaseSalesOrder } from '@/types/api/marketing/marketing';
interface MarketingFilterModal {
ref: RefObject<HTMLDialogElement | null>;
@@ -31,25 +33,59 @@ const MarketingFilterModal = ({
// ===== OPTIONS =====
const {
options: productsOptions,
rawData: productsRawData,
isLoadingOptions: isLoadingProductsOptions,
setInputValue: setProductsInputValue,
loadMore: loadMoreProducts,
} = useSelect(ProductApi.basePath, 'id', 'name', '', {
} = useSelect<BaseMarketing>(MarketingApi.basePath, 'id', 'so_number', '', {
limit: 'limit',
});
const productsOptions = useMemo(() => {
if (!productsRawData || !isResponseSuccess(productsRawData)) return [];
const productsMap = new Map<number, { value: number; label: string }>();
productsRawData.data.forEach((deliveryOrder: BaseMarketing) => {
deliveryOrder.sales_order?.forEach((so: BaseSalesOrder) => {
const product = so.product_warehouse?.product;
if (product?.id && product?.name) {
productsMap.set(product.id, {
value: product.id,
label: product.name,
});
}
});
});
return Array.from(productsMap.values());
}, [productsRawData]);
const {
options: customersOptions,
isLoadingOptions: isLoadingCustomersOptions,
setInputValue: setCustomersInputValue,
loadMore: loadMoreCustomers,
} = useSelect(CustomerApi.basePath, 'id', 'name', '', {
} = useSelect(MarketingApi.basePath, 'customer.id', 'customer.name', '', {
limit: 'limit',
});
const statusOptions = MARKETING_APPROVAL_LINE.map((item) => ({
value: item.step_name.split(' ').join('_').toUpperCase(),
label: item.step_name,
}));
const uniqueCustomersOptions = useMemo(() => {
const seen = new Set();
return customersOptions.filter((customer) => {
if (seen.has(customer.value)) return false;
seen.add(customer.value);
return true;
});
}, [customersOptions]);
const statusOptions = [
...MARKETING_APPROVAL_LINE.map((item) => ({
value: item.step_name.split(' ').join('_').toUpperCase(),
label: item.step_name,
})),
{ value: 'DITOLAK', label: 'Ditolak' },
];
const formik = useFormik<{
product_ids: OptionType[];
@@ -151,7 +187,7 @@ const MarketingFilterModal = ({
label='Customer'
isClearable
placeholder='Pilih customer'
options={customersOptions}
options={uniqueCustomersOptions}
isLoading={isLoadingCustomersOptions}
value={formik.values.customer_id}
onChange={customerChangeHandler}
@@ -109,7 +109,9 @@ const RowsOptionsMenu = ({
className='p-3 justify-start text-sm font-semibold w-full'
>
<Icon icon='heroicons:truck' width={20} height={20} />
Deliver Item
{props.row.original.latest_approval.step_number == 2
? 'Deliver Item'
: 'Edit Delivery'}
</Button>
</RequirePermission>
</>
@@ -379,8 +381,13 @@ const MarketingTable = () => {
},
},
{
accessorKey: 'so_number',
accessorKey: 'so_do_number',
header: 'No. Order',
cell: (props) => {
return props.row.original.do_number
? props.row.original.do_number
: props.row.original.so_number;
},
},
{
accessorKey: 'so_date',
@@ -408,7 +415,7 @@ const MarketingTable = () => {
: approval?.step_number == 2
? 'info'
: approval?.step_number == 3
? 'warning'
? 'success'
: 'neutral'
: 'neutral'
}
@@ -63,6 +63,10 @@ const SalesOrderFormModal = ({
const modalAction = searchParams.get('action');
const marketingId = searchParams.get('id');
const [currentModalAction, setCurrentModalAction] = useState<string | null>(
modalAction
);
const isModalActionForForm =
modalAction === 'add' ||
modalAction === 'edit' ||
@@ -194,6 +198,13 @@ const SalesOrderFormModal = ({
: 'KG' // termasuk "QTY" dan "KG"
: undefined;
// Jika value dari data product ada week, kirim "AYAM_PULLET, jika tidak ada kirim "AYAM"
let marketingTypeValue =
product.marketing_type?.value?.toUpperCase() || '';
if (marketingTypeValue === 'AYAM,AYAM_PULLET') {
marketingTypeValue = product.week ? 'AYAM_PULLET' : 'AYAM';
}
return {
vehicle_number: product.vehicle_number as string,
kandang_id: product.kandang_id as number,
@@ -203,12 +214,11 @@ const SalesOrderFormModal = ({
qty: parseFloat(String(product.qty || 0)),
avg_weight: parseFloat(String(product.avg_weight || 0)),
total_price: parseFloat(String(product.total_price || 0)),
marketing_type:
product.marketing_type?.value?.toUpperCase() || '',
marketing_type: marketingTypeValue,
convertion_unit: normalizedConvertionUnit,
weight_per_convertion:
product.weight_per_convertion ?? undefined,
week: product.week?.value ?? undefined,
week: product.week ?? undefined,
} as CreateSalesOrderProductPayload;
}),
} as CreateSalesOrderPayload)
@@ -390,7 +400,7 @@ const SalesOrderFormModal = ({
}
formik.setFieldValue('sales_order', updatedProducts);
console.log(formik.values);
setSelectedMarketingProduct(null);
nextButtonHandler();
},
[memoSalesOrder, nextButtonHandler]
@@ -412,7 +422,17 @@ const SalesOrderFormModal = ({
// ================== EFFECT ==================
useEffect(() => {
if (modalAction === 'add' || modalAction === 'edit') {
setCurrentModalAction(modalAction);
formModal.openModal();
if (modalAction === 'add') {
formik.resetForm();
setStep(1);
setSelectedMarketingProduct(null);
setSelectedDeliveryProduct(null);
setFormErrorMessage('');
setFormErrorList([]);
}
}
}, [modalAction]);
@@ -724,8 +744,8 @@ const SalesOrderFormModal = ({
ref={successModal.ref}
iconPosition='left'
type='success'
text={`${modalAction === 'add' ? 'Data Berhasil Ditambahkan' : 'Data Berhasil Diubah'}`}
subtitleText={`${modalAction === 'add' ? 'Data sales order telah berhasil disimpan.' : 'Data sales order telah berhasil diubah.'}`}
text={`${currentModalAction === 'add' ? 'Data Berhasil Ditambahkan' : 'Data Berhasil Diubah'}`}
subtitleText={`${currentModalAction === 'add' ? 'Data sales order telah berhasil disimpan.' : 'Data sales order telah berhasil diubah.'}`}
primaryButton={{
text: 'Oke',
color: 'primary',
@@ -735,13 +755,15 @@ const SalesOrderFormModal = ({
},
}}
>
<MemoizedSalesOrderProductTable
formType={'success'}
data={memoSalesOrder}
onDelete={handleDeleteSO}
onEdit={handleEditSO}
onAddProductClick={handleAddSOClick}
/>
<div className='max-h-[50vh] overflow-y-auto'>
<MemoizedSalesOrderProductTable
formType={'success'}
data={memoSalesOrder}
onDelete={handleDeleteSO}
onEdit={handleEditSO}
onAddProductClick={handleAddSOClick}
/>
</div>
</ConfirmationModal>
<ConfirmationModal
@@ -128,12 +128,7 @@ export const SalesProductToFieldValues = (
label: formatTitleCase(product.convertion_unit),
}
: null,
week: product.week
? {
value: product.week,
label: `Week ${product.week}`,
}
: null,
week: product.week ?? null,
total_peti: product.total_peti,
weight_per_convertion: product.weight_per_convertion,
uom: product.product_warehouse.product.uom.name,
@@ -30,13 +30,7 @@ type DeliveryOrderProductSchemaType = {
/** Harga per butir telur untuk TELUR + QTY */
price_per_qty?: number | null | undefined;
/** Week untuk ayam pullet */
week?:
| {
value?: number;
label?: string;
}
| null
| undefined;
week?: number | null | undefined;
};
export const DeliveryOrderProductSchema: Yup.ObjectSchema<DeliveryOrderProductSchemaType> =
@@ -79,26 +73,18 @@ export const DeliveryOrderProductSchema: Yup.ObjectSchema<DeliveryOrderProductSc
sisa_berat: Yup.number().nullable().optional().notRequired(),
price_sisa_berat: Yup.number().nullable().optional().notRequired(),
price_per_qty: Yup.number().nullable().optional().notRequired(),
week: Yup.object({
value: Yup.number(),
label: Yup.string(),
})
week: Yup.number()
.nullable()
.default(null)
.optional()
.notRequired()
.when('marketing_type', {
is: (marketingType: { value: string } | null | undefined) =>
marketingType?.value?.toLowerCase() === 'ayam_pullet',
then: (schema) =>
schema
.shape({
value: Yup.number().required(
'Week wajib diisi untuk Ayam Pullet!'
),
label: Yup.string().required(
'Week wajib diisi untuk Ayam Pullet!'
),
})
.required('Week wajib diisi untuk Ayam Pullet!'),
.min(1, 'Week wajib diisi untuk Ayam Pullet!')
.required('Week wajib diisi untuk Ayam Pullet!')
.typeError('Week harus berupa angka!'),
otherwise: (schema) => schema.optional().notRequired(),
}),
});
@@ -36,6 +36,7 @@ const DeliveryOrderProductForm = ({
exisitingValues,
onSubmitForm,
onUpdateForm,
isLoading,
}: {
formState: 'add' | 'edit';
salesOrders: BaseSalesOrder[];
@@ -46,6 +47,7 @@ const DeliveryOrderProductForm = ({
id: number,
value: DeliveryOrderProductFormValues
) => Promise<void>;
isLoading?: boolean;
}) => {
const [formikErrorMessage, setFormErrorMessage] = useState('');
const [selectedProduct, setSelectedProduct] = useState<OptionType | null>(
@@ -178,6 +180,25 @@ const DeliveryOrderProductForm = ({
},
});
const hasWeekField = useMemo(() => {
const marketingType = formik.values.marketing_type?.value?.toLowerCase();
if (marketingType === 'ayam_pullet') {
return true;
}
if (formik.values.marketing_product?.product_warehouse_data) {
return Boolean(
formik.values.marketing_product?.product_warehouse_data?.week !==
undefined &&
formik.values.marketing_product?.product_warehouse_data?.week !==
null &&
formik.values.marketing_product?.product_warehouse_data?.week > 0
);
}
return false;
}, [formik.values.marketing_product, formik.values.marketing_type]);
const handleResetForm = () => {
setFormErrorMessage('');
formik.resetForm({
@@ -362,20 +383,24 @@ const DeliveryOrderProductForm = ({
avg_weight: '',
total_weight: '',
vehicle_number: '',
week: null,
});
return;
}
const soFieldValues = SalesProductToFieldValues(so);
formik.setValues({
...formik.values,
marketing_product_id: selected.value as number,
marketing_product: SalesProductToFieldValues(so),
marketing_product: soFieldValues,
qty: so.qty,
unit_price: so.unit_price,
total_price: so.total_price,
avg_weight: so.avg_weight,
total_weight: so.total_weight,
vehicle_number: so.vehicle_number,
week: soFieldValues.week ?? null,
});
}}
startAdornment={
@@ -509,21 +534,26 @@ const DeliveryOrderProductForm = ({
)}
{/* Konversi Satuan Week Pullet */}
{formik.values.marketing_type?.value.toLowerCase() ===
'ayam_pullet' && (
<SelectInputRadio
required
label='Minggu'
options={optionsWeek}
value={
formik.values.week?.value
? (formik.values.week as { value: number; label: string })
: null
{(formik.values.marketing_type?.value.toLowerCase() ===
'ayam_pullet' ||
hasWeekField) && (
<NumberInput
required={
formik.values.marketing_type?.value.toLowerCase() ===
'ayam_pullet'
}
onChange={(val) => {
formik.setFieldValue('week', val);
label='Minggu'
name='week'
value={formik.values.week ?? undefined}
onChange={(e) => {
formik.setFieldValue('week', Number(e.target.value));
setCurrentInput(e.target.name);
}}
placeholder='Pilih Week'
onBlur={() => handleBlurField('week')}
isError={formik.touched.week && Boolean(formik.errors.week)}
errorMessage={formik.errors.week as string}
placeholder='Masukan Minggu'
decimalScale={0}
/>
)}
@@ -792,8 +822,8 @@ const DeliveryOrderProductForm = ({
<div className='absolute sm:w-full bottom-0 right-0 p-4'>
<Button
type='submit'
isLoading={formik.isSubmitting}
disabled={formik.isSubmitting}
isLoading={formik.isSubmitting || isLoading}
disabled={formik.isSubmitting || isLoading}
className='w-full p-3 rounded-lg text-base-100 text-sm font-semibold'
>
Submit
@@ -37,13 +37,7 @@ type SalesOrderProductSchemaType = {
/** Harga per butir telur untuk TELUR + QTY */
price_per_qty?: number | null | undefined;
/** Week untuk ayam pullet */
week?:
| {
value?: number;
label?: string;
}
| null
| undefined;
week?: number | null | undefined;
};
export const SalesOrderProductSchema: Yup.ObjectSchema<SalesOrderProductSchemaType> =
@@ -102,26 +96,18 @@ export const SalesOrderProductSchema: Yup.ObjectSchema<SalesOrderProductSchemaTy
sisa_berat: Yup.number().nullable().optional().notRequired(),
price_sisa_berat: Yup.number().nullable().optional().notRequired(),
price_per_qty: Yup.number().nullable().optional().notRequired(),
week: Yup.object({
value: Yup.number(),
label: Yup.string(),
})
week: Yup.number()
.nullable()
.default(null)
.optional()
.notRequired()
.when('marketing_type', {
is: (marketingType: { value: string } | null | undefined) =>
marketingType?.value?.toLowerCase() === 'ayam_pullet',
then: (schema) =>
schema
.shape({
value: Yup.number().required(
'Week wajib diisi untuk Ayam Pullet!'
),
label: Yup.string().required(
'Week wajib diisi untuk Ayam Pullet!'
),
})
.required('Week wajib diisi untuk Ayam Pullet!'),
.min(1, 'Week wajib diisi untuk Ayam Pullet!')
.required('Week wajib diisi untuk Ayam Pullet!')
.typeError('Week harus berupa angka!'),
otherwise: (schema) => schema.optional().notRequired(),
}),
});
@@ -117,6 +117,19 @@ const SalesOrderProductForm = ({
isInitialValid: false,
});
const hasWeekField = useMemo(() => {
const marketingType = formik.values.marketing_type?.value?.toLowerCase();
if (marketingType === 'ayam_pullet') {
return true;
}
return Boolean(
selectedProductWarehouse?.week !== undefined &&
selectedProductWarehouse?.week !== null &&
selectedProductWarehouse?.week > 0
);
}, [selectedProductWarehouse, formik.values.marketing_type]);
// ===== Options =====
const {
options: kandangSourceOptions,
@@ -180,10 +193,20 @@ const SalesOrderProductForm = ({
setSelectedProductWarehouse(productWarehouse || null);
formik.setFieldValue('qty', productWarehouse?.quantity);
formik.setFieldValue('uom', productWarehouse?.product?.uom?.name || '');
if (
productWarehouse?.week !== undefined &&
productWarehouse?.week !== null &&
productWarehouse?.week > 0
) {
formik.setFieldValue('week', productWarehouse.week);
} else {
formik.setFieldValue('week', null);
}
handleBlurField('qty');
} else {
formik.setFieldValue('qty', '');
formik.setFieldValue('uom', '');
formik.setFieldValue('week', null);
}
};
@@ -465,21 +488,26 @@ const SalesOrderProductForm = ({
)}
{/* Konversi Satuan Week Pullet */}
{formik.values.marketing_type?.value.toLowerCase() ===
'ayam_pullet' && (
<SelectInputRadio
required
label='Minggu'
options={optionsWeek}
value={
formik.values.week?.value
? (formik.values.week as { value: number; label: string })
: null
{(formik.values.marketing_type?.value.toLowerCase() ===
'ayam_pullet' ||
hasWeekField) && (
<NumberInput
required={
formik.values.marketing_type?.value.toLowerCase() ===
'ayam_pullet'
}
onChange={(val) => {
formik.setFieldValue('week', val);
label='Minggu'
name='week'
value={formik.values.week ?? undefined}
onChange={(e) => {
formik.setFieldValue('week', Number(e.target.value));
setCurrentInput(e.target.name);
}}
placeholder='Pilih Week'
onBlur={() => handleBlurField('week')}
isError={formik.touched.week && Boolean(formik.errors.week)}
errorMessage={formik.errors.week as string}
placeholder='Masukan Minggu'
decimalScale={0}
/>
)}
@@ -1,5 +1,6 @@
import { DeliveryOrderProductFormValues } from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.schema';
import Button from '@/components/Button';
import Card from '@/components/Card';
import { Icon } from '@iconify/react';
import { useRef } from 'react';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
@@ -18,6 +19,7 @@ type DeliveryOrderProductTableProps = {
| 'detail'
| 'rejected'
| 'pending'
| 'success'
| string
| null;
marketing?: Marketing;
@@ -31,7 +33,6 @@ const DeliveryOrderProductTable = ({
formType,
onEdit,
onDelete,
onAddProductClick,
marketing,
}: DeliveryOrderProductTableProps) => {
const onEditRef = useRef(onEdit);
@@ -39,161 +40,195 @@ const DeliveryOrderProductTable = ({
const onDeleteRef = useRef(onDelete);
onDeleteRef.current = onDelete;
const approvalStepNumber = marketing?.latest_approval?.step_number;
const renderTableContent = (item: DeliveryOrderProductFormValues) => {
const doItem = marketing?.delivery_order?.find(
(doItem) => doItem.do_number === item.do_number
);
return (
<>
<tr className='border-b border-tools-table-outline border-base-content/5'>
<th className='w-1/3 text-start not-first:font-medium text-base-content/50 text-sm px-4 py-3'>
Label
</th>
<th className='text-start font-medium text-base-content/50 text-sm px-4 py-3'>
<div className='flex w-full flex-row gap-1 items-center justify-between h-full'>
<div>Value</div>
{formType !== 'success' &&
(formType === 'add_delivery' ||
formType === 'edit_delivery' ||
formType === 'detail') && (
<div className='flex flex-row gap-1.5 items-center'>
<Button
type='button'
variant='ghost'
color='none'
onClick={() => {
onEditRef.current(item.id as number, item);
}}
className='p-0 hover:text-base-content'
>
<Icon icon='heroicons:pencil' width={20} height={20} />
</Button>
</div>
)}
</div>
</th>
</tr>
<>
<tr>
<td className='text-sm px-4 py-3'>Gudang</td>
<td className='text-sm px-4 py-3'>
{doItem?.warehouse?.name ||
item.marketing_product?.product_warehouse_data?.warehouse?.name}
</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Produk</td>
<td className='text-sm px-4 py-3'>
{item.marketing_product?.product_warehouse?.label}
</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Qty</td>
<td className='text-sm px-4 py-3'>
{item.qty
? `${formatNumber(parseFloat(item.qty as string))} ${item.marketing_product?.uom ?? ''}`
: '-'}
</td>
</tr>
{Number(item.avg_weight ?? 0) > 0 && (
<tr>
<td className='text-sm px-4 py-3'>Avg Bobot</td>
<td className='text-sm px-4 py-3'>
{formatNumber(Number(item.avg_weight))} Kg
</td>
</tr>
)}
{Number(item.total_weight ?? 0) > 0 && (
<tr>
<td className='text-sm px-4 py-3'>Total Bobot</td>
<td className='text-sm px-4 py-3'>
{formatNumber(Number(item.total_weight))}
</td>
</tr>
)}
<tr>
<td className='text-sm px-4 py-3'>Total Harga Satuan</td>
<td className='text-sm px-4 py-3'>
{formatCurrency(parseFloat(item.unit_price as string))}
</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Total Penjualan</td>
<td className='text-sm px-4 py-3'>
{formatCurrency(parseFloat(item.total_price as string))}
</td>
</tr>
</>
<tr className='border-b border-t border-tools-table-outline border-base-content/5'>
<th className='w-1/3 text-start not-first:font-medium text-base-content/50 text-sm px-4 py-3'>
Label
</th>
<th className='text-start font-medium text-base-content/50 text-sm px-4 py-3'>
<div className='flex w-full flex-row gap-1 items-center justify-between h-full'>
<div>Value</div>
</div>
</th>
</tr>
<>
{approvalStepNumber !== 1 && (
<tr>
<td className='text-sm px-4 py-3'>Tanggal Pengiriman</td>
<td className='text-sm px-4 py-3'>
{item.delivery_date ? (
formatDate(item.delivery_date, 'DD MMM YYYY')
) : formType === 'add_delivery' ||
formType === 'edit_delivery' ||
formType === 'detail' ? (
<span
className='text-error hover:text-error/70 cursor-pointer hover:underline underline-offset-4'
onClick={() => {
onEditRef.current(item.id as number, item);
}}
>
Belum diisi
</span>
) : (
<span className='text-error'>Belum diisi</span>
)}
</td>
</tr>
)}
{item.do_number && (
<tr>
<td className='text-sm px-4 py-3'>No. Pengiriman</td>
<td className='text-sm px-4 py-3'>{item.do_number}</td>
</tr>
)}
<tr>
<td className='text-sm px-4 py-3'>No. Polisi</td>
<td className='text-sm px-4 py-3'>{item.vehicle_number}</td>
</tr>
{doItem && (
<tr>
<td className='text-sm px-4 py-3'>Dokumen Pengiriman</td>
<td className='text-sm px-4 py-3'>
<DeliveryOrderExport data={marketing} deliveryOrder={doItem} />
</td>
</tr>
)}
</>
</>
);
};
return (
<>
<div className='size-full flex flex-col relative overflow-x-hidden gap-3'>
{data.map((item) => {
const doItem = marketing?.delivery_order?.find(
(doItem) => doItem.do_number === item.do_number
);
return (
<div
className='rounded-lg border border-tools-table-outline border-base-content/5'
key={`table-${item.id}`}
>
<table
style={{
borderRadius: '0.5rem',
{data.map((item) => (
<div key={`table-${item.id}`}>
{formType === 'success' ? (
<div className='rounded-lg border border-tools-table-outline border-base-content/5'>
<table
style={{
borderRadius: '0.5rem',
}}
className='border-none w-full'
>
<tbody className='w-full'>{renderTableContent(item)}</tbody>
</table>
</div>
) : (
<Card
key={`table-${item.id}`}
title={
item.marketing_product?.product_warehouse?.label || 'Produk'
}
collapsible={true}
defaultCollapsed={false}
variant='bordered'
className={{
wrapper: 'w-full rounded-lg',
body: 'p-0',
title: 'px-2 py-1.5 font-normal text-sm',
collapsible: 'rounded-lg',
}}
className='border-none w-full'
>
<tbody className='w-full'>
<tr className='border-b border-tools-table-outline border-base-content/5'>
<th className='w-1/3 text-start not-first:font-medium text-base-content/50 text-sm px-4 py-3'>
Label
</th>
<th className='text-start font-medium text-base-content/50 text-sm px-4 py-3'>
<div className='flex w-full flex-row gap-1 items-center justify-between h-full mt-2'>
<div>Value</div>
{(formType === 'add_delivery' ||
formType === 'edit_delivery' ||
formType === 'detail') && (
<div className='flex flex-row gap-1.5 items-center'>
<Button
type='button'
variant='ghost'
color='none'
onClick={() => {
onEditRef.current(item.id as number, item);
}}
className='p-0 hover:text-base-content'
>
<Icon
icon='heroicons:pencil'
width={20}
height={20}
/>
</Button>
<Button
type='button'
variant='ghost'
color='none'
onClick={() => {
onDeleteRef.current(item.id as number);
}}
className='p-0 text-error hover:text-base-content'
>
<Icon
icon='heroicons:trash'
width={20}
height={20}
/>
</Button>
</div>
)}
</div>
</th>
</tr>
<>
<tr>
<td className='text-sm px-4 py-3'>Tanggal Pengiriman</td>
<td className='text-sm px-4 py-3'>
{item.delivery_date ? (
formatDate(item.delivery_date, 'DD MMM YYYY')
) : (
<span className='text-error'>Belum diisi</span>
)}
</td>
</tr>
{item.do_number && (
<tr>
<td className='text-sm px-4 py-3'>No. Pengiriman</td>
<td className='text-sm px-4 py-3'>{item.do_number}</td>
</tr>
)}
<tr>
<td className='text-sm px-4 py-3'>No. Polisi</td>
<td className='text-sm px-4 py-3'>
{item.vehicle_number}
</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Gudang</td>
<td className='text-sm px-4 py-3'>
{item.marketing_product?.product_warehouse?.label}
</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Produk</td>
<td className='text-sm px-4 py-3'>
{item.marketing_product?.product_warehouse?.label}
</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Qty</td>
<td className='text-sm px-4 py-3'>
{item.qty
? `${formatNumber(parseFloat(item.qty as string))} ${item.marketing_product?.uom ?? ''}`
: '-'}
</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Avg Bobot</td>
<td className='text-sm px-4 py-3'>
{item.avg_weight
? formatNumber(
parseFloat(item.avg_weight as string)
) + ' Kg'
: '-'}
</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Total Bobot</td>
<td className='text-sm px-4 py-3'>
{formatNumber(parseFloat(item.total_weight as string))}
</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Total Harga Satuan</td>
<td className='text-sm px-4 py-3'>
{formatCurrency(parseFloat(item.unit_price as string))}
</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Total Penjualan</td>
<td className='text-sm px-4 py-3'>
{formatCurrency(parseFloat(item.total_price as string))}
</td>
</tr>
{doItem && (
<tr>
<td className='text-sm px-4 py-3'>
Dokumen Pengiriman
</td>
<td className='text-sm px-4 py-3'>
<DeliveryOrderExport
data={marketing}
deliveryOrder={doItem}
/>
</td>
</tr>
)}
</>
</tbody>
</table>
</div>
);
})}
<table
style={{
borderRadius: '0.5rem',
}}
className='border-none w-full'
>
<tbody className='w-full'>{renderTableContent(item)}</tbody>
</table>
</Card>
)}
</div>
))}
</div>
</>
);
@@ -1,16 +1,11 @@
'use client';
import Button from '@/components/Button';
import Card from '@/components/Card';
import { SalesOrderProductFormValues } from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema';
import {
formatCurrency,
formatNumber,
formatVechicleNumber,
} from '@/lib/helper';
import { formatCurrency, formatNumber } from '@/lib/helper';
import { Icon } from '@iconify/react';
import { useMemo, useRef } from 'react';
import * as TanStack from '@tanstack/react-table';
import CheckboxInput from '@/components/input/CheckboxInput';
import { useRef } from 'react';
type SalesOrderProductTableProps = {
data: SalesOrderProductFormValues[];
@@ -32,268 +27,167 @@ const SalesOrderProductTable = ({
const onEditRef = useRef(onEdit);
onEditRef.current = onEdit;
const columns = useMemo(
() => [
{
id: 'select',
header: ({
table,
}: {
table: TanStack.Table<SalesOrderProductFormValues>;
}) => (
<div className='w-full flex flex-row justify-center'>
<CheckboxInput
name='allRow'
checked={table.getIsAllRowsSelected()}
indeterminate={table.getIsSomeRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
const renderTableContent = (item: SalesOrderProductFormValues) => (
<>
<tr className='border-b border-tools-table-outline border-base-content/5'>
<th className='w-1/3 text-start not-first:font-medium text-base-content/50 text-sm px-4 py-3'>
Label
</th>
<th className='text-start font-medium text-base-content/50 text-sm px-4 py-3'>
<div className='flex w-full flex-row gap-1 items-center justify-between h-full'>
<div>Value</div>
{formType !== 'success' && (
<div className='flex flex-row gap-1.5 items-center'>
<Button
type='button'
variant='ghost'
color='none'
onClick={() => {
onEditRef.current(item.id as number);
}}
className='p-0 hover:text-base-content'
>
<Icon icon='heroicons:pencil' width={20} height={20} />
</Button>
<Button
type='button'
variant='ghost'
color='none'
onClick={() => {
onDeleteRef.current(item.id as number);
}}
className='p-0 text-error hover:text-base-content'
>
<Icon icon='heroicons:trash' width={20} height={20} />
</Button>
</div>
)}
</div>
),
cell: ({ row }: { row: TanStack.Row<SalesOrderProductFormValues> }) => (
<div>
<CheckboxInput
name='row'
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
value={`${row.original.product_warehouse_id}${row.original.kandang_id}`}
/>
</div>
),
},
{
accessorFn: (row: SalesOrderProductFormValues) =>
formatVechicleNumber(row.vehicle_number as string),
header: 'No. Polisi',
},
{
accessorFn: (row: SalesOrderProductFormValues) => row.kandang?.label,
header: 'Kandang',
},
{
accessorFn: (row: SalesOrderProductFormValues) =>
row.product_warehouse?.label,
header: 'Produk',
},
{
accessorFn: (row: SalesOrderProductFormValues) =>
formatCurrency(parseFloat(row.unit_price as string)),
header: 'Harga Satuan (Rp)',
},
{
accessorFn: (row: SalesOrderProductFormValues) =>
formatNumber(parseFloat(row.total_weight as string), undefined, 0, 5),
header: 'Total Bobot (Kg)',
},
{
accessorFn: (row: SalesOrderProductFormValues) =>
formatNumber(parseFloat(row.qty as string)),
header: 'Kuantitas',
cell: ({ row }: { row: TanStack.Row<SalesOrderProductFormValues> }) =>
formatNumber(
parseFloat(row.original.qty as string),
undefined,
0,
5
) +
' ' +
(row.original.uom ?? ''),
},
{
accessorFn: (row: SalesOrderProductFormValues) =>
formatNumber(parseFloat(row.avg_weight as string), undefined, 0, 5),
header: 'Avg. Bobot (Kg)',
},
{
accessorFn: (row: SalesOrderProductFormValues) =>
formatCurrency(parseFloat(row.total_price as string)),
header: 'Total Penjualan (Rp)',
},
{
header: 'Aksi',
cell: (
props: TanStack.CellContext<SalesOrderProductFormValues, unknown>
) => (
<div className='flex flex-row gap-1 items-center justify-end h-full mt-2'>
<Button
color='warning'
className='p-1'
onClick={() => onEditRef.current(props.row.original.id as number)}
type='button'
>
<Icon icon='mdi:pencil' width={16} height={16} /> Edit
</Button>
<Button
color='error'
className='p-1'
onClick={() =>
onDeleteRef.current(props.row.original.id as number)
}
type='button'
>
<Icon icon='mdi:trash' width={16} height={16} /> Hapus
</Button>
</div>
),
},
],
[]
</th>
</tr>
<>
<tr>
<td className='text-sm px-4 py-3'>No. Polisi</td>
<td className='text-sm px-4 py-3'>{item.vehicle_number}</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Gudang</td>
<td className='text-sm px-4 py-3'>{item.kandang?.label}</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Kategori</td>
<td className='text-sm px-4 py-3'>{item.marketing_type?.label}</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Produk</td>
<td className='text-sm px-4 py-3'>{item.product_warehouse?.label}</td>
</tr>
{item.marketing_type?.value.toLowerCase() === 'telur' && (
<tr>
<td className='text-sm px-4 py-3'>Tipe Konversi</td>
<td className='text-sm px-4 py-3'>{item.convertion_unit?.label}</td>
</tr>
)}
{item.marketing_type?.value.toLowerCase() === 'ayam_pullet' && (
<tr>
<td className='text-sm px-4 py-3'>Tipe Konversi</td>
<td className='text-sm px-4 py-3'>Week {item.week}</td>
</tr>
)}
{item.convertion_unit?.value.toLowerCase() === 'peti' && (
<tr>
<td className='text-sm px-4 py-3'>Total Peti</td>
<td className='text-sm px-4 py-3'>
{item.total_peti} {item.convertion_unit?.label}
</td>
</tr>
)}
{item.marketing_type?.value.toLowerCase() !== 'trading' && (
<>
<tr>
<td className='text-sm px-4 py-3'>Total Bobot</td>
<td className='text-sm px-4 py-3'>
{item.total_weight
? formatNumber(parseFloat(item.total_weight as string)) +
' Kg'
: '0 Kg'}
</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Avg Bobot</td>
<td className='text-sm px-4 py-3'>
{item.avg_weight
? formatNumber(parseFloat(item.avg_weight as string)) + ' Kg'
: '0 Kg'}
</td>
</tr>
</>
)}
<tr>
<td className='text-sm px-4 py-3'>
{item.marketing_type?.value === 'telur'
? 'Total Butir Telur'
: 'Qty'}
</td>
<td className='text-sm px-4 py-3'>
{`${formatNumber(parseFloat(item.qty as string))} ${item.uom || ''}`}
</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Harga Satuan</td>
<td className='text-sm px-4 py-3'>
{formatCurrency(parseFloat(item.unit_price as string))}
</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Total Penjualan</td>
<td className='text-sm px-4 py-3'>
{formatCurrency(parseFloat(item.total_price as string))}
</td>
</tr>
</>
</>
);
return (
<>
<div className='size-full flex flex-col relative overflow-x-hidden gap-3'>
{data.map((item) => (
<div
className='rounded-lg border border-tools-table-outline border-base-content/5'
key={`table-${item.id}`}
>
<table
style={{
borderRadius: '0.5rem',
}}
className='border-none w-full'
>
<tbody className='w-full'>
<tr className='border-b border-tools-table-outline border-base-content/5'>
<th className='w-1/3 text-start not-first:font-medium text-base-content/50 text-sm px-4 py-3'>
Label
</th>
<th className='text-start font-medium text-base-content/50 text-sm px-4 py-3'>
<div className='flex w-full flex-row gap-1 items-center justify-between h-full mt-2'>
<div>Value</div>
{formType !== 'success' && (
<div className='flex flex-row gap-1.5 items-center'>
<Button
type='button'
variant='ghost'
color='none'
onClick={() => {
onEditRef.current(item.id as number);
}}
className='p-0 hover:text-base-content'
>
<Icon
icon='heroicons:pencil'
width={20}
height={20}
/>
</Button>
<Button
type='button'
variant='ghost'
color='none'
onClick={() => {
onDeleteRef.current(item.id as number);
}}
className='p-0 text-error hover:text-base-content'
>
<Icon
icon='heroicons:trash'
width={20}
height={20}
/>
</Button>
</div>
)}
</div>
</th>
</tr>
<>
<tr>
<td className='text-sm px-4 py-3'>No. Polisi</td>
<td className='text-sm px-4 py-3'>{item.vehicle_number}</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Gudang</td>
<td className='text-sm px-4 py-3'>{item.kandang?.label}</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Kategori</td>
<td className='text-sm px-4 py-3'>
{item.marketing_type?.label}
</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Produk</td>
<td className='text-sm px-4 py-3'>
{item.product_warehouse?.label}
</td>
</tr>
{item.marketing_type?.value.toLowerCase() === 'telur' && (
<tr>
<td className='text-sm px-4 py-3'>Tipe Konversi</td>
<td className='text-sm px-4 py-3'>
{item.convertion_unit?.label}
</td>
</tr>
)}
{item.marketing_type?.value.toLowerCase() ===
'ayam_pullet' && (
<tr>
<td className='text-sm px-4 py-3'>Tipe Konversi</td>
<td className='text-sm px-4 py-3'>{item.week?.label}</td>
</tr>
)}
{item.convertion_unit?.value.toLowerCase() === 'peti' && (
<tr>
<td className='text-sm px-4 py-3'>Total Peti</td>
<td className='text-sm px-4 py-3'>
{item.total_peti} {item.convertion_unit?.label}
</td>
</tr>
)}
{item.marketing_type?.value.toLowerCase() !== 'trading' && (
<>
<tr>
<td className='text-sm px-4 py-3'>Total Bobot</td>
<td className='text-sm px-4 py-3'>
{item.total_weight
? formatNumber(
parseFloat(item.total_weight as string)
) + ' Kg'
: '0 Kg'}
</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Avg Bobot</td>
<td className='text-sm px-4 py-3'>
{item.avg_weight
? formatNumber(
parseFloat(item.avg_weight as string)
) + ' Kg'
: '0 Kg'}
</td>
</tr>
</>
)}
<tr>
<td className='text-sm px-4 py-3'>
{item.marketing_type?.value === 'telur'
? 'Total Butir Telur'
: 'Qty'}
</td>
<td className='text-sm px-4 py-3'>
{`${formatNumber(parseFloat(item.qty as string))} ${item.uom || ''}`}
</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Harga Satuan</td>
<td className='text-sm px-4 py-3'>
{formatCurrency(parseFloat(item.unit_price as string))}
</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Total Penjualan</td>
<td className='text-sm px-4 py-3'>
{formatCurrency(parseFloat(item.total_price as string))}
</td>
</tr>
</>
</tbody>
</table>
<div key={`table-${item.id}`}>
{formType === 'success' ? (
<div className='rounded-lg border border-tools-table-outline border-base-content/5'>
<table
style={{
borderRadius: '0.5rem',
}}
className='border-none w-full'
>
<tbody className='w-full'>{renderTableContent(item)}</tbody>
</table>
</div>
) : (
<Card
title={item.product_warehouse?.label || 'Produk'}
collapsible={true}
defaultCollapsed={false}
variant='bordered'
className={{
wrapper: 'w-full rounded-lg',
body: 'p-0',
title: 'px-2 py-1.5 font-normal text-sm',
collapsible: 'rounded-lg',
}}
>
<table
style={{
borderRadius: '0.5rem',
}}
className='border-none w-full'
>
<tbody className='w-full'>{renderTableContent(item)}</tbody>
</table>
</Card>
)}
</div>
))}
{formType != 'add_deliver' &&
@@ -1,289 +0,0 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission';
import { Fcr } from '@/types/api/master-data/fcr';
import { FcrApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
const RowOptionsMenu = ({
type = 'dropdown',
props,
deleteClickHandler,
}: {
type: 'dropdown' | 'collapse';
props: CellContext<Fcr, unknown>;
deleteClickHandler: () => void;
}) => {
return (
<RowOptionsMenuWrapper type={type}>
<RequirePermission permissions='lti.master.fcr.detail'>
<Button
href={`/master-data/fcr/detail/?fcrId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.fcr.update'>
<Button
href={`/master-data/fcr/detail/edit/?fcrId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
Edit
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.fcr.delete'>
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
</RequirePermission>
</RowOptionsMenuWrapper>
);
};
const FcrsTable = () => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: { search: '', nameSort: '' },
paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' },
});
const {
data: fcrs,
isLoading,
mutate: refreshFcrs,
} = useSWR(
`${FcrApi.basePath}${getTableFilterQueryString()}`,
FcrApi.getAllFetcher
);
const deleteModal = useModal();
const [selectedFcr, setSelectedFcr] = useState<Fcr | undefined>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]);
const fcrsColumns: ColumnDef<Fcr>[] = [
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedFcr(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
const deleteResponse = await FcrApi.delete(selectedFcr?.id as number);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshFcrs();
deleteModal.closeModal();
toast.success('Successfully delete FCR!');
setIsDeleteLoading(false);
};
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
};
// track sorting
useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
if (!isNameSorted) {
updateFilter('nameSort', '');
} else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
}
}, [sorting]);
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
<div className='w-full flex flex-row'>
<RequirePermission permissions='lti.master.fcr.create'>
<Button
href='/master-data/fcr/add'
variant='outline'
color='primary'
className='w-full sm:w-fit'
>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah
</Button>
</RequirePermission>
</div>
<DebouncedTextInput
name='search'
placeholder='Cari FCR'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
<div className='flex flex-row justify-end'>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/>
</div>
</div>
<Table<Fcr>
data={isResponseSuccess(fcrs) ? fcrs?.data : []}
columns={fcrsColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(fcrs) ? fcrs?.meta?.page : 0}
totalItems={isResponseSuccess(fcrs) ? fcrs?.meta?.total_results : 0}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20': isResponseSuccess(fcrs) && fcrs?.data?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
}}
/>
</div>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data FCR ini (${selectedFcr?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
</>
);
};
export default FcrsTable;
@@ -1,26 +0,0 @@
import * as Yup from 'yup';
const FcrStandardSchema: Yup.ObjectSchema<{
weight: number | string;
fcr_number: number | string;
mortality: number | string;
}> = Yup.object({
weight: Yup.number().nullable().required('Bobot wajib diisi!'),
fcr_number: Yup.number()
.nullable()
.typeError('FCR harus angka!')
.required('FCR harus diisi!'),
mortality: Yup.number().nullable().required('Mortalitas wajib diisi!'),
});
export const FcrFormSchema = Yup.object({
name: Yup.string().required('Nama wajib diisi!'),
fcrStandards: Yup.array()
.of(FcrStandardSchema)
.min(1, 'Minimal 1 FCR Standard diisi1')
.required('FCR wajib diisi!'),
});
export const UpdateFcrFormSchema = FcrFormSchema;
export type FcrFormValues = Yup.InferType<typeof FcrFormSchema>;
@@ -1,401 +0,0 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useFormik } from 'formik';
import { toast } from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import RequirePermission from '@/components/helper/RequirePermission';
import {
FcrFormSchema,
FcrFormValues,
UpdateFcrFormSchema,
} from '@/components/pages/master-data/fcr/form/FcrForm.schema';
import { isResponseError } from '@/lib/api-helper';
import {
CreateFcrPayload,
Fcr,
FcrWithStandards,
UpdateFcrPayload,
} from '@/types/api/master-data/fcr';
import { FcrApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
interface FcrFormProps {
type?: 'add' | 'edit' | 'detail';
initialValues?: FcrWithStandards;
}
const FcrForm = ({ type = 'add', initialValues }: FcrFormProps) => {
const router = useRouter();
const deleteModal = useModal();
const [fcrFormErrorMessage, setFcrFormErrorMessage] = useState('');
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const createFcrHandler = useCallback(
async (payload: CreateFcrPayload) => {
const createFcrRes = await FcrApi.create(payload);
if (isResponseError(createFcrRes)) {
setFcrFormErrorMessage(createFcrRes.message);
return;
}
toast.success(createFcrRes?.message as string);
router.push('/master-data/fcr');
},
[router]
);
const updateFcrHandler = useCallback(
async (fcrId: number, payload: UpdateFcrPayload) => {
const updateFcrRes = await FcrApi.update(fcrId, payload);
if (updateFcrRes?.status === 'error') {
setFcrFormErrorMessage(updateFcrRes.message);
return;
}
toast.success(updateFcrRes?.message as string);
router.refresh();
router.push('/master-data/fcr');
},
[router]
);
const formikInitialValues = useMemo<FcrFormValues>(() => {
return {
name: initialValues?.name ?? '',
fcrStandards: initialValues?.fcr_standards
? initialValues?.fcr_standards
: [
{
weight: '',
fcr_number: '',
mortality: '',
},
],
};
}, [initialValues]);
const formik = useFormik<FcrFormValues>({
initialValues: formikInitialValues,
validationSchema: type === 'edit' ? UpdateFcrFormSchema : FcrFormSchema,
onSubmit: async (values) => {
setFcrFormErrorMessage('');
const fcrPayload: CreateFcrPayload = {
name: values.name,
fcr_standards: values.fcrStandards as CreateFcrPayload['fcr_standards'],
};
switch (type) {
case 'add':
await createFcrHandler(fcrPayload);
break;
case 'edit':
await updateFcrHandler(initialValues?.id as number, fcrPayload);
break;
}
},
});
const { setValues: formikSetValues } = formik;
const addFcrStandard = () =>
formik.setFieldValue('fcrStandards', [
...formik.values.fcrStandards,
{
weight: '',
fcr_number: '',
mortality: '',
},
]);
const removeFcrStandard = (i: number) =>
formik.setFieldValue(
'fcrStandards',
formik.values.fcrStandards.filter((_, idx) => idx !== i)
);
const deleteFcrClickHandler = () => {
deleteModal.openModal();
};
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await FcrApi.delete(initialValues?.id as number);
deleteModal.closeModal();
toast.success('Successfully delete FCR!');
setIsDeleteLoading(false);
router.push('/master-data/fcr');
};
const isRepeaterInputError = (
column: keyof CreateFcrPayload['fcr_standards'][0],
idx: number
) => {
return (
formik.touched.fcrStandards?.[idx]?.[column] &&
Boolean(
formik.errors.fcrStandards?.[idx] instanceof Object &&
formik.errors.fcrStandards?.[idx]?.[column]
)
);
};
useEffect(() => {
formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]);
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
return (
<>
<section className='w-full max-w-5xl'>
<header className='flex flex-col gap-4'>
<Button
href='/master-data/fcr'
variant='link'
className='w-fit p-0 text-primary'
>
<Icon icon='uil:arrow-left' width={24} height={24} />
Kembali
</Button>
<h1 className='text-2xl font-bold text-center'>
{type === 'add' && 'Tambah FCR'}
{type === 'edit' && 'Edit FCR'}
{type === 'detail' && 'Detail FCR'}
</h1>
</header>
<form
onSubmit={handleFormSubmit}
onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6'
>
<div className='flex flex-col gap-4'>
<TextInput
required
label='Nama'
name='name'
placeholder='Masukkan nama FCR'
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.name && Boolean(formik.errors.name)}
errorMessage={formik.errors.name}
readOnly={type === 'detail'}
/>
<div>
<div className='overflow-x-auto'>
<table className='table'>
<thead>
<tr>
<th>Bobot</th>
<th>FCR</th>
<th>Mortalitas</th>
{type !== 'detail' && <th>Aksi</th>}
</tr>
</thead>
<tbody>
{formik.values.fcrStandards.map((fcrStandard, idx) => (
<tr key={idx}>
<td>
<TextInput
required
type='number'
name={`fcrStandards[${idx}].weight`}
placeholder='Masukkan bobot'
value={fcrStandard.weight}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isRepeaterInputError('weight', idx)}
readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-24',
}}
/>
</td>
<td>
<TextInput
required
type='number'
name={`fcrStandards[${idx}].fcr_number`}
placeholder='Masukkan FCR'
value={fcrStandard.fcr_number}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isRepeaterInputError('fcr_number', idx)}
readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-24',
}}
/>
</td>
<td>
<TextInput
required
type='number'
name={`fcrStandards[${idx}].mortality`}
placeholder='Masukkan mortalitas'
value={fcrStandard.mortality}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isRepeaterInputError('mortality', idx)}
readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-24',
}}
/>
</td>
{type !== 'detail' && (
<td>
<Button
type='button'
color='error'
onClick={() => removeFcrStandard(idx)}
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
/>
</Button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
{type !== 'detail' && (
<Button
type='button'
color='success'
onClick={addFcrStandard}
className='w-fit mx-auto'
>
<Icon icon='ic:round-plus' width={24} height={24} /> Tambah FCR
</Button>
)}
</div>
<AlertErrorList formErrorList={formErrorList} onClose={close} />
<div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && (
<div className='flex flex-row justify-start gap-2'>
<RequirePermission permissions='lti.master.fcr.delete'>
<Button
type='button'
color='error'
onClick={deleteFcrClickHandler}
className='px-4'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
className='justify-start text-sm'
/>
Delete
</Button>
</RequirePermission>
{type !== 'edit' && (
<RequirePermission permissions='lti.master.fcr.update'>
<Button
type='button'
color='warning'
href={`/master-data/fcr/detail/edit/?fcrId=${initialValues?.id}`}
className='px-4'
>
<Icon
icon='material-symbols:edit-outline'
width={24}
height={24}
className='justify-start text-sm'
/>
Edit
</Button>
</RequirePermission>
)}
</div>
)}
{type !== 'detail' && (
<div
className={cn('flex flex-row justify-end gap-2', {
'w-full': type === 'add',
})}
>
<Button type='reset' color='warning' className='px-4'>
Reset
</Button>
<Button
type='submit'
color='primary'
isLoading={formik.isSubmitting}
disabled={formik.isSubmitting}
className='px-4'
>
Submit
</Button>
</div>
)}
</div>
{fcrFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{fcrFormErrorMessage}</span>
</div>
)}
</form>
</section>
{type !== 'add' && (
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data FCR ini (${initialValues?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
)}
</>
);
};
export default FcrForm;
@@ -33,6 +33,9 @@ import RequirePermission from '@/components/helper/RequirePermission';
import StatusBadge from '@/components/helper/StatusBadge';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import ProjectFlockConfirmationModal from './ProjectFlockConfirmationModal';
import { useProjectFlockStore } from '@/stores/project-flock/project-flock.store';
import { ProjectFlockFormValues } from './form/ProjectFlockForm.schema';
const RowOptionsMenu = ({
props,
@@ -137,6 +140,15 @@ const RowOptionsMenu = ({
};
const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
const isSuccess = useProjectFlockStore((s) => s.isSuccess);
const setIsSuccess = useProjectFlockStore((s) => s.setIsSuccess);
const createdProjectFlock = useProjectFlockStore(
(s) => s.createdProjectFlock
);
const setCreatedProjectFlock = useProjectFlockStore(
(s) => s.setCreatedProjectFlock
);
const {
state: tableFilterState,
updateFilter,
@@ -180,6 +192,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
const [sorting, setSorting] = useState<SortingState>([]);
const deleteModal = useModal();
const confirmModal = useModal();
const successModal = useModal();
const [approvalAction, setApprovalAction] = useState<'APPROVED' | 'REJECTED'>(
'APPROVED'
);
@@ -275,6 +288,64 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
refreshProjectFlocks();
}, [refresh]);
useEffect(() => {
if (isSuccess) {
successModal.openModal();
}
}, [isSuccess, successModal]);
const handleSuccessModalClose = () => {
successModal.closeModal();
setIsSuccess(false);
setCreatedProjectFlock(null);
};
const projectFlockFormValues = useMemo(() => {
if (!createdProjectFlock) return undefined;
return {
flock: {
value: 0,
label: createdProjectFlock.flock_name || '',
},
flock_name: createdProjectFlock.flock_name || '',
area: {
value: createdProjectFlock.area_id,
label: createdProjectFlock.area?.name || '',
},
area_id: createdProjectFlock.area_id,
category_option: {
value: createdProjectFlock.category,
label: createdProjectFlock.category,
},
category: createdProjectFlock.category,
production_standard: {
value: createdProjectFlock.production_standard_id,
label: createdProjectFlock.production_standard?.name || '',
},
production_standard_id: createdProjectFlock.production_standard_id,
location: {
value: createdProjectFlock.location_id,
label: createdProjectFlock.location?.name || '',
},
location_id: createdProjectFlock.location_id,
kandang_ids: createdProjectFlock.kandangs?.map((k) => k.id) || [],
project_budgets:
createdProjectFlock.project_budgets?.map((budget) => ({
nonstock: budget.nonstock
? {
value: budget.nonstock_id,
label: budget.nonstock.name || '',
}
: null,
nonstock_id: budget.nonstock_id,
qty: budget.qty,
price: budget.price,
total_price: budget.qty * budget.price,
})) || [],
} as ProjectFlockFormValues;
}, [createdProjectFlock]);
// ====== MEMO ======
const selectedSingleRow: ProjectFlock | null | undefined = useMemo(() => {
return selectedRowIds.length === 1
@@ -363,10 +434,6 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
accessorKey: 'location.name',
header: 'Lokasi',
},
{
accessorKey: 'fcr.name',
header: 'FCR',
},
{
accessorKey: 'category',
header: 'Kategori',
@@ -877,6 +944,16 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
isLoading: isApproveLoading,
}}
/>
<ProjectFlockConfirmationModal
ref={successModal.ref}
type='success'
text='Data Berhasil Ditambahkan'
subtitleText='Data project flock telah berhasil disimpan.'
projectFlockForm={projectFlockFormValues}
onClose={handleSuccessModalClose}
secondaryButton={undefined}
/>
</>
);
};
@@ -16,11 +16,6 @@ type ProjectFlockFormSchemaType = {
label: string;
} | null;
category: string;
fcr: {
value: number | string;
label: string;
} | null;
fcr_id: number;
production_standard: {
value: number | string;
label: string;
@@ -96,15 +91,6 @@ export const ProjectFlockFormSchema: Yup.ObjectSchema<ProjectFlockFormSchemaType
.oneOf(['GROWING', 'LAYING'], 'Kategori wajib diisi!')
.required('Kategori wajib diisi!'),
// FCR
fcr: Yup.object({
value: Yup.number().required('ID FCR wajib diisi!'),
label: Yup.string().required('Nama FCR wajib diisi!'),
}).nullable(),
fcr_id: Yup.number()
.min(1, 'FCR wajib diisi!')
.required('FCR wajib diisi!'),
// Production Standard
production_standard: Yup.object({
value: Yup.number().required('ID Standar Produksi wajib diisi!'),
@@ -9,7 +9,6 @@ import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
import {
AreaApi,
FcrApi,
FlockApi,
KandangApi,
LocationApi,
@@ -53,7 +52,7 @@ import Table from '@/components/Table';
import { ColumnDef } from '@tanstack/react-table';
import StatusBadge from '@/components/helper/StatusBadge';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import ProjectFlockConfirmationModal from '../ProjectFlockConfirmationModal';
import { useProjectFlockStore } from '@/stores/project-flock/project-flock.store';
interface ProjectFlockFormProps {
formType?: 'add' | 'edit' | 'detail';
@@ -214,6 +213,10 @@ const ProjectFlockForm = ({
}: ProjectFlockFormProps) => {
// State
const router = useRouter();
const setIsSuccess = useProjectFlockStore((s) => s.setIsSuccess);
const setCreatedProjectFlock = useProjectFlockStore(
(s) => s.setCreatedProjectFlock
);
const [formStep, setFormStep] = useState<'form' | 'confirmation'>('form');
@@ -240,7 +243,6 @@ const ProjectFlockForm = ({
const subscribeValidate = useUiStore((s) => s.subscribeValidate);
const setIsValid = useUiStore((s) => s.setIsValid);
const successModal = useModal();
const deleteModal = useModal();
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
@@ -263,6 +265,8 @@ const ProjectFlockForm = ({
loadMore: loadMoreFlock,
} = useSelect(FlockApi.basePath, 'id', 'name', '', {
project_category: selectedCategory,
location_id: selectedLocation,
area_id: selectedArea,
});
const {
@@ -284,13 +288,6 @@ const ProjectFlockForm = ({
: ((initialValues?.area?.id ?? '') as string),
});
const {
options: optionsFcr,
isLoadingOptions: isLoadingFcrs,
setInputValue: setInputValueFcr,
loadMore: loadMoreFcr,
} = useSelect(FcrApi.basePath, 'id', 'name');
const {
options: optionsProductionStandards,
isLoadingOptions: isLoadingProductionStandards,
@@ -439,7 +436,9 @@ const ProjectFlockForm = ({
if (isResponseSuccess(createProjectFlockRes)) {
toast.success(createProjectFlockRes?.message as string);
handleReset();
successModal.openModal();
setCreatedProjectFlock(createProjectFlockRes?.data ?? null);
setIsSuccess(true);
router.push('/production/project-flock');
}
if (isResponseError(createProjectFlockRes)) {
setProjectFlockFormErrorMessage(createProjectFlockRes?.message as string);
@@ -457,7 +456,9 @@ const ProjectFlockForm = ({
if (isResponseSuccess(updateProjectFlockRes)) {
toast.success(updateProjectFlockRes?.message as string);
handleReset();
successModal.openModal();
setCreatedProjectFlock(updateProjectFlockRes?.data ?? null);
setIsSuccess(true);
router.push('/production/project-flock');
}
if (isResponseError(updateProjectFlockRes)) {
setProjectFlockFormErrorMessage(updateProjectFlockRes?.message as string);
@@ -505,12 +506,6 @@ const ProjectFlockForm = ({
label: initialValues.category,
}
: null,
fcr: initialValues?.fcr
? {
value: initialValues.fcr?.id,
label: initialValues.fcr.name,
}
: null,
production_standard: initialValues?.production_standard
? {
value: initialValues.production_standard?.id,
@@ -531,7 +526,6 @@ const ProjectFlockForm = ({
category: initialValues?.category as NonNullable<
'GROWING' | 'LAYING' | undefined
>,
fcr_id: initialValues?.fcr?.id ?? 0,
production_standard_id: initialValues?.production_standard?.id ?? 0,
location_id: initialValues?.location?.id ?? 0,
kandang_ids: initialValues?.kandangs?.map(
@@ -574,7 +568,6 @@ const ProjectFlockForm = ({
flock_name: values.flock_name as string,
area_id: values.area_id as number,
category: values.category as string,
fcr_id: values.fcr_id as number,
production_standard_id: values.production_standard_id as number,
location_id: values.location_id as number,
kandang_ids: values.kandang_ids as number[],
@@ -996,25 +989,6 @@ const ProjectFlockForm = ({
isClearable
isDisabled={formType != 'add'}
/>
<SelectInput
required
label='FCR'
placeholder='Pilih FCR'
value={formik.values.fcr as OptionType}
onChange={(val) => {
optionChangeHandler(val, 'fcr');
}}
onInputChange={setInputValueFcr}
onMenuScrollToBottom={loadMoreFcr}
options={optionsFcr}
isLoading={isLoadingFcrs}
isError={
formik.touched.fcr_id && Boolean(formik.errors.fcr_id)
}
errorMessage={formik.errors.fcr_id as string}
isClearable
isDisabled={formType != 'add'}
/>
<SelectInput
required
label='Kategori'
@@ -1423,19 +1397,6 @@ const ProjectFlockForm = ({
</div>
</form>
<ProjectFlockConfirmationModal
ref={successModal.ref}
type='success'
text='Data Berhasil Ditambahkan'
subtitleText='Data project flock telah berhasil disimpan.'
projectFlockForm={formikLastValues}
onClose={() => {
router.push('/production/project-flock');
setFormikLastValues(undefined);
}}
secondaryButton={undefined}
/>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
@@ -31,8 +31,7 @@ import {
RecordingApi,
ProjectFlockApi,
} from '@/services/api/production';
import { FcrApi, ProductionStandardApi } from '@/services/api/master-data';
import { FcrWithStandards, FcrStandard } from '@/types/api/master-data/fcr';
import { ProductionStandardApi } from '@/services/api/master-data';
import {
ProductionStandard,
StandardDetails,
@@ -87,24 +86,6 @@ interface RecordingFormProps {
initialValues?: Recording;
}
const fcrStandardColumns: ColumnDef<FcrStandard>[] = [
{
accessorKey: 'weight',
header: 'Weight',
cell: (props) => formatNumber(props.getValue() as number),
},
{
accessorKey: 'fcr_number',
header: 'FCR Number',
cell: (props) => formatNumber(props.getValue() as number),
},
{
accessorKey: 'mortality',
header: 'Mortality',
cell: (props) => formatNumber(props.getValue() as number),
},
];
const productionStandardColumns: ColumnDef<StandardDetails>[] = [
{
accessorKey: 'week',
@@ -253,36 +234,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const approveModal = useModal();
const rejectModal = useModal();
const deleteModal = useModal();
const fcrStandardModal = useModal();
const productionStandardModal = useModal();
const [fcrStandards, setFcrStandards] = useState<FcrStandard[]>([]);
const [productionStandards, setProductionStandards] =
useState<ProductionStandard | null>(null);
const [isFcrModalOpen, setIsFcrModalOpen] = useState(false);
const [isProductionStandardModalOpen, setIsProductionStandardModalOpen] =
useState(false);
useEffect(() => {
const checkFcrModalOpen = () => {
const isOpen = fcrStandardModal.ref.current?.open || false;
setIsFcrModalOpen(isOpen);
};
checkFcrModalOpen();
const observer = new MutationObserver(checkFcrModalOpen);
if (fcrStandardModal.ref.current) {
observer.observe(fcrStandardModal.ref.current, {
attributes: true,
attributeFilter: ['open'],
});
}
return () => observer.disconnect();
}, [fcrStandardModal.ref]);
useEffect(() => {
const checkProductionStandardModalOpen = () => {
const isOpen = productionStandardModal.ref.current?.open || false;
@@ -460,24 +419,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
? projectFlockKandangLookupData.data
: undefined;
const fcrId = useMemo(() => {
if (type === 'add') {
return projectFlockKandangLookup?.project_flock?.fcr?.id;
}
return initialValues?.project_flock?.fcr?.id;
}, [type, projectFlockKandangLookup, initialValues]);
const { data: fcr, isLoading: isLoadingFcrStandards } = useSWR(
isFcrModalOpen && fcrId ? `fcr-detail-${fcrId}` : null,
() => FcrApi.getSingle(fcrId!)
);
useEffect(() => {
if (fcr?.status === 'success') {
setFcrStandards((fcr.data as FcrWithStandards).fcr_standards || []);
}
}, [fcr]);
const productionStandardId = useMemo(() => {
if (type === 'add') {
return projectFlockKandangLookup?.project_flock?.production_standard_id;
@@ -606,7 +547,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
isLoadingOptions: isLoadingEggProducts,
loadMore: loadMoreEggProducts,
} = useSelect(ProductWarehouseApi.basePath, 'id', 'product.name', 'search', {
search: 'telur',
type: 'TELUR',
location_id: eggProductsLocationId,
kandang_id: eggProductsKandangId,
});
@@ -886,20 +827,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
if (isResponseSuccess(eggProductsData) && selectedKandang) {
const data = eggProductsData.data as unknown as ProductWarehouse[];
data.forEach((product) => {
const productName = product.product.name;
if (
productName.toLowerCase().includes('telur') ||
productName.toLowerCase().includes('egg') ||
productName.toLowerCase().includes('pecah') ||
productName.toLowerCase().includes('konsumsi') ||
productName.toLowerCase().includes('baik')
) {
options.push({
value: product.id,
label: product.product.name,
});
}
options.push({
value: product.id,
label: product.product.name,
});
});
}
@@ -1952,24 +1883,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
: '-'}
</p>
</div>
<div>
<span className='text-sm text-gray-600'>Standard FCR</span>
<div className='mt-1'>
<Badge
variant='soft'
color='primary'
className={{
badge:
'cursor-pointer hover:opacity-80 transition-opacity whitespace-nowrap',
}}
onClick={() => fcrStandardModal.openModal()}
>
{projectFlockKandangLookup?.project_flock?.fcr?.name ||
initialValues?.project_flock?.fcr?.name ||
'-'}
</Badge>
</div>
</div>
<div>
<span className='text-sm text-gray-600'>
Standard Produksi
@@ -2160,22 +2073,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</div>
</div>
)}
<div>
<span className='text-sm text-gray-600'>Standard FCR</span>
<div className='mt-1'>
<Badge
variant='soft'
color='primary'
className={{
badge:
'cursor-pointer hover:opacity-80 transition-opacity whitespace-nowrap',
}}
onClick={() => fcrStandardModal.openModal()}
>
{initialValues.project_flock?.fcr?.name || '-'}
</Badge>
</div>
</div>
<div>
<span className='text-sm text-gray-600'>
Standard Produksi
@@ -2227,21 +2124,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</tr>
</thead>
<tbody>
<tr>
<td className='py-3 font-medium'>FCR (g)</td>
<td className='text-center py-3'>
<span className='font-semibold'>
{initialValues.fcr_value != null
? `${formatNumber(initialValues.fcr_value)} g`
: '-'}
</span>
</td>
<td className='text-center py-3 text-gray-600'>
{initialValues.project_flock?.fcr?.fcr_std != null
? `${formatNumber(initialValues.project_flock?.fcr?.fcr_std)} g`
: '-'}
</td>
</tr>
<tr>
<td className='py-3 font-medium'>Feed Intake (g)</td>
<td className='text-center py-3'>
@@ -2587,6 +2469,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
)
: null
}
disabled={type === 'detail'}
/>
{getStockUsageAdornment(idx)}
</div>
@@ -2793,6 +2676,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
)
: null
}
disabled={type === 'detail'}
/>
</td>
{(type as 'add' | 'edit' | 'detail') !== 'detail' && (
@@ -3009,6 +2893,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
}}
placeholder='Masukkan jumlah telur'
inputSuffix={'Butir'}
disabled={type === 'detail'}
/>
</td>
<td>
@@ -3035,6 +2920,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
}}
placeholder='Masukkan total berat telur (Kilogram)...'
inputSuffix='Kilogram'
disabled={type === 'detail'}
/>
</td>
{(type as 'add' | 'edit' | 'detail') !==
@@ -3283,62 +3169,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</>
)}
{/* FCR Standard Modal */}
<Modal
ref={fcrStandardModal.ref}
closeOnBackdrop={true}
className={{
modal: 'p-0',
modalBox: 'p-0 rounded-2xl xl:max-w-4/12 max-w-sm',
}}
>
<div className='space-y-6'>
{/* Modal Header */}
<div className='flex items-center justify-between gap-2 py-3 border-b border-gray-300 px-4'>
<div className='flex items-center gap-2 text-primary'>
<Icon icon='mdi:chart-line' width={20} height={20} />
<h3 className='font-semibold'>Detail Standard FCR</h3>
</div>
<Button
variant='link'
onClick={fcrStandardModal.closeModal}
className='text-gray-500 hover:text-gray-700 transition-colors cursor-pointer'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<div className='px-4'>
{isLoadingFcrStandards ? (
<div className='flex justify-center py-8'>
<span className='loading loading-spinner loading-lg'></span>
</div>
) : fcrStandards.length > 0 ? (
<Table<FcrStandard>
data={fcrStandards}
columns={fcrStandardColumns}
pageSize={100}
className={{
tableWrapperClassName: 'overflow-x-auto',
tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-4 py-3 text-xs font-semibold text-gray-500 whitespace-nowrap border-l border-l-gray-200 border-r border-r-gray-200 border-t border-t-gray-200 border-gray-200 border-b-0',
bodyRowClassName:
'hover:bg-gray-50 transition-colors border-b border-gray-200 first:border-t first:border-t-gray-200 border-l border-l-gray-200 border-r border-r-gray-200',
bodyColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
paginationClassName: 'hidden',
}}
/>
) : (
<p className='text-sm text-gray-500'>
Tidak ada data FCR standards
</p>
)}
</div>
</div>
</Modal>
{/* Production Standard Modal */}
<Modal
closeOnBackdrop={true}
+52 -54
View File
@@ -1,7 +1,8 @@
'use client';
import { ChangeEventHandler, useCallback, useState } from 'react';
import { ChangeEventHandler, useCallback, useMemo, useState } from 'react';
import useSWR from 'swr';
import useSWRInfinite from 'swr/infinite';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
@@ -17,16 +18,19 @@ import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission';
import StatusBadge from '@/components/helper/StatusBadge';
import PurchaseOrderInvoice from '@/components/pages/purchase/order/PurchaseOrderInvoice';
import { cn, formatDate } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { BaseApiResponse } from '@/types/api/api-general';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
import { Purchase } from '@/types/api/purchase/purchase';
import { PurchaseApi } from '@/services/api/purchase';
import { ExpenseApi } from '@/services/api/expense';
import { Expense } from '@/types/api/expense';
import { Color } from '@/types/theme';
import Link from 'next/link';
// ===== STATUS BADGE UTILITIES =====
const statusTextMap: Record<string, string> = {
@@ -159,27 +163,33 @@ const PurchaseTable = () => {
PurchaseApi.getAllFetcher
);
const [isDownloadingInvoice, setIsDownloadingInvoice] = useState(false);
const [invoicePurchaseData, setInvoicePurchaseData] =
useState<Purchase | null>(null);
const handleDownloadInvoice = async (purchaseId: number) => {
setIsDownloadingInvoice(true);
try {
const response = await PurchaseApi.getSingle(purchaseId);
if (isResponseSuccess(response) && response.data) {
setInvoicePurchaseData(response.data);
setTimeout(() => {
setInvoicePurchaseData(null);
}, 1000);
}
} catch {
toast.error('Gagal mengambil data purchase order.');
} finally {
setIsDownloadingInvoice(false);
}
const getKey = (
pageIndex: number,
previousPageData: BaseApiResponse<Expense>[] | null
) => {
if (pageIndex > 0 && !previousPageData) return null;
return `${ExpenseApi.basePath}?page=${pageIndex + 1}&limit=100`;
};
const { data: expensesPages } = useSWRInfinite(
getKey,
ExpenseApi.getAllFetcher
);
const expenseMap = useMemo(() => {
const map = new Map<string, number>();
if (!expensesPages) return map;
expensesPages.forEach((page) => {
if (isResponseSuccess(page)) {
page.data.forEach((expense: Expense) => {
map.set(expense.reference_number, expense.id);
});
}
});
return map;
}, [expensesPages]);
// ===== TABLE COLUMNS DEFINITION =====
const purchaseColumns: ColumnDef<Purchase>[] = [
{
@@ -191,37 +201,34 @@ const PurchaseTable = () => {
},
{
accessorKey: 'po_expedition',
header: 'PO Ekspedisi',
header: 'Ekspedisi PO',
cell: (props) => {
const purchase = props.row.original;
if (!purchase.po_number || purchase.po_number === 'Belum dibuat') {
return <span>-</span>;
}
const poExpedition = props.row.original.po_expedition;
if (!poExpedition || poExpedition.length === 0) return '-';
return (
<Button
color='primary'
className='w-fit min-w-32 flex items-center justify-start gap-1 px-2 py-1 text-sm font-mono'
onClick={() => handleDownloadInvoice(purchase.id)}
disabled={isDownloadingInvoice}
>
<Icon
icon={
isDownloadingInvoice
? 'eos-icons:loading'
: 'material-symbols:file-open-outline'
<ul className='list-disc pl-4'>
{poExpedition.map((exp, index) => {
const expenseId = expenseMap.get(exp.refrence);
if (expenseId) {
return (
<li key={index}>
<Link
href={`/expense/detail/?expenseId=${expenseId}`}
className='p-0 h-auto text-primary underline'
>
{exp.refrence}
</Link>
</li>
);
}
width={16}
height={16}
/>
{purchase.po_number}
</Button>
return <li key={index}>{exp.refrence}</li>;
})}
</ul>
);
},
},
{
accessorKey: 'supplier.name',
accessorKey: 'supplier',
header: 'Vendor',
cell: (props) => props.row.original.supplier.name,
},
@@ -505,15 +512,6 @@ const PurchaseTable = () => {
onClick: confirmationModalDeleteClickHandler,
}}
/>
{invoicePurchaseData && (
<div className='hidden'>
<PurchaseOrderInvoice
data={invoicePurchaseData}
triggerDownloadOnMount={true}
/>
</div>
)}
</>
);
};
@@ -402,6 +402,13 @@ const PurchaseOrderAcceptApprovalForm = ({
<tbody>
{purchaseItems?.map((purchaseItem, idx) => {
const formItem = formik.values.items?.[idx];
const originalPurchaseItem = initialValues?.items?.find(
(item) => item.id === purchaseItem.id
);
const isReceivedQtyDisabled =
originalPurchaseItem?.has_chickin === true;
return (
<tr key={`purchase-item-${idx}`}>
<td>
@@ -580,7 +587,12 @@ const PurchaseOrderAcceptApprovalForm = ({
decimalScale={0}
thousandSeparator=','
decimalSeparator='.'
bottomLabel={`Total: ${purchaseItems[idx]?.quantity ? formatNumber(purchaseItems[idx].quantity) : 0}`}
disabled={isReceivedQtyDisabled}
bottomLabel={
isReceivedQtyDisabled
? 'Sudah chickin, tidak bisa diubah'
: `Total: ${purchaseItems[idx]?.quantity ? formatNumber(purchaseItems[idx].quantity) : 0}`
}
isError={
isRepeaterInputError(idx, 'received_qty').isError ||
(formItem?.received_qty
@@ -740,6 +740,13 @@ const PurchaseOrderStaffApprovalForm = ({
)
return null;
const originalPurchaseItem =
initialValues?.items?.find(
(item) => item.id === purchaseItem.id
);
const isQtyDisabled =
originalPurchaseItem?.has_chickin === true;
return (
<tr key={`purchase-item-${purchaseItem.id}`}>
<td>
@@ -807,7 +814,12 @@ const PurchaseOrderStaffApprovalForm = ({
placeholder='Masukkan jumlah'
allowNegative={false}
decimalScale={0}
bottomLabel={`Previous: ${formatNumber(purchaseItem.quantity)}`}
disabled={isQtyDisabled}
bottomLabel={
isQtyDisabled
? 'Sudah chickin, tidak bisa diubah'
: `Previous: ${formatNumber(purchaseItem.quantity)}`
}
className={{
wrapper: 'min-w-32',
}}
@@ -1,6 +1,6 @@
'use client';
import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
import { useMemo, useState } from 'react';
import {
Page,
Text,
@@ -235,16 +235,11 @@ const pdfStyles = StyleSheet.create({
interface PurchaseOrderInvoiceProps {
data?: Purchase;
className?: string;
triggerDownloadOnMount?: boolean;
}
const PurchaseOrderInvoice = ({
data,
triggerDownloadOnMount,
}: PurchaseOrderInvoiceProps) => {
const PurchaseOrderInvoice = ({ data }: PurchaseOrderInvoiceProps) => {
const [, setIsGeneratingPDF] = useState(false);
const purchaseData = data;
const hasDownloadedRef = useRef(false);
const grandTotal = useMemo(() => {
return (
@@ -255,7 +250,7 @@ const PurchaseOrderInvoice = ({
);
}, [purchaseData?.items]);
const handleDownloadPDF = useCallback(async () => {
const handleDownloadPDF = async () => {
if (!purchaseData) {
toast.error('No purchase order data available');
return;
@@ -515,20 +510,7 @@ const PurchaseOrderInvoice = ({
} finally {
setIsGeneratingPDF(false);
}
}, [purchaseData]);
useEffect(() => {
if (triggerDownloadOnMount && purchaseData && !hasDownloadedRef.current) {
hasDownloadedRef.current = true;
handleDownloadPDF();
}
}, [triggerDownloadOnMount, purchaseData]);
useEffect(() => {
if (!triggerDownloadOnMount) {
hasDownloadedRef.current = false;
}
}, [triggerDownloadOnMount]);
};
if (!purchaseData) {
return (
@@ -538,10 +520,6 @@ const PurchaseOrderInvoice = ({
);
}
if (triggerDownloadOnMount) {
return null;
}
return purchaseData?.po_number &&
purchaseData.po_number !== 'Belum dibuat' ? (
<Button
@@ -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;
@@ -2,22 +2,26 @@
import {
Page,
Text,
View,
Document,
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',
@@ -34,53 +38,6 @@ const pdfStyles = StyleSheet.create({
titleSection: {
marginBottom: 10,
},
mainTitle: {
fontSize: 14,
fontWeight: 'bold',
marginBottom: 5,
color: '#1f74bf',
},
supplierTitle: {
fontSize: 12,
fontWeight: 'bold',
marginBottom: 8,
color: '#1f74bf',
},
supplierInfo: {
fontSize: 9,
marginBottom: 5,
color: '#333333',
},
badge: {
backgroundColor: '#1f74bf',
color: '#FFFFFF',
padding: 2,
borderRadius: 2,
fontSize: 7,
fontWeight: 'bold',
alignSelf: 'center',
marginRight: 4,
},
badgeLunas: {
backgroundColor: '#1f74bf',
color: '#FFFFFF',
},
badgeBelumLunas: {
backgroundColor: '#F97316',
color: '#FFFFFF',
},
textError: {
color: '#DC2626',
},
parameterBadge: {
backgroundColor: '#F5F5F5',
color: '#333333',
padding: 4,
borderRadius: 4,
fontSize: 8,
marginRight: 8,
marginBottom: 4,
},
parameterContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
@@ -100,194 +57,183 @@ interface CustomerPaymentExportPDFParams {
};
}
const getParameterText = (
params?: CustomerPaymentExportPDFParams['params']
) => {
const paramsText = [];
if (params?.customer_name) {
paramsText.push(`Customer: ${params.customer_name}`);
} else {
paramsText.push('Semua Customer');
}
// TODO: Uncomment when BE is ready
// if (params?.sales) {
// paramsText.push(`Sales: ${params.sales}`);
// }
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: '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 ? (
<View
style={[
pdfStyles.badge,
item.status === 'LUNAS'
? pdfStyles.badgeLunas
: pdfStyles.badgeBelumLunas,
]}
>
<Text>{item.status === 'LUNAS' ? 'Lunas' : 'Belum Lunas'}</Text>
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
style={{
backgroundColor: getPDFBadgeStyle(row.status, 'payment').bg,
color: getPDFBadgeStyle(row.status, 'payment').text,
borderColor: getPDFBadgeStyle(row.status, 'payment').border,
}}
>
{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) => {
@@ -302,54 +248,44 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
>
{/* Title and Parameters */}
<View style={pdfStyles.titleSection}>
<Text style={pdfStyles.mainTitle}>
<PdfTypography size='h1' variant='primary'>
Laporan &gt; Kontrol Pembayaran Customer
</Text>
</PdfTypography>
<View style={pdfStyles.parameterContainer}>
<View style={pdfStyles.parameterBadge}>
<Text>
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')
: '-'}
</Text>
</View>
<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>
{/* TODO: Uncomment when BE is ready */}
{/* <View style={pdfStyles.parameterBadge}>
<Text>Filter Tanggal: Tanggal DO</Text>
</View> */}
<View style={pdfStyles.parameterBadge}>
<Text>
Customer: {params.params?.customer_name || 'Semua Customer'}
</Text>
</View>
<View style={pdfStyles.parameterBadge}>
<Text>
Dicetak: {formatDate(new Date(), 'DD MMM YYYY HH:mm')}
</Text>
</View>
{/* <PdfParamBadge>
Filter Tanggal: Tanggal DO
</PdfParamBadge> */}
<PdfParamBadge>
Customer: {params.params?.customer_name || 'Semua Customer'}
</PdfParamBadge>
<PdfParamBadge>
Dicetak: {formatDate(new Date(), 'DD MMM YYYY HH:mm')}
</PdfParamBadge>
</View>
<Text style={pdfStyles.supplierTitle}>
<PdfTypography size='h2' variant='primary'>
{customerReport.customer.name}
</Text>
<Text style={pdfStyles.supplierInfo}>
</PdfTypography>
<PdfTypography size='label'>
Alamat: {customerReport.customer.address || '-'}
</Text>
</PdfTypography>
</View>
{/* 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
@@ -357,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 },
@@ -2,7 +2,6 @@
import {
Page,
Text,
View,
Document,
StyleSheet,
@@ -12,53 +11,18 @@ import {
import { formatDate, formatCurrency, formatNumber } from '@/lib/helper';
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 } 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,
@@ -69,133 +33,6 @@ const pdfStyles = StyleSheet.create({
titleSection: {
marginBottom: 10,
},
mainTitle: {
fontSize: 14,
fontWeight: 'bold',
marginBottom: 5,
color: '#1f74bf',
},
supplierTitle: {
fontSize: 12,
fontWeight: 'bold',
marginBottom: 8,
color: '#1f74bf',
},
supplierInfo: {
fontSize: 9,
marginBottom: 5,
color: '#333333',
},
table: {
borderWidth: 1,
borderColor: '#000000',
marginBottom: 15,
},
tableRow: {
flexDirection: 'row',
},
tableHeader: {
backgroundColor: '#F5F5F5',
},
tableCell: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
textAlign: 'left',
},
tableCellNo: {
flex: 0.5,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
textAlign: 'center',
},
tableCellLast: {
flex: 1,
padding: 4,
fontSize: 7,
},
tableCellHeader: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
paddingVertical: 12,
textAlign: 'center',
},
tableCellHeaderRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
textAlign: 'right',
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
paddingVertical: 12,
},
tableCellRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
textAlign: 'right',
},
tableCellCenter: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
textAlign: 'center',
},
tableBorderBottom: {
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
},
summaryRow: {
backgroundColor: '#F0F0F0',
fontWeight: 'bold',
},
badge: {
paddingVertical: 2,
paddingHorizontal: 4,
borderRadius: 12,
fontSize: 5,
fontWeight: 'bold',
borderWidth: 1,
textAlign: 'center',
whiteSpace: 'nowrap',
},
parameterBadge: {
backgroundColor: '#F5F5F5',
color: '#333333',
padding: 4,
borderRadius: 4,
fontSize: 8,
marginRight: 8,
marginBottom: 4,
},
parameterContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
@@ -203,6 +40,225 @@ const pdfStyles = StyleSheet.create({
},
});
const getTableColumns = (total?: DebtSupplier['total']): PdfColumn[] => {
type DebtRow = DebtSupplier['rows'][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',
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',
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',
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: 'due_date',
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',
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
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,
}}
>
{(row as unknown as DebtRow).due_status}
</PdfStatusBadge>
</View>
) : (
'-'
),
footer: '',
},
{
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: '',
},
];
};
interface DebtSupplierExportPDFParams {
data: DebtSupplier[];
params?: {
@@ -219,418 +275,70 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
{params.data.map((supplierReport, supplierIndex) => (
<Page
key={supplierIndex}
size='A4'
size='A3'
orientation='landscape'
style={pdfStyles.page}
>
{/* Title and Supplier Info */}
<View style={pdfStyles.titleSection}>
<Text style={pdfStyles.mainTitle}>
<PdfTypography size='h1' variant='primary'>
Laporan &gt; Rekapitulasi Hutang ke Supplier
</Text>
</PdfTypography>
<View style={pdfStyles.parameterContainer}>
<View style={pdfStyles.parameterBadge}>
<Text>
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')
: '-'}
</Text>
</View>
<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>
{params.params?.filter_by && (
<View style={pdfStyles.parameterBadge}>
<Text>
Filter Tanggal:{' '}
{params.params.filter_by === 'po_date'
? 'Tanggal PO'
: params.params.filter_by === 'received_date'
? 'Tanggal Terima'
: params.params.filter_by === 'due_date'
? 'Tanggal Jatuh Tempo'
: params.params.filter_by}
</Text>
</View>
<PdfParamBadge>
Filter Tanggal:{' '}
{params.params.filter_by === 'po_date'
? 'Tanggal PO'
: params.params.filter_by === 'received_date'
? 'Tanggal Terima'
: params.params.filter_by === 'due_date'
? 'Tanggal Jatuh Tempo'
: params.params.filter_by}
</PdfParamBadge>
)}
<View style={pdfStyles.parameterBadge}>
<Text>
Supplier: {params.params?.supplier_name || 'Semua Supplier'}
</Text>
</View>
<View style={pdfStyles.parameterBadge}>
<Text>
Dicetak: {formatDate(new Date(), 'DD MMM YYYY HH:mm')}
</Text>
</View>
<PdfParamBadge>
Supplier: {params.params?.supplier_name || 'Semua Supplier'}
</PdfParamBadge>
<PdfParamBadge>
Dicetak: {formatDate(new Date(), 'DD MMM YYYY HH:mm')}
</PdfParamBadge>
</View>
<Text style={pdfStyles.supplierTitle}>
<PdfTypography size='h2' variant='primary'>
{supplierReport.supplier.name}
</Text>
<Text style={pdfStyles.supplierInfo}>
</PdfTypography>
<PdfTypography size='label'>
{supplierReport.supplier.category}
</Text>
</PdfTypography>
</View>
{/* Table */}
<View style={pdfStyles.table}>
{/* Table Header */}
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={[pdfStyles.tableCellHeader, { flex: 0.5 }]}>
<Text>No</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<Text>No. PR</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<Text>No. PO</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 0.7 }]}>
<Text>Tgl Terima/Bayar</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 0.7 }]}>
<Text>Tgl PO</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 0.6 }]}>
<Text>Aging</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<Text>Area</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<Text>Gudang</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<Text>Jatuh Tempo</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 2 }]}>
<Text>Status Jatuh Tempo</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.5 }]}>
<Text>Nominal Pembelian (Rp)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.5 }]}>
<Text>Pembayaran (Rp)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.5 }]}>
<Text>Sisa Saldo Hutang (Rp)</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>Status</Text>
</View>
<View
style={[
pdfStyles.tableCellHeader,
{ flex: 1, borderRight: 'none' },
]}
>
<Text>No. Perjalanan</Text>
</View>
</View>
{/* Initial Balance Row */}
<View style={[pdfStyles.tableRow, pdfStyles.tableBorderBottom]}>
<View style={[pdfStyles.tableCellNo, { flex: 0.5 }]}>
<Text></Text> {/* NO */}
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text></Text> {/* No. PR */}
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text></Text> {/* No. PO */}
</View>
<View style={[pdfStyles.tableCellCenter, { flex: 0.7 }]}>
<Text></Text> {/* Tgl Terima/Bayar */}
</View>
<View style={[pdfStyles.tableCellCenter, { flex: 0.7 }]}>
<Text></Text> {/* Tgl PO */}
</View>
<View style={[pdfStyles.tableCellCenter, { flex: 0.6 }]}>
<Text></Text> {/* Aging */}
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text></Text> {/* Area */}
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text></Text> {/* Gudang */}
</View>
<View style={[pdfStyles.tableCellCenter, { flex: 1 }]}>
<Text></Text> {/* Jatuh Tempo */}
</View>
<View style={[pdfStyles.tableCell, { flex: 2 }]}>
<Text></Text> {/* Status Jatuh Tempo */}
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.5 }]}>
<Text></Text> {/* Nominal Pembelian (Rp) */}
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.5 }]}>
<Text></Text> {/* Pembayaran (Rp) */}
</View>
<View
style={[
pdfStyles.tableCellRight,
{
flex: 1.5,
<PdfTable
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
? {
valueKey: 'balance',
value: supplierReport.initial_balance,
align: 'right',
color: supplierReport.initial_balance < 0 ? 'red' : 'black',
},
]}
>
<Text>
{' '}
{/* Sisa Saldo Hutang (Rp) */}
{formatCurrency(supplierReport.initial_balance || 0)}
</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
<Text></Text> {/* Status */}
</View>
<View
style={[
pdfStyles.tableCell, // No. Perjalanan
{ flex: 1, borderRight: 'none' },
]}
>
<Text></Text>
</View>
</View>
{/* Table Body */}
{supplierReport.rows.map((item, index) => (
<View
key={index}
style={[
pdfStyles.tableRow,
index < supplierReport.rows.length - 1
? pdfStyles.tableBorderBottom
: {},
]}
>
<View style={[pdfStyles.tableCellNo, { flex: 0.5 }]}>
<Text>{index + 1}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text>{item.pr_number || '-'}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text>{item.po_number || '-'}</Text>
</View>
<View style={[pdfStyles.tableCellCenter, { flex: 0.7 }]}>
<Text>
{item.received_date
? item.received_date != '-'
? formatDate(item.received_date, 'DD MMM YY')
: '-'
: '-'}
</Text>
</View>
<View style={[pdfStyles.tableCellCenter, { flex: 0.7 }]}>
<Text>
{item.po_date
? item.po_date != '-'
? formatDate(item.po_date, 'DD MMM YY')
: '-'
: '-'}
</Text>
</View>
<View style={[pdfStyles.tableCellCenter, { flex: 0.6 }]}>
<Text>{formatNumber(item.aging)} Hari</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text>{item.area?.name || '-'}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text>{item.warehouse?.name || '-'}</Text>
</View>
<View style={[pdfStyles.tableCellCenter, { flex: 1 }]}>
<Text>
{item.due_date
? item.due_date != '-'
? formatDate(item.due_date, 'DD MMM YY')
: '-'
: '-'}
</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 2 }]}>
{item.due_status && item.due_status !== '-' ? (
<View
style={[
pdfStyles.badge,
{
backgroundColor: getPDFBadgeStyle(
item.due_status,
'due'
).bg,
borderColor: getPDFBadgeStyle(item.due_status, 'due')
.border,
},
]}
>
<Text
style={{
color: getPDFBadgeStyle(item.due_status, 'due').text,
}}
>
{item.due_status}
</Text>
</View>
) : (
<Text>-</Text>
)}
</View>
<View
style={[
pdfStyles.tableCellRight,
{
flex: 1.5,
color: item.total_price < 0 ? 'red' : 'black',
},
]}
>
<Text>{formatCurrency(item.total_price)}</Text>
</View>
<View
style={[
pdfStyles.tableCellRight,
{
flex: 1.5,
color: item.payment_price < 0 ? 'red' : 'black',
},
]}
>
<Text>{formatCurrency(item.payment_price)}</Text>
</View>
<View
style={[
pdfStyles.tableCellRight,
{ flex: 1.5, color: item.balance < 0 ? 'red' : 'black' },
]}
>
<Text>{formatCurrency(item.balance)}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
{item.status && item.status !== '-' ? (
<View
style={[
pdfStyles.badge,
{
backgroundColor: getPDFBadgeStyle(
item.status,
'payment'
).bg,
borderColor: getPDFBadgeStyle(item.status, 'payment')
.border,
},
]}
>
<Text
style={{
color: getPDFBadgeStyle(item.status, 'payment').text,
}}
>
{item.status}
</Text>
</View>
) : (
<Text>-</Text>
)}
</View>
<View
style={[
pdfStyles.tableCell, // No. Perjalanan
{ flex: 1, borderRight: 'none' },
]}
>
<Text>{item.travel_number || '-'}</Text>
</View>
</View>
))}
{/* Summary Row */}
{supplierReport.total && (
<View style={[pdfStyles.tableRow, pdfStyles.summaryRow]}>
<View style={[pdfStyles.tableCellNo, { flex: 0.5 }]}>
<Text>Total</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 0.7 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 0.7 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCellCenter, { flex: 0.6 }]}>
<Text>{formatNumber(supplierReport.total.aging)} Hari</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 2 }]}>
<Text></Text>
</View>
<View
style={[
pdfStyles.tableCellRight,
{
flex: 1.5,
color:
supplierReport.total.total_price < 0 ? 'red' : 'black',
},
]}
>
<Text>
{formatCurrency(supplierReport.total.total_price)}
</Text>
</View>
<View
style={[
pdfStyles.tableCellRight,
{
flex: 1.5,
color:
supplierReport.total.payment_price < 0
? 'red'
: 'black',
},
]}
>
<Text>
{formatCurrency(supplierReport.total.payment_price)}
</Text>
</View>
<View
style={[
pdfStyles.tableCellRight,
{
flex: 1.5,
color:
supplierReport.total.debt_price < 0 ? 'red' : 'black',
},
]}
>
<Text>{formatCurrency(supplierReport.total.debt_price)}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCellLast, { flex: 1 }]}>
<Text></Text>
</View>
</View>
)}
</View>
}
: undefined
}
/>
</Page>
))}
</Document>
@@ -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;
}
};
+1 -11
View File
@@ -220,7 +220,6 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
'lti.master.area.list',
'lti.master.banks.list',
'lti.master.customer.list',
'lti.master.fcr.list',
'lti.master.flocks.list',
'lti.master.kandangs.list',
'lti.master.locations.list',
@@ -283,11 +282,6 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
link: '/master-data/nonstock',
permission: ['lti.master.nonstocks.list'],
},
{
text: 'FCR',
link: '/master-data/fcr',
permission: ['lti.master.fcr.list'],
},
{
text: 'Supplier',
link: '/master-data/supplier',
@@ -512,13 +506,9 @@ export const FILTER_TYPE_OPTIONS = [
];
export const MARKETING_TYPE_OPTIONS = [
{
label: 'Ayam Pullet',
value: 'AYAM_PULLET',
},
{
label: 'Ayam',
value: 'AYAM',
value: 'AYAM,AYAM_PULLET',
},
{
label: 'Trading',
-5
View File
@@ -195,11 +195,6 @@ export const ROUTE_PERMISSIONS: Record<string, string[]> = {
'/master-data/nonstock/detail/': ['lti.master.nonstocks.detail'],
'/master-data/nonstock/detail/edit/': ['lti.master.nonstocks.update'],
'/master-data/fcr/': ['lti.master.fcr.list'],
'/master-data/fcr/add/': ['lti.master.fcr.create'],
'/master-data/fcr/detail/': ['lti.master.fcr.detail'],
'/master-data/fcr/detail/edit/': ['lti.master.fcr.update'],
'/master-data/supplier/': ['lti.master.suppliers.list'],
'/master-data/supplier/add/': ['lti.master.suppliers.create'],
'/master-data/supplier/detail/': ['lti.master.suppliers.detail'],
+1
View File
@@ -40,6 +40,7 @@ export const safeRound = (num: number, decimals: number) => {
export const formatTitleCase = (value: string) => {
return value
.toLowerCase()
.replace(/_/g, ' ')
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
+2 -2
View File
@@ -15,7 +15,7 @@ export type MarketingFormValues = {
total_price?: string | number;
marketing_type?: { value: string; label: string } | null;
convertion_unit?: { value: string; label: string } | null;
week?: { value?: number; label?: string } | null;
week?: number | null;
weight_per_convertion?: number | null;
price_per_convertion?: number | null;
total_peti?: number | null;
@@ -100,7 +100,7 @@ export const calculateAyamPullet = (
): void => {
const { values, setFieldValue } = ctx;
const unitPrice = Number(values.unit_price || 0);
const week = Number(values.week?.value || 0);
const week = Number(values.week || 0);
const qty = Number(values.qty || 0);
const avgWeight = Number(values.avg_weight || 0);
const totalWeight = Number(values.total_weight || 0);
+1 -11
View File
@@ -54,11 +54,7 @@ import {
CreateBankPayload,
UpdateBankPayload,
} from '@/types/api/master-data/bank';
import {
CreateFcrPayload,
Fcr,
UpdateFcrPayload,
} from '@/types/api/master-data/fcr';
import {
CreateFlockPayload,
Flock,
@@ -131,12 +127,6 @@ export const BankApi = new BaseApiService<
UpdateBankPayload
>('/master-data/banks');
export const FcrApi = new BaseApiService<
Fcr,
CreateFcrPayload,
UpdateFcrPayload
>('/master-data/fcrs');
export const FlockApi = new BaseApiService<
Flock,
CreateFlockPayload,
@@ -0,0 +1,19 @@
'use client';
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { createProjectFlockSlice } from '@/stores/project-flock/slices/project-flock.slice';
import { ProjectFlockSlice } from '@/types/stores';
export type ProjectFlockStore = ProjectFlockSlice;
export const useProjectFlockStore = create<ProjectFlockStore>()(
devtools(
(...args) => ({
...createProjectFlockSlice(...args),
}),
{
name: 'ProjectFlockStore',
}
)
);
@@ -0,0 +1,24 @@
import { ProjectFlockSlice } from '@/types/stores';
import { StateCreator } from 'zustand';
export const createProjectFlockSlice: StateCreator<
ProjectFlockSlice,
[],
[],
ProjectFlockSlice
> = (set) => ({
// Initial state
isSuccess: false,
createdProjectFlock: null,
// Actions
setIsSuccess: (success) => set({ isSuccess: success }),
setCreatedProjectFlock: (data) => set({ createdProjectFlock: data }),
resetProjectFlock: () =>
set({
isSuccess: false,
createdProjectFlock: null,
}),
});
-1
View File
@@ -1,5 +1,4 @@
import { Area } from '@/types/api/master-data/area';
import { Fcr } from '@/types/api/master-data/fcr';
import { Flock } from '@/types/api/master-data/flock';
import { Location } from '@/types/api/master-data/location';
import { Kandang } from '@/types/api/master-data/kandang';
+1
View File
@@ -11,6 +11,7 @@ export type BaseProductWarehouse = {
quantity: number;
product: Product;
warehouse: Warehouse;
week?: number | null;
};
export type ProductWarehouse = BaseMetadata & BaseProductWarehouse;
+2
View File
@@ -17,6 +17,8 @@ export type BaseMarketing = {
status?: string;
so_number: string;
so_date: string;
do_number?: string;
do_date?: string;
customer: Customer;
sales_person: CreatedUser;
notes: string;
-30
View File
@@ -1,30 +0,0 @@
import { BaseMetadata } from '@/types/api/api-general';
export type BaseFcr = {
id: number;
name: string;
};
export type FcrStandard = {
id: number;
weight: number;
fcr_number: number;
mortality: number;
};
export type Fcr = BaseMetadata & BaseFcr;
export type FcrWithStandards = Fcr & {
fcr_standards: FcrStandard[];
};
export type CreateFcrPayload = {
name: string;
fcr_standards: {
weight: number;
fcr_number: number;
mortality: number;
}[];
};
export type UpdateFcrPayload = CreateFcrPayload;
-4
View File
@@ -1,5 +1,4 @@
import { Area } from '@/types/api/master-data/area';
import { Fcr } from '@/types/api/master-data/fcr';
import { Flock } from '@/types/api/master-data/flock';
import { Kandang } from '@/types/api/master-data/kandang';
import { Location } from '@/types/api/master-data/location';
@@ -16,8 +15,6 @@ export type BaseProjectFlock = {
area: Area;
area_id: number;
category: string;
fcr: Fcr;
fcr_id: number;
production_standard: ProductionStandard;
production_standard_id: number;
location: Location;
@@ -51,7 +48,6 @@ export type CreateProjectFlockPayload = {
flock_name: string;
area_id: number;
category: string;
fcr_id: number;
production_standard_id: number;
location_id: number;
kandang_ids: number[];
+2 -1
View File
@@ -50,6 +50,7 @@ export type PurchaseItem = {
expedition_vendor_name?: string | null;
received_qty?: number | null;
transport_per_item?: number | null;
has_chickin?: boolean;
expedition_vendor?: {
id?: number;
name?: string;
@@ -76,7 +77,7 @@ export type BasePurchase = {
items?: PurchaseItem[];
latest_approval?: BaseApproval;
requester_name?: string;
po_expedition?: string[];
po_expedition?: { id: number; refrence: string }[];
created_user?: CreatedUser;
products?: PurchaseItemProduct[];
};
+12
View File
@@ -5,6 +5,7 @@ import type {
UniformityDetail,
VerifyUniformityResponse,
} from '@/types/api/production/uniformity';
import type { ProjectFlock } from '@/types/api/production/project-flock';
type MainUiSlice = {
mainDrawerOpen: boolean;
@@ -97,3 +98,14 @@ export type DashboardFilterSlice = {
setFilterValues: (values: DashboardFilterType) => void;
resetFilterValues: () => void;
};
export type ProjectFlockSlice = {
// State
isSuccess: boolean;
createdProjectFlock: ProjectFlock | null;
// Actions
setIsSuccess: (success: boolean) => void;
setCreatedProjectFlock: (data: ProjectFlock | null) => void;
resetProjectFlock: () => void;
};