Merge branch ‘development’ of gitlab.com:mbugroup/lti-web-client into

feat/FE/US-285/marketing-closing-report
This commit is contained in:
rstubryan
2025-12-10 11:44:46 +07:00
parent 99fbcaaea3
commit e90c7d993c
3 changed files with 197 additions and 204 deletions
+109 -53
View File
@@ -14,6 +14,7 @@ import {
SortingState, SortingState,
OnChangeFn, OnChangeFn,
Row, Row,
HeaderContext,
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import { rankItem } from '@tanstack/match-sorter-utils'; import { rankItem } from '@tanstack/match-sorter-utils';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
@@ -31,6 +32,9 @@ interface TableClassNames {
tableBodyClassName?: string; tableBodyClassName?: string;
bodyRowClassName?: string; bodyRowClassName?: string;
bodyColumnClassName?: string; bodyColumnClassName?: string;
tableFooterClassName?: string;
footerRowClassName?: string;
footerColumnClassName?: string;
paginationClassName?: string; paginationClassName?: string;
} }
@@ -53,6 +57,7 @@ export interface TableProps<TData extends object> {
rowSelection?: Record<string, boolean>; rowSelection?: Record<string, boolean>;
setRowSelection?: OnChangeFn<Record<string, boolean>>; setRowSelection?: OnChangeFn<Record<string, boolean>>;
enableRowSelection?: boolean | ((row: Row<TData>) => boolean); enableRowSelection?: boolean | ((row: Row<TData>) => boolean);
renderFooter?: boolean;
withCheckbox?: boolean; withCheckbox?: boolean;
rowOptions?: number[]; rowOptions?: number[];
} }
@@ -67,18 +72,22 @@ const emptyContentDefaultValue = (
</div> </div>
); );
const TABLE_DEFAULT_STYLING = { export const TABLE_DEFAULT_STYLING = {
containerClassName: 'w-full mb-20', containerClassName: 'w-full mb-20',
tableWrapperClassName: tableWrapperClassName:
'overflow-x-auto border border-solid border-base-content/10 rounded-lg', 'overflow-x-auto border border-solid border-base-content/10 rounded-lg',
tableClassName: 'font-inter w-full table-auto text-sm font-medium', tableClassName: 'font-inter w-full table-auto text-sm font-medium',
tableHeaderClassName: '', tableHeaderClassName: '',
headerRowClassName: '', headerRowClassName: '',
headerColumnClassName: 'px-4 py-3 text-base-content/50', headerColumnClassName:
'px-4 py-3 border-base-content/10 text-base-content/50',
tableBodyClassName: '', tableBodyClassName: '',
bodyRowClassName: 'border-t border-t-base-content/10', bodyRowClassName: 'border-t border-base-content/10',
bodyColumnClassName: 'px-4 py-3 text-base-content', bodyColumnClassName: 'px-4 py-3 text-base-content',
paginationClassName: '', paginationClassName: '',
tableFooterClassName: 'font-semibold border-base-content/10',
footerRowClassName: 'bg-base-200 border-t-2 border-base-content/10',
footerColumnClassName: 'p-4 text-base-content whitespace-nowrap',
}; };
const Table = <TData extends object>({ const Table = <TData extends object>({
@@ -100,6 +109,7 @@ const Table = <TData extends object>({
rowSelection, rowSelection,
setRowSelection, setRowSelection,
enableRowSelection, enableRowSelection,
renderFooter = false,
withCheckbox = false, withCheckbox = false,
rowOptions = [10, 20, 50, 100], rowOptions = [10, 20, 50, 100],
}: TableProps<TData>) => { }: TableProps<TData>) => {
@@ -214,58 +224,82 @@ const Table = <TData extends object>({
key={headerGroup.id} key={headerGroup.id}
className={tableClassNames.headerRowClassName} className={tableClassNames.headerRowClassName}
> >
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => {
<th const columnRelativeDepth =
key={header.id} header.depth - header.column.depth;
colSpan={header.colSpan} if (
onClick={header.column.getToggleSortingHandler()} !header.isPlaceholder &&
className={cn( columnRelativeDepth > 1 &&
header.column.getCanSort() header.id === header.column.id
? 'cursor-pointer select-none' ) {
: '', return null;
{ }
'first:w-9 first:pr-0': withCheckbox, let rowSpan = 1;
}, if (header.isPlaceholder) {
tableClassNames.headerColumnClassName const leafs = header.getLeafHeaders();
)} rowSpan = leafs[leafs.length - 1].depth - header.depth;
> }
<div className='flex items-center gap-1'> return (
{flexRender( <th
header.column.columnDef.header, key={header.id}
header.getContext() colSpan={header.colSpan}
rowSpan={rowSpan}
onClick={header.column.getToggleSortingHandler()}
className={cn(
header.column.getCanSort()
? 'cursor-pointer select-none'
: '',
{
'first:w-9 first:pr-0': withCheckbox,
},
{
'border-b': header.colSpan > 1,
},
tableClassNames.headerColumnClassName
)} )}
>
<div
className={cn('flex items-center gap-1 min-h-full', {
'justify-center': header.colSpan > 1,
})}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{header.column.getCanSort() && ( {header.column.getCanSort() && (
<div className='w-4 h-4 relative flex flex-col items-center'> <div className='w-4 h-4 relative flex flex-col items-center'>
<Icon <Icon
icon='heroicons:chevron-up-16-solid' icon='heroicons:chevron-up-16-solid'
width={18} width={18}
height={18} height={18}
className={cn( className={cn(
'absolute -top-1', 'absolute -top-1',
'transition-all ease-in-out duration-200', 'transition-all ease-in-out duration-200',
header.column.getIsSorted() === 'asc' header.column.getIsSorted() === 'asc'
? 'text-black' ? 'text-black'
: 'text-black/30' : 'text-black/30'
)} )}
/> />
<Icon <Icon
icon='heroicons:chevron-down-16-solid' icon='heroicons:chevron-down-16-solid'
width={18} width={18}
height={18} height={18}
className={cn( className={cn(
'absolute -bottom-1.5', 'absolute -bottom-1.5',
'transition-all ease-in-out duration-200', 'transition-all ease-in-out duration-200',
header.column.getIsSorted() === 'desc' header.column.getIsSorted() === 'desc'
? 'text-black' ? 'text-black'
: 'text-black/30' : 'text-black/30'
)} )}
/> />
</div> </div>
)} )}
</div> </div>
</th> </th>
))} );
})}
</tr> </tr>
))} ))}
</thead> </thead>
@@ -290,6 +324,28 @@ const Table = <TData extends object>({
</tr> </tr>
))} ))}
</tbody> </tbody>
<tfoot className={cn(tableClassNames.tableFooterClassName)}>
{renderFooter && (
<tr className={cn(tableClassNames.footerRowClassName)}>
{table.getAllLeafColumns().map((column) => (
<td
key={column.id}
className={cn(
{ 'first:w-9 first:pr-0': withCheckbox },
tableClassNames.footerColumnClassName
)}
>
{column.columnDef.footer &&
flexRender(column.columnDef.footer, {
column,
header: column.columnDef,
table,
} as HeaderContext<TData, unknown>)}
</td>
))}
</tr>
)}
</tfoot>
</table> </table>
</div> </div>
@@ -6,7 +6,7 @@ import Table from '@/components/Table';
import Card from '@/components/Card'; import Card from '@/components/Card';
import Badge from '@/components/Badge'; import Badge from '@/components/Badge';
import { formatCurrency, formatNumber, formatDate } from '@/lib/helper'; import { formatCurrency, formatNumber, formatDate } from '@/lib/helper';
import { BaseClosingSales, BaseSales } from '@/types/api/closing/closing'; import { BaseClosingSales, BaseSales } from '@/types/api/closing';
import { Product } from '@/types/api/master-data/product'; import { Product } from '@/types/api/master-data/product';
import { Customer } from '@/types/api/master-data/customer'; import { Customer } from '@/types/api/master-data/customer';
import { Kandang } from '@/types/api/master-data/kandang'; import { Kandang } from '@/types/api/master-data/kandang';
@@ -16,10 +16,6 @@ interface SalesReportTableProps {
initialValues?: BaseClosingSales; initialValues?: BaseClosingSales;
} }
interface FooterSalesRow extends BaseSales {
_isFooter: true;
}
const SalesReportTable = ({ const SalesReportTable = ({
type = 'detail', type = 'detail',
initialValues, initialValues,
@@ -72,29 +68,6 @@ const SalesReportTable = ({
}; };
}, [salesData]); }, [salesData]);
const footerData = useMemo((): FooterSalesRow[] => {
if (salesData.length === 0) return [];
const footerRow: FooterSalesRow = {
id: -999,
realization_date: 'Total Penjualan',
age: 0,
do_number: '',
product: {} as Product,
customer: {} as Customer,
qty: totals.totalQuantity,
weight: totals.totalWeight,
avg_weight: totals.avgWeight,
price: totals.avgPricePartner,
total_price: totals.totalPartner,
kandang: {} as Kandang,
payment_status: '',
_isFooter: true,
};
return [footerRow];
}, [salesData, totals]);
const salesColumns: ColumnDef<BaseSales>[] = useMemo( const salesColumns: ColumnDef<BaseSales>[] = useMemo(
() => [ () => [
{ {
@@ -102,43 +75,30 @@ const SalesReportTable = ({
accessorKey: 'realization_date', accessorKey: 'realization_date',
header: 'Tanggal Realisasi', header: 'Tanggal Realisasi',
cell: (props) => { cell: (props) => {
const isFooter = '_isFooter' in props.row.original;
if (isFooter) {
return (
<div className='font-semibold text-gray-900 col-span-5'>
{props.row.original.realization_date}
</div>
);
}
const date = props.row.original.realization_date; const date = props.row.original.realization_date;
return date ? formatDate(date, 'DD MMM YYYY') : '-'; return date ? formatDate(date, 'DD MMM YYYY') : '-';
}, },
footer: () => (
<div className='font-semibold text-gray-900'>Total Penjualan</div>
),
}, },
{ {
id: 'age', id: 'age',
accessorKey: 'age', accessorKey: 'age',
header: 'Umur', header: 'Umur',
cell: (props) => { cell: (props) => props.getValue() || '-',
const isFooter = '_isFooter' in props.row.original;
return isFooter ? null : props.getValue() || '-';
},
}, },
{ {
id: 'do_number', id: 'do_number',
accessorKey: 'do_number', accessorKey: 'do_number',
header: 'No. DO', header: 'No. DO',
cell: (props) => { cell: (props) => props.getValue() || '-',
const isFooter = '_isFooter' in props.row.original;
return isFooter ? null : props.getValue() || '-';
},
}, },
{ {
id: 'product', id: 'product',
accessorKey: 'product', accessorKey: 'product',
header: 'Produk', header: 'Produk',
cell: (props) => { cell: (props) => {
const isFooter = '_isFooter' in props.row.original;
if (isFooter) return null;
const product = props.getValue() as Product; const product = props.getValue() as Product;
return product?.name || '-'; return product?.name || '-';
}, },
@@ -148,47 +108,43 @@ const SalesReportTable = ({
accessorKey: 'customer', accessorKey: 'customer',
header: 'Customer', header: 'Customer',
cell: (props) => { cell: (props) => {
const isFooter = '_isFooter' in props.row.original;
if (isFooter) return null;
const customer = props.getValue() as Customer; const customer = props.getValue() as Customer;
return customer?.name || '-'; return customer?.name || '-';
}, },
}, },
{ {
id: 'qty', id: 'jumlah',
accessorKey: 'qty', header: 'Jumlah',
header: 'Kuantitas', columns: [
cell: (props) => { {
const value = props.getValue() as number; id: 'qty',
const isFooter = '_isFooter' in props.row.original; accessorKey: 'qty',
return ( header: 'Kuantitas',
<div cell: (props) => {
className={ const value = props.getValue() as number;
isFooter ? 'text-left font-semibold text-gray-900' : 'text-left' return <div className='text-left'>{formatNumber(value)}</div>;
} },
> footer: () => (
{formatNumber(value)} <div className='text-left font-semibold text-gray-900'>
</div> {formatNumber(totals.totalQuantity)}
); </div>
}, ),
}, },
{ {
id: 'weight', id: 'weight',
accessorKey: 'weight', accessorKey: 'weight',
header: 'Kg', header: 'Kg',
cell: (props) => { cell: (props) => {
const value = props.getValue() as number; const value = props.getValue() as number;
const isFooter = '_isFooter' in props.row.original; return <div className='text-left'>{formatNumber(value)}</div>;
return ( },
<div footer: () => (
className={ <div className='text-left font-semibold text-gray-900'>
isFooter ? 'text-left font-semibold text-gray-900' : 'text-left' {formatNumber(totals.totalWeight)}
} </div>
> ),
{formatNumber(value)} },
</div> ],
);
},
}, },
{ {
id: 'avg_weight', id: 'avg_weight',
@@ -196,17 +152,13 @@ const SalesReportTable = ({
header: 'AVG (Kg)', header: 'AVG (Kg)',
cell: (props) => { cell: (props) => {
const value = props.getValue() as number; const value = props.getValue() as number;
const isFooter = '_isFooter' in props.row.original; return <div className='text-left'>{formatNumber(value)}</div>;
return (
<div
className={
isFooter ? 'text-left font-semibold text-gray-900' : 'text-left'
}
>
{formatNumber(value)}
</div>
);
}, },
footer: () => (
<div className='text-left font-semibold text-gray-900'>
{formatNumber(totals.avgWeight)}
</div>
),
}, },
{ {
id: 'price_partner', id: 'price_partner',
@@ -214,19 +166,13 @@ const SalesReportTable = ({
header: 'Harga Mitra (Rp)', header: 'Harga Mitra (Rp)',
cell: (props) => { cell: (props) => {
const value = props.getValue() as number; const value = props.getValue() as number;
const isFooter = '_isFooter' in props.row.original; return <div className='text-right'>{formatCurrency(value)}</div>;
return (
<div
className={
isFooter
? 'text-right font-semibold text-gray-900'
: 'text-right'
}
>
{formatCurrency(value)}
</div>
);
}, },
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(totals.avgPricePartner)}
</div>
),
}, },
{ {
id: 'total_mitra', id: 'total_mitra',
@@ -234,19 +180,13 @@ const SalesReportTable = ({
header: 'Total Mitra (Rp)', header: 'Total Mitra (Rp)',
cell: (props) => { cell: (props) => {
const value = props.getValue() as number; const value = props.getValue() as number;
const isFooter = '_isFooter' in props.row.original; return <div className='text-right'>{formatCurrency(value)}</div>;
return (
<div
className={
isFooter
? 'text-right font-semibold text-gray-900'
: 'text-right'
}
>
{formatCurrency(value)}
</div>
);
}, },
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(totals.totalPartner)}
</div>
),
}, },
{ {
id: 'price_act', id: 'price_act',
@@ -254,18 +194,7 @@ const SalesReportTable = ({
header: 'Harga Act (Rp)', header: 'Harga Act (Rp)',
cell: (props) => { cell: (props) => {
const value = props.getValue() as number; const value = props.getValue() as number;
const isFooter = '_isFooter' in props.row.original; return <div className='text-right'>{formatCurrency(value)}</div>;
return (
<div
className={
isFooter
? 'text-right font-semibold text-gray-900'
: 'text-right'
}
>
{formatCurrency(value)}
</div>
);
}, },
}, },
{ {
@@ -274,18 +203,7 @@ const SalesReportTable = ({
header: 'Total Act (Rp)', header: 'Total Act (Rp)',
cell: (props) => { cell: (props) => {
const value = props.getValue() as number; const value = props.getValue() as number;
const isFooter = '_isFooter' in props.row.original; return <div className='text-right'>{formatCurrency(value)}</div>;
return (
<div
className={
isFooter
? 'text-right font-semibold text-gray-900'
: 'text-right'
}
>
{formatCurrency(value)}
</div>
);
}, },
}, },
{ {
@@ -293,8 +211,6 @@ const SalesReportTable = ({
accessorKey: 'kandang', accessorKey: 'kandang',
header: 'Kandang', header: 'Kandang',
cell: (props) => { cell: (props) => {
const isFooter = '_isFooter' in props.row.original;
if (isFooter) return null;
const kandang = props.getValue() as Kandang; const kandang = props.getValue() as Kandang;
return kandang?.name || '-'; return kandang?.name || '-';
}, },
@@ -304,9 +220,6 @@ const SalesReportTable = ({
accessorKey: 'payment_status', accessorKey: 'payment_status',
header: 'Status Pembayaran', header: 'Status Pembayaran',
cell: (props) => { cell: (props) => {
const isFooter = '_isFooter' in props.row.original;
if (isFooter) return null;
const status = props.getValue() as string; const status = props.getValue() as string;
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
if (!status) return 'neutral'; if (!status) return 'neutral';
@@ -345,16 +258,14 @@ const SalesReportTable = ({
<Table <Table
data={salesData} data={salesData}
columns={salesColumns} columns={salesColumns}
footerData={footerData}
renderFooter={salesData.length > 0} renderFooter={salesData.length > 0}
className={{ className={{
tableWrapperClassName: 'overflow-x-auto', tableWrapperClassName: 'overflow-x-auto',
tableClassName: 'w-full table-auto text-sm', tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName: headerColumnClassName:
'px-4 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end whitespace-nowrap', 'px-4 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end 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: 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', '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: bodyColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap', 'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
tableFooterClassName: tableFooterClassName:
+28 -2
View File
@@ -1,9 +1,34 @@
import { Area } from '@/types/api/master-data/area'; import { Area } from '@/types/api/master-data/area';
import { Fcr } from '@/types/api/master-data/fcr'; import { Fcr } from '@/types/api/master-data/fcr';
import { Flock } from '@/types/api/master-data/flock'; import { Flock } from '@/types/api/master-data/flock';
import { Kandang } from '@/types/api/master-data/kandang';
import { Location } from '@/types/api/master-data/location'; import { Location } from '@/types/api/master-data/location';
import { BaseApproval, BaseMetadata } from '@/types/api/api-general'; import { Kandang } from '@/types/api/master-data/kandang';
import { Product } from '@type/api/master-data/product';
import { Customer } from '@type/api/master-data/customer';
import { BaseMetadata } from '@/types/api/api-general';
export type BaseSales = {
id: number;
realization_date: string;
age: number;
do_number: string;
product: Product;
customer: Customer;
qty: number;
weight: number;
avg_weight: number;
price: number;
total_price: number;
kandang: Kandang;
payment_status: string;
};
export type BaseClosingSales = {
project_type: string;
flock_id: number;
period: number;
sales: BaseSales[];
};
export type BaseClosing = { export type BaseClosing = {
id: number; id: number;
@@ -53,3 +78,4 @@ export type ClosingIncomingSapronak = {
}; };
export type ClosingOutgoingSapronak = ClosingIncomingSapronak; export type ClosingOutgoingSapronak = ClosingIncomingSapronak;
export type ClosingSales = BaseMetadata & BaseClosingSales;