Compare commits

..
36 Commits
Author SHA1 Message Date
efrilm 2641175e04 query key purchase order 2026-04-13 15:17:46 +07:00
efrilm c78b32d7b8 fix purchase request 2026-04-13 15:03:29 +07:00
Efril e8609a0ab9 fix report 2026-02-26 20:50:12 +07:00
Efril b22f400534 fix report 2026-02-26 20:36:02 +07:00
Efril b506d97c06 update report add service charger 2026-02-26 14:37:53 +07:00
Efril a3dd214a99 update report add standard hpp, realisasi hpp, dan status 2026-02-26 14:02:04 +07:00
efrilm 3c13aa897c add filter order 2026-01-30 17:22:18 +07:00
efrilm f4783236d2 fix order 2026-01-30 12:53:19 +07:00
efrilm 67ebb2ebd7 add excel pdf 2025-12-23 14:46:16 +07:00
efrilm de32d86e94 update daily report 2025-12-23 13:44:50 +07:00
efrilm 3cf7799a91 discount 2025-12-23 13:26:32 +07:00
efrilm 37de68879c update version 2025-12-10 11:22:47 +07:00
efrilm 80bab20f89 update docker 2025-11-26 01:15:10 +07:00
efrilm 6b46f9070a update docker 2025-11-26 01:14:32 +07:00
efrilm c5cda37efb updateclear 2025-11-25 21:45:53 +07:00
efrilm 2897436c5f update report 2025-11-25 21:39:40 +07:00
efrilm fe4176621c remove 2025-11-25 18:43:12 +07:00
efrilm 16deaf1890 add grafik at report 2025-11-25 18:15:18 +07:00
efrilm f434373eaf update report 2025-11-25 15:45:53 +07:00
efrilm 8e4ab5dd69 update report 2025-11-25 15:29:28 +07:00
efrilm 5f65211432 base url 2025-10-09 20:02:49 +07:00
efrilm ead773982a update report 2025-10-09 20:02:32 +07:00
efrilm aae1d49721 update report 2025-10-09 19:10:45 +07:00
efrilm 347d00271b base url 2025-10-07 14:35:11 +07:00
efrilm ac2fc4e920 category order 2025-10-07 00:01:53 +07:00
efrilm e551ae7feb update report 2025-10-06 17:05:11 +07:00
efrilm d059f31eaa update report 2025-10-04 17:17:50 +07:00
efrilm f1cf351ca4 Format Currency 2025-09-27 18:36:47 +07:00
Aditya Siregar 801fcdda2f Update baseUrl 2025-09-25 20:37:47 +07:00
Aditya Siregar 4ba5b9def3 Update Report 2025-09-25 20:31:33 +07:00
aefril 116f6b8009 Merge pull request 'efril' (#18) from efril into main
Reviewed-on: #18
2025-09-24 14:55:52 +00:00
aefril 8672f01261 Merge pull request 'fix: report page' (#17) from efril into main
Reviewed-on: #17
2025-09-23 08:32:13 +00:00
aefril bd14adde23 Merge pull request 'base url' (#16) from efril into main
Reviewed-on: #16
2025-09-21 13:38:26 +00:00
aefril 35b1426384 Merge pull request 'efril' (#15) from efril into main
Reviewed-on: #15
2025-09-20 11:04:26 +00:00
aefril 9806400308 Merge pull request 'api' (#14) from efril into main
Reviewed-on: #14
2025-09-18 09:32:44 +00:00
aefril f98b61e527 Merge pull request 'efril' (#13) from efril into main
Reviewed-on: #13
2025-09-18 04:01:32 +00:00
74 changed files with 5836 additions and 8136 deletions
+1125 -108
View File
File diff suppressed because it is too large Load Diff
+6 -4
View File
@@ -30,6 +30,7 @@
"@fullcalendar/list": "6.1.15",
"@fullcalendar/react": "6.1.15",
"@fullcalendar/timegrid": "6.1.15",
"@hello-pangea/dnd": "^18.0.1",
"@hookform/resolvers": "3.9.1",
"@iconify/react": "^6.0.1",
"@mui/lab": "6.0.0-beta.19",
@@ -52,21 +53,23 @@
"apexcharts": "3.49.0",
"axios": "^1.11.0",
"bootstrap-icons": "1.11.3",
"chart.js": "^4.5.1",
"chartjs-plugin-datalabels": "^2.2.0",
"classnames": "2.5.1",
"cmdk": "1.0.4",
"date-fns": "4.1.0",
"emoji-mart": "5.6.0",
"exceljs": "^4.4.0",
"fs-extra": "11.2.0",
"html2canvas": "^1.4.1",
"html2pdf.js": "^0.12.1",
"input-otp": "1.4.1",
"jspdf": "^3.0.3",
"jspdf": "^3.0.1",
"jspdf-autotable": "^5.0.2",
"keen-slider": "6.8.6",
"lucide-react": "^0.544.0",
"mapbox-gl": "3.9.0",
"negotiator": "1.0.0",
"next": "15.1.2",
"next": "^15.1.9",
"react": "18.3.1",
"react-apexcharts": "1.4.1",
"react-colorful": "5.6.1",
@@ -91,7 +94,6 @@
"@iconify/types": "2.0.0",
"@iconify/utils": "2.2.1",
"@types/fs-extra": "^11.0.4",
"@types/jspdf": "^1.3.3",
"@types/mapbox-gl": "^3.4.1",
"@types/negotiator": "^0.6.3",
"@types/node": "^22.10.2",
@@ -13,11 +13,10 @@ export const metadata: Metadata = {
}
const LoginPage = async () => {
return (
<div className='flex flex-col justify-center items-center min-bs-[100dvh] p-6'>
<Login />
</div>
)
// Vars
const mode = await getServerMode()
return <Login mode={mode} />
}
export default LoginPage
@@ -0,0 +1,38 @@
import DiscountAddHeader from '@/views/apps/marketing/discount/add/DiscountAddHeader'
import DiscountInformation from '@/views/apps/marketing/discount/add/DiscountAddInformation'
import DiscountOutlets from '@/views/apps/marketing/discount/add/DiscountAddOutlets'
import DiscountRules from '@/views/apps/marketing/discount/add/DiscountAddRule'
import DiscountSettings from '@/views/apps/marketing/discount/add/DiscountAddSetting'
import Grid from '@mui/material/Grid2'
const DiscountsAddPage = () => {
return (
<Grid container spacing={6}>
<Grid size={{ xs: 12 }}>
<DiscountAddHeader />
</Grid>
<Grid size={{ xs: 12, md: 8 }}>
<Grid container spacing={6}>
<Grid size={{ xs: 12 }}>
<DiscountInformation />
</Grid>
<Grid size={{ xs: 12 }}>
<DiscountRules />
</Grid>
</Grid>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<Grid container spacing={6}>
<Grid size={{ xs: 12 }}>
<DiscountSettings />
</Grid>
<Grid size={{ xs: 12 }}>
<DiscountOutlets />
</Grid>
</Grid>
</Grid>
</Grid>
)
}
export default DiscountsAddPage
@@ -0,0 +1,7 @@
import DiscountList from '@/views/apps/marketing/discount'
const DiscountPage = () => {
return <DiscountList />
}
export default DiscountPage
@@ -1,18 +0,0 @@
import ReportTitle from '@/components/report/ReportTitle'
import ReportPaymentMethodContent from '@/views/apps/report/financial/payment-method-report/ReportPaymentMethodContent'
import Grid from '@mui/material/Grid2'
const SalesProductReportPage = () => {
return (
<Grid container spacing={6}>
<Grid size={{ xs: 12 }}>
<ReportTitle title='Laporan Metode Pembayaran' />
</Grid>
<Grid size={{ xs: 12 }}>
<ReportPaymentMethodContent />
</Grid>
</Grid>
)
}
export default SalesProductReportPage
@@ -1,72 +1,22 @@
'use client'
import ReportTitle from '@/components/report/ReportTitle'
import ReportProfitLossCard from '@/views/apps/report/profit-loss/ReportProfitLossCard'
import ReportProfitLossContent from '@/views/apps/report/profit-loss/ReportProfitLossContent'
import Grid from '@mui/material/Grid2'
import { CircularProgress, Box } from '@mui/material'
import { useState } from 'react'
import { useProfitLossAnalytics } from '@/services/queries/analytics'
import { formatDateDDMMYYYY } from '@/utils/transform'
import Loading from '@/components/layout/shared/Loading'
const ProfitLossPage = () => {
const today = new Date()
const monthAgo = new Date()
monthAgo.setDate(today.getDate() - 30)
const [startDate, setStartDate] = useState<Date | null>(monthAgo)
const [endDate, setEndDate] = useState<Date | null>(today)
// Single API call at parent level
const {
data: profitData,
isLoading,
error
} = useProfitLossAnalytics({
date_from: formatDateDDMMYYYY(startDate!),
date_to: formatDateDDMMYYYY(endDate!)
})
// Handle loading state
if (isLoading) {
return <Loading />
}
// Handle error state
if (error) {
const ProfiltLossPage = () => {
return (
<Grid container spacing={6}>
<Grid size={{ xs: 12 }}>
<ReportTitle title='Laba Rugi' />
</Grid>
<Grid size={{ xs: 12 }}>
<Box display='flex' justifyContent='center' alignItems='center' minHeight={400}>
<span>Error loading data: {error.message}</span>
</Box>
</Grid>
</Grid>
)
}
return (
<Grid container spacing={6}>
<Grid size={{ xs: 12 }}>
<ReportTitle title='Laba Rugi' />
<ReportProfitLossCard />
</Grid>
<Grid size={{ xs: 12 }}>
<ReportProfitLossCard profitData={profitData} />
</Grid>
<Grid size={{ xs: 12 }}>
<ReportProfitLossContent
profitData={profitData}
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
<ReportProfitLossContent />
</Grid>
</Grid>
)
}
export default ProfitLossPage
export default ProfiltLossPage
@@ -1,19 +0,0 @@
import ReportTitle from '@/components/report/ReportTitle'
import ReportSalesOrderContent from '@/views/apps/report/sales/sales-order/ReportSalesOrderContent'
import Grid from '@mui/material/Grid2'
const SalesOrderReportPage = () => {
return (
<Grid container spacing={6}>
<Grid size={{ xs: 12 }}>
<ReportTitle title='Laporan Penjualan Pesanan' />
</Grid>
<Grid size={{ xs: 12 }}>
<ReportSalesOrderContent />
</Grid>
z
</Grid>
)
}
export default SalesOrderReportPage
@@ -1,18 +0,0 @@
import ReportTitle from '@/components/report/ReportTitle'
import ReportSalesProductCategoryContent from '@/views/apps/report/sales/sales-product-category/ReportSalesProductCategoryReport'
import Grid from '@mui/material/Grid2'
const SalesProductCategoryReportPage = () => {
return (
<Grid container spacing={6}>
<Grid size={{ xs: 12 }}>
<ReportTitle title='Laporan Penjualan per Kategori Produk' />
</Grid>
<Grid size={{ xs: 12 }}>
<ReportSalesProductCategoryContent />
</Grid>
</Grid>
)
}
export default SalesProductCategoryReportPage
@@ -1,18 +0,0 @@
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
@@ -1,18 +0,0 @@
import ReportTitle from '@/components/report/ReportTitle'
import ReportSalesContent from '@/views/apps/report/sales/sales-report/ReportSalesContent'
import Grid from '@mui/material/Grid2'
const SalesReportPage = () => {
return (
<Grid container spacing={6}>
<Grid size={{ xs: 12 }}>
<ReportTitle title='Laporan Penjualan' />
</Grid>
<Grid size={{ xs: 12 }}>
<ReportSalesContent />
</Grid>
</Grid>
)
}
export default SalesReportPage
@@ -8,24 +8,63 @@ import {
useCategoryAnalytics
} from '@/services/queries/analytics'
import { useOutletById } from '@/services/queries/outlets'
import { generateExcel } from '@/utils/excelGenerator'
import { generatePDF } from '@/utils/pdfGenerator'
import { formatCurrency, formatDate, formatDateDDMMYYYY, formatDatetime } from '@/utils/transform'
import ReportGeneratorComponent from '@/views/dashboards/daily-report/report-generator'
import ReportHeader from '@/views/dashboards/daily-report/report-header'
import React, { useEffect, useRef, useState } from 'react'
// Dummy HPP values — ganti dengan data real nantinya
const DUMMY_STD_HPP = 30 // % — ganti dengan data real nantinya
const DUMMY_REAL_HPP = 0 // % — ganti dengan kalkulasi real nantinya
const getHppStatus = (stdHpp: number, realHpp: number): 'Sehat' | 'Tidak Sehat' =>
realHpp <= stdHpp ? 'Sehat' : 'Tidak Sehat'
const StatusBadge = ({ status }: { status: 'Sehat' | 'Tidak Sehat' }) => (
<span
className={`inline-block px-3 py-1 rounded-full text-base font-bold ${
status === 'Sehat' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}
>
{status}
</span>
)
const DailyPOSReport = () => {
const reportRef = useRef<HTMLDivElement | null>(null)
const [isGeneratingExcel, setIsGeneratingExcel] = useState(false)
const [now, setNow] = useState(new Date())
const [selectedDate, setSelectedDate] = useState(new Date())
const [dateRange, setDateRange] = useState({
startDate: new Date(),
endDate: new Date()
})
const [filterType, setFilterType] = useState<'single' | 'range'>('single') // 'single' or 'range'
const [filterType, setFilterType] = useState<'single' | 'range'>('single')
const [isGeneratingPDF, setIsGeneratingPDF] = useState(false)
// Use selectedDate for single date filter, or dateRange for range filter
// PDF Font Size Configuration
const PDF_FONT_SIZES = {
heading: 18,
subheading: 16,
tableContent: 12,
tableHeader: 12,
tableFooter: 12,
grandTotal: 14,
footer: 11
}
// PDF Spacing Configuration
const PDF_SPACING = {
cellPadding: 5,
cellPaddingLarge: 6,
sectionGap: 20,
headerGap: 15
}
const getDateParams = () => {
if (filterType === 'single') {
return {
@@ -42,8 +81,15 @@ const DailyPOSReport = () => {
const dateParams = getDateParams()
const today = new Date()
const sevenDaysAgo = new Date()
sevenDaysAgo.setDate(today.getDate() - 7)
const { data: outlet } = useOutletById()
const { data: sales } = useSalesAnalytics(dateParams)
const { data: profitLoss7Days } = useProfitLossAnalytics({
date_from: formatDateDDMMYYYY(sevenDaysAgo),
date_to: formatDateDDMMYYYY(today)
})
const { data: profitLoss } = useProfitLossAnalytics(dateParams)
const { data: products } = useProductSalesAnalytics(dateParams)
const { data: paymentAnalytics } = usePaymentAnalytics(dateParams)
@@ -66,12 +112,10 @@ const DailyPOSReport = () => {
setNow(new Date())
}, [])
// Format date for input field (YYYY-MM-DD)
const formatDateForInput = (date: Date) => {
return date.toISOString().split('T')[0]
}
// Get display text for the report period
const getReportPeriodText = () => {
if (filterType === 'single') {
return `${formatDateDDMMYYYY(selectedDate)} - ${formatDateDDMMYYYY(selectedDate)}`
@@ -80,187 +124,58 @@ const DailyPOSReport = () => {
}
}
// Get report title based on filter type
const getReportTitle = () => {
if (filterType === 'single') {
return 'Laporan Transaksi'
} else {
const daysDiff = Math.ceil((dateRange.endDate.getTime() - dateRange.startDate.getTime()) / (1000 * 3600 * 24)) + 1
// return `Laporan Transaksi ${daysDiff} Hari`
return `Laporan Transaksi`
}
}
const handleGeneratePDF = async () => {
const reportElement = reportRef.current
if (!reportElement) {
alert('Report element tidak ditemukan')
return
}
// Set loading state
setIsGeneratingPDF(true)
try {
// Import jsPDF dan html2canvas
const jsPDF = (await import('jspdf')).default
const html2canvas = (await import('html2canvas')).default
// Pastikan element terlihat penuh
const originalOverflow = reportElement.style.overflow
reportElement.style.overflow = 'visible'
// Wait untuk memastikan rendering selesai
await new Promise(resolve => setTimeout(resolve, 300))
console.log('Starting PDF generation...')
// Update loading message
console.log('Capturing content...')
// Capture canvas dengan setting yang optimal
const canvas = await html2canvas(reportElement, {
scale: 1.5,
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff',
logging: false,
removeContainer: true,
imageTimeout: 0,
height: reportElement.scrollHeight,
width: reportElement.scrollWidth,
scrollX: 0,
scrollY: 0,
// Pastikan capture semua content
windowWidth: Math.max(reportElement.scrollWidth, window.innerWidth),
windowHeight: Math.max(reportElement.scrollHeight, window.innerHeight)
await generatePDF({
reportRef,
outlet,
profitLoss,
products,
paymentAnalytics,
category,
productSummary,
categorySummary,
filterType,
selectedDate,
dateRange,
now
})
console.log('Canvas captured:', canvas.width, 'x', canvas.height)
console.log('Generating PDF pages...')
// Restore overflow
reportElement.style.overflow = originalOverflow
// Create PDF
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
compress: true
})
// A4 dimensions
const pdfWidth = 210
const pdfHeight = 297
const margin = 5 // Small margin to prevent cutoff
// Calculate scaling
const availableWidth = pdfWidth - 2 * margin
const availableHeight = pdfHeight - 2 * margin
const imgWidth = availableWidth
const imgHeight = (canvas.height * availableWidth) / canvas.width
console.log('PDF dimensions - Canvas:', canvas.width, 'x', canvas.height)
console.log('PDF dimensions - Image:', imgWidth, 'x', imgHeight)
console.log('Available height per page:', availableHeight)
// Split content across pages
let yPosition = margin
let sourceY = 0
let pageCount = 1
let remainingHeight = imgHeight
while (remainingHeight > 0) {
console.log(`Processing page ${pageCount}, remaining height: ${remainingHeight}`)
if (pageCount > 1) {
pdf.addPage()
yPosition = margin
}
// Calculate how much content fits on this page
const heightForThisPage = Math.min(remainingHeight, availableHeight)
// Calculate source dimensions for cropping
const sourceHeight = (heightForThisPage * canvas.height) / imgHeight
// Create temporary canvas for this page portion
const tempCanvas = document.createElement('canvas')
const tempCtx = tempCanvas.getContext('2d')
if (!tempCtx) {
throw new Error('Unable to get 2D context from canvas')
}
tempCanvas.width = canvas.width
tempCanvas.height = sourceHeight
// Draw the portion we need
tempCtx.drawImage(
canvas,
0,
sourceY,
canvas.width,
sourceHeight, // Source rectangle
0,
0,
canvas.width,
sourceHeight // Destination rectangle
)
// Convert to image data
const pageImageData = tempCanvas.toDataURL('image/jpeg', 0.9)
// Add to PDF
pdf.addImage(pageImageData, 'JPEG', margin, yPosition, imgWidth, heightForThisPage)
// Update for next page
sourceY += sourceHeight
remainingHeight -= heightForThisPage
pageCount++
// Safety check to prevent infinite loop
if (pageCount > 20) {
console.warn('Too many pages, breaking loop')
break
}
}
console.log(`Generated ${pageCount - 1} pages`)
console.log('Finalizing PDF...')
// Add metadata
pdf.setProperties({
title: getReportTitle(),
subject: 'Transaction Report',
author: 'Apskel POS System',
creator: 'Apskel'
})
// Generate filename
const fileName =
filterType === 'single'
? `laporan-transaksi-${formatDateForInput(selectedDate)}.pdf`
: `laporan-transaksi-${formatDateForInput(dateRange.startDate)}-to-${formatDateForInput(dateRange.endDate)}.pdf`
console.log('Saving PDF:', fileName)
// Save PDF
pdf.save(fileName)
console.log('PDF generated successfully!')
} catch (error) {
console.error('Error generating PDF:', error)
alert(`Terjadi kesalahan saat membuat PDF: ${error}`)
} finally {
// Reset loading state
setIsGeneratingPDF(false)
}
}
const handleGenerateExcel = async () => {
setIsGeneratingExcel(true)
try {
await generateExcel({
outlet,
profitLoss,
products,
paymentAnalytics,
category,
productSummary,
categorySummary,
filterType,
selectedDate,
dateRange
})
} catch (error) {
console.error('Error generating Excel:', error)
alert(`Terjadi kesalahan saat membuat Excel: ${error}`)
} finally {
setIsGeneratingExcel(false)
}
}
const LoadingOverlay = ({ isVisible, message = 'Generating PDF...' }: { isVisible: boolean; message?: string }) => {
if (!isVisible) return null
@@ -268,15 +183,9 @@ const DailyPOSReport = () => {
<div className='fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm'>
<div className='bg-white rounded-lg shadow-xl p-8 max-w-sm w-full mx-4'>
<div className='text-center'>
{/* Animated Spinner */}
<div className='inline-block animate-spin rounded-full h-12 w-12 border-4 border-gray-200 border-t-purple-600 mb-4'></div>
{/* Loading Message */}
<h3 className='text-lg font-semibold text-gray-800 mb-2'>{message}</h3>
<p className='text-sm text-gray-600'>Mohon tunggu, proses ini mungkin membutuhkan beberapa detik...</p>
{/* Progress Steps */}
<div className='mt-6 space-y-2'>
<div className='flex items-center text-xs text-gray-500'>
<div className='w-2 h-2 bg-green-500 rounded-full mr-2 flex-shrink-0'></div>
@@ -297,212 +206,6 @@ const DailyPOSReport = () => {
)
}
return (
<div className='min-h-screen'>
<LoadingOverlay isVisible={isGeneratingPDF} message='Membuat PDF Laporan...' />
{/* Control Panel */}
<ReportGeneratorComponent
// Props wajib
className='min-w-full'
reportTitle='Laporan Penjualan'
filterType={filterType}
selectedDate={selectedDate}
dateRange={dateRange}
onFilterTypeChange={setFilterType}
onSingleDateChange={setSelectedDate}
onDateRangeChange={setDateRange}
onGeneratePDF={handleGeneratePDF}
// Props opsional
// isGenerating={isGenerating}
// customQuickActions={customQuickActions}
// labels={customLabels}
/>
{/* Report Template */}
<div ref={reportRef} className='max-w-4xl mx-auto bg-white min-h-[297mm]' style={{ width: '210mm' }}>
{/* Header */}
<ReportHeader
outlet={outlet}
reportTitle={getReportTitle()}
reportSubtitle='Laporan Bulanan'
brandName='Apskel'
brandColor='#36175e'
periode={getReportPeriodText()}
/>
{/* Performance Summary */}
<div className='p-8'>
<h3 className='text-xl font-bold mb-6' style={{ color: '#36175e' }}>
Ringkasan
</h3>
<div className='space-y-4'>
<div className='flex justify-between items-center py-2 border-b border-gray-200'>
<span className='text-gray-700'>Total Penjualan</span>
<span className='font-semibold text-gray-800'>
{formatCurrency(profitLoss?.summary.total_revenue ?? 0)}
</span>
</div>
<div className='flex justify-between items-center py-2 border-b border-gray-200'>
<span className='text-gray-700'>Total Diskon</span>
<span className='font-semibold text-gray-800'>
{formatCurrency(profitLoss?.summary.total_discount ?? 0)}
</span>
</div>
<div className='flex justify-between items-center py-2 border-b border-gray-200'>
<span className='text-gray-700'>Total Pajak</span>
<span className='font-semibold text-gray-800'>{formatCurrency(profitLoss?.summary.total_tax ?? 0)}</span>
</div>
<div className='flex justify-between items-center py-2 border-b border-gray-200'>
<span className='text-gray-700 font-bold'>Total</span>
<span className='font-bold text-gray-800'>{formatCurrency(profitLoss?.summary.total_revenue ?? 0)}</span>
</div>
</div>
</div>
<div className='px-8 pb-8'>
<h3 className='text-xl font-bold mb-6' style={{ color: '#36175e' }}>
Invoice
</h3>
<div className='space-y-4'>
<div className='flex justify-between items-center py-2 border-b border-gray-200'>
<span className='text-gray-700'>Total Invoice</span>
<span className='font-semibold text-gray-800'>{profitLoss?.summary.total_orders ?? 0}</span>
</div>
<div className='flex justify-between items-center py-2 border-b border-gray-200'>
<span className='text-gray-700'>Rata-rata Tagihan Per Invoice </span>
<span className='font-semibold text-gray-800'>
{formatCurrency(profitLoss?.summary.average_profit ?? 0)}
</span>
</div>
</div>
</div>
{/* Payment Method Summary */}
<div className='px-8 pb-8'>
<h3 className='text-xl font-bold mb-6' style={{ color: '#36175e' }}>
Ringkasan Metode Pembayaran
</h3>
<div className='bg-gray-50 rounded-lg border border-gray-200 overflow-hidden'>
<table className='w-full'>
<thead>
<tr className='text-gray-800 border-b-2 border-gray-300'>
<th className='text-left p-3 font-semibold'>Metode Pembayaran</th>
<th className='text-center p-3 font-semibold'>Tipe</th>
<th className='text-center p-3 font-semibold'>Jumlah Order</th>
<th className='text-right p-3 font-semibold'>Total Amount</th>
<th className='text-center p-3 font-semibold'>Persentase</th>
</tr>
</thead>
<tbody>
{paymentAnalytics?.data?.map((payment, index) => (
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className='p-3 font-medium text-gray-800'>{payment.payment_method_name}</td>
<td className='p-3 text-center'>
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
payment.payment_method_type === 'cash'
? 'bg-green-100 text-green-800'
: 'bg-blue-100 text-blue-800'
}`}
>
{payment.payment_method_type.toUpperCase()}
</span>
</td>
<td className='p-3 text-center text-gray-700'>{payment.order_count}</td>
<td className='p-3 text-right font-semibold text-gray-800'>
{formatCurrency(payment.total_amount)}
</td>
<td className='p-3 text-center font-medium' style={{ color: '#36175e' }}>
{(payment.percentage ?? 0).toFixed(1)}%
</td>
</tr>
)) || []}
</tbody>
<tfoot>
<tr className='text-gray-800 border-t-2 border-gray-300'>
<td className='p-3 font-bold'>TOTAL</td>
<td className='p-3'></td>
<td className='p-3 text-center font-bold'>{paymentAnalytics?.summary.total_orders ?? 0}</td>
<td className='p-3 text-right font-bold'>
{formatCurrency(paymentAnalytics?.summary.total_amount ?? 0)}
</td>
<td className='p-3 text-center font-bold'></td>
</tr>
</tfoot>
</table>
</div>
</div>
{/* Category Summary */}
<div className='px-8 pb-8'>
<h3 className='text-xl font-bold mb-6' style={{ color: '#36175e' }}>
Ringkasan Kategori
</h3>
<div className='bg-gray-50 rounded-lg border border-gray-200 overflow-hidden'>
<table className='w-full'>
<thead>
<tr className='text-gray-800 border-b-2 border-gray-300'>
<th className='text-left p-3 font-semibold'>Nama</th>
<th className='text-center p-3 font-semibold'>Total Produk</th>
<th className='text-center p-3 font-semibold'>Qty</th>
<th className='text-right p-3 font-semibold'>Pendapatan</th>
</tr>
</thead>
<tbody>
{category?.data?.map((c, index) => (
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className='p-3 font-medium text-gray-800'>{c.category_name}</td>
<td className='p-3 text-center text-gray-700'>{c.product_count}</td>
<td className='p-3 text-center text-gray-700'>{c.total_quantity}</td>
<td className='p-3 text-right font-semibold' style={{ color: '#36175e' }}>
{formatCurrency(c.total_revenue)}
</td>
</tr>
)) || []}
</tbody>
<tfoot>
<tr className='text-gray-800 border-t-2 border-gray-300'>
<td className='p-3 font-bold'>TOTAL</td>
<td className='p-3 text-center font-bold'>{categorySummary?.productCount ?? 0}</td>
<td className='p-3 text-center font-bold'>{categorySummary?.totalQuantity ?? 0}</td>
<td className='p-3 text-right font-bold'>{formatCurrency(categorySummary?.totalRevenue ?? 0)}</td>
</tr>
</tfoot>
</table>
</div>
</div>
{/* Transaction Summary */}
<div className='px-8 pb-8'>
<h3 className='text-xl font-bold mb-6' style={{ color: '#36175e' }}>
Ringkasan Item
</h3>
<div className='bg-gray-50 rounded-lg 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(
@@ -517,119 +220,344 @@ const DailyPOSReport = () => {
{} as Record<string, any[]>
) || {}
const rows: JSX.Element[] = []
let globalIndex = 0
return (
<div className='min-h-screen'>
<LoadingOverlay isVisible={isGeneratingPDF} message='Membuat PDF Laporan...' />
// Sort categories alphabetically
Object.keys(groupedProducts)
.sort()
.forEach(categoryName => {
const categoryProducts = groupedProducts[categoryName]
<ReportGeneratorComponent
reportTitle='Laporan Penjualan'
filterType={filterType}
selectedDate={selectedDate}
dateRange={dateRange}
onFilterTypeChange={setFilterType}
onSingleDateChange={setSelectedDate}
onDateRangeChange={setDateRange}
onGeneratePDF={handleGeneratePDF}
onGenerateExcel={handleGenerateExcel}
isGeneratingExcel={isGeneratingExcel}
/>
// Category header row
rows.push(
<tr
key={`category-${categoryName}`}
className='bg-gray-100 border-b border-gray-300'
style={{ pageBreakInside: 'avoid' }}
<div ref={reportRef} className='max-w-4xl mx-auto bg-white min-h-[297mm]' style={{ width: '210mm' }}>
{/* Wrap header in a section for easy capture */}
<div className='report-header-section'>
<ReportHeader
outlet={outlet}
reportTitle={getReportTitle()}
reportSubtitle='Laporan Bulanan'
brandName='Apskel'
brandColor='#36175e'
periode={getReportPeriodText()}
/>
</div>
{/* Performance Summary */}
<div className='p-8'>
<h3 className='text-3xl font-bold mb-8' style={{ color: '#36175e' }}>
Ringkasan
</h3>
<div className='space-y-5'>
<div className='flex justify-between items-center py-3 border-b border-gray-200'>
<span className='text-xl text-gray-700'>Total Penjualan</span>
<span className='text-xl font-semibold text-gray-800'>
{formatCurrency(profitLoss?.summary.total_revenue ?? 0)}
</span>
</div>
<div className='flex justify-between items-center py-3 border-b border-gray-200'>
<span className='text-xl text-gray-700'>Total Diskon</span>
<span className='text-xl font-semibold text-gray-800'>
{formatCurrency(profitLoss?.summary.total_discount ?? 0)}
</span>
</div>
<div className='flex justify-between items-center py-3 border-b border-gray-200'>
<span className='text-xl text-gray-700'>Total Pajak</span>
<span className='text-xl font-semibold text-gray-800'>
{formatCurrency(profitLoss?.summary.total_tax ?? 0)}
</span>
</div>
<div className='flex justify-between items-center py-3 border-b border-gray-200'>
<span className='text-xl text-gray-700'>Service Charge</span>
<span className='text-xl font-semibold text-gray-800'>{formatCurrency(0)}</span>
</div>
<div className='flex justify-between items-center py-3 border-b-2 border-gray-300'>
<span className='text-xl text-gray-700 font-bold'>Total</span>
<span className='text-xl font-bold text-gray-800'>
{formatCurrency(profitLoss?.summary.total_revenue ?? 0)}
</span>
</div>
</div>
</div>
{/* Invoice */}
<div className='px-8 pb-8'>
<h3 className='text-3xl font-bold mb-8' style={{ color: '#36175e' }}>
Invoice
</h3>
<div className='space-y-5'>
<div className='flex justify-between items-center py-3 border-b border-gray-200'>
<span className='text-xl text-gray-700'>Total Invoice</span>
<span className='text-xl font-semibold text-gray-800'>{profitLoss?.summary.total_orders ?? 0}</span>
</div>
</div>
</div>
{/* Payment Method Summary */}
<div className='px-8 pb-8'>
<h3 className='text-3xl font-bold mb-8' style={{ color: '#36175e' }}>
Ringkasan Metode Pembayaran
</h3>
<div className='bg-gray-50 rounded-lg border border-gray-200 overflow-hidden'>
<table className='w-full'>
<thead>
<tr className='text-gray-800 border-b-2 border-gray-300'>
<th className='text-left text-xl p-4 font-semibold'>Metode Pembayaran</th>
<th className='text-center text-xl p-4 font-semibold'>Tipe</th>
<th className='text-center text-xl p-4 font-semibold'>Jumlah Order</th>
<th className='text-right text-xl p-4 font-semibold'>Total Amount</th>
<th className='text-center text-xl p-4 font-semibold'>Persentase</th>
</tr>
</thead>
<tbody>
{paymentAnalytics?.data?.map((payment, index) => (
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className='p-4 text-xl font-medium text-gray-800'>{payment.payment_method_name}</td>
<td className='p-4 text-center'>
<span
className={`px-3 py-1 rounded-full text-lg font-medium ${
payment.payment_method_type === 'cash'
? 'bg-green-100 text-green-800'
: 'bg-blue-100 text-blue-800'
}`}
>
<td
className='p-3 font-bold text-gray-900 border-r border-gray-300'
{payment.payment_method_type.toUpperCase()}
</span>
</td>
<td className='p-4 text-xl text-center text-gray-700'>{payment.order_count}</td>
<td className='p-4 text-xl text-right font-semibold text-gray-800'>
{formatCurrency(payment.total_amount)}
</td>
<td className='p-4 text-xl text-center font-medium' style={{ color: '#36175e' }}>
{(payment.percentage ?? 0).toFixed(1)}%
</td>
</tr>
)) || []}
</tbody>
<tfoot>
<tr className='text-gray-800 border-t-2 border-gray-300'>
<td className='p-4 text-xl font-bold'>TOTAL</td>
<td className='p-4'></td>
<td className='p-4 text-xl text-center font-bold'>{paymentAnalytics?.summary.total_orders ?? 0}</td>
<td className='p-4 text-xl text-right font-bold'>
{formatCurrency(paymentAnalytics?.summary.total_amount ?? 0)}
</td>
<td className='p-4 text-center font-bold'></td>
</tr>
</tfoot>
</table>
</div>
</div>
{/* Category Summary — +3 kolom baru */}
<div className='px-8 pb-8'>
<h3 className='text-3xl font-bold mb-8' style={{ color: '#36175e' }}>
Ringkasan Kategori
</h3>
<div className='bg-gray-50 rounded-lg border border-gray-200 overflow-hidden'>
<table className='w-full'>
<thead>
<tr className='text-gray-800 border-b-2 border-gray-300'>
<th className='text-left text-xl p-4 font-semibold'>Nama</th>
<th className='text-center text-xl p-4 font-semibold'>Qty</th>
<th className='text-right text-xl p-4 font-semibold'>Pendapatan</th>
<th className='text-center text-xl p-4 font-semibold'>% Std HPP</th>
<th className='text-center text-xl p-4 font-semibold'>% Real HPP</th>
<th className='text-center text-xl p-4 font-semibold'>Status</th>
</tr>
</thead>
<tbody>
{category?.data?.map((c, index) => {
const stdHpp = DUMMY_STD_HPP
const realHpp = DUMMY_REAL_HPP
const status = getHppStatus(stdHpp, realHpp)
return (
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className='p-4 text-xl font-medium text-gray-800'>{c.category_name}</td>
<td className='p-4 text-xl text-center text-gray-700'>{c.total_quantity}</td>
<td className='p-4 text-xl text-right font-semibold' style={{ color: '#36175e' }}>
{formatCurrency(c.total_revenue)}
</td>
<td className='p-4 text-xl text-center text-gray-700'>{stdHpp}%</td>
<td className='p-4 text-xl text-center text-gray-700'>{realHpp}%</td>
<td className='p-4 text-center'>
<StatusBadge status={status} />
</td>
</tr>
)
}) || []}
</tbody>
<tfoot>
<tr className='text-gray-800 border-t-2 border-gray-300'>
<td className='p-4 text-xl font-bold'>TOTAL</td>
<td className='p-4 text-xl text-center font-bold'>{categorySummary?.totalQuantity ?? 0}</td>
<td className='p-4 text-xl text-right font-bold'>
{formatCurrency(categorySummary?.totalRevenue ?? 0)}
</td>
<td className='p-4'></td>
<td className='p-4'></td>
<td className='p-4'></td>
</tr>
</tfoot>
</table>
</div>
</div>
{/* Product Summary — +3 kolom baru */}
<div className='px-8 pb-8'>
<h3 className='text-3xl font-bold mb-8' style={{ color: '#36175e' }}>
Ringkasan Item Per Kategori
</h3>
<div className='space-y-12'>
{Object.keys(groupedProducts)
.sort((a, b) => {
const productsA = groupedProducts[a]
const productsB = groupedProducts[b]
const orderA = productsA[0]?.category_order ?? 999
const orderB = productsB[0]?.category_order ?? 999
return orderA - orderB
})
.map((categoryName, catIndex) => {
const categoryProducts = groupedProducts[categoryName].sort((a, b) => {
const skuA = a.product_sku || ''
const skuB = b.product_sku || ''
return skuA.localeCompare(skuB)
})
const categoryTotalQty = categoryProducts.reduce((sum, item) => sum + (item.quantity_sold || 0), 0)
const categoryTotalRevenue = categoryProducts.reduce((sum, item) => sum + (item.revenue || 0), 0)
return (
<div key={categoryName} style={{ display: 'block', marginBottom: '70px' }}>
<h4
className='text-2xl font-bold mb-0 px-4 py-4 bg-gray-100 rounded-t-lg'
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>
)
</h4>
// 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' }}
<div className='bg-gray-50 rounded-b-lg border-l-2 border-r-2 border-b-2 border-gray-300'>
<table
className='w-full'
style={{ borderCollapse: 'collapse', tableLayout: 'fixed', width: '100%' }}
>
<colgroup>
<col style={{ width: '28%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '22%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '14%' }} />
</colgroup>
<thead>
<tr className='text-gray-800 border-b-2 border-gray-300 bg-gray-100'>
<th className='text-left text-xl p-5 font-semibold border-r border-gray-300'>Produk</th>
<th className='text-center text-xl p-5 font-semibold border-r border-gray-300'>Qty</th>
<th className='text-right text-xl p-5 font-semibold border-r border-gray-300'>
Pendapatan
</th>
<th className='text-center text-xl p-5 font-semibold border-r border-gray-300'>
% Std HPP
</th>
<th className='text-center text-xl p-5 font-semibold border-r border-gray-300'>
% Real HPP
</th>
<th className='text-center text-xl p-5 font-semibold'>Status</th>
</tr>
</thead>
<tbody>
{categoryProducts.map((item, index) => {
const stdHpp = DUMMY_STD_HPP
const realHpp = DUMMY_REAL_HPP
const status = getHppStatus(stdHpp, realHpp)
return (
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className='p-5 text-xl font-medium text-gray-800 border-r border-gray-200'>
{item.product_name}
</td>
<td className='p-3 text-center text-gray-700 border-r border-gray-200'>
<td className='p-5 text-xl 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'>
<td className='p-5 text-xl 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 className='p-5 text-xl text-center text-gray-700 border-r border-gray-200'>
{stdHpp}%
</td>
<td className='p-5 text-xl text-center text-gray-700 border-r border-gray-200'>
{realHpp}%
</td>
<td className='p-5 text-center'>
<StatusBadge status={status} />
</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'>
<tr className='bg-gray-200 border-t-2 border-gray-400'>
<td className='p-5 text-xl font-bold text-gray-800 border-r border-gray-400'>
Subtotal {categoryName}
</td>
<td className='p-5 text-xl text-center font-bold text-gray-800 border-r border-gray-400'>
{categoryTotalQty}
</td>
<td className='p-5 text-xl text-right font-bold text-gray-800 border-r border-gray-400'>
{formatCurrency(categoryTotalRevenue)}
</td>
<td className='p-5 border-r border-gray-400'></td>
<td className='p-5 border-r border-gray-400'></td>
<td className='p-5'></td>
</tr>
</tfoot>
</table>
</div>
</div>
)
})}
{/* Grand Total */}
<div className='bg-purple-50 rounded-lg border-2 border-purple-300 mt-6'>
<table className='w-full' style={{ borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '28%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '22%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '14%' }} />
</colgroup>
<tfoot>
<tr className='text-gray-800'>
<td className='p-5 text-2xl font-bold border-r-2 border-purple-300' style={{ color: '#36175e' }}>
TOTAL KESELURUHAN
</td>
<td
className='p-5 text-2xl text-center font-bold border-r-2 border-purple-300'
style={{ color: '#36175e' }}
>
{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'>
<td
className='p-5 text-2xl text-right font-bold border-r-2 border-purple-300'
style={{ color: '#36175e' }}
>
{formatCurrency(productSummary.totalRevenue ?? 0)}
</td>
<td className='p-3'></td>
<td className='p-5 border-r-2 border-purple-300'></td>
<td className='p-5 border-r-2 border-purple-300'></td>
<td className='p-5'></td>
</tr>
</tfoot>
</table>
@@ -639,7 +567,7 @@ const DailyPOSReport = () => {
{/* Footer */}
<div className='px-8 py-6 border-t-2 border-gray-200 mt-8'>
<div className='flex justify-between items-center text-sm text-gray-600'>
<div className='flex justify-between items-center text-base text-gray-600'>
<p>© 2025 Apskel - Sistem POS Terpadu</p>
<p></p>
<p>Dicetak pada: {now.toLocaleDateString('id-ID')}</p>
@@ -4,7 +4,12 @@ import { TextField, Typography, useTheme } from '@mui/material'
import { useState } from 'react'
import Loading from '../../../../../../components/layout/shared/Loading'
import { useDashboardAnalytics } from '../../../../../../services/queries/analytics'
import { formatDateDDMMYYYY, formatForInputDate, formatShortCurrency } from '../../../../../../utils/transform'
import {
formatCurrency,
formatDateDDMMYYYY,
formatForInputDate,
formatShortCurrency
} from '../../../../../../utils/transform'
import OrdersReport from '../../../../../../views/dashboards/orders/OrdersReport'
import PaymentMethodReport from '../../../../../../views/dashboards/payment-methods/PaymentMethodReport'
import ProductSales from '../../../../../../views/dashboards/products/ProductSales'
@@ -115,14 +120,14 @@ const DashboardOverview = () => {
<MetricCard
iconClass='tabler-cash'
title='Total Sales'
value={formatShortCurrency(salesData.overview.total_sales)}
value={formatCurrency(salesData.overview.total_sales)}
bgColor='bg-green-500'
isCurrency={true}
/>
<MetricCard
iconClass='tabler-trending-up'
title='Average Order Value'
value={formatShortCurrency(salesData.overview.average_order_value)}
value={formatCurrency(salesData.overview.average_order_value)}
bgColor='bg-purple-500'
isCurrency={true}
/>
@@ -162,21 +162,21 @@ const DashboardProfitloss = () => {
<MetricCard
iconClass='tabler-currency-dollar'
title='Total Revenue'
value={formatShortCurrency(profitData.summary.total_revenue)}
value={formatCurrency(profitData.summary.total_revenue)}
bgColor='bg-green-500'
isCurrency={true}
/>
<MetricCard
iconClass='tabler-receipt'
title='Total Cost'
value={formatShortCurrency(profitData.summary.total_cost)}
value={formatCurrency(profitData.summary.total_cost)}
bgColor='bg-red-500'
isCurrency={true}
/>
<MetricCard
iconClass='tabler-trending-up'
title='Gross Profit'
value={formatShortCurrency(profitData.summary.gross_profit)}
value={formatCurrency(profitData.summary.gross_profit)}
subtitle={`Margin: ${formatPercentage(profitData.summary.gross_profit_margin)}`}
bgColor='bg-blue-500'
isNegative={profitData.summary.gross_profit < 0}
@@ -186,7 +186,7 @@ const DashboardProfitloss = () => {
iconClass='tabler-percentage'
title='Profitability Ratio'
value={formatPercentage(profitData.summary.profitability_ratio)}
subtitle={`Avg Profit: ${formatShortCurrency(profitData.summary.average_profit)}`}
subtitle={`Avg Profit: ${formatCurrency(profitData.summary.average_profit)}`}
bgColor='bg-purple-500'
/>
</div>
@@ -199,7 +199,7 @@ const DashboardProfitloss = () => {
<h3 className='text-lg font-semibold text-gray-900'>Net Profit</h3>
</div>
<p className='text-3xl font-bold text-green-600 mb-2'>
Rp {formatShortCurrency(profitData.summary.net_profit)}
{formatCurrency(profitData.summary.net_profit)}
</p>
<p className='text-sm text-gray-600'>
Margin: {formatPercentage(profitData.summary.net_profit_margin)}
@@ -31,27 +31,27 @@ import { getLocalizedUrl } from '@/utils/i18n'
const shortcuts: ShortcutsType[] = [
{
url: '/apps/calendar',
icon: 'tabler-box',
title: 'Produk',
subtitle: 'Kelola Produk'
icon: 'tabler-calendar',
title: 'Calendar',
subtitle: 'Appointments'
},
{
url: '/apps/invoice/list',
icon: 'tabler-file-dollar',
title: 'Pembelian',
subtitle: 'Kelola Pembelian'
title: 'Invoice App',
subtitle: 'Manage Accounts'
},
{
url: '/apps/user/list',
icon: 'tabler-user',
title: 'Pengguna',
subtitle: 'Kelola Pengguna'
title: 'Users',
subtitle: 'Manage Users'
},
{
url: '/apps/vendor',
url: '/apps/roles',
icon: 'tabler-users-group',
title: 'Vendor',
subtitle: 'Kelola Vendor'
title: 'Role Management',
subtitle: 'Permissions'
},
{
url: '/',
@@ -60,10 +60,10 @@ const shortcuts: ShortcutsType[] = [
subtitle: 'User Dashboard'
},
{
url: '/apps/reports',
url: '/pages/account-settings',
icon: 'tabler-settings',
title: 'Laporan',
subtitle: 'Lihat Laporan'
title: 'Settings',
subtitle: 'Account Settings'
}
]
@@ -20,28 +20,28 @@ import { verticalLayoutClasses } from '@layouts/utils/layoutClasses'
// Vars
const shortcuts: ShortcutsType[] = [
{
url: '/apps/inventory/products/list',
icon: 'tabler-box',
title: 'Produk',
subtitle: 'Kelola Produk'
url: '/apps/calendar',
icon: 'tabler-calendar',
title: 'Calendar',
subtitle: 'Appointments'
},
{
url: '/apps/purchase/purchase-orders',
url: '/apps/invoice/list',
icon: 'tabler-file-dollar',
title: 'Pembelian',
subtitle: 'Kelola Pembelian'
title: 'Invoice App',
subtitle: 'Manage Accounts'
},
{
url: '/apps/user/list',
icon: 'tabler-user',
title: 'Pengguna',
subtitle: 'Kelola Pengguna'
title: 'Users',
subtitle: 'Manage Users'
},
{
url: '/apps/vendor/list',
url: '/apps/roles',
icon: 'tabler-users-group',
title: 'Vendor',
subtitle: 'Kelola Vendor'
title: 'Role Management',
subtitle: 'Permissions'
},
{
url: '/',
@@ -50,10 +50,10 @@ const shortcuts: ShortcutsType[] = [
subtitle: 'User Dashboard'
},
{
url: '/apps/report',
url: '/pages/account-settings',
icon: 'tabler-settings',
title: 'Laporan',
subtitle: 'Lihat Laporan'
title: 'Settings',
subtitle: 'Account Settings'
}
]
@@ -173,6 +173,7 @@ const VerticalMenu = ({ dictionary, scrollMenu }: Props) => {
</MenuItem> */}
{/* <MenuItem href={`/${locale}/apps/marketing/voucher`}>{dictionary['navigation'].voucher}</MenuItem> */}
<MenuItem href={`/${locale}/apps/marketing/tier`}>{dictionary['navigation'].tiers_text}</MenuItem>
{/* <MenuItem href={`/${locale}/apps/marketing/discount`}>{dictionary['navigation'].discount}</MenuItem> */}
</SubMenu>
<SubMenu label={dictionary['navigation'].inventory} icon={<i className='tabler-salad' />}>
<SubMenu label={dictionary['navigation'].products}>
+2 -1
View File
@@ -137,6 +137,7 @@
"voucher": "Voucher",
"tiers_text": "Tiers",
"games": "Games",
"game_prizes": "Game Prizes"
"game_prizes": "Game Prizes",
"discount": "Discount"
}
}
+2 -1
View File
@@ -137,6 +137,7 @@
"voucher": "Vocher",
"tiers_text": "Tiers",
"games": "Permainan",
"game_prizes": "Hadiah Permainan"
"game_prizes": "Hadiah Permainan",
"Discount": "Diskon"
}
}
+3 -1
View File
@@ -10,6 +10,7 @@ import productRecipeReducer from '@/redux-store/slices/productRecipe'
import organizationReducer from '@/redux-store/slices/organization'
import userReducer from '@/redux-store/slices/user'
import vendorReducer from '@/redux-store/slices/vendor'
import discountReducer from '@/redux-store/slices/discount'
export const store = configureStore({
reducer: {
@@ -21,7 +22,8 @@ export const store = configureStore({
productRecipeReducer,
organizationReducer,
userReducer,
vendorReducer
vendorReducer,
discountReducer
},
middleware: getDefaultMiddleware => getDefaultMiddleware({ serializableCheck: false })
})
+193
View File
@@ -0,0 +1,193 @@
// redux-store/slices/discount.ts
import { CategoryRuleType, CreateDiscountPayload, ProductRuleType } from '@/types/services/discount'
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
// Temporary interfaces for rules before discount is created
interface TempProductRule {
product_id: string
product_name?: string
rule_type: ProductRuleType
quantity: number
free_quantity: number
}
interface TempCategoryRule {
category_id: string
category_name?: string
rule_type: CategoryRuleType
}
interface DiscountState {
discountRequest: CreateDiscountPayload
selectedOutlets: string[]
productRules: TempProductRule[]
categoryRules: TempCategoryRule[]
}
const initialState: DiscountState = {
discountRequest: {
campaign_id: '',
code: '',
name: '',
type: 'percentage',
value: 0,
min_purchase_amount: 0,
min_purchase_qty: 0,
start_date: '',
end_date: '',
usage_limit_per_customer: undefined,
usage_limit_total: undefined,
customer_type: 'all',
is_stackable: false,
priority: 10
},
selectedOutlets: [],
productRules: [],
categoryRules: []
}
const discountSlice = createSlice({
name: 'discount',
initialState,
reducers: {
// Set entire discount object
setDiscount: (state, action: PayloadAction<CreateDiscountPayload>) => {
state.discountRequest = action.payload
},
// Set individual field
setDiscountField: (state, action: PayloadAction<{ field: keyof CreateDiscountPayload; value: any }>) => {
const { field, value } = action.payload
state.discountRequest[field] = value as never
},
// Reset discount to initial state
resetDiscount: state => {
state.discountRequest = initialState.discountRequest
state.selectedOutlets = []
state.productRules = []
state.categoryRules = []
},
// Outlet management
setDiscountOutlets: (state, action: PayloadAction<string[]>) => {
state.selectedOutlets = action.payload
},
addDiscountOutlet: (state, action: PayloadAction<string>) => {
if (!state.selectedOutlets.includes(action.payload)) {
state.selectedOutlets.push(action.payload)
}
},
removeDiscountOutlet: (state, action: PayloadAction<string>) => {
state.selectedOutlets = state.selectedOutlets.filter(id => id !== action.payload)
},
// Product rules management
setProductRules: (state, action: PayloadAction<TempProductRule[]>) => {
state.productRules = action.payload
},
addProductRule: (state, action: PayloadAction<TempProductRule>) => {
state.productRules.push(action.payload)
},
updateProductRule: (state, action: PayloadAction<{ index: number; rule: TempProductRule }>) => {
const { index, rule } = action.payload
if (index >= 0 && index < state.productRules.length) {
state.productRules[index] = rule
}
},
removeProductRule: (state, action: PayloadAction<number>) => {
state.productRules = state.productRules.filter((_, index) => index !== action.payload)
},
clearProductRules: state => {
state.productRules = []
},
// Category rules management
setCategoryRules: (state, action: PayloadAction<TempCategoryRule[]>) => {
state.categoryRules = action.payload
},
addCategoryRule: (state, action: PayloadAction<TempCategoryRule>) => {
state.categoryRules.push(action.payload)
},
updateCategoryRule: (state, action: PayloadAction<{ index: number; rule: TempCategoryRule }>) => {
const { index, rule } = action.payload
if (index >= 0 && index < state.categoryRules.length) {
state.categoryRules[index] = rule
}
},
removeCategoryRule: (state, action: PayloadAction<number>) => {
state.categoryRules = state.categoryRules.filter((_, index) => index !== action.payload)
},
clearCategoryRules: state => {
state.categoryRules = []
},
// Bulk operations
clearAllRules: state => {
state.productRules = []
state.categoryRules = []
},
// Set multiple fields at once
setDiscountFields: (state, action: PayloadAction<Partial<CreateDiscountPayload>>) => {
state.discountRequest = { ...state.discountRequest, ...action.payload }
}
}
})
export const {
setDiscount,
setDiscountField,
resetDiscount,
setDiscountOutlets,
addDiscountOutlet,
removeDiscountOutlet,
setProductRules,
addProductRule,
updateProductRule,
removeProductRule,
clearProductRules,
setCategoryRules,
addCategoryRule,
updateCategoryRule,
removeCategoryRule,
clearCategoryRules,
clearAllRules,
setDiscountFields
} = discountSlice.actions
export default discountSlice.reducer
// Selectors (optional but recommended)
export const selectDiscountRequest = (state: { discountReducer: DiscountState }) =>
state.discountReducer.discountRequest
export const selectSelectedOutlets = (state: { discountReducer: DiscountState }) =>
state.discountReducer.selectedOutlets
export const selectProductRules = (state: { discountReducer: DiscountState }) => state.discountReducer.productRules
export const selectCategoryRules = (state: { discountReducer: DiscountState }) => state.discountReducer.categoryRules
export const selectIsDiscountValid = (state: { discountReducer: DiscountState }) => {
const { discountRequest } = state.discountReducer
return (
discountRequest.campaign_id !== '' &&
discountRequest.code !== '' &&
discountRequest.name !== '' &&
discountRequest.start_date !== '' &&
discountRequest.end_date !== '' &&
(discountRequest.type === 'free_product' || discountRequest.value > 0)
)
}
@@ -1,251 +0,0 @@
import type { PaymentReport } from '@/types/services/analytic'
export class ExcelExportPaymentService {
/**
* Export Payment Method Report to Excel
*/
static async exportPaymentMethodToExcel(paymentData: PaymentReport, filename?: string) {
try {
// Dynamic import untuk xlsx library
const XLSX = await import('xlsx')
// Prepare data untuk Excel
const worksheetData: any[][] = []
// Header dengan report info (baris 1-2)
worksheetData.push(['LAPORAN METODE PEMBAYARAN']) // Row 0 - Main title
worksheetData.push([`Periode: ${paymentData.date_from.split('T')[0]} - ${paymentData.date_to.split('T')[0]}`]) // Row 1 - Period
worksheetData.push([]) // Empty row
// Add Summary Section
worksheetData.push(['RINGKASAN PERIODE']) // Section header
worksheetData.push([]) // Empty row
const summaryData = [
['Total Amount:', `Rp ${paymentData.summary.total_amount.toLocaleString('id-ID')}`],
['Total Orders:', paymentData.summary.total_orders.toString()],
['Total Payments:', paymentData.summary.total_payments.toString()],
['Average Order Value:', `Rp ${paymentData.summary.average_order_value.toLocaleString('id-ID')}`]
]
summaryData.forEach(row => {
worksheetData.push([row[0], row[1]]) // Only 2 columns needed
})
worksheetData.push([]) // Empty row
worksheetData.push([]) // Empty row
// Payment Method Details Section Header
worksheetData.push(['RINCIAN METODE PEMBAYARAN']) // Section header
worksheetData.push([]) // Empty row
// Header row untuk tabel payment method data
const headerRow = ['No', 'Metode Pembayaran', 'Tipe', 'Jumlah Order', 'Total Amount', 'Persentase']
worksheetData.push(headerRow)
// Add payment method data rows
paymentData.data.forEach((payment, index) => {
const rowData = [
index + 1, // No
payment.payment_method_name,
payment.payment_method_type.toUpperCase(),
payment.order_count,
payment.total_amount, // Store as number for Excel formatting
`${(payment.percentage ?? 0).toFixed(1)}%`
]
worksheetData.push(rowData)
})
// Add total row
const totalRow = ['TOTAL', '', '', paymentData.summary.total_orders, paymentData.summary.total_amount, '100.0%']
worksheetData.push(totalRow)
// Create workbook dan worksheet
const workbook = XLSX.utils.book_new()
const worksheet = XLSX.utils.aoa_to_sheet(worksheetData)
// Apply basic formatting
this.applyBasicFormatting(worksheet, worksheetData.length, XLSX)
// Add worksheet ke workbook
XLSX.utils.book_append_sheet(workbook, worksheet, 'Metode Pembayaran')
// Generate filename
const exportFilename = filename || this.generateFilename('Metode_Pembayaran')
// Download file
XLSX.writeFile(workbook, exportFilename)
return { success: true, filename: exportFilename }
} catch (error) {
console.error('Error exporting to Excel:', error)
return { success: false, error: 'Failed to export Excel file' }
}
}
/**
* Apply basic formatting (SheetJS compatible)
*/
private static applyBasicFormatting(worksheet: any, totalRows: number, XLSX: any) {
// Set column widths
const colWidths = [
{ wch: 8 }, // No
{ wch: 25 }, // Metode Pembayaran
{ wch: 12 }, // Tipe
{ wch: 15 }, // Jumlah Order
{ wch: 20 }, // Total Amount
{ wch: 12 } // Persentase
]
worksheet['!cols'] = colWidths
// Set row heights for better spacing
worksheet['!rows'] = [
{ hpt: 30 }, // Title row
{ hpt: 25 }, // Period row
{ hpt: 15 }, // Empty row
{ hpt: 25 }, // Summary header
{ hpt: 15 } // Empty row
]
// Merge cells untuk headers
const merges = [
{ s: { r: 0, c: 0 }, e: { r: 0, c: 5 } }, // Title (span across all columns)
{ s: { r: 1, c: 0 }, e: { r: 1, c: 5 } }, // Period (span across all columns)
{ s: { r: 3, c: 0 }, e: { r: 3, c: 5 } } // Summary header (span across all columns)
]
// Find and add merge for payment method details header
for (let i = 0; i < totalRows; i++) {
const cell = worksheet[XLSX.utils.encode_cell({ r: i, c: 0 })]
if (cell && cell.v === 'RINCIAN METODE PEMBAYARAN') {
merges.push({ s: { r: i, c: 0 }, e: { r: i, c: 5 } }) // Span across all columns
break
}
}
worksheet['!merges'] = merges
// Apply number formatting untuk currency cells
this.applyNumberFormatting(worksheet, totalRows, XLSX)
}
/**
* Apply number formatting for currency and styling
*/
private static applyNumberFormatting(worksheet: any, totalRows: number, XLSX: any) {
// Find table data start (after header row)
let dataStartRow = -1
for (let i = 0; i < totalRows; i++) {
const cell = worksheet[XLSX.utils.encode_cell({ r: i, c: 0 })]
if (cell && cell.v === 'No') {
dataStartRow = i + 1
break
}
}
if (dataStartRow === -1) return
// Count actual data rows (excluding total row)
const dataRowsCount = totalRows - dataStartRow - 1 // -1 for total row
// Apply currency formatting to Total Amount column (column 4 - index 4)
for (let row = dataStartRow; row <= dataStartRow + dataRowsCount; row++) {
// Include total row
const cellAddress = XLSX.utils.encode_cell({ r: row, c: 4 }) // Total Amount column
const cell = worksheet[cellAddress]
if (cell && typeof cell.v === 'number') {
// Apply Indonesian currency format
cell.z = '#,##0'
cell.t = 'n'
}
}
// Apply styling to header row
const headerRowIndex = dataStartRow - 1
for (let col = 0; col < 6; col++) {
const cellAddress = XLSX.utils.encode_cell({ r: headerRowIndex, c: col })
const cell = worksheet[cellAddress]
if (cell) {
// Apply bold formatting (basic approach for SheetJS)
cell.s = {
font: { bold: true },
fill: { fgColor: { rgb: 'F3F4F6' } }, // Light gray background
border: {
bottom: { style: 'medium', color: { rgb: '000000' } }
}
}
}
}
// Apply styling to total row
const totalRowIndex = dataStartRow + dataRowsCount
for (let col = 0; col < 6; col++) {
const cellAddress = XLSX.utils.encode_cell({ r: totalRowIndex, c: col })
const cell = worksheet[cellAddress]
if (cell) {
// Apply bold formatting for total row
cell.s = {
font: { bold: true },
border: {
top: { style: 'medium', color: { rgb: '000000' } }
}
}
}
}
}
/**
* Generate filename with timestamp
*/
private static generateFilename(prefix: string): string {
const now = new Date()
const year = now.getFullYear()
const month = (now.getMonth() + 1).toString().padStart(2, '0')
const day = now.getDate().toString().padStart(2, '0')
const hour = now.getHours().toString().padStart(2, '0')
const minute = now.getMinutes().toString().padStart(2, '0')
return `${prefix}_${year}_${month}_${day}_${hour}${minute}.xlsx`
}
/**
* Export Payment Method data with custom configuration
*/
static async exportCustomPaymentData(
data: any[][],
sheetName: string = 'Payment Method',
filename?: string,
options?: {
colWidths?: { wch: number }[]
merges?: { s: { r: number; c: number }; e: { r: number; c: number } }[]
}
) {
try {
const XLSX = await import('xlsx')
const workbook = XLSX.utils.book_new()
const worksheet = XLSX.utils.aoa_to_sheet(data)
// Apply options
if (options?.colWidths) {
worksheet['!cols'] = options.colWidths
}
if (options?.merges) {
worksheet['!merges'] = options.merges
}
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName)
const exportFilename = filename || this.generateFilename('Payment_Method_Export')
XLSX.writeFile(workbook, exportFilename)
return { success: true, filename: exportFilename }
} catch (error) {
console.error('Error exporting to Excel:', error)
return { success: false, error: 'Failed to export Excel file' }
}
}
}
@@ -1,278 +0,0 @@
// services/excelExportService.ts
import type { ProfitLossReport } from '@/types/services/analytic'
export class ExcelExportProfitLossService {
/**
* Export Profit Loss Report to Excel
*/
static async exportProfitLossToExcel(profitData: ProfitLossReport, filename?: string) {
try {
// Dynamic import untuk xlsx library
const XLSX = await import('xlsx')
// Prepare data untuk Excel
const worksheetData: any[][] = []
// Header dengan company info (baris 1-2) - update data
worksheetData.push(['LABA RUGI']) // Row 0 - Main title
worksheetData.push([`Periode: ${profitData.date_from.split('T')[0]} - ${profitData.date_to.split('T')[0]}`]) // Row 1 - Period
worksheetData.push([]) // Empty row
// Add Summary Section
worksheetData.push(['RINGKASAN PERIODE']) // Section header
worksheetData.push([]) // Empty row
const summaryData = [
['Total Revenue:', `Rp ${profitData.summary.total_revenue.toLocaleString('id-ID')}`],
['Total Cost:', `Rp ${profitData.summary.total_cost.toLocaleString('id-ID')}`],
['Gross Profit:', `Rp ${profitData.summary.gross_profit.toLocaleString('id-ID')}`],
['Gross Profit Margin:', `${profitData.summary.gross_profit_margin.toFixed(1)}%`],
['Total Tax:', `Rp ${profitData.summary.total_tax.toLocaleString('id-ID')}`],
['Total Discount:', `Rp ${profitData.summary.total_discount.toLocaleString('id-ID')}`],
['Net Profit:', `Rp ${profitData.summary.net_profit.toLocaleString('id-ID')}`],
['Net Profit Margin:', `${profitData.summary.net_profit_margin.toFixed(1)}%`],
['Total Orders:', profitData.summary.total_orders.toString()],
['Average Profit:', `Rp ${profitData.summary.average_profit.toLocaleString('id-ID')}`],
['Profitability Ratio:', `${profitData.summary.profitability_ratio.toFixed(1)}%`]
]
summaryData.forEach(row => {
worksheetData.push([row[0], row[1]]) // Only 2 columns needed
})
worksheetData.push([]) // Empty row
worksheetData.push([]) // Empty row
// Daily Data Section Header
worksheetData.push(['RINCIAN HARIAN']) // Section header
worksheetData.push([]) // Empty row
// Prepare date columns - ambil dari daily data
const dateColumns = profitData.data.map(daily => {
const date = new Date(daily.date)
return date.toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short'
})
})
// Header row untuk tabel daily data (hanya tanggal)
const headerRow = ['NO', 'KET', '', ...dateColumns]
worksheetData.push(headerRow)
// Prepare data rows
const rows = this.prepareProfitLossRows(profitData)
// Add data rows ke worksheet (hanya nilai per tanggal)
rows.forEach(row => {
const rowData = [
row.no,
row.label,
':',
...row.values.map(val => val.today) // Store as numbers for better Excel handling
]
worksheetData.push(rowData)
})
// Create workbook dan worksheet
const workbook = XLSX.utils.book_new()
const worksheet = XLSX.utils.aoa_to_sheet(worksheetData)
// Apply basic formatting
this.applyBasicFormatting(worksheet, dateColumns.length, worksheetData.length, XLSX)
// Add worksheet ke workbook
XLSX.utils.book_append_sheet(workbook, worksheet, 'Laba Rugi')
// Generate filename
const exportFilename = filename || this.generateFilename('Laba_Rugi')
// Download file
XLSX.writeFile(workbook, exportFilename)
return { success: true, filename: exportFilename }
} catch (error) {
console.error('Error exporting to Excel:', error)
return { success: false, error: 'Failed to export Excel file' }
}
}
/**
* Prepare profit loss data rows (tanpa percentage)
*/
private static prepareProfitLossRows(profitData: ProfitLossReport) {
return [
{
no: 1,
label: 'TOTAL PENJ',
values: profitData.data.map(daily => ({
today: daily.revenue,
mtd: daily.revenue // TODO: Replace with actual MTD data from API
}))
},
{
no: 2,
label: 'HPP',
values: profitData.data.map(daily => ({
today: daily.cost,
mtd: daily.cost
}))
},
{
no: 3,
label: 'Laba Kotor (1-2)',
values: profitData.data.map(daily => ({
today: daily.gross_profit,
mtd: daily.gross_profit
}))
},
{
no: 4,
label: 'Biaya lain',
values: profitData.data.map(daily => {
const totalCosts = daily.tax + daily.discount
return {
today: totalCosts,
mtd: totalCosts
}
})
},
{
no: 5,
label: 'Laba/Rugi (3-4)',
values: profitData.data.map(daily => ({
today: daily.net_profit,
mtd: daily.net_profit
}))
}
]
}
/**
* Apply basic formatting (SheetJS compatible)
*/
private static applyBasicFormatting(worksheet: any, dateColumnsCount: number, totalRows: number, XLSX: any) {
// Set column widths
const colWidths = [
{ wch: 5 }, // NO
{ wch: 25 }, // KET
{ wch: 3 }, // :
...Array(dateColumnsCount).fill({ wch: 18 })
]
worksheet['!cols'] = colWidths
// Set row heights for better spacing
worksheet['!rows'] = [
{ hpt: 30 }, // Title row
{ hpt: 25 }, // Period row
{ hpt: 15 }, // Empty row
{ hpt: 25 }, // Summary header
{ hpt: 15 } // Empty row
]
// Merge cells untuk headers
const merges = [
{ s: { r: 0, c: 0 }, e: { r: 0, c: dateColumnsCount + 2 } }, // Title
{ s: { r: 1, c: 0 }, e: { r: 1, c: dateColumnsCount + 2 } }, // Period
{ s: { r: 3, c: 0 }, e: { r: 3, c: dateColumnsCount + 2 } } // Summary header
]
// Find and add merge for daily data header
for (let i = 0; i < totalRows; i++) {
const cell = worksheet[XLSX.utils.encode_cell({ r: i, c: 0 })]
if (cell && cell.v === 'RINCIAN HARIAN') {
merges.push({ s: { r: i, c: 0 }, e: { r: i, c: dateColumnsCount + 2 } })
break
}
}
worksheet['!merges'] = merges
// Apply number formatting untuk currency cells
this.applyNumberFormatting(worksheet, totalRows, dateColumnsCount + 3, XLSX)
}
/**
* Apply number formatting for currency
*/
private static applyNumberFormatting(worksheet: any, totalRows: number, totalCols: number, XLSX: any) {
// Find table data start (after "NO" header)
let dataStartRow = -1
for (let i = 0; i < totalRows; i++) {
const cell = worksheet[XLSX.utils.encode_cell({ r: i, c: 0 })]
if (cell && cell.v === 'NO') {
dataStartRow = i + 1
break
}
}
if (dataStartRow === -1) return
// Apply currency formatting to data cells (starting from column 3 - after NO, KET, :)
for (let row = dataStartRow; row < dataStartRow + 5; row++) {
// 5 data rows
for (let col = 3; col < totalCols; col++) {
const cellAddress = XLSX.utils.encode_cell({ r: row, c: col })
const cell = worksheet[cellAddress]
if (cell && typeof cell.v === 'number') {
// Apply Indonesian currency format
cell.z = '#,##0'
cell.t = 'n'
}
}
}
}
/**
* Generate filename with timestamp
*/
private static generateFilename(prefix: string): string {
const now = new Date()
const year = now.getFullYear()
const month = (now.getMonth() + 1).toString().padStart(2, '0')
const day = now.getDate().toString().padStart(2, '0')
const hour = now.getHours().toString().padStart(2, '0')
const minute = now.getMinutes().toString().padStart(2, '0')
return `${prefix}_${year}_${month}_${day}_${hour}${minute}.xlsx`
}
/**
* Export any data to Excel with custom configuration
*/
static async exportToExcel(
data: any[][],
sheetName: string = 'Sheet1',
filename?: string,
options?: {
colWidths?: { wch: number }[]
merges?: { s: { r: number; c: number }; e: { r: number; c: number } }[]
}
) {
try {
const XLSX = await import('xlsx')
const workbook = XLSX.utils.book_new()
const worksheet = XLSX.utils.aoa_to_sheet(data)
// Apply options
if (options?.colWidths) {
worksheet['!cols'] = options.colWidths
}
if (options?.merges) {
worksheet['!merges'] = options.merges
}
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName)
const exportFilename = filename || this.generateFilename('Export')
XLSX.writeFile(workbook, exportFilename)
return { success: true, filename: exportFilename }
} catch (error) {
console.error('Error exporting to Excel:', error)
return { success: false, error: 'Failed to export Excel file' }
}
}
}
@@ -1,335 +0,0 @@
// services/excelExportSalesOrderService.ts
import type { SalesReport } from '@/types/services/analytic'
export class ExcelExportSalesOrderService {
/**
* Export Sales Order Report to Excel
*/
static async exportSalesOrderToExcel(salesData: SalesReport, filename?: string) {
try {
// Dynamic import untuk xlsx library
const XLSX = await import('xlsx')
// Prepare data untuk Excel
const worksheetData: any[][] = []
// Header dengan report info (baris 1-2)
worksheetData.push(['LAPORAN PESANAN PENJUALAN']) // Row 0 - Main title
worksheetData.push([`Periode: ${salesData.date_from.split('T')[0]} - ${salesData.date_to.split('T')[0]}`]) // Row 1 - Period
worksheetData.push([]) // Empty row
// Add Summary Section
worksheetData.push(['RINGKASAN PERIODE']) // Section header
worksheetData.push([]) // Empty row
const summaryData = [
['Total Sales:', `Rp ${salesData.summary.total_sales.toLocaleString('id-ID')}`],
['Total Orders:', salesData.summary.total_orders.toString()],
['Total Items:', salesData.summary.total_items.toString()],
['Average Order Value:', `Rp ${salesData.summary.average_order_value.toLocaleString('id-ID')}`],
['Total Tax:', `Rp ${salesData.summary.total_tax.toLocaleString('id-ID')}`],
['Total Discount:', `Rp ${salesData.summary.total_discount.toLocaleString('id-ID')}`],
['Net Sales:', `Rp ${salesData.summary.net_sales.toLocaleString('id-ID')}`]
]
summaryData.forEach(row => {
worksheetData.push([row[0], row[1]]) // Only 2 columns needed
})
worksheetData.push([]) // Empty row
worksheetData.push([]) // Empty row
// Daily Sales Section Header
worksheetData.push(['RINCIAN HARIAN']) // Section header
worksheetData.push([]) // Empty row
// Header row untuk tabel daily sales data
const headerRow = ['No', 'Tanggal', 'Penjualan', 'Pesanan', 'Qty', 'Pajak', 'Diskon', 'Pendapatan']
worksheetData.push(headerRow)
// Add daily sales data rows
salesData.data?.forEach((dailySales, index) => {
const rowData = [
index + 1, // No
this.formatDate(dailySales.date),
dailySales.sales, // Store as number for Excel formatting
dailySales.orders,
dailySales.items,
dailySales.tax, // Store as number for Excel formatting
dailySales.discount, // Store as number for Excel formatting
dailySales.net_sales // Store as number for Excel formatting
]
worksheetData.push(rowData)
})
// Add total row
const totalRow = [
'TOTAL',
'',
salesData.summary.total_sales,
salesData.summary.total_orders,
salesData.summary.total_items,
salesData.summary.total_tax,
salesData.summary.total_discount,
salesData.summary.net_sales
]
worksheetData.push(totalRow)
// Create workbook dan worksheet
const workbook = XLSX.utils.book_new()
const worksheet = XLSX.utils.aoa_to_sheet(worksheetData)
// Apply basic formatting
this.applyBasicFormatting(worksheet, worksheetData.length, XLSX)
// Add worksheet ke workbook
XLSX.utils.book_append_sheet(workbook, worksheet, 'Pesanan Penjualan')
// Generate filename
const exportFilename = filename || this.generateFilename('Pesanan_Penjualan')
// Download file
XLSX.writeFile(workbook, exportFilename)
return { success: true, filename: exportFilename }
} catch (error) {
console.error('Error exporting to Excel:', error)
return { success: false, error: 'Failed to export Excel file' }
}
}
/**
* Apply basic formatting (SheetJS compatible)
*/
private static applyBasicFormatting(worksheet: any, totalRows: number, XLSX: any) {
// Set column widths
const colWidths = [
{ wch: 8 }, // No
{ wch: 15 }, // Tanggal
{ wch: 18 }, // Penjualan
{ wch: 12 }, // Pesanan
{ wch: 10 }, // Qty
{ wch: 15 }, // Pajak
{ wch: 15 }, // Diskon
{ wch: 18 } // Pendapatan
]
worksheet['!cols'] = colWidths
// Set row heights for better spacing
worksheet['!rows'] = [
{ hpt: 30 }, // Title row
{ hpt: 25 }, // Period row
{ hpt: 15 }, // Empty row
{ hpt: 25 }, // Summary header
{ hpt: 15 } // Empty row
]
// Merge cells untuk headers
const merges = [
{ s: { r: 0, c: 0 }, e: { r: 0, c: 7 } }, // Title (span across all columns)
{ s: { r: 1, c: 0 }, e: { r: 1, c: 7 } }, // Period (span across all columns)
{ s: { r: 3, c: 0 }, e: { r: 3, c: 7 } } // Summary header (span across all columns)
]
// Find and add merge for daily sales header
for (let i = 0; i < totalRows; i++) {
const cell = worksheet[XLSX.utils.encode_cell({ r: i, c: 0 })]
if (cell && cell.v === 'RINCIAN HARIAN') {
merges.push({ s: { r: i, c: 0 }, e: { r: i, c: 7 } }) // Span across all columns
break
}
}
worksheet['!merges'] = merges
// Apply number formatting untuk currency cells
this.applyNumberFormatting(worksheet, totalRows, XLSX)
}
/**
* Apply number formatting for currency and styling
*/
private static applyNumberFormatting(worksheet: any, totalRows: number, XLSX: any) {
// Find table data start (after header row)
let dataStartRow = -1
for (let i = 0; i < totalRows; i++) {
const cell = worksheet[XLSX.utils.encode_cell({ r: i, c: 0 })]
if (cell && cell.v === 'No') {
dataStartRow = i + 1
break
}
}
if (dataStartRow === -1) return
// Count actual data rows (excluding total row)
const dataRowsCount = totalRows - dataStartRow - 1 // -1 for total row
// Apply currency formatting to currency columns (columns 2, 5, 6, 7 - Penjualan, Pajak, Diskon, Pendapatan)
const currencyColumns = [2, 5, 6, 7]
for (let row = dataStartRow; row <= dataStartRow + dataRowsCount; row++) {
// Include total row
currencyColumns.forEach(col => {
const cellAddress = XLSX.utils.encode_cell({ r: row, c: col })
const cell = worksheet[cellAddress]
if (cell && typeof cell.v === 'number') {
// Apply Indonesian currency format
cell.z = '#,##0'
cell.t = 'n'
}
})
}
// Apply styling to header row
const headerRowIndex = dataStartRow - 1
for (let col = 0; col < 8; col++) {
const cellAddress = XLSX.utils.encode_cell({ r: headerRowIndex, c: col })
const cell = worksheet[cellAddress]
if (cell) {
// Apply bold formatting (basic approach for SheetJS)
cell.s = {
font: { bold: true },
fill: { fgColor: { rgb: 'F3F4F6' } }, // Light gray background
border: {
bottom: { style: 'medium', color: { rgb: '000000' } }
}
}
}
}
// Apply styling to total row
const totalRowIndex = dataStartRow + dataRowsCount
for (let col = 0; col < 8; col++) {
const cellAddress = XLSX.utils.encode_cell({ r: totalRowIndex, c: col })
const cell = worksheet[cellAddress]
if (cell) {
// Apply bold formatting for total row
cell.s = {
font: { bold: true },
border: {
top: { style: 'medium', color: { rgb: '000000' } }
}
}
}
}
}
/**
* Format date for display
*/
private static formatDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('id-ID', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
}
/**
* Generate filename with timestamp
*/
private static generateFilename(prefix: string): string {
const now = new Date()
const year = now.getFullYear()
const month = (now.getMonth() + 1).toString().padStart(2, '0')
const day = now.getDate().toString().padStart(2, '0')
const hour = now.getHours().toString().padStart(2, '0')
const minute = now.getMinutes().toString().padStart(2, '0')
return `${prefix}_${year}_${month}_${day}_${hour}${minute}.xlsx`
}
/**
* Export Sales Order data with custom configuration
*/
static async exportCustomSalesOrderData(
salesData: SalesReport,
options?: {
includeSummary?: boolean
includeItemsColumn?: boolean
customFilename?: string
sheetName?: string
}
) {
try {
const XLSX = await import('xlsx')
const worksheetData: any[][] = []
// Always include title and period
worksheetData.push(['LAPORAN PESANAN PENJUALAN'])
worksheetData.push([`Periode: ${salesData.date_from.split('T')[0]} - ${salesData.date_to.split('T')[0]}`])
worksheetData.push([])
// Optional summary
if (options?.includeSummary !== false) {
worksheetData.push(['RINGKASAN PERIODE'])
worksheetData.push([])
const summaryData = [
['Total Sales:', `Rp ${salesData.summary.total_sales.toLocaleString('id-ID')}`],
['Total Orders:', salesData.summary.total_orders.toString()],
['Total Items:', salesData.summary.total_items.toString()],
['Net Sales:', `Rp ${salesData.summary.net_sales.toLocaleString('id-ID')}`]
]
summaryData.forEach(row => worksheetData.push([row[0], row[1]]))
worksheetData.push([])
worksheetData.push([])
}
worksheetData.push(['RINCIAN HARIAN'])
worksheetData.push([])
// Header row based on options
const headerRow =
options?.includeItemsColumn !== false
? ['No', 'Tanggal', 'Penjualan', 'Pesanan', 'Qty', 'Pajak', 'Diskon', 'Pendapatan']
: ['No', 'Tanggal', 'Penjualan', 'Pesanan', 'Pajak', 'Diskon', 'Pendapatan']
worksheetData.push(headerRow)
// Add daily data based on options
salesData.data?.forEach((dailySales, index) => {
const rowData =
options?.includeItemsColumn !== false
? [
index + 1,
this.formatDate(dailySales.date),
dailySales.sales,
dailySales.orders,
dailySales.items,
dailySales.tax,
dailySales.discount,
dailySales.net_sales
]
: [
index + 1,
this.formatDate(dailySales.date),
dailySales.sales,
dailySales.orders,
dailySales.tax,
dailySales.discount,
dailySales.net_sales
]
worksheetData.push(rowData)
})
const workbook = XLSX.utils.book_new()
const worksheet = XLSX.utils.aoa_to_sheet(worksheetData)
this.applyBasicFormatting(worksheet, worksheetData.length, XLSX)
const sheetName = options?.sheetName || 'Pesanan Penjualan'
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName)
const exportFilename = options?.customFilename || this.generateFilename('Custom_Sales_Order')
XLSX.writeFile(workbook, exportFilename)
return { success: true, filename: exportFilename }
} catch (error) {
console.error('Error exporting custom sales order data to Excel:', error)
return { success: false, error: 'Failed to export Excel file' }
}
}
}
@@ -1,325 +0,0 @@
// services/excelExportCategoryService.ts
import type { CategoryReport } from '@/types/services/analytic'
export class ExcelExportSalesProductCategoryService {
/**
* Export Category Sales Report to Excel
*/
static async exportCategorySalesToExcel(categoryData: CategoryReport, filename?: string) {
try {
// Dynamic import untuk xlsx library
const XLSX = await import('xlsx')
// Prepare data untuk Excel
const worksheetData: any[][] = []
// Header dengan report info (baris 1-2)
worksheetData.push(['LAPORAN PENJUALAN KATEGORI']) // Row 0 - Main title
worksheetData.push([`Periode: ${categoryData.date_from.split('T')[0]} - ${categoryData.date_to.split('T')[0]}`]) // Row 1 - Period
worksheetData.push([]) // Empty row
// Calculate summary
const categorySummary = {
totalRevenue: categoryData.data?.reduce((sum, item) => sum + (item?.total_revenue || 0), 0) || 0,
orderCount: categoryData.data?.reduce((sum, item) => sum + (item?.order_count || 0), 0) || 0,
productCount: categoryData.data?.reduce((sum, item) => sum + (item?.product_count || 0), 0) || 0,
totalQuantity: categoryData.data?.reduce((sum, item) => sum + (item?.total_quantity || 0), 0) || 0,
totalCategories: categoryData.data?.length || 0
}
// Add Summary Section
worksheetData.push(['RINGKASAN PERIODE']) // Section header
worksheetData.push([]) // Empty row
const summaryData = [
['Total Kategori:', categorySummary.totalCategories.toString()],
['Total Produk:', categorySummary.productCount.toString()],
['Total Quantity:', categorySummary.totalQuantity.toString()],
['Total Orders:', categorySummary.orderCount.toString()],
['Total Revenue:', `Rp ${categorySummary.totalRevenue.toLocaleString('id-ID')}`]
]
summaryData.forEach(row => {
worksheetData.push([row[0], row[1]]) // Only 2 columns needed
})
worksheetData.push([]) // Empty row
worksheetData.push([]) // Empty row
// Category Details Section Header
worksheetData.push(['RINCIAN KATEGORI']) // Section header
worksheetData.push([]) // Empty row
// Header row untuk tabel category data
const headerRow = ['No', 'Nama', 'Total Produk', 'Qty', 'Total Orders', 'Pendapatan']
worksheetData.push(headerRow)
// Add category data rows
categoryData.data?.forEach((category, index) => {
const rowData = [
index + 1, // No
category.category_name,
category.product_count,
category.total_quantity,
category.order_count,
category.total_revenue // Store as number for Excel formatting
]
worksheetData.push(rowData)
})
// Add total row
const totalRow = [
'TOTAL',
'',
categorySummary.productCount,
categorySummary.totalQuantity,
categorySummary.orderCount,
categorySummary.totalRevenue
]
worksheetData.push(totalRow)
// Create workbook dan worksheet
const workbook = XLSX.utils.book_new()
const worksheet = XLSX.utils.aoa_to_sheet(worksheetData)
// Apply basic formatting
this.applyBasicFormatting(worksheet, worksheetData.length, XLSX)
// Add worksheet ke workbook
XLSX.utils.book_append_sheet(workbook, worksheet, 'Penjualan Kategori')
// Generate filename
const exportFilename = filename || this.generateFilename('Penjualan_Kategori')
// Download file
XLSX.writeFile(workbook, exportFilename)
return { success: true, filename: exportFilename }
} catch (error) {
console.error('Error exporting to Excel:', error)
return { success: false, error: 'Failed to export Excel file' }
}
}
/**
* Apply basic formatting (SheetJS compatible)
*/
private static applyBasicFormatting(worksheet: any, totalRows: number, XLSX: any) {
// Set column widths
const colWidths = [
{ wch: 8 }, // No
{ wch: 30 }, // Nama
{ wch: 15 }, // Total Produk
{ wch: 12 }, // Qty
{ wch: 15 }, // Total Orders
{ wch: 20 } // Pendapatan
]
worksheet['!cols'] = colWidths
// Set row heights for better spacing
worksheet['!rows'] = [
{ hpt: 30 }, // Title row
{ hpt: 25 }, // Period row
{ hpt: 15 }, // Empty row
{ hpt: 25 }, // Summary header
{ hpt: 15 } // Empty row
]
// Merge cells untuk headers
const merges = [
{ s: { r: 0, c: 0 }, e: { r: 0, c: 5 } }, // Title (span across all columns)
{ s: { r: 1, c: 0 }, e: { r: 1, c: 5 } }, // Period (span across all columns)
{ s: { r: 3, c: 0 }, e: { r: 3, c: 5 } } // Summary header (span across all columns)
]
// Find and add merge for category details header
for (let i = 0; i < totalRows; i++) {
const cell = worksheet[XLSX.utils.encode_cell({ r: i, c: 0 })]
if (cell && cell.v === 'RINCIAN KATEGORI') {
merges.push({ s: { r: i, c: 0 }, e: { r: i, c: 5 } }) // Span across all columns
break
}
}
worksheet['!merges'] = merges
// Apply number formatting untuk currency cells
this.applyNumberFormatting(worksheet, totalRows, XLSX)
}
/**
* Apply number formatting for currency and styling
*/
private static applyNumberFormatting(worksheet: any, totalRows: number, XLSX: any) {
// Find table data start (after header row)
let dataStartRow = -1
for (let i = 0; i < totalRows; i++) {
const cell = worksheet[XLSX.utils.encode_cell({ r: i, c: 0 })]
if (cell && cell.v === 'No') {
dataStartRow = i + 1
break
}
}
if (dataStartRow === -1) return
// Count actual data rows (excluding total row)
const dataRowsCount = totalRows - dataStartRow - 1 // -1 for total row
// Apply currency formatting to Pendapatan column (column 5 - index 5)
for (let row = dataStartRow; row <= dataStartRow + dataRowsCount; row++) {
// Include total row
const cellAddress = XLSX.utils.encode_cell({ r: row, c: 5 }) // Pendapatan column
const cell = worksheet[cellAddress]
if (cell && typeof cell.v === 'number') {
// Apply Indonesian currency format
cell.z = '#,##0'
cell.t = 'n'
}
}
// Apply styling to header row
const headerRowIndex = dataStartRow - 1
for (let col = 0; col < 6; col++) {
const cellAddress = XLSX.utils.encode_cell({ r: headerRowIndex, c: col })
const cell = worksheet[cellAddress]
if (cell) {
// Apply bold formatting (basic approach for SheetJS)
cell.s = {
font: { bold: true },
fill: { fgColor: { rgb: 'F3F4F6' } }, // Light gray background
border: {
bottom: { style: 'medium', color: { rgb: '000000' } }
}
}
}
}
// Apply styling to total row
const totalRowIndex = dataStartRow + dataRowsCount
for (let col = 0; col < 6; col++) {
const cellAddress = XLSX.utils.encode_cell({ r: totalRowIndex, c: col })
const cell = worksheet[cellAddress]
if (cell) {
// Apply bold formatting for total row
cell.s = {
font: { bold: true },
border: {
top: { style: 'medium', color: { rgb: '000000' } }
}
}
}
}
}
/**
* Generate filename with timestamp
*/
private static generateFilename(prefix: string): string {
const now = new Date()
const year = now.getFullYear()
const month = (now.getMonth() + 1).toString().padStart(2, '0')
const day = now.getDate().toString().padStart(2, '0')
const hour = now.getHours().toString().padStart(2, '0')
const minute = now.getMinutes().toString().padStart(2, '0')
return `${prefix}_${year}_${month}_${day}_${hour}${minute}.xlsx`
}
/**
* Export Category Sales data with custom configuration
*/
static async exportCustomCategoryData(
categoryData: CategoryReport,
options?: {
includeSummary?: boolean
includeOrderCount?: boolean
customFilename?: string
sheetName?: string
}
) {
try {
const XLSX = await import('xlsx')
const worksheetData: any[][] = []
// Always include title and period
worksheetData.push(['LAPORAN PENJUALAN KATEGORI'])
worksheetData.push([`Periode: ${categoryData.date_from.split('T')[0]} - ${categoryData.date_to.split('T')[0]}`])
worksheetData.push([])
// Optional summary
if (options?.includeSummary !== false) {
const categorySummary = {
totalRevenue: categoryData.data?.reduce((sum, item) => sum + (item?.total_revenue || 0), 0) || 0,
orderCount: categoryData.data?.reduce((sum, item) => sum + (item?.order_count || 0), 0) || 0,
productCount: categoryData.data?.reduce((sum, item) => sum + (item?.product_count || 0), 0) || 0,
totalQuantity: categoryData.data?.reduce((sum, item) => sum + (item?.total_quantity || 0), 0) || 0,
totalCategories: categoryData.data?.length || 0
}
worksheetData.push(['RINGKASAN PERIODE'])
worksheetData.push([])
const summaryData = [
['Total Kategori:', categorySummary.totalCategories.toString()],
['Total Produk:', categorySummary.productCount.toString()],
['Total Quantity:', categorySummary.totalQuantity.toString()],
['Total Revenue:', `Rp ${categorySummary.totalRevenue.toLocaleString('id-ID')}`]
]
summaryData.forEach(row => worksheetData.push([row[0], row[1]]))
worksheetData.push([])
worksheetData.push([])
}
worksheetData.push(['RINCIAN KATEGORI'])
worksheetData.push([])
// Header row based on options
const headerRow =
options?.includeOrderCount !== false
? ['No', 'Nama', 'Total Produk', 'Qty', 'Total Orders', 'Pendapatan']
: ['No', 'Nama', 'Total Produk', 'Qty', 'Pendapatan']
worksheetData.push(headerRow)
// Add category data based on options
categoryData.data?.forEach((category, index) => {
const rowData =
options?.includeOrderCount !== false
? [
index + 1,
category.category_name,
category.product_count,
category.total_quantity,
category.order_count,
category.total_revenue
]
: [
index + 1,
category.category_name,
category.product_count,
category.total_quantity,
category.total_revenue
]
worksheetData.push(rowData)
})
const workbook = XLSX.utils.book_new()
const worksheet = XLSX.utils.aoa_to_sheet(worksheetData)
this.applyBasicFormatting(worksheet, worksheetData.length, XLSX)
const sheetName = options?.sheetName || 'Penjualan Kategori'
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName)
const exportFilename = options?.customFilename || this.generateFilename('Custom_Category_Sales')
XLSX.writeFile(workbook, exportFilename)
return { success: true, filename: exportFilename }
} catch (error) {
console.error('Error exporting custom category data to Excel:', error)
return { success: false, error: 'Failed to export Excel file' }
}
}
}
@@ -1,414 +0,0 @@
// services/excelExportProductService.ts
import type { ProductSalesReport } from '@/types/services/analytic'
export class ExcelExportSalesProductService {
/**
* Export Product Sales Report to Excel
*/
static async exportProductSalesToExcel(productData: ProductSalesReport, filename?: string) {
try {
// Dynamic import untuk xlsx library
const XLSX = await import('xlsx')
// Prepare data untuk Excel
const worksheetData: any[][] = []
// Header dengan report info (baris 1-2)
worksheetData.push(['LAPORAN PENJUALAN PRODUK']) // Row 0 - Main title
worksheetData.push([`Periode: ${productData.date_from.split('T')[0]} - ${productData.date_to.split('T')[0]}`]) // Row 1 - Period
worksheetData.push([]) // Empty row
// Calculate summary
const productSummary = {
totalQuantitySold: productData.data?.reduce((sum, item) => sum + (item?.quantity_sold || 0), 0) || 0,
totalRevenue: productData.data?.reduce((sum, item) => sum + (item?.revenue || 0), 0) || 0,
totalOrders: productData.data?.reduce((sum, item) => sum + (item?.order_count || 0), 0) || 0,
averageRevenue: 0
}
productSummary.averageRevenue =
productSummary.totalQuantitySold > 0 ? productSummary.totalRevenue / productSummary.totalQuantitySold : 0
// Add Summary Section
worksheetData.push(['RINGKASAN PERIODE']) // Section header
worksheetData.push([]) // Empty row
const summaryData = [
['Total Quantity Sold:', productSummary.totalQuantitySold.toString()],
['Total Orders:', productSummary.totalOrders.toString()],
['Total Revenue:', `Rp ${productSummary.totalRevenue.toLocaleString('id-ID')}`],
['Average Revenue per Item:', `Rp ${productSummary.averageRevenue.toLocaleString('id-ID')}`]
]
summaryData.forEach(row => {
worksheetData.push([row[0], row[1]]) // Only 2 columns needed
})
worksheetData.push([]) // Empty row
worksheetData.push([]) // Empty row
// Product Details Section Header
worksheetData.push(['RINCIAN PRODUK']) // Section header
worksheetData.push([]) // Empty row
// Group products by category
const groupedProducts =
productData.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[]>
) || {}
// Header row untuk tabel product data
const headerRow = ['Kategori', 'Produk', 'Qty', 'Order', 'Pendapatan', 'Rata Rata']
worksheetData.push(headerRow)
// Add grouped products data
Object.keys(groupedProducts)
.sort()
.forEach(categoryName => {
const categoryProducts = groupedProducts[categoryName]
// Category header row
worksheetData.push([categoryName.toUpperCase(), '', '', '', '', ''])
// Category products
categoryProducts.forEach(item => {
const rowData = [
'', // Empty for category column (indented effect)
item.product_name,
item.quantity_sold,
item.order_count || 0,
item.revenue, // Store as number for Excel formatting
item.average_price // Store as number for Excel formatting
]
worksheetData.push(rowData)
})
// Category subtotal
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)
const categorySubtotalRow = [
`Subtotal ${categoryName}`,
'',
categoryTotalQty,
categoryTotalOrders,
categoryTotalRevenue,
''
]
worksheetData.push(categorySubtotalRow)
worksheetData.push([]) // Empty row between categories
})
// Grand total
const grandTotalRow = [
'TOTAL KESELURUHAN',
'',
productSummary.totalQuantitySold,
productSummary.totalOrders,
productSummary.totalRevenue,
''
]
worksheetData.push(grandTotalRow)
// Create workbook dan worksheet
const workbook = XLSX.utils.book_new()
const worksheet = XLSX.utils.aoa_to_sheet(worksheetData)
// Apply basic formatting
this.applyBasicFormatting(worksheet, worksheetData.length, XLSX)
// Add worksheet ke workbook
XLSX.utils.book_append_sheet(workbook, worksheet, 'Penjualan Produk')
// Generate filename
const exportFilename = filename || this.generateFilename('Penjualan_Produk')
// Download file
XLSX.writeFile(workbook, exportFilename)
return { success: true, filename: exportFilename }
} catch (error) {
console.error('Error exporting to Excel:', error)
return { success: false, error: 'Failed to export Excel file' }
}
}
/**
* Apply basic formatting (SheetJS compatible)
*/
private static applyBasicFormatting(worksheet: any, totalRows: number, XLSX: any) {
// Set column widths
const colWidths = [
{ wch: 25 }, // Kategori
{ wch: 40 }, // Produk
{ wch: 12 }, // Qty
{ wch: 12 }, // Order
{ wch: 20 }, // Pendapatan
{ wch: 18 } // Rata Rata
]
worksheet['!cols'] = colWidths
// Set row heights for better spacing
worksheet['!rows'] = [
{ hpt: 30 }, // Title row
{ hpt: 25 }, // Period row
{ hpt: 15 }, // Empty row
{ hpt: 25 }, // Summary header
{ hpt: 15 } // Empty row
]
// Merge cells untuk headers
const merges = [
{ s: { r: 0, c: 0 }, e: { r: 0, c: 5 } }, // Title (span across all columns)
{ s: { r: 1, c: 0 }, e: { r: 1, c: 5 } }, // Period (span across all columns)
{ s: { r: 3, c: 0 }, e: { r: 3, c: 5 } } // Summary header (span across all columns)
]
// Find and add merge for product details header
for (let i = 0; i < totalRows; i++) {
const cell = worksheet[XLSX.utils.encode_cell({ r: i, c: 0 })]
if (cell && cell.v === 'RINCIAN PRODUK') {
merges.push({ s: { r: i, c: 0 }, e: { r: i, c: 5 } }) // Span across all columns
break
}
}
worksheet['!merges'] = merges
// Apply number formatting untuk currency cells
this.applyNumberFormatting(worksheet, totalRows, XLSX)
}
/**
* Apply number formatting for currency and styling
*/
private static applyNumberFormatting(worksheet: any, totalRows: number, XLSX: any) {
// Find table data start (after header row)
let dataStartRow = -1
for (let i = 0; i < totalRows; i++) {
const cell = worksheet[XLSX.utils.encode_cell({ r: i, c: 0 })]
if (cell && cell.v === 'Kategori') {
dataStartRow = i + 1
break
}
}
if (dataStartRow === -1) return
// Apply currency formatting to Pendapatan and Rata Rata columns (columns 4 and 5)
for (let row = dataStartRow; row < totalRows; row++) {
// Pendapatan column (index 4)
const revenueCell = worksheet[XLSX.utils.encode_cell({ r: row, c: 4 })]
if (revenueCell && typeof revenueCell.v === 'number') {
revenueCell.z = '#,##0'
revenueCell.t = 'n'
}
// Rata Rata column (index 5)
const avgCell = worksheet[XLSX.utils.encode_cell({ r: row, c: 5 })]
if (avgCell && typeof avgCell.v === 'number') {
avgCell.z = '#,##0'
avgCell.t = 'n'
}
}
// Apply styling to header row
const headerRowIndex = dataStartRow - 1
for (let col = 0; col < 6; col++) {
const cellAddress = XLSX.utils.encode_cell({ r: headerRowIndex, c: col })
const cell = worksheet[cellAddress]
if (cell) {
// Apply bold formatting (basic approach for SheetJS)
cell.s = {
font: { bold: true },
fill: { fgColor: { rgb: 'F3F4F6' } }, // Light gray background
border: {
bottom: { style: 'medium', color: { rgb: '000000' } }
}
}
}
}
// Apply styling to category headers and totals
for (let row = dataStartRow; row < totalRows; row++) {
const cell = worksheet[XLSX.utils.encode_cell({ r: row, c: 0 })]
if (cell && cell.v) {
const cellValue = cell.v.toString()
// Style category headers (uppercase text without "Subtotal" or "TOTAL")
if (
cellValue === cellValue.toUpperCase() &&
!cellValue.includes('Subtotal') &&
!cellValue.includes('TOTAL') &&
cellValue.length > 0
) {
for (let col = 0; col < 6; col++) {
const categoryCellAddress = XLSX.utils.encode_cell({ r: row, c: col })
const categoryCell = worksheet[categoryCellAddress]
if (categoryCell) {
categoryCell.s = {
font: { bold: true, color: { rgb: '36175E' } },
fill: { fgColor: { rgb: 'F8F8F8' } }
}
}
}
}
// Style subtotal and total rows
if (cellValue.startsWith('Subtotal') || cellValue.startsWith('TOTAL')) {
for (let col = 0; col < 6; col++) {
const totalCellAddress = XLSX.utils.encode_cell({ r: row, c: col })
const totalCell = worksheet[totalCellAddress]
if (totalCell) {
totalCell.s = {
font: { bold: true },
border: {
top: { style: 'medium', color: { rgb: '000000' } }
}
}
}
}
}
}
}
}
/**
* Generate filename with timestamp
*/
private static generateFilename(prefix: string): string {
const now = new Date()
const year = now.getFullYear()
const month = (now.getMonth() + 1).toString().padStart(2, '0')
const day = now.getDate().toString().padStart(2, '0')
const hour = now.getHours().toString().padStart(2, '0')
const minute = now.getMinutes().toString().padStart(2, '0')
return `${prefix}_${year}_${month}_${day}_${hour}${minute}.xlsx`
}
/**
* Export Product Sales data with custom configuration
*/
static async exportCustomProductData(
productData: ProductSalesReport,
options?: {
includeSummary?: boolean
customFilename?: string
sheetName?: string
groupByCategory?: boolean
}
) {
try {
const XLSX = await import('xlsx')
const worksheetData: any[][] = []
// Always include title and period
worksheetData.push(['LAPORAN PENJUALAN PRODUK'])
worksheetData.push([`Periode: ${productData.date_from.split('T')[0]} - ${productData.date_to.split('T')[0]}`])
worksheetData.push([])
// Optional summary
if (options?.includeSummary !== false) {
const productSummary = {
totalQuantitySold: productData.data?.reduce((sum, item) => sum + (item?.quantity_sold || 0), 0) || 0,
totalRevenue: productData.data?.reduce((sum, item) => sum + (item?.revenue || 0), 0) || 0,
totalOrders: productData.data?.reduce((sum, item) => sum + (item?.order_count || 0), 0) || 0
}
worksheetData.push(['RINGKASAN PERIODE'])
worksheetData.push([])
const summaryData = [
['Total Quantity Sold:', productSummary.totalQuantitySold.toString()],
['Total Orders:', productSummary.totalOrders.toString()],
['Total Revenue:', `Rp ${productSummary.totalRevenue.toLocaleString('id-ID')}`]
]
summaryData.forEach(row => worksheetData.push([row[0], row[1]]))
worksheetData.push([])
worksheetData.push([])
}
worksheetData.push(['RINCIAN PRODUK'])
worksheetData.push([])
// Header row
const headerRow =
options?.groupByCategory !== false
? ['Kategori', 'Produk', 'Qty', 'Order', 'Pendapatan', 'Rata Rata']
: ['Produk', 'Kategori', 'Qty', 'Order', 'Pendapatan', 'Rata Rata']
worksheetData.push(headerRow)
// Add product data based on grouping option
if (options?.groupByCategory !== false) {
// Group by category (default)
const groupedProducts =
productData.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[]>
) || {}
Object.keys(groupedProducts)
.sort()
.forEach(categoryName => {
const categoryProducts = groupedProducts[categoryName]
worksheetData.push([categoryName.toUpperCase(), '', '', '', '', ''])
categoryProducts.forEach(item => {
worksheetData.push([
'',
item.product_name,
item.quantity_sold,
item.order_count || 0,
item.revenue,
item.average_price
])
})
})
} else {
// Flat list without grouping
productData.data?.forEach(item => {
worksheetData.push([
item.product_name,
item.category_name,
item.quantity_sold,
item.order_count || 0,
item.revenue,
item.average_price
])
})
}
const workbook = XLSX.utils.book_new()
const worksheet = XLSX.utils.aoa_to_sheet(worksheetData)
this.applyBasicFormatting(worksheet, worksheetData.length, XLSX)
const sheetName = options?.sheetName || 'Penjualan Produk'
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName)
const exportFilename = options?.customFilename || this.generateFilename('Custom_Product_Sales')
XLSX.writeFile(workbook, exportFilename)
return { success: true, filename: exportFilename }
} catch (error) {
console.error('Error exporting custom product data to Excel:', error)
return { success: false, error: 'Failed to export Excel file' }
}
}
}
@@ -1,469 +0,0 @@
// services/excelExportSalesService.ts
import type { CategoryReport, PaymentReport, ProductSalesReport, ProfitLossReport } from '@/types/services/analytic'
export interface SalesReportData {
profitLoss: ProfitLossReport
paymentAnalytics: PaymentReport
categoryAnalytics: CategoryReport
productAnalytics: ProductSalesReport
}
export class ExcelExportSalesService {
/**
* Export Sales Report to Excel
*/
static async exportSalesReportToExcel(salesData: SalesReportData, filename?: string) {
try {
// Dynamic import untuk xlsx library
const XLSX = await import('xlsx')
// Prepare data untuk Excel
const worksheetData: any[][] = []
// Header dengan report info (baris 1-2)
worksheetData.push(['LAPORAN TRANSAKSI']) // Row 0 - Main title
worksheetData.push([
`Periode: ${salesData.profitLoss.date_from.split('T')[0]} - ${salesData.profitLoss.date_to.split('T')[0]}`
]) // Row 1 - Period
worksheetData.push([]) // Empty row
// Add Summary Section (Ringkasan)
worksheetData.push(['RINGKASAN PERIODE']) // Section header
worksheetData.push([]) // Empty row
const ringkasanData = [
['Total Penjualan:', `Rp ${salesData.profitLoss.summary.total_revenue.toLocaleString('id-ID')}`],
['Total Diskon:', `Rp ${salesData.profitLoss.summary.total_discount.toLocaleString('id-ID')}`],
['Total Pajak:', `Rp ${salesData.profitLoss.summary.total_tax.toLocaleString('id-ID')}`],
['Total:', `Rp ${salesData.profitLoss.summary.total_revenue.toLocaleString('id-ID')}`]
]
ringkasanData.forEach(row => {
worksheetData.push([row[0], row[1]])
})
worksheetData.push([]) // Empty row
worksheetData.push([]) // Empty row
// Add Invoice Section
worksheetData.push(['INVOICE']) // Section header
worksheetData.push([]) // Empty row
const invoiceData = [
['Total Invoice:', salesData.profitLoss.summary.total_orders.toString()],
['Rata-rata Tagihan Per Invoice:', `Rp ${salesData.profitLoss.summary.average_profit.toLocaleString('id-ID')}`]
]
invoiceData.forEach(row => {
worksheetData.push([row[0], row[1]])
})
worksheetData.push([]) // Empty row
worksheetData.push([]) // Empty row
// Add Payment Methods Section
worksheetData.push(['RINGKASAN METODE PEMBAYARAN']) // Section header
worksheetData.push([]) // Empty row
// Payment methods table header
const paymentHeaderRow = ['No', 'Metode Pembayaran', 'Tipe', 'Jumlah Order', 'Total Amount', 'Persentase']
worksheetData.push(paymentHeaderRow)
// Payment methods data
salesData.paymentAnalytics.data?.forEach((payment, index) => {
const rowData = [
index + 1,
payment.payment_method_name,
payment.payment_method_type.toUpperCase(),
payment.order_count,
payment.total_amount,
`${(payment.percentage ?? 0).toFixed(1)}%`
]
worksheetData.push(rowData)
})
// Payment methods total row
const paymentTotalRow = [
'TOTAL',
'',
'',
salesData.paymentAnalytics.summary?.total_orders ?? 0,
salesData.paymentAnalytics.summary?.total_amount ?? 0,
'100.0%'
]
worksheetData.push(paymentTotalRow)
worksheetData.push([]) // Empty row
worksheetData.push([]) // Empty row
// Add Category Section
worksheetData.push(['RINGKASAN KATEGORI']) // Section header
worksheetData.push([]) // Empty row
// Category table header
const categoryHeaderRow = ['No', 'Nama', 'Total Produk', 'Qty', 'Pendapatan']
worksheetData.push(categoryHeaderRow)
// Calculate category summaries
const categorySummary = {
totalRevenue: salesData.categoryAnalytics.data?.reduce((sum, item) => sum + (item?.total_revenue || 0), 0) || 0,
productCount: salesData.categoryAnalytics.data?.reduce((sum, item) => sum + (item?.product_count || 0), 0) || 0,
totalQuantity:
salesData.categoryAnalytics.data?.reduce((sum, item) => sum + (item?.total_quantity || 0), 0) || 0
}
// Category data
salesData.categoryAnalytics.data?.forEach((category, index) => {
const rowData = [
index + 1,
category.category_name,
category.product_count,
category.total_quantity,
category.total_revenue
]
worksheetData.push(rowData)
})
// Category total row
const categoryTotalRow = [
'TOTAL',
'',
categorySummary.productCount,
categorySummary.totalQuantity,
categorySummary.totalRevenue
]
worksheetData.push(categoryTotalRow)
worksheetData.push([]) // Empty row
worksheetData.push([]) // Empty row
// Add Product Section
worksheetData.push(['RINGKASAN ITEM']) // Section header
worksheetData.push([]) // Empty row
// Group products by category
const groupedProducts =
salesData.productAnalytics.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[]>
) || {}
// Calculate product summary
const productSummary = {
totalQuantitySold:
salesData.productAnalytics.data?.reduce((sum, item) => sum + (item?.quantity_sold || 0), 0) || 0,
totalRevenue: salesData.productAnalytics.data?.reduce((sum, item) => sum + (item?.revenue || 0), 0) || 0,
totalOrders: salesData.productAnalytics.data?.reduce((sum, item) => sum + (item?.order_count || 0), 0) || 0
}
// Product table header
const productHeaderRow = ['Kategori', 'Produk', 'Qty', 'Order', 'Pendapatan', 'Rata Rata']
worksheetData.push(productHeaderRow)
// Add grouped products data
Object.keys(groupedProducts)
.sort()
.forEach(categoryName => {
const categoryProducts = groupedProducts[categoryName]
// Category header row
worksheetData.push([categoryName.toUpperCase(), '', '', '', '', ''])
// Category products
categoryProducts.forEach(item => {
const rowData = [
'',
item.product_name,
item.quantity_sold,
item.order_count || 0,
item.revenue,
item.average_price
]
worksheetData.push(rowData)
})
// Category subtotal
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)
const categoryAverage = categoryTotalQty > 0 ? categoryTotalRevenue / categoryTotalQty : 0
const categorySubtotalRow = [
`Subtotal ${categoryName}`,
'',
categoryTotalQty,
categoryTotalOrders,
categoryTotalRevenue,
categoryAverage
]
worksheetData.push(categorySubtotalRow)
worksheetData.push([]) // Empty row between categories
})
// Grand total
const grandTotalAverage =
productSummary.totalQuantitySold > 0 ? productSummary.totalRevenue / productSummary.totalQuantitySold : 0
const grandTotalRow = [
'TOTAL KESELURUHAN',
'',
productSummary.totalQuantitySold,
productSummary.totalOrders,
productSummary.totalRevenue,
grandTotalAverage
]
worksheetData.push(grandTotalRow)
// Create workbook dan worksheet
const workbook = XLSX.utils.book_new()
const worksheet = XLSX.utils.aoa_to_sheet(worksheetData)
// Apply basic formatting
this.applyBasicFormatting(worksheet, worksheetData.length, XLSX)
// Add worksheet ke workbook
XLSX.utils.book_append_sheet(workbook, worksheet, 'Laporan Transaksi')
// Generate filename
const exportFilename = filename || this.generateFilename('Laporan_Transaksi')
// Download file
XLSX.writeFile(workbook, exportFilename)
return { success: true, filename: exportFilename }
} catch (error) {
console.error('Error exporting sales report to Excel:', error)
return { success: false, error: 'Failed to export Excel file' }
}
}
/**
* Apply basic formatting (SheetJS compatible)
*/
private static applyBasicFormatting(worksheet: any, totalRows: number, XLSX: any) {
// Set column widths
const colWidths = [
{ wch: 25 }, // First column (category/label)
{ wch: 30 }, // Second column (description/name)
{ wch: 15 }, // Third column (numbers)
{ wch: 15 }, // Fourth column (numbers)
{ wch: 20 }, // Fifth column (amounts)
{ wch: 15 } // Sixth column (percentages/averages)
]
worksheet['!cols'] = colWidths
// Set row heights for better spacing
worksheet['!rows'] = [
{ hpt: 30 }, // Title row
{ hpt: 25 }, // Period row
{ hpt: 15 }, // Empty row
{ hpt: 25 }, // Section headers
{ hpt: 15 } // Empty row
]
// Merge cells untuk main headers
const merges = [
{ s: { r: 0, c: 0 }, e: { r: 0, c: 5 } }, // Main title
{ s: { r: 1, c: 0 }, e: { r: 1, c: 5 } } // Period
]
// Find and add merges for section headers
const sectionHeaders = [
'RINGKASAN PERIODE',
'INVOICE',
'RINGKASAN METODE PEMBAYARAN',
'RINGKASAN KATEGORI',
'RINGKASAN ITEM'
]
for (let i = 0; i < totalRows; i++) {
const cell = worksheet[XLSX.utils.encode_cell({ r: i, c: 0 })]
if (cell && sectionHeaders.includes(cell.v)) {
merges.push({ s: { r: i, c: 0 }, e: { r: i, c: 5 } })
}
}
worksheet['!merges'] = merges
// Apply number formatting untuk currency cells
this.applyNumberFormatting(worksheet, totalRows, XLSX)
}
/**
* Apply number formatting for currency
*/
private static applyNumberFormatting(worksheet: any, totalRows: number, XLSX: any) {
// Apply currency formatting to amount columns
for (let row = 0; row < totalRows; row++) {
// Check columns that might contain currency values (columns 1, 4, 5)
;[1, 4, 5].forEach(col => {
const cellAddress = XLSX.utils.encode_cell({ r: row, c: col })
const cell = worksheet[cellAddress]
if (cell && typeof cell.v === 'number' && cell.v > 1000) {
// Apply Indonesian currency format for large numbers
cell.z = '#,##0'
cell.t = 'n'
}
})
}
// Apply formatting to specific sections
this.applySectionFormatting(worksheet, totalRows, XLSX)
}
/**
* Apply specific formatting to sections
*/
private static applySectionFormatting(worksheet: any, totalRows: number, XLSX: any) {
// Find and format table headers and total rows
const headerKeywords = ['No', 'Metode Pembayaran', 'Nama', 'Kategori', 'Produk']
const totalKeywords = ['TOTAL', 'Subtotal']
for (let row = 0; row < totalRows; row++) {
const cell = worksheet[XLSX.utils.encode_cell({ r: row, c: 0 })]
if (cell) {
// Format table headers
if (headerKeywords.some(keyword => cell.v === keyword)) {
for (let col = 0; col < 6; col++) {
const headerCellAddress = XLSX.utils.encode_cell({ r: row, c: col })
const headerCell = worksheet[headerCellAddress]
if (headerCell) {
headerCell.s = {
font: { bold: true },
fill: { fgColor: { rgb: 'F3F4F6' } },
border: {
bottom: { style: 'medium', color: { rgb: '000000' } }
}
}
}
}
}
// Format total rows
if (totalKeywords.some(keyword => cell.v?.toString().startsWith(keyword))) {
for (let col = 0; col < 6; col++) {
const totalCellAddress = XLSX.utils.encode_cell({ r: row, c: col })
const totalCell = worksheet[totalCellAddress]
if (totalCell) {
totalCell.s = {
font: { bold: true },
border: {
top: { style: 'medium', color: { rgb: '000000' } }
}
}
}
}
}
// Format section headers
const sectionHeaders = [
'RINGKASAN PERIODE',
'INVOICE',
'RINGKASAN METODE PEMBAYARAN',
'RINGKASAN KATEGORI',
'RINGKASAN ITEM'
]
if (sectionHeaders.includes(cell.v)) {
cell.s = {
font: { bold: true, color: { rgb: '662D91' } },
fill: { fgColor: { rgb: 'F8F9FA' } }
}
}
}
}
}
/**
* Generate filename with timestamp
*/
private static generateFilename(prefix: string): string {
const now = new Date()
const year = now.getFullYear()
const month = (now.getMonth() + 1).toString().padStart(2, '0')
const day = now.getDate().toString().padStart(2, '0')
const hour = now.getHours().toString().padStart(2, '0')
const minute = now.getMinutes().toString().padStart(2, '0')
return `${prefix}_${year}_${month}_${day}_${hour}${minute}.xlsx`
}
/**
* Export custom sales data to Excel with configuration
*/
static async exportCustomSalesData(
salesData: SalesReportData,
options?: {
includeSections?: {
ringkasan?: boolean
invoice?: boolean
paymentMethods?: boolean
categories?: boolean
products?: boolean
}
customFilename?: string
sheetName?: string
}
) {
try {
const XLSX = await import('xlsx')
const worksheetData: any[][] = []
// Always include title and period
worksheetData.push(['LAPORAN TRANSAKSI'])
worksheetData.push([
`Periode: ${salesData.profitLoss.date_from.split('T')[0]} - ${salesData.profitLoss.date_to.split('T')[0]}`
])
worksheetData.push([])
const sections = options?.includeSections || {
ringkasan: true,
invoice: true,
paymentMethods: true,
categories: true,
products: true
}
// Conditionally add sections based on options
if (sections.ringkasan) {
worksheetData.push(['RINGKASAN PERIODE'])
worksheetData.push([])
// Add ringkasan data...
const ringkasanData = [
['Total Penjualan:', `Rp ${salesData.profitLoss.summary.total_revenue.toLocaleString('id-ID')}`],
['Total Diskon:', `Rp ${salesData.profitLoss.summary.total_discount.toLocaleString('id-ID')}`],
['Total Pajak:', `Rp ${salesData.profitLoss.summary.total_tax.toLocaleString('id-ID')}`],
['Total:', `Rp ${salesData.profitLoss.summary.total_revenue.toLocaleString('id-ID')}`]
]
ringkasanData.forEach(row => worksheetData.push([row[0], row[1]]))
worksheetData.push([])
worksheetData.push([])
}
// Add other sections similarly based on options...
const workbook = XLSX.utils.book_new()
const worksheet = XLSX.utils.aoa_to_sheet(worksheetData)
this.applyBasicFormatting(worksheet, worksheetData.length, XLSX)
const sheetName = options?.sheetName || 'Laporan Transaksi'
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName)
const exportFilename = options?.customFilename || this.generateFilename('Custom_Sales_Report')
XLSX.writeFile(workbook, exportFilename)
return { success: true, filename: exportFilename }
} catch (error) {
console.error('Error exporting custom sales report to Excel:', error)
return { success: false, error: 'Failed to export Excel file' }
}
}
}
@@ -1,364 +0,0 @@
// services/pdfExportPaymentService.ts
import { PaymentReport } from '@/types/services/analytic'
export class PDFExportPaymentService {
/**
* Export Payment Method Report to PDF
*/
static async exportPaymentMethodToPDF(paymentData: PaymentReport, filename?: string) {
try {
// Dynamic import untuk jsPDF
const jsPDFModule = await import('jspdf')
const jsPDF = jsPDFModule.default
// Create new PDF document - PORTRAIT A4
const pdf = new jsPDF('p', 'mm', 'a4')
// Add content
await this.addPaymentReportContent(pdf, paymentData)
// Generate filename
const exportFilename = filename || this.generateFilename('Laporan_Metode_Pembayaran', 'pdf')
// Save PDF
pdf.save(exportFilename)
return { success: true, filename: exportFilename }
} catch (error) {
console.error('Error exporting payment report to PDF:', error)
return { success: false, error: `PDF export failed: ${(error as Error).message}` }
}
}
/**
* Add payment report content to PDF
*/
private static async addPaymentReportContent(pdf: any, paymentData: PaymentReport) {
let yPos = 20
const pageWidth = pdf.internal.pageSize.getWidth()
const pageHeight = pdf.internal.pageSize.getHeight()
const marginLeft = 20
const marginRight = 20
const marginBottom = 15
// Helper function to check page break
const checkPageBreak = (neededSpace: number) => {
if (yPos + neededSpace > pageHeight - marginBottom) {
pdf.addPage()
yPos = 20
return true
}
return false
}
// Title
yPos = this.addReportTitle(pdf, paymentData, yPos, pageWidth, marginLeft, marginRight)
// Section 1: Ringkasan
checkPageBreak(50)
yPos = this.addRingkasanSection(pdf, paymentData, yPos, pageWidth, marginLeft, marginRight, checkPageBreak)
// Section 2: Payment Methods Detail
checkPageBreak(80)
yPos = this.addPaymentMethodsSection(pdf, paymentData, yPos, pageWidth, marginLeft, marginRight, checkPageBreak)
}
/**
* Add report title
*/
private static addReportTitle(
pdf: any,
paymentData: PaymentReport,
startY: number,
pageWidth: number,
marginLeft: number,
marginRight: number
): number {
let yPos = startY
// Title
pdf.setFontSize(20)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(0, 0, 0)
pdf.text('Laporan Metode Pembayaran', pageWidth / 2, yPos, { align: 'center' })
yPos += 10
// Period
pdf.setFontSize(12)
pdf.setFont('helvetica', 'normal')
const periodText = `${paymentData.date_from.split('T')[0]} - ${paymentData.date_to.split('T')[0]}`
pdf.text(periodText, pageWidth / 2, yPos, { align: 'center' })
yPos += 10
// Purple line separator
pdf.setDrawColor(102, 45, 145)
pdf.setLineWidth(2)
pdf.line(marginLeft, yPos, pageWidth - marginRight, yPos)
yPos += 15
return yPos
}
/**
* Add Ringkasan section - SAMA SEPERTI SALES REPORT STYLE
*/
private static addRingkasanSection(
pdf: any,
paymentData: PaymentReport,
startY: number,
pageWidth: number,
marginLeft: number,
marginRight: number,
checkPageBreak: (space: number) => boolean
): number {
let yPos = startY
// Section title - SAMA SEPERTI SALES REPORT
pdf.setFontSize(14)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(102, 45, 145)
pdf.text('Ringkasan', marginLeft, yPos)
yPos += 12
// Reset text color
pdf.setTextColor(0, 0, 0)
pdf.setFontSize(11)
const ringkasanItems = [
{ label: 'Total Amount', value: this.formatCurrency(paymentData.summary.total_amount), bold: false },
{ label: 'Total Orders', value: paymentData.summary.total_orders.toString(), bold: false },
{ label: 'Total Payments', value: paymentData.summary.total_payments.toString(), bold: false },
{ label: 'Average Order Value', value: this.formatCurrency(paymentData.summary.average_order_value), bold: true }
]
ringkasanItems.forEach((item, index) => {
if (checkPageBreak(15)) yPos = 20
// Set font weight
pdf.setFont('helvetica', item.bold ? 'bold' : 'normal')
pdf.text(item.label, marginLeft, yPos)
pdf.text(item.value, pageWidth - marginRight, yPos, { align: 'right' })
// Light separator line (except for last row)
if (!item.bold) {
pdf.setDrawColor(230, 230, 230)
pdf.setLineWidth(0.5)
pdf.line(marginLeft, yPos + 2, pageWidth - marginRight, yPos + 2)
}
yPos += 8
})
return yPos + 20
}
/**
* Add Payment Methods section - ORIGINAL STYLE DARI SALES REPORT
*/
private static addPaymentMethodsSection(
pdf: any,
paymentData: PaymentReport,
startY: number,
pageWidth: number,
marginLeft: number,
marginRight: number,
checkPageBreak: (space: number) => boolean
): number {
let yPos = startY
// Section title
pdf.setFontSize(14)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(102, 45, 145)
pdf.text('Rincian Metode Pembayaran', marginLeft, yPos)
yPos += 12
// Reset formatting
pdf.setTextColor(0, 0, 0)
// Table setup
const tableWidth = pageWidth - marginLeft - marginRight
const colWidths = [50, 25, 30, 35, 25] // Method, Type, Order, Amount, %
let currentX = marginLeft
// Table header
pdf.setFillColor(240, 240, 240)
pdf.rect(marginLeft, yPos, tableWidth, 10, 'F')
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(9)
const headers = ['Metode Pembayaran', 'Tipe', 'Jumlah Order', 'Total Amount', 'Persentase']
currentX = marginLeft
headers.forEach((header, index) => {
if (index === 0) {
pdf.text(header, currentX + 2, yPos + 6)
} else {
pdf.text(header, currentX + colWidths[index] / 2, yPos + 6, { align: 'center' })
}
currentX += colWidths[index]
})
yPos += 12
// Table rows
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(9)
paymentData.data?.forEach((payment, index) => {
if (checkPageBreak(10)) yPos = 20
currentX = marginLeft
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(9)
pdf.setTextColor(0, 0, 0)
// Method name
pdf.text(payment.payment_method_name, currentX + 2, yPos + 5)
currentX += colWidths[0]
// Type with simple color coding
const typeText = payment.payment_method_type.toUpperCase()
if (payment.payment_method_type === 'cash') {
pdf.setTextColor(0, 120, 0) // Green
} else if (payment.payment_method_type === 'card') {
pdf.setTextColor(0, 80, 200) // Blue
} else {
pdf.setTextColor(200, 100, 0) // Orange
}
pdf.text(typeText, currentX + colWidths[1] / 2, yPos + 5, { align: 'center' })
pdf.setTextColor(0, 0, 0) // Reset color
currentX += colWidths[1]
// Order count
pdf.text(payment.order_count.toString(), currentX + colWidths[2] / 2, yPos + 5, { align: 'center' })
currentX += colWidths[2]
// Amount
pdf.text(this.formatCurrency(payment.total_amount), currentX + colWidths[3] - 2, yPos + 5, { align: 'right' })
currentX += colWidths[3]
// Percentage
pdf.text(`${(payment.percentage ?? 0).toFixed(1)}%`, currentX + colWidths[4] / 2, yPos + 5, { align: 'center' })
// Draw bottom border line
pdf.setDrawColor(230, 230, 230)
pdf.setLineWidth(0.3)
pdf.line(marginLeft, yPos + 8, marginLeft + tableWidth, yPos + 8)
yPos += 10
})
// Table footer (Total) - directly after last row
pdf.setFillColor(245, 245, 245) // Lighter gray
pdf.rect(marginLeft, yPos, tableWidth, 10, 'F')
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(9)
currentX = marginLeft
pdf.text('TOTAL', currentX + 2, yPos + 6)
currentX += colWidths[0] + colWidths[1]
pdf.text((paymentData.summary?.total_orders ?? 0).toString(), currentX + colWidths[2] / 2, yPos + 6, {
align: 'center'
})
currentX += colWidths[2]
pdf.text(this.formatCurrency(paymentData.summary?.total_amount ?? 0), currentX + colWidths[3] - 2, yPos + 6, {
align: 'right'
})
currentX += colWidths[3]
pdf.text('100.0%', currentX + colWidths[4] / 2, yPos + 6, { align: 'center' })
return yPos + 20
}
/**
* Format currency for display
*/
private static formatCurrency(amount: number): string {
return `Rp ${amount.toLocaleString('id-ID')}`
}
/**
* Generate filename with timestamp
*/
private static generateFilename(prefix: string, extension: string): string {
const now = new Date()
const year = now.getFullYear()
const month = (now.getMonth() + 1).toString().padStart(2, '0')
const day = now.getDate().toString().padStart(2, '0')
const hour = now.getHours().toString().padStart(2, '0')
const minute = now.getMinutes().toString().padStart(2, '0')
return `${prefix}_${year}_${month}_${day}_${hour}${minute}.${extension}`
}
/**
* Export Payment Method data with custom configuration
*/
static async exportCustomPaymentToPDF(
paymentData: PaymentReport,
options?: {
title?: string
includeSummary?: boolean
customFilename?: string
}
) {
try {
const jsPDFModule = await import('jspdf')
const jsPDF = jsPDFModule.default
const pdf = new jsPDF('p', 'mm', 'a4')
let yPos = 20
const pageWidth = pdf.internal.pageSize.getWidth()
const pageHeight = pdf.internal.pageSize.getHeight()
const marginLeft = 20
const marginRight = 20
const marginBottom = 15
const checkPageBreak = (neededSpace: number) => {
if (yPos + neededSpace > pageHeight - marginBottom) {
pdf.addPage()
yPos = 20
return true
}
return false
}
// Custom title if provided
if (options?.title) {
pdf.setFontSize(20)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(0, 0, 0)
pdf.text(options.title, pageWidth / 2, yPos, { align: 'center' })
yPos += 15
} else {
yPos = this.addReportTitle(pdf, paymentData, yPos, pageWidth, marginLeft, marginRight)
}
// Optional summary section
if (options?.includeSummary !== false) {
checkPageBreak(50)
yPos = this.addRingkasanSection(pdf, paymentData, yPos, pageWidth, marginLeft, marginRight, checkPageBreak)
}
// Payment methods detail
checkPageBreak(80)
yPos = this.addPaymentMethodsSection(pdf, paymentData, yPos, pageWidth, marginLeft, marginRight, checkPageBreak)
const exportFilename = options?.customFilename || this.generateFilename('Custom_Payment_Report', 'pdf')
pdf.save(exportFilename)
return { success: true, filename: exportFilename }
} catch (error) {
console.error('Error exporting custom payment report to PDF:', error)
return { success: false, error: `PDF export failed: ${(error as Error).message}` }
}
}
}
@@ -1,577 +0,0 @@
// services/pdfExportService.ts
import type { ProfitLossReport } from '@/types/services/analytic'
export class PDFExportProfitLossService {
/**
* Export Profit Loss Report to PDF (Simple approach)
*/
static async exportProfitLossToPDF(profitData: ProfitLossReport, filename?: string) {
try {
// Dynamic import untuk jsPDF
const jsPDFModule = await import('jspdf')
const jsPDF = jsPDFModule.default
// Create new PDF document - PORTRAIT A4
const pdf = new jsPDF('p', 'mm', 'a4') // portrait
// Add content
this.addBasicContent(pdf, profitData)
// Generate filename
const exportFilename = filename || this.generateFilename('Laba_Rugi', 'pdf')
// Save PDF
pdf.save(exportFilename)
return { success: true, filename: exportFilename }
} catch (error) {
console.error('Error exporting to PDF:', error)
return { success: false, error: `PDF export failed: ${(error as Error).message}` }
}
}
/**
* Add basic content to PDF with proper page management
*/
private static addBasicContent(pdf: any, profitData: ProfitLossReport) {
let yPos = 20 // Reduced from 30
const pageWidth = pdf.internal.pageSize.getWidth()
const pageHeight = pdf.internal.pageSize.getHeight()
const marginBottom = 15 // Reduced from 20
// Title - Center aligned
pdf.setFontSize(18) // Reduced from 20
pdf.setFont('helvetica', 'bold')
pdf.text('Laporan Laba Rugi', pageWidth / 2, yPos, { align: 'center' })
yPos += 10 // Reduced from 15
// Period - Center aligned
pdf.setFontSize(11) // Reduced from 12
pdf.setFont('helvetica', 'normal')
const periodText = `${profitData.date_from.split('T')[0]} - ${profitData.date_to.split('T')[0]}`
pdf.text(periodText, pageWidth / 2, yPos, { align: 'center' })
yPos += 12 // Reduced from 20
// Purple line separator
pdf.setDrawColor(102, 45, 145) // Purple color
pdf.setLineWidth(1.5) // Reduced from 2
pdf.line(20, yPos, pageWidth - 20, yPos)
yPos += 15 // Reduced from 25
// Ringkasan section
pdf.setFontSize(14) // Reduced from 16
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(102, 45, 145) // Purple color
pdf.text('Ringkasan', 20, yPos)
yPos += 12 // Reduced from 20
// Reset text color to black
pdf.setTextColor(0, 0, 0)
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(11) // Reduced from 12
// Summary items with consistent spacing
const summaryItems = [
{ label: 'Total Penjualan', value: this.formatCurrency(profitData.summary.total_revenue) },
{ label: 'Total Biaya', value: this.formatCurrency(profitData.summary.total_cost) },
{ label: 'Total Diskon', value: this.formatCurrency(profitData.summary.total_discount) },
{ label: 'Total Pajak', value: this.formatCurrency(profitData.summary.total_tax) },
{ label: 'Laba Kotor', value: this.formatCurrency(profitData.summary.gross_profit) },
{ label: 'Laba Bersih', value: this.formatCurrency(profitData.summary.net_profit) }
]
summaryItems.forEach((item, index) => {
// Add some spacing between items
if (index > 0) yPos += 8 // Reduced from 12
// Check if we need new page for summary items
if (yPos > pageHeight - marginBottom - 15) {
pdf.addPage()
yPos = 20 // Reduced from 30
}
// Label on left
pdf.text(item.label, 20, yPos)
// Value on right
pdf.text(item.value, pageWidth - 20, yPos, { align: 'right' })
// Light gray line separator
pdf.setDrawColor(230, 230, 230)
pdf.setLineWidth(0.3)
pdf.line(20, yPos + 3, pageWidth - 20, yPos + 3)
})
yPos += 20 // Reduced from 30
// Check if we need new page before daily breakdown
if (yPos > pageHeight - marginBottom - 40) {
pdf.addPage()
yPos = 20 // Reduced from 30
}
// Daily breakdown section
pdf.setFontSize(14) // Reduced from 16
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(102, 45, 145) // Purple color
pdf.text('Rincian Harian', 20, yPos)
yPos += 12 // Reduced from 20
// Reset text color
pdf.setTextColor(0, 0, 0)
// Create simple daily breakdown with page management
profitData.data.forEach((daily, index) => {
// Estimate space needed for this daily section (approx 100mm)
const estimatedSpace = 100
// Check if we need new page before adding daily section
if (yPos + estimatedSpace > pageHeight - marginBottom) {
pdf.addPage()
yPos = 20 // Reduced from 30
}
yPos = this.addCleanDailySection(pdf, daily, yPos, pageWidth, pageHeight, marginBottom)
yPos += 10 // Reduced from 15 - Space between daily sections
})
}
/**
* Add clean daily section with page break management
*/
private static addCleanDailySection(
pdf: any,
dailyData: any,
startY: number,
pageWidth: number,
pageHeight: number,
marginBottom: number
) {
const date = new Date(dailyData.date).toLocaleDateString('id-ID', {
day: '2-digit',
month: 'long',
year: 'numeric'
})
let yPos = startY
// Check if we have enough space for the header
if (yPos > pageHeight - marginBottom - 20) {
pdf.addPage()
yPos = 20 // Reduced from 30
}
// Date header
pdf.setFontSize(12) // Reduced from 14
pdf.setFont('helvetica', 'bold')
pdf.text(date, 20, yPos)
yPos += 10 // Reduced from 15
// Daily data items
pdf.setFontSize(10) // Reduced from 11
pdf.setFont('helvetica', 'normal')
const dailyItems = [
{ label: 'Penjualan', value: this.formatCurrency(dailyData.revenue) },
{ label: 'HPP (Biaya Pokok)', value: this.formatCurrency(dailyData.cost) },
{ label: 'Laba Kotor', value: this.formatCurrency(dailyData.gross_profit) },
{ label: 'Pajak', value: this.formatCurrency(dailyData.tax) },
{ label: 'Diskon', value: this.formatCurrency(dailyData.discount) },
{ label: 'Jumlah Order', value: dailyData.orders.toString() + ' transaksi' },
{ label: 'Laba Bersih', value: this.formatCurrency(dailyData.net_profit), isTotal: true }
]
dailyItems.forEach((item, index) => {
if (index > 0) yPos += 7 // Reduced from 10
// Check if we need new page for each item
if (yPos > pageHeight - marginBottom - 15) {
pdf.addPage()
yPos = 20 // Reduced from 30
}
// Special styling for total (Laba Bersih)
if (item.isTotal) {
pdf.setFont('helvetica', 'bold')
// Light background for total row - adjusted to center with text
pdf.setFillColor(248, 248, 248)
pdf.rect(20, yPos - 4, pageWidth - 40, 9, 'F') // Slightly bigger and better positioned
} else {
pdf.setFont('helvetica', 'normal')
}
// Label on left - consistent with other rows
pdf.text(item.label, 25, yPos)
// Value on right - consistent with other rows
pdf.text(item.value, pageWidth - 25, yPos, { align: 'right' })
// Subtle line separator (except for last item)
if (index < dailyItems.length - 1) {
pdf.setDrawColor(245, 245, 245)
pdf.setLineWidth(0.2)
pdf.line(25, yPos + 1.5, pageWidth - 25, yPos + 1.5) // Reduced from yPos + 2
}
})
return yPos + 6 // Reduced from 8
}
/**
* Format currency for display
*/
private static formatCurrency(amount: number): string {
return `Rp ${amount.toLocaleString('id-ID')}`
}
/**
* Generate filename with timestamp
*/
private static generateFilename(prefix: string, extension: string): string {
const now = new Date()
const year = now.getFullYear()
const month = (now.getMonth() + 1).toString().padStart(2, '0')
const day = now.getDate().toString().padStart(2, '0')
const hour = now.getHours().toString().padStart(2, '0')
const minute = now.getMinutes().toString().padStart(2, '0')
return `${prefix}_${year}_${month}_${day}_${hour}${minute}.${extension}`
}
/**
* Alternative: More precise page break management
*/
static async exportWithBetterPageBreaks(profitData: ProfitLossReport, filename?: string) {
try {
const jsPDFModule = await import('jspdf')
const jsPDF = jsPDFModule.default
const pdf = new jsPDF('p', 'mm', 'a4')
// Use more precise measurements
const pageHeight = pdf.internal.pageSize.getHeight() // ~297mm for A4
const safeHeight = pageHeight - 30 // Keep 30mm margin from bottom
let currentY = 30
// Helper function to check and add new page
const checkPageBreak = (neededSpace: number) => {
if (currentY + neededSpace > safeHeight) {
pdf.addPage()
currentY = 30
return true
}
return false
}
// Add title and header
currentY = this.addTitleSection(pdf, profitData, currentY)
// Add summary with page break check
checkPageBreak(80) // Estimate 80mm needed for summary
currentY = this.addSummarySection(pdf, profitData, currentY, checkPageBreak)
// Add daily breakdown
checkPageBreak(40) // Space for section header
currentY = this.addDailyBreakdownSection(pdf, profitData, currentY, checkPageBreak)
const exportFilename = filename || this.generateFilename('Laba_Rugi', 'pdf')
pdf.save(exportFilename)
return { success: true, filename: exportFilename }
} catch (error) {
console.error('Error exporting to PDF:', error)
return { success: false, error: `PDF export failed: ${(error as Error).message}` }
}
}
/**
* Add title section
*/
private static addTitleSection(pdf: any, profitData: ProfitLossReport, startY: number): number {
let yPos = startY
const pageWidth = pdf.internal.pageSize.getWidth()
// Title
pdf.setFontSize(20)
pdf.setFont('helvetica', 'bold')
pdf.text('Laporan Laba Rugi', pageWidth / 2, yPos, { align: 'center' })
yPos += 15
// Period
pdf.setFontSize(12)
pdf.setFont('helvetica', 'normal')
const periodText = `${profitData.date_from.split('T')[0]} - ${profitData.date_to.split('T')[0]}`
pdf.text(periodText, pageWidth / 2, yPos, { align: 'center' })
yPos += 20
// Separator line
pdf.setDrawColor(102, 45, 145)
pdf.setLineWidth(2)
pdf.line(20, yPos, pageWidth - 20, yPos)
yPos += 25
return yPos
}
/**
* Add summary section with page break callback
*/
private static addSummarySection(
pdf: any,
profitData: ProfitLossReport,
startY: number,
checkPageBreak: (space: number) => boolean
): number {
let yPos = startY
const pageWidth = pdf.internal.pageSize.getWidth()
// Section title
pdf.setFontSize(16)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(102, 45, 145)
pdf.text('Ringkasan', 20, yPos)
yPos += 20
// Reset formatting
pdf.setTextColor(0, 0, 0)
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(12)
const summaryItems = [
{ label: 'Total Penjualan', value: this.formatCurrency(profitData.summary.total_revenue) },
{ label: 'Total Biaya', value: this.formatCurrency(profitData.summary.total_cost) },
{ label: 'Total Diskon', value: this.formatCurrency(profitData.summary.total_discount) },
{ label: 'Total Pajak', value: this.formatCurrency(profitData.summary.total_tax) },
{ label: 'Laba Kotor', value: this.formatCurrency(profitData.summary.gross_profit) },
{ label: 'Laba Bersih', value: this.formatCurrency(profitData.summary.net_profit) }
]
summaryItems.forEach((item, index) => {
if (index > 0) yPos += 12
// Check page break for each item
if (checkPageBreak(15)) {
yPos = 30
}
pdf.text(item.label, 20, yPos)
pdf.text(item.value, pageWidth - 20, yPos, { align: 'right' })
// Separator line
pdf.setDrawColor(230, 230, 230)
pdf.setLineWidth(0.3)
pdf.line(20, yPos + 3, pageWidth - 20, yPos + 3)
})
return yPos + 30
}
/**
* Add daily breakdown section with page break management
*/
private static addDailyBreakdownSection(
pdf: any,
profitData: ProfitLossReport,
startY: number,
checkPageBreak: (space: number) => boolean
): number {
let yPos = startY
// Section title
pdf.setFontSize(16)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(102, 45, 145)
pdf.text('Rincian Harian', 20, yPos)
yPos += 20
pdf.setTextColor(0, 0, 0)
profitData.data.forEach((daily, index) => {
// Check if we need space for this daily section (estimate ~90mm)
if (checkPageBreak(90)) {
yPos = 30
}
yPos = this.addSingleDayData(pdf, daily, yPos)
yPos += 15
})
return yPos
}
/**
* Add single day data
*/
private static addSingleDayData(pdf: any, dailyData: any, startY: number): number {
const pageWidth = pdf.internal.pageSize.getWidth()
let yPos = startY
const date = new Date(dailyData.date).toLocaleDateString('id-ID', {
day: '2-digit',
month: 'long',
year: 'numeric'
})
// Date header
pdf.setFontSize(14)
pdf.setFont('helvetica', 'bold')
pdf.text(date, 20, yPos)
yPos += 15
// Daily items
pdf.setFontSize(11)
pdf.setFont('helvetica', 'normal')
const items = [
{ label: 'Penjualan', value: this.formatCurrency(dailyData.revenue) },
{ label: 'HPP (Biaya Pokok)', value: this.formatCurrency(dailyData.cost) },
{ label: 'Laba Kotor', value: this.formatCurrency(dailyData.gross_profit) },
{ label: 'Pajak', value: this.formatCurrency(dailyData.tax) },
{ label: 'Diskon', value: this.formatCurrency(dailyData.discount) },
{ label: 'Jumlah Order', value: dailyData.orders.toString() + ' transaksi' },
{ label: 'Laba Bersih', value: this.formatCurrency(dailyData.net_profit), isTotal: true }
]
items.forEach((item, index) => {
if (index > 0) yPos += 10
if (item.isTotal) {
pdf.setFont('helvetica', 'bold')
pdf.setFillColor(248, 248, 248)
pdf.rect(20, yPos - 4, pageWidth - 40, 10, 'F')
} else {
pdf.setFont('helvetica', 'normal')
}
pdf.text(item.label, 25, yPos)
pdf.text(item.value, pageWidth - 25, yPos, { align: 'right' })
if (index < items.length - 1) {
pdf.setDrawColor(245, 245, 245)
pdf.setLineWidth(0.2)
pdf.line(25, yPos + 2, pageWidth - 25, yPos + 2)
}
})
return yPos + 8
}
/**
* Alternative HTML to PDF method (if needed)
*/
static async exportToHTMLPDF(profitData: ProfitLossReport, filename?: string) {
try {
const htmlContent = this.generateSimpleHTML(profitData)
// Create a temporary element and trigger print
const printWindow = window.open('', '_blank')
if (printWindow) {
printWindow.document.write(htmlContent)
printWindow.document.close()
printWindow.focus()
printWindow.print()
printWindow.close()
}
return { success: true, filename: filename || 'Laba_Rugi.pdf' }
} catch (error) {
return { success: false, error: `HTML PDF export failed: ${(error as Error).message}` }
}
}
/**
* Generate simple HTML for printing
*/
private static generateSimpleHTML(profitData: ProfitLossReport): string {
const dateColumns = profitData.data.map(daily => {
const date = new Date(daily.date)
return date.toLocaleDateString('id-ID', { day: '2-digit', month: 'short' })
})
return `
<!DOCTYPE html>
<html>
<head>
<title>Laporan Laba Rugi</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
h1 { color: #1f4e79; text-align: center; }
h2 { color: #333; border-bottom: 2px solid #ccc; }
.summary { margin-bottom: 30px; }
.summary-item { margin: 5px 0; padding: 5px; background: #f9f9f9; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { border: 1px solid #ccc; padding: 8px; text-align: left; }
th { background-color: #d35400; color: white; text-align: center; }
.number { text-align: right; }
.center { text-align: center; }
@media print {
body { margin: 10px; }
.page-break { page-break-before: always; }
}
</style>
</head>
<body>
<h1>LAPORAN LABA RUGI</h1>
<p style="text-align: center;">
Periode: ${profitData.date_from.split('T')[0]} - ${profitData.date_to.split('T')[0]}
</p>
<h2>RINGKASAN PERIODE</h2>
<div class="summary">
<div class="summary-item">Total Revenue: <strong>${this.formatCurrency(profitData.summary.total_revenue)}</strong></div>
<div class="summary-item">Total Cost: <strong>${this.formatCurrency(profitData.summary.total_cost)}</strong></div>
<div class="summary-item">Gross Profit: <strong>${this.formatCurrency(profitData.summary.gross_profit)}</strong></div>
<div class="summary-item">Net Profit: <strong>${this.formatCurrency(profitData.summary.net_profit)}</strong></div>
<div class="summary-item">Total Orders: <strong>${profitData.summary.total_orders}</strong></div>
</div>
<div class="page-break"></div>
<h2>RINCIAN HARIAN</h2>
<table>
<thead>
<tr>
<th>NO</th>
<th>KETERANGAN</th>
<th></th>
${dateColumns.map(date => `<th>${date}</th>`).join('')}
</tr>
</thead>
<tbody>
<tr>
<td class="center">1</td>
<td><strong>TOTAL PENJ</strong></td>
<td class="center">:</td>
${profitData.data.map(daily => `<td class="number">${this.formatCurrency(daily.revenue)}</td>`).join('')}
</tr>
<tr>
<td class="center">2</td>
<td><strong>HPP</strong></td>
<td class="center">:</td>
${profitData.data.map(daily => `<td class="number">${this.formatCurrency(daily.cost)}</td>`).join('')}
</tr>
<tr>
<td class="center">3</td>
<td><strong>Laba Kotor</strong></td>
<td class="center">:</td>
${profitData.data.map(daily => `<td class="number">${this.formatCurrency(daily.gross_profit)}</td>`).join('')}
</tr>
<tr>
<td class="center">4</td>
<td><strong>Biaya lain</strong></td>
<td class="center">:</td>
${profitData.data.map(daily => `<td class="number">${this.formatCurrency(daily.tax + daily.discount)}</td>`).join('')}
</tr>
<tr style="background-color: #154360; color: white; font-weight: bold;">
<td class="center">5</td>
<td>Laba/Rugi</td>
<td class="center">:</td>
${profitData.data.map(daily => `<td class="number">${this.formatCurrency(daily.net_profit)}</td>`).join('')}
</tr>
</tbody>
</table>
</body>
</html>
`
}
}
@@ -1,529 +0,0 @@
// services/pdfExportSalesOrderService.ts
import { SalesReport } from '@/types/services/analytic'
export class PDFExportSalesOrderService {
/**
* Export Sales Order Report to PDF
*/
static async exportSalesOrderToPDF(salesData: SalesReport, filename?: string) {
try {
// Dynamic import untuk jsPDF
const jsPDFModule = await import('jspdf')
const jsPDF = jsPDFModule.default
// Create new PDF document - PORTRAIT A4
const pdf = new jsPDF('p', 'mm', 'a4')
// Add content
await this.addSalesOrderReportContent(pdf, salesData)
// Generate filename
const exportFilename = filename || this.generateFilename('Laporan_Pesanan_Penjualan', 'pdf')
// Save PDF
pdf.save(exportFilename)
return { success: true, filename: exportFilename }
} catch (error) {
console.error('Error exporting sales order report to PDF:', error)
return { success: false, error: `PDF export failed: ${(error as Error).message}` }
}
}
/**
* Add sales order report content to PDF
*/
private static async addSalesOrderReportContent(pdf: any, salesData: SalesReport) {
let yPos = 20
const pageWidth = pdf.internal.pageSize.getWidth()
const pageHeight = pdf.internal.pageSize.getHeight()
const marginLeft = 20
const marginRight = 20
const marginBottom = 15
// Helper function to check page break
const checkPageBreak = (neededSpace: number) => {
if (yPos + neededSpace > pageHeight - marginBottom) {
pdf.addPage()
yPos = 20
return true
}
return false
}
// Title
yPos = this.addReportTitle(pdf, salesData, yPos, pageWidth, marginLeft, marginRight)
// Section 1: Ringkasan
checkPageBreak(60)
yPos = this.addRingkasanSection(pdf, salesData, yPos, pageWidth, marginLeft, marginRight, checkPageBreak)
// Section 2: Daily Sales Details
checkPageBreak(100)
yPos = this.addDailySalesSection(pdf, salesData, yPos, pageWidth, marginLeft, marginRight, checkPageBreak)
}
/**
* Add report title
*/
private static addReportTitle(
pdf: any,
salesData: SalesReport,
startY: number,
pageWidth: number,
marginLeft: number,
marginRight: number
): number {
let yPos = startY
// Title
pdf.setFontSize(20)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(0, 0, 0)
pdf.text('Laporan Pesanan Penjualan', pageWidth / 2, yPos, { align: 'center' })
yPos += 10
// Period
pdf.setFontSize(12)
pdf.setFont('helvetica', 'normal')
const periodText = `${salesData.date_from.split('T')[0]} - ${salesData.date_to.split('T')[0]}`
pdf.text(periodText, pageWidth / 2, yPos, { align: 'center' })
yPos += 10
// Purple line separator
pdf.setDrawColor(102, 45, 145)
pdf.setLineWidth(2)
pdf.line(marginLeft, yPos, pageWidth - marginRight, yPos)
yPos += 15
return yPos
}
/**
* Add Ringkasan section
*/
private static addRingkasanSection(
pdf: any,
salesData: SalesReport,
startY: number,
pageWidth: number,
marginLeft: number,
marginRight: number,
checkPageBreak: (space: number) => boolean
): number {
let yPos = startY
// Section title
pdf.setFontSize(14)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(102, 45, 145)
pdf.text('Ringkasan', marginLeft, yPos)
yPos += 12
// Reset text color
pdf.setTextColor(0, 0, 0)
pdf.setFontSize(11)
const ringkasanItems = [
{ label: 'Total Sales', value: this.formatCurrency(salesData.summary.total_sales), bold: false },
{ label: 'Total Orders', value: salesData.summary.total_orders.toString(), bold: false },
{ label: 'Total Items', value: salesData.summary.total_items.toString(), bold: false },
{ label: 'Average Order Value', value: this.formatCurrency(salesData.summary.average_order_value), bold: false },
{ label: 'Total Tax', value: this.formatCurrency(salesData.summary.total_tax), bold: false },
{ label: 'Total Discount', value: this.formatCurrency(salesData.summary.total_discount), bold: false },
{ label: 'Net Sales', value: this.formatCurrency(salesData.summary.net_sales), bold: true }
]
ringkasanItems.forEach((item, index) => {
if (checkPageBreak(15)) yPos = 20
// Set font weight
pdf.setFont('helvetica', item.bold ? 'bold' : 'normal')
pdf.text(item.label, marginLeft, yPos)
pdf.text(item.value, pageWidth - marginRight, yPos, { align: 'right' })
// Light separator line (except for bold row)
if (!item.bold) {
pdf.setDrawColor(230, 230, 230)
pdf.setLineWidth(0.5)
pdf.line(marginLeft, yPos + 2, pageWidth - marginRight, yPos + 2)
}
yPos += 8
})
return yPos + 20
}
/**
* Add Daily Sales section
*/
private static addDailySalesSection(
pdf: any,
salesData: SalesReport,
startY: number,
pageWidth: number,
marginLeft: number,
marginRight: number,
checkPageBreak: (space: number) => boolean
): number {
let yPos = startY
// Section title
pdf.setFontSize(14)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(102, 45, 145)
pdf.text('Rincian Harian', marginLeft, yPos)
yPos += 12
// Reset formatting
pdf.setTextColor(0, 0, 0)
// Table setup - adjust for 7 columns
const tableWidth = pageWidth - marginLeft - marginRight
const colWidths = [20, 20, 25, 15, 20, 20, 25] // Date, Sales, Orders, Items, Tax, Discount, Net Sales
let currentX = marginLeft
// Table header
pdf.setFillColor(240, 240, 240)
pdf.rect(marginLeft, yPos, tableWidth, 10, 'F')
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(8) // Smaller font for more columns
const headers = ['Tanggal', 'Penjualan', 'Pesanan', 'Qty', 'Pajak', 'Diskon', 'Pendapatan']
currentX = marginLeft
headers.forEach((header, index) => {
if (index === 0) {
pdf.text(header, currentX + 2, yPos + 6)
} else {
pdf.text(header, currentX + colWidths[index] / 2, yPos + 6, { align: 'center' })
}
currentX += colWidths[index]
})
yPos += 12
// Table rows
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(8)
salesData.data?.forEach((dailySales, index) => {
if (checkPageBreak(10)) yPos = 20
currentX = marginLeft
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(8)
pdf.setTextColor(0, 0, 0)
// Date
pdf.text(this.formatDate(dailySales.date), currentX + 2, yPos + 5)
currentX += colWidths[0]
// Sales
pdf.text(this.formatCurrencyShort(dailySales.sales), currentX + colWidths[1] - 2, yPos + 5, { align: 'right' })
currentX += colWidths[1]
// Orders
pdf.text(dailySales.orders.toString(), currentX + colWidths[2] / 2, yPos + 5, { align: 'center' })
currentX += colWidths[2]
// Items
pdf.text(dailySales.items.toString(), currentX + colWidths[3] / 2, yPos + 5, { align: 'center' })
currentX += colWidths[3]
// Tax
pdf.text(this.formatCurrencyShort(dailySales.tax), currentX + colWidths[4] - 2, yPos + 5, { align: 'right' })
currentX += colWidths[4]
// Discount
pdf.text(this.formatCurrencyShort(dailySales.discount), currentX + colWidths[5] - 2, yPos + 5, { align: 'right' })
currentX += colWidths[5]
// Net Sales
pdf.text(this.formatCurrencyShort(dailySales.net_sales), currentX + colWidths[6] - 2, yPos + 5, {
align: 'right'
})
// Draw bottom border line
pdf.setDrawColor(230, 230, 230)
pdf.setLineWidth(0.3)
pdf.line(marginLeft, yPos + 8, marginLeft + tableWidth, yPos + 8)
yPos += 10
})
// Table footer (Total)
pdf.setFillColor(245, 245, 245) // Lighter gray
pdf.rect(marginLeft, yPos, tableWidth, 10, 'F')
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(8)
currentX = marginLeft
pdf.text('TOTAL', currentX + 2, yPos + 6)
currentX += colWidths[0]
pdf.text(this.formatCurrencyShort(salesData.summary.total_sales), currentX + colWidths[1] - 2, yPos + 6, {
align: 'right'
})
currentX += colWidths[1]
pdf.text(salesData.summary.total_orders.toString(), currentX + colWidths[2] / 2, yPos + 6, { align: 'center' })
currentX += colWidths[2]
pdf.text(salesData.summary.total_items.toString(), currentX + colWidths[3] / 2, yPos + 6, { align: 'center' })
currentX += colWidths[3]
pdf.text(this.formatCurrencyShort(salesData.summary.total_tax), currentX + colWidths[4] - 2, yPos + 6, {
align: 'right'
})
currentX += colWidths[4]
pdf.text(this.formatCurrencyShort(salesData.summary.total_discount), currentX + colWidths[5] - 2, yPos + 6, {
align: 'right'
})
currentX += colWidths[5]
pdf.text(this.formatCurrencyShort(salesData.summary.net_sales), currentX + colWidths[6] - 2, yPos + 6, {
align: 'right'
})
return yPos + 20
}
/**
* Format currency for display
*/
private static formatCurrency(amount: number): string {
return `Rp ${amount.toLocaleString('id-ID')}`
}
/**
* Format currency short for table display
*/
private static formatCurrencyShort(amount: number): string {
if (amount >= 1000000) {
return `${(amount / 1000000).toFixed(1)}M`
} else if (amount >= 1000) {
return `${(amount / 1000).toFixed(0)}K`
} else {
return amount.toString()
}
}
/**
* Format date for display
*/
private static formatDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('id-ID', {
day: '2-digit',
month: '2-digit'
})
}
/**
* Generate filename with timestamp
*/
private static generateFilename(prefix: string, extension: string): string {
const now = new Date()
const year = now.getFullYear()
const month = (now.getMonth() + 1).toString().padStart(2, '0')
const day = now.getDate().toString().padStart(2, '0')
const hour = now.getHours().toString().padStart(2, '0')
const minute = now.getMinutes().toString().padStart(2, '0')
return `${prefix}_${year}_${month}_${day}_${hour}${minute}.${extension}`
}
/**
* Export Sales Order data with custom configuration
*/
static async exportCustomSalesOrderToPDF(
salesData: SalesReport,
options?: {
title?: string
includeSummary?: boolean
customFilename?: string
compactMode?: boolean
}
) {
try {
const jsPDFModule = await import('jspdf')
const jsPDF = jsPDFModule.default
const pdf = new jsPDF('p', 'mm', 'a4')
let yPos = 20
const pageWidth = pdf.internal.pageSize.getWidth()
const pageHeight = pdf.internal.pageSize.getHeight()
const marginLeft = 20
const marginRight = 20
const marginBottom = 15
const checkPageBreak = (neededSpace: number) => {
if (yPos + neededSpace > pageHeight - marginBottom) {
pdf.addPage()
yPos = 20
return true
}
return false
}
// Custom title if provided
if (options?.title) {
pdf.setFontSize(20)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(0, 0, 0)
pdf.text(options.title, pageWidth / 2, yPos, { align: 'center' })
yPos += 15
} else {
yPos = this.addReportTitle(pdf, salesData, yPos, pageWidth, marginLeft, marginRight)
}
// Optional summary section
if (options?.includeSummary !== false) {
checkPageBreak(60)
yPos = this.addRingkasanSection(pdf, salesData, yPos, pageWidth, marginLeft, marginRight, checkPageBreak)
}
// Daily sales details
checkPageBreak(100)
if (options?.compactMode) {
yPos = this.addCompactDailySalesSection(
pdf,
salesData,
yPos,
pageWidth,
marginLeft,
marginRight,
checkPageBreak
)
} else {
yPos = this.addDailySalesSection(pdf, salesData, yPos, pageWidth, marginLeft, marginRight, checkPageBreak)
}
const exportFilename = options?.customFilename || this.generateFilename('Custom_Sales_Order', 'pdf')
pdf.save(exportFilename)
return { success: true, filename: exportFilename }
} catch (error) {
console.error('Error exporting custom sales order report to PDF:', error)
return { success: false, error: `PDF export failed: ${(error as Error).message}` }
}
}
/**
* Add Compact Daily Sales section (fewer columns)
*/
private static addCompactDailySalesSection(
pdf: any,
salesData: SalesReport,
startY: number,
pageWidth: number,
marginLeft: number,
marginRight: number,
checkPageBreak: (space: number) => boolean
): number {
let yPos = startY
// Section title
pdf.setFontSize(14)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(102, 45, 145)
pdf.text('Rincian Harian (Compact)', marginLeft, yPos)
yPos += 12
// Reset formatting
pdf.setTextColor(0, 0, 0)
// Table setup - compact with 4 columns
const tableWidth = pageWidth - marginLeft - marginRight
const colWidths = [30, 40, 30, 40] // Date, Sales, Orders, Net Sales
let currentX = marginLeft
// Table header
pdf.setFillColor(240, 240, 240)
pdf.rect(marginLeft, yPos, tableWidth, 10, 'F')
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(9)
const headers = ['Tanggal', 'Penjualan', 'Pesanan', 'Pendapatan']
currentX = marginLeft
headers.forEach((header, index) => {
if (index === 0) {
pdf.text(header, currentX + 2, yPos + 6)
} else {
pdf.text(header, currentX + colWidths[index] / 2, yPos + 6, { align: 'center' })
}
currentX += colWidths[index]
})
yPos += 12
// Table rows
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(9)
salesData.data?.forEach((dailySales, index) => {
if (checkPageBreak(10)) yPos = 20
currentX = marginLeft
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(9)
pdf.setTextColor(0, 0, 0)
// Date
pdf.text(this.formatDate(dailySales.date), currentX + 2, yPos + 5)
currentX += colWidths[0]
// Sales
pdf.text(this.formatCurrency(dailySales.sales), currentX + colWidths[1] - 2, yPos + 5, { align: 'right' })
currentX += colWidths[1]
// Orders
pdf.text(dailySales.orders.toString(), currentX + colWidths[2] / 2, yPos + 5, { align: 'center' })
currentX += colWidths[2]
// Net Sales
pdf.text(this.formatCurrency(dailySales.net_sales), currentX + colWidths[3] - 2, yPos + 5, { align: 'right' })
// Draw bottom border line
pdf.setDrawColor(230, 230, 230)
pdf.setLineWidth(0.3)
pdf.line(marginLeft, yPos + 8, marginLeft + tableWidth, yPos + 8)
yPos += 10
})
// Table footer (Total)
pdf.setFillColor(245, 245, 245)
pdf.rect(marginLeft, yPos, tableWidth, 10, 'F')
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(9)
currentX = marginLeft
pdf.text('TOTAL', currentX + 2, yPos + 6)
currentX += colWidths[0]
pdf.text(this.formatCurrency(salesData.summary.total_sales), currentX + colWidths[1] - 2, yPos + 6, {
align: 'right'
})
currentX += colWidths[1]
pdf.text(salesData.summary.total_orders.toString(), currentX + colWidths[2] / 2, yPos + 6, { align: 'center' })
currentX += colWidths[2]
pdf.text(this.formatCurrency(salesData.summary.net_sales), currentX + colWidths[3] - 2, yPos + 6, {
align: 'right'
})
return yPos + 20
}
}
@@ -1,556 +0,0 @@
// services/pdfExportCategoryService.ts
import { CategoryReport } from '@/types/services/analytic'
export class PDFExportSalesProductCategoryService {
/**
* Export Category Sales Report to PDF
*/
static async exportCategorySalesToPDF(categoryData: CategoryReport, filename?: string) {
try {
// Dynamic import untuk jsPDF
const jsPDFModule = await import('jspdf')
const jsPDF = jsPDFModule.default
// Create new PDF document - PORTRAIT A4
const pdf = new jsPDF('p', 'mm', 'a4')
// Add content
await this.addCategoryReportContent(pdf, categoryData)
// Generate filename
const exportFilename = filename || this.generateFilename('Laporan_Penjualan_Kategori', 'pdf')
// Save PDF
pdf.save(exportFilename)
return { success: true, filename: exportFilename }
} catch (error) {
console.error('Error exporting category report to PDF:', error)
return { success: false, error: `PDF export failed: ${(error as Error).message}` }
}
}
/**
* Add category report content to PDF
*/
private static async addCategoryReportContent(pdf: any, categoryData: CategoryReport) {
let yPos = 20
const pageWidth = pdf.internal.pageSize.getWidth()
const pageHeight = pdf.internal.pageSize.getHeight()
const marginLeft = 20
const marginRight = 20
const marginBottom = 15
// Helper function to check page break
const checkPageBreak = (neededSpace: number) => {
if (yPos + neededSpace > pageHeight - marginBottom) {
pdf.addPage()
yPos = 20
return true
}
return false
}
// Title
yPos = this.addReportTitle(pdf, categoryData, yPos, pageWidth, marginLeft, marginRight)
// Section 1: Ringkasan
checkPageBreak(50)
yPos = this.addRingkasanSection(pdf, categoryData, yPos, pageWidth, marginLeft, marginRight, checkPageBreak)
// Section 2: Category Details
checkPageBreak(80)
yPos = this.addCategoryDetailsSection(pdf, categoryData, yPos, pageWidth, marginLeft, marginRight, checkPageBreak)
}
/**
* Add report title
*/
private static addReportTitle(
pdf: any,
categoryData: CategoryReport,
startY: number,
pageWidth: number,
marginLeft: number,
marginRight: number
): number {
let yPos = startY
// Title
pdf.setFontSize(20)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(0, 0, 0)
pdf.text('Laporan Penjualan Kategori', pageWidth / 2, yPos, { align: 'center' })
yPos += 10
// Period
pdf.setFontSize(12)
pdf.setFont('helvetica', 'normal')
const periodText = `${categoryData.date_from.split('T')[0]} - ${categoryData.date_to.split('T')[0]}`
pdf.text(periodText, pageWidth / 2, yPos, { align: 'center' })
yPos += 10
// Purple line separator
pdf.setDrawColor(102, 45, 145)
pdf.setLineWidth(2)
pdf.line(marginLeft, yPos, pageWidth - marginRight, yPos)
yPos += 15
return yPos
}
/**
* Add Ringkasan section
*/
private static addRingkasanSection(
pdf: any,
categoryData: CategoryReport,
startY: number,
pageWidth: number,
marginLeft: number,
marginRight: number,
checkPageBreak: (space: number) => boolean
): number {
let yPos = startY
// Section title
pdf.setFontSize(14)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(102, 45, 145)
pdf.text('Ringkasan', marginLeft, yPos)
yPos += 12
// Reset text color
pdf.setTextColor(0, 0, 0)
pdf.setFontSize(11)
// Calculate summary
const categorySummary = {
totalRevenue: categoryData.data?.reduce((sum, item) => sum + (item?.total_revenue || 0), 0) || 0,
orderCount: categoryData.data?.reduce((sum, item) => sum + (item?.order_count || 0), 0) || 0,
productCount: categoryData.data?.reduce((sum, item) => sum + (item?.product_count || 0), 0) || 0,
totalQuantity: categoryData.data?.reduce((sum, item) => sum + (item?.total_quantity || 0), 0) || 0,
totalCategories: categoryData.data?.length || 0,
averageRevenuePerCategory: 0
}
categorySummary.averageRevenuePerCategory =
categorySummary.totalCategories > 0 ? categorySummary.totalRevenue / categorySummary.totalCategories : 0
const ringkasanItems = [
{ label: 'Total Kategori', value: categorySummary.totalCategories.toString(), bold: false },
{ label: 'Total Produk', value: categorySummary.productCount.toString(), bold: false },
{ label: 'Total Quantity', value: categorySummary.totalQuantity.toString(), bold: false },
{ label: 'Total Orders', value: categorySummary.orderCount.toString(), bold: false },
{ label: 'Total Revenue', value: this.formatCurrency(categorySummary.totalRevenue), bold: true },
{
label: 'Rata-rata Revenue per Kategori',
value: this.formatCurrency(categorySummary.averageRevenuePerCategory),
bold: false
}
]
ringkasanItems.forEach((item, index) => {
if (checkPageBreak(15)) yPos = 20
// Set font weight
pdf.setFont('helvetica', item.bold ? 'bold' : 'normal')
pdf.text(item.label, marginLeft, yPos)
pdf.text(item.value, pageWidth - marginRight, yPos, { align: 'right' })
// Light separator line (except for bold row)
if (!item.bold) {
pdf.setDrawColor(230, 230, 230)
pdf.setLineWidth(0.5)
pdf.line(marginLeft, yPos + 2, pageWidth - marginRight, yPos + 2)
}
yPos += 8
})
return yPos + 20
}
/**
* Add Category Details section
*/
private static addCategoryDetailsSection(
pdf: any,
categoryData: CategoryReport,
startY: number,
pageWidth: number,
marginLeft: number,
marginRight: number,
checkPageBreak: (space: number) => boolean
): number {
let yPos = startY
// Section title
pdf.setFontSize(14)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(102, 45, 145)
pdf.text('Rincian Kategori', marginLeft, yPos)
yPos += 12
// Reset formatting
pdf.setTextColor(0, 0, 0)
// Table setup
const tableWidth = pageWidth - marginLeft - marginRight
const colWidths = [50, 30, 25, 35] // Name, Products, Qty, Revenue
let currentX = marginLeft
// Table header
pdf.setFillColor(240, 240, 240)
pdf.rect(marginLeft, yPos, tableWidth, 10, 'F')
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(9)
const headers = ['Nama', 'Total Produk', 'Qty', 'Pendapatan']
currentX = marginLeft
headers.forEach((header, index) => {
if (index === 0) {
pdf.text(header, currentX + 2, yPos + 6)
} else {
pdf.text(header, currentX + colWidths[index] / 2, yPos + 6, { align: 'center' })
}
currentX += colWidths[index]
})
yPos += 12
// Calculate summary for footer
const categorySummary = {
totalRevenue: categoryData.data?.reduce((sum, item) => sum + (item?.total_revenue || 0), 0) || 0,
productCount: categoryData.data?.reduce((sum, item) => sum + (item?.product_count || 0), 0) || 0,
totalQuantity: categoryData.data?.reduce((sum, item) => sum + (item?.total_quantity || 0), 0) || 0
}
// Table rows
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(9)
categoryData.data?.forEach((category, index) => {
if (checkPageBreak(10)) yPos = 20
currentX = marginLeft
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(9)
pdf.setTextColor(0, 0, 0)
// Category name
pdf.text(category.category_name, currentX + 2, yPos + 5)
currentX += colWidths[0]
// Product count
pdf.text(category.product_count.toString(), currentX + colWidths[1] / 2, yPos + 5, { align: 'center' })
currentX += colWidths[1]
// Quantity
pdf.text(category.total_quantity.toString(), currentX + colWidths[2] / 2, yPos + 5, { align: 'center' })
currentX += colWidths[2]
// Revenue
pdf.text(this.formatCurrency(category.total_revenue), currentX + colWidths[3] - 2, yPos + 5, { align: 'right' })
// Draw bottom border line
pdf.setDrawColor(230, 230, 230)
pdf.setLineWidth(0.3)
pdf.line(marginLeft, yPos + 8, marginLeft + tableWidth, yPos + 8)
yPos += 10
})
// Table footer (Total)
pdf.setFillColor(245, 245, 245) // Lighter gray
pdf.rect(marginLeft, yPos, tableWidth, 10, 'F')
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(9)
currentX = marginLeft
pdf.text('TOTAL', currentX + 2, yPos + 6)
currentX += colWidths[0]
pdf.text(categorySummary.productCount.toString(), currentX + colWidths[1] / 2, yPos + 6, { align: 'center' })
currentX += colWidths[1]
pdf.text(categorySummary.totalQuantity.toString(), currentX + colWidths[2] / 2, yPos + 6, { align: 'center' })
currentX += colWidths[2]
pdf.text(this.formatCurrency(categorySummary.totalRevenue), currentX + colWidths[3] - 2, yPos + 6, {
align: 'right'
})
return yPos + 20
}
/**
* Format currency for display
*/
private static formatCurrency(amount: number): string {
return `Rp ${amount.toLocaleString('id-ID')}`
}
/**
* Generate filename with timestamp
*/
private static generateFilename(prefix: string, extension: string): string {
const now = new Date()
const year = now.getFullYear()
const month = (now.getMonth() + 1).toString().padStart(2, '0')
const day = now.getDate().toString().padStart(2, '0')
const hour = now.getHours().toString().padStart(2, '0')
const minute = now.getMinutes().toString().padStart(2, '0')
return `${prefix}_${year}_${month}_${day}_${hour}${minute}.${extension}`
}
/**
* Export Category Sales data with custom configuration
*/
static async exportCustomCategoryToPDF(
categoryData: CategoryReport,
options?: {
title?: string
includeSummary?: boolean
customFilename?: string
includeOrderCount?: boolean
}
) {
try {
const jsPDFModule = await import('jspdf')
const jsPDF = jsPDFModule.default
const pdf = new jsPDF('p', 'mm', 'a4')
let yPos = 20
const pageWidth = pdf.internal.pageSize.getWidth()
const pageHeight = pdf.internal.pageSize.getHeight()
const marginLeft = 20
const marginRight = 20
const marginBottom = 15
const checkPageBreak = (neededSpace: number) => {
if (yPos + neededSpace > pageHeight - marginBottom) {
pdf.addPage()
yPos = 20
return true
}
return false
}
// Custom title if provided
if (options?.title) {
pdf.setFontSize(20)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(0, 0, 0)
pdf.text(options.title, pageWidth / 2, yPos, { align: 'center' })
yPos += 15
} else {
yPos = this.addReportTitle(pdf, categoryData, yPos, pageWidth, marginLeft, marginRight)
}
// Optional summary section
if (options?.includeSummary !== false) {
checkPageBreak(50)
yPos = this.addRingkasanSection(pdf, categoryData, yPos, pageWidth, marginLeft, marginRight, checkPageBreak)
}
// Category details
checkPageBreak(80)
yPos = this.addCategoryDetailsSection(pdf, categoryData, yPos, pageWidth, marginLeft, marginRight, checkPageBreak)
const exportFilename = options?.customFilename || this.generateFilename('Custom_Category_Sales', 'pdf')
pdf.save(exportFilename)
return { success: true, filename: exportFilename }
} catch (error) {
console.error('Error exporting custom category report to PDF:', error)
return { success: false, error: `PDF export failed: ${(error as Error).message}` }
}
}
/**
* Export Category Sales with extended table including order count
*/
static async exportExtendedCategoryToPDF(categoryData: CategoryReport, filename?: string) {
try {
const jsPDFModule = await import('jspdf')
const jsPDF = jsPDFModule.default
const pdf = new jsPDF('p', 'mm', 'a4')
let yPos = 20
const pageWidth = pdf.internal.pageSize.getWidth()
const pageHeight = pdf.internal.pageSize.getHeight()
const marginLeft = 20
const marginRight = 20
const marginBottom = 15
const checkPageBreak = (neededSpace: number) => {
if (yPos + neededSpace > pageHeight - marginBottom) {
pdf.addPage()
yPos = 20
return true
}
return false
}
// Title section
yPos = this.addReportTitle(pdf, categoryData, yPos, pageWidth, marginLeft, marginRight)
// Summary section
checkPageBreak(50)
yPos = this.addRingkasanSection(pdf, categoryData, yPos, pageWidth, marginLeft, marginRight, checkPageBreak)
// Extended table with order count
checkPageBreak(80)
yPos = this.addExtendedCategoryDetailsSection(
pdf,
categoryData,
yPos,
pageWidth,
marginLeft,
marginRight,
checkPageBreak
)
const exportFilename = filename || this.generateFilename('Laporan_Kategori_Extended', 'pdf')
pdf.save(exportFilename)
return { success: true, filename: exportFilename }
} catch (error) {
console.error('Error exporting extended category report to PDF:', error)
return { success: false, error: `PDF export failed: ${(error as Error).message}` }
}
}
/**
* Add Extended Category Details section with order count
*/
private static addExtendedCategoryDetailsSection(
pdf: any,
categoryData: CategoryReport,
startY: number,
pageWidth: number,
marginLeft: number,
marginRight: number,
checkPageBreak: (space: number) => boolean
): number {
let yPos = startY
// Section title
pdf.setFontSize(14)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(102, 45, 145)
pdf.text('Rincian Kategori (Extended)', marginLeft, yPos)
yPos += 12
// Reset formatting
pdf.setTextColor(0, 0, 0)
// Table setup - wider for 5 columns
const tableWidth = pageWidth - marginLeft - marginRight
const colWidths = [40, 25, 20, 20, 35] // Name, Products, Qty, Orders, Revenue
let currentX = marginLeft
// Table header
pdf.setFillColor(240, 240, 240)
pdf.rect(marginLeft, yPos, tableWidth, 10, 'F')
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(9)
const headers = ['Nama', 'Total Produk', 'Qty', 'Orders', 'Pendapatan']
currentX = marginLeft
headers.forEach((header, index) => {
if (index === 0) {
pdf.text(header, currentX + 2, yPos + 6)
} else {
pdf.text(header, currentX + colWidths[index] / 2, yPos + 6, { align: 'center' })
}
currentX += colWidths[index]
})
yPos += 12
// Calculate summary for footer
const categorySummary = {
totalRevenue: categoryData.data?.reduce((sum, item) => sum + (item?.total_revenue || 0), 0) || 0,
productCount: categoryData.data?.reduce((sum, item) => sum + (item?.product_count || 0), 0) || 0,
totalQuantity: categoryData.data?.reduce((sum, item) => sum + (item?.total_quantity || 0), 0) || 0,
orderCount: categoryData.data?.reduce((sum, item) => sum + (item?.order_count || 0), 0) || 0
}
// Table rows
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(9)
categoryData.data?.forEach((category, index) => {
if (checkPageBreak(10)) yPos = 20
currentX = marginLeft
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(9)
pdf.setTextColor(0, 0, 0)
// Category name
const categoryName =
category.category_name.length > 30 ? category.category_name.substring(0, 27) + '...' : category.category_name
pdf.text(categoryName, currentX + 2, yPos + 5)
currentX += colWidths[0]
// Product count
pdf.text(category.product_count.toString(), currentX + colWidths[1] / 2, yPos + 5, { align: 'center' })
currentX += colWidths[1]
// Quantity
pdf.text(category.total_quantity.toString(), currentX + colWidths[2] / 2, yPos + 5, { align: 'center' })
currentX += colWidths[2]
// Order count
pdf.text(category.order_count.toString(), currentX + colWidths[3] / 2, yPos + 5, { align: 'center' })
currentX += colWidths[3]
// Revenue
pdf.text(this.formatCurrency(category.total_revenue), currentX + colWidths[4] - 2, yPos + 5, { align: 'right' })
// Draw bottom border line
pdf.setDrawColor(230, 230, 230)
pdf.setLineWidth(0.3)
pdf.line(marginLeft, yPos + 8, marginLeft + tableWidth, yPos + 8)
yPos += 10
})
// Table footer (Total)
pdf.setFillColor(245, 245, 245) // Lighter gray
pdf.rect(marginLeft, yPos, tableWidth, 10, 'F')
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(9)
currentX = marginLeft
pdf.text('TOTAL', currentX + 2, yPos + 6)
currentX += colWidths[0]
pdf.text(categorySummary.productCount.toString(), currentX + colWidths[1] / 2, yPos + 6, { align: 'center' })
currentX += colWidths[1]
pdf.text(categorySummary.totalQuantity.toString(), currentX + colWidths[2] / 2, yPos + 6, { align: 'center' })
currentX += colWidths[2]
pdf.text(categorySummary.orderCount.toString(), currentX + colWidths[3] / 2, yPos + 6, { align: 'center' })
currentX += colWidths[3]
pdf.text(this.formatCurrency(categorySummary.totalRevenue), currentX + colWidths[4] - 2, yPos + 6, {
align: 'right'
})
return yPos + 20
}
}
@@ -1,691 +0,0 @@
import { CategoryReport, PaymentReport, ProductSalesReport, ProfitLossReport } from '@/types/services/analytic'
export interface SalesReportData {
profitLoss: ProfitLossReport
paymentAnalytics: PaymentReport
categoryAnalytics: CategoryReport
productAnalytics: ProductSalesReport
}
export class PDFExportSalesService {
/**
* Export Sales Report to PDF
*/
static async exportSalesReportToPDF(salesData: SalesReportData, filename?: string) {
try {
// Dynamic import untuk jsPDF
const jsPDFModule = await import('jspdf')
const jsPDF = jsPDFModule.default
// Create new PDF document - PORTRAIT A4
const pdf = new jsPDF('p', 'mm', 'a4')
// Add content
await this.addSalesReportContent(pdf, salesData)
// Generate filename
const exportFilename = filename || this.generateFilename('Laporan_Transaksi', 'pdf')
// Save PDF
pdf.save(exportFilename)
return { success: true, filename: exportFilename }
} catch (error) {
console.error('Error exporting sales report to PDF:', error)
return { success: false, error: `PDF export failed: ${(error as Error).message}` }
}
}
/**
* Add sales report content to PDF
*/
private static async addSalesReportContent(pdf: any, salesData: SalesReportData) {
let yPos = 20
const pageWidth = pdf.internal.pageSize.getWidth()
const pageHeight = pdf.internal.pageSize.getHeight()
const marginLeft = 20
const marginRight = 20
const marginBottom = 15
// Helper function to check page break
const checkPageBreak = (neededSpace: number) => {
if (yPos + neededSpace > pageHeight - marginBottom) {
pdf.addPage()
yPos = 20
return true
}
return false
}
// Title
yPos = this.addReportTitle(pdf, salesData.profitLoss, yPos, pageWidth, marginLeft, marginRight)
// Section 1: Ringkasan
checkPageBreak(50)
yPos = this.addRingkasanSection(pdf, salesData.profitLoss, yPos, pageWidth, marginLeft, marginRight, checkPageBreak)
// Section 2: Invoice Summary
checkPageBreak(40)
yPos = this.addInvoiceSection(pdf, salesData.profitLoss, yPos, pageWidth, marginLeft, marginRight, checkPageBreak)
// Section 3: Payment Methods
checkPageBreak(80)
yPos = this.addPaymentMethodsSection(
pdf,
salesData.paymentAnalytics,
yPos,
pageWidth,
marginLeft,
marginRight,
checkPageBreak
)
// Section 4: Category Summary
checkPageBreak(80)
yPos = this.addCategorySection(
pdf,
salesData.categoryAnalytics,
yPos,
pageWidth,
marginLeft,
marginRight,
checkPageBreak
)
// Section 5: Product Summary
checkPageBreak(100)
yPos = this.addProductSection(
pdf,
salesData.productAnalytics,
yPos,
pageWidth,
marginLeft,
marginRight,
checkPageBreak
)
}
/**
* Add report title
*/
private static addReportTitle(
pdf: any,
profitLoss: ProfitLossReport,
startY: number,
pageWidth: number,
marginLeft: number,
marginRight: number
): number {
let yPos = startY
// Title
pdf.setFontSize(20)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(0, 0, 0)
pdf.text('Laporan Transaksi', pageWidth / 2, yPos, { align: 'center' })
yPos += 10
// Period
pdf.setFontSize(12)
pdf.setFont('helvetica', 'normal')
const periodText = `${profitLoss.date_from.split('T')[0]} - ${profitLoss.date_to.split('T')[0]}`
pdf.text(periodText, pageWidth / 2, yPos, { align: 'center' })
yPos += 10
// Purple line separator
pdf.setDrawColor(102, 45, 145)
pdf.setLineWidth(2)
pdf.line(marginLeft, yPos, pageWidth - marginRight, yPos)
yPos += 15
return yPos
}
/**
* Add Ringkasan section - SAMAKAN DENGAN PAYMENT METHODS STYLE
*/
private static addRingkasanSection(
pdf: any,
profitLoss: ProfitLossReport,
startY: number,
pageWidth: number,
marginLeft: number,
marginRight: number,
checkPageBreak: (space: number) => boolean
): number {
let yPos = startY
// Section title - SAMA SEPERTI PAYMENT METHODS
pdf.setFontSize(14)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(102, 45, 145)
pdf.text('Ringkasan', marginLeft, yPos)
yPos += 12
// Reset text color
pdf.setTextColor(0, 0, 0)
pdf.setFontSize(11)
const ringkasanItems = [
{ label: 'Total Penjualan', value: this.formatCurrency(profitLoss.summary.total_revenue), bold: false },
{ label: 'Total Diskon', value: this.formatCurrency(profitLoss.summary.total_discount), bold: false },
{ label: 'Total Pajak', value: this.formatCurrency(profitLoss.summary.total_tax), bold: false },
{ label: 'Total', value: this.formatCurrency(profitLoss.summary.total_revenue), bold: true }
]
ringkasanItems.forEach((item, index) => {
if (checkPageBreak(15)) yPos = 20
// Set font weight
pdf.setFont('helvetica', item.bold ? 'bold' : 'normal')
pdf.text(item.label, marginLeft, yPos)
pdf.text(item.value, pageWidth - marginRight, yPos, { align: 'right' })
// Light separator line (except for total row)
if (!item.bold) {
pdf.setDrawColor(230, 230, 230)
pdf.setLineWidth(0.5)
pdf.line(marginLeft, yPos + 2, pageWidth - marginRight, yPos + 2)
}
yPos += 8
})
return yPos + 20
}
/**
* Add Invoice section - SAMAKAN DENGAN PAYMENT METHODS STYLE
*/
private static addInvoiceSection(
pdf: any,
profitLoss: ProfitLossReport,
startY: number,
pageWidth: number,
marginLeft: number,
marginRight: number,
checkPageBreak: (space: number) => boolean
): number {
let yPos = startY
// Section title - SAMA SEPERTI PAYMENT METHODS
pdf.setFontSize(14)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(102, 45, 145)
pdf.text('Invoice', marginLeft, yPos)
yPos += 12
// Reset formatting
pdf.setTextColor(0, 0, 0)
pdf.setFontSize(11)
const invoiceItems = [
{ label: 'Total Invoice', value: profitLoss.summary.total_orders.toString() },
{ label: 'Rata-rata Tagihan Per Invoice', value: this.formatCurrency(profitLoss.summary.average_profit) }
]
invoiceItems.forEach((item, index) => {
if (checkPageBreak(15)) yPos = 20
pdf.setFont('helvetica', 'normal')
pdf.text(item.label, marginLeft, yPos)
pdf.text(item.value, pageWidth - marginRight, yPos, { align: 'right' })
// Light separator line
if (index < invoiceItems.length - 1) {
pdf.setDrawColor(230, 230, 230)
pdf.setLineWidth(0.5)
pdf.line(marginLeft, yPos + 2, pageWidth - marginRight, yPos + 2)
}
yPos += 8
})
return yPos + 20
}
/**
* Add Payment Methods section - ORIGINAL STYLE (3 jam lu bikin ini!)
*/
private static addPaymentMethodsSection(
pdf: any,
paymentAnalytics: PaymentReport,
startY: number,
pageWidth: number,
marginLeft: number,
marginRight: number,
checkPageBreak: (space: number) => boolean
): number {
let yPos = startY
// Section title
pdf.setFontSize(14)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(102, 45, 145)
pdf.text('Ringkasan Metode Pembayaran', marginLeft, yPos)
yPos += 12
// Reset formatting
pdf.setTextColor(0, 0, 0)
// Table setup
const tableWidth = pageWidth - marginLeft - marginRight
const colWidths = [50, 25, 30, 35, 25] // Method, Type, Order, Amount, %
let currentX = marginLeft
// Table header
pdf.setFillColor(240, 240, 240)
pdf.rect(marginLeft, yPos, tableWidth, 10, 'F')
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(9)
const headers = ['Metode Pembayaran', 'Tipe', 'Jumlah Order', 'Total Amount', 'Persentase']
currentX = marginLeft
headers.forEach((header, index) => {
if (index === 0) {
pdf.text(header, currentX + 2, yPos + 6)
} else {
pdf.text(header, currentX + colWidths[index] / 2, yPos + 6, { align: 'center' })
}
currentX += colWidths[index]
})
yPos += 12
// Table rows
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(9)
paymentAnalytics.data?.forEach((payment, index) => {
if (checkPageBreak(10)) yPos = 20
currentX = marginLeft
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(9)
pdf.setTextColor(0, 0, 0)
// Method name
pdf.text(payment.payment_method_name, currentX + 2, yPos + 5)
currentX += colWidths[0]
// Type with simple color coding
const typeText = payment.payment_method_type.toUpperCase()
if (payment.payment_method_type === 'cash') {
pdf.setTextColor(0, 120, 0) // Green
} else if (payment.payment_method_type === 'card') {
pdf.setTextColor(0, 80, 200) // Blue
} else {
pdf.setTextColor(200, 100, 0) // Orange
}
pdf.text(typeText, currentX + colWidths[1] / 2, yPos + 5, { align: 'center' })
pdf.setTextColor(0, 0, 0) // Reset color
currentX += colWidths[1]
// Order count
pdf.text(payment.order_count.toString(), currentX + colWidths[2] / 2, yPos + 5, { align: 'center' })
currentX += colWidths[2]
// Amount
pdf.text(this.formatCurrency(payment.total_amount), currentX + colWidths[3] - 2, yPos + 5, { align: 'right' })
currentX += colWidths[3]
// Percentage
pdf.text(`${(payment.percentage ?? 0).toFixed(1)}%`, currentX + colWidths[4] / 2, yPos + 5, { align: 'center' })
// Draw bottom border line
pdf.setDrawColor(230, 230, 230)
pdf.setLineWidth(0.3)
pdf.line(marginLeft, yPos + 8, marginLeft + tableWidth, yPos + 8)
yPos += 10
})
// Table footer (Total) - directly after last row
pdf.setFillColor(245, 245, 245) // Lighter gray
pdf.rect(marginLeft, yPos, tableWidth, 10, 'F')
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(9)
currentX = marginLeft
pdf.text('TOTAL', currentX + 2, yPos + 6)
currentX += colWidths[0] + colWidths[1]
pdf.text((paymentAnalytics.summary?.total_orders ?? 0).toString(), currentX + colWidths[2] / 2, yPos + 6, {
align: 'center'
})
currentX += colWidths[2]
pdf.text(this.formatCurrency(paymentAnalytics.summary?.total_amount ?? 0), currentX + colWidths[3] - 2, yPos + 6, {
align: 'right'
})
return yPos + 20
}
/**
* Add Category section - SAMAKAN DENGAN PAYMENT METHODS STYLE
*/
private static addCategorySection(
pdf: any,
categoryAnalytics: CategoryReport,
startY: number,
pageWidth: number,
marginLeft: number,
marginRight: number,
checkPageBreak: (space: number) => boolean
): number {
let yPos = startY
// Section title - SAMA SEPERTI PAYMENT METHODS
pdf.setFontSize(14)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(102, 45, 145)
pdf.text('Ringkasan Kategori', marginLeft, yPos)
yPos += 12
// Reset formatting
pdf.setTextColor(0, 0, 0)
// Table setup
const tableWidth = pageWidth - marginLeft - marginRight
const colWidths = [50, 30, 25, 35] // Name, Products, Qty, Revenue
let currentX = marginLeft
// Table header - SAMA SEPERTI PAYMENT METHODS
pdf.setFillColor(240, 240, 240)
pdf.rect(marginLeft, yPos, tableWidth, 10, 'F')
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(9)
const headers = ['Nama', 'Total Produk', 'Qty', 'Pendapatan']
currentX = marginLeft
headers.forEach((header, index) => {
if (index === 0) {
pdf.text(header, currentX + 2, yPos + 6)
} else {
pdf.text(header, currentX + colWidths[index] / 2, yPos + 6, { align: 'center' })
}
currentX += colWidths[index]
})
yPos += 12
// Calculate summaries
const categorySummary = {
totalRevenue: categoryAnalytics.data?.reduce((sum, item) => sum + (item?.total_revenue || 0), 0) || 0,
productCount: categoryAnalytics.data?.reduce((sum, item) => sum + (item?.product_count || 0), 0) || 0,
totalQuantity: categoryAnalytics.data?.reduce((sum, item) => sum + (item?.total_quantity || 0), 0) || 0
}
// Table rows - SAMA SEPERTI PAYMENT METHODS
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(9)
categoryAnalytics.data?.forEach((category, index) => {
if (checkPageBreak(10)) yPos = 20
currentX = marginLeft
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(9)
pdf.setTextColor(0, 0, 0)
// Category name
pdf.text(category.category_name, currentX + 2, yPos + 5)
currentX += colWidths[0]
// Product count
pdf.text(category.product_count.toString(), currentX + colWidths[1] / 2, yPos + 5, { align: 'center' })
currentX += colWidths[1]
// Quantity
pdf.text(category.total_quantity.toString(), currentX + colWidths[2] / 2, yPos + 5, { align: 'center' })
currentX += colWidths[2]
// Revenue
pdf.text(this.formatCurrency(category.total_revenue), currentX + colWidths[3] - 2, yPos + 5, { align: 'right' })
// Draw bottom border line - SAMA SEPERTI PAYMENT METHODS
pdf.setDrawColor(230, 230, 230)
pdf.setLineWidth(0.3)
pdf.line(marginLeft, yPos + 8, marginLeft + tableWidth, yPos + 8)
yPos += 10
})
// Table footer (Total) - SAMA SEPERTI PAYMENT METHODS
pdf.setFillColor(245, 245, 245) // Lighter gray
pdf.rect(marginLeft, yPos, tableWidth, 10, 'F')
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(9)
currentX = marginLeft
pdf.text('TOTAL', currentX + 2, yPos + 6)
currentX += colWidths[0]
pdf.text(categorySummary.productCount.toString(), currentX + colWidths[1] / 2, yPos + 6, { align: 'center' })
currentX += colWidths[1]
pdf.text(categorySummary.totalQuantity.toString(), currentX + colWidths[2] / 2, yPos + 6, { align: 'center' })
currentX += colWidths[2]
pdf.text(this.formatCurrency(categorySummary.totalRevenue), currentX + colWidths[3] - 2, yPos + 6, {
align: 'right'
})
return yPos + 20
}
/**
* Add Product section - SAMAKAN DENGAN PAYMENT METHODS STYLE
*/
private static addProductSection(
pdf: any,
productAnalytics: ProductSalesReport,
startY: number,
pageWidth: number,
marginLeft: number,
marginRight: number,
checkPageBreak: (space: number) => boolean
): number {
let yPos = startY // Hapus extra spacing, biar sama dengan section lain
// Section title - SAMA SEPERTI PAYMENT METHODS
pdf.setFontSize(14)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(102, 45, 145)
pdf.text('Ringkasan Item', marginLeft, yPos)
yPos += 12
// Reset formatting
pdf.setTextColor(0, 0, 0)
// Table setup
const tableWidth = pageWidth - marginLeft - marginRight
const colWidths = [60, 20, 20, 30, 30] // Product, Qty, Order, Revenue, Average
let currentX = marginLeft
// Table header - SAMA SEPERTI PAYMENT METHODS
pdf.setFillColor(240, 240, 240)
pdf.rect(marginLeft, yPos, tableWidth, 10, 'F')
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(9)
const headers = ['Produk', 'Qty', 'Order', 'Pendapatan', 'Rata Rata']
currentX = marginLeft
headers.forEach((header, index) => {
if (index === 0) {
pdf.text(header, currentX + 2, yPos + 6)
} else {
pdf.text(header, currentX + colWidths[index] / 2, yPos + 6, { align: 'center' })
}
currentX += colWidths[index]
})
yPos += 12
// Group products by category
const groupedProducts =
productAnalytics.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[]>
) || {}
// Calculate product summary
const productSummary = {
totalQuantitySold: productAnalytics.data?.reduce((sum, item) => sum + (item?.quantity_sold || 0), 0) || 0,
totalRevenue: productAnalytics.data?.reduce((sum, item) => sum + (item?.revenue || 0), 0) || 0,
totalOrders: productAnalytics.data?.reduce((sum, item) => sum + (item?.order_count || 0), 0) || 0
}
// Table rows - SAMA SEPERTI PAYMENT METHODS
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(9)
// Render grouped products
Object.keys(groupedProducts)
.sort()
.forEach(categoryName => {
const categoryProducts = groupedProducts[categoryName]
// Check page break for category header
if (checkPageBreak(10)) yPos = 20
// Category header - SAMA STYLE SEPERTI PAYMENT METHODS TAPI WARNA LEBIH SOFT
pdf.setFillColor(248, 248, 248) // Warna lebih soft
pdf.rect(marginLeft, yPos, tableWidth, 10, 'F')
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(9)
pdf.setTextColor(102, 45, 145)
pdf.text(categoryName.toUpperCase(), marginLeft + 2, yPos + 6)
pdf.setTextColor(0, 0, 0)
yPos += 10 // Kurangi jarak, langsung ke 10px seperti row normal
// Category products
categoryProducts.forEach((item, index) => {
if (checkPageBreak(10)) yPos = 20
currentX = marginLeft
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(9)
pdf.setTextColor(0, 0, 0)
// Product name (indented and truncated if needed)
const productName =
item.product_name.length > 45 ? item.product_name.substring(0, 42) + '...' : item.product_name
pdf.text(` ${productName}`, currentX + 2, yPos + 5) // Indented for products
currentX += colWidths[0]
// Quantity
pdf.text(item.quantity_sold.toString(), currentX + colWidths[1] / 2, yPos + 5, { align: 'center' })
currentX += colWidths[1]
// Order count
pdf.text((item.order_count || 0).toString(), currentX + colWidths[2] / 2, yPos + 5, { align: 'center' })
currentX += colWidths[2]
// Revenue
pdf.text(this.formatCurrency(item.revenue), currentX + colWidths[3] - 2, yPos + 5, { align: 'right' })
currentX += colWidths[3]
// Average price
pdf.text(this.formatCurrency(item.average_price), currentX + colWidths[4] - 2, yPos + 5, { align: 'right' })
// Draw bottom border line - SAMA SEPERTI PAYMENT METHODS
pdf.setDrawColor(230, 230, 230)
pdf.setLineWidth(0.3)
pdf.line(marginLeft, yPos + 8, marginLeft + tableWidth, yPos + 8)
yPos += 10
})
// Category subtotal - WARNA LEBIH SOFT
if (checkPageBreak(10)) yPos = 20
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)
pdf.setFillColor(240, 240, 240) // Sama dengan table header, lebih soft
pdf.rect(marginLeft, yPos, tableWidth, 10, 'F')
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(9)
currentX = marginLeft
pdf.text(`Subtotal ${categoryName}`, currentX + 2, yPos + 6)
currentX += colWidths[0]
pdf.text(categoryTotalQty.toString(), currentX + colWidths[1] / 2, yPos + 6, { align: 'center' })
currentX += colWidths[1]
pdf.text(categoryTotalOrders.toString(), currentX + colWidths[2] / 2, yPos + 6, { align: 'center' })
currentX += colWidths[2]
pdf.text(this.formatCurrency(categoryTotalRevenue), currentX + colWidths[3] - 2, yPos + 6, { align: 'right' })
yPos += 10 // Kurangi spacing dari 15 ke 10
})
// Grand total - SAMA SEPERTI PAYMENT METHODS FOOTER
if (checkPageBreak(10)) yPos = 20
pdf.setFillColor(245, 245, 245) // Lighter gray - SAMA SEPERTI PAYMENT METHODS
pdf.rect(marginLeft, yPos, tableWidth, 10, 'F')
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(9)
currentX = marginLeft
pdf.text('TOTAL KESELURUHAN', currentX + 2, yPos + 6)
currentX += colWidths[0]
pdf.text(productSummary.totalQuantitySold.toString(), currentX + colWidths[1] / 2, yPos + 6, { align: 'center' })
currentX += colWidths[1]
pdf.text(productSummary.totalOrders.toString(), currentX + colWidths[2] / 2, yPos + 6, { align: 'center' })
currentX += colWidths[2]
pdf.text(this.formatCurrency(productSummary.totalRevenue), currentX + colWidths[3] - 2, yPos + 6, {
align: 'right'
})
return yPos + 25
}
/**
* Format currency for display
*/
private static formatCurrency(amount: number): string {
return `Rp ${amount.toLocaleString('id-ID')}`
}
/**
* Generate filename with timestamp
*/
private static generateFilename(prefix: string, extension: string): string {
const now = new Date()
const year = now.getFullYear()
const month = (now.getMonth() + 1).toString().padStart(2, '0')
const day = now.getDate().toString().padStart(2, '0')
const hour = now.getHours().toString().padStart(2, '0')
const minute = now.getMinutes().toString().padStart(2, '0')
return `${prefix}_${year}_${month}_${day}_${hour}${minute}.${extension}`
}
}
@@ -1,439 +0,0 @@
// services/pdfExportProductService.ts
import { ProductSalesReport } from '@/types/services/analytic'
export class PDFExportSalesProductService {
/**
* Export Product Sales Report to PDF
*/
static async exportProductSalesToPDF(productData: ProductSalesReport, filename?: string) {
try {
// Dynamic import untuk jsPDF
const jsPDFModule = await import('jspdf')
const jsPDF = jsPDFModule.default
// Create new PDF document - PORTRAIT A4
const pdf = new jsPDF('p', 'mm', 'a4')
// Add content
await this.addProductReportContent(pdf, productData)
// Generate filename
const exportFilename = filename || this.generateFilename('Laporan_Penjualan_Produk', 'pdf')
// Save PDF
pdf.save(exportFilename)
return { success: true, filename: exportFilename }
} catch (error) {
console.error('Error exporting product report to PDF:', error)
return { success: false, error: `PDF export failed: ${(error as Error).message}` }
}
}
/**
* Add product report content to PDF
*/
private static async addProductReportContent(pdf: any, productData: ProductSalesReport) {
let yPos = 20
const pageWidth = pdf.internal.pageSize.getWidth()
const pageHeight = pdf.internal.pageSize.getHeight()
const marginLeft = 20
const marginRight = 20
const marginBottom = 15
// Helper function to check page break
const checkPageBreak = (neededSpace: number) => {
if (yPos + neededSpace > pageHeight - marginBottom) {
pdf.addPage()
yPos = 20
return true
}
return false
}
// Title
yPos = this.addReportTitle(pdf, productData, yPos, pageWidth, marginLeft, marginRight)
// Section 1: Ringkasan
checkPageBreak(50)
yPos = this.addRingkasanSection(pdf, productData, yPos, pageWidth, marginLeft, marginRight, checkPageBreak)
// Section 2: Product Details
checkPageBreak(100)
yPos = this.addProductDetailsSection(pdf, productData, yPos, pageWidth, marginLeft, marginRight, checkPageBreak)
}
/**
* Add report title
*/
private static addReportTitle(
pdf: any,
productData: ProductSalesReport,
startY: number,
pageWidth: number,
marginLeft: number,
marginRight: number
): number {
let yPos = startY
// Title
pdf.setFontSize(20)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(0, 0, 0)
pdf.text('Laporan Penjualan Produk', pageWidth / 2, yPos, { align: 'center' })
yPos += 10
// Period
pdf.setFontSize(12)
pdf.setFont('helvetica', 'normal')
const periodText = `${productData.date_from.split('T')[0]} - ${productData.date_to.split('T')[0]}`
pdf.text(periodText, pageWidth / 2, yPos, { align: 'center' })
yPos += 10
// Purple line separator
pdf.setDrawColor(102, 45, 145)
pdf.setLineWidth(2)
pdf.line(marginLeft, yPos, pageWidth - marginRight, yPos)
yPos += 15
return yPos
}
/**
* Add Ringkasan section
*/
private static addRingkasanSection(
pdf: any,
productData: ProductSalesReport,
startY: number,
pageWidth: number,
marginLeft: number,
marginRight: number,
checkPageBreak: (space: number) => boolean
): number {
let yPos = startY
// Section title
pdf.setFontSize(14)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(102, 45, 145)
pdf.text('Ringkasan', marginLeft, yPos)
yPos += 12
// Reset text color
pdf.setTextColor(0, 0, 0)
pdf.setFontSize(11)
// Calculate summary
const productSummary = {
totalQuantitySold: productData.data?.reduce((sum, item) => sum + (item?.quantity_sold || 0), 0) || 0,
totalRevenue: productData.data?.reduce((sum, item) => sum + (item?.revenue || 0), 0) || 0,
totalOrders: productData.data?.reduce((sum, item) => sum + (item?.order_count || 0), 0) || 0,
averageRevenue: 0,
totalProducts: productData.data?.length || 0
}
productSummary.averageRevenue =
productSummary.totalQuantitySold > 0 ? productSummary.totalRevenue / productSummary.totalQuantitySold : 0
const ringkasanItems = [
{ label: 'Total Produk', value: productSummary.totalProducts.toString(), bold: false },
{ label: 'Total Quantity Sold', value: productSummary.totalQuantitySold.toString(), bold: false },
{ label: 'Total Orders', value: productSummary.totalOrders.toString(), bold: false },
{ label: 'Total Revenue', value: this.formatCurrency(productSummary.totalRevenue), bold: true },
{ label: 'Average Revenue per Item', value: this.formatCurrency(productSummary.averageRevenue), bold: false }
]
ringkasanItems.forEach((item, index) => {
if (checkPageBreak(15)) yPos = 20
// Set font weight
pdf.setFont('helvetica', item.bold ? 'bold' : 'normal')
pdf.text(item.label, marginLeft, yPos)
pdf.text(item.value, pageWidth - marginRight, yPos, { align: 'right' })
// Light separator line (except for bold row)
if (!item.bold) {
pdf.setDrawColor(230, 230, 230)
pdf.setLineWidth(0.5)
pdf.line(marginLeft, yPos + 2, pageWidth - marginRight, yPos + 2)
}
yPos += 8
})
return yPos + 20
}
/**
* Add Product Details section - SAMA STYLE SEPERTI SALES REPORT
*/
private static addProductDetailsSection(
pdf: any,
productData: ProductSalesReport,
startY: number,
pageWidth: number,
marginLeft: number,
marginRight: number,
checkPageBreak: (space: number) => boolean
): number {
let yPos = startY
// Section title
pdf.setFontSize(14)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(102, 45, 145)
pdf.text('Rincian Produk', marginLeft, yPos)
yPos += 12
// Reset formatting
pdf.setTextColor(0, 0, 0)
// Table setup
const tableWidth = pageWidth - marginLeft - marginRight
const colWidths = [60, 20, 20, 30, 30] // Product, Qty, Order, Revenue, Average
let currentX = marginLeft
// Table header
pdf.setFillColor(240, 240, 240)
pdf.rect(marginLeft, yPos, tableWidth, 10, 'F')
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(9)
const headers = ['Produk', 'Qty', 'Order', 'Pendapatan', 'Rata Rata']
currentX = marginLeft
headers.forEach((header, index) => {
if (index === 0) {
pdf.text(header, currentX + 2, yPos + 6)
} else {
pdf.text(header, currentX + colWidths[index] / 2, yPos + 6, { align: 'center' })
}
currentX += colWidths[index]
})
yPos += 12
// Group products by category
const groupedProducts =
productData.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[]>
) || {}
// Calculate product summary
const productSummary = {
totalQuantitySold: productData.data?.reduce((sum, item) => sum + (item?.quantity_sold || 0), 0) || 0,
totalRevenue: productData.data?.reduce((sum, item) => sum + (item?.revenue || 0), 0) || 0,
totalOrders: productData.data?.reduce((sum, item) => sum + (item?.order_count || 0), 0) || 0
}
// Table rows
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(9)
// Render grouped products
Object.keys(groupedProducts)
.sort()
.forEach(categoryName => {
const categoryProducts = groupedProducts[categoryName]
// Check page break for category header
if (checkPageBreak(10)) yPos = 20
// Category header
pdf.setFillColor(248, 248, 248) // Soft background
pdf.rect(marginLeft, yPos, tableWidth, 10, 'F')
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(9)
pdf.setTextColor(102, 45, 145)
pdf.text(categoryName.toUpperCase(), marginLeft + 2, yPos + 6)
pdf.setTextColor(0, 0, 0)
yPos += 10
// Category products
categoryProducts.forEach((item, index) => {
if (checkPageBreak(10)) yPos = 20
currentX = marginLeft
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(9)
pdf.setTextColor(0, 0, 0)
// Product name (indented and truncated if needed)
const productName =
item.product_name.length > 45 ? item.product_name.substring(0, 42) + '...' : item.product_name
pdf.text(` ${productName}`, currentX + 2, yPos + 5) // Indented for products
currentX += colWidths[0]
// Quantity
pdf.text(item.quantity_sold.toString(), currentX + colWidths[1] / 2, yPos + 5, { align: 'center' })
currentX += colWidths[1]
// Order count
pdf.text((item.order_count || 0).toString(), currentX + colWidths[2] / 2, yPos + 5, { align: 'center' })
currentX += colWidths[2]
// Revenue
pdf.text(this.formatCurrency(item.revenue), currentX + colWidths[3] - 2, yPos + 5, { align: 'right' })
currentX += colWidths[3]
// Average price
pdf.text(this.formatCurrency(item.average_price), currentX + colWidths[4] - 2, yPos + 5, { align: 'right' })
// Draw bottom border line
pdf.setDrawColor(230, 230, 230)
pdf.setLineWidth(0.3)
pdf.line(marginLeft, yPos + 8, marginLeft + tableWidth, yPos + 8)
yPos += 10
})
// Category subtotal
if (checkPageBreak(10)) yPos = 20
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)
pdf.setFillColor(240, 240, 240) // Sama dengan table header
pdf.rect(marginLeft, yPos, tableWidth, 10, 'F')
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(9)
currentX = marginLeft
pdf.text(`Subtotal ${categoryName}`, currentX + 2, yPos + 6)
currentX += colWidths[0]
pdf.text(categoryTotalQty.toString(), currentX + colWidths[1] / 2, yPos + 6, { align: 'center' })
currentX += colWidths[1]
pdf.text(categoryTotalOrders.toString(), currentX + colWidths[2] / 2, yPos + 6, { align: 'center' })
currentX += colWidths[2]
pdf.text(this.formatCurrency(categoryTotalRevenue), currentX + colWidths[3] - 2, yPos + 6, { align: 'right' })
yPos += 10
})
// Grand total
if (checkPageBreak(10)) yPos = 20
pdf.setFillColor(245, 245, 245) // Lighter gray
pdf.rect(marginLeft, yPos, tableWidth, 10, 'F')
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(9)
currentX = marginLeft
pdf.text('TOTAL KESELURUHAN', currentX + 2, yPos + 6)
currentX += colWidths[0]
pdf.text(productSummary.totalQuantitySold.toString(), currentX + colWidths[1] / 2, yPos + 6, { align: 'center' })
currentX += colWidths[1]
pdf.text(productSummary.totalOrders.toString(), currentX + colWidths[2] / 2, yPos + 6, { align: 'center' })
currentX += colWidths[2]
pdf.text(this.formatCurrency(productSummary.totalRevenue), currentX + colWidths[3] - 2, yPos + 6, {
align: 'right'
})
return yPos + 25
}
/**
* Format currency for display
*/
private static formatCurrency(amount: number): string {
return `Rp ${amount.toLocaleString('id-ID')}`
}
/**
* Generate filename with timestamp
*/
private static generateFilename(prefix: string, extension: string): string {
const now = new Date()
const year = now.getFullYear()
const month = (now.getMonth() + 1).toString().padStart(2, '0')
const day = now.getDate().toString().padStart(2, '0')
const hour = now.getHours().toString().padStart(2, '0')
const minute = now.getMinutes().toString().padStart(2, '0')
return `${prefix}_${year}_${month}_${day}_${hour}${minute}.${extension}`
}
/**
* Export Product Sales data with custom configuration
*/
static async exportCustomProductToPDF(
productData: ProductSalesReport,
options?: {
title?: string
includeSummary?: boolean
customFilename?: string
groupByCategory?: boolean
}
) {
try {
const jsPDFModule = await import('jspdf')
const jsPDF = jsPDFModule.default
const pdf = new jsPDF('p', 'mm', 'a4')
let yPos = 20
const pageWidth = pdf.internal.pageSize.getWidth()
const pageHeight = pdf.internal.pageSize.getHeight()
const marginLeft = 20
const marginRight = 20
const marginBottom = 15
const checkPageBreak = (neededSpace: number) => {
if (yPos + neededSpace > pageHeight - marginBottom) {
pdf.addPage()
yPos = 20
return true
}
return false
}
// Custom title if provided
if (options?.title) {
pdf.setFontSize(20)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(0, 0, 0)
pdf.text(options.title, pageWidth / 2, yPos, { align: 'center' })
yPos += 15
} else {
yPos = this.addReportTitle(pdf, productData, yPos, pageWidth, marginLeft, marginRight)
}
// Optional summary section
if (options?.includeSummary !== false) {
checkPageBreak(50)
yPos = this.addRingkasanSection(pdf, productData, yPos, pageWidth, marginLeft, marginRight, checkPageBreak)
}
// Product details
checkPageBreak(100)
yPos = this.addProductDetailsSection(pdf, productData, yPos, pageWidth, marginLeft, marginRight, checkPageBreak)
const exportFilename = options?.customFilename || this.generateFilename('Custom_Product_Sales', 'pdf')
pdf.save(exportFilename)
return { success: true, filename: exportFilename }
} catch (error) {
console.error('Error exporting custom product report to PDF:', error)
return { success: false, error: `PDF export failed: ${(error as Error).message}` }
}
}
}
+287
View File
@@ -0,0 +1,287 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '../api'
import { toast } from 'react-toastify'
import {
CreateDiscountPayload,
UpdateDiscountPayload,
AddOutletsPayload,
ReplaceOutletsPayload,
AddProductRulePayload,
UpdateProductRulePayload,
AddCategoryRulePayload,
UpdateCategoryRulePayload,
Discount,
DiscountOutlet,
ProductRule,
CategoryRule
} from '@/types/services/discount'
export const useDiscountsMutation = () => {
const queryClient = useQueryClient()
const createDiscount = useMutation({
mutationFn: async (payload: CreateDiscountPayload) => {
const response = await api.post('/discounts', payload)
return response.data
},
onSuccess: () => {
toast.success('Discount created successfully!')
queryClient.invalidateQueries({ queryKey: ['discounts'] })
},
onError: (error: any) => {
toast.error(error.response?.data?.errors?.[0]?.cause || 'Create failed')
}
})
const updateDiscount = useMutation({
mutationFn: async ({ id, payload }: { id: string; payload: UpdateDiscountPayload }) => {
const response = await api.put(`/discounts/${id}`, payload)
return response.data
},
onSuccess: () => {
toast.success('Discount updated successfully!')
queryClient.invalidateQueries({ queryKey: ['discounts'] })
queryClient.invalidateQueries({ queryKey: ['discount'] })
},
onError: (error: any) => {
toast.error(error.response?.data?.errors?.[0]?.cause || 'Update failed')
}
})
const toggleDiscountStatus = useMutation({
mutationFn: async ({ id, isActive }: { id: string; isActive: boolean }) => {
const response = await api.patch(`/discounts/${id}/toggle-status`, { is_active: isActive })
return response.data
},
onSuccess: () => {
toast.success('Discount status updated!')
queryClient.invalidateQueries({ queryKey: ['discounts'] })
queryClient.invalidateQueries({ queryKey: ['discount'] })
},
onError: (error: any) => {
toast.error(error.response?.data?.errors?.[0]?.cause || 'Status update failed')
}
})
const deleteDiscount = useMutation({
mutationFn: async (id: string) => {
const response = await api.delete(`/discounts/${id}`)
return response.data
},
onSuccess: () => {
toast.success('Discount deleted successfully!')
queryClient.invalidateQueries({ queryKey: ['discounts'] })
},
onError: (error: any) => {
toast.error(error.response?.data?.errors?.[0]?.cause || 'Delete failed')
}
})
const validateDiscount = useMutation({
mutationFn: async ({
code,
purchaseAmount,
customerId
}: {
code: string
purchaseAmount: number
customerId?: string
}) => {
const response = await api.post('/discounts/validate', {
code,
purchase_amount: purchaseAmount,
customer_id: customerId
})
return response.data
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Validation failed')
}
})
return {
createDiscount,
updateDiscount,
toggleDiscountStatus,
deleteDiscount,
validateDiscount
}
}
export const useDiscountOutletsMutation = () => {
const queryClient = useQueryClient()
const addOutlets = useMutation({
mutationFn: async ({ discountId, payload }: { discountId: string; payload: AddOutletsPayload }) => {
const response = await api.post(`/discounts/${discountId}/outlets`, payload)
return response.data
},
onSuccess: (_, variables) => {
toast.success('Outlets added successfully!')
queryClient.invalidateQueries({
queryKey: ['discount', variables.discountId, 'outlets']
})
},
onError: (error: any) => {
toast.error(error.response?.data?.errors?.[0]?.cause || 'Add outlets failed')
}
})
const replaceOutlets = useMutation({
mutationFn: async ({ discountId, payload }: { discountId: string; payload: ReplaceOutletsPayload }) => {
const response = await api.put(`/discounts/${discountId}/outlets`, payload)
return response.data
},
onSuccess: (_, variables) => {
toast.success('Outlets replaced successfully!')
queryClient.invalidateQueries({
queryKey: ['discount', variables.discountId, 'outlets']
})
},
onError: (error: any) => {
toast.error(error.response?.data?.errors?.[0]?.cause || 'Replace outlets failed')
}
})
const removeOutlet = useMutation({
mutationFn: async ({ discountId, outletId }: { discountId: string; outletId: string }) => {
const response = await api.delete(`/discounts/${discountId}/outlets/${outletId}`)
return response.data
},
onSuccess: (_, variables) => {
toast.success('Outlet removed successfully!')
queryClient.invalidateQueries({
queryKey: ['discount', variables.discountId, 'outlets']
})
},
onError: (error: any) => {
toast.error(error.response?.data?.errors?.[0]?.cause || 'Remove outlet failed')
}
})
return { addOutlets, replaceOutlets, removeOutlet }
}
export const useProductRulesMutation = () => {
const queryClient = useQueryClient()
const addProductRule = useMutation({
mutationFn: async ({ discountId, payload }: { discountId: string; payload: AddProductRulePayload }) => {
const response = await api.post(`/discounts/${discountId}/products`, payload)
return response.data
},
onSuccess: (_, variables) => {
toast.success('Product rule added successfully!')
queryClient.invalidateQueries({
queryKey: ['discount', variables.discountId, 'product-rules']
})
},
onError: (error: any) => {
toast.error(error.response?.data?.errors?.[0]?.cause || 'Add product rule failed')
}
})
const updateProductRule = useMutation({
mutationFn: async ({
discountId,
productRuleId,
payload
}: {
discountId: string
productRuleId: string
payload: UpdateProductRulePayload
}) => {
const response = await api.put(`/discounts/${discountId}/products/${productRuleId}`, payload)
return response.data
},
onSuccess: (_, variables) => {
toast.success('Product rule updated successfully!')
queryClient.invalidateQueries({
queryKey: ['discount', variables.discountId, 'product-rules']
})
},
onError: (error: any) => {
toast.error(error.response?.data?.errors?.[0]?.cause || 'Update product rule failed')
}
})
const deleteProductRule = useMutation({
mutationFn: async ({ discountId, productRuleId }: { discountId: string; productRuleId: string }) => {
const response = await api.delete(`/discounts/${discountId}/products/${productRuleId}`)
return response.data
},
onSuccess: (_, variables) => {
toast.success('Product rule deleted successfully!')
queryClient.invalidateQueries({
queryKey: ['discount', variables.discountId, 'product-rules']
})
},
onError: (error: any) => {
toast.error(error.response?.data?.errors?.[0]?.cause || 'Delete product rule failed')
}
})
return { addProductRule, updateProductRule, deleteProductRule }
}
export const useCategoryRulesMutation = () => {
const queryClient = useQueryClient()
const addCategoryRule = useMutation({
mutationFn: async ({ discountId, payload }: { discountId: string; payload: AddCategoryRulePayload }) => {
const response = await api.post(`/discounts/${discountId}/categories`, payload)
return response.data
},
onSuccess: (_, variables) => {
toast.success('Category rule added successfully!')
queryClient.invalidateQueries({
queryKey: ['discount', variables.discountId, 'category-rules']
})
},
onError: (error: any) => {
toast.error(error.response?.data?.errors?.[0]?.cause || 'Add category rule failed')
}
})
const updateCategoryRule = useMutation({
mutationFn: async ({
discountId,
categoryRuleId,
payload
}: {
discountId: string
categoryRuleId: string
payload: UpdateCategoryRulePayload
}) => {
const response = await api.put(`/discounts/${discountId}/categories/${categoryRuleId}`, payload)
return response.data
},
onSuccess: (_, variables) => {
toast.success('Category rule updated successfully!')
queryClient.invalidateQueries({
queryKey: ['discount', variables.discountId, 'category-rules']
})
},
onError: (error: any) => {
toast.error(error.response?.data?.errors?.[0]?.cause || 'Update category rule failed')
}
})
const deleteCategoryRule = useMutation({
mutationFn: async ({ discountId, categoryRuleId }: { discountId: string; categoryRuleId: string }) => {
const response = await api.delete(`/discounts/${discountId}/categories/${categoryRuleId}`)
return response.data
},
onSuccess: (_, variables) => {
toast.success('Category rule deleted successfully!')
queryClient.invalidateQueries({
queryKey: ['discount', variables.discountId, 'category-rules']
})
},
onError: (error: any) => {
toast.error(error.response?.data?.errors?.[0]?.cause || 'Delete category rule failed')
}
})
return { addCategoryRule, updateCategoryRule, deleteCategoryRule }
}
+5 -19
View File
@@ -13,7 +13,7 @@ export const usePurchaseOrdersMutation = () => {
},
onSuccess: () => {
toast.success('Purchase Order created successfully!')
queryClient.invalidateQueries({ queryKey: ['purchase-orders'] })
queryClient.invalidateQueries({ queryKey: ['purchase-orders', 'ingredients'] })
},
onError: (error: any) => {
toast.error(error.response?.data?.errors?.[0]?.cause || 'Create failed')
@@ -22,31 +22,17 @@ export const usePurchaseOrdersMutation = () => {
const sendPaymentPurchaseOrder = useMutation({
mutationFn: async ({ id, payload }: { id: string; payload: SendPaymentPurchaseOrderRequest }) => {
const response = await api.put(`/purchase-orders/${id}`, payload)
const response = await api.put(`/purchase-orders/${id}/status/received`, payload)
return response.data
},
onSuccess: () => {
toast.success('Purchase Order Payment successfully!')
toast.success('Send Payment Order successfully!')
queryClient.invalidateQueries({ queryKey: ['purchase-orders'] })
},
onError: (error: any) => {
toast.error(error.response?.data?.errors?.[0]?.cause || 'Create failed')
toast.error(error.response?.data?.errors?.[0]?.cause || 'Send Payment Order failed')
}
})
const updateStatus = useMutation({
mutationFn: async ({ id, payload }: { id: string; payload: 'approved' | 'rejected' }) => {
const response = await api.put(`/purchase-orders/${id}`, { status: payload })
return response.data
},
onSuccess: () => {
toast.success('Purchase Order Status successfully!')
queryClient.invalidateQueries({ queryKey: ['purchase-orders'] })
},
onError: (error: any) => {
toast.error(error.response?.data?.errors?.[0]?.cause || 'Create failed')
}
})
return { createPurchaseOrder, sendPaymentPurchaseOrder, updateStatus }
return { createPurchaseOrder, sendPaymentPurchaseOrder }
}
+4 -2
View File
@@ -9,7 +9,7 @@ interface CategoriesQueryParams {
}
export function useCategories(params: CategoriesQueryParams = {}) {
const { page = 1, limit = 10, search = '', ...filters } = params
const { page = 1, limit = 50, search = '', ...filters } = params
return useQuery<Categories>({
queryKey: ['categories', { page, limit, search, ...filters }],
@@ -30,7 +30,9 @@ export function useCategories(params: CategoriesQueryParams = {}) {
})
const res = await api.get(`/categories?${queryParams.toString()}`)
return res.data.data
const data = res.data.data
return data
}
})
}
+116
View File
@@ -0,0 +1,116 @@
import { useQuery } from '@tanstack/react-query'
import { api } from '../api'
import {
CategoryRule,
Discount,
DiscountOutlet,
DiscountsResponse,
FetchDiscountsParams,
ProductRule
} from '@/types/services/discount'
export function useDiscounts(params: FetchDiscountsParams = {}) {
const { page = 1, limit = 10, search = '', ...filters } = params
return useQuery<DiscountsResponse>({
queryKey: ['discounts', { page, limit, search, ...filters }],
queryFn: async () => {
const queryParams = new URLSearchParams()
queryParams.append('page', page.toString())
queryParams.append('limit', limit.toString())
if (search) {
queryParams.append('search', search)
}
Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
queryParams.append(key, value.toString())
}
})
const res = await api.get(`/discounts?${queryParams.toString()}`)
console.log(res.data.data)
return res.data.data
}
})
}
export function useAllDiscounts() {
return useQuery<Discount[]>({
queryKey: ['discounts', 'all'],
queryFn: async () => {
const res = await api.get('/discounts?page=1&limit=100')
return res.data.discounts
}
})
}
export function useDiscountsByCampaign(campaignId: string) {
return useQuery<Discount[]>({
queryKey: ['discounts', 'campaign', campaignId],
queryFn: async () => {
const res = await api.get(`/discounts?campaign_id=${campaignId}&limit=100`)
return res.data.discounts
},
enabled: !!campaignId
})
}
export function useDiscountById(id: string) {
return useQuery<Discount>({
queryKey: ['discount', id],
queryFn: async () => {
const res = await api.get(`/discounts/${id}`)
return res.data
},
enabled: !!id,
staleTime: 5 * 60 * 1000
})
}
export function useDiscountByCode(code: string) {
return useQuery<Discount>({
queryKey: ['discount', 'code', code],
queryFn: async () => {
const res = await api.get(`/discounts/code/${code}`)
return res.data
},
enabled: !!code,
staleTime: 5 * 60 * 1000
})
}
export function useDiscountOutlets(discountId: string) {
return useQuery<DiscountOutlet[]>({
queryKey: ['discount', discountId, 'outlets'],
queryFn: async () => {
const res = await api.get(`/discounts/${discountId}/outlets`)
return res.data
},
enabled: !!discountId
})
}
export function useProductRules(discountId: string) {
return useQuery<ProductRule[]>({
queryKey: ['discount', discountId, 'product-rules'],
queryFn: async () => {
const res = await api.get(`/discounts/${discountId}/products`)
return res.data
},
enabled: !!discountId
})
}
export function useCategoryRules(discountId: string) {
return useQuery<CategoryRule[]>({
queryKey: ['discount', discountId, 'category-rules'],
queryFn: async () => {
const res = await api.get(`/discounts/${discountId}/categories`)
return res.data
},
enabled: !!discountId
})
}
+2
View File
@@ -6,6 +6,8 @@ interface OrdersQueryParams {
page?: number
limit?: number
search?: string
date_from?: string
date_to?: string
}
export function useOrders(params: OrdersQueryParams = {}) {
+2
View File
@@ -31,8 +31,10 @@ export interface SalesReport {
export interface ProductData {
product_id: string
product_name: string
product_sku: string
category_id: string
category_name: string
category_order: number
quantity_sold: number
revenue: number
average_price: number
+18 -17
View File
@@ -1,25 +1,26 @@
export interface Category {
id: string;
organization_id: string;
name: string;
description: string | null;
business_type: string;
metadata: Record<string, any>;
created_at: string;
updated_at: string;
id: string
organization_id: string
name: string
description: string | null
business_type: string
order: number
metadata: Record<string, any>
created_at: string
updated_at: string
}
export interface Categories {
categories: Category[];
total_count: number;
page: number;
limit: number;
total_pages: number;
categories: Category[]
total_count: number
page: number
limit: number
total_pages: number
}
export interface CategoryRequest {
name: string;
description: string | null;
business_type: string;
name: string
description: string | null
business_type: string
order: number
}
+215
View File
@@ -0,0 +1,215 @@
/**
* Discount type enum
*/
export type DiscountType = 'percentage' | 'fixed_amout' | 'free_product'
/**
* Customer type enum
*/
export type CustomerType = 'all' | 'member' | 'vip'
/**
* Product rule type enum
*/
export type ProductRuleType = 'required' | 'free' | 'excluded'
/**
* Category rule type enum
*/
export type CategoryRuleType = 'included' | 'excluded'
/**
* Discount interface
*/
export interface Discount {
id: string
campaign_id: string
code: string
name: string
type: DiscountType
value: number
min_purchase_qty: number
min_purchase_amount: number
start_date: string
end_date: string
usage_limit_per_customer: number | null
usage_limit_total: number | null
usage_count: number
customer_type: CustomerType
is_stackable: boolean
is_active: boolean
priority: number
created_at: string
updated_at: string
}
/**
* Discount Outlet interface
*/
export interface DiscountOutlet {
id: string
discount_id: string
outlet_id: string
created_at: string
}
/**
* Product Rule interface
*/
export interface ProductRule {
id: string
discount_id: string
product_id: string
rule_type: ProductRuleType
quantity: number
free_quantity: number
created_at: string
}
/**
* Category Rule interface
*/
export interface CategoryRule {
id: string
discount_id: string
category_id: string
rule_type: CategoryRuleType
created_at: string
}
/**
* Pagination metadata
*/
export interface Pagination {
page: number
limit: number
total_count: number
total_pages: number
}
/**
* Discounts response with pagination
*/
export interface DiscountsResponse {
discounts: Discount[]
pagination: Pagination
}
/**
* Fetch discounts parameters
*/
export interface FetchDiscountsParams {
page?: number
limit?: number
sort?: string
order?: 'asc' | 'desc'
search?: string
campaign_id?: string
type?: DiscountType
customer_type?: CustomerType
is_active?: boolean
}
/**
* Create discount payload
*/
export interface CreateDiscountPayload {
campaign_id: string
code: string
name: string
type: DiscountType
value: number
min_purchase_amount?: number
min_purchase_qty?: number
start_date: string
end_date: string
usage_limit_per_customer?: number
usage_limit_total?: number
customer_type: CustomerType
is_stackable: boolean
priority: number
}
/**
* Update discount payload
*/
export interface UpdateDiscountPayload {
campaign_id?: string
code?: string
name?: string
type?: DiscountType
value?: number
min_purchase_amount?: number
min_purchase_qty?: number
start_date?: string
end_date?: string
usage_limit_per_customer?: number
usage_limit_total?: number
customer_type?: CustomerType
is_stackable?: boolean
is_active?: boolean
priority?: number
}
/**
* Add outlets payload
*/
export interface AddOutletsPayload {
outlet_ids: string[]
}
/**
* Replace outlets payload
*/
export interface ReplaceOutletsPayload {
outlet_ids: string[]
}
/**
* Add product rule payload
*/
export interface AddProductRulePayload {
product_id: string
rule_type: ProductRuleType
quantity?: number
free_quantity?: number
}
/**
* Update product rule payload
*/
export interface UpdateProductRulePayload {
rule_type: ProductRuleType
quantity?: number
free_quantity?: number
}
/**
* Add category rule payload
*/
export interface AddCategoryRulePayload {
category_id: string
rule_type: CategoryRuleType
}
/**
* Update category rule payload
*/
export interface UpdateCategoryRulePayload {
rule_type: CategoryRuleType
}
/**
* Discount State for Redux
*/
export interface DiscountState {
discounts: Discount[]
allDiscounts: Discount[]
discountsPagination: Pagination | null
selectedDiscount: Discount | null
discountOutlets: DiscountOutlet[]
productRules: ProductRule[]
categoryRules: CategoryRule[]
loading: boolean
error: string | null
}
+527
View File
@@ -0,0 +1,527 @@
// excelGenerator.ts
import { formatCurrency, formatDateDDMMYYYY } from '@/utils/transform'
import type ExcelJS from 'exceljs'
interface ExcelGeneratorParams {
outlet: any
profitLoss: any
products: any
paymentAnalytics: any
category: any
productSummary: {
totalQuantitySold: number
totalRevenue: number
totalOrders: number
}
categorySummary: {
totalRevenue: number
orderCount: number
productCount: number
totalQuantity: number
}
filterType: 'single' | 'range'
selectedDate: Date
dateRange: { startDate: Date; endDate: Date }
}
const formatDateForInput = (date: Date) => {
return date.toISOString().split('T')[0]
}
const getReportPeriodText = (params: ExcelGeneratorParams) => {
if (params.filterType === 'single') {
return `${formatDateDDMMYYYY(params.selectedDate)} - ${formatDateDDMMYYYY(params.selectedDate)}`
}
return `${formatDateDDMMYYYY(params.dateRange.startDate)} - ${formatDateDDMMYYYY(params.dateRange.endDate)}`
}
// ========== EXCEL STYLES ==========
const headerStyle: Partial<ExcelJS.Style> = {
font: { bold: true, size: 12, color: { argb: 'FFFFFFFF' } },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF36175E' } } as ExcelJS.FillPattern,
alignment: { vertical: 'middle', horizontal: 'center' },
border: {
top: { style: 'thin', color: { argb: 'FFD1D5DB' } },
left: { style: 'thin', color: { argb: 'FFD1D5DB' } },
bottom: { style: 'thin', color: { argb: 'FFD1D5DB' } },
right: { style: 'thin', color: { argb: 'FFD1D5DB' } }
}
}
const titleStyle: Partial<ExcelJS.Style> = {
font: { bold: true, size: 18 },
alignment: { vertical: 'middle', horizontal: 'left' }
}
const subtitleStyle: Partial<ExcelJS.Style> = {
font: { size: 11, color: { argb: 'FF6B7280' } },
alignment: { vertical: 'middle', horizontal: 'left' }
}
const labelStyle: Partial<ExcelJS.Style> = {
font: { bold: true, size: 11 },
alignment: { vertical: 'middle', horizontal: 'left' }
}
const totalRowStyle: Partial<ExcelJS.Style> = {
font: { bold: true, size: 11 },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF3F4F6' } } as ExcelJS.FillPattern,
border: {
top: { style: 'thin', color: { argb: 'FFD1D5DB' } },
left: { style: 'thin', color: { argb: 'FFD1D5DB' } },
bottom: { style: 'thin', color: { argb: 'FFD1D5DB' } },
right: { style: 'thin', color: { argb: 'FFD1D5DB' } }
}
}
const dataStyle: Partial<ExcelJS.Style> = {
border: {
top: { style: 'thin', color: { argb: 'FFF3F4F6' } },
left: { style: 'thin', color: { argb: 'FFF3F4F6' } },
bottom: { style: 'thin', color: { argb: 'FFF3F4F6' } },
right: { style: 'thin', color: { argb: 'FFF3F4F6' } }
}
}
const dataStyleAlt: Partial<ExcelJS.Style> = {
...dataStyle,
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFAFAFA' } } as ExcelJS.FillPattern
}
const currencyFormat = '#,##0'
const percentageFormat = '0.0"%"'
// ========== HELPER: STATUS STYLE ==========
// Sehat → teks hijau gelap, background hijau muda
// Tidak Sehat → teks merah gelap, background merah muda
const getStatusStyle = (status: 'Sehat' | 'Tidak Sehat', isAlt: boolean): Partial<ExcelJS.Style> => {
const baseStyle = isAlt ? dataStyleAlt : dataStyle
if (status === 'Sehat') {
return {
...baseStyle,
font: { bold: true, size: 11, color: { argb: 'FF166534' } }, // green-800
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFDCFCE7' } } as ExcelJS.FillPattern, // green-100
alignment: { horizontal: 'center', vertical: 'middle' },
border: dataStyle.border
}
}
return {
...baseStyle,
font: { bold: true, size: 11, color: { argb: 'FF991B1B' } }, // red-800
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFEE2E2' } } as ExcelJS.FillPattern, // red-100
alignment: { horizontal: 'center', vertical: 'middle' },
border: dataStyle.border
}
}
// Dummy HPP values — ganti dengan data real nantinya
const DUMMY_STD_HPP = 30 // 30% — ganti dengan data real nantinya
const DUMMY_REAL_HPP = 0 // 0% — ganti dengan kalkulasi real nantinya
const getDummyStatus = (stdHpp: number, realHpp: number): 'Sehat' | 'Tidak Sehat' =>
realHpp <= stdHpp ? 'Sehat' : 'Tidak Sehat'
export const generateExcel = async (params: ExcelGeneratorParams) => {
const {
outlet,
profitLoss,
products,
paymentAnalytics,
category,
productSummary,
categorySummary,
filterType,
selectedDate,
dateRange
} = params
const ExcelJS = await import('exceljs')
const workbook = new ExcelJS.Workbook()
workbook.creator = outlet?.name || 'POS System'
workbook.created = new Date()
// ============ SHEET 1: RINGKASAN ============
const ws1 = workbook.addWorksheet('Ringkasan', {
views: [{ showGridLines: false }]
})
ws1.mergeCells('A1:B1')
const titleCell = ws1.getCell('A1')
titleCell.value = 'LAPORAN TRANSAKSI'
titleCell.style = {
font: { bold: true, size: 20, color: { argb: 'FF1F2937' } },
alignment: { vertical: 'middle', horizontal: 'left' }
}
ws1.getRow(1).height = 30
ws1.getCell('A2').value = outlet?.name || ''
ws1.getCell('A2').style = { ...subtitleStyle, font: { size: 12, color: { argb: 'FF4B5563' } } }
ws1.getCell('A3').value = outlet?.address || ''
ws1.getCell('A3').style = subtitleStyle
ws1.getCell('A4').value = outlet?.phone || ''
ws1.getCell('A4').style = subtitleStyle
ws1.getCell('A5').value = 'Periode:'
ws1.getCell('B5').value = getReportPeriodText(params)
ws1.getCell('A5').style = labelStyle
ws1.getCell('B5').style = { font: { size: 11 } }
ws1.getCell('A7').value = 'RINGKASAN'
ws1.getCell('A7').style = {
font: { bold: true, size: 14, color: { argb: 'FF36175E' } },
alignment: { vertical: 'middle', horizontal: 'left' }
}
ws1.getCell('A8').value = 'Keterangan'
ws1.getCell('B8').value = 'Jumlah (IDR)'
ws1.getCell('A8').style = headerStyle
ws1.getCell('B8').style = headerStyle
ws1.getRow(8).height = 25
const summaryData = [
['Total Penjualan', profitLoss?.summary.total_revenue || 0],
['Total Diskon', profitLoss?.summary.total_discount || 0],
['Total Pajak', profitLoss?.summary.total_tax || 0],
['PB1', 0],
['Service Charge', profitLoss?.summary.service_charge || 0]
]
summaryData.forEach((row, idx) => {
const rowNum = idx + 9
ws1.getCell(`A${rowNum}`).value = row[0]
ws1.getCell(`B${rowNum}`).value = row[1]
const isAlt = idx % 2 === 1
const baseStyle = isAlt ? dataStyleAlt : dataStyle
ws1.getCell(`A${rowNum}`).style = { ...baseStyle, alignment: { horizontal: 'left' } }
ws1.getCell(`B${rowNum}`).style = { ...baseStyle, alignment: { horizontal: 'right' }, numFmt: currencyFormat }
ws1.getRow(rowNum).height = 22
})
const totalRow = 9 + summaryData.length
ws1.getCell(`A${totalRow}`).value = 'Total'
ws1.getCell(`B${totalRow}`).value = profitLoss?.summary.total_revenue || 0
ws1.getCell(`A${totalRow}`).style = { ...totalRowStyle, alignment: { horizontal: 'left' } }
ws1.getCell(`B${totalRow}`).style = { ...totalRowStyle, alignment: { horizontal: 'right' }, numFmt: currencyFormat }
ws1.getRow(totalRow).height = 25
const invoiceRow = totalRow + 2
ws1.getCell(`A${invoiceRow}`).value = 'INVOICE'
ws1.getCell(`A${invoiceRow}`).style = {
font: { bold: true, size: 14, color: { argb: 'FF36175E' } },
alignment: { vertical: 'middle', horizontal: 'left' }
}
ws1.getCell(`A${invoiceRow + 1}`).value = 'Total Invoice'
ws1.getCell(`B${invoiceRow + 1}`).value = profitLoss?.summary.total_orders || 0
ws1.getCell(`A${invoiceRow + 1}`).style = { ...dataStyle, font: { bold: true } }
ws1.getCell(`B${invoiceRow + 1}`).style = { ...dataStyle, alignment: { horizontal: 'right' }, font: { bold: true } }
ws1.getColumn(1).width = 28
ws1.getColumn(2).width = 22
// ============ SHEET 2: METODE PEMBAYARAN ============
const ws2 = workbook.addWorksheet('Metode Pembayaran', {
views: [{ showGridLines: false }]
})
ws2.mergeCells('A1:D1')
const paymentTitle = ws2.getCell('A1')
paymentTitle.value = 'RINGKASAN METODE PEMBAYARAN'
paymentTitle.style = titleStyle
ws2.getRow(1).height = 30
const paymentHeaders = ['Metode', 'Jumlah Order', 'Total Amount (IDR)', 'Persentase']
paymentHeaders.forEach((header, idx) => {
const cell = ws2.getCell(3, idx + 1)
cell.value = header
cell.style = headerStyle
})
ws2.getRow(3).height = 25
let paymentRow = 4
paymentAnalytics?.data?.forEach((payment: any, idx: number) => {
const isAlt = idx % 2 === 1
const baseStyle = isAlt ? dataStyleAlt : dataStyle
ws2.getCell(paymentRow, 1).value = payment.payment_method_name
ws2.getCell(paymentRow, 2).value = payment.order_count
ws2.getCell(paymentRow, 3).value = payment.total_amount
ws2.getCell(paymentRow, 4).value = (payment.percentage || 0) / 100
ws2.getCell(paymentRow, 1).style = { ...baseStyle, alignment: { horizontal: 'left' } }
ws2.getCell(paymentRow, 2).style = { ...baseStyle, alignment: { horizontal: 'center' } }
ws2.getCell(paymentRow, 3).style = { ...baseStyle, alignment: { horizontal: 'right' }, numFmt: currencyFormat }
ws2.getCell(paymentRow, 4).style = { ...baseStyle, alignment: { horizontal: 'center' }, numFmt: percentageFormat }
ws2.getRow(paymentRow).height = 22
paymentRow++
})
ws2.getCell(paymentRow, 1).value = 'TOTAL'
ws2.getCell(paymentRow, 2).value = paymentAnalytics?.summary.total_orders || 0
ws2.getCell(paymentRow, 3).value = paymentAnalytics?.summary.total_amount || 0
ws2.getCell(paymentRow, 4).value = ''
ws2.getCell(paymentRow, 1).style = { ...totalRowStyle, alignment: { horizontal: 'left' } }
ws2.getCell(paymentRow, 2).style = { ...totalRowStyle, alignment: { horizontal: 'center' } }
ws2.getCell(paymentRow, 3).style = { ...totalRowStyle, alignment: { horizontal: 'right' }, numFmt: currencyFormat }
ws2.getCell(paymentRow, 4).style = totalRowStyle
ws2.getRow(paymentRow).height = 25
ws2.getColumn(1).width = 28
ws2.getColumn(2).width = 16
ws2.getColumn(3).width = 22
ws2.getColumn(4).width = 16
// ============ SHEET 3: KATEGORI ============
// + 3 kolom baru: % Standard HPP | % Realisasi HPP | Status
const ws3 = workbook.addWorksheet('Kategori', {
views: [{ showGridLines: false }]
})
ws3.mergeCells('A1:F1')
const categoryTitle = ws3.getCell('A1')
categoryTitle.value = 'RINGKASAN KATEGORI'
categoryTitle.style = titleStyle
ws3.getRow(1).height = 30
const categoryHeaders = ['Nama', 'Qty', 'Pendapatan (IDR)', '% Standard HPP', '% Realisasi HPP', 'Status']
categoryHeaders.forEach((header, idx) => {
const cell = ws3.getCell(3, idx + 1)
cell.value = header
cell.style = headerStyle
})
ws3.getRow(3).height = 25
let categoryRow = 4
category?.data?.forEach((cat: any, idx: number) => {
const isAlt = idx % 2 === 1
const baseStyle = isAlt ? dataStyleAlt : dataStyle
const stdHpp = DUMMY_STD_HPP
const realHpp = DUMMY_REAL_HPP
const status = getDummyStatus(stdHpp, realHpp)
ws3.getCell(categoryRow, 1).value = cat.category_name
ws3.getCell(categoryRow, 2).value = cat.total_quantity
ws3.getCell(categoryRow, 3).value = cat.total_revenue
ws3.getCell(categoryRow, 4).value = stdHpp // akan diformat sebagai persentase
ws3.getCell(categoryRow, 5).value = realHpp
ws3.getCell(categoryRow, 6).value = status
ws3.getCell(categoryRow, 1).style = { ...baseStyle, alignment: { horizontal: 'left' } }
ws3.getCell(categoryRow, 2).style = { ...baseStyle, alignment: { horizontal: 'center' } }
ws3.getCell(categoryRow, 3).style = { ...baseStyle, alignment: { horizontal: 'right' }, numFmt: currencyFormat }
ws3.getCell(categoryRow, 4).style = { ...baseStyle, alignment: { horizontal: 'center' }, numFmt: '0.0"%"' }
ws3.getCell(categoryRow, 5).style = { ...baseStyle, alignment: { horizontal: 'center' }, numFmt: '0.0"%"' }
ws3.getCell(categoryRow, 6).style = getStatusStyle(status, isAlt)
ws3.getRow(categoryRow).height = 22
categoryRow++
})
// Total row (3 kolom baru dikosongkan)
ws3.getCell(categoryRow, 1).value = 'TOTAL'
ws3.getCell(categoryRow, 2).value = categorySummary.totalQuantity
ws3.getCell(categoryRow, 3).value = categorySummary.totalRevenue
ws3.getCell(categoryRow, 4).value = ''
ws3.getCell(categoryRow, 5).value = ''
ws3.getCell(categoryRow, 6).value = ''
ws3.getCell(categoryRow, 1).style = { ...totalRowStyle, alignment: { horizontal: 'left' } }
ws3.getCell(categoryRow, 2).style = { ...totalRowStyle, alignment: { horizontal: 'center' } }
ws3.getCell(categoryRow, 3).style = { ...totalRowStyle, alignment: { horizontal: 'right' }, numFmt: currencyFormat }
ws3.getCell(categoryRow, 4).style = totalRowStyle
ws3.getCell(categoryRow, 5).style = totalRowStyle
ws3.getCell(categoryRow, 6).style = totalRowStyle
ws3.getRow(categoryRow).height = 25
ws3.getColumn(1).width = 35
ws3.getColumn(2).width = 12
ws3.getColumn(3).width = 22
ws3.getColumn(4).width = 18 // % Standard HPP
ws3.getColumn(5).width = 18 // % Realisasi HPP
ws3.getColumn(6).width = 16 // Status
// ============ SHEET 4: DETAIL PRODUK ============
// + 3 kolom baru: % Standard HPP | % Realisasi HPP | Status
const ws4 = workbook.addWorksheet('Detail Produk', {
views: [{ showGridLines: false }]
})
ws4.mergeCells('A1:F1')
const productTitle = ws4.getCell('A1')
productTitle.value = 'RINGKASAN ITEM PER KATEGORI'
productTitle.style = titleStyle
ws4.getRow(1).height = 30
const groupedProducts =
products?.data?.reduce(
(acc: any, item: any) => {
const categoryName = item.category_name || 'Tidak Berkategori'
if (!acc[categoryName]) acc[categoryName] = []
acc[categoryName].push(item)
return acc
},
{} as Record<string, any[]>
) || {}
let currentRow = 3
Object.keys(groupedProducts)
.sort((a, b) => {
const orderA = groupedProducts[a][0]?.category_order ?? 999
const orderB = groupedProducts[b][0]?.category_order ?? 999
return orderA - orderB
})
.forEach((categoryName, index) => {
// Category header — span 6 kolom
ws4.mergeCells(`A${currentRow}:F${currentRow}`)
const catHeader = ws4.getCell(`A${currentRow}`)
catHeader.value = `${index + 1}. ${categoryName.toUpperCase()}`
catHeader.style = {
font: { bold: true, size: 13, color: { argb: 'FF36175E' } },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF9FAFB' } } as ExcelJS.FillPattern,
alignment: { vertical: 'middle', horizontal: 'left' },
border: {
top: { style: 'thin', color: { argb: 'FFD1D5DB' } },
left: { style: 'thin', color: { argb: 'FFD1D5DB' } },
bottom: { style: 'thin', color: { argb: 'FFD1D5DB' } },
right: { style: 'thin', color: { argb: 'FFD1D5DB' } }
}
}
ws4.getRow(currentRow).height = 28
currentRow++
// Column headers — 6 kolom
const prodHeaders = ['Produk', 'Qty', 'Pendapatan (IDR)', '% Standard HPP', '% Realisasi HPP', 'Status']
prodHeaders.forEach((header, idx) => {
const cell = ws4.getCell(currentRow, idx + 1)
cell.value = header
cell.style = {
font: { bold: true, size: 11, color: { argb: 'FFFFFFFF' } },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF36175E' } } as ExcelJS.FillPattern,
alignment: { vertical: 'middle', horizontal: 'center' },
border: {
top: { style: 'thin', color: { argb: 'FFD1D5DB' } },
left: { style: 'thin', color: { argb: 'FFD1D5DB' } },
bottom: { style: 'thin', color: { argb: 'FFD1D5DB' } },
right: { style: 'thin', color: { argb: 'FFD1D5DB' } }
}
}
})
ws4.getRow(currentRow).height = 24
currentRow++
const categoryProducts = groupedProducts[categoryName].sort((a: any, b: any) =>
(a.product_sku || '').localeCompare(b.product_sku || '')
)
categoryProducts.forEach((product: any, idx: number) => {
const isAlt = idx % 2 === 1
const baseStyle = isAlt ? dataStyleAlt : dataStyle
const stdHpp = DUMMY_STD_HPP
const realHpp = DUMMY_REAL_HPP
const status = getDummyStatus(stdHpp, realHpp)
ws4.getCell(currentRow, 1).value = product.product_name
ws4.getCell(currentRow, 2).value = product.quantity_sold
ws4.getCell(currentRow, 3).value = product.revenue
ws4.getCell(currentRow, 4).value = stdHpp
ws4.getCell(currentRow, 5).value = realHpp
ws4.getCell(currentRow, 6).value = status
ws4.getCell(currentRow, 1).style = { ...baseStyle, alignment: { horizontal: 'left' } }
ws4.getCell(currentRow, 2).style = { ...baseStyle, alignment: { horizontal: 'center' } }
ws4.getCell(currentRow, 3).style = { ...baseStyle, alignment: { horizontal: 'right' }, numFmt: currencyFormat }
ws4.getCell(currentRow, 4).style = { ...baseStyle, alignment: { horizontal: 'center' }, numFmt: '0.0"%"' }
ws4.getCell(currentRow, 5).style = { ...baseStyle, alignment: { horizontal: 'center' }, numFmt: '0.0"%"' }
ws4.getCell(currentRow, 6).style = getStatusStyle(status, isAlt)
ws4.getRow(currentRow).height = 20
currentRow++
})
// Subtotal — 3 kolom baru dikosongkan
const subQty = categoryProducts.reduce((sum: number, p: any) => sum + p.quantity_sold, 0)
const subRevenue = categoryProducts.reduce((sum: number, p: any) => sum + p.revenue, 0)
const subtotalFill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF3F4F6' } } as ExcelJS.FillPattern
ws4.getCell(currentRow, 1).value = `Subtotal ${categoryName}`
ws4.getCell(currentRow, 2).value = subQty
ws4.getCell(currentRow, 3).value = subRevenue
ws4.getCell(currentRow, 4).value = ''
ws4.getCell(currentRow, 5).value = ''
ws4.getCell(currentRow, 6).value = ''
ws4.getCell(currentRow, 1).style = { ...totalRowStyle, alignment: { horizontal: 'left' }, fill: subtotalFill }
ws4.getCell(currentRow, 2).style = { ...totalRowStyle, alignment: { horizontal: 'center' }, fill: subtotalFill }
ws4.getCell(currentRow, 3).style = {
...totalRowStyle,
alignment: { horizontal: 'right' },
numFmt: currencyFormat,
fill: subtotalFill
}
ws4.getCell(currentRow, 4).style = { ...totalRowStyle, fill: subtotalFill }
ws4.getCell(currentRow, 5).style = { ...totalRowStyle, fill: subtotalFill }
ws4.getCell(currentRow, 6).style = { ...totalRowStyle, fill: subtotalFill }
ws4.getRow(currentRow).height = 24
currentRow += 3
})
// Grand Total
const grandFill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF36175E' } } as ExcelJS.FillPattern
const grandFont = { bold: true, size: 13, color: { argb: 'FFFFFFFF' } }
ws4.getCell(currentRow, 1).value = 'TOTAL KESELURUHAN'
ws4.getCell(currentRow, 2).value = productSummary.totalQuantitySold
ws4.getCell(currentRow, 3).value = productSummary.totalRevenue
ws4.getCell(currentRow, 4).value = ''
ws4.getCell(currentRow, 5).value = ''
ws4.getCell(currentRow, 6).value = ''
ws4.getCell(currentRow, 1).style = {
...totalRowStyle,
alignment: { horizontal: 'left' },
font: grandFont,
fill: grandFill
}
ws4.getCell(currentRow, 2).style = {
...totalRowStyle,
alignment: { horizontal: 'center' },
font: grandFont,
fill: grandFill
}
ws4.getCell(currentRow, 3).style = {
...totalRowStyle,
alignment: { horizontal: 'right' },
numFmt: currencyFormat,
font: grandFont,
fill: grandFill
}
ws4.getCell(currentRow, 4).style = { ...totalRowStyle, font: grandFont, fill: grandFill }
ws4.getCell(currentRow, 5).style = { ...totalRowStyle, font: grandFont, fill: grandFill }
ws4.getCell(currentRow, 6).style = { ...totalRowStyle, font: grandFont, fill: grandFill }
ws4.getRow(currentRow).height = 28
ws4.getColumn(1).width = 45
ws4.getColumn(2).width = 12
ws4.getColumn(3).width = 22
ws4.getColumn(4).width = 18 // % Standard HPP
ws4.getColumn(5).width = 18 // % Realisasi HPP
ws4.getColumn(6).width = 16 // Status
// ============ GENERATE & DOWNLOAD FILE ============
const fileName =
filterType === 'single'
? `laporan-transaksi-${formatDateForInput(selectedDate)}.xlsx`
: `laporan-transaksi-${formatDateForInput(dateRange.startDate)}-to-${formatDateForInput(dateRange.endDate)}.xlsx`
const buffer = await workbook.xlsx.writeBuffer()
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
a.click()
window.URL.revokeObjectURL(url)
}
+535
View File
@@ -0,0 +1,535 @@
// pdfGenerator.ts
import { formatCurrency } from '@/utils/transform'
// PDF Configuration
const PDF_FONT_SIZES = {
heading: 18,
subheading: 16,
tableContent: 12,
tableHeader: 12,
tableFooter: 12,
grandTotal: 14,
footer: 11
}
const PDF_SPACING = {
cellPadding: 5,
cellPaddingLarge: 6,
sectionGap: 20,
headerGap: 15
}
interface PDFGeneratorParams {
reportRef: React.RefObject<HTMLDivElement>
outlet: any
profitLoss: any
products: any
paymentAnalytics: any
category: any
productSummary: {
totalQuantitySold: number
totalRevenue: number
totalOrders: number
}
categorySummary: {
totalRevenue: number
orderCount: number
productCount: number
totalQuantity: number
}
filterType: 'single' | 'range'
selectedDate: Date
dateRange: { startDate: Date; endDate: Date }
now: Date
}
const formatDateForInput = (date: Date) => {
return date.toISOString().split('T')[0]
}
// Helper: tentukan warna status
const getStatusColor = (status: string): [number, number, number] => {
return status === 'Sehat' ? [22, 163, 74] : [220, 38, 38] // green-600 / red-600
}
export const generatePDF = async (params: PDFGeneratorParams) => {
const {
reportRef,
outlet,
profitLoss,
products,
paymentAnalytics,
category,
productSummary,
categorySummary,
filterType,
selectedDate,
dateRange,
now
} = params
const reportElement = reportRef.current
if (!reportElement) {
throw new Error('Report element tidak ditemukan')
}
// Dynamic imports
const jsPDF = (await import('jspdf')).default
const html2canvas = (await import('html2canvas')).default
const autoTable = (await import('jspdf-autotable')).default
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
compress: true
})
// Capture header section
const headerElement = reportElement.querySelector('.report-header-section') as HTMLElement
if (headerElement) {
const headerCanvas = await html2canvas(headerElement, {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
logging: false,
windowWidth: 794
})
const headerImgWidth = 190
const headerImgHeight = (headerCanvas.height * headerImgWidth) / headerCanvas.width
const headerImgData = headerCanvas.toDataURL('image/jpeg', 0.95)
pdf.addImage(headerImgData, 'JPEG', 10, 10, headerImgWidth, headerImgHeight)
}
let currentY = 80
// ========== RINGKASAN SECTION ==========
pdf.setFontSize(PDF_FONT_SIZES.heading)
pdf.setTextColor(54, 23, 94)
pdf.setFont('helvetica', 'bold')
pdf.text('Ringkasan', 14, currentY)
currentY += 15
autoTable(pdf, {
startY: currentY,
head: [],
body: [
['Total Penjualan', formatCurrency(profitLoss?.summary.total_revenue ?? 0)],
['Total Diskon', formatCurrency(profitLoss?.summary.total_discount ?? 0)],
['Total Pajak', formatCurrency(profitLoss?.summary.total_tax ?? 0)],
['Service Charge', formatCurrency(profitLoss?.summary.service_charge ?? 0)],
['Total', formatCurrency(profitLoss?.summary.total_revenue ?? 0)]
],
theme: 'grid',
styles: {
fontSize: PDF_FONT_SIZES.subheading,
cellPadding: PDF_SPACING.cellPadding,
lineColor: [0, 0, 0]
},
columnStyles: {
0: { fontStyle: 'normal', textColor: [60, 60, 60] },
1: { halign: 'right', fontStyle: 'bold', textColor: [60, 60, 60] }
},
margin: { left: 14, right: 14 }
})
currentY = (pdf as any).lastAutoTable.finalY + 20
// ========== INVOICE SECTION ==========
pdf.setFontSize(PDF_FONT_SIZES.heading)
pdf.text('Invoice', 14, currentY)
currentY += 15
autoTable(pdf, {
startY: currentY,
head: [],
body: [['Total Invoice', String(profitLoss?.summary.total_orders ?? 0)]],
theme: 'grid',
styles: {
lineColor: [0, 0, 0],
fontSize: PDF_FONT_SIZES.subheading,
cellPadding: PDF_SPACING.cellPadding
},
columnStyles: {
0: { fontStyle: 'normal', textColor: [60, 60, 60] },
1: { halign: 'right', fontStyle: 'bold', textColor: [60, 60, 60] }
},
margin: { left: 14, right: 14 }
})
pdf.addPage()
currentY = 20
// ========== PAYMENT METHOD SECTION ==========
pdf.setFontSize(PDF_FONT_SIZES.heading)
pdf.text('Ringkasan Metode Pembayaran', 14, currentY)
currentY += 15
const paymentBody =
paymentAnalytics?.data?.map((payment: any) => [
payment.payment_method_name,
String(payment.order_count),
formatCurrency(payment.total_amount),
`${(payment.percentage ?? 0).toFixed(1)}%`
]) || []
autoTable(pdf, {
startY: currentY,
head: [['Metode', 'Jumlah', 'Total', 'Persentase']],
body: paymentBody,
foot: [
[
'TOTAL',
String(paymentAnalytics?.summary.total_orders ?? 0),
formatCurrency(paymentAnalytics?.summary.total_amount ?? 0),
''
]
],
theme: 'grid',
styles: {
fontSize: PDF_FONT_SIZES.tableContent,
cellPadding: PDF_SPACING.cellPadding,
lineColor: [0, 0, 0],
lineWidth: 0.1
},
headStyles: {
fillColor: [54, 23, 94],
textColor: 255,
fontStyle: 'bold',
fontSize: PDF_FONT_SIZES.tableHeader,
lineColor: [0, 0, 0],
lineWidth: 0.1
},
footStyles: {
fillColor: [220, 220, 220],
textColor: [60, 60, 60],
fontStyle: 'bold',
fontSize: PDF_FONT_SIZES.tableFooter,
lineColor: [0, 0, 0],
lineWidth: 0.1,
halign: 'center'
},
columnStyles: {
1: { halign: 'center' },
2: { halign: 'right' },
3: { halign: 'right' }
},
didParseCell: (data: any) => {
if (data.section === 'foot') {
if (data.column.index === 0) {
data.cell.styles.halign = 'left'
} else if (data.column.index === 1) {
data.cell.styles.halign = 'center'
} else if (data.column.index === 2) {
data.cell.styles.halign = 'right'
}
}
},
margin: { left: 14, right: 14 }
})
currentY = (pdf as any).lastAutoTable.finalY + 20
// ========== CATEGORY SECTION ==========
// Kolom baru: % Standard HPP, % Realisasi HPP, Status — diisi dummy visual untuk saat ini
pdf.setFontSize(PDF_FONT_SIZES.heading)
pdf.text('Ringkasan Kategori', 14, currentY)
currentY += 15
const DUMMY_STD_HPP = 30 // % (nilai dummy — ganti dengan data real nantinya)
const categoryBody =
category?.data?.map((c: any) => {
const stdHpp = DUMMY_STD_HPP
const realisasiHpp = 0 // dummy — ganti dengan kalkulasi real
const status = realisasiHpp <= stdHpp ? 'Sehat' : 'Tidak Sehat'
return [
c.category_name,
String(c.total_quantity),
formatCurrency(c.total_revenue),
`${stdHpp}%`,
`${realisasiHpp}%`,
'' // dikosongkan — status digambar manual via didDrawCell agar tidak double
]
}) || []
// Simpan status per baris untuk dipakai di didDrawCell
const categoryStatuses: string[] =
category?.data?.map(() => {
const realisasiHpp = 0
return realisasiHpp <= DUMMY_STD_HPP ? 'Sehat' : 'Tidak Sehat'
}) || []
autoTable(pdf, {
startY: currentY,
head: [['Nama', 'Qty', 'Pendapatan', '% Std HPP', '% Real HPP', 'Status']],
body: categoryBody,
foot: [
[
'TOTAL',
String(categorySummary?.totalQuantity ?? 0),
formatCurrency(categorySummary?.totalRevenue ?? 0),
'',
'',
''
]
],
theme: 'grid',
showFoot: 'lastPage',
tableWidth: 'auto',
styles: {
fontSize: 9,
cellPadding: PDF_SPACING.cellPadding,
lineColor: [0, 0, 0],
lineWidth: 0.1
},
headStyles: {
fillColor: [54, 23, 94],
textColor: 255,
fontStyle: 'bold',
fontSize: 9,
lineColor: [0, 0, 0],
lineWidth: 0.1
},
footStyles: {
fillColor: [220, 220, 220],
textColor: [60, 60, 60],
fontStyle: 'bold',
fontSize: 9,
lineColor: [0, 0, 0],
lineWidth: 0.1
},
columnStyles: {
1: { halign: 'center' },
2: { halign: 'right' },
3: { halign: 'center' },
4: { halign: 'center' },
5: { halign: 'center' }
},
didParseCell: (data: any) => {
// Footer alignment
if (data.section === 'foot') {
if (data.column.index === 0) data.cell.styles.halign = 'left'
else if (data.column.index === 1) data.cell.styles.halign = 'center'
else if (data.column.index === 2) data.cell.styles.halign = 'right'
}
// Paksa teks status tidak dirender oleh autoTable (set textColor = white)
if (data.section === 'body' && data.column.index === 5) {
data.cell.styles.textColor = [255, 255, 255]
}
},
didDrawCell: (data: any) => {
if (data.section === 'body' && data.column.index === 5) {
const status = categoryStatuses[data.row.index] || 'Sehat'
const color = getStatusColor(status)
pdf.setTextColor(...color)
pdf.setFontSize(9)
pdf.setFont('helvetica', 'bold')
pdf.text(status, data.cell.x + data.cell.width / 2, data.cell.y + data.cell.height / 2 + 1, { align: 'center' })
pdf.setTextColor(0, 0, 0)
pdf.setFont('helvetica', 'normal')
}
},
margin: { left: 14, right: 14 }
})
currentY = (pdf as any).lastAutoTable.finalY + 20
// ========== PRODUCT DETAILS BY CATEGORY ==========
const groupedProducts =
products?.data?.reduce(
(acc: any, item: any) => {
const categoryName = item.category_name || 'Tidak Berkategori'
if (!acc[categoryName]) {
acc[categoryName] = []
}
acc[categoryName].push(item)
return acc
},
{} as Record<string, any[]>
) || {}
pdf.addPage()
currentY = 20
pdf.setFontSize(PDF_FONT_SIZES.heading)
pdf.text('Ringkasan Item Per Kategori', 14, currentY)
currentY += 10
Object.keys(groupedProducts)
.sort((a, b) => {
const productsA = groupedProducts[a]
const productsB = groupedProducts[b]
const orderA = productsA[0]?.category_order ?? 999
const orderB = productsB[0]?.category_order ?? 999
return orderA - orderB
})
.forEach((categoryName, index) => {
const categoryProducts = groupedProducts[categoryName].sort((a: any, b: any) => {
const skuA = a.product_sku || ''
const skuB = b.product_sku || ''
return skuA.localeCompare(skuB)
})
const categoryTotalQty = categoryProducts.reduce((sum: number, item: any) => sum + (item.quantity_sold || 0), 0)
const categoryTotalRevenue = categoryProducts.reduce((sum: number, item: any) => sum + (item.revenue || 0), 0)
// Kolom baru: % Standard HPP, % Realisasi HPP, Status — dummy visual
const productBody = categoryProducts.map((item: any) => {
const stdHpp = 0 // dummy — ganti dengan data real
const realisasiHpp = 0 // dummy — ganti dengan kalkulasi real
return [
item.product_name,
String(item.quantity_sold),
formatCurrency(item.revenue),
`${stdHpp}%`,
`${realisasiHpp}%`,
'' // dikosongkan — status digambar manual via didDrawCell agar tidak double
]
})
// Status per baris produk
const productStatuses: string[] = categoryProducts.map(() => (0 <= 0 ? 'Sehat' : 'Tidak Sehat'))
const estimatedHeight = (productBody.length + 3) * 12
if (currentY + estimatedHeight > 270) {
pdf.addPage()
currentY = 20
}
pdf.setFontSize(PDF_FONT_SIZES.subheading)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(54, 23, 94)
pdf.text(`${index + 1}. ${categoryName.toUpperCase()}`, 16, currentY)
currentY += 15
autoTable(pdf, {
startY: currentY,
head: [['Produk', 'Qty', 'Pendapatan', '% Std HPP', '% Real HPP', 'Status']],
body: productBody,
foot: [
[`Subtotal ${categoryName}`, String(categoryTotalQty), formatCurrency(categoryTotalRevenue), '', '', '']
],
showFoot: 'lastPage',
theme: 'grid',
styles: {
fontSize: 9,
cellPadding: PDF_SPACING.cellPadding,
lineColor: [0, 0, 0],
lineWidth: 0.1
},
headStyles: {
fillColor: [54, 23, 94],
textColor: 255,
fontStyle: 'bold',
fontSize: 9,
lineColor: [0, 0, 0],
lineWidth: 0.1
},
footStyles: {
fillColor: [200, 200, 200],
textColor: [60, 60, 60],
fontStyle: 'bold',
fontSize: 9,
lineColor: [0, 0, 0],
lineWidth: 0.1
},
columnStyles: {
0: { cellWidth: 44 },
1: { halign: 'center', cellWidth: 20 },
2: { halign: 'right', cellWidth: 50 },
3: { halign: 'center', cellWidth: 22 },
4: { halign: 'center', cellWidth: 22 },
5: { halign: 'center', cellWidth: 22 }
},
didParseCell: (data: any) => {
if (data.section === 'foot') {
if (data.column.index === 0) data.cell.styles.halign = 'left'
else if (data.column.index === 1) data.cell.styles.halign = 'center'
else if (data.column.index === 2) data.cell.styles.halign = 'right'
}
// Paksa teks status tidak dirender oleh autoTable (set textColor = white)
if (data.section === 'body' && data.column.index === 5) {
data.cell.styles.textColor = [255, 255, 255]
}
},
didDrawCell: (data: any) => {
// Warna teks Status (kolom index 5) — ambil dari array, bukan cell.text agar tidak double
if (data.section === 'body' && data.column.index === 5) {
const status = productStatuses[data.row.index] || 'Sehat'
const color = getStatusColor(status)
pdf.setTextColor(...color)
pdf.setFontSize(9)
pdf.setFont('helvetica', 'bold')
pdf.text(status, data.cell.x + data.cell.width / 2, data.cell.y + data.cell.height / 2 + 1, {
align: 'center'
})
pdf.setTextColor(0, 0, 0)
pdf.setFont('helvetica', 'normal')
}
},
margin: { left: 14, right: 14 }
})
currentY = (pdf as any).lastAutoTable.finalY + 15
})
// ========== GRAND TOTAL ==========
if (currentY > 250) {
pdf.addPage()
currentY = 20
}
autoTable(pdf, {
startY: currentY,
head: [],
body: [
[
'TOTAL KESELURUHAN',
String(productSummary.totalQuantitySold),
formatCurrency(productSummary.totalRevenue),
'',
'',
''
]
],
theme: 'grid',
styles: {
fontSize: PDF_FONT_SIZES.grandTotal,
cellPadding: 6,
fontStyle: 'bold',
textColor: [54, 23, 94],
lineColor: [0, 0, 0],
lineWidth: 0.2
},
columnStyles: {
0: { cellWidth: 44 },
1: { halign: 'center', cellWidth: 20 },
2: { halign: 'right', cellWidth: 50 },
3: { cellWidth: 22 },
4: { cellWidth: 22 },
5: { cellWidth: 22 }
},
margin: { left: 14, right: 14 }
})
// ========== FOOTER ==========
const pageCount = pdf.getNumberOfPages()
for (let i = 1; i <= pageCount; i++) {
pdf.setPage(i)
pdf.setFontSize(11)
pdf.setTextColor(120, 120, 120)
pdf.text('© 2025 Apskel - Sistem POS Terpadu', 14, 287)
pdf.text(`Dicetak pada: ${now.toLocaleDateString('id-ID')}`, 190, 287, { align: 'right' })
}
// ========== SAVE PDF ==========
const fileName =
filterType === 'single'
? `laporan-transaksi-${formatDateForInput(selectedDate)}.pdf`
: `laporan-transaksi-${formatDateForInput(dateRange.startDate)}-to-${formatDateForInput(dateRange.endDate)}.pdf`
pdf.save(fileName)
}
-43
View File
@@ -1,43 +0,0 @@
'use client'
// MUI Imports
import { styled } from '@mui/material/styles'
// Styled Component
const AuthIllustrationWrapper = styled('div')(({ theme }) => ({
width: '100%',
maxWidth: 450,
position: 'relative',
[theme.breakpoints.up('md')]: {
'&:before': {
zIndex: -1,
position: 'absolute',
height: '234px',
width: '238px',
content: '""',
top: '-80px',
left: '-45px',
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='238' height='234' viewBox='0 0 238 234' fill='none'%3E%3Crect x='87.9395' y='0.5' width='149' height='149' rx='19.5' stroke='%23${theme.palette.primary.main.slice(
1
)}' stroke-opacity='0.16'/%3E%3Crect y='33.5608' width='200' height='200' rx='10' fill='%23${theme.palette.primary.main.slice(
1
)}' fill-opacity='0.08'/%3E%3C/svg%3E")`
},
'&:after': {
zIndex: -1,
position: 'absolute',
height: '180px',
width: '180px',
content: '""',
right: '-57px',
bottom: '-64px',
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180' viewBox='0 0 180 180' fill='none'%3E%3Crect x='1' y='1' width='178' height='178' rx='19' stroke='%23${theme.palette.primary.main.slice(
1
)}' stroke-opacity='0.16' stroke-width='2' stroke-dasharray='8 8'/%3E%3Crect x='22.5' y='22.5' width='135' height='135' rx='10' fill='%23${theme.palette.primary.main.slice(
1
)}' fill-opacity='0.08'/%3E%3C/svg%3E")`
}
}
}))
export default AuthIllustrationWrapper
+86 -34
View File
@@ -8,8 +8,8 @@ import Link from 'next/link'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
// MUI Imports
import Card from '@mui/material/Card'
import CardContent from '@mui/material/CardContent'
import useMediaQuery from '@mui/material/useMediaQuery'
import { styled, useTheme } from '@mui/material/styles'
import Typography from '@mui/material/Typography'
import IconButton from '@mui/material/IconButton'
import InputAdornment from '@mui/material/InputAdornment'
@@ -17,7 +17,7 @@ import Checkbox from '@mui/material/Checkbox'
import Button from '@mui/material/Button'
import FormControlLabel from '@mui/material/FormControlLabel'
import Divider from '@mui/material/Divider'
import { CircularProgress } from '@mui/material'
import Alert from '@mui/material/Alert'
// Third-party Imports
import { Controller, useForm } from 'react-hook-form'
@@ -25,10 +25,11 @@ import { valibotResolver } from '@hookform/resolvers/valibot'
import { email, object, minLength, string, pipe, nonEmpty } from 'valibot'
import type { SubmitHandler } from 'react-hook-form'
import type { InferInput } from 'valibot'
import { toast } from 'react-toastify'
import classnames from 'classnames'
// Type Imports
import type { Locale } from '@configs/i18n'
import type { SystemMode } from '@core/types'
import type { Locale } from '@/configs/i18n'
// Component Imports
import Logo from '@components/layout/shared/Logo'
@@ -37,12 +38,39 @@ import CustomTextField from '@core/components/mui/TextField'
// Config Imports
import themeConfig from '@configs/themeConfig'
// Hook Imports
import { useImageVariant } from '@core/hooks/useImageVariant'
import { useSettings } from '@core/hooks/useSettings'
// Util Imports
import { getLocalizedUrl } from '@/utils/i18n'
import { useAuthMutation } from '../services/mutations/auth'
import { CircularProgress } from '@mui/material'
import { toast } from 'react-toastify'
// Styled Component Imports
import AuthIllustrationWrapper from './AuthIllustrationWrapper'
// Styled Custom Components
const LoginIllustration = styled('img')(({ theme }) => ({
zIndex: 2,
blockSize: 'auto',
maxBlockSize: 680,
maxInlineSize: '100%',
margin: theme.spacing(12),
[theme.breakpoints.down(1536)]: {
maxBlockSize: 550
},
[theme.breakpoints.down('lg')]: {
maxBlockSize: 450
}
}))
const MaskImg = styled('img')({
blockSize: 'auto',
maxBlockSize: 355,
inlineSize: '100%',
position: 'absolute',
insetBlockEnd: 0,
zIndex: -1
})
type ErrorType = {
message: string[]
@@ -59,17 +87,29 @@ const schema = object({
)
})
const Login = () => {
const Login = ({ mode }: { mode: SystemMode }) => {
// States
const [isPasswordShown, setIsPasswordShown] = useState(false)
const [errorState, setErrorState] = useState<ErrorType | null>(null)
const { login } = useAuthMutation()
// Vars
const darkImg = '/images/pages/auth-mask-dark.png'
const lightImg = '/images/pages/auth-mask-light.png'
const darkIllustration = '/images/illustrations/auth/v2-login-dark.png'
const lightIllustration = '/images/illustrations/auth/v2-login-light.png'
const borderedDarkIllustration = '/images/illustrations/auth/v2-login-dark-border.png'
const borderedLightIllustration = '/images/illustrations/auth/v2-login-light-border.png'
// Hooks
const router = useRouter()
const searchParams = useSearchParams()
const { lang: locale } = useParams()
const { settings } = useSettings()
const theme = useTheme()
const hidden = useMediaQuery(theme.breakpoints.down('md'))
const authBackground = useImageVariant(mode, lightImg, darkImg)
const {
control,
@@ -83,6 +123,14 @@ const Login = () => {
}
})
const characterIllustration = useImageVariant(
mode,
lightIllustration,
darkIllustration,
borderedLightIllustration,
borderedDarkIllustration
)
const handleClickShowPassword = () => setIsPasswordShown(show => !show)
const onSubmit: SubmitHandler<FormData> = async (data: FormData) => {
@@ -90,9 +138,11 @@ const Login = () => {
onSuccess: (data: any) => {
if (data?.user?.role === 'admin') {
const redirectURL = searchParams.get('redirectTo') ?? '/dashboards/overview'
router.replace(getLocalizedUrl(redirectURL, locale as Locale))
} else {
const redirectURL = searchParams.get('redirectTo') ?? '/sa/organizations/list'
router.replace(getLocalizedUrl(redirectURL, locale as Locale))
}
},
@@ -103,17 +153,34 @@ const Login = () => {
}
return (
<AuthIllustrationWrapper>
<Card className='flex flex-col sm:is-[450px]'>
<CardContent className='sm:!p-12'>
<Link href={getLocalizedUrl('/', locale as Locale)} className='flex justify-center mbe-6'>
<div className='flex bs-full justify-center'>
<div
className={classnames(
'flex bs-full items-center justify-center flex-1 min-bs-[100dvh] relative p-6 max-md:hidden',
{
'border-ie': settings.skin === 'bordered'
}
)}
>
<LoginIllustration src={characterIllustration} alt='character-illustration' />
{!hidden && <MaskImg alt='mask' src={authBackground} />}
</div>
<div className='flex justify-center items-center bs-full bg-backgroundPaper !min-is-full p-6 md:!min-is-[unset] md:p-12 md:is-[480px]'>
<div className='absolute block-start-5 sm:block-start-[33px] inline-start-6 sm:inline-start-[38px]'>
<Logo />
</Link>
<div className='flex flex-col gap-1 mbe-6'>
</div>
<div className='flex flex-col gap-6 is-full sm:is-auto md:is-full sm:max-is-[400px] md:max-is-[unset] mbs-8 sm:mbs-11 md:mbs-0'>
<div className='flex flex-col gap-1'>
<Typography variant='h4'>{`Welcome to ${themeConfig.templateName}! 👋🏻`}</Typography>
<Typography>Please sign-in to your account and start the adventure</Typography>
</div>
<form noValidate autoComplete='off' onSubmit={handleSubmit(onSubmit)} className='flex flex-col gap-6'>
<form
noValidate
autoComplete='off'
action={() => {}}
onSubmit={handleSubmit(onSubmit)}
className='flex flex-col gap-6'
>
<Controller
name='email'
control={control}
@@ -147,7 +214,7 @@ const Login = () => {
fullWidth
label='Password'
placeholder='············'
id='outlined-adornment-password'
id='login-password'
type={isPasswordShown ? 'text' : 'password'}
onChange={e => {
field.onChange(e.target.value)
@@ -196,25 +263,10 @@ const Login = () => {
Create an account
</Typography>
</div>
<Divider className='gap-2 text-textPrimary'>or</Divider>
<div className='flex justify-center items-center gap-1.5'>
<IconButton className='text-facebook' size='small'>
<i className='tabler-brand-facebook-filled' />
</IconButton>
<IconButton className='text-twitter' size='small'>
<i className='tabler-brand-twitter-filled' />
</IconButton>
<IconButton className='text-textPrimary' size='small'>
<i className='tabler-brand-github-filled' />
</IconButton>
<IconButton className='text-error' size='small'>
<i className='tabler-brand-google-filled' />
</IconButton>
</div>
</form>
</CardContent>
</Card>
</AuthIllustrationWrapper>
</div>
</div>
</div>
)
}
@@ -13,8 +13,10 @@ import Checkbox from '@mui/material/Checkbox'
import Chip from '@mui/material/Chip'
import MenuItem from '@mui/material/MenuItem'
import TablePagination from '@mui/material/TablePagination'
import TextField from '@mui/material/TextField'
import type { TextFieldProps } from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { Box, CircularProgress, useTheme } from '@mui/material'
// Third-party Imports
import type { RankingInfo } from '@tanstack/match-sorter-utils'
@@ -40,11 +42,10 @@ import { getLocalizedUrl } from '@/utils/i18n'
// Style Imports
import tableStyles from '@core/styles/table.module.css'
import { Box, CircularProgress } from '@mui/material'
import Loading from '../../../../../components/layout/shared/Loading'
import { useOrders } from '../../../../../services/queries/orders'
import { Order } from '../../../../../types/services/order'
import { formatCurrency } from '../../../../../utils/transform'
import { formatCurrency, formatDateDDMMYYYY, formatForInputDate } from '../../../../../utils/transform'
declare module '@tanstack/table-core' {
interface FilterFns {
@@ -105,15 +106,28 @@ const DebouncedInput = ({
const columnHelper = createColumnHelper<ECommerceOrderTypeWithAction>()
const OrderListTable = () => {
const theme = useTheme()
// States
const [rowSelection, setRowSelection] = useState({})
const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [search, setSearch] = useState('')
// Set default date range to current month
const today = new Date()
const firstDayOfMonth = new Date(today.getFullYear(), today.getMonth(), 1)
const [filter, setFilter] = useState({
date_from: formatForInputDate(firstDayOfMonth),
date_to: formatForInputDate(today)
})
const { data, isLoading, error, isFetching } = useOrders({
page: currentPage,
limit: pageSize,
date_from: formatDateDDMMYYYY(new Date(filter.date_from)),
date_to: formatDateDDMMYYYY(new Date(filter.date_to)),
search
})
@@ -163,7 +177,7 @@ const OrderListTable = () => {
cell: ({ row }) => (
<Typography
component={Link}
href={getLocalizedUrl(`/apps/ecommerce/orders/details/${row.original.order_number}`, locale as Locale)}
href={getLocalizedUrl(`/apps/inventory/orders/${row.original.id}/details`, locale as Locale)}
color='primary.main'
>{`#${row.original.order_number}`}</Typography>
)
@@ -203,6 +217,10 @@ const OrderListTable = () => {
header: 'Discount',
cell: ({ row }) => <Typography>{formatCurrency(row.original.discount_amount)}</Typography>
}),
columnHelper.accessor('created_at', {
header: 'Created At',
cell: ({ row }) => <Typography>{formatDateDDMMYYYY(new Date(row.original.created_at))}</Typography>
}),
columnHelper.accessor('action', {
header: 'Action',
cell: ({ row }) => (
@@ -214,10 +232,7 @@ const OrderListTable = () => {
{
text: 'View',
icon: 'tabler-eye',
href: getLocalizedUrl(
`/apps/ecommerce/orders/${row.original.id}/details`,
locale as Locale
),
href: getLocalizedUrl(`/apps/inventory/orders/${row.original.id}/details`, locale as Locale),
linkProps: { className: 'flex items-center gap-2 is-full plb-2 pli-4' }
},
{
@@ -248,21 +263,74 @@ const OrderListTable = () => {
state: {
rowSelection,
pagination: {
pageIndex: currentPage, // <= penting!
pageIndex: currentPage,
pageSize
}
},
enableRowSelection: true, //enable row selection for all rows
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
// Disable client-side pagination since we're handling it server-side
manualPagination: true,
pageCount: Math.ceil(totalCount / pageSize)
})
return (
<Card>
<CardContent className='flex justify-between max-sm:flex-col sm:items-center gap-4'>
<CardContent className='flex flex-col gap-4'>
{/* Date Range Filter */}
<div className='flex items-center gap-4 max-sm:flex-col'>
<Typography variant='body2' className='min-w-fit'>
Filter by Date:
</Typography>
<TextField
type='date'
value={filter.date_from}
onChange={e => {
setFilter({
...filter,
date_from: e.target.value
})
setCurrentPage(1)
}}
size='small'
sx={{
'& .MuiOutlinedInput-root': {
'&.Mui-focused fieldset': {
borderColor: 'primary.main'
},
'& fieldset': {
borderColor: theme.palette.mode === 'dark' ? 'rgba(231, 227, 252, 0.22)' : theme.palette.divider
}
}
}}
/>
<Typography>-</Typography>
<TextField
type='date'
value={filter.date_to}
onChange={e => {
setFilter({
...filter,
date_to: e.target.value
})
setCurrentPage(1)
}}
size='small'
sx={{
'& .MuiOutlinedInput-root': {
'&.Mui-focused fieldset': {
borderColor: 'primary.main'
},
'& fieldset': {
borderColor: theme.palette.mode === 'dark' ? 'rgba(231, 227, 252, 0.22)' : theme.palette.divider
}
}
}}
/>
</div>
{/* Search and Actions */}
<div className='flex justify-between max-sm:flex-col sm:items-center gap-4'>
<DebouncedInput
value={search}
onChange={value => setSearch(value as string)}
@@ -290,7 +358,9 @@ const OrderListTable = () => {
Export
</Button>
</div>
</div>
</CardContent>
<div className='overflow-x-auto'>
{isLoading ? (
<Loading />
@@ -33,7 +33,8 @@ const AddCategoryDrawer = (props: Props) => {
const [formData, setFormData] = useState<CategoryRequest>({
name: '',
description: '',
business_type: ''
business_type: '',
order: 0
})
// Handle Form Submit
@@ -54,13 +55,21 @@ const AddCategoryDrawer = (props: Props) => {
})
}
const handleOrderChange = (e: any) => {
setFormData({
...formData,
order: parseInt(e.target.value) || 0
})
}
// Handle Form Reset
const handleReset = () => {
handleClose()
setFormData({
name: '',
description: '',
business_type: ''
business_type: '',
order: 0
})
}
@@ -99,6 +108,15 @@ const AddCategoryDrawer = (props: Props) => {
>
<MenuItem value='restaurant'>Restaurant</MenuItem>
</CustomTextField>
<CustomTextField
fullWidth
label='Order'
name='order'
type='number'
value={formData.order}
onChange={handleOrderChange}
placeholder='0'
/>
<CustomTextField
fullWidth
label='Description'
@@ -34,7 +34,8 @@ const EditCategoryDrawer = (props: Props) => {
const [formData, setFormData] = useState<CategoryRequest>({
name: '',
description: '',
business_type: ''
business_type: '',
order: 0
})
useEffect(() => {
@@ -42,7 +43,8 @@ const EditCategoryDrawer = (props: Props) => {
setFormData({
name: data.name,
description: data.description,
business_type: data.business_type
business_type: data.business_type,
order: data.order
})
}
}, [data])
@@ -51,11 +53,14 @@ const EditCategoryDrawer = (props: Props) => {
const handleFormSubmit = (e: any) => {
e.preventDefault()
updateCategory({ id: data.id, payload: formData }, {
updateCategory(
{ id: data.id, payload: formData },
{
onSuccess: () => {
handleReset()
}
})
}
)
}
const handleInputChange = (e: any) => {
@@ -65,13 +70,21 @@ const EditCategoryDrawer = (props: Props) => {
})
}
const handleOrderChange = (e: any) => {
setFormData({
...formData,
order: parseInt(e.target.value) || 0
})
}
// Handle Form Reset
const handleReset = () => {
handleClose()
setFormData({
name: '',
description: '',
business_type: ''
business_type: '',
order: 0
})
}
@@ -110,6 +123,15 @@ const EditCategoryDrawer = (props: Props) => {
>
<MenuItem value='restaurant'>Restaurant</MenuItem>
</CustomTextField>
<CustomTextField
fullWidth
label='Order'
name='order'
type='number'
value={formData.order}
onChange={handleOrderChange}
placeholder='0'
/>
<CustomTextField
fullWidth
label='Description'
@@ -12,6 +12,7 @@ import MenuItem from '@mui/material/MenuItem'
import TablePagination from '@mui/material/TablePagination'
import type { TextFieldProps } from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { Box, CircularProgress } from '@mui/material'
// Third-party Imports
import type { RankingInfo } from '@tanstack/match-sorter-utils'
@@ -19,24 +20,26 @@ import { rankItem } from '@tanstack/match-sorter-utils'
import type { ColumnDef, FilterFn } from '@tanstack/react-table'
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'
import classnames from 'classnames'
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd'
// Component Imports
import TablePaginationComponent from '@components/TablePaginationComponent'
import CustomTextField from '@core/components/mui/TextField'
import OptionMenu from '@core/components/option-menu'
import AddCategoryDrawer from './AddCategoryDrawer'
// Style Imports
import tableStyles from '@core/styles/table.module.css'
import { Box, CircularProgress } from '@mui/material'
import EditCategoryDrawer from './EditCategoryDrawer'
import ConfirmDeleteDialog from '../../../../../components/dialogs/confirm-delete'
import Loading from '../../../../../components/layout/shared/Loading'
// Services Imports
import { useCategoriesMutation } from '../../../../../services/mutations/categories'
import { useCategories } from '../../../../../services/queries/categories'
import { Category } from '../../../../../types/services/category'
import EditCategoryDrawer from './EditCategoryDrawer'
import { formatDate } from '../../../../../utils/transform'
// Style Imports
import tableStyles from '@core/styles/table.module.css'
declare module '@tanstack/table-core' {
interface FilterFns {
fuzzy: FilterFn<unknown>
@@ -51,15 +54,8 @@ type CategoryWithActionsType = Category & {
}
const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
// Rank the item
const itemRank = rankItem(row.getValue(columnId), value)
// Store the itemRank info
addMeta({
itemRank
})
// Return if the item should be filtered in/out
addMeta({ itemRank })
return itemRank.passed
}
@@ -73,7 +69,6 @@ const DebouncedInput = ({
onChange: (value: string | number) => void
debounce?: number
} & Omit<TextFieldProps, 'onChange'>) => {
// States
const [value, setValue] = useState(initialValue)
useEffect(() => {
@@ -86,47 +81,51 @@ const DebouncedInput = ({
}, debounce)
return () => clearTimeout(timeout)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value])
return <CustomTextField {...props} value={value} onChange={e => setValue(e.target.value)} />
}
// Column Definitions
const columnHelper = createColumnHelper<CategoryWithActionsType>()
const ProductCategoryTable = () => {
// States
const [addCategoryOpen, setAddCategoryOpen] = useState(false)
const [editCategoryOpen, setEditCategoryOpen] = useState(false)
const [rowSelection, setRowSelection] = useState({})
const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [pageSize, setPageSize] = useState(50)
const [categoryId, setCategoryId] = useState('')
const [openConfirm, setOpenConfirm] = useState(false)
const [currentCategory, setCurrentCategory] = useState<Category>()
const [search, setSearch] = useState('')
const [localCategories, setLocalCategories] = useState<Category[]>([])
// Fetch products with pagination and search
const { data, isLoading, error, isFetching } = useCategories({
page: currentPage,
limit: pageSize
})
const { mutate: deleteCategory, isPending: isDeleting } = useCategoriesMutation().deleteCategory
const { mutate: updateCategory } = useCategoriesMutation().updateCategory
const categories = data?.categories ?? []
const totalCount = data?.total_count ?? 0
// Update local categories when data changes
useEffect(() => {
if (categories.length > 0) {
setLocalCategories(categories)
}
}, [categories])
const handlePageChange = useCallback((event: unknown, newPage: number) => {
setCurrentPage(newPage)
}, [])
// Handle page size change
const handlePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const newPageSize = parseInt(event.target.value, 10)
const newPageSize = parseInt(event.target.value, 50)
setPageSize(newPageSize)
setCurrentPage(1) // Reset to first page
setCurrentPage(1)
}, [])
const handleDelete = () => {
@@ -135,8 +134,56 @@ const ProductCategoryTable = () => {
})
}
const handleDragEnd = (result: DropResult) => {
if (!result.destination) return
const items = Array.from(localCategories)
const [reorderedItem] = items.splice(result.source.index, 1)
items.splice(result.destination.index, 0, reorderedItem)
setLocalCategories(items)
// Update order for all affected items
items.forEach((item, index) => {
const newOrder = index + 1 // Order dimulai dari 1
// Only update if order changed
if (item.order !== newOrder) {
const updateData = {
name: item.name,
description: item.description,
business_type: item.business_type,
order: newOrder
}
updateCategory(
{ id: item.id, payload: updateData },
{
onSuccess: () => {
console.log(`Category ${item.name} order updated to ${newOrder}`)
},
onError: error => {
console.error(`Failed to update category ${item.name}:`, error)
}
}
)
}
})
}
const columns = useMemo<ColumnDef<CategoryWithActionsType, any>[]>(
() => [
{
id: 'drag',
header: '',
cell: () => (
<div className='cursor-move flex items-center justify-center'>
<i className='tabler-grip-vertical text-textSecondary text-xl' />
</div>
),
size: 50,
enableSorting: false
},
{
id: 'select',
header: ({ table }) => (
@@ -163,7 +210,6 @@ const ProductCategoryTable = () => {
header: 'Name',
cell: ({ row }) => (
<div className='flex items-center gap-3'>
{/* <img src={row.original.image} width={38} height={38} className='rounded bg-actionHover' /> */}
<div className='flex flex-col items-start'>
<Typography className='font-medium' color='text.primary'>
{row.original.name}
@@ -173,7 +219,7 @@ const ProductCategoryTable = () => {
)
}),
columnHelper.accessor('description', {
header: 'Decription',
header: 'Description',
cell: ({ row }) => <Typography>{row.original.description || '-'}</Typography>
}),
columnHelper.accessor('business_type', {
@@ -219,12 +265,11 @@ const ProductCategoryTable = () => {
enableSorting: false
})
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[data]
)
const table = useReactTable({
data: categories as Category[],
data: localCategories as Category[],
columns,
filterFns: {
fuzzy: fuzzyFilter
@@ -232,14 +277,13 @@ const ProductCategoryTable = () => {
state: {
rowSelection,
pagination: {
pageIndex: currentPage, // <= penting!
pageIndex: currentPage,
pageSize
}
},
enableRowSelection: true, //enable row selection for all rows
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
// Disable client-side pagination since we're handling it server-side
manualPagination: true,
pageCount: Math.ceil(totalCount / pageSize)
})
@@ -261,9 +305,9 @@ const ProductCategoryTable = () => {
onChange={handlePageSizeChange}
className='flex-auto max-sm:is-full sm:is-[70px]'
>
<MenuItem value='10'>10</MenuItem>
<MenuItem value='15'>15</MenuItem>
<MenuItem value='25'>25</MenuItem>
<MenuItem value='50'>50</MenuItem>
<MenuItem value='75'>75</MenuItem>
<MenuItem value='100'>100</MenuItem>
</CustomTextField>
<Button
variant='contained'
@@ -275,10 +319,11 @@ const ProductCategoryTable = () => {
</Button>
</div>
</div>
<div className='overflow-x-auto'>
<div className='overflow-x-auto relative'>
{isLoading ? (
<Loading />
) : (
<DragDropContext onDragEnd={handleDragEnd}>
<table className={tableStyles.table}>
<thead>
{table.getHeaderGroups().map(headerGroup => (
@@ -286,7 +331,6 @@ const ProductCategoryTable = () => {
{headerGroup.headers.map(header => (
<th key={header.id}>
{header.isPlaceholder ? null : (
<>
<div
className={classnames({
'flex items-center': header.column.getIsSorted(),
@@ -300,38 +344,58 @@ const ProductCategoryTable = () => {
desc: <i className='tabler-chevron-down text-xl' />
}[header.column.getIsSorted() as 'asc' | 'desc'] ?? null}
</div>
</>
)}
</th>
))}
</tr>
))}
</thead>
<Droppable droppableId='category-table'>
{provided => (
<tbody {...provided.droppableProps} ref={provided.innerRef}>
{table.getFilteredRowModel().rows.length === 0 ? (
<tbody>
<tr>
<td colSpan={table.getVisibleFlatColumns().length} className='text-center'>
No data available
</td>
</tr>
</tbody>
) : (
<tbody>
{table
table
.getRowModel()
.rows.slice(0, table.getState().pagination.pageSize)
.map(row => {
.map((row, index) => {
const categoryId = row.original.id
return (
<tr key={row.id} className={classnames({ selected: row.getIsSelected() })}>
{row.getVisibleCells().map(cell => (
<td key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>
<Draggable key={categoryId} draggableId={categoryId} index={index}>
{(provided, snapshot) => (
<tr
ref={provided.innerRef}
{...provided.draggableProps}
className={classnames({
selected: row.getIsSelected()
})}
style={{
...provided.draggableProps.style,
backgroundColor: snapshot.isDragging ? '#f3f4f6' : 'transparent'
}}
>
{row.getVisibleCells().map((cell, cellIndex) => (
<td key={cell.id} {...(cellIndex === 0 ? provided.dragHandleProps : {})}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
)}
</Draggable>
)
})}
})
)}
{provided.placeholder}
</tbody>
)}
</Droppable>
</table>
</DragDropContext>
)}
{isFetching && !isLoading && (
@@ -365,7 +429,7 @@ const ProductCategoryTable = () => {
page={currentPage}
onPageChange={handlePageChange}
onRowsPerPageChange={handlePageSizeChange}
rowsPerPageOptions={[10, 25, 50]}
rowsPerPageOptions={[50, 75, 100]}
disabled={isLoading}
/>
</Card>
@@ -0,0 +1,171 @@
'use client'
import { useState, useEffect } from 'react'
import Button from '@mui/material/Button'
import Divider from '@mui/material/Divider'
import Drawer from '@mui/material/Drawer'
import IconButton from '@mui/material/IconButton'
import Typography from '@mui/material/Typography'
import Radio from '@mui/material/Radio'
import RadioGroup from '@mui/material/RadioGroup'
import FormControlLabel from '@mui/material/FormControlLabel'
import MenuItem from '@mui/material/MenuItem'
import CustomTextField from '@core/components/mui/TextField'
import { useCategories } from '@/services/queries/categories'
import { CategoryRuleType } from '@/types/services/discount'
interface CategoryRule {
category_id: string
category_name?: string
rule_type: CategoryRuleType
}
type Props = {
open: boolean
onClose: () => void
onConfirm: (rule: CategoryRule) => void
}
const CategoryRuleDialog = (props: Props) => {
const { open, onClose, onConfirm } = props
const [selectedCategory, setSelectedCategory] = useState('')
const [ruleType, setRuleType] = useState<CategoryRuleType>('included')
const [searchQuery, setSearchQuery] = useState('')
const { data: categoriesData } = useCategories({ page: 1, limit: 100 })
const categories = categoriesData?.categories ?? []
useEffect(() => {
if (open) {
setSelectedCategory('')
setRuleType('included')
setSearchQuery('')
}
}, [open])
const filteredCategories = categories.filter(category =>
category.name.toLowerCase().includes(searchQuery.toLowerCase())
)
const handleConfirm = () => {
if (!selectedCategory) return
const category = categories.find(c => c.id === selectedCategory)
onConfirm({
category_id: selectedCategory,
category_name: category?.name,
rule_type: ruleType
})
handleReset()
}
const handleReset = () => {
setSelectedCategory('')
setRuleType('included')
setSearchQuery('')
onClose()
}
return (
<Drawer
open={open}
anchor='right'
variant='temporary'
onClose={handleReset}
ModalProps={{ keepMounted: true }}
sx={{ '& .MuiDrawer-paper': { width: { xs: 300, sm: 500 } } }}
>
<div className='flex items-center justify-between pli-6 plb-5'>
<Typography variant='h5'>Add Category Rule</Typography>
<IconButton size='small' onClick={handleReset}>
<i className='tabler-x text-textSecondary text-2xl' />
</IconButton>
</div>
<Divider />
<div className='p-6'>
<div className='flex flex-col gap-5'>
{/* Category Search & Select */}
<div>
<Typography variant='body2' className='mbe-2'>
Select Category *
</Typography>
<CustomTextField
fullWidth
placeholder='Search category...'
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className='mbe-2'
/>
<div className='max-h-48 overflow-y-auto border rounded'>
{filteredCategories.length === 0 ? (
<div className='text-center py-4'>
<Typography variant='body2' color='text.secondary'>
No categories found
</Typography>
</div>
) : (
<RadioGroup value={selectedCategory} onChange={e => setSelectedCategory(e.target.value)}>
{filteredCategories.map(category => (
<div
key={category.id}
className='flex items-center p-3 hover:bg-actionHover cursor-pointer border-b last:border-b-0'
onClick={() => setSelectedCategory(category.id)}
>
<FormControlLabel
value={category.id}
control={<Radio />}
label={
<div>
<Typography variant='body1'>{category.name}</Typography>
{category.description && (
<Typography variant='body2' color='text.secondary'>
{category.description}
</Typography>
)}
</div>
}
sx={{ margin: 0, width: '100%' }}
/>
</div>
))}
</RadioGroup>
)}
</div>
</div>
{/* Rule Type */}
<CustomTextField
select
fullWidth
label='Rule Type *'
value={ruleType}
onChange={e => setRuleType(e.target.value as CategoryRuleType)}
>
<MenuItem value='included'>Included (Apply discount)</MenuItem>
<MenuItem value='excluded'>Excluded (No discount)</MenuItem>
</CustomTextField>
<Typography variant='caption' color='text.secondary' className='-mbs-4'>
{ruleType === 'included' && 'Only products in this category can get discount'}
{ruleType === 'excluded' && 'Products in this category will not get discount'}
</Typography>
{/* Action Buttons */}
<div className='flex items-center gap-4'>
<Button variant='contained' onClick={handleConfirm} disabled={!selectedCategory}>
Add Rule
</Button>
<Button variant='tonal' color='error' onClick={handleReset}>
Cancel
</Button>
</div>
</div>
</div>
</Drawer>
)
}
export default CategoryRuleDialog
@@ -0,0 +1,144 @@
'use client'
import CustomTextField from '@/@core/components/mui/TextField'
import { useOutlets } from '@/services/queries/outlets'
import { Button, Checkbox, Divider, Drawer, FormControlLabel, IconButton, Typography } from '@mui/material'
import { useEffect, useState } from 'react'
type Props = {
open: boolean
onClose: () => void
selectedOutlets: string[]
onConfirm: (outletIds: string[]) => void
}
const OutletSelectionDialog = (props: Props) => {
const { open, onClose, selectedOutlets, onConfirm } = props
const [selected, setSelected] = useState<string[]>(selectedOutlets)
const [searchQuery, setSearchQuery] = useState('')
const { data: outletsData } = useOutlets({ page: 1, limit: 100 })
const outlets = outletsData?.outlets ?? []
useEffect(() => {
if (open) {
setSelected(selectedOutlets)
setSearchQuery('')
}
}, [open, selectedOutlets])
const filteredOutlets = outlets.filter(outlet => outlet.name.toLowerCase().includes(searchQuery.toLowerCase()))
const handleToggle = (outletId: string) => {
if (selected.includes(outletId)) {
setSelected(selected.filter(id => id !== outletId))
} else {
setSelected([...selected, outletId])
}
}
const handleSelectAll = () => {
if (selected.length === filteredOutlets.length) {
setSelected([])
} else {
setSelected(filteredOutlets.map(o => o.id))
}
}
const handleConfirm = () => {
onConfirm(selected)
onClose()
}
const handleReset = () => {
setSelected(selectedOutlets)
setSearchQuery('')
onClose()
}
return (
<Drawer
open={open}
anchor='right'
variant='temporary'
onClose={handleReset}
ModalProps={{ keepMounted: true }}
sx={{ '& .MuiDrawer-paper': { width: { xs: 300, sm: 500 } } }}
>
<div className='flex items-center justify-between pli-6 plb-5'>
<Typography variant='h5'>Select Outlets</Typography>
<IconButton size='small' onClick={handleReset}>
<i className='tabler-x text-textSecondary text-2xl' />
</IconButton>
</div>
<Divider />
<div className='p-6'>
{/* Search */}
<CustomTextField
fullWidth
placeholder='Search outlets...'
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className='mbe-4'
/>
{/* Select All */}
<div className='flex items-center justify-between mbe-4'>
<Button variant='text' size='small' onClick={handleSelectAll}>
{selected.length === filteredOutlets.length ? 'Deselect All' : 'Select All'}
</Button>
<Typography variant='body2' color='text.secondary'>
{selected.length} selected
</Typography>
</div>
{/* Outlet List */}
<div className='max-h-[400px] overflow-y-auto border rounded'>
{filteredOutlets.length === 0 ? (
<div className='text-center py-8'>
<Typography variant='body2' color='text.secondary'>
No outlets found
</Typography>
</div>
) : (
filteredOutlets.map(outlet => (
<div
key={outlet.id}
className='flex items-center p-3 hover:bg-actionHover cursor-pointer border-b last:border-b-0'
onClick={() => handleToggle(outlet.id)}
>
<FormControlLabel
control={<Checkbox checked={selected.includes(outlet.id)} />}
label={
<div>
<Typography variant='body1'>{outlet.name}</Typography>
{outlet.address && (
<Typography variant='body2' color='text.secondary'>
{outlet.address}
</Typography>
)}
</div>
}
sx={{ margin: 0, width: '100%' }}
/>
</div>
))
)}
</div>
{/* Action Buttons */}
<div className='flex items-center gap-4 mbs-6'>
<Button variant='contained' onClick={handleConfirm}>
Confirm ({selected.length})
</Button>
<Button variant='tonal' color='error' onClick={handleReset}>
Cancel
</Button>
</div>
</div>
</Drawer>
)
}
export default OutletSelectionDialog
@@ -0,0 +1,204 @@
'use client'
import { useState, useEffect } from 'react'
import Button from '@mui/material/Button'
import Divider from '@mui/material/Divider'
import Drawer from '@mui/material/Drawer'
import IconButton from '@mui/material/IconButton'
import Typography from '@mui/material/Typography'
import Radio from '@mui/material/Radio'
import RadioGroup from '@mui/material/RadioGroup'
import FormControlLabel from '@mui/material/FormControlLabel'
import MenuItem from '@mui/material/MenuItem'
import CustomTextField from '@core/components/mui/TextField'
import { useProducts } from '@/services/queries/products'
import { ProductRuleType } from '@/types/services/discount'
interface ProductRule {
product_id: string
product_name?: string
rule_type: ProductRuleType
quantity: number
free_quantity: number
}
type Props = {
open: boolean
onClose: () => void
onConfirm: (rule: ProductRule) => void
}
const ProductRuleDialog = (props: Props) => {
const { open, onClose, onConfirm } = props
const [selectedProduct, setSelectedProduct] = useState('')
const [ruleType, setRuleType] = useState<ProductRuleType>('required')
const [quantity, setQuantity] = useState(1)
const [freeQuantity, setFreeQuantity] = useState(0)
const [searchQuery, setSearchQuery] = useState('')
const { data: productsData } = useProducts({ page: 1, limit: 100 })
const products = productsData?.products ?? []
useEffect(() => {
if (open) {
setSelectedProduct('')
setRuleType('required')
setQuantity(1)
setFreeQuantity(0)
setSearchQuery('')
}
}, [open])
const filteredProducts = products.filter(product => product.name.toLowerCase().includes(searchQuery.toLowerCase()))
const handleConfirm = () => {
if (!selectedProduct) return
const product = products.find(p => p.id === selectedProduct)
onConfirm({
product_id: selectedProduct,
product_name: product?.name,
rule_type: ruleType,
quantity,
free_quantity: ruleType === 'free' ? freeQuantity : 0
})
handleReset()
}
const handleReset = () => {
setSelectedProduct('')
setRuleType('required')
setQuantity(1)
setFreeQuantity(0)
setSearchQuery('')
onClose()
}
return (
<Drawer
open={open}
anchor='right'
variant='temporary'
onClose={handleReset}
ModalProps={{ keepMounted: true }}
sx={{ '& .MuiDrawer-paper': { width: { xs: 300, sm: 500 } } }}
>
<div className='flex items-center justify-between pli-6 plb-5'>
<Typography variant='h5'>Add Product Rule</Typography>
<IconButton size='small' onClick={handleReset}>
<i className='tabler-x text-textSecondary text-2xl' />
</IconButton>
</div>
<Divider />
<div className='p-6'>
<div className='flex flex-col gap-5'>
{/* Product Search & Select */}
<div>
<Typography variant='body2' className='mbe-2'>
Select Product *
</Typography>
<CustomTextField
fullWidth
placeholder='Search product...'
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className='mbe-2'
/>
<div className='max-h-48 overflow-y-auto border rounded'>
{filteredProducts.length === 0 ? (
<div className='text-center py-4'>
<Typography variant='body2' color='text.secondary'>
No products found
</Typography>
</div>
) : (
<RadioGroup value={selectedProduct} onChange={e => setSelectedProduct(e.target.value)}>
{filteredProducts.map(product => (
<div
key={product.id}
className='flex items-center p-3 hover:bg-actionHover cursor-pointer border-b last:border-b-0'
onClick={() => setSelectedProduct(product.id)}
>
<FormControlLabel
value={product.id}
control={<Radio />}
label={
<div>
<Typography variant='body1'>{product.name}</Typography>
<Typography variant='body2' color='text.secondary'>
SKU: {product.sku}
</Typography>
</div>
}
sx={{ margin: 0, width: '100%' }}
/>
</div>
))}
</RadioGroup>
)}
</div>
</div>
{/* Rule Type */}
<CustomTextField
select
fullWidth
label='Rule Type *'
value={ruleType}
onChange={e => setRuleType(e.target.value as ProductRuleType)}
>
<MenuItem value='required'>Required (Must buy)</MenuItem>
<MenuItem value='free'>Free (Get free)</MenuItem>
<MenuItem value='excluded'>Excluded (No discount)</MenuItem>
</CustomTextField>
<Typography variant='caption' color='text.secondary' className='-mbs-4'>
{ruleType === 'required' && 'Product must be in cart to get discount'}
{ruleType === 'free' && 'Product will be given for free when conditions met'}
{ruleType === 'excluded' && 'Product will not get discount'}
</Typography>
{/* Quantity */}
{ruleType !== 'excluded' && (
<CustomTextField
fullWidth
type='number'
label={`${ruleType === 'required' ? 'Minimum ' : ''}Quantity`}
value={quantity}
onChange={e => setQuantity(Number(e.target.value))}
inputProps={{ min: 1 }}
/>
)}
{/* Free Quantity */}
{ruleType === 'free' && (
<CustomTextField
fullWidth
type='number'
label='Free Quantity'
value={freeQuantity}
onChange={e => setFreeQuantity(Number(e.target.value))}
inputProps={{ min: 0 }}
helperText={`Buy ${quantity}, get ${freeQuantity} free`}
/>
)}
{/* Action Buttons */}
<div className='flex items-center gap-4'>
<Button variant='contained' onClick={handleConfirm} disabled={!selectedProduct}>
Add Rule
</Button>
<Button variant='tonal' color='error' onClick={handleReset}>
Cancel
</Button>
</div>
</div>
</div>
</Drawer>
)
}
export default ProductRuleDialog
@@ -0,0 +1,88 @@
'use client'
import { useParams, useRouter } from 'next/navigation'
import Button from '@mui/material/Button'
import Typography from '@mui/material/Typography'
import { CircularProgress } from '@mui/material'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from '@/redux-store'
import { getLocalizedUrl } from '@/utils/i18n'
import type { Locale } from '@configs/i18n'
import { useDiscountsMutation } from '@/services/mutations/discount'
import { resetDiscount } from '@/redux-store/slices/discount'
const DiscountAddHeader = () => {
const dispatch = useDispatch()
const params = useParams()
const router = useRouter()
const { lang: locale } = params
const { createDiscount, updateDiscount } = useDiscountsMutation()
const { discountRequest } = useSelector((state: RootState) => state.discountReducer)
const isEdit = !!params?.id
const isCreating = createDiscount.isPending
const isUpdating = updateDiscount.isPending
const handleSubmit = () => {
const payload = {
...discountRequest,
value: Number(discountRequest.value),
min_purchase_amount: Number(discountRequest.min_purchase_amount) || 0,
min_purchase_qty: Number(discountRequest.min_purchase_qty) || 0,
usage_limit_per_customer: discountRequest.usage_limit_per_customer
? Number(discountRequest.usage_limit_per_customer)
: undefined,
usage_limit_total: discountRequest.usage_limit_total ? Number(discountRequest.usage_limit_total) : undefined,
priority: Number(discountRequest.priority),
start_date: discountRequest.start_date ? new Date(discountRequest.start_date + 'T00:00:00').toISOString() : '',
end_date: discountRequest.end_date ? new Date(discountRequest.end_date + 'T23:59:59').toISOString() : ''
}
if (isEdit) {
updateDiscount.mutate(
{ id: params?.id as string, payload },
{
onSuccess: () => {
dispatch(resetDiscount())
router.push(getLocalizedUrl('/apps/marketing/discount', locale as Locale))
}
}
)
} else {
createDiscount.mutate(payload, {
onSuccess: () => {
dispatch(resetDiscount())
router.push(getLocalizedUrl('/apps/marketing/discount', locale as Locale))
}
})
}
}
const handleDiscard = () => {
dispatch(resetDiscount())
router.push(getLocalizedUrl('/apps/marketing/discount', locale as Locale))
}
return (
<div className='flex flex-wrap sm:items-center justify-between max-sm:flex-col gap-6'>
<div>
<Typography variant='h4' className='mbe-1'>
{isEdit ? 'Edit Discount' : 'Add a new discount'}
</Typography>
<Typography>Create promotional discounts for your store</Typography>
</div>
<div className='flex flex-wrap max-sm:flex-col gap-4'>
<Button variant='tonal' color='secondary' onClick={handleDiscard} disabled={isCreating || isUpdating}>
Discard
</Button>
<Button variant='contained' disabled={isCreating || isUpdating} onClick={handleSubmit}>
{isEdit ? 'Update Discount' : 'Publish Discount'}
{(isCreating || isUpdating) && <CircularProgress color='inherit' size={16} className='ml-2' />}
</Button>
</div>
</div>
)
}
export default DiscountAddHeader
@@ -0,0 +1,183 @@
'use client'
import { useEffect } from 'react'
import { useParams } from 'next/navigation'
import Grid from '@mui/material/Grid2'
import Card from '@mui/material/Card'
import CardHeader from '@mui/material/CardHeader'
import CardContent from '@mui/material/CardContent'
import MenuItem from '@mui/material/MenuItem'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from '@/redux-store'
import CustomTextField from '@core/components/mui/TextField'
import Loading from '@/components/layout/shared/Loading'
import { useDiscountById } from '@/services/queries/discount'
import { useCampaigns } from '@/services/queries/campaign'
import { resetDiscount, setDiscount, setDiscountField } from '@/redux-store/slices/discount'
const DiscountInformation = () => {
const dispatch = useDispatch()
const params = useParams()
const { data: discount, isLoading } = useDiscountById(params?.id as string)
const { data: campaignsData } = useCampaigns({ page: 1, limit: 100 })
const { discountRequest } = useSelector((state: RootState) => state.discountReducer)
const isEdit = !!params?.id
const campaigns = campaignsData?.campaigns ?? []
useEffect(() => {
if (params?.id) {
if (discount) {
dispatch(
setDiscount({
campaign_id: discount.campaign_id,
code: discount.code,
name: discount.name,
type: discount.type,
value: discount.value,
min_purchase_amount: discount.min_purchase_amount,
min_purchase_qty: discount.min_purchase_qty,
start_date: new Date(discount.start_date).toISOString().split('T')[0],
end_date: new Date(discount.end_date).toISOString().split('T')[0],
usage_limit_per_customer: discount.usage_limit_per_customer ?? 0,
usage_limit_total: discount.usage_limit_total ?? 0,
customer_type: discount.customer_type,
is_stackable: discount.is_stackable,
priority: discount.priority
})
)
}
} else {
dispatch(resetDiscount())
}
}, [params?.id, discount, dispatch])
const handleInputChange = (field: any, value: any) => {
dispatch(setDiscountField({ field, value }))
}
if (isLoading) return <Loading />
return (
<Card>
<CardHeader title='Discount Information' />
<CardContent>
<Grid container spacing={6} className='mbe-6'>
<Grid size={{ xs: 12 }}>
<CustomTextField
select
fullWidth
label='Campaign'
value={discountRequest.campaign_id || ''}
onChange={e => handleInputChange('campaign_id', e.target.value)}
>
<MenuItem value=''>Select Campaign</MenuItem>
{campaigns.map(campaign => (
<MenuItem key={campaign.id} value={campaign.id}>
{campaign.name}
</MenuItem>
))}
</CustomTextField>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<CustomTextField
fullWidth
label='Discount Code'
placeholder='FLASH50'
value={discountRequest.code || ''}
onChange={e => handleInputChange('code', e.target.value.toUpperCase())}
helperText='Only uppercase letters and numbers'
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<CustomTextField
fullWidth
label='Discount Name'
placeholder='Flash Sale 50%'
value={discountRequest.name || ''}
onChange={e => handleInputChange('name', e.target.value)}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<CustomTextField
select
fullWidth
label='Discount Type'
value={discountRequest.type || 'percentage'}
onChange={e => handleInputChange('type', e.target.value)}
>
<MenuItem value='percentage'>Percentage (%)</MenuItem>
<MenuItem value='fixed_amount'>Fixed Amount (Rp)</MenuItem>
<MenuItem value='free_product'>Free Product</MenuItem>
</CustomTextField>
</Grid>
{discountRequest.type !== 'free_product' && (
<Grid size={{ xs: 12, sm: 6 }}>
<CustomTextField
fullWidth
type='number'
label={`Discount Value ${discountRequest.type === 'percentage' ? '(%)' : '(Rp)'}`}
placeholder={discountRequest.type === 'percentage' ? '50' : '50000'}
value={discountRequest.value || ''}
onChange={e => handleInputChange('value', e.target.value)}
/>
</Grid>
)}
<Grid size={{ xs: 12, sm: 6 }}>
<CustomTextField
fullWidth
type='number'
label='Minimum Purchase Amount (Rp)'
placeholder='100000'
value={discountRequest.min_purchase_amount || ''}
onChange={e => handleInputChange('min_purchase_amount', e.target.value)}
helperText='Leave 0 if no minimum'
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<CustomTextField
fullWidth
type='number'
label='Minimum Purchase Quantity'
placeholder='1'
value={discountRequest.min_purchase_qty || ''}
onChange={e => handleInputChange('min_purchase_qty', e.target.value)}
helperText='Leave 0 if no minimum'
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<CustomTextField
fullWidth
type='date'
label='Start Date'
value={discountRequest.start_date || ''}
onChange={e => handleInputChange('start_date', e.target.value)}
InputLabelProps={{ shrink: true }}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<CustomTextField
fullWidth
type='date'
label='End Date'
value={discountRequest.end_date || ''}
onChange={e => handleInputChange('end_date', e.target.value)}
InputLabelProps={{ shrink: true }}
/>
</Grid>
</Grid>
</CardContent>
</Card>
)
}
export default DiscountInformation
@@ -0,0 +1,72 @@
'use client'
import { useState } from 'react'
import Card from '@mui/material/Card'
import CardHeader from '@mui/material/CardHeader'
import CardContent from '@mui/material/CardContent'
import Button from '@mui/material/Button'
import Chip from '@mui/material/Chip'
import IconButton from '@mui/material/IconButton'
import Typography from '@mui/material/Typography'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from '@/redux-store'
import { setDiscountOutlets } from '@/redux-store/slices/discount'
import OutletSelectionDialog from './Dialog/OutletSelectionDrawer'
const DiscountOutlets = () => {
const dispatch = useDispatch()
const [openDialog, setOpenDialog] = useState(false)
const { selectedOutlets } = useSelector((state: RootState) => state.discountReducer)
const handleConfirm = (outletIds: string[]) => {
dispatch(setDiscountOutlets(outletIds))
setOpenDialog(false)
}
const handleRemove = (outletId: string) => {
dispatch(setDiscountOutlets(selectedOutlets.filter(id => id !== outletId)))
}
return (
<>
<Card>
<CardHeader
title='Applicable Outlets'
action={
<Button variant='tonal' size='small' onClick={() => setOpenDialog(true)}>
Select Outlets
</Button>
}
/>
<CardContent>
{selectedOutlets.length === 0 ? (
<Typography variant='body2' color='text.secondary' className='text-center py-4'>
No outlets selected. Click "Select Outlets" to add.
</Typography>
) : (
<div className='flex flex-wrap gap-2'>
{selectedOutlets.map(outletId => (
<Chip
key={outletId}
label={`Outlet ${outletId}`}
onDelete={() => handleRemove(outletId)}
color='primary'
variant='tonal'
/>
))}
</div>
)}
</CardContent>
</Card>
<OutletSelectionDialog
open={openDialog}
onClose={() => setOpenDialog(false)}
selectedOutlets={selectedOutlets}
onConfirm={handleConfirm}
/>
</>
)
}
export default DiscountOutlets
@@ -0,0 +1,147 @@
'use client'
import { useState } from 'react'
import Card from '@mui/material/Card'
import CardHeader from '@mui/material/CardHeader'
import CardContent from '@mui/material/CardContent'
import Button from '@mui/material/Button'
import Typography from '@mui/material/Typography'
import IconButton from '@mui/material/IconButton'
import Chip from '@mui/material/Chip'
import Divider from '@mui/material/Divider'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from '@/redux-store'
import { removeCategoryRule, removeProductRule, setCategoryRules, setProductRules } from '@/redux-store/slices/discount'
import CategoryRuleDialog from './Dialog/CategoryRuleDialog'
import ProductRuleDialog from './Dialog/ProductRuleDialog'
const DiscountRules = () => {
const dispatch = useDispatch()
const [openProductDialog, setOpenProductDialog] = useState(false)
const [openCategoryDialog, setOpenCategoryDialog] = useState(false)
const { productRules, categoryRules } = useSelector((state: RootState) => state.discountReducer)
const handleAddProductRule = (rule: any) => {
dispatch(setProductRules([...productRules, rule]))
setOpenProductDialog(false)
}
const handleAddCategoryRule = (rule: any) => {
dispatch(setCategoryRules([...categoryRules, rule]))
setOpenCategoryDialog(false)
}
return (
<>
<Card>
<CardHeader title='Discount Rules' />
<CardContent>
{/* Product Rules */}
<div className='mbe-6'>
<div className='flex justify-between items-center mbe-4'>
<Typography variant='h6'>Product Rules</Typography>
<Button
variant='tonal'
size='small'
startIcon={<i className='tabler-plus' />}
onClick={() => setOpenProductDialog(true)}
>
Add Product
</Button>
</div>
{productRules.length === 0 ? (
<Typography
variant='body2'
color='text.secondary'
className='text-center py-4 border border-dashed rounded'
>
No product rules. Click "Add Product" to create rules.
</Typography>
) : (
<div className='flex flex-col gap-3'>
{productRules.map((rule, index) => (
<div key={index} className='flex justify-between items-center p-3 border rounded'>
<div>
<Typography variant='body1' className='font-medium'>
{rule.product_name || `Product ${rule.product_id}`}
</Typography>
<div className='flex gap-2 mbs-2'>
<Chip label={rule.rule_type} size='small' variant='tonal' />
<Chip label={`Qty: ${rule.quantity}`} size='small' variant='outlined' />
{rule.rule_type === 'free' && (
<Chip label={`Free: ${rule.free_quantity}`} size='small' variant='outlined' color='success' />
)}
</div>
</div>
<IconButton size='small' color='error' onClick={() => dispatch(removeProductRule(index))}>
<i className='tabler-trash' />
</IconButton>
</div>
))}
</div>
)}
</div>
<Divider className='mbe-6' />
{/* Category Rules */}
<div>
<div className='flex justify-between items-center mbe-4'>
<Typography variant='h6'>Category Rules</Typography>
<Button
variant='tonal'
size='small'
startIcon={<i className='tabler-plus' />}
onClick={() => setOpenCategoryDialog(true)}
>
Add Category
</Button>
</div>
{categoryRules.length === 0 ? (
<Typography
variant='body2'
color='text.secondary'
className='text-center py-4 border border-dashed rounded'
>
No category rules. Click "Add Category" to create rules.
</Typography>
) : (
<div className='flex flex-col gap-3'>
{categoryRules.map((rule, index) => (
<div key={index} className='flex justify-between items-center p-3 border rounded'>
<div>
<Typography variant='body1' className='font-medium'>
{rule.category_name || `Category ${rule.category_id}`}
</Typography>
<Chip label={rule.rule_type} size='small' variant='tonal' className='mbs-2' />
</div>
<IconButton size='small' color='error' onClick={() => dispatch(removeCategoryRule(index))}>
<i className='tabler-trash' />
</IconButton>
</div>
))}
</div>
)}
</div>
</CardContent>
</Card>
<ProductRuleDialog
open={openProductDialog}
onClose={() => setOpenProductDialog(false)}
onConfirm={handleAddProductRule}
/>
<CategoryRuleDialog
open={openCategoryDialog}
onClose={() => setOpenCategoryDialog(false)}
onConfirm={handleAddCategoryRule}
/>
</>
)
}
export default DiscountRules
@@ -0,0 +1,87 @@
'use client'
import Card from '@mui/material/Card'
import CardHeader from '@mui/material/CardHeader'
import CardContent from '@mui/material/CardContent'
import MenuItem from '@mui/material/MenuItem'
import FormControlLabel from '@mui/material/FormControlLabel'
import Switch from '@mui/material/Switch'
import Divider from '@mui/material/Divider'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from '@/redux-store'
import CustomTextField from '@core/components/mui/TextField'
import { setDiscountField } from '@/redux-store/slices/discount'
const DiscountSettings = () => {
const dispatch = useDispatch()
const { discountRequest } = useSelector((state: RootState) => state.discountReducer)
const handleInputChange = (field: any, value: any) => {
dispatch(setDiscountField({ field, value }))
}
return (
<Card>
<CardHeader title='Settings' />
<CardContent className='flex flex-col gap-4'>
<CustomTextField
fullWidth
type='number'
label='Usage Limit Per Customer'
placeholder='5'
value={discountRequest.usage_limit_per_customer || ''}
onChange={e =>
handleInputChange('usage_limit_per_customer', e.target.value ? Number(e.target.value) : undefined)
}
helperText='Leave empty for unlimited'
/>
<CustomTextField
fullWidth
type='number'
label='Total Usage Limit'
placeholder='100'
value={discountRequest.usage_limit_total || ''}
onChange={e => handleInputChange('usage_limit_total', e.target.value ? Number(e.target.value) : undefined)}
helperText='Leave empty for unlimited'
/>
<CustomTextField
select
fullWidth
label='Customer Type'
value={discountRequest.customer_type || 'all'}
onChange={e => handleInputChange('customer_type', e.target.value)}
>
<MenuItem value='all'>All Customers</MenuItem>
<MenuItem value='member'>Member Only</MenuItem>
<MenuItem value='vip'>VIP Only</MenuItem>
</CustomTextField>
<CustomTextField
fullWidth
type='number'
label='Priority'
placeholder='10'
value={discountRequest.priority || 10}
onChange={e => handleInputChange('priority', e.target.value)}
helperText='Higher number = higher priority (1-100)'
/>
<Divider />
<FormControlLabel
control={
<Switch
checked={discountRequest.is_stackable || false}
onChange={e => handleInputChange('is_stackable', e.target.checked)}
/>
}
label='Can be combined with other discounts (Stackable)'
/>
</CardContent>
</Card>
)
}
export default DiscountSettings
@@ -0,0 +1,17 @@
// MUI Imports
import Grid from '@mui/material/Grid2'
import DiscountListTable from './list'
// Type Imports
const DiscountList = () => {
return (
<Grid container spacing={6}>
<Grid size={{ xs: 12 }}>
<DiscountListTable />
</Grid>
</Grid>
)
}
export default DiscountList
@@ -0,0 +1,508 @@
'use client'
// React Imports
import { useCallback, useEffect, useMemo, useState } from 'react'
// Next Imports
import Link from 'next/link'
import { useParams } from 'next/navigation'
// MUI Imports
import Button from '@mui/material/Button'
import Card from '@mui/material/Card'
import CardHeader from '@mui/material/CardHeader'
import Checkbox from '@mui/material/Checkbox'
import Chip from '@mui/material/Chip'
import Divider from '@mui/material/Divider'
import IconButton from '@mui/material/IconButton'
import MenuItem from '@mui/material/MenuItem'
import TablePagination from '@mui/material/TablePagination'
import type { TextFieldProps } from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { Box, CircularProgress } from '@mui/material'
// Third-party Imports
import type { RankingInfo } from '@tanstack/match-sorter-utils'
import { rankItem } from '@tanstack/match-sorter-utils'
import type { ColumnDef, FilterFn } from '@tanstack/react-table'
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'
import classnames from 'classnames'
// Type Imports
import type { Locale } from '@configs/i18n'
// Component Imports
import TablePaginationComponent from '@components/TablePaginationComponent'
import CustomTextField from '@core/components/mui/TextField'
import OptionMenu from '@core/components/option-menu'
import Loading from '@/components/layout/shared/Loading'
import ConfirmDeleteDialog from '@/components/dialogs/confirm-delete'
// Service Imports
// Util Imports
import { getLocalizedUrl } from '@/utils/i18n'
import { formatCurrency } from '@/utils/transform'
// Style Imports
import tableStyles from '@core/styles/table.module.css'
import { Discount, FetchDiscountsParams } from '@/types/services/discount'
import { useDiscounts } from '@/services/queries/discount'
import { useDiscountsMutation } from '@/services/mutations/discount'
declare module '@tanstack/table-core' {
interface FilterFns {
fuzzy: FilterFn<unknown>
}
interface FilterMeta {
itemRank: RankingInfo
}
}
type DiscountWithActionsType = Discount & {
actions?: string
}
const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
const itemRank = rankItem(row.getValue(columnId), value)
addMeta({ itemRank })
return itemRank.passed
}
const DebouncedInput = ({
value: initialValue,
onChange,
debounce = 500,
...props
}: {
value: string | number
onChange: (value: string | number) => void
debounce?: number
} & Omit<TextFieldProps, 'onChange'>) => {
const [value, setValue] = useState(initialValue)
useEffect(() => {
setValue(initialValue)
}, [initialValue])
useEffect(() => {
const timeout = setTimeout(() => {
onChange(value)
}, debounce)
return () => clearTimeout(timeout)
}, [value])
return <CustomTextField {...props} value={value} onChange={e => setValue(e.target.value)} />
}
// Column Definitions
const columnHelper = createColumnHelper<DiscountWithActionsType>()
const DiscountListTable = () => {
const [rowSelection, setRowSelection] = useState({})
const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [openConfirm, setOpenConfirm] = useState(false)
const [discountId, setDiscountId] = useState('')
const [search, setSearch] = useState('')
const [filter, setFilter] = useState<FetchDiscountsParams>({
type: undefined,
customer_type: undefined,
is_active: undefined,
campaign_id: undefined
})
// Hooks
const { lang: locale } = useParams()
// Fetch discounts with pagination and search
const { data, isLoading, error, isFetching } = useDiscounts({
page: currentPage,
limit: pageSize,
search: search,
...filter
})
const { deleteDiscount, toggleDiscountStatus } = useDiscountsMutation()
const discounts = data?.discounts ?? []
const totalCount = data?.pagination.total_count ?? 0
const handlePageChange = useCallback((event: unknown, newPage: number) => {
setCurrentPage(newPage)
}, [])
const handlePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const newPageSize = parseInt(event.target.value, 10)
setPageSize(newPageSize)
setCurrentPage(1)
}, [])
const handleDelete = () => {
deleteDiscount.mutate(discountId, {
onSuccess: () => setOpenConfirm(false)
})
}
const handleToggleStatus = (id: string, currentStatus: boolean) => {
toggleDiscountStatus.mutate({ id, isActive: !currentStatus })
}
const getDiscountTypeColor = (type: string) => {
switch (type) {
case 'percentage':
return 'primary'
case 'fixed_amount':
return 'success'
case 'free_product':
return 'info'
default:
return 'default'
}
}
const getCustomerTypeColor = (type: string) => {
switch (type) {
case 'all':
return 'default'
case 'member':
return 'warning'
case 'vip':
return 'error'
default:
return 'default'
}
}
const formatDiscountValue = (discount: Discount) => {
if (discount.type === 'percentage') {
return `${discount.value}%`
} else if (discount.type === 'fixed_amount') {
return formatCurrency(discount.value)
} else {
return 'Free Product'
}
}
const columns = useMemo<ColumnDef<DiscountWithActionsType, any>[]>(
() => [
{
id: 'select',
header: ({ table }) => (
<Checkbox
{...{
checked: table.getIsAllRowsSelected(),
indeterminate: table.getIsSomeRowsSelected(),
onChange: table.getToggleAllRowsSelectedHandler()
}}
/>
),
cell: ({ row }) => (
<Checkbox
{...{
checked: row.getIsSelected(),
disabled: !row.getCanSelect(),
indeterminate: row.getIsSomeSelected(),
onChange: row.getToggleSelectedHandler()
}}
/>
)
},
columnHelper.accessor('code', {
header: 'Discount Code',
cell: ({ row }) => (
<div className='flex flex-col'>
<Typography className='font-medium' color='text.primary'>
{row.original.code}
</Typography>
<Typography variant='body2' color='text.secondary'>
{row.original.name}
</Typography>
</div>
)
}),
columnHelper.accessor('type', {
header: 'Type',
cell: ({ row }) => (
<Chip
label={row.original.type.replace('_', ' ').toUpperCase()}
variant='tonal'
color={getDiscountTypeColor(row.original.type)}
size='small'
/>
)
}),
columnHelper.accessor('value', {
header: 'Value',
cell: ({ row }) => <Typography className='font-medium'>{formatDiscountValue(row.original)}</Typography>
}),
columnHelper.accessor('customer_type', {
header: 'Customer Type',
cell: ({ row }) => (
<Chip
label={row.original.customer_type.toUpperCase()}
variant='tonal'
color={getCustomerTypeColor(row.original.customer_type)}
size='small'
/>
)
}),
columnHelper.accessor('min_purchase_amount', {
header: 'Min Purchase',
cell: ({ row }) => (
<Typography>
{row.original.min_purchase_amount ? formatCurrency(row.original.min_purchase_amount) : '-'}
</Typography>
)
}),
columnHelper.accessor('usage_count', {
header: 'Usage',
cell: ({ row }) => (
<div className='flex flex-col'>
<Typography variant='body2'>
{row.original.usage_count} / {row.original.usage_limit_total || '∞'}
</Typography>
</div>
)
}),
columnHelper.accessor('start_date', {
header: 'Period',
cell: ({ row }) => (
<div className='flex flex-col'>
<Typography variant='body2'>{new Date(row.original.start_date).toLocaleDateString()}</Typography>
<Typography variant='body2' color='text.secondary'>
to {new Date(row.original.end_date).toLocaleDateString()}
</Typography>
</div>
)
}),
columnHelper.accessor('is_active', {
header: 'Status',
cell: ({ row }) => (
<div className='flex items-center gap-2'>
<Chip
label={row.original.is_active ? 'Active' : 'Inactive'}
variant='tonal'
color={row.original.is_active ? 'success' : 'error'}
size='small'
/>
</div>
)
}),
columnHelper.accessor('actions', {
header: 'Actions',
cell: ({ row }) => (
<div className='flex items-center'>
<IconButton
LinkComponent={Link}
href={getLocalizedUrl(`/apps/marketing/discount/${row.original.id}/detail`, locale as Locale)}
>
<i className='tabler-eye text-textSecondary' />
</IconButton>
<IconButton
LinkComponent={Link}
href={getLocalizedUrl(`/apps/marketing/discount/${row.original.id}/edit`, locale as Locale)}
>
<i className='tabler-edit text-textSecondary' />
</IconButton>
<OptionMenu
iconButtonProps={{ size: 'medium' }}
iconClassName='text-textSecondary'
options={[
{
text: row.original.is_active ? 'Deactivate' : 'Activate',
icon: row.original.is_active ? 'tabler-toggle-left' : 'tabler-toggle-right',
menuItemProps: {
onClick: () => handleToggleStatus(row.original.id, row.original.is_active)
}
},
{
text: 'Delete',
icon: 'tabler-trash',
menuItemProps: {
onClick: () => {
setOpenConfirm(true)
setDiscountId(row.original.id)
}
}
},
{ text: 'Duplicate', icon: 'tabler-copy' }
]}
/>
</div>
),
enableSorting: false
})
],
[locale]
)
const table = useReactTable({
data: discounts as Discount[],
columns,
filterFns: {
fuzzy: fuzzyFilter
},
state: {
rowSelection,
pagination: {
pageIndex: currentPage,
pageSize
}
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
pageCount: Math.ceil(totalCount / pageSize)
})
return (
<>
<Card>
<CardHeader title='Filters' />
<Divider />
<div className='flex flex-wrap justify-between gap-4 p-6'>
<DebouncedInput
value={search}
onChange={value => setSearch(value as string)}
placeholder='Search Discount Code or Name'
className='max-sm:is-full'
/>
<div className='flex flex-wrap items-center max-sm:flex-col gap-4 max-sm:is-full is-auto'>
<CustomTextField
select
value={pageSize}
onChange={handlePageSizeChange}
className='flex-auto is-[70px] max-sm:is-full'
>
<MenuItem value='10'>10</MenuItem>
<MenuItem value='25'>25</MenuItem>
<MenuItem value='50'>50</MenuItem>
</CustomTextField>
<Button
color='secondary'
variant='tonal'
className='max-sm:is-full is-auto'
startIcon={<i className='tabler-upload' />}
>
Export
</Button>
<Button
variant='contained'
component={Link}
className='max-sm:is-full is-auto'
href={getLocalizedUrl('/apps/marketing/discount/add', locale as Locale)}
startIcon={<i className='tabler-plus' />}
>
Add Discount
</Button>
</div>
</div>
<div className='overflow-x-auto relative'>
{isLoading ? (
<Loading />
) : (
<table className={tableStyles.table}>
<thead>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th key={header.id}>
{header.isPlaceholder ? null : (
<div
className={classnames({
'flex items-center': header.column.getIsSorted(),
'cursor-pointer select-none': header.column.getCanSort()
})}
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{{
asc: <i className='tabler-chevron-up text-xl' />,
desc: <i className='tabler-chevron-down text-xl' />
}[header.column.getIsSorted() as 'asc' | 'desc'] ?? null}
</div>
)}
</th>
))}
</tr>
))}
</thead>
{table.getFilteredRowModel().rows.length === 0 ? (
<tbody>
<tr>
<td colSpan={table.getVisibleFlatColumns().length} className='text-center'>
No data available
</td>
</tr>
</tbody>
) : (
<tbody>
{table
.getRowModel()
.rows.slice(0, table.getState().pagination.pageSize)
.map(row => {
return (
<tr key={row.id} className={classnames({ selected: row.getIsSelected() })}>
{row.getVisibleCells().map(cell => (
<td key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>
))}
</tr>
)
})}
</tbody>
)}
</table>
)}
{isFetching && !isLoading && (
<Box
position='absolute'
top={0}
left={0}
right={0}
bottom={0}
display='flex'
alignItems='center'
justifyContent='center'
bgcolor='rgba(255,255,255,0.7)'
zIndex={1}
>
<CircularProgress size={24} />
</Box>
)}
</div>
<TablePagination
component={() => (
<TablePaginationComponent
pageIndex={currentPage}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={handlePageChange}
/>
)}
count={totalCount}
rowsPerPage={pageSize}
page={currentPage}
onPageChange={handlePageChange}
onRowsPerPageChange={handlePageSizeChange}
rowsPerPageOptions={[10, 25, 50]}
disabled={isLoading}
/>
</Card>
<ConfirmDeleteDialog
open={openConfirm}
onClose={() => setOpenConfirm(false)}
onConfirm={handleDelete}
isLoading={deleteDiscount.isPending}
/>
</>
)
}
export default DiscountListTable
@@ -1,6 +1,6 @@
'use client'
import React, { useState } from 'react'
import React from 'react'
import {
Card,
CardHeader,
@@ -14,19 +14,10 @@ import {
TableRow,
Box,
Button,
IconButton,
Menu,
MenuItem,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
CircularProgress
IconButton
} from '@mui/material'
import Grid from '@mui/material/Grid2'
import { PurchaseOrder, SendPaymentPurchaseOrderRequest } from '@/types/services/purchaseOrder'
import { usePurchaseOrdersMutation } from '@/services/mutations/purchaseOrder'
import { PurchaseOrder } from '@/types/services/purchaseOrder'
interface Props {
data?: PurchaseOrder
@@ -35,88 +26,6 @@ interface Props {
const PurchaseDetailInformation = ({ data }: Props) => {
const purchaseOrder = data
// State for menu and dialog
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [confirmDialog, setConfirmDialog] = useState<{
open: boolean
type: 'approve' | 'reject' | null
title: string
message: string
}>({
open: false,
type: null,
title: '',
message: ''
})
const { updateStatus } = usePurchaseOrdersMutation()
const handleMenuClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget)
}
const handleMenuClose = () => {
setAnchorEl(null)
}
const handleApproveClick = () => {
setConfirmDialog({
open: true,
type: 'approve',
title: 'Konfirmasi Persetujuan',
message: 'Apakah Anda yakin ingin menyetujui purchase order ini?'
})
handleMenuClose()
}
const handleRejectClick = () => {
setConfirmDialog({
open: true,
type: 'reject',
title: 'Konfirmasi Penolakan',
message: 'Apakah Anda yakin ingin menolak purchase order ini?'
})
handleMenuClose()
}
const handleConfirmAction = async () => {
if (!purchaseOrder?.id) return
setIsSubmitting(true)
try {
const status = confirmDialog.type === 'approve' ? 'approved' : 'rejected'
updateStatus.mutate({
id: purchaseOrder.id,
payload: status
})
// Close dialog after successful submission
setConfirmDialog({
open: false,
type: null,
title: '',
message: ''
})
} catch (error) {
console.error('Error updating status:', error)
// Handle error (you might want to show a toast or error message)
} finally {
setIsSubmitting(false)
}
}
const handleCancelAction = () => {
setConfirmDialog({
open: false,
type: null,
title: '',
message: ''
})
}
// Helper functions
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
@@ -137,8 +46,7 @@ const PurchaseDetailInformation = ({ data }: Props) => {
sent: 'Dikirim',
approved: 'Disetujui',
received: 'Diterima',
cancelled: 'Dibatalkan',
rejected: 'Ditolak'
cancelled: 'Dibatalkan'
}
return statusMap[status] || status
}
@@ -149,8 +57,7 @@ const PurchaseDetailInformation = ({ data }: Props) => {
sent: 'warning',
approved: 'success',
received: 'info',
cancelled: 'error',
rejected: 'error'
cancelled: 'error'
}
return colorMap[status] || 'info'
}
@@ -159,11 +66,7 @@ const PurchaseDetailInformation = ({ data }: Props) => {
const totalQuantity = (purchaseOrder?.items ?? []).reduce((sum, item) => sum + (item?.quantity ?? 0), 0)
const total = (purchaseOrder?.items ?? []).reduce((sum, item) => sum + (item?.amount ?? 0) * item?.quantity, 0)
// Check if actions should be available (only for certain statuses)
const canApproveOrReject = purchaseOrder?.status === 'received'
return (
<>
<Card sx={{ width: '100%' }}>
<CardHeader
title={
@@ -178,7 +81,7 @@ const PurchaseDetailInformation = ({ data }: Props) => {
<Button startIcon={<i className='tabler-printer' />} variant='outlined' size='small' sx={{ mr: 1 }}>
Print
</Button>
<IconButton onClick={handleMenuClick}>
<IconButton>
<i className='tabler-dots-vertical' />
</IconButton>
</Box>
@@ -265,7 +168,6 @@ const PurchaseDetailInformation = ({ data }: Props) => {
</TableCell>
<TableCell sx={{ borderTop: '2px solid #e0e0e0' }}></TableCell>
<TableCell sx={{ borderTop: '2px solid #e0e0e0' }}></TableCell>
<TableCell sx={{ borderTop: '2px solid #e0e0e0' }}></TableCell>
</TableRow>
</TableBody>
</Table>
@@ -302,71 +204,6 @@ const PurchaseDetailInformation = ({ data }: Props) => {
</Box>
</CardContent>
</Card>
{/* Menu */}
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
PaperProps={{
sx: {
minWidth: 160
}
}}
>
<MenuItem
onClick={handleApproveClick}
disabled={!canApproveOrReject}
sx={{
color: 'success.main',
'&:hover': {
backgroundColor: 'success.light',
color: 'success.dark'
}
}}
>
<i className='tabler-check' style={{ marginRight: 8 }} />
Disetujui
</MenuItem>
<MenuItem
onClick={handleRejectClick}
disabled={!canApproveOrReject}
sx={{
color: 'error.main',
'&:hover': {
backgroundColor: 'error.light',
color: 'error.dark'
}
}}
>
<i className='tabler-x' style={{ marginRight: 8 }} />
Ditolak
</MenuItem>
</Menu>
{/* Confirmation Dialog */}
<Dialog open={confirmDialog.open} onClose={handleCancelAction} maxWidth='sm' fullWidth>
<DialogTitle>{confirmDialog.title}</DialogTitle>
<DialogContent>
<DialogContentText>{confirmDialog.message}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleCancelAction} color='inherit' disabled={isSubmitting}>
Batal
</Button>
<Button
onClick={handleConfirmAction}
variant='contained'
color={confirmDialog.type === 'approve' ? 'success' : 'error'}
disabled={isSubmitting}
startIcon={isSubmitting ? <CircularProgress size={16} /> : null}
autoFocus
>
{isSubmitting ? 'Memproses...' : confirmDialog.type === 'approve' ? 'Setujui' : 'Tolak'}
</Button>
</DialogActions>
</Dialog>
</>
)
}
@@ -407,7 +407,7 @@ const PurchaseAddForm: React.FC = () => {
description: item.description || undefined,
quantity: item.quantity,
unit_id: item.unit!.value,
amount: item.amount
amount: item.total
})),
attachment_file_ids: formData.attachment_file_ids.length > 0 ? formData.attachment_file_ids : undefined
}
+12 -17
View File
@@ -11,31 +11,26 @@ const ReportFinancialList: React.FC = () => {
const { lang: locale } = useParams()
const financialReports = [
// {
// title: 'Arus Kas',
// iconClass: 'tabler-cash',
// link: getLocalizedUrl(`/apps/report/cash-flow`, locale as Locale)
// },
{
title: 'Arus Kas',
iconClass: 'tabler-cash',
link: getLocalizedUrl(`/apps/report/cash-flow`, locale as Locale)
},
{
title: 'Laba Rugi',
iconClass: 'tabler-cash',
link: getLocalizedUrl(`/apps/report/profit-loss`, locale as Locale)
},
{
title: 'Metode Pembayaran',
title: 'Neraca',
iconClass: 'tabler-cash',
link: getLocalizedUrl(`/apps/report/financial/payment-method`, locale as Locale)
link: getLocalizedUrl(`/apps/report/neraca`, locale as Locale)
},
{
title: 'Ringkasan Eksekutif',
iconClass: 'tabler-cash',
link: getLocalizedUrl(`/apps/report/neraca`, locale as Locale)
}
// {
// title: 'Neraca',
// iconClass: 'tabler-cash',
// link: getLocalizedUrl(`/apps/report/neraca`, locale as Locale)
// },
// {
// title: 'Ringkasan Eksekutif',
// iconClass: 'tabler-cash',
// link: getLocalizedUrl(`/apps/report/neraca`, locale as Locale)
// }
]
return (
+16 -26
View File
@@ -12,45 +12,35 @@ const ReportSalesList: React.FC = () => {
const salesReports = [
{
title: 'Penjualan',
title: 'Detail Penjualan',
iconClass: 'tabler-receipt-2',
link: getLocalizedUrl(`/apps/report/sales/sales-report`, locale as Locale)
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: getLocalizedUrl(`/apps/report/sales/sales-product`, locale as Locale)
link: ''
},
{
title: 'Penjualan per Kategori Produk',
iconClass: 'tabler-receipt-2',
link: getLocalizedUrl(`/apps/report/sales/sales-product-category`, locale as Locale)
link: ''
},
{
title: 'Penjualan Pesanan',
title: 'Penjualan Produk per Pelanggan',
iconClass: 'tabler-receipt-2',
link: getLocalizedUrl(`/apps/report/sales/sales-order`, locale as Locale)
link: ''
},
{
title: 'Pemesanan per 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 (
@@ -1,173 +0,0 @@
'use client'
import DateRangePicker from '@/components/RangeDatePicker'
import { ReportItemHeader, ReportItemSubheader } from '@/components/report/ReportItem'
import { ExcelExportPaymentService } from '@/services/export/excel/ExcelExportPaymentService'
import { PDFExportPaymentService } from '@/services/export/pdf/PDFExportPaymentService'
import { usePaymentAnalytics } from '@/services/queries/analytics'
import { formatCurrency, formatDateDDMMYYYY } from '@/utils/transform'
import { Button, Card, CardContent, Menu, MenuItem } from '@mui/material'
import { useState } from 'react'
const ReportPaymentMethodContent = () => {
const [startDate, setStartDate] = useState<Date | null>(new Date())
const [endDate, setEndDate] = useState<Date | null>(new Date())
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const { data: paymentAnalytics } = usePaymentAnalytics({
date_from: formatDateDDMMYYYY(startDate!),
date_to: formatDateDDMMYYYY(endDate!)
})
const handleExportExcel = async () => {
if (!paymentAnalytics) {
console.warn('No data available for export')
return
}
try {
const result = await ExcelExportPaymentService.exportPaymentMethodToExcel(paymentAnalytics)
if (result.success) {
console.log(`File exported successfully: ${result.filename}`)
// Optional: Show success message to user
} else {
console.error('Export failed:', result.error)
// Optional: Show error message to user
}
} catch (error) {
console.error('Export error:', error)
}
}
const handleExportPDF = async () => {
if (!paymentAnalytics) {
console.warn('No data available for export')
return
}
try {
const result = await PDFExportPaymentService.exportPaymentMethodToPDF(
paymentAnalytics,
`Laporan_Metode_Pembayaran_${formatDateDDMMYYYY(startDate!)}_${formatDateDDMMYYYY(endDate!)}.pdf`
)
if (result.success) {
console.log(`PDF exported successfully: ${result.filename}`)
// Optional: Show success message to user
} else {
console.error('PDF export failed:', result.error)
// Optional: Show error message to user
}
} catch (error) {
console.error('PDF export error:', error)
}
}
const handleExportClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget)
}
const handleExportClose = () => {
setAnchorEl(null)
}
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' />}
endIcon={<i className='tabler-chevron-down' />}
className='max-sm:is-full'
onClick={handleExportClick}
>
Ekspor
</Button>
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleExportClose}>
<MenuItem
onClick={() => {
handleExportExcel()
handleExportClose()
}}
>
Export Excel
</MenuItem>
<MenuItem
onClick={() => {
handleExportPDF()
handleExportClose()
}}
>
Export PDF
</MenuItem>
</Menu>
<DateRangePicker
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
</div>
</div>
<CardContent>
<ReportItemHeader
title='Ringkasan Metode Pembayaran'
date={`${paymentAnalytics?.date_from.split('T')[0]} - ${paymentAnalytics?.date_to.split('T')[0]}`}
/>
<div className='bg-gray-50 border border-gray-200 overflow-hidden'>
<table className='w-full'>
<thead>
<tr className='text-gray-800 border-b-2 border-gray-300'>
<th className='text-left p-3 font-semibold'>Metode Pembayaran</th>
<th className='text-center p-3 font-semibold'>Tipe</th>
<th className='text-center p-3 font-semibold'>Jumlah Order</th>
<th className='text-right p-3 font-semibold'>Total Amount</th>
<th className='text-center p-3 font-semibold'>Persentase</th>
</tr>
</thead>
<tbody>
{paymentAnalytics?.data?.map((payment, index) => (
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className='p-3 font-medium text-gray-800'>{payment.payment_method_name}</td>
<td className='p-3 text-center'>
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
payment.payment_method_type === 'cash'
? 'bg-green-100 text-green-800'
: 'bg-blue-100 text-blue-800'
}`}
>
{payment.payment_method_type.toUpperCase()}
</span>
</td>
<td className='p-3 text-center text-gray-700'>{payment.order_count}</td>
<td className='p-3 text-right font-semibold text-gray-800'>{formatCurrency(payment.total_amount)}</td>
<td className='p-3 text-center font-medium' style={{ color: '#36175e' }}>
{(payment.percentage ?? 0).toFixed(1)}%
</td>
</tr>
)) || []}
</tbody>
<tfoot>
<tr className='text-gray-800 border-t-2 border-gray-300'>
<td className='p-3 font-bold'>TOTAL</td>
<td className='p-3'></td>
<td className='p-3 text-center font-bold'>{paymentAnalytics?.summary.total_orders ?? 0}</td>
<td className='p-3 text-right font-bold'>
{formatCurrency(paymentAnalytics?.summary.total_amount ?? 0)}
</td>
<td className='p-3 text-center font-bold'></td>
</tr>
</tfoot>
</table>
</div>
<ReportItemSubheader title='' />
</CardContent>
</Card>
)
}
export default ReportPaymentMethodContent
@@ -1,105 +1,89 @@
// MUI Imports
import Grid from '@mui/material/Grid2'
// Type Imports
import type { UserDataType } from '@components/card-statistics/HorizontalWithSubtitle'
// Component Imports
import HorizontalWithSubtitle from '@components/card-statistics/HorizontalWithSubtitle'
import { ProfitLossReport } from '@/types/services/analytic'
// Utility functions
const formatIDR = (amount: number) => {
return new Intl.NumberFormat('id-ID', {
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount)
}
const formatPercentage = (value: number) => {
return `${value.toFixed(1)}%`
}
interface ReportProfitLossCardProps {
profitData: ProfitLossReport | undefined
}
const ReportProfitLossCard = ({ profitData }: ReportProfitLossCardProps) => {
if (!profitData) {
return null // Will be handled by parent loading state
}
// Using actual data from API response with correct field names
const data: UserDataType[] = [
// Vars
const data: UserDataType[] = [
{
title: 'Pendapatan',
stats: formatIDR(profitData.summary.total_revenue),
avatarIcon: 'tabler-trending-up',
avatarColor: 'success',
trend: 'positive',
trendNumber: 'Current Period',
subtitle: 'Total Revenue'
},
{
title: 'Margin Laba Bersih',
stats: formatPercentage(profitData.summary.net_profit_margin),
avatarIcon: 'tabler-gauge',
avatarColor: profitData.summary.net_profit_margin >= 0 ? 'success' : 'error',
trend: profitData.summary.net_profit_margin >= 0 ? 'positive' : 'negative',
trendNumber: 'Current Period',
subtitle: 'Net Profit Margin'
},
{
title: 'Laba Kotor',
stats: formatIDR(profitData.summary.gross_profit),
avatarIcon: 'tabler-trending-up',
avatarColor: profitData.summary.gross_profit >= 0 ? 'success' : 'error',
trend: profitData.summary.gross_profit >= 0 ? 'positive' : 'negative',
trendNumber: 'Current Period',
subtitle: 'Gross Profit'
},
{
title: 'Laba Bersih',
stats: formatIDR(profitData.summary.net_profit),
avatarIcon: profitData.summary.net_profit >= 0 ? 'tabler-trending-up' : 'tabler-trending-down',
avatarColor: profitData.summary.net_profit >= 0 ? 'success' : 'error',
trend: profitData.summary.net_profit >= 0 ? 'positive' : 'negative',
trendNumber: 'Current Period',
subtitle: 'Net Profit'
},
{
title: 'Margin Laba Kotor',
stats: formatPercentage(profitData.summary.gross_profit_margin),
avatarIcon: 'tabler-gauge',
avatarColor: profitData.summary.gross_profit_margin >= 0 ? 'success' : 'error',
trend: profitData.summary.gross_profit_margin >= 0 ? 'positive' : 'negative',
trendNumber: 'Current Period',
subtitle: 'Gross Profit Margin'
},
{
title: 'Total Cost',
stats: formatIDR(profitData.summary.total_cost),
stats: '29.004.775',
avatarIcon: 'tabler-trending-down',
avatarColor: 'error',
trend: 'negative',
trendNumber: 'Current Period',
subtitle: 'Total Cost'
trendNumber: '48,8%',
subtitle: 'vs Bulan Lalu'
},
{
title: 'Tax',
stats: formatIDR(profitData.summary.total_tax),
avatarIcon: 'tabler-receipt-tax',
avatarColor: 'warning',
trend: 'neutral',
trendNumber: 'Current Period',
subtitle: 'Total Tax'
},
{
title: 'Total Orders',
stats: profitData.summary.total_orders.toString(),
avatarIcon: 'tabler-shopping-cart',
avatarColor: 'info',
title: 'Margin Laba Bersih',
stats: '38%',
avatarIcon: 'tabler-gauge',
avatarColor: 'success',
trend: 'positive',
trendNumber: 'Current Period',
subtitle: 'Total Orders'
trendNumber: 'Bulan Ini',
subtitle: 'Bulan Ini'
},
{
title: 'Laba Kotor',
stats: '21.076.389',
avatarIcon: 'tabler-trending-down',
avatarColor: 'error',
trend: 'negative',
trendNumber: '43,5%',
subtitle: 'vs bulan lalu'
},
{
title: 'Laba Bersih',
stats: '11.111.074',
avatarIcon: 'tabler-trending-down',
avatarColor: 'error',
trend: 'negative',
trendNumber: '36,8%',
subtitle: 'vs bulan lalu'
},
{
title: 'Margin Laba Kotor',
stats: '73%',
avatarIcon: 'tabler-gauge',
avatarColor: 'success',
trend: 'positive',
trendNumber: 'Bulan Ini',
subtitle: 'Bulan Ini'
},
{
title: 'Biaya Operasional',
stats: '9.965.315',
avatarIcon: 'tabler-trending-down',
avatarColor: 'error',
trend: 'negative',
trendNumber: '49,4%',
subtitle: 'vs Bulan Lalu'
},
{
title: 'Rasio Biaya Operasional',
stats: '61,7%',
avatarIcon: 'tabler-gauge',
avatarColor: 'success',
trend: 'positive',
trendNumber: 'Bulan Ini',
subtitle: 'Bulan Ini'
},
{
title: 'EBITDA',
stats: '11.032.696',
avatarIcon: 'tabler-trending-down',
avatarColor: 'error',
trend: 'negative',
trendNumber: '37,3%',
subtitle: 'vs bulan lalu'
}
]
]
const ReportProfitLossCard = () => {
return (
<Grid container spacing={6}>
{data.map((item, i) => (
@@ -2,86 +2,12 @@
import DateRangePicker from '@/components/RangeDatePicker'
import { ReportItem, ReportItemFooter, ReportItemHeader, ReportItemSubheader } from '@/components/report/ReportItem'
import { ExcelExportProfitLossService } from '@/services/export/excel/ExcelExportProfitLossService'
import { PDFExportProfitLossService } from '@/services/export/pdf/PDFExportProfitLossService'
import { ProfitLossReport } from '@/types/services/analytic'
import { Button, Card, CardContent, Box, Menu, MenuItem, ListItemIcon, ListItemText } from '@mui/material'
import { Button, Card, CardContent, Paper } from '@mui/material'
import { useState } from 'react'
interface ReportProfitLossContentProps {
profitData: ProfitLossReport | undefined
startDate: Date | null
endDate: Date | null
onStartDateChange: (date: Date | null) => void
onEndDateChange: (date: Date | null) => void
}
// Utility function to format date for display
const formatDisplayDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleDateString('id-ID', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
}
const ReportProfitLossContent = ({
profitData,
startDate,
endDate,
onStartDateChange,
onEndDateChange
}: ReportProfitLossContentProps) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const open = Boolean(anchorEl)
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget)
}
const handleClose = () => {
setAnchorEl(null)
}
const handleExportExcel = async () => {
if (!profitData) return
handleClose()
try {
const result = await ExcelExportProfitLossService.exportProfitLossToExcel(profitData)
if (result.success) {
console.log('Excel export successful:', result.filename)
} else {
console.error('Excel export failed:', result.error)
alert('Export Excel gagal. Silakan coba lagi.')
}
} catch (error) {
console.error('Excel export error:', error)
alert('Terjadi kesalahan saat export Excel.')
}
}
const handleExportPDF = async () => {
if (!profitData) return
handleClose()
try {
const result = await PDFExportProfitLossService.exportProfitLossToPDF(profitData)
if (result.success) {
console.log('PDF export successful:', result.filename)
// Optional: Show success notification
} else {
console.error('PDF export failed:', result.error)
alert('Export PDF gagal. Silakan coba lagi.')
}
} catch (error) {
console.error('PDF export error:', error)
alert('Terjadi kesalahan saat export PDF.')
}
}
const ReportProfitLossContent = () => {
const [startDate, setStartDate] = useState<Date | null>(new Date())
const [endDate, setEndDate] = useState<Date | null>(new Date())
return (
<Card>
@@ -90,170 +16,99 @@ const ReportProfitLossContent = ({
<Button
color='secondary'
variant='tonal'
startIcon={<i className='tabler-download' />}
endIcon={<i className='tabler-chevron-down' />}
startIcon={<i className='tabler-upload' />}
className='max-sm:is-full'
onClick={handleClick}
disabled={!profitData}
aria-controls={open ? 'export-menu' : undefined}
aria-haspopup='true'
aria-expanded={open ? 'true' : undefined}
>
Export
Ekspor
</Button>
<Menu
id='export-menu'
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{
'aria-labelledby': 'export-button'
}}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
>
<MenuItem onClick={handleExportExcel}>
<ListItemIcon>
<i className='tabler-file-type-xls text-green-600' />
</ListItemIcon>
<ListItemText>Export to Excel</ListItemText>
</MenuItem>
<MenuItem onClick={handleExportPDF}>
<ListItemIcon>
<i className='tabler-file-type-pdf text-red-600' />
</ListItemIcon>
<ListItemText>Export to PDF</ListItemText>
</MenuItem>
</Menu>
<DateRangePicker
startDate={startDate}
endDate={endDate}
onStartDateChange={onStartDateChange}
onEndDateChange={onEndDateChange}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
</div>
</div>
<CardContent>
{profitData ? (
<>
{/* Summary Section */}
<ReportItemHeader
title='Pendapatan'
date={`${profitData.date_from.split('T')[0]} - ${profitData.date_to.split('T')[0]}`}
/>
<ReportItemHeader title='Pendapatan' date='10/09/2025' />
<ReportItemSubheader title='Penjualan' />
<ReportItem accountCode='4-40000' accountName='Pendapatan' amount={116791108} onClick={() => {}} />
<ReportItemSubheader title='Penghasilan lain' />
<ReportItem
accountCode=''
accountName='Revenue'
amount={profitData.summary.total_revenue}
accountCode='7-70001'
accountName='Pendapatan Bunga - Deposito'
amount={-86486}
onClick={() => {}}
/>
<ReportItemFooter title='Total Pendapatan' amount={profitData.summary.total_revenue} />
<ReportItem accountCode='7-70099' accountName='Pendapatan Lain - lain' amount={54054} onClick={() => {}} />
<ReportItem
accountCode='7-70100'
accountName='Pendapatan lainnya (Service Charge)'
amount={-15315}
onClick={() => {}}
/>
<ReportItemFooter title='Total Pendapatan' amount={116743360} />
<ReportItemSubheader title='' />
<ReportItemHeader
title='Beban Pokok Penjualan'
date={`${profitData.date_from.split('T')[0]} - ${profitData.date_to.split('T')[0]}`}
/>
<ReportItem
accountCode=''
accountName='Cost of Goods Sold'
amount={profitData.summary.total_cost}
onClick={() => {}}
/>
<ReportItemFooter title='Total Beban Pokok Penjualan' amount={profitData.summary.total_cost} />
<ReportItemHeader title='Beban Pokok Penjualan' date='10/09/2025' />
<ReportItem accountCode='5-50000' accountName='Beban Pokok Pendapatan' amount={35018079} onClick={() => {}} />
<ReportItem accountCode='5-50300' accountName='Pengiriman & Pengangkutan' amount={-15315} onClick={() => {}} />
<ReportItemFooter title='Total Beban Pokok Penjualan' amount={35002764} />
<ReportItemSubheader title='' />
<ReportItemHeader title='Laba Kotor' amount={profitData.summary.gross_profit} />
<ReportItemHeader title='Laba Kotor' amount={81740597} />
<ReportItemSubheader title='' />
{/* Daily Data Breakdown Section */}
{profitData.data && profitData.data.length > 0 && (
<>
<ReportItemHeader
title='Rincian Data Harian'
date={`${profitData.date_from.split('T')[0]} - ${profitData.date_to.split('T')[0]}`}
/>
<ReportItemSubheader title='Breakdown per Hari' />
{profitData.data.map((dailyData, index) => (
<div key={index} className='mb-4'>
<ReportItemSubheader title={`Data ${formatDisplayDate(dailyData.date)}`} />
<ReportItem
accountCode=''
accountName='Revenue Harian'
amount={dailyData.revenue}
onClick={() => {}}
/>
<ReportItem accountCode='' accountName='Cost Harian' amount={dailyData.cost} onClick={() => {}} />
<ReportItem
accountCode=''
accountName='Gross Profit Harian'
amount={dailyData.gross_profit}
onClick={() => {}}
/>
<ReportItem accountCode='' accountName='Tax Harian' amount={dailyData.tax} onClick={() => {}} />
<ReportItem
accountCode=''
accountName='Discount Harian'
amount={dailyData.discount}
onClick={() => {}}
/>
<ReportItem
accountCode=''
accountName='Orders Harian'
amount={dailyData.orders}
onClick={() => {}}
/>
<ReportItemFooter
title={`Net Profit ${formatDisplayDate(dailyData.date)}`}
amount={dailyData.net_profit}
/>
</div>
))}
<ReportItemSubheader title='' />
</>
)}
{/* Operational Costs Section */}
<ReportItemHeader
title='Biaya Operasional'
date={`${profitData.date_from.split('T')[0]} - ${profitData.date_to.split('T')[0]}`}
/>
<ReportItemHeader title='Biaya Operasional' date='10/09/2025' />
<ReportItemSubheader title='Biaya Operasional' />
<ReportItem accountCode='' accountName='Tax' amount={profitData.summary.total_tax} onClick={() => {}} />
<ReportItem accountCode='6-60218' accountName='Air' amount={15315} onClick={() => {}} />
<ReportItem
accountCode=''
accountName='Discount'
amount={profitData.summary.total_discount}
accountCode='6-60301'
accountName='Alat Tulis Kantor & Printing'
amount={-19820}
onClick={() => {}}
/>
<ReportItemFooter
title='Total Biaya Operasional'
amount={profitData.summary.total_tax + profitData.summary.total_discount}
<ReportItem accountCode='6-60302' accountName='Bea Materai' amount={-40541} onClick={() => {}} />
<ReportItem
accountCode='6-60003'
accountName='Bensin, Tol dan Parkir - Penjualan'
amount={6264865}
onClick={() => {}}
/>
<ReportItem accountCode='6-60401' accountName='Biaya Sewa - Kendaraan' amount={62162} onClick={() => {}} />
<ReportItem accountCode='6-60403' accountName='Biaya Sewa - Lain - lain' amount={63964} onClick={() => {}} />
<ReportItem accountCode='6-60402' accountName='Biaya Sewa - Operasional' amount={-2703} onClick={() => {}} />
<ReportItem accountCode='6-60101' accountName='Gaji' amount={6306} onClick={() => {}} />
<ReportItem accountCode='6-60001' accountName='Iklan & Promosi' amount={7851892} onClick={() => {}} />
<ReportItem accountCode='6-60002' accountName='Komisi & Fee' amount={6277748} onClick={() => {}} />
<ReportItem accountCode='6-60005' accountName='Komunikasi - Penjualan' amount={12058018} onClick={() => {}} />
<ReportItem accountCode='6-60206' accountName='Komunikasi - Umum' amount={85586} onClick={() => {}} />
<ReportItem accountCode='6-60500' accountName='Penyusutan - Bangunan' amount={73874} onClick={() => {}} />
<ReportItem accountCode='6-60502' accountName='Penyusutan - Kendaraan' amount={-78378} onClick={() => {}} />
<ReportItem
accountCode='6-60004'
accountName='Perjalanan Dinas - Penjualan'
amount={6745045}
onClick={() => {}}
/>
<ReportItem accountCode='6-60204' accountName='Perjalanan Dinas - Umum' amount={-48649} onClick={() => {}} />
<ReportItem accountCode='6-60304' accountName='Supplies dan Material' amount={58559} onClick={() => {}} />
<ReportItem accountCode='6-60106' accountName='THR & Bonus' amount={-59459} onClick={() => {}} />
<ReportItemSubheader title='Biaya Lain-Lain' />
<ReportItem
accountCode='8-80002'
accountName='(Laba)/Rugi Pelepasan Aset Tetap'
amount={2703}
onClick={() => {}}
/>
<ReportItem accountCode='8-80999' accountName='Beban Lain - lain' amount={81982} onClick={() => {}} />
<ReportItem accountCode='8-80100' accountName='Penyesuaian Persediaan' amount={-1477900} onClick={() => {}} />
<ReportItem accountCode='8-80001' accountName='Provisi' amount={-12613} onClick={() => {}} />
<ReportItemFooter title='Total Biaya Operasional' amount={37907956} />
<ReportItemSubheader title='' />
<ReportItemHeader title='Laba Bersih' amount={profitData.summary.net_profit} />
</>
) : (
<Box display='flex' justifyContent='center' alignItems='center' minHeight={200}>
<span>No data available</span>
</Box>
)}
<ReportItemHeader title='Laba Bersih' amount={43832641} />
</CardContent>
</Card>
)
@@ -1,78 +0,0 @@
import Grid from '@mui/material/Grid2'
import type { UserDataType } from '@components/card-statistics/HorizontalWithSubtitle'
import HorizontalWithSubtitle from '@components/card-statistics/HorizontalWithSubtitle'
import { ProfitLossReport, SalesReport } from '@/types/services/analytic'
// Utility functions
const formatIDR = (amount: number) => {
return new Intl.NumberFormat('id-ID', {
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount)
}
const formatPercentage = (value: number) => {
return `${value.toFixed(1)}%`
}
interface ReportSalesOrderCardProps {
sales: SalesReport | undefined
}
const ReportSalesOrderCard = ({ sales }: ReportSalesOrderCardProps) => {
if (!sales) {
return null // Will be handled by parent loading state
}
// Using actual data from API response with correct field names
const data: UserDataType[] = [
{
title: 'Penjualan',
stats: formatIDR(sales.summary.total_sales),
avatarIcon: 'tabler-trending-up',
avatarColor: 'success',
trend: 'positive',
trendNumber: '',
subtitle: 'Total Penjualan'
},
{
title: 'Total Pesanan',
stats: sales.summary.total_orders.toString(),
avatarIcon: 'tabler-gauge',
avatarColor: 'success',
trend: 'positive',
trendNumber: '',
subtitle: 'Total Pesanan'
},
{
title: 'Rata Rata',
stats: formatIDR(sales.summary.average_order_value),
avatarIcon: 'tabler-trending-up',
avatarColor: sales.summary.average_order_value >= 0 ? 'success' : 'error',
trend: sales.summary.average_order_value >= 0 ? 'positive' : 'negative',
trendNumber: '',
subtitle: 'Rata Rata Nilai Pesanan'
},
{
title: 'Penjualan Bersih',
stats: formatIDR(sales.summary.net_sales),
avatarIcon: sales.summary.net_sales >= 0 ? 'tabler-trending-up' : 'tabler-trending-down',
avatarColor: sales.summary.net_sales >= 0 ? 'success' : 'error',
trend: sales.summary.net_sales >= 0 ? 'positive' : 'negative',
trendNumber: '',
subtitle: 'Net Profit'
}
]
return (
<Grid container spacing={6}>
{data.map((item, i) => (
<Grid key={i} size={{ xs: 12, sm: 6, md: 3 }}>
<HorizontalWithSubtitle {...item} />
</Grid>
))}
</Grid>
)
}
export default ReportSalesOrderCard
@@ -1,160 +0,0 @@
'use client'
import DateRangePicker from '@/components/RangeDatePicker'
import { ReportItemHeader, ReportItemSubheader } from '@/components/report/ReportItem'
import { useSalesAnalytics } from '@/services/queries/analytics'
import { formatCurrency, formatDate, formatDateDDMMYYYY } from '@/utils/transform'
import { Button, Card, CardContent, Menu, MenuItem } from '@mui/material'
import { useState } from 'react'
import ReportSalesOrderCard from './ReportSalesOrderCard'
import { PDFExportSalesOrderService } from '@/services/export/pdf/PDFExportSalesOrderService'
import { ExcelExportSalesOrderService } from '@/services/export/excel/ExcelExportSalesOrderService'
const ReportSalesOrderContent = () => {
const [startDate, setStartDate] = useState<Date | null>(new Date())
const [endDate, setEndDate] = useState<Date | null>(new Date())
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const { data: sales } = useSalesAnalytics({
date_from: formatDateDDMMYYYY(startDate!),
date_to: formatDateDDMMYYYY(endDate!)
})
const handleExportClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget)
}
const handleExportClose = () => {
setAnchorEl(null)
}
const handleExportExcel = async () => {
if (!sales) {
console.warn('No data available for export')
return
}
try {
const result = await ExcelExportSalesOrderService.exportSalesOrderToExcel(
sales,
`Pesanan_Penjualan_${formatDateDDMMYYYY(startDate!)}_${formatDateDDMMYYYY(endDate!)}.xlsx`
)
if (result.success) {
console.log(`Excel exported successfully: ${result.filename}`)
} else {
console.error('Export failed:', result.error)
}
} catch (error) {
console.error('Export error:', error)
}
}
const handleExportPDF = async () => {
if (!sales) {
console.warn('No data available for export')
return
}
try {
const result = await PDFExportSalesOrderService.exportSalesOrderToPDF(
sales,
`Laporan_Pesanan_Penjualan_${formatDateDDMMYYYY(startDate!)}_${formatDateDDMMYYYY(endDate!)}.pdf`
)
if (result.success) {
console.log(`PDF exported successfully: ${result.filename}`)
} else {
console.error('Export failed:', result.error)
}
} catch (error) {
console.error('Export error:', error)
}
}
return (
<>
<ReportSalesOrderCard sales={sales} />
<Card className='mt-5'>
<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' />}
endIcon={<i className='tabler-chevron-down' />}
className='max-sm:is-full'
onClick={handleExportClick}
>
Ekspor
</Button>
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleExportClose}>
<MenuItem
onClick={() => {
handleExportExcel()
handleExportClose()
}}
>
Export Excel
</MenuItem>
<MenuItem
onClick={() => {
handleExportPDF()
handleExportClose()
}}
>
Export PDF
</MenuItem>
</Menu>
<DateRangePicker
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
</div>
</div>
<CardContent>
<ReportItemHeader
title='Pesanan'
date={`${sales?.date_from.split('T')[0]} - ${sales?.date_to.split('T')[0]}`}
/>
<div className='bg-gray-50 border border-gray-200 overflow-hidden'>
<table className='w-full'>
<thead>
<tr className='text-gray-800 border-b-2 border-gray-300'>
<th className='text-left p-3 font-semibold'>Date</th>
<th className='text-center p-3 font-semibold'>Penjualan</th>
<th className='text-center p-3 font-semibold'>Pesanan</th>
<th className='text-right p-3 font-semibold'>Qty</th>
<th className='text-right p-3 font-semibold'>Pajak</th>
<th className='text-right p-3 font-semibold'>Diskon</th>
<th className='text-right p-3 font-semibold'>Pendapatan</th>
</tr>
</thead>
<tbody>
{sales?.data?.map((c, index) => (
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className='p-3 font-medium text-gray-800'>{formatDate(c.date)}</td>
<td className='p-3 text-center text-gray-700'>{formatCurrency(c.sales)}</td>
<td className='p-3 text-center text-gray-700'>{c.orders}</td>
<td className='p-3 text-center text-gray-700'>{c.items}</td>
<td className='p-3 text-center text-gray-700'>{formatCurrency(c.tax)}</td>
<td className='p-3 text-center text-gray-700'>{formatCurrency(c.discount)}</td>
<td className='p-3 text-right font-semibold' style={{ color: '#36175e' }}>
{formatCurrency(c.net_sales)}
</td>
</tr>
)) || []}
</tbody>
</table>
</div>
<ReportItemSubheader title='' />
</CardContent>
</Card>
</>
)
}
export default ReportSalesOrderContent
@@ -1,281 +0,0 @@
'use client'
import DateRangePicker from '@/components/RangeDatePicker'
import { ReportItemHeader, ReportItemSubheader } from '@/components/report/ReportItem'
import { ExcelExportSalesProductService } from '@/services/export/excel/ExcelExportSalesProductService'
import { PDFExportSalesProductService } from '@/services/export/pdf/PdfExportSalesProductSevice'
import { useProductSalesAnalytics } from '@/services/queries/analytics'
import { formatCurrency, formatDateDDMMYYYY } from '@/utils/transform'
import { Button, Card, CardContent, Menu, MenuItem } 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 [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
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
}
const handleExportClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget)
}
const handleExportClose = () => {
setAnchorEl(null)
}
const handleExportExcel = async () => {
if (!products) {
console.warn('No data available for export')
return
}
try {
const result = await ExcelExportSalesProductService.exportProductSalesToExcel(
products,
`Penjualan_Produk_${formatDateDDMMYYYY(startDate!)}_${formatDateDDMMYYYY(endDate!)}.xlsx`
)
if (result.success) {
console.log(`Excel exported successfully: ${result.filename}`)
} else {
console.error('Export failed:', result.error)
}
} catch (error) {
console.error('Export error:', error)
}
}
const handleExportPDF = async () => {
if (!products) {
console.warn('No data available for export')
return
}
try {
const result = await PDFExportSalesProductService.exportProductSalesToPDF(
products,
`Laporan_Penjualan_Produk_${formatDateDDMMYYYY(startDate!)}_${formatDateDDMMYYYY(endDate!)}.pdf`
)
if (result.success) {
console.log(`PDF exported successfully: ${result.filename}`)
} else {
console.error('Export failed:', result.error)
}
} catch (error) {
console.error('Export error:', error)
}
}
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' />}
endIcon={<i className='tabler-chevron-down' />}
className='max-sm:is-full'
onClick={handleExportClick}
>
Ekspor
</Button>
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleExportClose}>
<MenuItem
onClick={() => {
handleExportExcel()
handleExportClose()
}}
>
Export Excel
</MenuItem>
<MenuItem
onClick={() => {
handleExportPDF()
handleExportClose()
}}
>
Export PDF
</MenuItem>
</Menu>
<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
@@ -1,164 +0,0 @@
'use client'
import DateRangePicker from '@/components/RangeDatePicker'
import { ReportItemHeader, ReportItemSubheader } from '@/components/report/ReportItem'
import { ExcelExportSalesProductCategoryService } from '@/services/export/excel/ExcelExportSalesProductCategoryService'
import { PDFExportSalesProductCategoryService } from '@/services/export/pdf/PDFExportSalesProductCategoryService'
import { useCategoryAnalytics } from '@/services/queries/analytics'
import { formatCurrency, formatDateDDMMYYYY } from '@/utils/transform'
import { Button, Card, CardContent, Menu, MenuItem } from '@mui/material'
import { useState } from 'react'
const ReportSalesProductCategoryContent = () => {
const [startDate, setStartDate] = useState<Date | null>(new Date())
const [endDate, setEndDate] = useState<Date | null>(new Date())
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const { data: category } = useCategoryAnalytics({
date_from: formatDateDDMMYYYY(startDate!),
date_to: formatDateDDMMYYYY(endDate!)
})
const categorySummary = {
totalRevenue: category?.data?.reduce((sum, item) => sum + (item?.total_revenue || 0), 0) || 0,
orderCount: category?.data?.reduce((sum, item) => sum + (item?.order_count || 0), 0) || 0,
productCount: category?.data?.reduce((sum, item) => sum + (item?.product_count || 0), 0) || 0,
totalQuantity: category?.data?.reduce((sum, item) => sum + (item?.total_quantity || 0), 0) || 0
}
const handleExportClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget)
}
const handleExportClose = () => {
setAnchorEl(null)
}
const handleExportExcel = async () => {
if (!category) {
console.warn('No data available for export')
return
}
try {
const result = await ExcelExportSalesProductCategoryService.exportCategorySalesToExcel(
category,
`Penjualan_Kategori_${formatDateDDMMYYYY(startDate!)}_${formatDateDDMMYYYY(endDate!)}.xlsx`
)
if (result.success) {
console.log(`Excel exported successfully: ${result.filename}`)
} else {
console.error('Export failed:', result.error)
}
} catch (error) {
console.error('Export error:', error)
}
}
const handleExportPDF = async () => {
if (!category) {
console.warn('No data available for export')
return
}
try {
const result = await PDFExportSalesProductCategoryService.exportCategorySalesToPDF(
category,
`Laporan_Penjualan_Kategori_${formatDateDDMMYYYY(startDate!)}_${formatDateDDMMYYYY(endDate!)}.pdf`
)
if (result.success) {
console.log(`PDF exported successfully: ${result.filename}`)
} else {
console.error('Export failed:', result.error)
}
} catch (error) {
console.error('Export error:', error)
}
}
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' />}
endIcon={<i className='tabler-chevron-down' />}
className='max-sm:is-full'
onClick={handleExportClick}
>
Ekspor
</Button>
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleExportClose}>
<MenuItem
onClick={() => {
handleExportExcel()
handleExportClose()
}}
>
Export Excel
</MenuItem>
<MenuItem
onClick={() => {
handleExportPDF()
handleExportClose()
}}
>
Export PDF
</MenuItem>
</Menu>
<DateRangePicker
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
</div>
</div>
<CardContent>
<ReportItemHeader
title='Ringkasan Kategori'
date={`${category?.date_from.split('T')[0]} - ${category?.date_to.split('T')[0]}`}
/>
<div className='bg-gray-50 border border-gray-200 overflow-hidden'>
<table className='w-full'>
<thead>
<tr className='text-gray-800 border-b-2 border-gray-300'>
<th className='text-left p-3 font-semibold'>Nama</th>
<th className='text-center p-3 font-semibold'>Total Produk</th>
<th className='text-center p-3 font-semibold'>Qty</th>
<th className='text-right p-3 font-semibold'>Pendapatan</th>
</tr>
</thead>
<tbody>
{category?.data?.map((c, index) => (
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className='p-3 font-medium text-gray-800'>{c.category_name}</td>
<td className='p-3 text-center text-gray-700'>{c.product_count}</td>
<td className='p-3 text-center text-gray-700'>{c.total_quantity}</td>
<td className='p-3 text-right font-semibold' style={{ color: '#36175e' }}>
{formatCurrency(c.total_revenue)}
</td>
</tr>
)) || []}
</tbody>
<tfoot>
<tr className='text-gray-800 border-t-2 border-gray-300'>
<td className='p-3 font-bold'>TOTAL</td>
<td className='p-3 text-center font-bold'>{categorySummary?.productCount ?? 0}</td>
<td className='p-3 text-center font-bold'>{categorySummary?.totalQuantity ?? 0}</td>
<td className='p-3 text-right font-bold'>{formatCurrency(categorySummary?.totalRevenue ?? 0)}</td>
</tr>
</tfoot>
</table>
</div>
<ReportItemSubheader title='' />
</CardContent>
</Card>
)
}
export default ReportSalesProductCategoryContent
@@ -1,442 +0,0 @@
'use client'
import DateRangePicker from '@/components/RangeDatePicker'
import { ReportItem, ReportItemFooter, ReportItemHeader, ReportItemSubheader } from '@/components/report/ReportItem'
import { ExcelExportSalesService } from '@/services/export/excel/ExcelExportSalesService'
import { PDFExportSalesService } from '@/services/export/pdf/PDFExportSalesService'
import {
useCategoryAnalytics,
usePaymentAnalytics,
useProductSalesAnalytics,
useProfitLossAnalytics
} from '@/services/queries/analytics'
import { formatCurrency, formatDateDDMMYYYY } from '@/utils/transform'
import { Button, Card, CardContent, Menu, MenuItem, Paper } from '@mui/material'
import { useState } from 'react'
const ReportSalesContent = () => {
const [startDate, setStartDate] = useState<Date | null>(new Date())
const [endDate, setEndDate] = useState<Date | null>(new Date())
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const { data: profitLoss } = useProfitLossAnalytics({
date_from: formatDateDDMMYYYY(startDate!),
date_to: formatDateDDMMYYYY(endDate!)
})
const { data: paymentAnalytics } = usePaymentAnalytics({
date_from: formatDateDDMMYYYY(startDate!),
date_to: formatDateDDMMYYYY(endDate!)
})
const { data: category } = useCategoryAnalytics({
date_from: formatDateDDMMYYYY(startDate!),
date_to: formatDateDDMMYYYY(endDate!)
})
const { data: products } = useProductSalesAnalytics({
date_from: formatDateDDMMYYYY(startDate!),
date_to: formatDateDDMMYYYY(endDate!)
})
const categorySummary = {
totalRevenue: category?.data?.reduce((sum, item) => sum + (item?.total_revenue || 0), 0) || 0,
orderCount: category?.data?.reduce((sum, item) => sum + (item?.order_count || 0), 0) || 0,
productCount: category?.data?.reduce((sum, item) => sum + (item?.product_count || 0), 0) || 0,
totalQuantity: category?.data?.reduce((sum, item) => sum + (item?.total_quantity || 0), 0) || 0
}
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
}
const handleExportPDF = async () => {
try {
const salesData = {
profitLoss: profitLoss!,
paymentAnalytics: paymentAnalytics!,
categoryAnalytics: category!,
productAnalytics: products!
}
const result = await PDFExportSalesService.exportSalesReportToPDF(salesData)
if (result.success) {
console.log('PDF export successful:', result.filename)
// Optional: Show success notification
} else {
console.error('PDF export failed:', result.error)
alert('Export PDF gagal. Silakan coba lagi.')
}
} catch (error) {
console.error('PDF export error:', error)
alert('Terjadi kesalahan saat export PDF.')
}
}
const handleExportExcel = async () => {
try {
const salesData = {
profitLoss: profitLoss!,
paymentAnalytics: paymentAnalytics!,
categoryAnalytics: category!,
productAnalytics: products!
}
const result = await ExcelExportSalesService.exportSalesReportToExcel(salesData)
if (result.success) {
console.log('Excel export successful:', result.filename)
// Optional: Show success notification
} else {
console.error('Excel export failed:', result.error)
alert('Export Excel gagal. Silakan coba lagi.')
}
} catch (error) {
console.error('Excel export error:', error)
alert('Terjadi kesalahan saat export Excel.')
}
}
const handleExportClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget)
}
const handleExportClose = () => {
setAnchorEl(null)
}
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' />}
endIcon={<i className='tabler-chevron-down' />}
className='max-sm:is-full'
onClick={handleExportClick}
>
Ekspor
</Button>
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleExportClose}>
<MenuItem
onClick={() => {
handleExportExcel()
handleExportClose()
}}
>
Export Excel
</MenuItem>
<MenuItem
onClick={() => {
handleExportPDF()
handleExportClose()
}}
>
Export PDF
</MenuItem>
</Menu>
<DateRangePicker
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
</div>
</div>
<CardContent>
<ReportItemHeader
title='Ringkasan'
date={`${profitLoss?.date_from.split('T')[0]} - ${profitLoss?.date_to.split('T')[0]}`}
/>
<ReportItem
accountCode=''
accountName='Total Penjualan'
amount={profitLoss?.summary.total_revenue ?? 0}
onClick={() => {}}
/>
<ReportItem
accountCode=''
accountName='Total Diskon'
amount={profitLoss?.summary.total_discount ?? 0}
onClick={() => {}}
/>
<ReportItem
accountCode=''
accountName='Total Pajak'
amount={profitLoss?.summary.total_tax ?? 0}
onClick={() => {}}
/>
<ReportItemFooter title='Total' amount={profitLoss?.summary.total_revenue ?? 0} />
<ReportItemSubheader title='' />
<ReportItemHeader
title='Invoice'
date={`${profitLoss?.date_from.split('T')[0]} - ${profitLoss?.date_to.split('T')[0]}`}
/>
<ReportItem
accountCode=''
accountName='Total Invoice'
amount={profitLoss?.summary.total_orders ?? 0}
onClick={() => {}}
/>
<ReportItem
accountCode=''
accountName='Rata-rata Tagihan per Invoice'
amount={profitLoss?.summary.average_profit ?? 0}
onClick={() => {}}
/>
<ReportItemSubheader title='' />
<ReportItemHeader
title='Ringkasan Metode Pembayaran'
date={`${profitLoss?.date_from.split('T')[0]} - ${profitLoss?.date_to.split('T')[0]}`}
/>
<div className='bg-gray-50 border border-gray-200 overflow-hidden'>
<table className='w-full'>
<thead>
<tr className='text-gray-800 border-b-2 border-gray-300'>
<th className='text-left p-3 font-semibold'>Metode Pembayaran</th>
<th className='text-center p-3 font-semibold'>Tipe</th>
<th className='text-center p-3 font-semibold'>Jumlah Order</th>
<th className='text-right p-3 font-semibold'>Total Amount</th>
<th className='text-center p-3 font-semibold'>Persentase</th>
</tr>
</thead>
<tbody>
{paymentAnalytics?.data?.map((payment, index) => (
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className='p-3 font-medium text-gray-800'>{payment.payment_method_name}</td>
<td className='p-3 text-center'>
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
payment.payment_method_type === 'cash'
? 'bg-green-100 text-green-800'
: 'bg-blue-100 text-blue-800'
}`}
>
{payment.payment_method_type.toUpperCase()}
</span>
</td>
<td className='p-3 text-center text-gray-700'>{payment.order_count}</td>
<td className='p-3 text-right font-semibold text-gray-800'>{formatCurrency(payment.total_amount)}</td>
<td className='p-3 text-center font-medium' style={{ color: '#36175e' }}>
{(payment.percentage ?? 0).toFixed(1)}%
</td>
</tr>
)) || []}
</tbody>
<tfoot>
<tr className='text-gray-800 border-t-2 border-gray-300'>
<td className='p-3 font-bold'>TOTAL</td>
<td className='p-3'></td>
<td className='p-3 text-center font-bold'>{paymentAnalytics?.summary.total_orders ?? 0}</td>
<td className='p-3 text-right font-bold'>
{formatCurrency(paymentAnalytics?.summary.total_amount ?? 0)}
</td>
<td className='p-3 text-center font-bold'></td>
</tr>
</tfoot>
</table>
</div>
<ReportItemSubheader title='' />
<ReportItemHeader
title='Ringkasan Kategori'
date={`${profitLoss?.date_from.split('T')[0]} - ${profitLoss?.date_to.split('T')[0]}`}
/>
<div className='bg-gray-50 border border-gray-200 overflow-hidden'>
<table className='w-full'>
<thead>
<tr className='text-gray-800 border-b-2 border-gray-300'>
<th className='text-left p-3 font-semibold'>Nama</th>
<th className='text-center p-3 font-semibold'>Total Produk</th>
<th className='text-center p-3 font-semibold'>Qty</th>
<th className='text-right p-3 font-semibold'>Pendapatan</th>
</tr>
</thead>
<tbody>
{category?.data?.map((c, index) => (
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className='p-3 font-medium text-gray-800'>{c.category_name}</td>
<td className='p-3 text-center text-gray-700'>{c.product_count}</td>
<td className='p-3 text-center text-gray-700'>{c.total_quantity}</td>
<td className='p-3 text-right font-semibold' style={{ color: '#36175e' }}>
{formatCurrency(c.total_revenue)}
</td>
</tr>
)) || []}
</tbody>
<tfoot>
<tr className='text-gray-800 border-t-2 border-gray-300'>
<td className='p-3 font-bold'>TOTAL</td>
<td className='p-3 text-center font-bold'>{categorySummary?.productCount ?? 0}</td>
<td className='p-3 text-center font-bold'>{categorySummary?.totalQuantity ?? 0}</td>
<td className='p-3 text-right font-bold'>{formatCurrency(categorySummary?.totalRevenue ?? 0)}</td>
</tr>
</tfoot>
</table>
</div>
<ReportItemSubheader title='' />
<ReportItemHeader
title='Ringkasan Item'
date={`${profitLoss?.date_from.split('T')[0]} - ${profitLoss?.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 ReportSalesContent
@@ -11,7 +11,7 @@ import classnames from 'classnames'
import CustomAvatar, { CustomAvatarProps } from '../../../@core/components/mui/Avatar'
import { ThemeColor } from '../../../@core/types'
import { Skeleton, Typography } from '@mui/material'
import { formatShortCurrency } from '../../../utils/transform'
import { formatCurrency, formatShortCurrency } from '../../../utils/transform'
type Props = {
title: string
@@ -46,7 +46,7 @@ const DistributedBarChartOrder = ({
{title}
</Typography>
<Typography color='text.primary' variant='h4'>
{isCurrency ? 'Rp ' + formatShortCurrency(value) : formatShortCurrency(value)}
{isCurrency ? formatCurrency(value) : formatShortCurrency(value)}
</Typography>
</div>
<CustomAvatar variant='rounded' skin={avatarSkin} size={52} color={avatarColor}>
@@ -59,6 +59,7 @@ interface ReportGeneratorProps {
onSingleDateChange: (date: Date) => void
onDateRangeChange: (dateRange: DateRange) => void
onGeneratePDF: () => void
onGenerateExcel: () => void
// Optional props dengan default values
maxWidth?: string
@@ -66,8 +67,10 @@ interface ReportGeneratorProps {
customQuickActions?: CustomQuickActions | null
periodFormat?: string
downloadButtonText?: string
downloadExcelButtonText?: string
cardShadow?: string
primaryColor?: string
excelButtonColor?: string
// Optional helper functions
formatDateForInput?: ((date: Date) => string) | null
@@ -80,6 +83,7 @@ interface ReportGeneratorProps {
// Loading state
isGenerating?: boolean
isGeneratingExcel?: boolean
// Custom labels
labels?: Labels
@@ -87,7 +91,8 @@ interface ReportGeneratorProps {
// Custom styled components yang responsif terhadap theme
const StyledCard = styled(Card)(({ theme }) => ({
margin: '0 0 24px',
maxWidth: '1024px',
margin: '0 auto 24px',
boxShadow: theme.palette.mode === 'dark' ? '0 2px 10px rgba(20, 21, 33, 0.3)' : '0 2px 10px rgba(58, 53, 65, 0.1)',
borderRadius: '8px',
backgroundColor: theme.palette.mode === 'dark' ? theme.palette.background.paper : '#ffffff'
@@ -109,6 +114,22 @@ const PurpleButton = styled(Button)(({ theme }) => ({
}
}))
const GreenButton = styled(Button)(({ theme }) => ({
backgroundColor: '#16a34a',
color: 'white',
textTransform: 'none',
fontWeight: 500,
padding: '8px 24px',
'&:hover': {
backgroundColor: '#15803d',
opacity: 0.9
},
'&:disabled': {
backgroundColor: theme.palette.mode === 'dark' ? '#444' : '#ccc',
color: theme.palette.mode === 'dark' ? '#888' : '#666'
}
}))
const QuickActionButton = styled(Button)(({ theme }) => ({
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(231, 227, 252, 0.08)' : '#f8f7fa',
color: theme.palette.mode === 'dark' ? theme.palette.text.secondary : '#6f6b7d',
@@ -139,6 +160,7 @@ const ReportGeneratorComponent: React.FC<ReportGeneratorProps> = ({
onSingleDateChange,
onDateRangeChange,
onGeneratePDF,
onGenerateExcel,
// Optional props dengan default values
maxWidth = '1024px',
@@ -146,8 +168,10 @@ const ReportGeneratorComponent: React.FC<ReportGeneratorProps> = ({
customQuickActions = null,
periodFormat = 'id-ID',
downloadButtonText = 'Download PDF',
downloadExcelButtonText = 'Download Excel',
cardShadow,
primaryColor = '#36175e',
excelButtonColor = '#16a34a',
// Optional helper functions
formatDateForInput = null,
@@ -160,6 +184,7 @@ const ReportGeneratorComponent: React.FC<ReportGeneratorProps> = ({
// Loading state
isGenerating = false,
isGeneratingExcel = false,
// Custom labels
labels = {
@@ -174,7 +199,7 @@ const ReportGeneratorComponent: React.FC<ReportGeneratorProps> = ({
last7DaysLabel: '7 Hari Terakhir',
last30DaysLabel: '30 Hari Terakhir',
periodLabel: 'Periode:',
exportHelpText: 'Klik tombol download untuk mengeksport laporan ke PDF'
exportHelpText: 'Klik tombol download untuk mengeksport laporan ke PDF atau Excel'
}
}) => {
const theme = useTheme()
@@ -270,6 +295,7 @@ const ReportGeneratorComponent: React.FC<ReportGeneratorProps> = ({
</Typography>
}
action={
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<PurpleButton
onClick={onGeneratePDF}
variant='contained'
@@ -278,6 +304,15 @@ const ReportGeneratorComponent: React.FC<ReportGeneratorProps> = ({
>
{isGenerating ? 'Generating...' : downloadButtonText}
</PurpleButton>
<GreenButton
onClick={onGenerateExcel}
variant='contained'
disabled={isGeneratingExcel}
sx={{ backgroundColor: excelButtonColor }}
>
{isGeneratingExcel ? 'Generating...' : downloadExcelButtonText}
</GreenButton>
</Box>
}
sx={{ pb: 2 }}
/>
@@ -504,7 +539,7 @@ const ReportGeneratorComponent: React.FC<ReportGeneratorProps> = ({
mt: 1
}}
>
{labels.exportHelpText || 'Klik tombol download untuk mengeksport laporan ke PDF'}
{labels.exportHelpText || 'Klik tombol download untuk mengeksport laporan ke PDF atau Excel'}
</Typography>
</InfoBox>
@@ -38,8 +38,8 @@ const ReportHeader: FC<ReportHeaderProps> = ({
<Box sx={{ p: theme.spacing(8, 8, 6) }}>
<Box sx={{ textAlign: 'center' }}>
<Typography
variant='h4'
component='h3'
variant='h1'
component='h1'
sx={{
fontWeight: 700,
color: '#222222'
@@ -49,7 +49,7 @@ const ReportHeader: FC<ReportHeaderProps> = ({
</Typography>
{periode && (
<Typography
variant='body2'
variant='h5'
sx={{
color: '#222222',
mt: 2