Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-334/expedition-hpp-report

This commit is contained in:
rstubryan
2025-12-23 10:43:56 +07:00
53 changed files with 6330 additions and 689 deletions
@@ -6,16 +6,18 @@ import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import Tabs from '@/components/Tabs';
import ClosingGeneralInformationTable from '@/components/pages/closing/ClosingGeneralInformationTable';
import ClosingSapronakTabContent from '@/components/pages/closing/ClosingSapronakTabContent';
import ClosingProductionDataTabContent from '@/components/pages/closing/ClosingProductionDataTabContent';
import {
ClosingGeneralInformation,
BaseClosingSales,
ClosingHppExpedition,
} from '@/types/api/closing';
import ClosingSapronakTabContent from './ClosingSapronakTabContent';
import ClosingSapronakCalculationTabContent from '@/components/pages/closing/ClosingSapronakCalculationTabContent';
import ClosingOverheadTabContent from '@/components/pages/closing/ClosingOverheadTabContent';
import SalesReportTable from './sale/SalesReportTable';
import ClosingFinanceTabContent from '@/components/pages/closing/ClosingFinanceTabContent';
import SalesReportTable from '@/components/pages/closing/sale/SalesReportTable';
import HppExpeditionReportTable from './hpp-ekspedisi/HppExpeditionReportTable';
interface ClosingDetailProps {
@@ -63,12 +65,12 @@ const ClosingDetail: React.FC<ClosingDetailProps> = ({
{
id: 'dataProduksi',
label: 'Data Produksi',
content: 'Data Produksi',
content: <ClosingProductionDataTabContent projectFlockId={id} />,
},
{
id: 'keuangan',
label: 'Keuangan',
content: 'Keuangan',
content: <ClosingFinanceTabContent projectFlockId={id} />,
},
];
@@ -0,0 +1,17 @@
import ClosingFinanceTable from '@/components/pages/closing/ClosingFinanceTable';
const ClosingFinanceTabContent = ({
projectFlockId,
}: {
projectFlockId: number;
}) => {
return (
<div className='flex flex-col gap-4'>
{projectFlockId && (
<ClosingFinanceTable projectFlockId={projectFlockId} />
)}
</div>
);
};
export default ClosingFinanceTabContent;
@@ -0,0 +1,495 @@
import Card from '@/components/Card';
import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper';
import { formatCurrency, formatTitleCase } from '@/lib/helper';
import { ClosingApi } from '@/services/api/closing';
import {
DataSummarySubTotal,
HppPurchaseData,
ProfitLossDataAmount,
} from '@/types/api/closing';
import useSWR from 'swr';
type HppTableRow =
| (HppPurchaseData & {
group_name: string;
group_index: number;
isGroupHeader?: boolean;
})
| {
group_name: string;
group_index: number;
isGroupHeader: true;
type?: never;
budgeting?: never;
realization?: never;
};
type ProfitLossTableRow =
| (DataSummarySubTotal & {
type: string;
group_name: string;
group_index: number;
isGroupHeader?: boolean;
})
| {
group_name: string;
group_index: number;
isGroupHeader: true;
type?: never;
rp_per_bird?: never;
rp_per_kg?: never;
amount?: never;
};
const ClosingFinanceTable = ({
projectFlockId,
}: {
projectFlockId: number;
}) => {
const { data: finance, isLoading } = useSWR(
`/closing/finance/${projectFlockId}`,
() => ClosingApi.getFinance(projectFlockId)
);
const hppTableData: HppTableRow[] = isResponseSuccess(finance)
? finance.data.hpp_purchases.hpp.flatMap((hpp, groupIndex) => [
// Group header row
{
group_name: hpp.group_name,
group_index: groupIndex,
isGroupHeader: true as const,
},
// Data rows
...hpp.data.map((item) => ({
group_name: hpp.group_name,
group_index: groupIndex,
type: item.type,
budgeting: item.budgeting,
realization: item.realization,
isGroupHeader: false as const,
})),
])
: [];
const profitLossTableData: ProfitLossTableRow[] = isResponseSuccess(finance)
? [
// Pembelian group
...finance.data.profit_loss.data.pembelian.map((item) => ({
label: 'Pembelian',
group_name: 'Pembelian',
group_index: 1,
type: item.type,
rp_per_bird: item.rp_per_bird,
rp_per_kg: item.rp_per_kg,
amount: item.amount,
isGroupHeader: false as const,
})),
{
label: finance.data.profit_loss.data.summary.gross_profit.label,
group_name: 'Penjualan',
group_index: 0,
isGroupHeader: true as const,
type: finance.data.profit_loss.data.summary.gross_profit.label,
rp_per_bird:
finance.data.profit_loss.data.summary.gross_profit.rp_per_bird,
rp_per_kg:
finance.data.profit_loss.data.summary.gross_profit.rp_per_kg,
amount: finance.data.profit_loss.data.summary.gross_profit.amount,
},
// Penjualan group
...finance.data.profit_loss.data.penjualan.map((item) => ({
label: 'Penjualan',
group_name: 'Penjualan',
group_index: 0,
type: item.type,
rp_per_bird: item.rp_per_bird,
rp_per_kg: item.rp_per_kg,
amount: item.amount,
isGroupHeader: false as const,
})),
{
label: finance.data.profit_loss.data.summary.sub_total.label,
group_name: 'Pembelian',
group_index: 1,
isGroupHeader: true as const,
type: finance.data.profit_loss.data.summary.sub_total.label,
rp_per_bird:
finance.data.profit_loss.data.summary.sub_total.rp_per_bird,
rp_per_kg: finance.data.profit_loss.data.summary.sub_total.rp_per_kg,
amount: finance.data.profit_loss.data.summary.sub_total.amount,
},
]
: [];
return (
<div className='flex flex-col gap-4'>
<>
<Card
variant='bordered'
className={{
wrapper: 'w-full',
}}
>
<div className='grid grid-cols-2 gap-6'>
<div className='flex flex-col gap-1'>
<div>
{isResponseSuccess(finance)
? formatTitleCase(
finance.data.profit_loss.data.summary.gross_profit
.label || '-'
)
: 'Laba Rugi Brutto'}
</div>
<div className='text-lg font-bold'>
{isResponseSuccess(finance)
? formatCurrency(
finance.data.profit_loss.data.summary.gross_profit.amount
)
: '-'}
</div>
</div>
<div className='flex flex-col gap-1'>
<div>
{isResponseSuccess(finance)
? formatTitleCase(
finance.data.profit_loss.data.summary.net_profit.label ||
'-'
)
: 'Laba Rugi Netto'}
</div>
<div className='text-lg font-bold'>
{isResponseSuccess(finance)
? formatCurrency(
finance.data.profit_loss.data.summary.net_profit.amount
)
: '-'}
</div>
</div>
</div>
</Card>
<Card
title={
isResponseSuccess(finance)
? finance.data.hpp_purchases.title
: 'HPP Purchases'
}
variant='bordered'
collapsible
className={{
wrapper: 'w-full',
}}
>
<div className='mt-6 p-0 mb-0'>
<Table<HppTableRow>
data={hppTableData}
columns={[
{
header: 'No.',
enableSorting: false,
accessorFn: (item, index) => {
if (item.isGroupHeader) return '-';
const dataRowsBefore = hppTableData
.slice(0, index)
.filter((row) => !row.isGroupHeader).length;
return dataRowsBefore + 1;
},
footer: (props) => {
return 'HPP';
},
},
{
header: 'Type',
enableSorting: false,
accessorFn: (item) => formatTitleCase(item.type || '-'),
},
{
header: 'Budgeting',
enableSorting: false,
columns: [
{
header: 'Rp/Ekor',
id: 'budgeting_rp_per_bird',
enableSorting: false,
accessorFn: (item) =>
formatCurrency(item.budgeting?.rp_per_bird || 0),
footer: (props) => {
return props.column.id === 'budgeting_rp_per_bird' &&
isResponseSuccess(finance)
? formatCurrency(
finance.data.hpp_purchases.summary_hpp.budgeting
.rp_per_bird || 0
)
: '-';
},
},
{
header: 'Rp/Kg',
id: 'budgeting_rp_per_kg',
enableSorting: false,
accessorFn: (item) =>
formatCurrency(item.budgeting?.rp_per_kg || 0),
footer: (props) => {
return props.column.id === 'budgeting_rp_per_kg' &&
isResponseSuccess(finance)
? formatCurrency(
finance.data.hpp_purchases.summary_hpp.budgeting
.rp_per_kg || 0
)
: '-';
},
},
{
header: 'Jumlah (Rp)',
id: 'budgeting_amount',
enableSorting: false,
accessorFn: (item) =>
formatCurrency(item.budgeting?.amount || 0),
footer: (props) => {
return props.column.id === 'budgeting_amount' &&
isResponseSuccess(finance)
? formatCurrency(
finance.data.hpp_purchases.summary_hpp.budgeting
.amount || 0
)
: '-';
},
},
],
},
{
header: 'Realization',
enableSorting: false,
columns: [
{
header: 'Rp/Ekor',
id: 'realization_rp_per_bird',
enableSorting: false,
accessorFn: (item) =>
formatCurrency(item.realization?.rp_per_bird || 0),
footer: (props) => {
return props.column.id === 'realization_rp_per_bird' &&
isResponseSuccess(finance)
? formatCurrency(
finance.data.hpp_purchases.summary_hpp.realization
.rp_per_bird || 0
)
: '-';
},
},
{
header: 'Rp/Kg',
id: 'realization_rp_per_kg',
enableSorting: false,
accessorFn: (item) =>
formatCurrency(item.realization?.rp_per_kg || 0),
footer: (props) => {
return props.column.id === 'realization_rp_per_kg' &&
isResponseSuccess(finance)
? formatCurrency(
finance.data.hpp_purchases.summary_hpp.realization
.rp_per_kg || 0
)
: '-';
},
},
{
header: 'Jumlah (Rp)',
id: 'realization_amount',
enableSorting: false,
accessorFn: (item) =>
formatCurrency(item.realization?.amount || 0),
footer: (props) => {
return props.column.id === 'realization_amount' &&
isResponseSuccess(finance)
? formatCurrency(
finance.data.hpp_purchases.summary_hpp.realization
.amount || 0
)
: '-';
},
},
],
},
]}
renderCustomRow={(row) => {
const rowData = row.original;
if (rowData.isGroupHeader) {
return (
<tr
key={row.id}
className={TABLE_DEFAULT_STYLING.bodyRowClassName}
>
<td
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
></td>
<td
colSpan={7}
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
>
<div className='font-bold'>
{formatTitleCase(rowData.group_name ?? '-')}
</div>
</td>
</tr>
);
}
return null;
}}
renderFooter={isResponseSuccess(finance)}
/>
</div>
</Card>
<Card
title={
isResponseSuccess(finance)
? finance.data.profit_loss.title
: 'Profit/Loss'
}
variant='bordered'
collapsible
className={{
wrapper: 'w-full',
}}
>
<div className='mt-6 p-0 mb-0'>
<Table<ProfitLossTableRow>
data={profitLossTableData}
columns={[
{
header: 'Jenis',
enableSorting: false,
accessorFn: (item) => item.type,
cell: (item) => (
<div className=''>
{formatTitleCase(item.row.original.type || '-')}
</div>
),
footer: (item) => (
<div className='font-bold uppercase'>
{isResponseSuccess(finance)
? formatTitleCase(
finance.data.profit_loss.data.summary.net_profit
.label || '-'
)
: '-'}
</div>
),
},
{
header: 'Rp/Ekor',
enableSorting: false,
accessorFn: (item) => formatCurrency(item.rp_per_bird || 0),
footer: (item) => (
<div className='font-bold'>
{isResponseSuccess(finance)
? formatCurrency(
finance.data.profit_loss.data.summary.net_profit
.rp_per_bird || 0
)
: formatCurrency(0)}
</div>
),
},
{
header: 'Rp/Kg',
enableSorting: false,
accessorFn: (item) => formatCurrency(item.rp_per_kg || 0),
footer: (item) => (
<div className='font-bold'>
{isResponseSuccess(finance)
? formatCurrency(
finance.data.profit_loss.data.summary.net_profit
.rp_per_kg || 0
)
: formatCurrency(0)}
</div>
),
},
{
header: 'Jumlah (Rp)',
enableSorting: false,
accessorFn: (item) => formatCurrency(item.amount || 0),
footer: (item) => (
<div className='font-bold'>
{isResponseSuccess(finance)
? formatCurrency(
finance.data.profit_loss.data.summary.net_profit
.amount || 0
)
: formatCurrency(0)}
</div>
),
},
]}
renderCustomRow={(row) => {
const rowData = row.original;
if (rowData.isGroupHeader) {
if (rowData.amount) {
return (
<tr
key={row.id}
className={TABLE_DEFAULT_STYLING.footerRowClassName}
>
<td
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
>
<div className='font-bold ps-6 uppercase'>
{formatTitleCase(rowData.label ?? '-')}
</div>
</td>
<td
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
>
<div className='font-bold'>
{formatCurrency(rowData.rp_per_bird ?? 0)}
</div>
</td>
<td
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
>
<div className='font-bold'>
{formatCurrency(rowData.rp_per_kg ?? 0)}
</div>
</td>
<td
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
>
<div className='font-bold'>
{formatCurrency(rowData.amount ?? 0)}
</div>
</td>
</tr>
);
}
return (
<tr
key={row.id}
className={TABLE_DEFAULT_STYLING.bodyRowClassName}
>
<td
colSpan={4}
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
>
<div className='font-bold'>
{formatTitleCase(rowData.group_name ?? '-')}
</div>
</td>
</tr>
);
}
return null;
}}
className={{
paginationClassName: 'hidden',
}}
renderFooter={isResponseSuccess(finance)}
/>
</div>
</Card>
</>
</div>
);
};
export default ClosingFinanceTable;
@@ -0,0 +1,235 @@
'use client';
import useSWR from 'swr';
import { ClosingApi } from '@/services/api/closing';
import { isResponseSuccess } from '@/lib/api-helper';
import { formatNumber } from '@/lib/helper';
interface ClosingProductionDataTabContentProps {
projectFlockId: number;
}
const ClosingProductionDataTabContent = ({
projectFlockId,
}: ClosingProductionDataTabContentProps) => {
const { data: productionData, isLoading } = useSWR(
`${ClosingApi.basePath}/${projectFlockId}/production-data`,
() => ClosingApi.getProductionData(projectFlockId)
);
if (isLoading) {
return (
<div className='w-full flex justify-center py-8'>
<span className='loading loading-spinner loading-lg' />
</div>
);
}
if (!productionData || !isResponseSuccess(productionData)) {
return (
<div className='w-full text-center py-8 text-gray-500'>
Gagal memuat data produksi.
</div>
);
}
const { purchase, sales, performance } = productionData.data;
// Helper for consistent row styling
const DataRow = ({
label,
value,
unit = '',
valueClassName = 'font-bold text-gray-800',
unitClassName = 'text-gray-500 w-12 text-right',
}: {
label: string;
value: string | number;
unit?: string;
valueClassName?: string;
unitClassName?: string;
}) => (
<div className='flex justify-between items-center py-1'>
<span className='text-gray-500 text-sm font-medium w-1/2'>{label}</span>
<div className='flex gap-2 w-1/2 justify-end items-center'>
<span className={valueClassName}>{value}</span>
{unit && <span className={unitClassName}>{unit}</span>}
</div>
</div>
);
return (
<div className='w-full rounded-xl p-8 shadow-sm'>
<h2 className='text-lg font-bold mb-8 text-gray-800'>Data Produksi</h2>
<div className='grid grid-cols-1 lg:grid-cols-2 gap-x-24 gap-y-12 relative'>
{/* Left Column */}
<div className='space-y-10'>
{/* Purchase Section */}
<section>
<h3 className='font-bold text-gray-700 mb-4 text-base'>
Pembelian
</h3>
<div className='space-y-1'>
<DataRow
label='Populasi Awal'
value={formatNumber(purchase.initial_population)}
unit='Ekor'
/>
<DataRow
label='Claim Culling'
value={formatNumber(purchase.claim_culling)}
unit='Ekor'
/>
<DataRow
label='Populasi Akhir'
value={formatNumber(purchase.final_population)}
unit='Ekor'
/>
<DataRow
label='Pakan Masuk'
value={formatNumber(purchase.feed_in)}
unit='Kg'
/>
<DataRow
label='Pakan Terpakai'
value={formatNumber(purchase.feed_used)}
unit='Kg'
/>
<DataRow
label='Pakan Terpakai per Ekor'
value={formatNumber(purchase.feed_used_per_head)}
unit='Kg'
/>
</div>
</section>
{/* Sales Section */}
<section>
<h3 className='font-bold text-gray-700 mb-4 text-base'>
Penjualan
</h3>
<div className='space-y-4'>
{/* Chicken Sales */}
<div className='space-y-1'>
<DataRow
label='Penjualan (Ekor)'
value={formatNumber(sales.chicken.sales_population)}
unit='Ekor'
/>
<DataRow
label='Penjualan (Kg)'
value={formatNumber(sales.chicken.sales_weight)}
unit='Kg'
/>
<DataRow
label='Bobot Rata-Rata'
value={formatNumber(sales.chicken.average_weight)}
unit='Kg/Ekor'
/>
<DataRow
label='Harga Jual Rata-Rata'
value={formatNumber(
sales.chicken.chicken_average_selling_price
)}
unit='Rupiah'
/>
</div>
{/* Egg Sales (if available) */}
{sales.egg && (
<>
<div className='h-px bg-gray-100 my-2' />
<div className='space-y-1'>
<DataRow
label='Telur (Butir)'
value={formatNumber(sales.egg.egg_pieces)}
unit='Butir'
/>
<DataRow
label='Telur (Kg)'
value={formatNumber(sales.egg.egg_mass_kg)}
unit='Kg'
/>
<DataRow
label='Berat Telur Rata-Rata'
value={formatNumber(sales.egg.average_egg_weight_kg)}
unit='Kg'
/>
<DataRow
label='Harga Jual Telur Rata-Rata'
value={formatNumber(sales.egg.egg_average_selling_price)}
unit='Rupiah'
/>
</div>
</>
)}
</div>
</section>
</div>
{/* Divider Line (Absolute centered) */}
<div className='hidden lg:block absolute left-1/2 top-0 bottom-0 w-px bg-gray-200 -translate-x-1/2' />
{/* Right Column */}
<div className='space-y-10 flex flex-col h-full'>
{/* Performance Section */}
<section>
<h3 className='font-bold text-gray-700 mb-4 text-base'>
Performance
</h3>
<div className='space-y-1'>
<DataRow
label='Deplesi'
value={formatNumber(performance.depletion)}
unit='Ekor'
/>
<DataRow
label='Umur'
value={formatNumber(performance.age_day)}
unit='Hari'
/>
<DataRow
label='Mortalitas Std'
value={formatNumber(performance.mortality_std)}
unitClassName='hidden'
/>
<DataRow
label='Mortalitas Act'
value={formatNumber(performance.mortality_act)}
unitClassName='hidden'
/>
<DataRow
label='DEFF Mortalitas'
value={formatNumber(performance.deff_mortality)}
unitClassName='hidden'
/>
<DataRow
label='FCR Std'
value={formatNumber(performance.fcr_std)}
unitClassName='hidden'
/>
<DataRow
label='FCR Act'
value={formatNumber(performance.fcr_act)}
unitClassName='hidden'
/>
<DataRow
label='DEFF FCR'
value={formatNumber(performance.deff_fcr)}
unitClassName='hidden'
/>
<DataRow
label='AWG'
value={formatNumber(performance.awg)}
unit='Gr/Hari'
/>
</div>
</section>
</div>
</div>
</div>
);
};
export default ClosingProductionDataTabContent;
@@ -154,66 +154,74 @@ const ClosingSapronakCalculationTable = ({
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='DOC Broiler'
collapsible
defaultCollapsed={false}
className={{
wrapper: 'w-full',
body: 'p-4 shadow',
}}
>
<Table<RowSapronakCalculation>
data={
isResponseSuccess(sapronakCalculation)
? (sapronakCalculation.data?.doc_broiler.rows ?? [])
: []
}
columns={docBroilerColumns}
className={{
containerClassName: 'my-4',
}}
renderFooter={isResponseSuccess(sapronakCalculation)}
/>
</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='OVK'
variant='bordered'
collapsible
defaultCollapsed={true}
className={{
wrapper: 'w-full',
}}
>
<Table<RowSapronakCalculation>
data={
isResponseSuccess(sapronakCalculation)
? (sapronakCalculation.data?.ovk.rows ?? [])
: []
}
columns={ovkColumns}
className={{
containerClassName: 'my-4',
}}
renderFooter={isResponseSuccess(sapronakCalculation)}
/>
</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>
</>
)}
<Card
title='Pakan'
variant='bordered'
collapsible
defaultCollapsed={true}
className={{
wrapper: 'w-full',
}}
>
<Table<RowSapronakCalculation>
data={
isResponseSuccess(sapronakCalculation)
? (sapronakCalculation.data?.pakan.rows ?? [])
: []
}
columns={pakanColumns}
className={{
containerClassName: 'my-4',
}}
renderFooter={isResponseSuccess(sapronakCalculation)}
/>
</Card>
</div>
);
};