mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-21 05:45:46 +00:00
feat(FE-326): Add SalesReportTable component
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user