mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
562 lines
18 KiB
TypeScript
562 lines
18 KiB
TypeScript
'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';
|
|
import { BaseClosingSales, BaseSales } from '@/types/api/closing/closing';
|
|
|
|
interface SalesReportTableProps {
|
|
type?: 'detail';
|
|
initialValues?: BaseClosingSales;
|
|
}
|
|
|
|
interface HeaderCell {
|
|
id: string;
|
|
content: React.ReactNode;
|
|
colSpan?: number;
|
|
rowSpan?: number;
|
|
className: string;
|
|
field?: 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) {
|
|
const mainCell: HeaderCell = {
|
|
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-200',
|
|
};
|
|
|
|
mainRow.push(mainCell);
|
|
|
|
group.subLabels.forEach((subLabel) => {
|
|
const subCell: HeaderCell = {
|
|
id: `sub-${subColumnIndex}`,
|
|
content: subLabel,
|
|
className:
|
|
'px-4 py-3 text-xs font-semibold text-gray-700 text-left whitespace-nowrap border border-gray-200',
|
|
};
|
|
|
|
if (group.label === 'Jumlah') {
|
|
subCell.field = subLabel === 'Kuantitas' ? 'quantity' : 'weight';
|
|
}
|
|
|
|
subRow.push(subCell);
|
|
subColumnIndex++;
|
|
});
|
|
} else {
|
|
const mainCell: HeaderCell = {
|
|
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-200',
|
|
};
|
|
|
|
mainCell.field = group.field;
|
|
|
|
mainRow.push(mainCell);
|
|
}
|
|
});
|
|
|
|
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;
|
|
};
|
|
|
|
// TODO: TEMPORARY - Remove this when backend API returns English field names
|
|
const mapIndonesianDataToEnglish = (data: BaseClosingSales): BaseSales[] => {
|
|
if (!data || !data.penjualan || !Array.isArray(data.penjualan)) {
|
|
return [];
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
return data.penjualan.map((item: any) => ({
|
|
id: item.id,
|
|
realization_date: item.tanggal_realisasi,
|
|
week_age: item.umur_minggu,
|
|
age_label: item.umur_label,
|
|
delivery_order_number: item.no_do,
|
|
product: item.produk,
|
|
product_category: item.jenis_produk,
|
|
customer: item.customer,
|
|
quantity: item.qty,
|
|
weight: item.kg,
|
|
average: item.avg,
|
|
price: item.harga,
|
|
total: item.total,
|
|
kandang: item.kandang,
|
|
kandang_id: item.kandang_id,
|
|
payment_status: item.status_pembayaran,
|
|
}));
|
|
};
|
|
// END TODO
|
|
|
|
const SalesReportTable = ({
|
|
type = 'detail',
|
|
initialValues,
|
|
}: SalesReportTableProps) => {
|
|
const [activeTabId, setActiveTabId] = useState<string>('penjualan');
|
|
|
|
const salesBroilerData: BaseSales[] = useMemo(() => {
|
|
if (activeTabId === 'penjualan' && initialValues) {
|
|
// TODO: TEMPORARY - Remove this when backend API returns English field names
|
|
if (initialValues.penjualan && Array.isArray(initialValues.penjualan)) {
|
|
return mapIndonesianDataToEnglish(initialValues);
|
|
}
|
|
// END TODO
|
|
return [];
|
|
}
|
|
return [];
|
|
}, [initialValues, activeTabId]);
|
|
|
|
const totals = useMemo(() => {
|
|
if (salesBroilerData.length === 0) {
|
|
return {
|
|
totalQuantity: 0,
|
|
totalWeight: 0,
|
|
avgWeight: 0,
|
|
avgPricePartner: 0,
|
|
totalPartner: 0,
|
|
avgPriceAct: 0,
|
|
totalAct: 0,
|
|
};
|
|
}
|
|
|
|
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 && item.price > 0
|
|
);
|
|
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<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') : '-';
|
|
},
|
|
},
|
|
{
|
|
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: 'Kuantitas',
|
|
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: ['Kuantitas', 'Kg'],
|
|
},
|
|
{ label: 'AVG (Kg)', field: 'average', rowSpan: 2 },
|
|
{ label: 'Harga Mitra (Rp)', field: 'price_partner', rowSpan: 2 },
|
|
{ label: 'Total Mitra (Rp)', field: 'total_mitra', 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</h2>
|
|
<Card
|
|
className={{
|
|
wrapper: 'w-full bg-base-100',
|
|
}}
|
|
>
|
|
<Table
|
|
data={salesBroilerData}
|
|
columns={salesColumns}
|
|
renderCustomHeaders={true}
|
|
customHeaderRows={salesCustomHeaderRows}
|
|
renderFooter={salesBroilerData.length > 0}
|
|
footerContent={
|
|
<tfoot>
|
|
<tr className='bg-gray-100 font-semibold border border-gray-200'>
|
|
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap '>
|
|
Total Penjualan
|
|
</td>
|
|
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap '></td>
|
|
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap '></td>
|
|
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap '></td>
|
|
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap '></td>
|
|
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap text-right '>
|
|
{formatNumber(totals.totalQuantity)}
|
|
</td>
|
|
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap text-right '>
|
|
{formatNumber(totals.totalWeight)}
|
|
</td>
|
|
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap text-right '>
|
|
{formatNumber(totals.avgWeight)}
|
|
</td>
|
|
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap text-right '>
|
|
{formatCurrency(totals.avgPricePartner)}
|
|
</td>
|
|
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap text-right '>
|
|
{formatCurrency(totals.totalPartner)}
|
|
</td>
|
|
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap text-right '>
|
|
{formatCurrency(totals.avgPriceAct)}
|
|
</td>
|
|
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap text-right '>
|
|
{formatCurrency(totals.totalAct)}
|
|
</td>
|
|
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap '></td>
|
|
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap '></td>
|
|
</tr>
|
|
</tfoot>
|
|
}
|
|
className={{
|
|
tableWrapperClassName: 'overflow-x-auto',
|
|
tableClassName: 'w-full table-auto text-sm',
|
|
headerRowClassName: 'hidden',
|
|
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',
|
|
}}
|
|
/>
|
|
</Card>
|
|
</div>
|
|
),
|
|
},
|
|
]}
|
|
variant='lifted'
|
|
/>
|
|
</section>
|
|
<section className='w-ful'>
|
|
<Card
|
|
collapsible={true}
|
|
defaultCollapsed={true}
|
|
className={{ wrapper: 'w-full bg-base-100' }}
|
|
title='(Latest) API Response Sample BE for Sales Report Data'
|
|
>
|
|
<pre>
|
|
{JSON.stringify(
|
|
{
|
|
code: 200,
|
|
status: 'success',
|
|
message: 'Retrieved sales report successfully',
|
|
data: {
|
|
project_type: 'GROWING',
|
|
flock_id: '1',
|
|
period: 10,
|
|
sales: [
|
|
{
|
|
id: 1,
|
|
realization_date: '2025-12-05T02:22:17.443165Z',
|
|
age: 20,
|
|
do_number: 'SO-DO-10001',
|
|
product: {
|
|
id: 1,
|
|
name: 'Laptop Gaming X500',
|
|
product_price: 15000000,
|
|
selling_price: 16500000.5,
|
|
uom: {
|
|
id: 1,
|
|
name: 'KG',
|
|
},
|
|
flags: ['Best Seller', 'New Arrival'],
|
|
product_category: {
|
|
id: 5,
|
|
name: 'Elektronik',
|
|
code: 'DOC',
|
|
},
|
|
},
|
|
customer: {
|
|
id: 12345,
|
|
name: 'PT. Solusi Teknologi Nusantara',
|
|
type: 'Perusahaan',
|
|
account_number: 'ACC1234567890',
|
|
balance: 5000000.75,
|
|
pic: {
|
|
id: 101,
|
|
name: 'Budi Santoso',
|
|
email: 'budi.santoso@example.com',
|
|
role: 'Manajer Akun',
|
|
},
|
|
},
|
|
qty: 6348,
|
|
weight: 19142,
|
|
avg_weight: 3.02,
|
|
price: 26419,
|
|
total_price: 505712498,
|
|
kandang: {
|
|
id: 1,
|
|
name: 'cibeber 1',
|
|
},
|
|
payment_status: 'Paid',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
null,
|
|
2
|
|
)}
|
|
</pre>
|
|
</Card>
|
|
</section>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default SalesReportTable;
|