Compare commits

..
41 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
efrilm 1b9908e882 update report 2025-09-23 22:27:58 +07:00
efrilm d245074904 no translate for payment method 2025-09-23 20:54:43 +07: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
efrilm 256f035997 fix: report page 2025-09-23 15:30:42 +07:00
aefril bd14adde23 Merge pull request 'base url' (#16) from efril into main
Reviewed-on: #16
2025-09-21 13:38:26 +00:00
efrilm 726d1f4984 base url 2025-09-21 20:36:36 +07:00
aefril 35b1426384 Merge pull request 'efril' (#15) from efril into main
Reviewed-on: #15
2025-09-20 11:04:26 +00:00
efrilm 9b92f4742f add printer type 2025-09-20 18:03:11 +07:00
efrilm 1bf2bad52c campaign detail 2025-09-19 17:38:48 +07:00
efrilm e55357e305 add campaign 2025-09-19 16:10:52 +07:00
53 changed files with 6550 additions and 1074 deletions
+2
View File
@@ -39,3 +39,5 @@ next-env.d.ts
# icon generated file
src/assets/iconify-icons/generated-icons.js
src/assets/iconify-icons/generated-icons.css
*.backup
+1212 -58
View File
File diff suppressed because it is too large Load Diff
+8 -2
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,19 +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",
"input-otp": "1.4.1",
"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",
@@ -80,7 +85,8 @@
"react-use": "17.6.0",
"recharts": "2.15.0",
"use-debounce": "^10.0.5",
"valibot": "0.42.1"
"valibot": "0.42.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@iconify/json": "2.2.286",
@@ -0,0 +1,7 @@
import CampaignDetailContent from '@/views/apps/marketing/campaign/detail'
const CampaignDetailPage = () => {
return <CampaignDetailContent />
}
export default CampaignDetailPage
@@ -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
@@ -8,23 +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)
// 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
}
// Use selectedDate for single date filter, or dateRange for range filter
const getDateParams = () => {
if (filterType === 'single') {
return {
@@ -41,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)
@@ -54,14 +101,6 @@ const DailyPOSReport = () => {
totalOrders: products?.data?.reduce((sum, item) => sum + (item?.order_count || 0), 0) || 0
}
// Calculate profit loss product summary
const profitLossProductSummary = {
totalRevenue: profitLoss?.product_data?.reduce((sum, item) => sum + (item?.revenue || 0), 0) || 0,
totalCost: profitLoss?.product_data?.reduce((sum, item) => sum + (item?.cost || 0), 0) || 0,
totalGrossProfit: profitLoss?.product_data?.reduce((sum, item) => sum + (item?.gross_profit || 0), 0) || 0,
totalQuantity: profitLoss?.product_data?.reduce((sum, item) => sum + (item?.quantity_sold || 0), 0) || 0
}
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,
@@ -73,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)}`
@@ -87,98 +124,107 @@ 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
setIsGeneratingPDF(true)
try {
// Import jsPDF dan html2canvas
const jsPDF = (await import('jspdf')).default
const html2canvas = (await import('html2canvas')).default
// Optimized canvas capture dengan scale lebih rendah
const canvas = await html2canvas(reportElement!, {
scale: 1.5, // Reduced from 2 to 1.5
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff',
logging: false, // Disable logging for performance
removeContainer: true, // Clean up after capture
imageTimeout: 0, // No timeout for image loading
height: reportElement!.scrollHeight,
width: reportElement!.scrollWidth
await generatePDF({
reportRef,
outlet,
profitLoss,
products,
paymentAnalytics,
category,
productSummary,
categorySummary,
filterType,
selectedDate,
dateRange,
now
})
// Compress canvas using JPEG with quality setting
const imgData = canvas.toDataURL('image/jpeg', 0.85) // JPEG with 85% quality instead of PNG
// Create PDF with compression
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
compress: true // Enable built-in compression
})
const imgWidth = 210
const pageHeight = 295
const imgHeight = (canvas.height * imgWidth) / canvas.width
let heightLeft = imgHeight
let position = 0
// Add first page with compressed image
pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight, '', 'FAST') // Use FAST compression
heightLeft -= pageHeight
// Handle multiple pages if needed
while (heightLeft >= 0) {
position = heightLeft - imgHeight
pdf.addPage()
pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight, '', 'FAST')
heightLeft -= pageHeight
}
// Additional compression options
pdf.setProperties({
title: `${getReportTitle()}`,
subject: 'Daily Transaction Report',
author: 'Apskel POS System',
creator: 'Apskel'
})
// Save with optimized settings
const fileName =
filterType === 'single'
? `laporan-transaksi-${formatDateForInput(selectedDate)}.pdf`
: `laporan-transaksi-${formatDateForInput(dateRange.startDate)}-to-${formatDateForInput(dateRange.endDate)}.pdf`
pdf.save(fileName, {
returnPromise: true
})
// Clean up canvas to free memory
canvas.width = canvas.height = 0
} catch (error) {
console.error('Error generating PDF:', error)
alert('Terjadi kesalahan saat membuat PDF. Pastikan jsPDF dan html2canvas sudah terinstall.')
alert(`Terjadi kesalahan saat membuat PDF: ${error}`)
} finally {
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
return (
<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'>
<div className='inline-block animate-spin rounded-full h-12 w-12 border-4 border-gray-200 border-t-purple-600 mb-4'></div>
<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>
<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>
<span>Capturing report content</span>
</div>
<div className='flex items-center text-xs text-gray-500'>
<div className='w-2 h-2 bg-blue-500 rounded-full mr-2 flex-shrink-0 animate-pulse'></div>
<span>Processing pages</span>
</div>
<div className='flex items-center text-xs text-gray-500'>
<div className='w-2 h-2 bg-gray-300 rounded-full mr-2 flex-shrink-0'></div>
<span>Finalizing PDF</span>
</div>
</div>
</div>
</div>
</div>
)
}
// 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[]>
) || {}
return (
<div className='min-h-screen'>
{/* Control Panel */}
<LoadingOverlay isVisible={isGeneratingPDF} message='Membuat PDF Laporan...' />
<ReportGeneratorComponent
// Props wajib
reportTitle='Laporan Penjualan'
filterType={filterType}
selectedDate={selectedDate}
@@ -187,15 +233,13 @@ const DailyPOSReport = () => {
onSingleDateChange={setSelectedDate}
onDateRangeChange={setDateRange}
onGeneratePDF={handleGeneratePDF}
// Props opsional
// isGenerating={isGenerating}
// customQuickActions={customQuickActions}
// labels={customLabels}
onGenerateExcel={handleGenerateExcel}
isGeneratingExcel={isGeneratingExcel}
/>
{/* Report Template */}
<div ref={reportRef} className='max-w-4xl mx-auto bg-white min-h-[297mm]' style={{ width: '210mm' }}>
{/* Header */}
{/* Wrap header in a section for easy capture */}
<div className='report-header-section'>
<ReportHeader
outlet={outlet}
reportTitle={getReportTitle()}
@@ -204,144 +248,84 @@ const DailyPOSReport = () => {
brandColor='#36175e'
periode={getReportPeriodText()}
/>
</div>
{/* Performance Summary */}
<div className='p-8'>
<h3 className='text-xl font-bold mb-6' style={{ color: '#36175e' }}>
1. Ringkasan
<h3 className='text-3xl font-bold mb-8' style={{ color: '#36175e' }}>
Ringkasan
</h3>
<div className='grid grid-cols-2 gap-6'>
<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 (termasuk rasik)</span>
<span className='font-semibold text-gray-800'>
<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-2 border-b border-gray-200'>
<span className='text-gray-700'>Total Terjual</span>
<span className='font-semibold text-gray-800'>{productSummary.totalQuantitySold}</span>
</div>
<div className='flex justify-between items-center py-2 border-b border-gray-200'>
<span className='text-gray-700'>HPP</span>
<span className='font-semibold text-gray-800'>
{formatCurrency(profitLoss?.summary.total_cost ?? 0)} |{' '}
{(((profitLoss?.summary.total_cost ?? 0) / (profitLoss?.summary.total_revenue || 1)) * 100).toFixed(
0
)}
%
<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-2 border-b-2 border-gray-300'>
<span className='text-lg font-semibold text-green-800'>Laba Kotor</span>
<span className='text-lg font-bold text-green-800'>
{formatCurrency(profitLoss?.summary.gross_profit ?? 0)} |{' '}
{(profitLoss?.summary.gross_profit_margin ?? 0).toFixed(0)}%
<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='space-y-4'>
<div className='flex justify-between items-center py-2 border-b border-gray-200'>
<span className='text-gray-700'>Biaya lain²</span>
<span className='font-semibold text-gray-800'>
{formatCurrency(profitLoss?.summary.total_tax ?? 0)} |{' '}
{(((profitLoss?.summary.total_tax ?? 0) / (profitLoss?.summary.total_revenue || 1)) * 100).toFixed(0)}
%
<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 className='flex justify-between items-center py-2 border-b-2 border-gray-300'>
<span className='text-lg font-bold text-blue-800'>Laba/Rugi</span>
<span className='text-lg font-bold text-blue-800'>
{formatCurrency(profitLoss?.summary.net_profit ?? 0)} |{' '}
{(profitLoss?.summary.net_profit_margin ?? 0).toFixed(0)}%
</span>
</div>
</div>
</div>
</div>
{/* Profit Loss Product Table */}
{/* Invoice */}
<div className='px-8 pb-8'>
<h3 className='text-lg font-medium mb-6' style={{ color: '#36175e' }}>
Laba Rugi Per Produk
<h3 className='text-3xl font-bold mb-8' style={{ color: '#36175e' }}>
Invoice
</h3>
<div className='bg-gray-50 rounded-lg border border-gray-200 overflow-hidden'>
<table className='w-full'>
<thead>
<tr style={{ backgroundColor: '#36175e' }} className='text-white'>
<th className='text-left p-3 font-semibold'>Produk</th>
<th className='text-center p-3 font-semibold'>Qty</th>
<th className='text-right p-3 font-semibold'>Pendapatan</th>
<th className='text-right p-3 font-semibold'>HPP</th>
<th className='text-right p-3 font-semibold'>Laba Kotor</th>
<th className='text-center p-3 font-semibold'>Margin (%)</th>
</tr>
</thead>
<tbody>
{profitLoss?.product_data?.map((item, index) => (
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className='p-3 font-medium text-gray-800'>{item.product_name}</td>
<td className='p-3 text-center text-gray-700'>{item.quantity_sold}</td>
<td className='p-3 text-right font-semibold text-gray-800'>{formatCurrency(item.revenue)}</td>
<td className='p-3 text-right font-semibold text-red-600'>{formatCurrency(item.cost)}</td>
<td className='p-3 text-right font-semibold text-green-600'>{formatCurrency(item.gross_profit)}</td>
<td className='p-3 text-center font-semibold' style={{ color: '#36175e' }}>
{(item.gross_profit_margin ?? 0).toFixed(1)}%
</td>
</tr>
)) || []}
</tbody>
<tfoot>
<tr style={{ backgroundColor: '#36175e' }} className='text-white'>
<td className='p-3 font-bold'>TOTAL</td>
<td className='p-3 text-center font-bold'>{profitLossProductSummary.totalQuantity}</td>
<td className='p-3 text-right font-bold'>{formatCurrency(profitLossProductSummary.totalRevenue)}</td>
<td className='p-3 text-right font-bold'>{formatCurrency(profitLossProductSummary.totalCost)}</td>
<td className='p-3 text-right font-bold'>
{formatCurrency(profitLossProductSummary.totalGrossProfit)}
</td>
<td className='p-3 text-center font-bold'>
{profitLossProductSummary.totalRevenue > 0
? (
(profitLossProductSummary.totalGrossProfit / profitLossProductSummary.totalRevenue) *
100
).toFixed(1)
: 0}
%
</td>
</tr>
</tfoot>
</table>
<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-xl font-bold mb-6' style={{ color: '#36175e' }}>
2. Ringkasan Metode Pembayaran
<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 style={{ backgroundColor: '#36175e' }} className='text-white'>
<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 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-3 font-medium text-gray-800'>{payment.payment_method_name}</td>
<td className='p-3 text-center'>
<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-2 py-1 rounded-full text-xs font-medium ${
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'
@@ -350,123 +334,240 @@ const DailyPOSReport = () => {
{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'>
<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-3 text-center font-medium' style={{ color: '#36175e' }}>
<td className='p-4 text-xl text-center font-medium' style={{ color: '#36175e' }}>
{(payment.percentage ?? 0).toFixed(1)}%
</td>
</tr>
)) || []}
</tbody>
<tfoot>
<tr style={{ backgroundColor: '#36175e' }} className='text-white'>
<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'>
<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-3 text-center font-bold'></td>
<td className='p-4 text-center font-bold'></td>
</tr>
</tfoot>
</table>
</div>
</div>
{/* Category Summary */}
{/* Category Summary — +3 kolom baru */}
<div className='px-8 pb-8'>
<h3 className='text-xl font-bold mb-6' style={{ color: '#36175e' }}>
2. Ringkasan Kategori
<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 style={{ backgroundColor: '#36175e' }} className='text-white'>
<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'>Jumlah Order</th>
<th className='text-center p-3 font-semibold'>Pendapatan</th>
<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) => (
{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-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-center text-gray-700'>{c.order_count}</td>
<td className='p-3 text-right font-semibold' style={{ color: '#36175e' }}>
<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 style={{ backgroundColor: '#36175e' }} className='text-white'>
<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-center font-bold'>{categorySummary?.orderCount ?? 0}</td>
<td className='p-3 text-right font-bold'>{formatCurrency(categorySummary?.totalRevenue ?? 0)}</td>
<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>
{/* Transaction Summary */}
{/* Product Summary — +3 kolom baru */}
<div className='px-8 pb-8'>
<h3 className='text-xl font-bold mb-6' style={{ color: '#36175e' }}>
4. Ringkasan Item
<h3 className='text-3xl font-bold mb-8' style={{ color: '#36175e' }}>
Ringkasan Item Per Kategori
</h3>
<div className='bg-gray-50 rounded-lg border border-gray-200 overflow-hidden'>
<table className='w-full'>
<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()}
</h4>
<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 style={{ backgroundColor: '#36175e' }} className='text-white'>
<th className='text-left p-3 font-semibold'>Produk</th>
<th className='text-left p-3 font-semibold'>Kategori</th>
<th className='text-center p-3 font-semibold'>Qty</th>
<th className='text-right p-3 font-semibold'>Order</th>
<th className='text-right p-3 font-semibold'>Pendapatan</th>
<th className='text-right p-3 font-semibold'>Rata Rata</th>
<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>
{products?.data?.map((item, index) => (
{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-3 font-medium text-gray-800'>{item.product_name}</td>
<td className='p-3 font-medium text-gray-800'>{item.category_name}</td>
<td className='p-3 text-center text-gray-700'>{item.quantity_sold}</td>
<td className='p-3 text-center text-gray-700'>{item.order_count ?? 0}</td>
<td className='p-3 text-right font-semibold text-gray-800'>{formatCurrency(item.revenue)}</td>
<td className='p-3 text-right font-medium text-gray-800'>{formatCurrency(item.average_price)}</td>
<td className='p-5 text-xl font-medium text-gray-800 border-r border-gray-200'>
{item.product_name}
</td>
<td className='p-5 text-xl text-center text-gray-700 border-r border-gray-200'>
{item.quantity_sold}
</td>
<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-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>
)) || []}
)
})}
</tbody>
<tfoot>
<tr style={{ backgroundColor: '#36175e' }} className='text-white'>
<td className='p-3 font-bold'>TOTAL</td>
<td className='p-3 text-center'></td>
<td className='p-3 text-center font-bold'>{productSummary.totalQuantitySold ?? 0}</td>
<td className='p-3 text-right font-bold'>{productSummary.totalOrders ?? 0}</td>
<td className='p-3 text-right font-bold'>{formatCurrency(productSummary.totalRevenue ?? 0)}</td>
<td className='p-3 text-right font-bold'></td>
<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>
)
})}
{/* Profit Loss Product Table */}
{/* 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-5 text-2xl text-right font-bold border-r-2 border-purple-300'
style={{ color: '#36175e' }}
>
{formatCurrency(productSummary.totalRevenue ?? 0)}
</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>
</div>
</div>
</div>
{/* 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)}
@@ -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
View File
@@ -7,6 +7,7 @@ const getToken = () => {
export const api = axios.create({
baseURL: 'https://api-pos.apskel.id/api/v1',
// baseURL: 'http://127.0.0.1:4000/api/v1',
headers: {
'Content-Type': 'application/json'
},
+49 -1
View File
@@ -1,4 +1,4 @@
import { CampaignRequest } from '@/types/services/campaign'
import { CampaignRequest, CampaignRuleRequest } from '@/types/services/campaign'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'react-toastify'
import { api } from '../api'
@@ -50,3 +50,51 @@ export const useCampaignsMutation = () => {
return { createCampaign, updateCampaign, deleteCampaign }
}
export const useCampaignRulesMutation = () => {
const queryClient = useQueryClient()
const createCampaignRule = useMutation({
mutationFn: async (newCampaignRule: CampaignRuleRequest) => {
const response = await api.post('/marketing/campaign-rules', newCampaignRule)
return response.data
},
onSuccess: () => {
toast.success('CampaignRule created successfully!')
queryClient.invalidateQueries({ queryKey: ['campaign-rules/campaign'] })
},
onError: (error: any) => {
toast.error(error.response?.data?.errors?.[0]?.cause || 'Create failed')
}
})
const updateCampaignRule = useMutation({
mutationFn: async ({ id, payload }: { id: string; payload: CampaignRuleRequest }) => {
const response = await api.put(`/marketing/campaign-rules/${id}`, payload)
return response.data
},
onSuccess: () => {
toast.success('CampaignRule updated successfully!')
queryClient.invalidateQueries({ queryKey: ['campaign-rules/campaign'] })
},
onError: (error: any) => {
toast.error(error.response?.data?.errors?.[0]?.cause || 'Update failed')
}
})
const deleteCampaignRule = useMutation({
mutationFn: async (id: string) => {
const response = await api.delete(`/marketing/campaign-rules/${id}`)
return response.data
},
onSuccess: () => {
toast.success('CampaignRule deleted successfully!')
queryClient.invalidateQueries({ queryKey: ['campaign-rules/campaign'] })
},
onError: (error: any) => {
toast.error(error.response?.data?.errors?.[0]?.cause || 'Delete failed')
}
})
return { createCampaignRule, updateCampaignRule, deleteCampaignRule }
}
+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 }
}
+4 -4
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,15 +22,15 @@ 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 created 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')
}
})
+11 -1
View File
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { api } from '../api'
import { Campaign, Campaigns } from '@/types/services/campaign'
import { Campaign, CampaignRule, Campaigns } from '@/types/services/campaign'
interface CampaignQueryParams {
page?: number
@@ -44,3 +44,13 @@ export function useCampaignById(id: string) {
}
})
}
export function useCampaignRulesByCampaignId(id: string) {
return useQuery<CampaignRule[]>({
queryKey: ['campaign-rules/campaign', id],
queryFn: async () => {
const res = await api.get(`/marketing/campaign-rules/campaign/${id}`)
return res.data.data
}
})
}
+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
+10 -6
View File
@@ -14,8 +14,9 @@ export interface Campaign {
is_active: boolean
show_on_app: boolean
position: number
metadata?: Record<string, any>
rules?: CampaignRule[]
metadata?: {
banner_url?: string
}
created_at: string // ISO string
updated_at: string // ISO string
}
@@ -43,16 +44,19 @@ export interface CampaignRequest {
is_active: boolean
show_on_app: boolean
position: number
metadata?: Record<string, any>
rules: CampaignRuleRequest[]
metadata?: {
banner_url?: string
}
}
export interface CampaignRuleRequest {
rule_type: RuleType
campaign_id: string
rule_type: 'TIER' | 'SPEND' | 'PRODUCT' | 'CATEGORY' | 'DAY' | 'LOCATION'
condition_value?: string
reward_type: RewardType
reward_type: 'POINTS' | 'TOKENS' | 'REWARD'
reward_value?: number
reward_subtype?: string
reward_ref_id?: string
}
export interface Campaigns {
+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)
}
+9 -1
View File
@@ -43,6 +43,15 @@ export const formatDateDDMMYYYY = (dateString: Date | string) => {
return `${day}-${month}-${year}`
}
export const formatDateYYYYMMDD = (dateString: Date | string) => {
const date = new Date(dateString)
date.setHours(0, 0, 0, 0)
const day = String(date.getDate()).padStart(2, '0')
const month = String(date.getMonth() + 1).padStart(2, '0')
const year = date.getFullYear()
return `${year}-${month}-${day}`
}
export const formatForInputDate = (dateString: Date | string) => {
const date = new Date(dateString)
const day = String(date.getDate()).padStart(2, '0')
@@ -51,7 +60,6 @@ export const formatForInputDate = (dateString: Date | string) => {
return `${year}-${month}-${day}`
}
export const formatDatetime = (dateString: string | number | Date) => {
const date = new Date(dateString)
@@ -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 />
@@ -82,6 +82,8 @@ const ProductOrganize = () => {
onChange={e => handleSelectChange('printer_type', e.target.value)}
>
<MenuItem value={`kitchen`}>Kitchen</MenuItem>
<MenuItem value={`bar`}>Bar</MenuItem>
<MenuItem value={`ticket`}>Ticket</MenuItem>
</CustomTextField>
</form>
</CardContent>
@@ -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>
@@ -7,49 +7,22 @@ import Drawer from '@mui/material/Drawer'
import IconButton from '@mui/material/IconButton'
import MenuItem from '@mui/material/MenuItem'
import Typography from '@mui/material/Typography'
import Divider from '@mui/material/Divider'
import Grid from '@mui/material/Grid2'
import Box from '@mui/material/Box'
import Switch from '@mui/material/Switch'
import FormControlLabel from '@mui/material/FormControlLabel'
import Chip from '@mui/material/Chip'
import InputAdornment from '@mui/material/InputAdornment'
// Third-party Imports
import { useForm, Controller, useFieldArray } from 'react-hook-form'
import { useForm, Controller } from 'react-hook-form'
// Component Imports
import CustomTextField from '@core/components/mui/TextField'
import ImageUpload from '@/components/ImageUpload'
// Types
import { Campaign } from '@/types/services/campaign'
import { Campaign, CampaignRequest, CampaignType } from '@/types/services/campaign'
import { useCampaignsMutation } from '@/services/mutations/campaign'
// Updated Type Definitions
export type CampaignType = 'REWARD' | 'POINTS' | 'TOKENS' | 'MIXED'
export type RuleType = 'TIER' | 'SPEND' | 'PRODUCT' | 'CATEGORY' | 'DAY' | 'LOCATION'
export type RewardType = 'POINTS' | 'TOKENS' | 'REWARD'
export interface CampaignRequest {
name: string
description?: string
type: CampaignType
start_date: string // ISO string
end_date: string // ISO string
is_active: boolean
show_on_app: boolean
position: number
metadata?: Record<string, any>
rules: CampaignRuleRequest[]
}
export interface CampaignRuleRequest {
rule_type: RuleType
condition_value?: string
reward_type: RewardType
reward_value?: number
reward_subtype?: string
}
import { useFilesMutation } from '@/services/mutations/files'
import { formatDateYYYYMMDD } from '@/utils/transform'
type Props = {
open: boolean
@@ -66,14 +39,7 @@ type FormValidateType = {
is_active: boolean
show_on_app: boolean
position: number
// Rules array
rules: {
rule_type: RuleType
condition_value: string
reward_type: RewardType
reward_value: number
reward_subtype: string
}[]
banner_url: string
}
// Initial form data
@@ -86,16 +52,7 @@ const initialData: FormValidateType = {
is_active: true,
show_on_app: true,
position: 1,
// Initial rule
rules: [
{
rule_type: 'SPEND',
condition_value: '',
reward_type: 'POINTS',
reward_value: 0,
reward_subtype: ''
}
]
banner_url: ''
}
const AddEditCampaignDrawer = (props: Props) => {
@@ -105,8 +62,10 @@ const AddEditCampaignDrawer = (props: Props) => {
// States
const [showMore, setShowMore] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [bannerUrl, setBannerUrl] = useState<string>('')
const { createCampaign, updateCampaign } = useCampaignsMutation()
const { mutate: uploadFile, isPending: isUploadPending } = useFilesMutation().uploadFile
// Determine if this is edit mode
const isEditMode = Boolean(data?.id)
@@ -123,12 +82,6 @@ const AddEditCampaignDrawer = (props: Props) => {
defaultValues: initialData
})
// Field array for rules
const { fields, append, remove } = useFieldArray({
control,
name: 'rules'
})
const watchedStartDate = watch('start_date')
const watchedEndDate = watch('end_date')
@@ -144,29 +97,16 @@ const AddEditCampaignDrawer = (props: Props) => {
is_active: data.is_active ?? true,
show_on_app: data.show_on_app ?? true,
position: data.position || 1,
// Map existing rules
rules: data.rules?.map(rule => ({
rule_type: rule.rule_type,
condition_value: rule.condition_value || '',
reward_type: rule.reward_type,
reward_value: rule.reward_value || 0,
reward_subtype: rule.reward_subtype || ''
})) || [
{
rule_type: 'SPEND',
condition_value: '',
reward_type: 'POINTS',
reward_value: 0,
reward_subtype: ''
}
]
banner_url: data.metadata?.banner_url || ''
}
resetForm(formData)
setBannerUrl(data.metadata?.banner_url || '')
setShowMore(true) // Always show more for edit mode
} else {
// Reset to initial data for add mode
resetForm(initialData)
setBannerUrl('')
setShowMore(false)
}
}, [data, isEditMode, resetForm])
@@ -175,20 +115,10 @@ const AddEditCampaignDrawer = (props: Props) => {
try {
setIsSubmitting(true)
// Create rules array
const rulesRequest: CampaignRuleRequest[] = formData.rules.map(rule => ({
rule_type: rule.rule_type,
condition_value: rule.condition_value || undefined,
reward_type: rule.reward_type,
reward_value: rule.reward_value || undefined,
reward_subtype: rule.reward_subtype || undefined
}))
// Create metadata from rules if needed
const metadata: Record<string, any> = {}
const spendRule = formData.rules.find(rule => rule.rule_type === 'SPEND')
if (spendRule?.condition_value) {
metadata.minPurchase = parseInt(spendRule.condition_value)
// Create metadata object
const metadata: { banner_url?: string } = {}
if (bannerUrl.trim()) {
metadata.banner_url = bannerUrl.trim()
}
// Create CampaignRequest object
@@ -196,13 +126,12 @@ const AddEditCampaignDrawer = (props: Props) => {
name: formData.name,
description: formData.description || undefined,
type: formData.type,
start_date: new Date(formData.start_date).toISOString(),
end_date: new Date(formData.end_date).toISOString(),
start_date: formatDateYYYYMMDD(formData.start_date),
end_date: formatDateYYYYMMDD(formData.end_date),
is_active: formData.is_active,
show_on_app: formData.show_on_app,
position: formData.position,
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
rules: rulesRequest
metadata: Object.keys(metadata).length > 0 ? metadata : undefined
}
if (isEditMode && data?.id) {
@@ -236,70 +165,29 @@ const AddEditCampaignDrawer = (props: Props) => {
const handleReset = () => {
handleClose()
resetForm(initialData)
setBannerUrl('')
setShowMore(false)
}
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0
}).format(value)
}
// Handle file upload
const handleBannerUpload = async (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const formData = new FormData()
formData.append('file', file)
formData.append('file_type', 'image')
formData.append('description', 'campaign banner upload')
const getRewardTypeLabel = (type: RewardType) => {
switch (type) {
case 'POINTS':
return 'Poin'
case 'TOKENS':
return 'Token'
case 'REWARD':
return 'Reward'
default:
return type
uploadFile(formData, {
onSuccess: r => {
setBannerUrl(r.file_url)
setValue('banner_url', r.file_url) // Update form value
resolve(r.id)
},
onError: er => {
reject(er)
}
}
const getRewardValuePlaceholder = (type: RewardType) => {
switch (type) {
case 'POINTS':
return 'Jumlah poin yang diberikan'
case 'TOKENS':
return 'Jumlah token yang diberikan'
case 'REWARD':
return 'Nilai reward'
default:
return 'Nilai reward'
}
}
const getConditionValuePlaceholder = (ruleType: RuleType) => {
switch (ruleType) {
case 'SPEND':
return 'Minimum pembelian (Rupiah)'
case 'TIER':
return 'Tier pelanggan (misal: GOLD, SILVER)'
case 'PRODUCT':
return 'ID atau nama produk'
case 'CATEGORY':
return 'Kategori produk'
case 'DAY':
return 'Hari dalam seminggu (misal: MONDAY)'
case 'LOCATION':
return 'Lokasi atau kota'
default:
return 'Nilai kondisi'
}
}
const getConditionValueInputProps = (ruleType: RuleType) => {
if (ruleType === 'SPEND') {
return {
startAdornment: <InputAdornment position='start'>Rp</InputAdornment>,
type: 'number' as const
}
}
return { type: 'text' as const }
})
})
}
return (
@@ -402,202 +290,6 @@ const AddEditCampaignDrawer = (props: Props) => {
/>
</div>
{/* Rules Section */}
<div>
<Box display='flex' alignItems='center' justifyContent='space-between' className='mb-4'>
<Typography variant='h6'>Aturan Kampanye</Typography>
<Button
variant='outlined'
size='small'
startIcon={<i className='tabler-plus' />}
onClick={() =>
append({
rule_type: 'SPEND',
condition_value: '',
reward_type: 'POINTS',
reward_value: 0,
reward_subtype: ''
})
}
>
Tambah Aturan
</Button>
</Box>
{fields.map((field, index) => (
<Box key={field.id} className='mb-6 p-4 border border-gray-200 rounded-lg'>
<Box display='flex' alignItems='center' justifyContent='between' className='mb-3'>
<Typography variant='subtitle2' className='font-medium'>
Aturan {index + 1}
</Typography>
{fields.length > 1 && (
<IconButton size='small' color='error' onClick={() => remove(index)}>
<i className='tabler-trash text-lg' />
</IconButton>
)}
</Box>
<div className='flex flex-col gap-4'>
{/* Rule Type */}
<div>
<Typography variant='body2' className='mb-2'>
Tipe Aturan <span className='text-red-500'>*</span>
</Typography>
<Controller
name={`rules.${index}.rule_type`}
control={control}
rules={{ required: 'Tipe aturan wajib dipilih' }}
render={({ field }) => (
<CustomTextField
{...field}
select
fullWidth
size='small'
error={!!errors.rules?.[index]?.rule_type}
helperText={errors.rules?.[index]?.rule_type?.message}
>
<MenuItem value='SPEND'>Minimum Pembelian</MenuItem>
<MenuItem value='TIER'>Tier Pelanggan</MenuItem>
<MenuItem value='PRODUCT'>Produk Tertentu</MenuItem>
<MenuItem value='CATEGORY'>Kategori Produk</MenuItem>
<MenuItem value='DAY'>Hari Tertentu</MenuItem>
<MenuItem value='LOCATION'>Lokasi Tertentu</MenuItem>
</CustomTextField>
)}
/>
</div>
{/* Condition Value */}
<div>
<Typography variant='body2' className='mb-2'>
Nilai Kondisi <span className='text-red-500'>*</span>
</Typography>
<Controller
name={`rules.${index}.condition_value`}
control={control}
rules={{ required: 'Nilai kondisi wajib diisi' }}
render={({ field }) => {
const ruleType = watch(`rules.${index}.rule_type`)
return (
<CustomTextField
{...field}
fullWidth
size='small'
placeholder={getConditionValuePlaceholder(ruleType)}
error={!!errors.rules?.[index]?.condition_value}
helperText={errors.rules?.[index]?.condition_value?.message}
InputProps={getConditionValueInputProps(ruleType)}
type={getConditionValueInputProps(ruleType).type}
/>
)
}}
/>
</div>
{/* Reward Type */}
<div>
<Typography variant='body2' className='mb-2'>
Jenis Reward <span className='text-red-500'>*</span>
</Typography>
<Controller
name={`rules.${index}.reward_type`}
control={control}
rules={{ required: 'Jenis reward wajib dipilih' }}
render={({ field }) => (
<CustomTextField
{...field}
select
fullWidth
size='small'
error={!!errors.rules?.[index]?.reward_type}
helperText={errors.rules?.[index]?.reward_type?.message}
>
<MenuItem value='POINTS'>
<div className='flex items-center gap-2'>
<i className='tabler-coins text-primary' />
Points
</div>
</MenuItem>
<MenuItem value='TOKENS'>
<div className='flex items-center gap-2'>
<i className='tabler-ticket text-success' />
Tokens
</div>
</MenuItem>
<MenuItem value='REWARD'>
<div className='flex items-center gap-2'>
<i className='tabler-percentage text-warning' />
Reward
</div>
</MenuItem>
</CustomTextField>
)}
/>
</div>
{/* Reward Value */}
<div>
<Typography variant='body2' className='mb-2'>
Nilai Reward <span className='text-red-500'>*</span>
</Typography>
<Controller
name={`rules.${index}.reward_value`}
control={control}
rules={{
required: 'Nilai reward wajib diisi',
min: { value: 1, message: 'Nilai reward minimal 1' }
}}
render={({ field }) => {
const rewardType = watch(`rules.${index}.reward_type`)
return (
<CustomTextField
{...field}
fullWidth
size='small'
type='number'
placeholder={getRewardValuePlaceholder(rewardType)}
error={!!errors.rules?.[index]?.reward_value}
helperText={errors.rules?.[index]?.reward_value?.message}
InputProps={{
endAdornment:
rewardType === 'POINTS' ? (
<InputAdornment position='end'>Poin</InputAdornment>
) : rewardType === 'TOKENS' ? (
<InputAdornment position='end'>Token</InputAdornment>
) : undefined
}}
onChange={e => field.onChange(Number(e.target.value))}
/>
)
}}
/>
</div>
{/* Reward Subtype (jika reward type adalah REWARD) */}
{watch(`rules.${index}.reward_type`) === 'REWARD' && (
<div>
<Typography variant='body2' className='mb-2'>
Sub-tipe Reward
</Typography>
<Controller
name={`rules.${index}.reward_subtype`}
control={control}
render={({ field }) => (
<CustomTextField {...field} select fullWidth size='small'>
<MenuItem value='DISCOUNT_PERCENT'>Diskon Persentase</MenuItem>
<MenuItem value='DISCOUNT_AMOUNT'>Diskon Nominal</MenuItem>
<MenuItem value='CASHBACK'>Cashback</MenuItem>
<MenuItem value='FREE_SHIPPING'>Gratis Ongkir</MenuItem>
</CustomTextField>
)}
/>
</div>
)}
</div>
</Box>
))}
</div>
{/* Tanggal Mulai */}
<div>
<Typography variant='body2' className='mb-2'>
@@ -723,6 +415,23 @@ const AddEditCampaignDrawer = (props: Props) => {
/>
</div>
{/* Banner Upload */}
<div>
<Typography variant='body2' className='mb-2'>
Banner Kampanye
</Typography>
<ImageUpload
onUpload={handleBannerUpload}
maxFileSize={1 * 1024 * 1024} // 1MB
currentImageUrl={bannerUrl}
dragDropText='Drop banner image here'
browseButtonText='Choose Banner'
/>
<Typography variant='caption' color='text.secondary' className='mt-1 block'>
Format: JPG, PNG. Ukuran maksimal: 1MB
</Typography>
</div>
{/* Position */}
<div>
<Typography variant='body2' className='mb-2'>
@@ -141,85 +141,6 @@ const getCampaignTypeColor = (type: string): ThemeColor => {
}
}
const getCampaignTypeIcon = (type: string): string => {
switch (type) {
case 'POINTS':
return 'tabler-coins'
case 'TOKENS':
return 'tabler-ticket'
case 'REWARD':
return 'tabler-gift'
case 'MIXED':
return 'tabler-layers-intersect'
default:
return 'tabler-tag'
}
}
const getRewardTypeColor = (rewardType: string): ThemeColor => {
switch (rewardType) {
case 'POINTS':
return 'primary'
case 'TOKENS':
return 'success'
case 'REWARD':
return 'warning'
default:
return 'info'
}
}
const getRewardTypeIcon = (rewardType: string): string => {
switch (rewardType) {
case 'POINTS':
return 'tabler-coins'
case 'TOKENS':
return 'tabler-ticket'
case 'REWARD':
return 'tabler-percentage'
default:
return 'tabler-gift'
}
}
const formatRewardValue = (rewardType: string, rewardValue?: number, rewardSubtype?: string): string => {
if (!rewardValue) return '-'
switch (rewardType) {
case 'POINTS':
return `${formatPoints(rewardValue)} Poin`
case 'TOKENS':
return formatCurrency(rewardValue)
case 'REWARD':
if (rewardSubtype === 'DISCOUNT_PERCENT') {
return `${rewardValue}%`
}
return formatCurrency(rewardValue)
default:
return rewardValue.toString()
}
}
const getMinimumPurchase = (campaign: Campaign): number => {
// Check rules for spend condition
const spendRule = campaign.rules?.find(rule => rule.rule_type === 'SPEND')
if (spendRule?.condition_value) {
return parseInt(spendRule.condition_value)
}
// Fallback to metadata
return campaign.metadata?.minPurchase || 0
}
const getPrimaryReward = (campaign: Campaign): { type: string; value?: number; subtype?: string } => {
const primaryRule = campaign.rules?.[0]
return {
type: primaryRule?.reward_type || 'POINTS',
value: primaryRule?.reward_value,
subtype: primaryRule?.reward_subtype
}
}
// Column Definitions
const columnHelper = createColumnHelper<CampaignWithAction>()
@@ -331,7 +252,7 @@ const CampaignListTable = () => {
cell: ({ row }) => (
<div className='flex items-center gap-4'>
<div className='flex flex-col'>
<Link href={getLocalizedUrl(`/apps/campaigns/${row.original.id}/detail`, locale as Locale)}>
<Link href={getLocalizedUrl(`/apps/marketing/campaign/${row.original.id}/detail`, locale as Locale)}>
<Typography className='font-medium cursor-pointer hover:underline text-primary'>
{row.original.name}
</Typography>
@@ -356,38 +277,6 @@ const CampaignListTable = () => {
/>
)
}),
columnHelper.accessor('metadata', {
header: 'Minimum Pembelian',
cell: ({ row }) => {
const minPurchase = getMinimumPurchase(row.original)
return (
<div className='flex items-center gap-2'>
<Icon className='tabler-coin' sx={{ color: 'var(--mui-palette-primary-main)' }} />
<Typography color='text.primary'>{minPurchase > 0 ? formatCurrency(minPurchase) : '-'}</Typography>
</div>
)
}
}),
columnHelper.accessor('rules', {
header: 'Reward Utama',
cell: ({ row }) => {
const reward = getPrimaryReward(row.original)
return (
<div className='flex items-center gap-2'>
<Icon
className={getRewardTypeIcon(reward.type)}
sx={{ color: `var(--mui-palette-${getRewardTypeColor(reward.type)}-main)` }}
/>
<Chip
label={formatRewardValue(reward.type, reward.value, reward.subtype)}
color={getRewardTypeColor(reward.type)}
variant='tonal'
size='small'
/>
</div>
)
}
}),
columnHelper.accessor('start_date', {
header: 'Periode Kampanye',
cell: ({ row }) => (
@@ -0,0 +1,417 @@
// React Imports
import { useState, useEffect } from 'react'
// MUI Imports
import Button from '@mui/material/Button'
import Drawer from '@mui/material/Drawer'
import IconButton from '@mui/material/IconButton'
import MenuItem from '@mui/material/MenuItem'
import Typography from '@mui/material/Typography'
import Box from '@mui/material/Box'
// Third-party Imports
import { useForm, Controller } from 'react-hook-form'
// Component Imports
import CustomTextField from '@core/components/mui/TextField'
// Types
import { CampaignRule, RuleType, RewardType, CampaignRuleRequest } from '@/types/services/campaign'
import { useCampaignRulesMutation } from '@/services/mutations/campaign'
type Props = {
open: boolean
handleClose: () => void
campaignId: string // Required campaign ID
data?: CampaignRule | null // Data for edit mode
}
type FormValidateType = {
rule_type: RuleType
condition_value: string
reward_type: RewardType
reward_value: number
reward_subtype: string
reward_ref_id: string
}
// Initial form data
const initialData: FormValidateType = {
rule_type: 'SPEND',
condition_value: '',
reward_type: 'POINTS',
reward_value: 0,
reward_subtype: '',
reward_ref_id: ''
}
const AddEditCampaignRuleDrawer = (props: Props) => {
// Props
const { open, handleClose, campaignId, data } = props
// States
const [isSubmitting, setIsSubmitting] = useState(false)
const { createCampaignRule, updateCampaignRule } = useCampaignRulesMutation()
// Determine if this is edit mode
const isEditMode = Boolean(data?.id)
// Hooks
const {
control,
reset: resetForm,
handleSubmit,
watch,
formState: { errors }
} = useForm<FormValidateType>({
defaultValues: initialData
})
const watchedRewardType = watch('reward_type')
// Effect to populate form when editing
useEffect(() => {
if (isEditMode && data) {
const formData: FormValidateType = {
rule_type: data.rule_type,
condition_value: data.condition_value || '',
reward_type: data.reward_type,
reward_value: data.reward_value || 0,
reward_subtype: data.reward_subtype || '',
reward_ref_id: data.reward_ref_id || ''
}
resetForm(formData)
} else {
// Reset to initial data for add mode
resetForm(initialData)
}
}, [data, isEditMode, resetForm])
const handleFormSubmit = async (formData: FormValidateType) => {
try {
setIsSubmitting(true)
// Create CampaignRuleRequest object
const ruleRequest: CampaignRuleRequest = {
campaign_id: campaignId,
rule_type: formData.rule_type,
condition_value: formData.condition_value.trim() || undefined,
reward_type: formData.reward_type,
reward_value: formData.reward_value > 0 ? formData.reward_value : undefined,
reward_subtype: formData.reward_subtype.trim() || undefined,
reward_ref_id: formData.reward_ref_id.trim() || undefined
}
if (isEditMode && data?.id) {
// Update existing campaign rule
updateCampaignRule.mutate(
{ id: data.id, payload: ruleRequest },
{
onSuccess: () => {
handleReset()
handleClose()
},
onError: error => {
console.error('Error updating campaign rule:', error)
}
}
)
} else {
// Create new campaign rule
createCampaignRule.mutate(ruleRequest, {
onSuccess: () => {
handleReset()
handleClose()
},
onError: error => {
console.error('Error creating campaign rule:', error)
}
})
}
} catch (error) {
console.error('Error submitting campaign rule:', error)
} finally {
setIsSubmitting(false)
}
}
const handleReset = () => {
handleClose()
resetForm(initialData)
}
// Helper function to get rule type options
const getRuleTypeOptions = () => [
{ value: 'TIER', label: 'Tier Based', icon: 'tabler-medal' },
{ value: 'SPEND', label: 'Spending Amount', icon: 'tabler-wallet' },
{ value: 'PRODUCT', label: 'Product Based', icon: 'tabler-package' },
{ value: 'CATEGORY', label: 'Category Based', icon: 'tabler-category' },
{ value: 'DAY', label: 'Day Based', icon: 'tabler-calendar' },
{ value: 'LOCATION', label: 'Location Based', icon: 'tabler-map-pin' }
]
// Helper function to get reward type options
const getRewardTypeOptions = () => [
{ value: 'POINTS', label: 'Points', icon: 'tabler-coins' },
{ value: 'TOKENS', label: 'Tokens', icon: 'tabler-ticket' },
{ value: 'REWARD', label: 'Reward Item', icon: 'tabler-gift' }
]
// Helper function to get condition placeholder based on rule type
const getConditionPlaceholder = (ruleType: RuleType) => {
switch (ruleType) {
case 'TIER':
return 'e.g., GOLD, SILVER, BRONZE'
case 'SPEND':
return 'e.g., 100000 (minimum spend amount)'
case 'PRODUCT':
return 'e.g., product_id_123'
case 'CATEGORY':
return 'e.g., electronics, fashion'
case 'DAY':
return 'e.g., monday, weekend'
case 'LOCATION':
return 'e.g., jakarta, bandung'
default:
return 'Enter condition value'
}
}
// Helper function to get reward subtype options based on reward type
const getRewardSubtypeOptions = (rewardType: RewardType) => {
switch (rewardType) {
case 'POINTS':
return ['bonus', 'cashback', 'referral']
case 'TOKENS':
return ['game', 'lottery', 'voucher']
case 'REWARD':
return ['product', 'discount', 'currency', 'experience']
default:
return []
}
}
return (
<Drawer
open={open}
anchor='right'
variant='temporary'
onClose={handleReset}
ModalProps={{ keepMounted: true }}
sx={{
'& .MuiDrawer-paper': {
width: { xs: 300, sm: 400 },
display: 'flex',
flexDirection: 'column',
height: '100%'
}
}}
>
{/* Sticky Header */}
<Box
sx={{
position: 'sticky',
top: 0,
zIndex: 10,
backgroundColor: 'background.paper',
borderBottom: 1,
borderColor: 'divider'
}}
>
<div className='flex items-center justify-between plb-5 pli-6'>
<Typography variant='h5'>{isEditMode ? 'Edit Campaign Rule' : 'Add New Campaign Rule'}</Typography>
<IconButton size='small' onClick={handleReset}>
<i className='tabler-x text-2xl text-textPrimary' />
</IconButton>
</div>
</Box>
{/* Scrollable Content */}
<Box sx={{ flex: 1, overflowY: 'auto' }}>
<form id='campaign-rule-form' onSubmit={handleSubmit(handleFormSubmit)}>
<div className='flex flex-col gap-6 p-6'>
{/* Rule Type */}
<div>
<Typography variant='body2' className='mb-2'>
Rule Type <span className='text-red-500'>*</span>
</Typography>
<Controller
name='rule_type'
control={control}
rules={{ required: 'Rule type is required' }}
render={({ field }) => (
<CustomTextField
{...field}
select
fullWidth
error={!!errors.rule_type}
helperText={errors.rule_type?.message}
>
{getRuleTypeOptions().map(option => (
<MenuItem key={option.value} value={option.value}>
<div className='flex items-center gap-2'>
<i className={`${option.icon} text-primary`} />
{option.label}
</div>
</MenuItem>
))}
</CustomTextField>
)}
/>
</div>
{/* Condition Value */}
<div>
<Typography variant='body2' className='mb-2'>
Condition Value
</Typography>
<Controller
name='condition_value'
control={control}
render={({ field }) => (
<CustomTextField
{...field}
fullWidth
placeholder={getConditionPlaceholder(watch('rule_type'))}
error={!!errors.condition_value}
helperText={errors.condition_value?.message}
/>
)}
/>
</div>
{/* Reward Type */}
<div>
<Typography variant='body2' className='mb-2'>
Reward Type <span className='text-red-500'>*</span>
</Typography>
<Controller
name='reward_type'
control={control}
rules={{ required: 'Reward type is required' }}
render={({ field }) => (
<CustomTextField
{...field}
select
fullWidth
error={!!errors.reward_type}
helperText={errors.reward_type?.message}
>
{getRewardTypeOptions().map(option => (
<MenuItem key={option.value} value={option.value}>
<div className='flex items-center gap-2'>
<i className={`${option.icon} text-primary`} />
{option.label}
</div>
</MenuItem>
))}
</CustomTextField>
)}
/>
</div>
{/* Reward Value */}
<div>
<Typography variant='body2' className='mb-2'>
Reward Value
</Typography>
<Controller
name='reward_value'
control={control}
rules={{
min: { value: 1, message: 'Reward value must be at least 1' }
}}
render={({ field }) => (
<CustomTextField
{...field}
fullWidth
type='number'
placeholder={`Enter ${watchedRewardType.toLowerCase()} amount`}
error={!!errors.reward_value}
helperText={errors.reward_value?.message}
onChange={e => field.onChange(Number(e.target.value))}
/>
)}
/>
</div>
{/* Reward Subtype */}
<div>
<Typography variant='body2' className='mb-2'>
Reward Subtype
</Typography>
<Controller
name='reward_subtype'
control={control}
render={({ field }) => (
<CustomTextField
{...field}
select
fullWidth
error={!!errors.reward_subtype}
helperText={errors.reward_subtype?.message}
>
<MenuItem value=''>
<em>None</em>
</MenuItem>
{getRewardSubtypeOptions(watchedRewardType).map(subtype => (
<MenuItem key={subtype} value={subtype}>
{subtype.charAt(0).toUpperCase() + subtype.slice(1)}
</MenuItem>
))}
</CustomTextField>
)}
/>
</div>
{/* Reward Reference ID */}
<div>
<Typography variant='body2' className='mb-2'>
Reward Reference ID
</Typography>
<Controller
name='reward_ref_id'
control={control}
render={({ field }) => (
<CustomTextField
{...field}
fullWidth
placeholder='e.g., product_id, voucher_id (optional)'
error={!!errors.reward_ref_id}
helperText={errors.reward_ref_id?.message || 'Optional reference to specific reward item'}
/>
)}
/>
</div>
</div>
</form>
</Box>
{/* Sticky Footer */}
<Box
sx={{
position: 'sticky',
bottom: 0,
zIndex: 10,
backgroundColor: 'background.paper',
borderTop: 1,
borderColor: 'divider',
p: 3
}}
>
<div className='flex items-center gap-4'>
<Button variant='contained' type='submit' form='campaign-rule-form' disabled={isSubmitting}>
{isSubmitting ? (isEditMode ? 'Updating...' : 'Creating...') : isEditMode ? 'Update Rule' : 'Create Rule'}
</Button>
<Button variant='outlined' color='error' onClick={handleReset} disabled={isSubmitting}>
Cancel
</Button>
</div>
</Box>
</Drawer>
)
}
export default AddEditCampaignRuleDrawer
@@ -0,0 +1,401 @@
'use client'
import Loading from '@/components/layout/shared/Loading'
import { useCampaignById, useCampaignRulesByCampaignId } from '@/services/queries/campaign'
import { CampaignRule, RewardType, RuleType } from '@/types/services/campaign'
import { formatCurrency } from '@/utils/transform'
import {
Avatar,
Card,
CardHeader,
CardContent,
Chip,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Box,
Divider,
Button,
IconButton,
Tooltip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
DialogContentText
} from '@mui/material'
// Using Tabler icons instead of Material-UI icons
import { useParams } from 'next/navigation'
import { useState } from 'react'
const CampaignDetailContent = () => {
const params = useParams()
// State for dialogs
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null)
const [selectedRule, setSelectedRule] = useState<CampaignRule | null>(null)
const { data: campaign, isLoading, error } = useCampaignById(params?.id as string)
const { data: rules, isLoading: rulesLoading, error: rulesError } = useCampaignRulesByCampaignId(params?.id as string)
if (isLoading) return <Loading />
// Action handlers
const handleAddRule = () => {
// TODO: Implement add rule functionality
// Could open a modal/form or navigate to add rule page
console.log('Add new rule for campaign:', params?.id)
}
const handleEditRule = (rule: CampaignRule) => {
// TODO: Implement edit rule functionality
// Could open a modal/form or navigate to edit rule page
console.log('Edit rule:', rule.id)
setSelectedRule(rule)
// You might want to open an edit modal here
}
const handleDeleteRule = (rule: CampaignRule) => {
setSelectedRule(rule)
setSelectedRuleId(rule.id)
setDeleteDialogOpen(true)
}
const confirmDeleteRule = async () => {
if (selectedRuleId) {
try {
// TODO: Implement actual delete API call
console.log('Deleting rule:', selectedRuleId)
// await deleteRuleMutation.mutateAsync(selectedRuleId)
// Close dialog and reset state
setDeleteDialogOpen(false)
setSelectedRuleId(null)
setSelectedRule(null)
// Show success message or refresh data
} catch (error) {
console.error('Failed to delete rule:', error)
// Handle error (show toast, etc.)
}
}
}
const cancelDelete = () => {
setDeleteDialogOpen(false)
setSelectedRuleId(null)
setSelectedRule(null)
}
// Helper function to format rule type display
const formatRuleType = (ruleType: RuleType) => {
const ruleTypeLabels: Record<RuleType, string> = {
TIER: 'Tier Based',
SPEND: 'Spending Amount',
PRODUCT: 'Product Based',
CATEGORY: 'Category Based',
DAY: 'Day Based',
LOCATION: 'Location Based'
}
return ruleTypeLabels[ruleType] || ruleType
}
// Helper function to get rule type color
const getRuleTypeColor = (ruleType: RuleType): 'primary' | 'secondary' | 'success' | 'warning' | 'info' => {
const colorMap: Record<RuleType, 'primary' | 'secondary' | 'success' | 'warning' | 'info'> = {
TIER: 'primary',
SPEND: 'success',
PRODUCT: 'info',
CATEGORY: 'warning',
DAY: 'secondary',
LOCATION: 'primary'
}
return colorMap[ruleType] || 'primary'
}
// Helper function to format reward type display
const formatRewardType = (rewardType: RewardType) => {
const rewardTypeLabels: Record<RewardType, string> = {
POINTS: 'Points',
TOKENS: 'Tokens',
REWARD: 'Reward Item'
}
return rewardTypeLabels[rewardType] || rewardType
}
// Helper function to get reward type color
const getRewardTypeColor = (rewardType: RewardType): 'primary' | 'secondary' | 'success' => {
const colorMap: Record<RewardType, 'primary' | 'secondary' | 'success'> = {
POINTS: 'success',
TOKENS: 'primary',
REWARD: 'secondary'
}
return colorMap[rewardType] || 'primary'
}
// Helper function to format reward value
const formatRewardValue = (rewardValue?: number, rewardType?: RewardType, rewardSubtype?: string) => {
if (!rewardValue) return '-'
// Format based on reward type
switch (rewardType) {
case 'POINTS':
return `${rewardValue.toLocaleString()} pts`
case 'TOKENS':
return `${rewardValue.toLocaleString()} tokens`
case 'REWARD':
// For reward items, check if it's currency-based
if (rewardSubtype === 'currency') {
return formatCurrency(rewardValue)
}
return rewardValue.toString()
default:
return rewardValue.toString()
}
}
// Helper function to format date
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
return (
<>
<div className='space-y-6'>
{/* Header Card */}
<Card>
<CardHeader
avatar={<Avatar src={campaign?.metadata?.banner_url || ''} alt={campaign?.name} className='w-16 h-16' />}
title={
<div className='flex items-center gap-3'>
<Typography variant='h4' component='h1' className='font-bold'>
{campaign?.name} ({campaign?.type})
</Typography>
<Chip
label={campaign?.is_active ? 'Active' : 'Inactive'}
color={campaign?.is_active ? 'success' : 'error'}
size='small'
/>
<Chip
label={campaign?.show_on_app ? 'Show on App' : 'Hide on App'}
color={campaign?.show_on_app ? 'info' : 'error'}
size='small'
/>
</div>
}
subheader={
<div className='flex flex-col gap-1 mt-2'>
<Typography variant='body2' color='textSecondary'>
{campaign?.start_date} - {campaign?.end_date}
</Typography>
<Typography variant='body2' color='textSecondary'>
{campaign?.description}
</Typography>
</div>
}
/>
</Card>
{/* Campaign Rules Table */}
<Card>
<CardHeader
title={
<div className='flex items-center justify-between'>
<div>
<Typography variant='h5' component='h2' className='font-semibold'>
Campaign Rules
</Typography>
{rules && rules.length > 0 && (
<Chip
label={`${rules.length} rule${rules.length !== 1 ? 's' : ''}`}
size='small'
variant='outlined'
className='mt-1'
/>
)}
</div>
<Button
variant='contained'
startIcon={<i className='tabler-plus' />}
onClick={handleAddRule}
size='small'
>
Add Rule
</Button>
</div>
}
subheader={
<Typography variant='body2' color='textSecondary'>
Rules and rewards configuration for this campaign
</Typography>
}
/>
<Divider />
<CardContent className='p-0'>
{rulesLoading ? (
<Box className='p-8 text-center'>
<Loading />
</Box>
) : rulesError ? (
<Box className='p-8 text-center'>
<Typography variant='body1' color='error'>
Failed to load campaign rules
</Typography>
<Typography variant='body2' color='textSecondary' className='mt-1'>
{rulesError.message || 'Please try refreshing the page'}
</Typography>
</Box>
) : rules && rules.length > 0 ? (
<TableContainer component={Paper} elevation={0}>
<Table>
<TableHead>
<TableRow>
<TableCell>Rule Type</TableCell>
<TableCell>Condition</TableCell>
<TableCell>Reward Type</TableCell>
<TableCell>Reward Value</TableCell>
<TableCell>Subtype</TableCell>
<TableCell>Created</TableCell>
<TableCell>Updated</TableCell>
<TableCell align='center' width={120}>
Actions
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rules.map((rule: CampaignRule) => (
<TableRow key={rule.id} hover>
<TableCell>
<Chip
label={formatRuleType(rule.rule_type)}
variant='outlined'
size='small'
color={getRuleTypeColor(rule.rule_type)}
/>
</TableCell>
<TableCell>
<Typography variant='body2'>{rule.condition_value || '-'}</Typography>
</TableCell>
<TableCell>
<Chip
label={formatRewardType(rule.reward_type)}
variant='outlined'
size='small'
color={getRewardTypeColor(rule.reward_type)}
/>
</TableCell>
<TableCell>
<Typography variant='body2' className='font-medium'>
{formatRewardValue(rule.reward_value, rule.reward_type, rule.reward_subtype)}
</Typography>
</TableCell>
<TableCell>
{rule.reward_subtype && (
<Chip label={rule.reward_subtype} variant='outlined' size='small' color='default' />
)}
</TableCell>
<TableCell>
<Typography variant='caption' color='textSecondary'>
{formatDate(rule.created_at)}
</Typography>
</TableCell>
<TableCell>
<Typography variant='caption' color='textSecondary'>
{formatDate(rule.updated_at)}
</Typography>
</TableCell>
<TableCell align='center'>
<Box className='flex items-center gap-1'>
<Tooltip title='Edit rule'>
<IconButton size='small' onClick={() => handleEditRule(rule)} color='primary'>
<i className='tabler-edit' />
</IconButton>
</Tooltip>
<Tooltip title='Delete rule'>
<IconButton size='small' onClick={() => handleDeleteRule(rule)} color='error'>
<i className='tabler-trash' />
</IconButton>
</Tooltip>
</Box>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
) : (
<Box className='p-8 text-center'>
<Typography variant='body1' color='textSecondary'>
No rules configured for this campaign
</Typography>
<Typography variant='body2' color='textSecondary' className='mt-1'>
Add rules to define how rewards are distributed
</Typography>
<Button
variant='outlined'
startIcon={<i className='tabler-plus' />}
onClick={handleAddRule}
className='mt-4'
>
Add First Rule
</Button>
</Box>
)}
</CardContent>
</Card>
</div>
{/* Delete Confirmation Dialog */}
<Dialog
open={deleteDialogOpen}
onClose={cancelDelete}
aria-labelledby='delete-rule-dialog-title'
aria-describedby='delete-rule-dialog-description'
>
<DialogTitle id='delete-rule-dialog-title'>Confirm Delete Rule</DialogTitle>
<DialogContent>
<DialogContentText id='delete-rule-dialog-description'>
Are you sure you want to delete this rule? This action cannot be undone.
{selectedRule && (
<Box className='mt-3 p-3 bg-gray-50 rounded'>
<Typography variant='body2' className='font-medium'>
Rule Details:
</Typography>
<Typography variant='caption' display='block'>
Type: {formatRuleType(selectedRule.rule_type)}
</Typography>
<Typography variant='caption' display='block'>
Reward:{' '}
{formatRewardValue(selectedRule.reward_value, selectedRule.reward_type, selectedRule.reward_subtype)}
</Typography>
</Box>
)}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={cancelDelete} color='primary'>
Cancel
</Button>
<Button onClick={confirmDeleteRule} color='error' variant='contained'>
Delete
</Button>
</DialogActions>
</Dialog>
</>
)
}
export default CampaignDetailContent
@@ -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
@@ -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
}
@@ -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
@@ -110,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',
@@ -140,6 +160,7 @@ const ReportGeneratorComponent: React.FC<ReportGeneratorProps> = ({
onSingleDateChange,
onDateRangeChange,
onGeneratePDF,
onGenerateExcel,
// Optional props dengan default values
maxWidth = '1024px',
@@ -147,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,
@@ -161,6 +184,7 @@ const ReportGeneratorComponent: React.FC<ReportGeneratorProps> = ({
// Loading state
isGenerating = false,
isGeneratingExcel = false,
// Custom labels
labels = {
@@ -175,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()
@@ -271,6 +295,7 @@ const ReportGeneratorComponent: React.FC<ReportGeneratorProps> = ({
</Typography>
}
action={
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<PurpleButton
onClick={onGeneratePDF}
variant='contained'
@@ -279,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 }}
/>
@@ -505,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>
@@ -7,9 +7,6 @@ import type { FC } from 'react'
import { Box, Typography, Divider } from '@mui/material'
import { useTheme } from '@mui/material/styles'
// Component Imports
import Logo from '@core/svg/Logo'
// Type Imports
import type { Outlet } from '@/types/services/outlet'
@@ -39,75 +36,28 @@ const ReportHeader: FC<ReportHeaderProps> = ({
return (
<Box className={className}>
<Box sx={{ p: theme.spacing(8, 8, 6) }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
{/* Left Section - Brand & Outlet Info */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Logo />
<Box>
<Box sx={{ textAlign: 'center' }}>
<Typography
variant='h3'
variant='h1'
component='h1'
sx={{
fontWeight: 700,
color: brandColor || theme.palette.primary.main
}}
>
{brandName}
</Typography>
{outlet?.name && (
<Typography
variant='h5'
component='h2'
sx={{
fontWeight: 600,
mt: 1,
color: theme.palette.text.primary
}}
>
{outlet.name}
</Typography>
)}
{outlet?.address && (
<Typography
variant='body2'
sx={{
mt: 1,
color: theme.palette.text.secondary
}}
>
{outlet.address}
</Typography>
)}
</Box>
</Box>
{/* Right Section - Report Info */}
<Box sx={{ textAlign: 'right' }}>
<Typography
variant='h4'
component='h3'
sx={{
fontWeight: 700,
color: theme.palette.text.primary
color: '#222222'
}}
>
{reportTitle}
</Typography>
{periode && (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 2, mt: 2 }}>
<Typography variant='body2' sx={{ color: theme.palette.text.secondary }}>
<Typography
variant='h5'
sx={{
color: '#222222',
mt: 2
}}
>
{periode}
</Typography>
</Box>
)}
{reportSubtitle && (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 2, mt: 2 }}>
<Typography variant='body2' sx={{ color: theme.palette.text.secondary }}>
{reportSubtitle}
</Typography>
</Box>
)}
</Box>
</Box>
</Box>
<Divider
@@ -19,10 +19,13 @@ const PaymentMethodReport = ({ payments }: { payments: PaymentDataItem[] }) => {
<h2 className='text-xl font-semibold text-gray-900'>Payment Methods</h2>
</div>
<div className='space-y-6'>
{payments && payments.map(method => (
{payments &&
payments.map(method => (
<div key={method.payment_method_id} className='border-b border-gray-200 pb-4 last:border-b-0'>
<div className='flex justify-between items-center mb-2'>
<span className='text-sm font-medium text-gray-900'>{method.payment_method_name}</span>
<span className='text-sm font-medium text-gray-900' translate='no'>
{method.payment_method_name}
</span>
<span className='text-sm text-gray-600'>{method.percentage.toFixed(1)}%</span>
</div>
<ProgressBar