feat(FE-326): Add SalesReportTable component

This commit is contained in:
rstubryan
2025-12-03 22:31:10 +07:00
parent 50559caf52
commit 3a87b039bf
@@ -0,0 +1,554 @@
'use client';
import Tabs from '@/components/Tabs';
import React, { useState, useMemo } from 'react';
import { ColumnDef } from '@tanstack/react-table';
import Table, { CustomHeaderRow } from '@/components/Table';
import Card from '@/components/Card';
import Badge from '@/components/Badge';
import { formatCurrency, formatNumber, formatDate } from '@/lib/helper';
type BaseClosingSales = {
id: number;
realization_date: string;
week_age: number;
age_label: string;
delivery_order_number: string;
product: string;
product_type: string;
customer: string;
quantity: number;
weight: number;
average: number;
price: number;
total: number;
kandang: string;
kandang_id: number;
payment_status: string;
};
const generateCustomHeaders = (template: {
groups: Array<{
label: string;
field?: string;
rowSpan?: number;
colSpan?: number;
subLabels?: string[];
}>;
}): CustomHeaderRow[] => {
const mainRow: Array<{
id: string;
content: React.ReactNode;
colSpan?: number;
rowSpan?: number;
className: string;
}> = [];
const subRow: Array<{
id: string;
content: React.ReactNode;
colSpan?: number;
rowSpan?: number;
className: string;
}> = [];
let subColumnIndex = 0;
template.groups.forEach((group) => {
if (group.subLabels) {
mainRow.push({
id: `${group.field || 'group'}-${subColumnIndex}`,
content: group.label,
colSpan: group.colSpan,
className:
'px-4 py-3 text-xs font-semibold text-gray-700 text-center whitespace-nowrap border border-gray-300',
});
group.subLabels.forEach((subLabel) => {
subRow.push({
id: `sub-${subColumnIndex}`,
content: subLabel,
className:
'px-4 py-3 text-xs font-semibold text-gray-700 text-left whitespace-nowrap border border-gray-300 border-t-0',
});
subColumnIndex++;
});
} else {
mainRow.push({
id: `${group.field}-header`,
content: group.label,
rowSpan: group.rowSpan,
className:
'px-4 py-3 text-xs font-semibold text-gray-700 text-left whitespace-nowrap border border-gray-300',
});
}
});
const rows: CustomHeaderRow[] = [
{
id: 'main-header',
cells: mainRow,
className: 'bg-gray-50',
},
];
if (subRow.length > 0) {
rows.push({
id: 'sub-header',
cells: subRow,
className: 'bg-gray-50',
});
}
return rows;
};
const SalesReportTable = () => {
const [activeTabId, setActiveTabId] = useState<string>('penjualan');
const salesBroilerData: BaseClosingSales[] = useMemo(
() => [
{
id: 1,
realization_date: '2025-10-02',
week_age: 3,
age_label: '3 Weeks',
delivery_order_number: 'DO.MBU.699',
product: 'MBU BERLIAN CHICK A',
product_type: 'CHICKEN',
customer: 'TIAN YUSTIAN',
quantity: 1045,
weight: 1000,
average: 0.96,
price: 25300,
total: 25300000,
kandang: 'ACE AWANG',
kandang_id: 1,
payment_status: 'Lunas',
},
{
id: 2,
realization_date: '2025-10-07',
week_age: 4,
age_label: '4 Weeks',
delivery_order_number: 'DO.MBU.1037',
product: 'MBU BERLIAN CHICK A',
product_type: 'CHICKEN',
customer: 'ZAENAL MUTAQIN',
quantity: 850,
weight: 1211.4,
average: 1.43,
price: 23700,
total: 28710180,
kandang: 'ACE AWANG',
kandang_id: 1,
payment_status: 'Lunas',
},
{
id: 3,
realization_date: '2025-10-09',
week_age: 4,
age_label: '4 Weeks',
delivery_order_number: 'DO.MBU.1107',
product: 'MBU BERLIAN CHICK A',
product_type: 'CHICKEN',
customer: 'CORNELIUS TONY KUSTANTO',
quantity: 560,
weight: 990,
average: 1.77,
price: 23100,
total: 22869000,
kandang: 'ACE AWANG',
kandang_id: 1,
payment_status: 'Lunas',
},
{
id: 4,
realization_date: '2025-10-09',
week_age: 0,
age_label: '',
delivery_order_number: 'DO.MBU.1108',
product: 'MBU BERLIAN CHICK A',
product_type: 'CHICKEN',
customer: 'CV. KOPO AB',
quantity: 1088,
weight: 1934.3,
average: 1.78,
price: 23100,
total: 44682330,
kandang: 'ACE AWANG',
kandang_id: 1,
payment_status: 'Lunas',
},
{
id: 5,
realization_date: '2025-10-09',
week_age: 0,
age_label: '',
delivery_order_number: 'DO.MBU.1110',
product: 'MBU BERLIAN CHICK A',
product_type: 'CHICKEN',
customer: 'H. MAMAN ROMANSAH',
quantity: 624,
weight: 1121.4,
average: 1.8,
price: 22960,
total: 25747344,
kandang: 'ACE AWANG',
kandang_id: 1,
payment_status: 'Lunas',
},
{
id: 6,
realization_date: '2025-10-09',
week_age: 0,
age_label: '',
delivery_order_number: 'DO.MBU.1133',
product: 'MBU BERLIAN CHICK A',
product_type: 'CHICKEN',
customer: 'PT. SAMUDERA MULIA LESTARI',
quantity: 624,
weight: 1102.3,
average: 1.77,
price: 23100,
total: 25463130,
kandang: 'ACE AWANG',
kandang_id: 1,
payment_status: 'Lunas',
},
],
[]
);
const totals = useMemo(() => {
const totalQuantity = salesBroilerData.reduce(
(sum, item) => sum + (item.quantity || 0),
0
);
const totalWeight = salesBroilerData.reduce(
(sum, item) => sum + (item.weight || 0),
0
);
const avgWeight = totalQuantity > 0 ? totalWeight / totalQuantity : 0;
const validPriceItems = salesBroilerData.filter(
(item) => item.price != null
);
const avgPricePartner =
validPriceItems.length > 0
? validPriceItems.reduce((sum, item) => sum + item.price, 0) /
validPriceItems.length
: 0;
const totalPartner = salesBroilerData.reduce(
(sum, item) => sum + (item.total || 0),
0
);
const avgPriceAct = avgPricePartner;
const totalAct = totalPartner;
return {
totalQuantity,
totalWeight,
avgWeight,
avgPricePartner,
totalPartner,
avgPriceAct,
totalAct,
};
}, [salesBroilerData]);
const salesColumns: ColumnDef<BaseClosingSales>[] = 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') : '-';
},
},
{
id: 'age_label',
accessorKey: 'age_label',
header: 'Umur',
cell: (props) => props.getValue() || '-',
},
{
id: 'delivery_order_number',
accessorKey: 'delivery_order_number',
header: 'No. DO',
cell: (props) => props.getValue() || '-',
},
{
id: 'product',
accessorKey: 'product',
header: 'Produk',
cell: (props) => props.getValue() || '-',
},
{
id: 'customer',
accessorKey: 'customer',
header: 'Customer',
cell: (props) => props.getValue() || '-',
},
{
id: 'quantity',
accessorKey: 'quantity',
header: 'Ekor',
cell: (props) => {
const value = props.getValue() as number;
const isSummary = props.row.id === 'summary';
return (
<div className={isSummary ? 'text-right' : ''}>
{formatNumber(value)}
</div>
);
},
},
{
id: 'weight',
accessorKey: 'weight',
header: 'Kg',
cell: (props) => {
const value = props.getValue() as number;
const isSummary = props.row.id === 'summary';
return (
<div className={isSummary ? 'text-right' : ''}>
{formatNumber(value)}
</div>
);
},
},
{
id: 'average',
accessorKey: 'average',
header: 'AVG (Kg)',
cell: (props) => {
const value = props.getValue() as number;
const isSummary = props.row.id === 'summary';
return (
<div className={isSummary ? 'text-right' : ''}>
{formatNumber(value)}
</div>
);
},
},
{
id: 'price_partner',
accessorKey: 'price',
header: 'Harga Mitra (Rp)',
cell: (props) => {
const value = props.getValue() as number;
const isSummary = props.row.id === 'summary';
return (
<div className={isSummary ? 'text-right' : ''}>
{formatCurrency(value)}
</div>
);
},
},
{
id: 'total_mitra',
accessorKey: 'total',
header: 'Total Mitra (Rp)',
cell: (props) => {
const value = props.getValue() as number;
const isSummary = props.row.id === 'summary';
return (
<div className={isSummary ? 'text-right' : ''}>
{formatCurrency(value)}
</div>
);
},
},
{
id: 'price_act',
accessorKey: 'price',
header: 'Harga Act (Rp)',
cell: (props) => {
const value = props.getValue() as number;
const isSummary = props.row.id === 'summary';
return (
<div className={isSummary ? 'text-right' : ''}>
{formatCurrency(value)}
</div>
);
},
},
{
id: 'total_act',
accessorKey: 'total',
header: 'Total Act (Rp)',
cell: (props) => {
const value = props.getValue() as number;
const isSummary = props.row.id === 'summary';
return (
<div className={isSummary ? 'text-right' : ''}>
{formatCurrency(value)}
</div>
);
},
},
{
id: 'kandang',
accessorKey: 'kandang',
header: 'Kandang',
cell: (props) => props.getValue() || '-',
},
{
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 'lunas':
return 'success';
case 'pending':
return 'warning';
case 'belum lunas':
return 'error';
default:
return 'neutral';
}
};
return (
<Badge variant='soft' size='xs' color={getStatusColor(status)}>
{status || '-'}
</Badge>
);
},
},
],
[]
);
const headerTemplate = {
groups: [
{ label: 'Tanggal Realisasi', field: 'realization_date', rowSpan: 2 },
{ label: 'Umur', field: 'age_label', rowSpan: 2 },
{ label: 'No. DO', field: 'delivery_order_number', rowSpan: 2 },
{ label: 'Produk', field: 'product', rowSpan: 2 },
{ label: 'Customer', field: 'customer', rowSpan: 2 },
{
label: 'Jumlah',
colSpan: 2,
subLabels: ['Ekor', 'Kg'],
},
{ label: 'AVG (Kg)', field: 'average', rowSpan: 2 },
{ label: 'Harga Mitra (Rp)', field: 'price_partner', rowSpan: 2 },
{ label: 'Total Mitra (Rp)', field: 'total_partner', rowSpan: 2 },
{ label: 'Harga Act (Rp)', field: 'price_act', rowSpan: 2 },
{ label: 'Total Act (Rp)', field: 'total_act', rowSpan: 2 },
{ label: 'Kandang', field: 'kandang', rowSpan: 2 },
{ label: 'Status Pembayaran', field: 'payment_status', rowSpan: 2 },
],
};
const salesCustomHeaderRows = useMemo(
() => generateCustomHeaders(headerTemplate),
[]
);
return (
<>
<section className='w-full'>
<Tabs
className='bg-base-100 p-2'
onTabChange={setActiveTabId}
activeTabId={activeTabId}
tabs={[
{
id: 'penjualan',
label: 'Penjualan',
content: (
<div className='p-4'>
<h2 className='text-xl font-semibold mb-4'>
Penjualan Ayam Besar
</h2>
<Card
className={{
wrapper: 'w-full bg-base-100',
}}
>
<Table
data={salesBroilerData}
columns={salesColumns}
renderCustomHeaders={true}
customHeaderRows={salesCustomHeaderRows}
className={{
tableWrapperClassName: 'overflow-x-auto',
tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'hidden',
bodyRowClassName: 'hover:bg-gray-50 transition-colors',
bodyColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap border border-gray-300',
}}
/>
{/* Summary Row */}
<table className='w-full table-auto text-sm mt-0'>
<tbody>
<tr className='bg-gray-100 font-semibold'>
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap border border-gray-300 border-t-0'>
Total Penjualan
</td>
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap border border-gray-300 border-t-0'>
-
</td>
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap border border-gray-300 border-t-0'>
-
</td>
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap border border-gray-300 border-t-0'>
-
</td>
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap border border-gray-300 border-t-0'>
-
</td>
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap text-right border border-gray-300 border-t-0'>
{formatNumber(totals.totalQuantity)}
</td>
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap text-right border border-gray-300 border-t-0'>
{formatNumber(totals.totalWeight)}
</td>
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap text-right border border-gray-300 border-t-0'>
{formatNumber(totals.avgWeight)}
</td>
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap text-right border border-gray-300 border-t-0'>
{formatCurrency(totals.avgPricePartner)}
</td>
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap text-right border border-gray-300 border-t-0'>
{formatCurrency(totals.totalPartner)}
</td>
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap text-right border border-gray-300 border-t-0'>
{formatCurrency(totals.avgPriceAct)}
</td>
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap text-right border border-gray-300 border-t-0'>
{formatCurrency(totals.totalAct)}
</td>
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap border border-gray-300 border-t-0'>
-
</td>
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap border border-gray-300 border-t-0'>
-
</td>
</tr>
</tbody>
</table>
</Card>
</div>
),
},
]}
variant='lifted'
/>
</section>
</>
);
};
export default SalesReportTable;