Merge branch 'development' into feat/FE/US-335/production-data-report

This commit is contained in:
ValdiANS
2025-12-18 18:50:22 +07:00
parent 65b60cc464
commit 096a8d394e
112 changed files with 7158 additions and 3238 deletions
+16 -5
View File
@@ -9,14 +9,25 @@ import ClosingGeneralInformationTable from '@/components/pages/closing/ClosingGe
import ClosingSapronakTabContent from '@/components/pages/closing/ClosingSapronakTabContent';
import ClosingProductionDataTabContent from '@/components/pages/closing/ClosingProductionDataTabContent';
import { ClosingGeneralInformation } from '@/types/api/closing';
import {
ClosingGeneralInformation,
BaseClosingSales,
} from '@/types/api/closing';
import ClosingSapronakCalculationTabContent from '@/components/pages/closing/ClosingSapronakCalculationTabContent';
import ClosingOverheadTabContent from '@/components/pages/closing/ClosingOverheadTabContent';
import SalesReportTable from '@/components/pages/closing/sale/SalesReportTable';
interface ClosingDetailProps {
id: number;
initialValue?: ClosingGeneralInformation;
salesData?: BaseClosingSales;
}
const ClosingDetail: React.FC<ClosingDetailProps> = ({ id, initialValue }) => {
const ClosingDetail: React.FC<ClosingDetailProps> = ({
id,
initialValue,
salesData,
}) => {
const [activeTab, setActiveTab] = useState<string>('sapronak');
const closingDetailTabs = useMemo(() => {
@@ -29,17 +40,17 @@ const ClosingDetail: React.FC<ClosingDetailProps> = ({ id, initialValue }) => {
{
id: 'perhitunganSapronak',
label: 'Perhitungan Sapronak',
content: 'Perhitungan Sapronak',
content: <ClosingSapronakCalculationTabContent projectFlockId={id} />,
},
{
id: 'penjualan',
label: 'Penjualan',
content: 'Penjualan',
content: <SalesReportTable initialValues={salesData} />,
},
{
id: 'overhead',
label: 'Overhead',
content: 'Overhead',
content: <ClosingOverheadTabContent projectFlockId={id} />,
},
{
id: 'hppEkspedisi',
@@ -0,0 +1,19 @@
import ClosingOverheadTable from '@/components/pages/closing/ClosingOverheadTable';
interface ClosingOverheadTabContentProps {
projectFlockId: number;
}
const ClosingOverheadTabContent = ({
projectFlockId,
}: ClosingOverheadTabContentProps) => {
return (
<div className='flex flex-col gap-4'>
{projectFlockId && (
<ClosingOverheadTable projectFlockId={projectFlockId} />
)}
</div>
);
};
export default ClosingOverheadTabContent;
@@ -0,0 +1,162 @@
import Card from '@/components/Card';
import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper';
import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper';
import { ClosingApi } from '@/services/api/closing';
import { Overhead, OverheadTotal } from '@/types/api/closing';
import { ColumnDef } from '@tanstack/react-table';
import { useMemo } from 'react';
import useSWR from 'swr';
interface ClosingOverheadTableProps {
type?: 'detail';
projectFlockId: number;
}
const ClosingOverheadTable = ({
type,
projectFlockId,
}: ClosingOverheadTableProps) => {
const { data: overhead, isLoading: isLoadingOverhead } = useSWR(
`${ClosingApi.basePath}/${projectFlockId}/overhead`,
() => ClosingApi.getOverhead(projectFlockId),
{
keepPreviousData: true,
}
);
// Helper function to create columns with footer support
const createColumns = (total?: OverheadTotal): ColumnDef<Overhead>[] => [
// Group untuk kolom tanpa footer
{
header: 'Nama Item',
accessorFn: (props) => props.item_name,
footer: 'Total Pengeluaran Overhead',
},
{
header: 'Satuan',
accessorFn: (props) => props.uom_name,
},
{
header: 'Budget Pengajuan',
footer: '',
columns: [
{
id: 'budget_quantity',
header: 'Jumlah',
accessorFn: (props) =>
props.budget_quantity ? formatNumber(props.budget_quantity) : '-',
footer: total ? () => formatNumber(total.budget_quantity) : '',
},
{
id: 'budget_unit_price',
header: 'Harga Satuan',
accessorFn: (props) =>
props.budget_unit_price
? formatCurrency(props.budget_unit_price)
: '-',
footer: '',
},
{
id: 'budget_total_amount',
header: 'Total',
accessorFn: (props) =>
props.budget_total_amount
? formatCurrency(props.budget_total_amount)
: '-',
footer: total ? () => formatCurrency(total.budget_total_amount) : '',
},
],
},
{
header: 'Realisasi',
footer: '',
columns: [
{
id: 'actual_date',
header: 'Tanggal',
accessorFn: (props) =>
props.actual_date
? formatDate(props.actual_date, 'DD MMM, YYYY')
: '-',
footer: '',
},
{
id: 'actual_quantity',
header: 'Jumlah',
accessorFn: (props) =>
props.actual_quantity ? formatNumber(props.actual_quantity) : '-',
footer: total ? () => formatNumber(total.actual_quantity) : '',
},
{
id: 'actual_unit_price',
header: 'Harga Satuan',
accessorFn: (props) =>
props.actual_unit_price
? formatCurrency(props.actual_unit_price)
: '-',
footer: '',
},
{
id: 'actual_total_amount',
header: 'Total',
accessorFn: (props) =>
props.actual_total_amount
? formatCurrency(props.actual_total_amount)
: '-',
footer: total ? () => formatCurrency(total.actual_total_amount) : '',
},
],
},
{
id: 'cost_per_bird',
header: 'Rp/Ekor',
accessorFn: (props) =>
props.cost_per_bird ? formatCurrency(props.cost_per_bird) : '-',
footer: total ? () => formatCurrency(total.cost_per_bird) : '',
},
];
const columns = useMemo(
() =>
isResponseSuccess(overhead)
? createColumns(overhead.data?.total)
: createColumns(),
[overhead]
);
return (
<>
<Card
title='Pengeluaran Overhead'
collapsible
defaultCollapsed={false}
className={{
wrapper: 'w-full',
body: 'p-4 shadow',
}}
>
<Table<Overhead>
data={
isResponseSuccess(overhead) ? (overhead.data?.overheads ?? []) : []
}
columns={columns}
className={{
containerClassName: 'my-4',
headerColumnClassName: cn(
TABLE_DEFAULT_STYLING.headerColumnClassName,
'whitespace-nowrap'
),
}}
renderFooter={
isResponseSuccess(overhead)
? overhead.data?.overheads.length > 0
: false
}
/>
</Card>
</>
);
};
export default ClosingOverheadTable;
@@ -0,0 +1,25 @@
'use client';
import ClosingIncomingSapronaksTable from '@/components/pages/closing/ClosingIncomingSapronaksTable';
import ClosingOutgoingSapronaksTable from '@/components/pages/closing/ClosingOutgoingSapronaksTable';
import ClosingSapronakCalculationTable from '@/components/pages/closing/ClosingSapronakCalculationTable';
interface ClosingSapronakCalculationTabContentProps {
projectFlockId?: number;
}
const ClosingSapronakCalculationTabContent = ({
projectFlockId,
}: ClosingSapronakCalculationTabContentProps) => {
return (
<div className='flex flex-col gap-4'>
{projectFlockId && (
<>
<ClosingSapronakCalculationTable projectFlockId={projectFlockId} />
</>
)}
</div>
);
};
export default ClosingSapronakCalculationTabContent;
@@ -0,0 +1,221 @@
'use client';
import Card from '@/components/Card';
import Table from '@/components/Table';
import { cn, formatCurrency, formatNumber } from '@/lib/helper';
import {
RowSapronakCalculation,
TotalSapronakCalculation,
} from '@/types/api/closing';
import { ColumnDef } from '@tanstack/react-table';
import { useMemo } from 'react';
import useSWR from 'swr';
import { ClosingApi } from '@/services/api/closing';
import { isResponseSuccess } from '@/lib/api-helper';
interface ClosingSapronakCalculationTableProps {
type?: 'detail';
projectFlockId: number;
}
const ClosingSapronakCalculationTable = ({
type,
projectFlockId,
}: ClosingSapronakCalculationTableProps) => {
const { data: sapronakCalculation, isLoading } = useSWR(
`/closing/sapronak-calculation/${projectFlockId}`,
() => ClosingApi.getPerhitunganSapronak(projectFlockId),
{
keepPreviousData: true,
}
);
// Helper function to create columns with footer support
const createColumns = (
total?: TotalSapronakCalculation
): ColumnDef<RowSapronakCalculation>[] => [
{
header: 'Tanggal',
accessorKey: 'tanggal',
cell: (props) => (props.getValue() as string) || '-',
footer: 'Total',
},
{
header: 'No. Referensi',
accessorKey: 'no_referensi',
cell: (props) => (props.getValue() as string) || '-',
footer: '',
},
{
header: 'QTY Masuk',
accessorKey: 'qty_masuk',
cell: (props) => formatNumber(props.getValue() as number),
footer: total
? () => (
<div className='font-semibold text-gray-900'>
{formatNumber(total.qty_masuk)}
</div>
)
: '',
},
{
header: 'QTY Keluar',
accessorKey: 'qty_keluar',
cell: (props) => formatNumber(props.getValue() as number),
footer: total
? () => (
<div className='font-semibold text-gray-900'>
{formatNumber(total.qty_keluar)}
</div>
)
: '',
},
{
header: 'QTY Pakai',
accessorKey: 'qty_pakai',
cell: (props) => formatNumber(props.getValue() as number),
footer: total
? () => (
<div className='font-semibold text-gray-900'>
{formatNumber(total.qty_pakai)}
</div>
)
: '',
},
{
header: 'Uraian',
accessorKey: 'uraian',
cell: (props) => (props.getValue() as string) || '-',
footer: '',
},
{
header: 'Kategori Produk',
accessorKey: 'kategori_produk',
cell: (props) => (props.getValue() as string) || '-',
footer: '',
},
{
header: 'Harga Beli/Qty (Rp)',
accessorKey: 'harga_beli_per_qty',
cell: (props) => formatCurrency(props.getValue() as number),
footer: total
? () => (
<div className='font-semibold text-gray-900'>
{formatCurrency(total.harga_beli_per_qty)}
</div>
)
: '',
},
{
header: 'Total Harga (Rp)',
accessorKey: 'total_harga',
cell: (props) => formatCurrency(props.getValue() as number),
footer: total
? () => (
<div className='font-semibold text-gray-900'>
{formatCurrency(total.total_harga)}
</div>
)
: '',
},
{
header: 'Keterangan',
accessorKey: 'keterangan',
cell: (props) => (props.getValue() as string) || '-',
footer: '',
},
];
// Memoize columns untuk setiap kategori
const docBroilerColumns = useMemo(
() =>
isResponseSuccess(sapronakCalculation)
? createColumns(sapronakCalculation.data?.doc_broiler.total)
: createColumns(),
[sapronakCalculation]
);
const ovkColumns = useMemo(
() =>
isResponseSuccess(sapronakCalculation)
? createColumns(sapronakCalculation.data?.ovk.total)
: createColumns(),
[sapronakCalculation]
);
const pakanColumns = useMemo(
() =>
isResponseSuccess(sapronakCalculation)
? createColumns(sapronakCalculation.data?.pakan.total)
: createColumns(),
[sapronakCalculation]
);
return (
<div className='flex flex-col gap-4'>
{isResponseSuccess(sapronakCalculation) && (
<>
<Card
title='DOC Broiler'
collapsible
defaultCollapsed={false}
className={{
wrapper: 'w-full',
body: 'p-4 shadow',
}}
>
<Table<RowSapronakCalculation>
data={sapronakCalculation.data?.doc_broiler.rows ?? []}
columns={docBroilerColumns}
className={{
containerClassName: 'my-4',
}}
renderFooter
/>
</Card>
<Card
title='OVK'
variant='bordered'
collapsible
defaultCollapsed={true}
className={{
wrapper: 'w-full',
}}
>
<Table<RowSapronakCalculation>
data={sapronakCalculation.data?.ovk.rows ?? []}
columns={ovkColumns}
className={{
containerClassName: 'my-4',
}}
renderFooter
/>
</Card>
<Card
title='Pakan'
variant='bordered'
collapsible
defaultCollapsed={true}
className={{
wrapper: 'w-full',
}}
>
<Table<RowSapronakCalculation>
data={sapronakCalculation.data?.pakan.rows ?? []}
columns={pakanColumns}
className={{
containerClassName: 'my-4',
}}
renderFooter
/>
</Card>
</>
)}
</div>
);
};
export default ClosingSapronakCalculationTable;
@@ -0,0 +1,285 @@
'use client';
import React, { useMemo } from 'react';
import { ColumnDef } from '@tanstack/react-table';
import Table from '@/components/Table';
import Card from '@/components/Card';
import Badge from '@/components/Badge';
import { formatCurrency, formatNumber, formatDate } from '@/lib/helper';
import { BaseClosingSales, BaseSales } from '@/types/api/closing';
import { Product } from '@/types/api/master-data/product';
import { Customer } from '@/types/api/master-data/customer';
import { Kandang } from '@/types/api/master-data/kandang';
interface SalesReportTableProps {
type?: 'detail';
initialValues?: BaseClosingSales;
}
const SalesReportTable = ({
type = 'detail',
initialValues,
}: SalesReportTableProps) => {
const salesData: BaseSales[] = useMemo(() => {
return initialValues?.sales || [];
}, [initialValues]);
const totals = useMemo(() => {
if (salesData.length === 0) {
return {
totalQuantity: 0,
totalWeight: 0,
avgWeight: 0,
avgPricePartner: 0,
totalPartner: 0,
};
}
const totalQuantity = salesData.reduce(
(sum, item) => sum + (item.qty || 0),
0
);
const totalWeight = salesData.reduce(
(sum, item) => sum + (item.weight || 0),
0
);
const avgWeight = totalQuantity > 0 ? totalWeight / totalQuantity : 0;
const validPriceItems = salesData.filter(
(item) => item.price != null && item.price > 0
);
const avgPricePartner =
validPriceItems.length > 0
? validPriceItems.reduce((sum, item) => sum + item.price, 0) /
validPriceItems.length
: 0;
const totalPartner = salesData.reduce(
(sum, item) => sum + (item.total_price || 0),
0
);
return {
totalQuantity,
totalWeight,
avgWeight,
avgPricePartner,
totalPartner,
};
}, [salesData]);
const salesColumns: ColumnDef<BaseSales>[] = useMemo(
() => [
{
id: 'realization_date',
accessorKey: 'realization_date',
header: 'Tanggal Realisasi',
cell: (props) => {
const date = props.row.original.realization_date;
return date ? formatDate(date, 'DD MMM YYYY') : '-';
},
footer: () => (
<div className='font-semibold text-gray-900'>Total Penjualan</div>
),
},
{
id: 'age',
accessorKey: 'age',
header: 'Umur',
cell: (props) => props.getValue() || '-',
},
{
id: 'do_number',
accessorKey: 'do_number',
header: 'No. DO',
cell: (props) => props.getValue() || '-',
},
{
id: 'product',
accessorKey: 'product',
header: 'Produk',
cell: (props) => {
const product = props.getValue() as Product;
return product?.name || '-';
},
},
{
id: 'customer',
accessorKey: 'customer',
header: 'Customer',
cell: (props) => {
const customer = props.getValue() as Customer;
return customer?.name || '-';
},
},
{
id: 'jumlah',
header: 'Jumlah',
columns: [
{
id: 'qty',
accessorKey: 'qty',
header: 'Kuantitas',
cell: (props) => {
const value = props.getValue() as number;
return <div className='text-left'>{formatNumber(value)}</div>;
},
footer: () => (
<div className='text-left font-semibold text-gray-900'>
{formatNumber(totals.totalQuantity)}
</div>
),
},
{
id: 'weight',
accessorKey: 'weight',
header: 'Kg',
cell: (props) => {
const value = props.getValue() as number;
return <div className='text-left'>{formatNumber(value)}</div>;
},
footer: () => (
<div className='text-left font-semibold text-gray-900'>
{formatNumber(totals.totalWeight)}
</div>
),
},
],
},
{
id: 'avg_weight',
accessorKey: 'avg_weight',
header: 'AVG (Kg)',
cell: (props) => {
const value = props.getValue() as number;
return <div className='text-left'>{formatNumber(value)}</div>;
},
footer: () => (
<div className='text-left font-semibold text-gray-900'>
{formatNumber(totals.avgWeight)}
</div>
),
},
{
id: 'price_partner',
accessorKey: 'price',
header: 'Harga Mitra (Rp)',
cell: (props) => {
const value = props.getValue() as number;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(totals.avgPricePartner)}
</div>
),
},
{
id: 'total_mitra',
accessorKey: 'total_price',
header: 'Total Mitra (Rp)',
cell: (props) => {
const value = props.getValue() as number;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(totals.totalPartner)}
</div>
),
},
{
id: 'price_act',
accessorKey: 'price',
header: 'Harga Act (Rp)',
cell: (props) => {
const value = props.getValue() as number;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
},
{
id: 'total_act',
accessorKey: 'total_price',
header: 'Total Act (Rp)',
cell: (props) => {
const value = props.getValue() as number;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
},
{
id: 'kandang',
accessorKey: 'kandang',
header: 'Kandang',
cell: (props) => {
const kandang = props.getValue() as Kandang;
return kandang?.name || '-';
},
},
{
id: 'payment_status',
accessorKey: 'payment_status',
header: 'Status Pembayaran',
cell: (props) => {
const status = props.getValue() as string;
const getStatusColor = (status: string) => {
if (!status) return 'neutral';
switch (status.toLowerCase()) {
case 'paid':
return 'success';
case 'tempo':
return 'warning';
default:
return 'neutral';
}
};
return (
<Badge variant='soft' size='sm' color={getStatusColor(status)}>
{status || '-'}
</Badge>
);
},
},
],
[]
);
return (
<>
<section className='w-full'>
<div className='p-4'>
<h2 className='text-xl font-semibold mb-4'>Penjualan</h2>
<Card
className={{
wrapper: 'w-full bg-base-100',
body: 'p-0',
}}
>
<Table
data={salesData}
columns={salesColumns}
renderFooter={salesData.length > 0}
className={{
tableWrapperClassName: 'overflow-x-auto',
tableClassName: 'w-full table-auto text-sm',
headerColumnClassName:
'px-4 py-3 text-xs font-semibold text-gray-500 whitespace-nowrap border-l border-l-gray-200 border-r border-r-gray-200 border-t border-t-gray-200 border-gray-200 border-b-0',
bodyRowClassName:
'hover:bg-gray-50 transition-colors border-b border-gray-200 first:border-t first:border-t-gray-200 border-l border-l-gray-200 border-r border-r-gray-200',
bodyColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
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>
</section>
</>
);
};
export default SalesReportTable;