refactor(FE): Move table components to a shared "table" directory

This commit is contained in:
rstubryan
2026-02-18 15:43:52 +07:00
parent d085b18788
commit 2f5d518e15
14 changed files with 9 additions and 9 deletions
@@ -0,0 +1,399 @@
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 { HppItem, ProfitLossItem } from '@/types/api/closing';
import { useSearchParams } from 'next/navigation';
import { useMemo } from 'react';
import useSWR from 'swr';
const ClosingFinanceTable = ({
projectFlockId,
}: {
projectFlockId: number;
}) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const { data: finance, isLoading } = useSWR(
`/closing/finance/${projectFlockId}${kandangId ? `/${kandangId}` : ''}`,
() =>
ClosingApi.getFinance(
projectFlockId,
kandangId ? Number(kandangId) : undefined
)
);
const hppTableData: HppItem[] = useMemo(() => {
if (isResponseSuccess(finance)) {
const customItems = {
label: 'HPP dan Pengeluaran',
code: 'custom_row',
} as HppItem;
const purchases = finance.data.hpp.items.filter(
(item) => item.category === 'purchase'
);
const totalBudgeting = {
label: 'HPP dan Bahan Baku',
code: 'custom_row',
} as HppItem;
const overheads = finance.data.hpp.items.filter(
(item) => item.category === 'overhead'
);
return [customItems, ...purchases, totalBudgeting, ...overheads];
}
return [];
}, [finance]);
const profitLossTableData: ProfitLossItem[] = useMemo(() => {
if (isResponseSuccess(finance)) {
const incomes = finance.data.profit_loss.items.filter(
(item) => item.type === 'income'
);
const purchases = finance.data.profit_loss.items.filter(
(item) => item.type === 'purchase'
);
const overheads = finance.data.profit_loss.items.filter(
(item) => item.type === 'overhead'
);
const grossProfit = {
label: 'LABA RUGI BRUTO',
code: 'custom_row',
type: 'gross_profit',
rp_per_bird:
finance.data.profit_loss.summary.gross_profit.rp_per_bird ?? 0,
rp_per_kg: finance.data.profit_loss.summary.gross_profit.rp_per_kg ?? 0,
amount: finance.data.profit_loss.summary.gross_profit.amount ?? 0,
} as ProfitLossItem;
const subtotal = {
label: 'Subtotal',
code: 'custom_row',
type: 'subtotal',
rp_per_bird:
finance.data.profit_loss.summary.sub_total.rp_per_bird ?? 0,
rp_per_kg: finance.data.profit_loss.summary.sub_total.rp_per_kg ?? 0,
amount: finance.data.profit_loss.summary.sub_total.amount ?? 0,
} as ProfitLossItem;
return [...incomes, ...purchases, grossProfit, ...overheads, subtotal];
}
return [];
}, [finance]);
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>Laba Rugi Brutto</div>
<div className='text-lg font-bold'>
{isResponseSuccess(finance)
? formatCurrency(
finance.data.profit_loss.summary.gross_profit.amount
)
: '-'}
</div>
</div>
<div className='flex flex-col gap-1'>
<div>Laba Rugi Netto</div>
<div className='text-lg font-bold'>
{isResponseSuccess(finance)
? formatCurrency(
finance.data.profit_loss.summary.net_profit.amount
)
: '-'}
</div>
</div>
</div>
</Card>
<Card
title='HPP Purchases'
variant='bordered'
collapsible
className={{
wrapper: 'w-full',
}}
>
<div className='mt-6 p-0 mb-0'>
<Table<HppItem>
data={hppTableData}
isLoading={isLoading}
columns={[
{
header: 'No.',
enableSorting: false,
accessorFn: (item, index) => {
if (item.code === 'custom_row') return '-';
const dataRowsBefore = hppTableData
.slice(0, index)
.filter((row) => row.code !== 'custom_row').length;
return dataRowsBefore + 1;
},
footer: (props) => {
return 'HPP';
},
},
{
header: 'Jenis',
enableSorting: false,
accessorFn: (item) => formatTitleCase(item.label || '-'),
},
{
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.summary?.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.summary?.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.summary?.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.summary?.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.summary?.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.summary?.realization?.amount || 0
)
: '-';
},
},
],
},
]}
renderCustomRow={(row) => {
const rowData = row.original;
if (rowData.code === 'custom_row') {
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.label ?? '-')}
</div>
</td>
</tr>
);
}
return null;
}}
renderFooter={isResponseSuccess(finance)}
/>
</div>
</Card>
<Card
title='Profit/Loss'
variant='bordered'
collapsible
className={{
wrapper: 'w-full',
}}
>
<div className='mt-6 p-0 mb-0'>
<Table<ProfitLossItem>
data={profitLossTableData}
isLoading={isLoading}
columns={[
{
header: 'Jenis',
enableSorting: false,
accessorFn: (item) => item.label,
cell: (item) => (
<div className=''>
{formatTitleCase(item.row.original.label || '-')}
</div>
),
footer: () => (
<div className='font-bold uppercase'>LABA RUGI NETTO</div>
),
},
{
header: 'Rp/Ekor',
enableSorting: false,
accessorFn: (item) => formatCurrency(item.rp_per_bird || 0),
footer: () => (
<div className='font-bold'>
{isResponseSuccess(finance)
? formatCurrency(
finance.data.profit_loss.summary.net_profit
.rp_per_bird || 0
)
: formatCurrency(0)}
</div>
),
},
{
header: 'Rp/Kg',
enableSorting: false,
accessorFn: (item) => formatCurrency(item.rp_per_kg || 0),
footer: () => (
<div className='font-bold'>
{isResponseSuccess(finance)
? formatCurrency(
finance.data.profit_loss.summary.net_profit
.rp_per_kg || 0
)
: formatCurrency(0)}
</div>
),
},
{
header: 'Jumlah (Rp)',
enableSorting: false,
accessorFn: (item) => formatCurrency(item.amount || 0),
footer: () => (
<div className='font-bold'>
{isResponseSuccess(finance)
? formatCurrency(
finance.data.profit_loss.summary.net_profit
.amount || 0
)
: formatCurrency(0)}
</div>
),
},
]}
renderCustomRow={(row) => {
const rowData = row.original;
if (rowData.code === 'custom_row') {
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 null;
}}
className={{
paginationClassName: 'hidden',
}}
renderFooter={isResponseSuccess(finance)}
/>
</div>
</Card>
</>
</div>
);
};
export default ClosingFinanceTable;
@@ -0,0 +1,174 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import { ColumnDef, SortingState } from '@tanstack/react-table';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import Card from '@/components/Card';
import Collapse from '@/components/Collapse';
import { cn, formatNumber } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ClosingApi } from '@/services/api/closing';
import { ClosingIncomingSapronakSummary } from '@/types/api/closing';
interface ClosingIncomingSapronaksSummaryTableProps {
projectFlockId: number;
}
const ClosingIncomingSapronaksSummaryTable = ({
projectFlockId,
}: ClosingIncomingSapronaksSummaryTableProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
nameSort: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
},
});
const {
data: incomingSapronakSummaries,
isLoading: isLoadingIncomingSapronakSummaries,
} = useSWR(
`${ClosingApi.basePath}/${projectFlockId}/sapronak/summary${getTableFilterQueryString()}&type=incoming&kandang_id=${kandangId ? `${kandangId}` : ''}`,
ClosingApi.getAllIncomingSapronakSummaryFetcher,
{
keepPreviousData: true,
}
);
const [open, setOpen] = useState(true);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const incomingSapronaksColumns: ColumnDef<ClosingIncomingSapronakSummary>[] =
[
{
header: '#',
cell: (props) => props.row.index + 1,
},
{
accessorKey: 'category',
header: 'Kategori',
},
{
accessorKey: 'total_qty',
header: 'Total Kuantitas',
cell: (props) =>
`${formatNumber(props.row.original.total_qty)} ${props.row.original.uom.name}`,
},
];
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
// track sorting
useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
if (!isNameSorted) {
updateFilter('nameSort', '');
} else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
}
}, [sorting, updateFilter]);
useEffect(() => {
if (!open) {
setOpen(
isResponseSuccess(incomingSapronakSummaries)
? incomingSapronakSummaries.data.length > 0
: false
);
}
}, [incomingSapronakSummaries, 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'>Ringkasan Sapronak Masuk</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'>
<Table<ClosingIncomingSapronakSummary>
data={
isResponseSuccess(incomingSapronakSummaries)
? incomingSapronakSummaries?.data
: []
}
columns={incomingSapronaksColumns}
pageSize={tableFilterState.pageSize}
onPageSizeChange={setPageSize}
rowOptions={[10, 20, 50, 100]}
page={
isResponseSuccess(incomingSapronakSummaries)
? incomingSapronakSummaries?.meta?.page
: 0
}
totalItems={
isResponseSuccess(incomingSapronakSummaries)
? incomingSapronakSummaries?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoadingIncomingSapronakSummaries}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'w-full mb-20':
isResponseSuccess(incomingSapronakSummaries) &&
incomingSapronakSummaries?.data?.length === 0,
}),
}}
/>
</div>
</Collapse>
</Card>
);
};
export default ClosingIncomingSapronaksSummaryTable;
@@ -0,0 +1,210 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
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, formatDate, formatNumber } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ClosingApi } from '@/services/api/closing';
import { ClosingIncomingSapronak } from '@/types/api/closing';
interface ClosingIncomingSapronaksTableProps {
projectFlockId: number;
}
const ClosingIncomingSapronaksTable = ({
projectFlockId,
}: ClosingIncomingSapronaksTableProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
nameSort: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
},
});
const { data: incomingSapronaks, isLoading: isLoadingIncomingSapronaks } =
useSWR(
`${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=incoming&kandang_id=${kandangId ? `${kandangId}` : ''}`,
ClosingApi.getAllIncomingSapronakFetcher
);
const [open, setOpen] = useState(true);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const incomingSapronaksColumns: ColumnDef<ClosingIncomingSapronak>[] = [
{
header: '#',
cell: (props) => props.row.index + 1,
},
{
accessorKey: 'date',
header: 'Tanggal',
cell: (props) => formatDate(props.row.original.date, 'DD MMM YYYY'),
},
{
accessorKey: 'reference_number',
header: 'No. Referensi',
},
{
accessorKey: 'transaction_type',
header: 'Jenis Transaksi',
},
{
accessorKey: 'product_name',
header: 'Produk',
},
{
accessorKey: 'product_category',
header: 'Kategori Produk',
},
{
accessorKey: 'source_warehouse',
header: 'Gudang Asal',
},
{
accessorKey: 'destination_warehouse',
header: 'Gudang Tujuan',
},
{
accessorKey: 'quantity',
header: 'Kuantitas',
cell: (props) =>
`${formatNumber(props.row.original.quantity)} ${props.row.original.unit}`,
},
{
accessorKey: 'notes',
header: 'Keterangan',
},
];
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
// track sorting
useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
if (!isNameSorted) {
updateFilter('nameSort', '');
} else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
}
}, [sorting, updateFilter]);
useEffect(() => {
if (!open) {
setOpen(
isResponseSuccess(incomingSapronaks)
? incomingSapronaks.data.length > 0
: false
);
}
}, [incomingSapronaks, 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'>Sapronak Masuk</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 Sapronak Masuk'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
</div>
<Table<ClosingIncomingSapronak>
data={
isResponseSuccess(incomingSapronaks)
? incomingSapronaks?.data
: []
}
columns={incomingSapronaksColumns}
pageSize={tableFilterState.pageSize}
onPageSizeChange={setPageSize}
rowOptions={[10, 20, 50, 100]}
page={
isResponseSuccess(incomingSapronaks)
? incomingSapronaks?.meta?.page
: 0
}
totalItems={
isResponseSuccess(incomingSapronaks)
? incomingSapronaks?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoadingIncomingSapronaks}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'w-full mb-20':
isResponseSuccess(incomingSapronaks) &&
incomingSapronaks?.data?.length === 0,
}),
}}
/>
</div>
</Collapse>
</Card>
);
};
export default ClosingIncomingSapronaksTable;
@@ -0,0 +1,174 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import { ColumnDef, SortingState } from '@tanstack/react-table';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import Card from '@/components/Card';
import Collapse from '@/components/Collapse';
import { cn, formatNumber } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ClosingApi } from '@/services/api/closing';
import { ClosingOutgoingSapronakSummary } from '@/types/api/closing';
interface ClosingOutgoingSapronaksSummaryTableProps {
projectFlockId: number;
}
const ClosingOutgoingSapronaksSummaryTable = ({
projectFlockId,
}: ClosingOutgoingSapronaksSummaryTableProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
nameSort: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
},
});
const {
data: outgoingSapronakSummaries,
isLoading: isLoadingOutgoingSapronakSummaries,
} = useSWR(
`${ClosingApi.basePath}/${projectFlockId}/sapronak/summary${getTableFilterQueryString()}&type=outgoing&kandang_id=${kandangId ? `${kandangId}` : ''}`,
ClosingApi.getAllIncomingSapronakSummaryFetcher,
{
keepPreviousData: true,
}
);
const [open, setOpen] = useState(true);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const outgoingSapronaksColumns: ColumnDef<ClosingOutgoingSapronakSummary>[] =
[
{
header: '#',
cell: (props) => props.row.index + 1,
},
{
accessorKey: 'category',
header: 'Kategori',
},
{
accessorKey: 'total_qty',
header: 'Total Kuantitas',
cell: (props) =>
`${formatNumber(props.row.original.total_qty)} ${props.row.original.uom.name}`,
},
];
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
// track sorting
useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
if (!isNameSorted) {
updateFilter('nameSort', '');
} else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
}
}, [sorting, updateFilter]);
useEffect(() => {
if (!open) {
setOpen(
isResponseSuccess(outgoingSapronakSummaries)
? outgoingSapronakSummaries.data.length > 0
: false
);
}
}, [outgoingSapronakSummaries, 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'>Ringkasan Sapronak Keluar</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'>
<Table<ClosingOutgoingSapronakSummary>
data={
isResponseSuccess(outgoingSapronakSummaries)
? outgoingSapronakSummaries?.data
: []
}
columns={outgoingSapronaksColumns}
pageSize={tableFilterState.pageSize}
onPageSizeChange={setPageSize}
rowOptions={[10, 20, 50, 100]}
page={
isResponseSuccess(outgoingSapronakSummaries)
? outgoingSapronakSummaries?.meta?.page
: 0
}
totalItems={
isResponseSuccess(outgoingSapronakSummaries)
? outgoingSapronakSummaries?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoadingOutgoingSapronakSummaries}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'w-full mb-20':
isResponseSuccess(outgoingSapronakSummaries) &&
outgoingSapronakSummaries?.data?.length === 0,
}),
}}
/>
</div>
</Collapse>
</Card>
);
};
export default ClosingOutgoingSapronaksSummaryTable;
@@ -0,0 +1,210 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
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, formatDate, formatNumber } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ClosingApi } from '@/services/api/closing';
import { ClosingOutgoingSapronak } from '@/types/api/closing';
interface ClosingOutgoingSapronaksTableProps {
projectFlockId: number;
}
const ClosingOutgoingSapronaksTable = ({
projectFlockId,
}: ClosingOutgoingSapronaksTableProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
nameSort: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
},
});
const { data: outgoingSapronaks, isLoading: isLoadingOutgoingSapronaks } =
useSWR(
`${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=outgoing&kandang_id=${kandangId ? `${kandangId}` : ''}`,
ClosingApi.getAllOutgoingSapronakFetcher
);
const [open, setOpen] = useState(true);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const outgoingSapronaksColumns: ColumnDef<ClosingOutgoingSapronak>[] = [
{
header: '#',
cell: (props) => props.row.index + 1,
},
{
accessorKey: 'date',
header: 'Tanggal',
cell: (props) => formatDate(props.row.original.date, 'DD MMM YYYY'),
},
{
accessorKey: 'reference_number',
header: 'No. Referensi',
},
{
accessorKey: 'transaction_type',
header: 'Jenis Transaksi',
},
{
accessorKey: 'product_name',
header: 'Produk',
},
{
accessorKey: 'product_category',
header: 'Kategori Produk',
},
{
accessorKey: 'source_warehouse',
header: 'Gudang Asal',
},
{
accessorKey: 'destination_warehouse',
header: 'Gudang Tujuan',
},
{
accessorKey: 'quantity',
header: 'Kuantitas',
cell: (props) =>
`${formatNumber(props.row.original.quantity)} ${props.row.original.unit}`,
},
{
accessorKey: 'notes',
header: 'Keterangan',
},
];
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
// track sorting
useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
if (!isNameSorted) {
updateFilter('nameSort', '');
} else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
}
}, [sorting, updateFilter]);
useEffect(() => {
if (!open) {
setOpen(
isResponseSuccess(outgoingSapronaks)
? outgoingSapronaks.data.length > 0
: false
);
}
}, [outgoingSapronaks, 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'>Sapronak Keluar</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 Sapronak Keluar'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
</div>
<Table<ClosingOutgoingSapronak>
data={
isResponseSuccess(outgoingSapronaks)
? outgoingSapronaks?.data
: []
}
columns={outgoingSapronaksColumns}
pageSize={tableFilterState.pageSize}
onPageSizeChange={setPageSize}
rowOptions={[10, 20, 50, 100]}
page={
isResponseSuccess(outgoingSapronaks)
? outgoingSapronaks?.meta?.page
: 0
}
totalItems={
isResponseSuccess(outgoingSapronaks)
? outgoingSapronaks?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoadingOutgoingSapronaks}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'w-full mb-20':
isResponseSuccess(outgoingSapronaks) &&
outgoingSapronaks?.data?.length === 0,
}),
}}
/>
</div>
</Collapse>
</Card>
);
};
export default ClosingOutgoingSapronaksTable;
@@ -0,0 +1,305 @@
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 {
ClosingGeneralInformation,
Overhead,
OverheadTotal,
} from '@/types/api/closing';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { Icon } from '@iconify/react';
import { ColumnDef } from '@tanstack/react-table';
import { useSearchParams } from 'next/navigation';
import { useMemo } from 'react';
import useSWR from 'swr';
interface ClosingOverheadTableProps {
projectFlockId: number;
generalInformation?: ClosingGeneralInformation;
kandangData?: ProjectFlockKandang;
}
const ClosingOverheadTable = ({
projectFlockId,
generalInformation,
kandangData,
}: ClosingOverheadTableProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const { data: overhead, isLoading: isLoadingOverhead } = useSWR(
`${ClosingApi.basePath}/${projectFlockId}/overhead`,
() => ClosingApi.getOverhead(projectFlockId),
{
keepPreviousData: true,
}
);
const { data: overheadKandang, isLoading: isLoadingOverheadKandang } = useSWR(
kandangId
? `${ClosingApi.basePath}/${projectFlockId}/${kandangId}/overhead`
: undefined,
() =>
ClosingApi.getOverhead(
projectFlockId,
kandangId ? Number(kandangId) : undefined
),
{
keepPreviousData: true,
}
);
const chickinPopulation = useMemo(() => {
if (kandangData) {
return kandangData?.chickins?.reduce(
(acc, chickin) => acc + chickin.usage_qty,
0
);
}
return 0;
}, [kandangData]);
const kandangTotal = useMemo(() => {
if (!isResponseSuccess(overhead)) {
return 0;
}
const total =
((chickinPopulation ?? 0) * overhead.data.total.actual_total_amount) /
(generalInformation?.population ?? 0);
return total;
}, [overhead, chickinPopulation, generalInformation]);
// Helper function to create columns with footer support
const createColumns = (
total?: OverheadTotal,
kandangId?: number
): ColumnDef<Overhead>[] => {
const flockColumn: ColumnDef<Overhead>[] = [
{
header: 'Budget Pengajuan',
footer: '',
columns: [
{
id: 'budget_quantity',
header: 'Jumlah',
accessorFn: (props) => formatNumber(props.budget_quantity),
footer: total ? () => formatNumber(total.budget_quantity) : '',
},
{
id: 'budget_unit_price',
header: 'Harga Satuan',
accessorFn: (props) => formatCurrency(props.budget_unit_price),
footer: '',
},
{
id: 'budget_total_amount',
header: 'Total',
accessorFn: (props) => formatCurrency(props.budget_total_amount),
footer: total
? () => formatCurrency(total.budget_total_amount)
: '0',
},
],
},
{
header: 'Realisasi',
footer: '',
columns: [
{
id: 'actual_date',
header: 'Tanggal',
accessorFn: (props) =>
formatDate(props.actual_date, 'DD MMM, YYYY'),
footer: '',
},
{
id: 'actual_quantity',
header: 'Jumlah',
accessorFn: (props) => formatNumber(props.actual_quantity),
footer: total ? () => formatNumber(total.actual_quantity) : '0',
},
{
id: 'actual_unit_price',
header: 'Harga Satuan',
accessorFn: (props) => formatCurrency(props.actual_unit_price),
footer: '',
},
{
id: 'actual_total_amount',
header: 'Total',
accessorFn: (props) => formatCurrency(props.actual_total_amount),
footer: total
? () => formatCurrency(total.actual_total_amount)
: '0',
},
],
},
];
const kandangColumn: ColumnDef<Overhead>[] = [
{
id: 'actual_date',
header: 'Tanggal',
accessorFn: (props) => formatDate(props.actual_date, 'DD MMM, YYYY'),
footer: '',
},
{
id: 'actual_quantity',
header: 'Jumlah',
accessorFn: (props) => formatNumber(props.actual_quantity),
footer: total ? () => formatNumber(total.actual_quantity) : '',
},
{
id: 'actual_unit_price',
header: 'Harga Satuan',
accessorFn: (props) => formatCurrency(props.actual_unit_price),
footer: '',
},
{
id: 'actual_total_amount',
header: 'Total',
accessorFn: (props) => formatCurrency(props.actual_total_amount),
footer: total ? () => formatCurrency(total.actual_total_amount) : '',
},
];
const finalColumns: ColumnDef<Overhead>[] = [
// Group untuk kolom tanpa footer
{
header: 'No',
accessorFn: (_, index) => index,
cell: (props) => props.row.index + 1,
},
{
header: 'Nama Item',
accessorFn: (props) => props.item_name,
footer: 'Total Pengeluaran Overhead',
},
{
header: 'Satuan',
accessorFn: (props) => props.uom_name,
},
...(kandangId ? kandangColumn : flockColumn),
{
id: 'cost_per_bird',
header: 'Rp/Ekor',
accessorFn: (props) => formatCurrency(props.cost_per_bird),
footer: total ? () => formatCurrency(total.cost_per_bird) : '',
},
];
return finalColumns;
};
const columns = useMemo(
() =>
isResponseSuccess(overhead)
? createColumns(
kandangId
? isResponseSuccess(overheadKandang)
? overheadKandang.data?.total
: undefined
: overhead.data?.total,
kandangId ? Number(kandangId) : undefined
)
: createColumns(),
[overhead, kandangId, overheadKandang]
);
return (
<>
<Card
title='Pengeluaran Overhead'
collapsible
defaultCollapsed={false}
className={{
wrapper: 'w-full',
body: 'p-4 shadow',
}}
>
<Table<Overhead>
data={
kandangId
? isResponseSuccess(overheadKandang)
? (overheadKandang.data?.overheads ?? [])
: []
: isResponseSuccess(overhead)
? (overhead.data?.overheads ?? [])
: []
}
columns={columns}
className={{
containerClassName: 'my-4',
headerColumnClassName: cn(
TABLE_DEFAULT_STYLING.headerColumnClassName,
'whitespace-nowrap'
),
}}
isLoading={isLoadingOverhead}
renderFooter={
isResponseSuccess(overhead)
? overhead.data?.overheads.length > 0
: false
}
/>
{kandangId && (
<Card
className={{
wrapper: 'w-full',
body: 'p-4 shadow-button-soft border border-base-content/10 rounded-lg',
}}
>
<div className='flex flex-row gap-3 w-full justify-center items-stretch'>
<div className='flex flex-row items-center justify-between'>
<h2 className='text-base font-bold'>Pembelian Kandang </h2>
</div>
<div className='flex flex-col items-center justify-center'>
<Icon icon='heroicons:equals' className='inline' />
</div>
<div className='flex flex-col flex-1 gap-1.5'>
<div className='flex flex-row gap-1.5 text-center items-center justify-center font-medium'>
Populasi Akhir KANDANG{' '}
<Icon icon='heroicons:x-mark' className='inline' /> Pemakaian
Di FARM
</div>
<hr className='w-full h-fit m-0 p-0 text-base-content/65' />
<div className='flex flex-row gap-1.5 text-center items-center justify-center font-medium'>
Populasi Akhir Proyek
</div>
</div>
<div className='flex flex-col items-center justify-center'>
<Icon icon='heroicons:equals' className='inline' />
</div>
<div className='flex flex-col flex-1 gap-1.5'>
<div className='flex flex-row gap-1.5 text-center items-center justify-center font-medium'>
{formatNumber(chickinPopulation ?? 0)}
<Icon icon='heroicons:x-mark' className='inline' />
{formatCurrency(
isResponseSuccess(overhead)
? overhead.data?.total.actual_total_amount
: 0
)}
</div>
<hr className='w-full h-fit m-0 p-0 text-base-content/65' />
<div className='flex flex-row gap-1.5 text-center items-center justify-center font-medium'>
{formatNumber(generalInformation?.population ?? 0)}
</div>
</div>
<div className='flex flex-col items-center justify-center'>
<Icon icon='heroicons:equals' className='inline' />
</div>
<div className='flex flex-row items-center justify-between'>
<h2 className='text-base font-bold'>
{formatNumber(kandangTotal || 0)}
</h2>
</div>
</div>
</Card>
)}
</Card>
</>
);
};
export default ClosingOverheadTable;
@@ -0,0 +1,268 @@
'use client';
import Card from '@/components/Card';
import Table from '@/components/Table';
import { formatCurrency, formatDate, 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';
import { ClosingGeneralInformation } from '@/types/api/closing';
import { useSearchParams } from 'next/navigation';
interface ClosingSapronakCalculationTableProps {
projectFlockId: number;
closingGeneralInformation?: ClosingGeneralInformation;
}
const ClosingSapronakCalculationTable = ({
projectFlockId,
closingGeneralInformation,
}: ClosingSapronakCalculationTableProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const { data: sapronakCalculation, isLoading } = useSWR(
`/closing/sapronak-calculation/${projectFlockId}${kandangId ? `/${kandangId}` : ''}`,
() => ClosingApi.getPerhitunganSapronak(projectFlockId, Number(kandangId)),
{
keepPreviousData: true,
}
);
// Helper function to create columns with footer support
const createColumns = (
total?: TotalSapronakCalculation
): ColumnDef<RowSapronakCalculation>[] => [
{
header: 'Tanggal',
accessorKey: 'date',
cell: (props) =>
props.row.original.date
? formatDate(props.row.original.date, 'DD MMM YYYY')
: '-',
footer: 'Total',
},
{
header: 'No. Referensi',
accessorKey: 'reference_number',
cell: (props) => (props.row.original.reference_number as string) || '-',
footer: '',
},
{
header: 'QTY Masuk',
accessorKey: 'qty_in',
cell: (props) =>
props.row.original.qty_in
? formatNumber(props.row.original.qty_in as number)
: '0',
footer: total
? () => (
<div className='font-semibold text-gray-900'>
{total?.qty_in ? formatNumber(total?.qty_in) : '0'}
</div>
)
: '',
},
{
header: 'QTY Keluar',
accessorKey: 'qty_out',
cell: (props) =>
props.row.original.qty_out
? formatNumber(props.row.original.qty_out as number)
: '0',
footer: total
? () => (
<div className='font-semibold text-gray-900'>
{total?.qty_out ? formatNumber(total?.qty_out) : '0'}
</div>
)
: '',
},
{
header: 'QTY Pakai',
accessorKey: 'qty_used',
cell: (props) =>
props.row.original.qty_used
? formatNumber(props.row.original.qty_used as number)
: '0',
footer: total
? () => (
<div className='font-semibold text-gray-900'>
{total?.qty_used ? formatNumber(total?.qty_used) : '0'}
</div>
)
: '',
},
{
header: 'Uraian',
accessorKey: 'description',
cell: (props) => (props.row.original.description as string) || '-',
footer: '',
},
{
header: 'Kategori Produk',
accessorKey: 'product_category',
cell: (props) => (props.row.original.product_category as string) || '-',
footer: '',
},
{
header: 'Harga Beli/Qty (Rp)',
accessorKey: 'unit_price',
cell: (props) =>
props.row.original.unit_price
? formatCurrency(props.row.original.unit_price as number)
: '-',
footer: total
? () => (
<div className='font-semibold text-gray-900'>
{total?.avg_unit_price
? formatCurrency(total?.avg_unit_price)
: '-'}
</div>
)
: '',
},
{
header: 'Total Harga (Rp)',
accessorKey: 'total_amount',
cell: (props) =>
props.row.original.total_amount
? formatCurrency(props.row.original.total_amount as number)
: '-',
footer: total
? () => (
<div className='font-semibold text-gray-900'>
{total?.total_amount ? formatCurrency(total?.total_amount) : '-'}
</div>
)
: '',
},
{
header: 'Keterangan',
accessorKey: 'notes',
cell: (props) => (props.row.original.notes as string) || '-',
footer: '',
},
];
// Memoize columns untuk setiap kategori
const docColumns = useMemo(
() =>
isResponseSuccess(sapronakCalculation)
? createColumns(sapronakCalculation.data?.doc?.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'>
{/* Table DOC jika kategori Project Flock Growing */}
<Card
title={
closingGeneralInformation?.project_type == 'GROWING'
? 'DOC'
: 'Pullet'
}
collapsible
defaultCollapsed={false}
className={{
wrapper: 'w-full',
body: 'p-4 shadow',
}}
>
<Table<RowSapronakCalculation>
data={
isResponseSuccess(sapronakCalculation)
? (sapronakCalculation.data?.doc?.rows ?? [])
: []
}
columns={docColumns}
className={{
containerClassName: 'my-4',
}}
renderFooter={
isResponseSuccess(sapronakCalculation) &&
sapronakCalculation.data?.doc?.rows.length > 0
}
/>
</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) &&
sapronakCalculation.data?.ovk?.rows.length > 0
}
/>
</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) &&
sapronakCalculation.data?.pakan?.rows.length > 0
}
/>
</Card>
</div>
);
};
export default ClosingSapronakCalculationTable;
@@ -0,0 +1,109 @@
'use client';
import React, { useMemo } from 'react';
import { ColumnDef } from '@tanstack/react-table';
import Table from '@/components/Table';
import Card from '@/components/Card';
import { formatCurrency } from '@/lib/helper';
import { BaseHppExpedition, BaseExpeditionCost } from '@/types/api/closing';
interface HppExpeditionReportTableProps {
type?: 'detail';
initialValues?: BaseHppExpedition;
}
const HppExpeditionReportTable = ({
initialValues,
}: HppExpeditionReportTableProps) => {
const costOfRevenueExpeditionData: BaseExpeditionCost[] = useMemo(() => {
return initialValues?.expedition_costs || [];
}, [initialValues]);
const totals = useMemo(() => {
const totalHpp = initialValues?.total_hpp_amount || 0;
return {
totalHpp,
};
}, [initialValues]);
const costOfRevenueExpeditionColumns: ColumnDef<BaseExpeditionCost>[] =
useMemo(
() => [
{
id: 'id',
accessorKey: 'id',
header: 'No',
cell: (props) => {
return <div>{props.row.index + 1}</div>;
},
footer: () => (
<div className='font-semibold text-gray-900'>
Total HPP Ekspedisi
</div>
),
},
{
id: 'expedition_vendor_name',
accessorKey: 'expedition_vendor_name',
header: 'Nama Ekspedisi',
cell: (props) => props.getValue() || '-',
},
{
id: 'hpp_amount',
accessorKey: 'hpp_amount',
header: 'HPP Ekspedisi',
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.totalHpp)}
</div>
),
},
],
[totals]
);
return (
<>
<section className='w-full'>
<div className='p-4'>
<h2 className='text-xl font-semibold mb-4'>HPP Ekspedisi</h2>
<Card
className={{
wrapper: 'w-full bg-base-100',
body: 'p-0',
}}
>
<Table
data={costOfRevenueExpeditionData}
columns={costOfRevenueExpeditionColumns}
renderFooter={costOfRevenueExpeditionData.length > 0}
className={{
tableWrapperClassName: 'overflow-x-auto',
tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-4 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end whitespace-nowrap',
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>
</section>
</>
);
};
export default HppExpeditionReportTable;
@@ -0,0 +1,333 @@
'use client';
import React, { useMemo } from 'react';
import { ColumnDef } from '@tanstack/react-table';
import Table from '@/components/Table';
import Card from '@/components/Card';
import { formatCurrency, formatNumber, formatDate } from '@/lib/helper';
import {
BaseClosingSales,
BaseSales,
ClosingSalesSummary,
} 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 = ({ initialValues }: SalesReportTableProps) => {
const salesData: BaseSales[] = useMemo(() => {
return initialValues?.sales || [];
}, [initialValues]);
const summary: ClosingSalesSummary | undefined = useMemo(() => {
return initialValues?.summary;
}, [initialValues]);
const totals = useMemo(() => {
if (salesData.length === 0) {
return {
totalQuantity: 0,
totalWeight: 0,
avgWeight: 0,
avgSalesPrice: 0,
totalSalesPrice: 0,
avgActualPrice: 0,
totalActualPrice: 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 totalSalesPrice = salesData.reduce(
(sum, item) => sum + (item.total_sales_price || 0),
0
);
const validSalesPriceItems = salesData.filter(
(item) => item.sales_price != null && item.sales_price > 0
);
const avgSalesPrice =
validSalesPriceItems.length > 0
? validSalesPriceItems.reduce(
(sum, item) => sum + item.sales_price,
0
) / validSalesPriceItems.length
: 0;
const totalActualPrice = salesData.reduce(
(sum, item) => sum + (item.total_actual_price || 0),
0
);
const validActualPriceItems = salesData.filter(
(item) => item.actual_price != null && item.actual_price > 0
);
const avgActualPrice =
validActualPriceItems.length > 0
? validActualPriceItems.reduce(
(sum, item) => sum + item.actual_price,
0
) / validActualPriceItems.length
: 0;
return {
totalQuantity,
totalWeight,
avgWeight,
avgSalesPrice,
totalSalesPrice,
avgActualPrice,
totalActualPrice,
};
}, [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) => {
const age = props.row.original.age;
const week = props.row.original.week;
return age && week ? `${age} hari (${week} minggu)` : '-';
},
},
{
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: 'sales_price',
accessorKey: 'sales_price',
header: 'Harga Sales (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'>
{summary
? formatCurrency(summary.avg_sales_price)
: formatCurrency(totals.avgSalesPrice)}
</div>
),
},
{
id: 'total_sales_price',
accessorKey: 'total_sales_price',
header: 'Total Sales (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'>
{summary
? formatCurrency(summary.total_sales_price)
: formatCurrency(totals.totalSalesPrice)}
</div>
),
},
{
id: 'actual_price',
accessorKey: 'actual_price',
header: 'Harga Act (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'>
{summary
? formatCurrency(summary.avg_actual_price)
: formatCurrency(totals.avgActualPrice)}
</div>
),
},
{
id: 'total_actual_price',
accessorKey: 'total_actual_price',
header: 'Total Act (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'>
{summary
? formatCurrency(summary.total_actual_price)
: formatCurrency(totals.totalActualPrice)}
</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;