Merge branch 'feat/FE/US-352/daily-kandang-hpp-report' into 'development'

[FEAT/FE][US#352] HPP Harian Per Kandang Report

See merge request mbugroup/lti-web-client!114
This commit is contained in:
Adnan Zahir
2025-12-23 10:20:10 +07:00
11 changed files with 1765 additions and 100 deletions
+16 -97
View File
@@ -28,7 +28,7 @@
"swr": "^2.3.6",
"tailwind-merge": "^3.3.1",
"use-debounce": "^10.0.6",
"xlsx": "^0.18.5",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"yup": "^1.7.0",
"zustand": "^5.0.8"
},
@@ -1871,6 +1871,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -1947,6 +1948,7 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
@@ -2470,6 +2472,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2487,15 +2490,6 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -2966,19 +2960,6 @@
],
"license": "CC-BY-4.0"
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -3020,15 +3001,6 @@
"node": ">=6"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -3111,18 +3083,6 @@
"node": ">=10"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3158,7 +3118,8 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/daisyui": {
"version": "5.5.8",
@@ -3624,6 +3585,7 @@
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3797,6 +3759,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -4311,15 +4274,6 @@
"react": ">=16.8.0"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -6370,6 +6324,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -6400,6 +6355,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -6974,18 +6930,6 @@
"node": ">=0.10.0"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stable-hash": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -7345,6 +7289,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -7512,6 +7457,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7787,24 +7733,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -7816,19 +7744,10 @@
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"version": "0.20.3",
"resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
@@ -7906,4 +7825,4 @@
}
}
}
}
}
+2 -2
View File
@@ -31,7 +31,7 @@
"swr": "^2.3.6",
"tailwind-merge": "^3.3.1",
"use-debounce": "^10.0.6",
"xlsx": "^0.18.5",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"yup": "^1.7.0",
"zustand": "^5.0.8"
},
@@ -50,4 +50,4 @@
"tailwindcss": "^4",
"typescript": "^5"
}
}
}
+11
View File
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
+114
View File
@@ -0,0 +1,114 @@
import React, { ReactNode, useState, useRef } from 'react';
import { cn } from '@/lib/helper';
export interface DropdownProps {
trigger: ReactNode;
children: ReactNode;
className?: {
wrapper?: string;
trigger?: string;
content?: string;
};
align?: 'start' | 'center' | 'end';
direction?: 'top' | 'bottom' | 'left' | 'right';
hover?: boolean;
defaultOpen?: boolean;
open?: boolean;
close?: boolean;
controlled?: boolean;
}
const Dropdown = ({
trigger,
children,
className,
align,
direction,
hover,
defaultOpen = false,
open,
close,
controlled = false,
}: DropdownProps) => {
const [isOpen, setIsOpen] = useState(defaultOpen);
const dropdownRef = useRef<HTMLDivElement>(null);
const toggleDropdown = () => {
if (!controlled) {
const newState = !isOpen;
setIsOpen(newState);
}
};
const getWrapperClasses = () => {
const openState = controlled ? open : isOpen;
return cn(
'dropdown',
{
'dropdown-start': align === 'start',
'dropdown-center': align === 'center',
'dropdown-end': align === 'end',
'dropdown-top': direction === 'top',
'dropdown-bottom': direction === 'bottom',
'dropdown-left': direction === 'left',
'dropdown-right': direction === 'right',
'dropdown-hover': hover,
'dropdown-open': openState && !close,
'dropdown-close': close,
},
className?.wrapper
);
};
const getTriggerClasses = () => {
return cn(className?.trigger);
};
const getContentClasses = () => {
return cn(
'dropdown-content z-[9999] shadow-sm bg-base-100 rounded-box',
className?.content
);
};
if (controlled) {
return (
<div className={getWrapperClasses()}>
{trigger}
{open && !close && (
<div tabIndex={-1} className={getContentClasses()}>
{children}
</div>
)}
</div>
);
}
return (
<div ref={dropdownRef} className={getWrapperClasses()}>
<div
tabIndex={0}
role='button'
className={getTriggerClasses()}
onClick={toggleDropdown}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleDropdown();
}
}}
>
{trigger}
</div>
{!close && (
<div tabIndex={-1} className={getContentClasses()}>
{children}
</div>
)}
</div>
);
};
export default Dropdown;
+1 -1
View File
@@ -7,7 +7,7 @@ import { Icon } from '@iconify/react';
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
import Button from '@/components/Button';
import Dropdown from '@/components/dropdown/Dropdown';
import Dropdown from '@/components/Dropdown';
import { useAuth } from '@/services/hooks/useAuth';
import { AuthApi } from '@/services/api/auth';
@@ -4,6 +4,7 @@ import { JSX, useState } from 'react';
import Tabs from '@/components/Tabs';
import DailyMarketingReportContent from '@/components/pages/report/DailyMarketingReportContent';
import HppPerKandangTab from './sale/tab/HppPerKandangTab';
type MarketingReportTabType =
| 'daily'
@@ -21,6 +22,11 @@ const marketingReportTabs: {
label: 'Penjualan Harian',
content: <DailyMarketingReportContent />,
},
{
id: 'daily-hpp',
label: 'HPP Harian Kandang',
content: <HppPerKandangTab />,
},
];
const MarketingReportContent = () => {
@@ -0,0 +1,37 @@
'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;
@@ -0,0 +1,497 @@
'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';
Font.register({
family: 'Helvetica',
src: 'helvetica',
});
const pdfStyles = StyleSheet.create({
page: {
fontSize: 10,
fontFamily: 'Helvetica',
padding: 20,
backgroundColor: '#FFFFFF',
},
titleSection: {
marginBottom: 10,
},
mainTitle: {
fontSize: 14,
fontWeight: 'bold',
marginBottom: 5,
color: '#1f74bf',
},
supplierTitle: {
fontSize: 12,
fontWeight: 'bold',
marginBottom: 8,
color: '#1f74bf',
},
table: {
borderWidth: 1,
borderColor: '#000000',
marginBottom: 15,
},
tableRow: {
flexDirection: 'row',
},
tableHeader: {
backgroundColor: '#F5F5F5',
},
tableCell: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
textAlign: 'left',
},
tableCellHeader: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
paddingVertical: 12,
textAlign: 'center',
},
tableCellHeaderRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
textAlign: 'right',
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
paddingVertical: 12,
},
tableCellRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
textAlign: 'right',
},
tableCellCenter: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
textAlign: 'center',
},
tableBorderBottom: {
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
},
supplierSection: {
marginBottom: 10,
},
supplierSectionBreak: {
marginBottom: 15,
},
parameterBadge: {
backgroundColor: '#F5F5F5',
color: '#333333',
padding: 4,
borderRadius: 4,
fontSize: 8,
marginRight: 8,
marginBottom: 4,
},
parameterContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
marginBottom: 8,
},
});
interface HppPerKandangExportParams {
data: HppPerKandangReport;
params: {
area_name?: string;
location_name?: string;
kandang_name?: string;
period?: string;
weight_min?: string;
weight_max?: string;
show_unrecorded?: string;
sort_by?: string;
};
}
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;
};
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.supplierSection}>
<Text style={pdfStyles.supplierTitle}>Rekapitulasi</Text>
<View style={pdfStyles.table}>
{/* Table Header */}
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>Rentang BW</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>Sisa Ekor</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>Sisa Kg</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Rata-Rata Bobot (Kg)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>Produksi Telur (Butir)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>Produksi Telur (Kg)</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.5 }]}>
<Text>Feed (Supplier)</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>DOC (Supplier)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Rata-Rata Harga DOC</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Nilai Nominal Telur</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>HPP Ayam</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>HPP Telur (RP/KG)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Nominal Sisa</Text>
</View>
</View>
{/* Table Body - Rekapitulasi */}
{rekapitulasiByWeightRange.map(
(group: HppPerKandangPerWeightRange, index: number) => (
<View
key={index}
style={[
pdfStyles.tableRow,
index < rekapitulasiByWeightRange.length - 1
? pdfStyles.tableBorderBottom
: {},
]}
>
<View style={[pdfStyles.tableCellCenter, { flex: 1.2 }]}>
<Text>{group.label}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatNumber(group.remaining_chicken_birds)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>
{formatNumber(group.remaining_chicken_weight_kg)}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatNumber(group.avg_weight_kg)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatNumber(group.egg_production_pieces)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatNumber(group.egg_production_kg)}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text>
{group.feed_suppliers
?.map(
(s: { alias?: string; name: string }) =>
s.alias || s.name
)
.join(' | ') || '-'}
</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
<Text>
{group.doc_suppliers
?.map(
(s: { alias?: string; name: string }) =>
s.alias || s.name
)
.join(' | ') || '-'}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(group.average_doc_price_rp)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(group.egg_value_rp)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatCurrency(group.hpp_rp)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(group.egg_hpp_rp_per_kg)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(group.remaining_value_rp)}</Text>
</View>
</View>
)
)}
</View>
</View>
{/* Detail Per Kandang Section */}
<View style={pdfStyles.supplierSectionBreak}>
<Text style={pdfStyles.supplierTitle}>Detail Per Kandang</Text>
<View style={pdfStyles.table}>
{/* Table Header */}
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={[pdfStyles.tableCellHeader, { flex: 0.5 }]}>
<Text>No</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.5 }]}>
<Text>Kandang</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<Text>Rentang BW</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>Rata-Rata Bobot (Kg)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>Sisa Ekor</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>Sisa Kg (Ayam)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>Produksi Telur (Butir)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>Produksi Telur (Kg)</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>Feed (Supplier)</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<Text>DOC (Supplier)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Rata-Rata Harga DOC</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Nilai Nominal Telur</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>HPP Ayam</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>HPP Telur (RP/KG)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Nominal Sisa</Text>
</View>
</View>
{/* Table Body - Detail Per Kandang */}
{data.rows.map((item: HppPerKandangRow, index: number) => (
<View
key={index}
style={[
pdfStyles.tableRow,
index < data.rows.length - 1
? pdfStyles.tableBorderBottom
: {},
]}
>
<View style={[pdfStyles.tableCellCenter, { flex: 0.5 }]}>
<Text>{index + 1}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text>{item.kandang?.name || '-'}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text>
{item.weight_range.weight_min.toFixed(2)} -{' '}
{item.weight_range.weight_max.toFixed(2)}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatNumber(item.avg_weight_kg)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(item.remaining_chicken_birds)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(item.remaining_chicken_weight_kg)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(item.egg_production_pieces)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(item.egg_production_kg)}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
<Text>
{item.feed_suppliers
?.map(
(s: { alias?: string; name: string }) =>
s.alias || s.name
)
.join(' | ')}
</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text>
{item.doc_suppliers
?.map(
(s: { alias?: string; name: string }) =>
s.alias || s.name
)
.join(' | ')}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.average_doc_price_rp)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.egg_value_rp)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatCurrency(item.hpp_rp)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatCurrency(item.egg_hpp_rp_per_kg)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.remaining_value_rp)}</Text>
</View>
</View>
))}
</View>
</View>
</Page>
</Document>
);
};
export const generateHppPerKandangPDF = async (
data: HppPerKandangExportParams['data'],
params: HppPerKandangExportParams['params']
): Promise<void> => {
const PDFDocument = createPDFDocument(data, params);
try {
const blob = await pdf(PDFDocument).toBlob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
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;
}
};
@@ -0,0 +1,959 @@
import { useState, useMemo, useCallback } from 'react';
import { ChangeEventHandler } from 'react';
import useSWR from 'swr';
import Card from '@/components/Card';
import SelectInput, {
useSelect,
OptionType,
} from '@/components/input/SelectInput';
import DateInput from '@/components/input/DateInput';
import NumberInput from '@/components/input/NumberInput';
import { AreaApi } from '@/services/api/master-data';
import { LocationApi } from '@/services/api/master-data';
import { KandangApi } from '@/services/api/master-data';
import { SaleReportApi } from '@/services/api/report/marketing-sale';
import Table from '@/components/Table';
import { ColumnDef, Row, flexRender } from '@tanstack/react-table';
import { formatCurrency, formatNumber } from '@/lib/helper';
import {
HppPerKandangReport,
HppPerKandangRow,
HppPerKandangPerWeightRange,
} from '@/types/api/report/hpp-per-kandang';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
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 toast from 'react-hot-toast';
import * as XLSX from 'xlsx';
import { Icon } from '@iconify/react';
const HppPerKandangTab = () => {
// ===== STATE MANAGEMENT =====
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
// ===== SUBMISSION STATE =====
const [isSubmitted, setIsSubmitted] = useState(false);
// ===== TABLE FILTER STATE =====
const { state: tableFilterState, updateFilter } = useTableFilter({
initial: {
area_id: [] as string[],
location_id: [] as string[],
kandang_id: [] as string[],
weight_min: '',
weight_max: '',
period: '',
sort_by: '',
show_unrecorded: false,
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
});
const { options: areaOptions, isLoadingOptions: isLoadingAreas } = useSelect(
AreaApi.basePath,
'id',
'name',
'search'
);
const { options: locationOptions, isLoadingOptions: isLoadingLocations } =
useSelect(LocationApi.basePath, 'id', 'name', 'search');
const { options: kandangOptions, isLoadingOptions: isLoadingKandangs } =
useSelect(KandangApi.basePath, 'id', 'name', 'search');
const showUnrecordedOptions: OptionType[] = [
{ value: 'false', label: 'Sembunyikan' },
{ value: 'true', label: 'Tampilkan' },
];
const areaChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const arr = Array.isArray(val) ? val : val ? [val] : [];
updateFilter(
'area_id',
arr.map((v) => String((v as OptionType).value))
);
setIsSubmitted(false);
},
[updateFilter]
);
const locationChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const arr = Array.isArray(val) ? val : val ? [val] : [];
updateFilter(
'location_id',
arr.map((v) => String((v as OptionType).value))
);
setIsSubmitted(false);
},
[updateFilter]
);
const kandangChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const arr = Array.isArray(val) ? val : val ? [val] : [];
updateFilter(
'kandang_id',
arr.map((v) => String((v as OptionType).value))
);
setIsSubmitted(false);
},
[updateFilter]
);
const weightMinChangeHandler = useCallback<
ChangeEventHandler<HTMLInputElement>
>(
(e) => {
const val = e.target.value;
updateFilter('weight_min', val ? String(parseFloat(val) || 0) : '');
setIsSubmitted(false);
},
[updateFilter]
);
const weightMaxChangeHandler = useCallback<
ChangeEventHandler<HTMLInputElement>
>(
(e) => {
const val = e.target.value;
updateFilter('weight_max', val ? String(parseFloat(val) || 0) : '');
setIsSubmitted(false);
},
[updateFilter]
);
const periodChangeHandler = useCallback<ChangeEventHandler<HTMLInputElement>>(
(e) => {
const val = e.target.value;
updateFilter('period', val || '');
setIsSubmitted(false);
},
[updateFilter]
);
const showUnrecordedChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
updateFilter('show_unrecorded', newVal?.value === 'true');
setIsSubmitted(false);
},
[updateFilter]
);
const resetFilters = useCallback(() => {
updateFilter('area_id', []);
updateFilter('location_id', []);
updateFilter('kandang_id', []);
updateFilter('weight_min', '');
updateFilter('weight_max', '');
updateFilter('period', '');
updateFilter('sort_by', '');
updateFilter('show_unrecorded', false);
setIsSubmitted(false);
}, [updateFilter]);
const handleSubmit = useCallback(() => {
if (!tableFilterState.period) {
toast.error('Periode wajib diisi');
return;
}
setIsSubmitted(true);
}, [tableFilterState.period]);
// ===== DATA FETCHING =====
const { data: hppPerKandang, isLoading } = useSWR(
isSubmitted
? () => {
const params = {
area_id:
tableFilterState.area_id.length > 0
? tableFilterState.area_id.join(',')
: undefined,
location_id:
tableFilterState.location_id.length > 0
? tableFilterState.location_id.join(',')
: undefined,
kandang_id:
tableFilterState.kandang_id.length > 0
? tableFilterState.kandang_id.join(',')
: undefined,
weight_min: tableFilterState.weight_min || undefined,
weight_max: tableFilterState.weight_max || undefined,
period: tableFilterState.period || undefined,
sort_by: tableFilterState.sort_by || undefined,
show_unrecorded: tableFilterState.show_unrecorded,
};
return ['hpp-per-kandang-report', params];
}
: null,
([, params]) =>
SaleReportApi.getHppPerKandangReport(
params.area_id,
params.location_id,
params.kandang_id,
params.weight_min,
params.weight_max,
params.period,
params.sort_by,
params.show_unrecorded
)
);
const data: HppPerKandangReport['rows'] = useMemo(
() =>
isResponseSuccess(hppPerKandang)
? (hppPerKandang?.data?.rows as HppPerKandangReport['rows']) || []
: [],
[hppPerKandang]
);
const summaryTotal =
isResponseSuccess(hppPerKandang) && hppPerKandang?.data?.summary?.total
? hppPerKandang.data.summary.total
: undefined;
const perWeightRangeSummary = useMemo(
() =>
isResponseSuccess(hppPerKandang) &&
hppPerKandang?.data?.summary?.per_weight_range
? hppPerKandang.data.summary.per_weight_range
: [],
[hppPerKandang]
);
const period =
isResponseSuccess(hppPerKandang) && hppPerKandang?.data?.period
? hppPerKandang.data.period
: undefined;
// ===== EXPORT DATA FETCHER =====
const hppPerKandangExport =
useCallback(async (): Promise<HppPerKandangReport | null> => {
const params = {
area_id:
tableFilterState.area_id.length > 0
? tableFilterState.area_id.join(',')
: undefined,
location_id:
tableFilterState.location_id.length > 0
? tableFilterState.location_id.join(',')
: undefined,
kandang_id:
tableFilterState.kandang_id.length > 0
? tableFilterState.kandang_id.join(',')
: undefined,
weight_min: tableFilterState.weight_min || undefined,
weight_max: tableFilterState.weight_max || undefined,
period: tableFilterState.period || undefined,
sort_by: tableFilterState.sort_by || undefined,
show_unrecorded: tableFilterState.show_unrecorded,
limit: 10000,
page: 1,
};
const response = await SaleReportApi.getHppPerKandangReport(
params.area_id,
params.location_id,
params.kandang_id,
params.weight_min,
params.weight_max,
params.period,
params.sort_by,
params.show_unrecorded
);
return isResponseSuccess(response) ? response.data : null;
}, [tableFilterState]);
// ===== TABLE COLUMNS DEFINITION =====
const allFeedSuppliers = useMemo(() => {
const suppliers = new Set<string>();
data.forEach((item: HppPerKandangRow) => {
item.feed_suppliers?.forEach((s: { alias?: string; name: string }) => {
suppliers.add(s.alias || s.name);
});
});
return Array.from(suppliers).join(' | ');
}, [data]);
const allDocSuppliers = useMemo(() => {
const suppliers = new Set<string>();
data.forEach((item: HppPerKandangRow) => {
item.doc_suppliers?.forEach((s: { alias?: string; name: string }) => {
suppliers.add(s.alias || s.name);
});
});
return Array.from(suppliers).join(' | ');
}, [data]);
// ===== EXPORT HANDLERS =====
const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true);
try {
const allDataForExport = await hppPerKandangExport();
if (
!allDataForExport ||
!allDataForExport?.rows ||
allDataForExport.rows.length === 0
) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
const allExportData =
allDataForExport.rows as HppPerKandangReport['rows'];
const summaryTotal = allDataForExport.summary.total;
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 Ayam (Ekor)': item.remaining_chicken_birds || 0,
'Sisa Ayam (KG)': item.remaining_chicken_weight_kg || 0,
'Produksi Telur (Butir)': item.egg_production_pieces || 0,
'Produksi 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,
'Nilai Nominal Telur (RP)': item.egg_value_rp || 0,
'HPP Ayam (RP)': item.hpp_rp || 0,
'HPP Telur (RP/KG)': item.egg_hpp_rp_per_kg || 0,
'Nilai Nominal Sisa Ayam (RP)': item.remaining_value_rp || 0,
})
);
excelData.push({
No: 'TOTAL',
Kandang: 'ALL',
'Rentang Bobot': '-',
'Rata-Rata Bobot (KG)': summaryTotal?.average_weight_kg || 0,
'Sisa Ayam (Ekor)': summaryTotal?.total_remaining_chicken_birds || 0,
'Sisa Ayam (KG)': summaryTotal?.total_remaining_chicken_weight_kg || 0,
'Produksi Telur (Butir)':
summaryTotal?.total_egg_production_pieces || 0,
'Produksi 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,
'Nilai Nominal Telur (RP)': summaryTotal?.total_egg_value_rp || 0,
'HPP Ayam (RP)': summaryTotal?.total_hpp_rp || 0,
'HPP Telur (RP/KG)': summaryTotal?.average_egg_hpp_rp_per_kg || 0,
'Nilai Nominal Sisa Ayam (RP)':
summaryTotal?.total_remaining_value_rp || 0,
});
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 Ayam (Ekor)
{ wch: 15 }, // Sisa Ayam (KG)
{ wch: 18 }, // Produksi Telur (Butir)
{ wch: 18 }, // Produksi Telur (KG)
{ wch: 20 }, // Feed (Supplier)
{ wch: 20 }, // DOC (Supplier)
{ wch: 20 }, // Rata-Rata Harga DOC (RP)
{ wch: 20 }, // Nilai Nominal Telur (RP)
{ wch: 15 }, // HPP Ayam (RP)
{ wch: 18 }, // HPP Telur (RP/KG)
{ wch: 25 }, // Nilai Nominal Sisa Ayam (RP)
];
worksheet['!cols'] = colWidths;
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'HPP 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,
]);
const handleExportPDF = useCallback(async () => {
setIsPdfExportLoading(true);
try {
const allDataForExport = await hppPerKandangExport();
if (
!allDataForExport ||
!allDataForExport?.rows ||
allDataForExport.rows.length === 0
) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
const areaName =
tableFilterState.area_id.length > 0
? tableFilterState.area_id
.map(
(id) =>
areaOptions.find((opt) => opt.value === Number(id))?.label
)
.filter(Boolean)
.join(', ') || 'Semua Area'
: 'Semua Area';
const locationName =
tableFilterState.location_id.length > 0
? tableFilterState.location_id
.map(
(id) =>
locationOptions.find((opt) => opt.value === Number(id))?.label
)
.filter(Boolean)
.join(', ') || 'Semua Lokasi'
: 'Semua Lokasi';
const kandangName =
tableFilterState.kandang_id.length > 0
? tableFilterState.kandang_id
.map(
(id) =>
kandangOptions.find((opt) => opt.value === Number(id))?.label
)
.filter(Boolean)
.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,
});
toast.success('PDF berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat PDF. Silakan coba lagi.');
} finally {
setIsPdfExportLoading(false);
}
}, [
hppPerKandangExport,
tableFilterState,
areaOptions,
locationOptions,
kandangOptions,
]);
const getTableColumns = (): ColumnDef<HppPerKandangReport['rows'][0]>[] => {
const tableColumns: ColumnDef<HppPerKandangReport['rows'][0]>[] = [
{
id: 'no',
header: 'No',
cell: (props) => props.row.index + 1,
footer: () => <div className='font-semibold text-gray-900'>TOTAL</div>,
},
{
id: 'kandang_name',
header: 'Kandang',
accessorKey: 'kandang.name',
cell: (props) => {
const kandang = props.row.original.kandang;
return kandang?.name || '-';
},
footer: () => <div className='font-semibold text-gray-900'>ALL</div>,
},
{
id: 'weight_range',
header: 'Rentang Bobot',
accessorKey: 'weight_range',
cell: (props) => {
const weightRange = props.row.original.weight_range;
return weightRange
? `${formatNumber(weightRange.weight_min)} - ${formatNumber(weightRange.weight_max)}`
: '-';
},
footer: () => <div className='font-semibold text-gray-900'>-</div>,
},
{
id: 'avg_weight_kg',
header: 'Rata-Rata Bobot (KG)',
accessorKey: 'avg_weight_kg',
cell: (props) => {
const value = props.row.original.avg_weight_kg;
return <div className='text-right'>{formatNumber(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatNumber(summaryTotal?.average_weight_kg || 0)}
</div>
),
},
{
id: 'remaining_chicken_birds',
header: 'Sisa Ayam (Ekor)',
accessorKey: 'remaining_chicken_birds',
cell: (props) => {
const value = props.row.original.remaining_chicken_birds;
return <div className='text-right'>{formatNumber(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatNumber(summaryTotal?.total_remaining_chicken_birds || 0)}
</div>
),
},
{
id: 'remaining_chicken_weight_kg',
header: 'Sisa Ayam (KG)',
accessorKey: 'remaining_chicken_weight_kg',
cell: (props) => {
const value = props.row.original.remaining_chicken_weight_kg;
return <div className='text-right'>{formatNumber(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatNumber(summaryTotal?.total_remaining_chicken_weight_kg || 0)}
</div>
),
},
{
id: 'egg_production_pieces',
header: 'Produksi Telur (Butir)',
accessorKey: 'egg_production_pieces',
cell: (props) => {
const value = props.row.original.egg_production_pieces;
return <div className='text-right'>{formatNumber(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatNumber(summaryTotal?.total_egg_production_pieces || 0)}
</div>
),
},
{
id: 'egg_production_kg',
header: 'Produksi Telur (KG)',
accessorKey: 'egg_production_kg',
cell: (props) => {
const value = props.row.original.egg_production_kg;
return <div className='text-right'>{formatNumber(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatNumber(summaryTotal?.total_remaining_chicken_weight_kg || 0)}
</div>
),
},
{
id: 'feed_suppliers',
header: 'Feed (Supplier)',
accessorKey: 'feed_suppliers',
cell: (props) => {
const suppliers = props.row.original.feed_suppliers;
return (
suppliers
?.map((s: { alias?: string; name: string }) => s.alias || s.name)
.join(' | ') || '-'
);
},
footer: () => (
<div className='font-semibold text-gray-900'>
{allFeedSuppliers || '-'}
</div>
),
},
{
id: 'doc_suppliers',
header: 'DOC (Supplier)',
accessorKey: 'doc_suppliers',
cell: (props) => {
const suppliers = props.row.original.doc_suppliers;
return (
suppliers
?.map((s: { alias?: string; name: string }) => s.alias || s.name)
.join(' | ') || '-'
);
},
footer: () => (
<div className='font-semibold text-gray-900'>
{allDocSuppliers || '-'}
</div>
),
},
{
id: 'average_doc_price_rp',
header: 'Rata-Rata Harga DOC (RP)',
accessorKey: 'average_doc_price_rp',
cell: (props) => {
const value = props.row.original.average_doc_price_rp;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summaryTotal?.total_average_doc_price_rp || 0)}
</div>
),
},
{
id: 'egg_value_rp',
header: 'Nilai Nominal Telur (RP)',
accessorKey: 'egg_value_rp',
cell: (props) => {
const value = props.row.original.egg_value_rp;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summaryTotal?.total_egg_value_rp || 0)}
</div>
),
},
{
id: 'hpp_rp',
header: 'HPP Ayam (RP)',
accessorKey: 'hpp_rp',
cell: (props) => {
const value = props.row.original.hpp_rp;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summaryTotal?.total_hpp_rp || 0)}
</div>
),
},
{
id: 'egg_hpp_rp_per_kg',
header: 'HPP Telur (RP/KG)',
accessorKey: 'egg_hpp_rp_per_kg',
cell: (props) => {
const value = props.row.original.egg_hpp_rp_per_kg;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summaryTotal?.average_egg_hpp_rp_per_kg || 0)}
</div>
),
},
{
id: 'remaining_value_rp',
header: 'Nilai Nominal Sisa Ayam (RP)',
accessorKey: 'remaining_value_rp',
cell: (props) => {
const value = props.row.original.remaining_value_rp;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summaryTotal?.total_remaining_value_rp || 0)}
</div>
),
},
];
return tableColumns;
};
// ===== CUSTOM ROW RENDERER =====
const renderCustomRow = useCallback(
(row: Row<HppPerKandangReport['rows'][0]>) => {
if (row.index === data.length - 1) {
const defaultRow = (
<tr
key={row.id}
className='hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200'
>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap border-gray-200'
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
);
const customRows = [
<tr
className='border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200'
key={'rekapitulasi-row'}
>
<td
colSpan={15}
className='px-4 py-3 text-gray-900 text-center font-semibold'
>
Rekapitulasi per rentang bobot
</td>
</tr>,
];
if (perWeightRangeSummary.length > 0) {
perWeightRangeSummary.forEach(
(item: HppPerKandangPerWeightRange, index = 0) => {
customRows.push(
<tr
key={`summary-${item.id}`}
className='hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200 [&_td]:px-4 [&_td]:py-3 [&_td]:text-xs [&_td]:text-gray-900 [&_td]:whitespace-nowrap'
>
<td className=''>{index + 1}</td>
<td className=''>ALL</td>
<td className=''>{item.label}</td>
<td className='text-right'>
{formatNumber(item.avg_weight_kg)}
</td>
<td className='text-right'>
{formatNumber(item.remaining_chicken_birds)}
</td>
<td className='text-right'>
{formatNumber(item.remaining_chicken_weight_kg)}
</td>
<td className='text-right'>
{formatNumber(item.egg_production_pieces)}
</td>
<td className='text-right'>
{formatNumber(item.egg_production_kg)}
</td>
<td className=''>
{item.feed_suppliers
?.map((s) => s.alias || s.name)
.join(' | ') || '-'}
</td>
<td className=''>
{item.doc_suppliers
?.map((s) => s.alias || s.name)
.join(' | ') || '-'}
</td>
<td className='text-right'>
{formatCurrency(item.average_doc_price_rp)}
</td>
<td className='text-right'>
{formatCurrency(item.egg_value_rp)}
</td>
<td className='text-right'>{formatCurrency(item.hpp_rp)}</td>
<td className='text-right'>
{formatCurrency(item.egg_hpp_rp_per_kg)}
</td>
<td className='text-right'>
{formatCurrency(item.remaining_value_rp)}
</td>
</tr>
);
}
);
}
return [defaultRow, ...customRows];
}
return null;
},
[data, perWeightRangeSummary]
);
return (
<div className='w-full p-0 sm:p-4'>
<Card
subtitle={
period
? `Laporan > HPP Harian Kandang (${period})`
: 'Laporan > HPP Harian Kandang'
}
className={{ wrapper: 'w-full', body: 'p-1!' }}
>
<div className='grid md:grid-cols-3 grid-cols-1 gap-4'>
<SelectInput
label='Area'
placeholder='Pilih Area'
isMulti
options={areaOptions}
value={areaOptions.filter((opt) =>
(tableFilterState.area_id || [])
.map(String)
.includes(String(opt.value))
)}
onChange={areaChangeHandler}
isLoading={isLoadingAreas}
isClearable
/>
<SelectInput
label='Lokasi'
placeholder='Pilih Lokasi'
isMulti
options={locationOptions}
value={locationOptions.filter((opt) =>
(tableFilterState.location_id || [])
.map(String)
.includes(String(opt.value))
)}
onChange={locationChangeHandler}
isLoading={isLoadingLocations}
isClearable
/>
<SelectInput
label='Kandang'
placeholder='Pilih Kandang'
isMulti
options={kandangOptions}
value={kandangOptions.filter((opt) =>
(tableFilterState.kandang_id || [])
.map(String)
.includes(String(opt.value))
)}
onChange={kandangChangeHandler}
isLoading={isLoadingKandangs}
isClearable
/>
</div>
<div className='grid md:grid-cols-3 grid-cols-1 gap-4'>
<div className='flex flex-row gap-4'>
<NumberInput
label='Rentang Bobot Min (Kg)'
name='weight_min'
placeholder='Masukkan bobot minimum'
value={tableFilterState.weight_min}
onChange={weightMinChangeHandler}
/>
<NumberInput
label='Rentang Bobot Max (Kg)'
name='weight_max'
placeholder='Masukkan bobot maximum'
value={tableFilterState.weight_max}
onChange={weightMaxChangeHandler}
/>
</div>
<DateInput
label='Periode'
name='period'
placeholder='Pilih Periode'
value={tableFilterState.period}
onChange={periodChangeHandler}
required
/>
<SelectInput
label='Tampilkan Kandang Tanpa Recording'
placeholder='Pilih Opsi'
options={showUnrecordedOptions}
value={
tableFilterState.show_unrecorded
? showUnrecordedOptions.find((opt) => opt.value === 'true') ||
null
: showUnrecordedOptions.find((opt) => opt.value === 'false') ||
null
}
onChange={showUnrecordedChangeHandler}
/>
</div>
<div className='mt-4 flex justify-end gap-2 [&_button]:px-4'>
<Button color='primary' onClick={handleSubmit}>
<Icon icon='heroicons:magnifying-glass' width={20} height={20} />
Cari
</Button>
<Button color='warning' onClick={resetFilters}>
<Icon icon='heroicons-outline:refresh' width={20} height={20} />
Reset
</Button>
<Dropdown
trigger={
<Button color='success' isLoading={isAnyExportLoading}>
Export
<Icon
icon='heroicons-outline:download'
width={20}
height={20}
/>
</Button>
}
align='end'
>
<Menu className='w-32'>
<MenuItem title='Excel' onClick={handleExportExcel} />
<MenuItem title='PDF' onClick={handleExportPDF} />
</Menu>
</Dropdown>
</div>
<div className='divider'></div>
{!isSubmitted ? (
<div className='mt-6 text-center text-gray-500'>
Silakan pilih filter dan klik tombol Cari untuk menampilkan data.
</div>
) : isLoading ? (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
) : data.length === 0 ? (
<div className='mt-6 text-center text-gray-500'>
Tidak ada data yang dapat ditampilkan...
</div>
) : (
<Table
data={data}
columns={getTableColumns()}
renderFooter={data.length > 0}
renderCustomRow={renderCustomRow}
className={{
containerClassName: 'w-full mt-6',
tableWrapperClassName: 'overflow-x-auto mt-4',
tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
headerColumnClassName:
'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200',
bodyRowClassName:
'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200',
bodyColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
tableFooterClassName:
'bg-gray-100 font-semibold border border-gray-200',
footerRowClassName: 'border-t-2 border-gray-300',
footerColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
}}
/>
)}
</Card>
</div>
);
};
export default HppPerKandangTab;
+53
View File
@@ -0,0 +1,53 @@
import { BaseApiService } from '@/services/api/base';
import { BaseApiResponse } from '@/types/api/api-general';
import { HppPerKandangReport } from '@/types/api/report/hpp-per-kandang';
export class MarketingSaleReportService extends BaseApiService<
HppPerKandangReport,
unknown,
unknown
> {
constructor(basePath: string) {
super(basePath);
}
async getHppPerKandangReport(
area_id?: string,
location_id?: string,
kandang_id?: string,
weight_min?: string,
weight_max?: string,
period?: string,
sort_by?: string,
show_unrecorded?: boolean,
page?: number,
limit?: number
): Promise<BaseApiResponse<HppPerKandangReport> | undefined> {
return await this.customRequest<BaseApiResponse<HppPerKandangReport>>(
`hpp-per-kandang`,
{
method: 'GET',
params: {
area_id: area_id,
location_id: location_id,
kandang_id: kandang_id,
weight_min: weight_min,
weight_max: weight_max,
period: period,
sort_by: sort_by,
show_unrecorded: show_unrecorded,
page: page,
limit: limit,
},
}
);
}
}
export const SaleReportApi = new MarketingSaleReportService(
'reports/marketings'
);
// export const SaleReportApi = new MarketingSaleReportService(
// 'http://localhost:4010/api/reports/marketings'
// );
+69
View File
@@ -0,0 +1,69 @@
import { BaseMetadata } from '@types/api/base-metadata';
import { Supplier } from '@/types/api/master-data/supplier';
import { Kandang } from '@/types/api/master-data/kandang';
export type HppPerKandangRow = {
id: number;
kandang: Kandang;
weight_range: {
weight_min: number;
weight_max: number;
};
remaining_chicken_birds: number;
remaining_chicken_weight_kg: number;
avg_weight_kg: number;
egg_production_pieces: number;
egg_production_kg: number;
egg_hpp_rp_per_kg: number;
egg_value_rp: number;
feed_suppliers: Supplier[];
doc_suppliers: Supplier[];
average_doc_price_rp: number;
hpp_rp: number;
remaining_value_rp: number;
};
export type HppPerKandangSummaryTotal = {
total_remaining_chicken_birds: number;
total_remaining_chicken_weight_kg: number;
average_weight_kg: number;
total_remaining_value_rp: number;
total_egg_production_pieces: number;
total_egg_production_kg: number;
average_egg_hpp_rp_per_kg: number;
total_egg_value_rp: number;
total_hpp_rp: number;
total_average_doc_price_rp: number;
};
export type HppPerKandangPerWeightRange = {
id: number;
weight_range: {
weight_min: number;
weight_max: number;
};
label: string;
remaining_chicken_birds: number;
remaining_chicken_weight_kg: number;
avg_weight_kg: number;
egg_production_pieces: number;
egg_production_kg: number;
egg_hpp_rp_per_kg: number;
egg_value_rp: number;
feed_suppliers: Supplier[];
doc_suppliers: Supplier[];
average_doc_price_rp: number;
hpp_rp: number;
remaining_value_rp: number;
};
export type HppPerKandangSummary = {
per_weight_range: HppPerKandangPerWeightRange[];
total: HppPerKandangSummaryTotal;
};
export type HppPerKandangReport = BaseMetadata & {
period: string;
rows: HppPerKandangRow[];
summary: HppPerKandangSummary;
};