Sales Per Product Report

This commit is contained in:
efrilm 2025-09-25 23:05:26 +07:00
parent 07c0bdb3af
commit 073f3dd89c
3 changed files with 251 additions and 26 deletions

View File

@ -0,0 +1,18 @@
import ReportTitle from '@/components/report/ReportTitle'
import ReportSalesPerProductContent from '@/views/apps/report/sales/sales-per-product/ReportSalesPerProductContent'
import Grid from '@mui/material/Grid2'
const SalesProductReportPage = () => {
return (
<Grid container spacing={6}>
<Grid size={{ xs: 12 }}>
<ReportTitle title='Laporan Penjualan per Produk' />
</Grid>
<Grid size={{ xs: 12 }}>
<ReportSalesPerProductContent />
</Grid>
</Grid>
)
}
export default SalesProductReportPage

View File

@ -16,36 +16,36 @@ const ReportSalesList: React.FC = () => {
iconClass: 'tabler-receipt-2',
link: getLocalizedUrl(`/apps/report/sales/sales-report`, locale as Locale)
},
{
title: 'Detail Penjualan',
iconClass: 'tabler-receipt-2',
link: ''
},
{
title: 'Tagihan Pelanggan',
iconClass: 'tabler-receipt-2',
link: ''
},
// {
// title: 'Detail Penjualan',
// iconClass: 'tabler-receipt-2',
// link: ''
// },
// {
// title: 'Tagihan Pelanggan',
// iconClass: 'tabler-receipt-2',
// link: ''
// },
{
title: 'Penjualan per Produk',
iconClass: 'tabler-receipt-2',
link: ''
},
{
title: 'Penjualan per Kategori Produk',
iconClass: 'tabler-receipt-2',
link: ''
},
{
title: 'Penjualan Produk per Pelanggan',
iconClass: 'tabler-receipt-2',
link: ''
},
{
title: 'Pemesanan per Produk',
iconClass: 'tabler-receipt-2',
link: ''
link: getLocalizedUrl(`/apps/report/sales/sales-product`, locale as Locale)
}
// {
// title: 'Penjualan per Kategori Produk',
// iconClass: 'tabler-receipt-2',
// link: ''
// },
// {
// title: 'Penjualan Produk per Pelanggan',
// iconClass: 'tabler-receipt-2',
// link: ''
// },
// {
// title: 'Pemesanan per Produk',
// iconClass: 'tabler-receipt-2',
// link: ''
// }
]
return (

View File

@ -0,0 +1,207 @@
'use client'
import DateRangePicker from '@/components/RangeDatePicker'
import { ReportItemHeader, ReportItemSubheader } from '@/components/report/ReportItem'
import { useProductSalesAnalytics } from '@/services/queries/analytics'
import { formatCurrency, formatDateDDMMYYYY } from '@/utils/transform'
import { Button, Card, CardContent } from '@mui/material'
import { useState } from 'react'
const ReportSalesPerProductContent = () => {
const [startDate, setStartDate] = useState<Date | null>(new Date())
const [endDate, setEndDate] = useState<Date | null>(new Date())
const { data: products } = useProductSalesAnalytics({
date_from: formatDateDDMMYYYY(startDate!),
date_to: formatDateDDMMYYYY(endDate!)
})
const productSummary = {
totalQuantitySold: products?.data?.reduce((sum, item) => sum + (item?.quantity_sold || 0), 0) || 0,
totalRevenue: products?.data?.reduce((sum, item) => sum + (item?.revenue || 0), 0) || 0,
totalOrders: products?.data?.reduce((sum, item) => sum + (item?.order_count || 0), 0) || 0
}
return (
<Card>
<div className='p-6 border-be'>
<div className='flex items-center justify-end gap-2'>
<Button
color='secondary'
variant='tonal'
startIcon={<i className='tabler-upload' />}
className='max-sm:is-full'
// onClick={handleExportPDF}
>
Ekspor
</Button>
<DateRangePicker
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
</div>
</div>
<CardContent>
<ReportItemHeader
title='Ringkasan Item'
date={`${products?.date_from.split('T')[0]} - ${products?.date_to.split('T')[0]}`}
/>
<div className='bg-gray-50 border border-gray-200 overflow-visible'>
<div className='overflow-x-auto'>
<table className='w-full table-fixed' style={{ minWidth: '100%' }}>
<colgroup>
<col style={{ width: '40%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '15%' }} />
</colgroup>
<thead>
<tr className='text-gray-800 border-b-2 border-gray-300'>
<th className='text-left p-3 font-semibold border-r border-gray-300'>Produk</th>
<th className='text-center p-3 font-semibold border-r border-gray-300'>Qty</th>
<th className='text-center p-3 font-semibold border-r border-gray-300'>Order</th>
<th className='text-right p-3 font-semibold border-r border-gray-300'>Pendapatan</th>
<th className='text-right p-3 font-semibold'>Rata Rata</th>
</tr>
</thead>
<tbody>
{(() => {
// Group products by category
const groupedProducts =
products?.data?.reduce(
(acc, item) => {
const categoryName = item.category_name || 'Tidak Berkategori'
if (!acc[categoryName]) {
acc[categoryName] = []
}
acc[categoryName].push(item)
return acc
},
{} as Record<string, any[]>
) || {}
const rows: JSX.Element[] = []
let globalIndex = 0
// Sort categories alphabetically
Object.keys(groupedProducts)
.sort()
.forEach(categoryName => {
const categoryProducts = groupedProducts[categoryName]
// Category header row
rows.push(
<tr
key={`category-${categoryName}`}
className='bg-gray-100 border-b border-gray-300'
style={{ pageBreakInside: 'avoid' }}
>
<td
className='p-3 font-bold text-gray-900 border-r border-gray-300'
style={{ color: '#36175e' }}
>
{categoryName.toUpperCase()}
</td>
<td className='p-3 border-r border-gray-300'></td>
<td className='p-3 border-r border-gray-300'></td>
<td className='p-3 border-r border-gray-300'></td>
<td className='p-3'></td>
</tr>
)
// Product rows for this category
categoryProducts.forEach((item, index) => {
globalIndex++
rows.push(
<tr
key={`product-${item.product_name}-${index}`}
className={`${globalIndex % 2 === 0 ? 'bg-white' : 'bg-gray-50'} border-b border-gray-200`}
style={{ pageBreakInside: 'avoid' }}
>
<td
className='p-3 pl-6 font-medium text-gray-800 border-r border-gray-200'
style={{ wordWrap: 'break-word' }}
>
{item.product_name}
</td>
<td className='p-3 text-center text-gray-700 border-r border-gray-200'>
{item.quantity_sold}
</td>
<td className='p-3 text-center text-gray-700 border-r border-gray-200'>
{item.order_count ?? 0}
</td>
<td className='p-3 text-right font-semibold text-gray-800 border-r border-gray-200'>
{formatCurrency(item.revenue)}
</td>
<td className='p-3 text-right font-medium text-gray-800'>
{formatCurrency(item.average_price)}
</td>
</tr>
)
})
// Category subtotal row
const categoryTotalQty = categoryProducts.reduce(
(sum, item) => sum + (item.quantity_sold || 0),
0
)
const categoryTotalOrders = categoryProducts.reduce(
(sum, item) => sum + (item.order_count || 0),
0
)
const categoryTotalRevenue = categoryProducts.reduce((sum, item) => sum + (item.revenue || 0), 0)
rows.push(
<tr
key={`subtotal-${categoryName}`}
className='bg-gray-200 border-b-2 border-gray-400'
style={{ pageBreakInside: 'avoid' }}
>
<td className='p-3 pl-6 font-semibold text-gray-800 border-r border-gray-400'>
Subtotal {categoryName}
</td>
<td className='p-3 text-center font-semibold text-gray-800 border-r border-gray-400'>
{categoryTotalQty}
</td>
<td className='p-3 text-center font-semibold text-gray-800 border-r border-gray-400'>
{categoryTotalOrders}
</td>
<td className='p-3 text-right font-semibold text-gray-800 border-r border-gray-400'>
{formatCurrency(categoryTotalRevenue)}
</td>
<td className='p-3'></td>
</tr>
)
})
return rows
})()}
</tbody>
<tfoot>
<tr className='text-gray-800 border-t-2 border-gray-300' style={{ pageBreakInside: 'avoid' }}>
<td className='p-3 font-bold border-r border-gray-300'>TOTAL KESELURUHAN</td>
<td className='p-3 text-center font-bold border-r border-gray-300'>
{productSummary.totalQuantitySold ?? 0}
</td>
<td className='p-3 text-center font-bold border-r border-gray-300'>
{productSummary.totalOrders ?? 0}
</td>
<td className='p-3 text-right font-bold border-r border-gray-300'>
{formatCurrency(productSummary.totalRevenue ?? 0)}
</td>
<td className='p-3'></td>
</tr>
</tfoot>
</table>
</div>
</div>
<ReportItemSubheader title='' />
</CardContent>
</Card>
)
}
export default ReportSalesPerProductContent