refactor(FE): Refactor marketing report components into a dedicated

folder
This commit is contained in:
rstubryan
2026-02-10 16:20:19 +07:00
parent be7b2a0f93
commit 5593463eab
9 changed files with 7 additions and 44 deletions
@@ -0,0 +1,289 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import useSWR from 'swr';
import { ColumnDef, SortingState } from '@tanstack/react-table';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Card from '@/components/Card';
import Collapse from '@/components/Collapse';
import {
cn,
formatCurrency,
formatDate,
formatNumber,
formatVechicleNumber,
} from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { DailyMarketingRow } from '@/types/api/report/marketing';
import { MarketingReportApi } from '@/services/api/report/marketing-report';
interface DailyMarketingsTableProps {
dailyMarketingsReportUrl: string;
onSetPage: (page: number) => void;
pageSize: number;
onSetPageSize: (pageSize: number) => void;
searchValue: string;
onSearchChange: ChangeEventHandler<HTMLInputElement>;
onFilterByChange: (filterBy: string) => void;
onSortByChange: (sort: 'asc' | 'desc' | '') => void;
}
const DailyMarketingsTable = ({
dailyMarketingsReportUrl,
onSetPage,
pageSize,
onSetPageSize,
searchValue,
onSearchChange,
onFilterByChange,
onSortByChange,
}: DailyMarketingsTableProps) => {
const { data: dailyMarketings, isLoading: isLoadingDailyMarketings } = useSWR(
dailyMarketingsReportUrl,
MarketingReportApi.getAllDailyMarketingFetcher,
{
keepPreviousData: true,
}
);
const [open, setOpen] = useState(true);
const [sorting, setSorting] = useState<SortingState>([]);
const dailyMarketingColumns: ColumnDef<DailyMarketingRow>[] = [
{
header: 'No',
cell: (props) => props.row.index + 1,
},
{
accessorKey: 'so_date',
header: 'Tanggal Jual',
cell: (props) => formatDate(props.row.original.so_date, 'DD-MMM-YYYY'),
footer: 'Total',
},
{
accessorKey: 'realization_date',
header: 'Tanggal Realisasi',
cell: (props) =>
formatDate(props.row.original.realization_date, 'DD-MMM-YYYY'),
},
{
accessorKey: 'aging_days',
header: 'Aging',
cell: (props) => `${props.row.original.aging_days} hari`,
},
{
accessorKey: 'warehouse',
header: 'Gudang',
cell: ({ row }) => row.original.warehouse.name,
},
{
accessorKey: 'customer',
header: 'Pelanggan',
cell: ({ row }) => row.original.customer.name,
},
{
accessorKey: 'do_number',
header: 'No. DO',
enableSorting: false,
},
{
accessorKey: 'sales_person',
header: 'Sales/Marketing',
cell: (props) => props.row.original.sales.name,
},
{
accessorKey: 'vehicle_number',
header: 'No. Polisi',
cell: (props) => (
<span className='text-nowrap'>
{formatVechicleNumber(props.row.original.vehicle_number)}
</span>
),
},
{
accessorKey: 'marketing_type',
header: 'Marketing Type',
enableSorting: false,
},
{
accessorKey: 'product',
header: 'Produk',
cell: ({ row }) => row.original.product.name,
},
{
accessorKey: 'qty',
header: 'Kuantitas',
cell: (props) => formatNumber(props.row.original.qty),
footer: () => {
const totalQty = isResponseSuccess(dailyMarketings)
? dailyMarketings?.total?.total_qty
: 0;
return totalQty ? formatNumber(totalQty) : '-';
},
},
{
accessorKey: 'average_weight',
header: 'Bobot Rata-Rata (Kg)',
cell: (props) => formatNumber(props.row.original.average_weight_kg),
footer: () => {
const totalAverageWeightKg = isResponseSuccess(dailyMarketings)
? dailyMarketings?.total?.average_weight_kg
: 0;
return totalAverageWeightKg ? formatNumber(totalAverageWeightKg) : '-';
},
},
{
accessorKey: 'total_weight',
header: 'Bobot Total (Kg)',
cell: (props) => formatNumber(props.row.original.total_weight_kg),
footer: () => {
const totalWeightKg = isResponseSuccess(dailyMarketings)
? dailyMarketings?.total?.total_weight_kg
: 0;
return totalWeightKg ? formatNumber(totalWeightKg) : '-';
},
},
{
accessorKey: 'sales_price',
header: 'Harga Jual (Rp)',
cell: (props) => formatCurrency(props.row.original.sales_price_per_kg),
footer: () => {
const totalSalesPrice = isResponseSuccess(dailyMarketings)
? dailyMarketings?.total?.average_sales_price
: 0;
return totalSalesPrice ? formatNumber(totalSalesPrice) : '-';
},
},
{
accessorKey: 'hpp_price',
header: 'HPP (Rp)',
cell: (props) => formatCurrency(props.row.original.hpp_price_per_kg),
footer: () => {
const totalHppPricePerKg = isResponseSuccess(dailyMarketings)
? dailyMarketings?.total?.total_hpp_price_per_kg
: 0;
return totalHppPricePerKg ? formatCurrency(totalHppPricePerKg) : '-';
},
},
{
accessorKey: 'sales_amount',
header: 'Total (Rp)',
cell: (props) => formatCurrency(props.row.original.sales_amount),
footer: () => {
const totalSalesAmount = isResponseSuccess(dailyMarketings)
? dailyMarketings?.total?.total_sales_amount
: 0;
return totalSalesAmount ? formatCurrency(totalSalesAmount) : '-';
},
},
];
useEffect(() => {
if (sorting.length === 1) {
onFilterByChange(sorting[0].id);
onSortByChange(sorting[0].desc ? 'desc' : 'asc');
} else {
onFilterByChange('');
onSortByChange('');
}
}, [sorting]);
useEffect(() => {
if (!open) {
setOpen(
isResponseSuccess(dailyMarketings)
? dailyMarketings.data.length > 0
: false
);
}
}, [dailyMarketings, isResponseSuccess]);
return (
<Card
className={{
wrapper: 'w-full',
body: 'p-4 shadow',
}}
>
<Collapse
open={open}
onOpenChange={setOpen}
title={
<div className='card-actions p-4 justify-between items-center w-full'>
<div className='card-title'>Penjualan Harian</div>
<Icon
icon='material-symbols:keyboard-arrow-down'
width={24}
height={24}
className={cn('text-primary transition-transform', {
'-rotate-180': open,
})}
/>
</div>
}
className='w-full!'
titleClassName='w-full p-0!'
>
<div className='w-full p-0'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-start items-end sm:items-center gap-4'>
<DebouncedTextInput
name='search'
placeholder='Cari Penjualan Harian'
value={searchValue}
onChange={onSearchChange}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
</div>
<Table<DailyMarketingRow>
data={
isResponseSuccess(dailyMarketings) ? dailyMarketings?.data : []
}
columns={dailyMarketingColumns}
pageSize={pageSize}
onPageSizeChange={onSetPageSize}
rowOptions={[10, 20, 50, 100]}
page={
isResponseSuccess(dailyMarketings)
? dailyMarketings?.meta?.page
: 0
}
totalItems={
isResponseSuccess(dailyMarketings)
? dailyMarketings?.meta?.total_results
: 0
}
onPageChange={onSetPage}
isLoading={isLoadingDailyMarketings}
sorting={sorting}
setSorting={setSorting}
renderFooter={true}
className={{
containerClassName: cn({
'w-full mb-20':
isResponseSuccess(dailyMarketings) &&
dailyMarketings?.data?.length === 0,
}),
}}
/>
</div>
</Collapse>
</Card>
);
};
export default DailyMarketingsTable;
@@ -0,0 +1,50 @@
'use client';
import { JSX, useState } from 'react';
import Tabs from '@/components/Tabs';
import DailyMarketingReportContent from '@/components/pages/report/marketing/tab/DailyMarketingReportContent';
import HppPerKandangTab from '@/components/pages/report/marketing/tab/HppPerKandangTab';
type MarketingReportTabType =
| 'daily'
| 'transaction'
| 'hpp-comparison'
| 'daily-hpp';
const marketingReportTabs: {
id: MarketingReportTabType;
label: string;
content: JSX.Element;
}[] = [
{
id: 'daily',
label: 'Penjualan Harian',
content: <DailyMarketingReportContent />,
},
{
id: 'daily-hpp',
label: 'HPP Harian Kandang',
content: <HppPerKandangTab />,
},
];
const MarketingReportContent = () => {
const [activeTab, setActiveTab] = useState<string>('daily');
return (
<section className='w-full max-w-full pb-16'>
<Tabs
activeTabId={activeTab}
onTabChange={setActiveTab}
tabs={marketingReportTabs}
variant='lifted'
className={{
content: '-m-px pl-px',
}}
/>
</section>
);
};
export default MarketingReportContent;
@@ -0,0 +1,264 @@
'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,
PdfTbodyCell,
PdfTfootCell,
} 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 = (): PdfColumn[] => [
{ key: 'no', header: 'No', flex: 0.5, align: 'center' },
{ key: 'so_date', header: 'Tanggal Sales Order', flex: 1.3, align: 'center' },
{
key: 'do_date',
header: 'Tanggal Delivery Order',
flex: 1.3,
align: 'center',
},
{ key: 'aging', header: 'Aging (Hari)', flex: 0.7, align: 'center' },
{ key: 'warehouse', header: 'Gudang', flex: 1.2, align: 'left' },
{ key: 'customer', header: 'Pelanggan', flex: 1.5, align: 'left' },
{ key: 'sales', header: 'Sales', flex: 1, align: 'left' },
{ key: 'product', header: 'Produk', flex: 1.3, align: 'left' },
{ key: 'do_number', header: 'Nomor DO', flex: 1.2, align: 'left' },
{ key: 'vehicle', header: 'Nomor Polisi', flex: 1, align: 'left' },
{ key: 'marketing_type', header: 'Tipe Marketing', flex: 1, align: 'center' },
{ key: 'qty', header: 'Quantity', flex: 0.7, align: 'right' },
{ key: 'avg_weight', header: 'Rata-Rata (Kg)', flex: 0.8, align: 'right' },
{
key: 'total_weight',
header: 'Total Berat (Kg)',
flex: 0.9,
align: 'right',
},
{ key: 'sales_price', header: 'Harga Jual (Rp)', flex: 0.9, align: 'right' },
{ key: 'hpp_price', header: 'HPP (Rp)', flex: 1.3, align: 'right' },
{ key: 'sales_amount', header: 'Total Jual (Rp)', flex: 1, align: 'right' },
{ key: 'hpp_amount', header: 'Total HPP (Rp)', flex: 1.3, align: 'right' },
];
const getTableData = (rows: DailyMarketingReport): PdfTbodyCell[][] => {
return rows.map((row, index) => [
{ key: 'no', value: index + 1 },
{
key: 'so_date',
value: row.so_date ? formatDate(row.so_date, 'DD MMM YY') : '-',
},
{
key: 'do_date',
value: row.realization_date
? formatDate(row.realization_date, 'DD MMM YY')
: '-',
},
{ key: 'aging', value: row.aging_days ?? '-' },
{ key: 'warehouse', value: row.warehouse?.name ?? '-' },
{ key: 'customer', value: row.customer?.name ?? '-' },
{ key: 'sales', value: row.sales?.name ?? '-' },
{ key: 'product', value: row.product?.name ?? '-' },
{ key: 'do_number', value: row.do_number ?? '-' },
{ key: 'vehicle', value: row.vehicle_number ?? '-' },
{
key: 'marketing_type',
value: 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', value: formatNumber(row.qty ?? 0), align: 'right' },
{
key: 'avg_weight',
value: formatNumber(row.average_weight_kg ?? 0),
align: 'right',
},
{
key: 'total_weight',
value: formatNumber(row.total_weight_kg ?? 0),
align: 'right',
},
{
key: 'sales_price',
value: formatCurrency(row.sales_price_per_kg ?? 0),
align: 'right',
},
{
key: 'hpp_price',
value: formatCurrency(row.hpp_price_per_kg ?? 0),
align: 'right',
},
{
key: 'sales_amount',
value: formatCurrency(row.sales_amount ?? 0),
align: 'right',
},
{
key: 'hpp_amount',
value: formatCurrency(row.hpp_amount ?? 0),
align: 'right',
},
]);
};
const getTableFooter = (summary?: SalesSummary): PdfTfootCell[] => {
if (!summary) return [];
return [
{ key: 'no', value: 'TOTAL' },
{ key: 'so_date', value: '' },
{ key: 'do_date', value: '' },
{ key: 'aging', value: '' },
{ key: 'warehouse', value: '' },
{ key: 'customer', value: '' },
{ key: 'sales', value: '' },
{ key: 'product', value: '' },
{ key: 'do_number', value: '' },
{ key: 'vehicle', value: '' },
{ key: 'marketing_type', value: '' },
{
key: 'qty',
value: formatNumber(summary.total_qty ?? 0),
align: 'right',
},
{
key: 'avg_weight',
value: formatNumber(summary.total_weight_kg ?? 0),
align: 'right',
},
{
key: 'total_weight',
value: formatNumber(summary.total_weight_kg ?? 0),
align: 'right',
},
{ key: 'sales_price', value: '' },
{
key: 'hpp_price',
value: formatCurrency(summary.total_hpp_price_per_kg ?? 0),
align: 'right',
},
{
key: 'sales_amount',
value: formatCurrency(summary.total_sales_amount ?? 0),
align: 'right',
},
{
key: 'hpp_amount',
value: formatCurrency(summary.total_hpp_amount ?? 0),
align: '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()}
data={getTableData(rows)}
footer={getTableFooter(summary)}
footerLabel='TOTAL'
/>
<PdfPageNumber />
</Page>
</Document>
);
};
export default DailyMarketingReportPDF;
@@ -0,0 +1,415 @@
'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,
PdfTbodyCell,
PdfTfootCell,
} 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[] => [
{ 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 || '-' },
{
key: 'sisa_butir',
value: formatNumber(group.egg_production_pieces || 0),
align: 'right',
},
{
key: 'sisa_kg',
value: formatNumber(group.egg_production_kg || 0),
align: 'right',
},
{
key: 'rata_rata_bobot',
value: formatNumber(group.avg_weight_kg || 0),
align: 'right',
},
{
key: 'feed_supplier',
value: formatSuppliers(group.feed_suppliers),
},
{
key: 'doc_supplier',
value: formatSuppliers(group.doc_suppliers),
},
{
key: 'rata_harga_doc',
value: formatCurrency(group.average_doc_price_rp || 0),
align: 'right',
},
{
key: 'hpp_telur',
value: formatCurrency(group.egg_hpp_rp_per_kg || 0),
align: 'right',
},
{
key: 'nominal_sisa',
value: formatCurrency(group.egg_value_rp || 0),
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 },
{ 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 || 0),
align: 'right',
},
{
key: 'sisa_butir',
value: formatNumber(item.egg_production_pieces || 0),
align: 'right',
},
{
key: 'sisa_kg',
value: formatNumber(item.egg_production_kg || 0),
align: 'right',
},
{
key: 'feed_supplier',
value: formatSuppliers(item.feed_suppliers),
},
{
key: 'doc_supplier',
value: formatSuppliers(item.doc_suppliers),
},
{
key: 'rata_harga_doc',
value: formatCurrency(item.average_doc_price_rp || 0),
align: 'right',
},
{
key: 'hpp_telur',
value: formatCurrency(item.egg_hpp_rp_per_kg || 0),
align: 'right',
},
{
key: 'nominal_sisa',
value: formatCurrency(item.egg_value_rp || 0),
align: 'right',
},
]);
};
const getDetailFooter = (
summary: HppPerKandangReport['summary'],
allFeedSuppliers: string,
allDocSuppliers: string
): PdfTfootCell[] => {
if (!summary?.total) return [];
return [
{ key: 'no', value: 'TOTAL' },
{ key: 'kandang', value: 'ALL' },
{ key: 'rentang_bw', value: '-' },
{
key: 'rata_rata_bobot',
value: formatNumber(summary.total.average_weight_kg || 0),
align: 'right',
},
{
key: 'sisa_butir',
value: formatNumber(summary.total.total_egg_production_pieces || 0),
align: 'right',
},
{
key: 'sisa_kg',
value: formatNumber(summary.total.total_egg_production_kg || 0),
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 || 0),
align: 'right',
},
{
key: 'hpp_telur',
value: formatCurrency(summary.total.average_egg_hpp_rp_per_kg || 0),
align: 'right',
},
{
key: 'nominal_sisa',
value: formatCurrency(summary.total.total_egg_value_rp || 0),
align: '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={getRekapitulasiData(rekapitulasiByWeightRange)}
/>
</View>
{/* Detail Per Kandang Section */}
<View style={pdfStyles.section}>
<PdfTypography size='h2' variant='primary'>
Detail Per Kandang
</PdfTypography>
<PdfTable
columns={getDetailColumns()}
data={getDetailData(params.data.rows)}
footer={
params.data.summary
? getDetailFooter(
params.data.summary,
allFeedSuppliers,
allDocSuppliers
)
: undefined
}
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);
};
@@ -0,0 +1,472 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import { pdf } from '@react-pdf/renderer';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import Dropdown from '@/components/dropdown/Dropdown';
import DateInput from '@/components/input/DateInput';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
import DailyMarketingsTable from '@/components/pages/report/marketing/DailyMarketingsTable';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import DailyMarketingReportPDF from '@/components/pages/report/marketing/export/DailyMarketingReportPDF';
import { Area } from '@/types/api/master-data/area';
import {
AreaApi,
CustomerApi,
LocationApi,
WarehouseApi,
} from '@/services/api/master-data';
import { Warehouse } from '@/types/api/master-data/warehouse';
import { Customer } from '@/types/api/master-data/customer';
import { MarketingReportApi } from '@/services/api/report/marketing-report';
import {
MARKETING_DATE_FILTER_TYPE_OPTIONS,
MARKETING_TYPE_OPTIONS,
} from '@/config/constant';
import { httpClient } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
import {
DailyMarketingReport,
DailyMarketingReportResponse,
} from '@/types/api/report/marketing';
import { isResponseError } from '@/lib/api-helper';
const DailyMarketingReportContent = () => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
reset: resetFilter,
} = useTableFilter({
initial: {
search: '',
area_id: '',
location_id: '',
warehouse_id: '',
customer_id: '',
start_date: '',
end_date: '',
marketing_type: '',
filter_by: '',
sort_by: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
area_id: 'area_id',
location_id: 'location_id',
warehouse_id: 'warehouse_id',
customer_id: 'customer_id',
start_date: 'start_date',
end_date: 'end_date',
marketing_type: 'marketing_type',
filter_by: 'filter_by',
sort_by: 'sort_by',
},
});
const dailyMarketingsReportUrl = `${MarketingReportApi.basePath}${getTableFilterQueryString()}`;
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false);
const [isLoadingExportingToPdf, setIsLoadingExportingToPdf] = useState(false);
const [selectedArea, setSelectedArea] = useState<OptionType | null>(null);
const {
setInputValue: setAreaInputValue,
options: areaOptions,
isLoadingOptions: isLoadingAreaOptions,
loadMore: loadMoreAreas,
} = useSelect<Area>(AreaApi.basePath, 'id', 'name');
const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedArea(val as OptionType);
updateFilter('area_id', val ? ((val as OptionType).value as string) : '');
};
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
null
);
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedLocation(val as OptionType);
updateFilter(
'location_id',
val ? ((val as OptionType).value as string) : ''
);
};
const [selectedWarehouse, setSelectedWarehouse] = useState<OptionType | null>(
null
);
const {
setInputValue: setWarehouseInputValue,
options: warehouseOptions,
isLoadingOptions: isLoadingWarehouseOptions,
loadMore: loadMoreWarehouses,
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name');
const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedWarehouse(val as OptionType);
updateFilter(
'warehouse_id',
val ? ((val as OptionType).value as string) : ''
);
};
const [selectedCustomer, setSelectedCustomer] = useState<OptionType | null>(
null
);
const {
setInputValue: setCustomerInputValue,
options: customerOptions,
isLoadingOptions: isLoadingCustomerOptions,
loadMore: loadMoreCustomers,
} = useSelect<Customer>(CustomerApi.basePath, 'id', 'name');
const customerChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedCustomer(val as OptionType);
updateFilter(
'customer_id',
val ? ((val as OptionType).value as string) : ''
);
};
const startDateChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
updateFilter('start_date', e.target.value ? e.target.value : '');
};
const endDateChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
updateFilter('end_date', e.target.value ? e.target.value : '');
};
const [selectedMarketingDateFilterType, setSelectedMarketingDateFilterType] =
useState<OptionType | null>(null);
const marketingDateFilterTypeChangeHandler = (
val: OptionType | OptionType[] | null
) => {
setSelectedMarketingDateFilterType(val as OptionType);
updateFilter('filter_by', val ? ((val as OptionType).value as string) : '');
};
const [selectedMarketingType, setSelectedMarketingType] =
useState<OptionType | null>(null);
const marketingTypeChangeHandler = (
val: OptionType | OptionType[] | null
) => {
setSelectedMarketingType(val as OptionType);
updateFilter(
'marketing_type',
val ? ((val as OptionType).value as string) : ''
);
};
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
const filterByChangeHandler = (filterBy: string) => {
updateFilter('filter_by', filterBy);
};
const sortByChangeHandler = (sort: 'asc' | 'desc' | '') => {
updateFilter('sort_by', sort);
};
const exportToExcelHandler = async () => {
setIsLoadingExportingToExcel(true);
await MarketingReportApi.exportDailyMarketingToExcel(
getTableFilterQueryString()
);
setIsLoadingExportingToExcel(false);
};
const exportToPdfHandler = async () => {
setIsLoadingExportingToPdf(true);
const params = new URLSearchParams(getTableFilterQueryString());
params.set('limit', '9999999');
const queryString = `?${params.toString()}`;
try {
const dailyMarketingsReport =
await httpClient<DailyMarketingReportResponse>(
`${MarketingReportApi.basePath}${queryString}`
);
if (isResponseError(dailyMarketingsReport)) {
toast.error('Gagal melakukan export penjualan harian! Coba lagi.');
return;
}
const openPdf = async () => {
const dailyMarketingReportPdfBlob = await pdf(
<DailyMarketingReportPDF
data={dailyMarketingsReport.data}
total={dailyMarketingsReport.total}
/>
).toBlob();
const dailyMarketingReportPdfUrl = URL.createObjectURL(
dailyMarketingReportPdfBlob
);
window.open(dailyMarketingReportPdfUrl, '_blank');
};
const downloadPdf = async () => {
const blob = await pdf(
<DailyMarketingReportPDF
data={dailyMarketingsReport.data}
total={dailyMarketingsReport.total}
/>
).toBlob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'laporan-penjualan-harian.pdf';
link.click();
URL.revokeObjectURL(url);
};
await openPdf();
} catch (error) {
toast.error('Gagal melakukan export penjualan harian! Coba lagi.');
}
setIsLoadingExportingToPdf(false);
};
const handleReset = () => {
setSelectedArea(null);
setSelectedLocation(null);
setSelectedWarehouse(null);
setSelectedCustomer(null);
setSelectedMarketingType(null);
resetFilter();
};
useEffect(() => {
if (
tableFilterState.filter_by === 'realization_date' ||
tableFilterState.filter_by === 'so_date'
) {
setSelectedMarketingDateFilterType({
label:
tableFilterState.filter_by === 'realization_date'
? 'Tanggal Realisasi'
: 'Tanggal SO',
value: tableFilterState.filter_by,
});
} else {
setSelectedMarketingDateFilterType(null);
}
}, [tableFilterState.filter_by]);
return (
<div className='w-full border border-gray-200 p-4'>
<div>
<h2 className='text-xl font-bold text-center'>Penjualan Harian</h2>
</div>
{/* Filters */}
<div className='flex flex-col gap-4 mb-6'>
<div className='grid grid-cols-12 gap-4'>
<SelectInput
label='Area'
placeholder='Pilih Area'
options={areaOptions}
isLoading={isLoadingAreaOptions}
value={selectedArea}
onChange={areaChangeHandler}
onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreAreas}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
}}
/>
<SelectInput
label='Lokasi'
placeholder='Pilih Lokasi'
options={locationOptions}
isLoading={isLoadingLocationOptions}
value={selectedLocation}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
}}
/>
<SelectInput
label='Gudang'
placeholder='Pilih Gudang'
options={warehouseOptions}
isLoading={isLoadingWarehouseOptions}
value={selectedWarehouse}
onChange={warehouseChangeHandler}
onInputChange={setWarehouseInputValue}
onMenuScrollToBottom={loadMoreWarehouses}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
}}
/>
<SelectInput
label='Customer'
placeholder='Pilih Customer'
options={customerOptions}
isLoading={isLoadingCustomerOptions}
value={selectedCustomer}
onChange={customerChangeHandler}
onInputChange={setCustomerInputValue}
onMenuScrollToBottom={loadMoreCustomers}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
}}
/>
<DateInput
name='startDate'
label='Tanggal Awal'
placeholder='Tanggal Realisasi'
value={tableFilterState.start_date}
onChange={startDateChangeHandler}
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
}}
/>
<DateInput
name='endDate'
label='Tanggal Akhir'
placeholder='Tanggal Realisasi'
value={tableFilterState.end_date}
onChange={endDateChangeHandler}
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
}}
/>
</div>
<div className='grid grid-cols-12 gap-4'>
<SelectInput
label='Filter Tanggal'
placeholder='Pilih Filter Tanggal'
options={MARKETING_DATE_FILTER_TYPE_OPTIONS}
value={selectedMarketingDateFilterType}
onChange={marketingDateFilterTypeChangeHandler}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
}}
/>
<SelectInput
label='Tipe Marketing'
placeholder='Pilih Tipe Marketing'
options={MARKETING_TYPE_OPTIONS}
value={selectedMarketingType}
onChange={marketingTypeChangeHandler}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
}}
/>
<div className='col-span-12 sm:col-span-6 lg:col-span-8 flex flex-wrap sm:justify-end items-end gap-2'>
<Button
color='primary'
// onClick={handleSearch}
className='flex-1 sm:flex-none'
>
<Icon icon='heroicons:magnifying-glass' width={20} height={20} />
Cari
</Button>
<Button
color='warning'
onClick={handleReset}
className='flex-1 sm:flex-none'
>
<Icon icon='heroicons-outline:refresh' width={20} height={20} />
Reset
</Button>
<Dropdown
align='end'
direction='bottom'
trigger={
<Button>
Export{' '}
<Icon
icon='heroicons-outline:download'
width={20}
height={20}
/>
</Button>
}
>
<Menu>
<MenuItem
title='Export to Excel'
icon='icon-park-outline:excel'
isLoading={isLoadingExportingToExcel}
onClick={exportToExcelHandler}
className='text-nowrap'
/>
<MenuItem
title='Export to PDF'
icon='icon-park-outline:file-pdf-one'
onClick={exportToPdfHandler}
className='text-nowrap'
/>
</Menu>
</Dropdown>
</div>
</div>
</div>
<DailyMarketingsTable
dailyMarketingsReportUrl={dailyMarketingsReportUrl}
onSetPage={setPage}
pageSize={tableFilterState.pageSize}
onSetPageSize={setPageSize}
searchValue={tableFilterState.search}
onSearchChange={searchChangeHandler}
onFilterByChange={filterByChangeHandler}
onSortByChange={sortByChangeHandler}
/>
</div>
);
};
export default DailyMarketingReportContent;
@@ -0,0 +1,870 @@
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 { ProjectFlockKandangApi } from '@/services/api/production';
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 '@/components/pages/report/marketing/export/HppPerkandangExportPDF';
import { generateHppPerKandangExcel } from '@/components/pages/report/marketing/export/HppPerkandangExportXLSX';
import toast from 'react-hot-toast';
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);
// ===== VALIDATION STATE =====
const [weightMaxError, setWeightMaxError] = useState<string>('');
// ===== 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 {
setInputValue: setAreaInputValue,
options: areaOptions,
isLoadingOptions: isLoadingAreas,
loadMore: loadMoreAreas,
} = useSelect(AreaApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocations,
loadMore: loadMoreLocations,
} = useSelect(LocationApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setKandangInputValue,
options: kandangOptions,
isLoadingOptions: isLoadingKandangs,
loadMore: loadMoreKandangs,
} = useSelect(
ProjectFlockKandangApi.basePath,
'id',
'name_with_period',
'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);
if (weightMaxError) {
setWeightMaxError('');
}
},
[updateFilter, weightMaxError]
);
const weightMaxChangeHandler = useCallback<
ChangeEventHandler<HTMLInputElement>
>(
(e) => {
const val = e.target.value;
const weightMax = val ? parseFloat(val) || 0 : 0;
const weightMin = tableFilterState.weight_min
? parseFloat(tableFilterState.weight_min)
: 0;
if (weightMax < weightMin) {
setWeightMaxError('Rentang bobot max tidak boleh lebih kecil dari min');
toast.error('Rentang bobot max tidak boleh lebih kecil dari min');
return;
}
setWeightMaxError('');
updateFilter('weight_max', val ? String(weightMax) : '');
setIsSubmitted(false);
},
[updateFilter, tableFilterState.weight_min]
);
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;
}
await generateHppPerKandangExcel({
data: allDataForExport,
allFeedSuppliers,
allDocSuppliers,
});
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally {
setIsExcelExportLoading(false);
}
}, [hppPerKandangExport, allFeedSuppliers, allDocSuppliers]);
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(
{
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 {
toast.error('Gagal membuat PDF. Silakan coba lagi.');
} finally {
setIsPdfExportLoading(false);
}
}, [
hppPerKandangExport,
tableFilterState,
areaOptions,
locationOptions,
kandangOptions,
allFeedSuppliers,
allDocSuppliers,
]);
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 row = props.row.original;
return row.name_with_periode || row.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: 'egg_production_pieces',
header: 'Sisa 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: 'Sisa 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_egg_production_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_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: 'egg_value_rp',
header: 'Nilai Nominal Sisa 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>
),
},
];
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={11}
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.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_hpp_rp_per_kg)}
</td>
<td className='text-right'>
{formatCurrency(item.egg_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}
onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreAreas}
isLoading={isLoadingAreas}
closeMenuOnSelect={false}
hideSelectedOptions={false}
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}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isLoading={isLoadingLocations}
closeMenuOnSelect={false}
hideSelectedOptions={false}
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}
onInputChange={setKandangInputValue}
onMenuScrollToBottom={loadMoreKandangs}
isLoading={isLoadingKandangs}
closeMenuOnSelect={false}
hideSelectedOptions={false}
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}
isError={!!weightMaxError}
errorMessage={weightMaxError}
/>
</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}
disabled={!!weightMaxError}
>
<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;