Compare commits

...
51 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
aefril 9806400308 Merge pull request 'api' (#14) from efril into main
Reviewed-on: #14
2025-09-18 09:32:44 +00:00
efrilm 3bfd0b9e19 api 2025-09-18 16:31:58 +07:00
aefril f98b61e527 Merge pull request 'efril' (#13) from efril into main
Reviewed-on: #13
2025-09-18 04:01:32 +00:00
efrilm 97ed613cc6 api 2025-09-18 11:00:00 +07:00
efrilm fdcb0e12f4 api 2025-09-18 10:59:10 +07:00
efrilm 7570e973b8 update vertical menu 2025-09-18 10:55:37 +07:00
efrilm e82173c6e2 Campaign 2025-09-18 03:36:05 +07:00
efrilm 4640d14cb7 Reward 2025-09-18 03:04:06 +07:00
Aditya Siregar 84454e66dc fix base url 2025-09-13 17:09:04 +07:00
aefril 54d8ff3444 Merge pull request 'base url' (#11) from efril into main
Reviewed-on: #11
2025-09-13 09:59:07 +00:00
61 changed files with 8286 additions and 1600 deletions
+2
View File
@@ -39,3 +39,5 @@ next-env.d.ts
# icon generated file # icon generated file
src/assets/iconify-icons/generated-icons.js src/assets/iconify-icons/generated-icons.js
src/assets/iconify-icons/generated-icons.css 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/list": "6.1.15",
"@fullcalendar/react": "6.1.15", "@fullcalendar/react": "6.1.15",
"@fullcalendar/timegrid": "6.1.15", "@fullcalendar/timegrid": "6.1.15",
"@hello-pangea/dnd": "^18.0.1",
"@hookform/resolvers": "3.9.1", "@hookform/resolvers": "3.9.1",
"@iconify/react": "^6.0.1", "@iconify/react": "^6.0.1",
"@mui/lab": "6.0.0-beta.19", "@mui/lab": "6.0.0-beta.19",
@@ -52,19 +53,23 @@
"apexcharts": "3.49.0", "apexcharts": "3.49.0",
"axios": "^1.11.0", "axios": "^1.11.0",
"bootstrap-icons": "1.11.3", "bootstrap-icons": "1.11.3",
"chart.js": "^4.5.1",
"chartjs-plugin-datalabels": "^2.2.0",
"classnames": "2.5.1", "classnames": "2.5.1",
"cmdk": "1.0.4", "cmdk": "1.0.4",
"date-fns": "4.1.0", "date-fns": "4.1.0",
"emoji-mart": "5.6.0", "emoji-mart": "5.6.0",
"exceljs": "^4.4.0",
"fs-extra": "11.2.0", "fs-extra": "11.2.0",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"input-otp": "1.4.1", "input-otp": "1.4.1",
"jspdf": "^3.0.1", "jspdf": "^3.0.1",
"jspdf-autotable": "^5.0.2",
"keen-slider": "6.8.6", "keen-slider": "6.8.6",
"lucide-react": "^0.544.0", "lucide-react": "^0.544.0",
"mapbox-gl": "3.9.0", "mapbox-gl": "3.9.0",
"negotiator": "1.0.0", "negotiator": "1.0.0",
"next": "15.1.2", "next": "^15.1.9",
"react": "18.3.1", "react": "18.3.1",
"react-apexcharts": "1.4.1", "react-apexcharts": "1.4.1",
"react-colorful": "5.6.1", "react-colorful": "5.6.1",
@@ -80,7 +85,8 @@
"react-use": "17.6.0", "react-use": "17.6.0",
"recharts": "2.15.0", "recharts": "2.15.0",
"use-debounce": "^10.0.5", "use-debounce": "^10.0.5",
"valibot": "0.42.1" "valibot": "0.42.1",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@iconify/json": "2.2.286", "@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 useCategoryAnalytics
} from '@/services/queries/analytics' } from '@/services/queries/analytics'
import { useOutletById } from '@/services/queries/outlets' 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 { formatCurrency, formatDate, formatDateDDMMYYYY, formatDatetime } from '@/utils/transform'
import ReportGeneratorComponent from '@/views/dashboards/daily-report/report-generator' import ReportGeneratorComponent from '@/views/dashboards/daily-report/report-generator'
import ReportHeader from '@/views/dashboards/daily-report/report-header' import ReportHeader from '@/views/dashboards/daily-report/report-header'
import React, { useEffect, useRef, useState } from 'react' 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 DailyPOSReport = () => {
const reportRef = useRef<HTMLDivElement | null>(null) const reportRef = useRef<HTMLDivElement | null>(null)
const [isGeneratingExcel, setIsGeneratingExcel] = useState(false)
const [now, setNow] = useState(new Date()) const [now, setNow] = useState(new Date())
const [selectedDate, setSelectedDate] = useState(new Date()) const [selectedDate, setSelectedDate] = useState(new Date())
const [dateRange, setDateRange] = useState({ const [dateRange, setDateRange] = useState({
startDate: new Date(), startDate: new Date(),
endDate: 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 = () => { const getDateParams = () => {
if (filterType === 'single') { if (filterType === 'single') {
return { return {
@@ -41,8 +81,15 @@ const DailyPOSReport = () => {
const dateParams = getDateParams() const dateParams = getDateParams()
const today = new Date()
const sevenDaysAgo = new Date()
sevenDaysAgo.setDate(today.getDate() - 7)
const { data: outlet } = useOutletById() 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: profitLoss } = useProfitLossAnalytics(dateParams)
const { data: products } = useProductSalesAnalytics(dateParams) const { data: products } = useProductSalesAnalytics(dateParams)
const { data: paymentAnalytics } = usePaymentAnalytics(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 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 = { const categorySummary = {
totalRevenue: category?.data?.reduce((sum, item) => sum + (item?.total_revenue || 0), 0) || 0, 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, orderCount: category?.data?.reduce((sum, item) => sum + (item?.order_count || 0), 0) || 0,
@@ -73,12 +112,10 @@ const DailyPOSReport = () => {
setNow(new Date()) setNow(new Date())
}, []) }, [])
// Format date for input field (YYYY-MM-DD)
const formatDateForInput = (date: Date) => { const formatDateForInput = (date: Date) => {
return date.toISOString().split('T')[0] return date.toISOString().split('T')[0]
} }
// Get display text for the report period
const getReportPeriodText = () => { const getReportPeriodText = () => {
if (filterType === 'single') { if (filterType === 'single') {
return `${formatDateDDMMYYYY(selectedDate)} - ${formatDateDDMMYYYY(selectedDate)}` return `${formatDateDDMMYYYY(selectedDate)} - ${formatDateDDMMYYYY(selectedDate)}`
@@ -87,98 +124,107 @@ const DailyPOSReport = () => {
} }
} }
// Get report title based on filter type
const getReportTitle = () => { const getReportTitle = () => {
if (filterType === 'single') { return 'Laporan Transaksi'
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 handleGeneratePDF = async () => {
const reportElement = reportRef.current setIsGeneratingPDF(true)
try { try {
// Import jsPDF dan html2canvas await generatePDF({
const jsPDF = (await import('jspdf')).default reportRef,
const html2canvas = (await import('html2canvas')).default outlet,
profitLoss,
// Optimized canvas capture dengan scale lebih rendah products,
const canvas = await html2canvas(reportElement!, { paymentAnalytics,
scale: 1.5, // Reduced from 2 to 1.5 category,
useCORS: true, productSummary,
allowTaint: true, categorySummary,
backgroundColor: '#ffffff', filterType,
logging: false, // Disable logging for performance selectedDate,
removeContainer: true, // Clean up after capture dateRange,
imageTimeout: 0, // No timeout for image loading now
height: reportElement!.scrollHeight,
width: reportElement!.scrollWidth
}) })
// 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) { } catch (error) {
console.error('Error generating PDF:', 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 ( return (
<div className='min-h-screen'> <div className='min-h-screen'>
{/* Control Panel */} <LoadingOverlay isVisible={isGeneratingPDF} message='Membuat PDF Laporan...' />
<ReportGeneratorComponent <ReportGeneratorComponent
// Props wajib
reportTitle='Laporan Penjualan' reportTitle='Laporan Penjualan'
filterType={filterType} filterType={filterType}
selectedDate={selectedDate} selectedDate={selectedDate}
@@ -187,161 +233,99 @@ const DailyPOSReport = () => {
onSingleDateChange={setSelectedDate} onSingleDateChange={setSelectedDate}
onDateRangeChange={setDateRange} onDateRangeChange={setDateRange}
onGeneratePDF={handleGeneratePDF} onGeneratePDF={handleGeneratePDF}
// Props opsional onGenerateExcel={handleGenerateExcel}
// isGenerating={isGenerating} isGeneratingExcel={isGeneratingExcel}
// customQuickActions={customQuickActions}
// labels={customLabels}
/> />
{/* Report Template */}
<div ref={reportRef} className='max-w-4xl mx-auto bg-white min-h-[297mm]' style={{ width: '210mm' }}> <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 */}
<ReportHeader <div className='report-header-section'>
outlet={outlet} <ReportHeader
reportTitle={getReportTitle()} outlet={outlet}
reportSubtitle='Laporan Bulanan' reportTitle={getReportTitle()}
brandName='Apskel' reportSubtitle='Laporan Bulanan'
brandColor='#36175e' brandName='Apskel'
periode={getReportPeriodText()} brandColor='#36175e'
/> periode={getReportPeriodText()}
/>
</div>
{/* Performance Summary */} {/* Performance Summary */}
<div className='p-8'> <div className='p-8'>
<h3 className='text-xl font-bold mb-6' style={{ color: '#36175e' }}> <h3 className='text-3xl font-bold mb-8' style={{ color: '#36175e' }}>
1. Ringkasan Ringkasan
</h3> </h3>
<div className='grid grid-cols-2 gap-6'> <div className='space-y-5'>
<div className='space-y-4'> <div className='flex justify-between items-center py-3 border-b border-gray-200'>
<div className='flex justify-between items-center py-2 border-b border-gray-200'> <span className='text-xl text-gray-700'>Total Penjualan</span>
<span className='text-gray-700'>Total Penjualan (termasuk rasik)</span> <span className='text-xl font-semibold text-gray-800'>
<span className='font-semibold text-gray-800'> {formatCurrency(profitLoss?.summary.total_revenue ?? 0)}
{formatCurrency(profitLoss?.summary.total_revenue ?? 0)} </span>
</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
)}
%
</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)}%
</span>
</div>
</div> </div>
<div className='flex justify-between items-center py-3 border-b border-gray-200'>
<div className='space-y-4'> <span className='text-xl text-gray-700'>Total Diskon</span>
<div className='flex justify-between items-center py-2 border-b border-gray-200'> <span className='text-xl font-semibold text-gray-800'>
<span className='text-gray-700'>Biaya lain²</span> {formatCurrency(profitLoss?.summary.total_discount ?? 0)}
<span className='font-semibold text-gray-800'> </span>
{formatCurrency(profitLoss?.summary.total_tax ?? 0)} |{' '} </div>
{(((profitLoss?.summary.total_tax ?? 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 Pajak</span>
</span> <span className='text-xl font-semibold text-gray-800'>
</div> {formatCurrency(profitLoss?.summary.total_tax ?? 0)}
<div className='flex justify-between items-center py-2 border-b-2 border-gray-300'> </span>
<span className='text-lg font-bold text-blue-800'>Laba/Rugi</span> </div>
<span className='text-lg font-bold text-blue-800'> <div className='flex justify-between items-center py-3 border-b border-gray-200'>
{formatCurrency(profitLoss?.summary.net_profit ?? 0)} |{' '} <span className='text-xl text-gray-700'>Service Charge</span>
{(profitLoss?.summary.net_profit_margin ?? 0).toFixed(0)}% <span className='text-xl font-semibold text-gray-800'>{formatCurrency(0)}</span>
</span> </div>
</div> <div className='flex justify-between items-center py-3 border-b-2 border-gray-300'>
<span className='text-xl text-gray-700 font-bold'>Total</span>
<span className='text-xl font-bold text-gray-800'>
{formatCurrency(profitLoss?.summary.total_revenue ?? 0)}
</span>
</div> </div>
</div> </div>
</div> </div>
{/* Profit Loss Product Table */} {/* Invoice */}
<div className='px-8 pb-8'> <div className='px-8 pb-8'>
<h3 className='text-lg font-medium mb-6' style={{ color: '#36175e' }}> <h3 className='text-3xl font-bold mb-8' style={{ color: '#36175e' }}>
Laba Rugi Per Produk Invoice
</h3> </h3>
<div className='bg-gray-50 rounded-lg border border-gray-200 overflow-hidden'> <div className='space-y-5'>
<table className='w-full'> <div className='flex justify-between items-center py-3 border-b border-gray-200'>
<thead> <span className='text-xl text-gray-700'>Total Invoice</span>
<tr style={{ backgroundColor: '#36175e' }} className='text-white'> <span className='text-xl font-semibold text-gray-800'>{profitLoss?.summary.total_orders ?? 0}</span>
<th className='text-left p-3 font-semibold'>Produk</th> </div>
<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> </div>
</div> </div>
{/* Payment Method Summary */} {/* Payment Method Summary */}
<div className='px-8 pb-8'> <div className='px-8 pb-8'>
<h3 className='text-xl font-bold mb-6' style={{ color: '#36175e' }}> <h3 className='text-3xl font-bold mb-8' style={{ color: '#36175e' }}>
2. Ringkasan Metode Pembayaran Ringkasan Metode Pembayaran
</h3> </h3>
<div className='bg-gray-50 rounded-lg border border-gray-200 overflow-hidden'> <div className='bg-gray-50 rounded-lg border border-gray-200 overflow-hidden'>
<table className='w-full'> <table className='w-full'>
<thead> <thead>
<tr style={{ backgroundColor: '#36175e' }} className='text-white'> <tr className='text-gray-800 border-b-2 border-gray-300'>
<th className='text-left p-3 font-semibold'>Metode Pembayaran</th> <th className='text-left text-xl p-4 font-semibold'>Metode Pembayaran</th>
<th className='text-center p-3 font-semibold'>Tipe</th> <th className='text-center text-xl p-4 font-semibold'>Tipe</th>
<th className='text-center p-3 font-semibold'>Jumlah Order</th> <th className='text-center text-xl p-4 font-semibold'>Jumlah Order</th>
<th className='text-right p-3 font-semibold'>Total Amount</th> <th className='text-right text-xl p-4 font-semibold'>Total Amount</th>
<th className='text-center p-3 font-semibold'>Persentase</th> <th className='text-center text-xl p-4 font-semibold'>Persentase</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{paymentAnalytics?.data?.map((payment, index) => ( {paymentAnalytics?.data?.map((payment, index) => (
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}> <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-4 text-xl font-medium text-gray-800'>{payment.payment_method_name}</td>
<td className='p-3 text-center'> <td className='p-4 text-center'>
<span <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' payment.payment_method_type === 'cash'
? 'bg-green-100 text-green-800' ? 'bg-green-100 text-green-800'
: 'bg-blue-100 text-blue-800' : 'bg-blue-100 text-blue-800'
@@ -350,123 +334,240 @@ const DailyPOSReport = () => {
{payment.payment_method_type.toUpperCase()} {payment.payment_method_type.toUpperCase()}
</span> </span>
</td> </td>
<td className='p-3 text-center text-gray-700'>{payment.order_count}</td> <td className='p-4 text-xl 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-right font-semibold text-gray-800'>
{formatCurrency(payment.total_amount)} {formatCurrency(payment.total_amount)}
</td> </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)}% {(payment.percentage ?? 0).toFixed(1)}%
</td> </td>
</tr> </tr>
)) || []} )) || []}
</tbody> </tbody>
<tfoot> <tfoot>
<tr style={{ backgroundColor: '#36175e' }} className='text-white'> <tr className='text-gray-800 border-t-2 border-gray-300'>
<td className='p-3 font-bold'>TOTAL</td> <td className='p-4 text-xl font-bold'>TOTAL</td>
<td className='p-3'></td> <td className='p-4'></td>
<td className='p-3 text-center font-bold'>{paymentAnalytics?.summary.total_orders ?? 0}</td> <td className='p-4 text-xl text-center font-bold'>{paymentAnalytics?.summary.total_orders ?? 0}</td>
<td className='p-3 text-right font-bold'> <td className='p-4 text-xl text-right font-bold'>
{formatCurrency(paymentAnalytics?.summary.total_amount ?? 0)} {formatCurrency(paymentAnalytics?.summary.total_amount ?? 0)}
</td> </td>
<td className='p-3 text-center font-bold'></td> <td className='p-4 text-center font-bold'></td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
</div> </div>
</div> </div>
{/* Category Summary */} {/* Category Summary — +3 kolom baru */}
<div className='px-8 pb-8'> <div className='px-8 pb-8'>
<h3 className='text-xl font-bold mb-6' style={{ color: '#36175e' }}> <h3 className='text-3xl font-bold mb-8' style={{ color: '#36175e' }}>
2. Ringkasan Kategori Ringkasan Kategori
</h3> </h3>
<div className='bg-gray-50 rounded-lg border border-gray-200 overflow-hidden'> <div className='bg-gray-50 rounded-lg border border-gray-200 overflow-hidden'>
<table className='w-full'> <table className='w-full'>
<thead> <thead>
<tr style={{ backgroundColor: '#36175e' }} className='text-white'> <tr className='text-gray-800 border-b-2 border-gray-300'>
<th className='text-left p-3 font-semibold'>Nama</th> <th className='text-left text-xl p-4 font-semibold'>Nama</th>
<th className='text-center p-3 font-semibold'>Total Produk</th> <th className='text-center text-xl p-4 font-semibold'>Qty</th>
<th className='text-center p-3 font-semibold'>Qty</th> <th className='text-right text-xl p-4 font-semibold'>Pendapatan</th>
<th className='text-right p-3 font-semibold'>Jumlah Order</th> <th className='text-center text-xl p-4 font-semibold'>% Std HPP</th>
<th className='text-center p-3 font-semibold'>Pendapatan</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> </tr>
</thead> </thead>
<tbody> <tbody>
{category?.data?.map((c, index) => ( {category?.data?.map((c, index) => {
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}> const stdHpp = DUMMY_STD_HPP
<td className='p-3 font-medium text-gray-800'>{c.category_name}</td> const realHpp = DUMMY_REAL_HPP
<td className='p-3 text-center text-gray-700'>{c.product_count}</td> const status = getHppStatus(stdHpp, realHpp)
<td className='p-3 text-center text-gray-700'>{c.total_quantity}</td> return (
<td className='p-3 text-center text-gray-700'>{c.order_count}</td> <tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<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>
{formatCurrency(c.total_revenue)} <td className='p-4 text-xl text-center text-gray-700'>{c.total_quantity}</td>
<td className='p-4 text-xl text-right font-semibold' style={{ color: '#36175e' }}>
{formatCurrency(c.total_revenue)}
</td>
<td className='p-4 text-xl text-center text-gray-700'>{stdHpp}%</td>
<td className='p-4 text-xl text-center text-gray-700'>{realHpp}%</td>
<td className='p-4 text-center'>
<StatusBadge status={status} />
</td>
</tr>
)
}) || []}
</tbody>
<tfoot>
<tr className='text-gray-800 border-t-2 border-gray-300'>
<td className='p-4 text-xl font-bold'>TOTAL</td>
<td className='p-4 text-xl text-center font-bold'>{categorySummary?.totalQuantity ?? 0}</td>
<td className='p-4 text-xl text-right font-bold'>
{formatCurrency(categorySummary?.totalRevenue ?? 0)}
</td>
<td className='p-4'></td>
<td className='p-4'></td>
<td className='p-4'></td>
</tr>
</tfoot>
</table>
</div>
</div>
{/* Product Summary — +3 kolom baru */}
<div className='px-8 pb-8'>
<h3 className='text-3xl font-bold mb-8' style={{ color: '#36175e' }}>
Ringkasan Item Per Kategori
</h3>
<div className='space-y-12'>
{Object.keys(groupedProducts)
.sort((a, b) => {
const productsA = groupedProducts[a]
const productsB = groupedProducts[b]
const orderA = productsA[0]?.category_order ?? 999
const orderB = productsB[0]?.category_order ?? 999
return orderA - orderB
})
.map((categoryName, catIndex) => {
const categoryProducts = groupedProducts[categoryName].sort((a, b) => {
const skuA = a.product_sku || ''
const skuB = b.product_sku || ''
return skuA.localeCompare(skuB)
})
const categoryTotalQty = categoryProducts.reduce((sum, item) => sum + (item.quantity_sold || 0), 0)
const categoryTotalRevenue = categoryProducts.reduce((sum, item) => sum + (item.revenue || 0), 0)
return (
<div key={categoryName} style={{ display: 'block', marginBottom: '70px' }}>
<h4
className='text-2xl font-bold mb-0 px-4 py-4 bg-gray-100 rounded-t-lg'
style={{ color: '#36175e' }}
>
{categoryName.toUpperCase()}
</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 className='text-gray-800 border-b-2 border-gray-300 bg-gray-100'>
<th className='text-left text-xl p-5 font-semibold border-r border-gray-300'>Produk</th>
<th className='text-center text-xl p-5 font-semibold border-r border-gray-300'>Qty</th>
<th className='text-right text-xl p-5 font-semibold border-r border-gray-300'>
Pendapatan
</th>
<th className='text-center text-xl p-5 font-semibold border-r border-gray-300'>
% Std HPP
</th>
<th className='text-center text-xl p-5 font-semibold border-r border-gray-300'>
% Real HPP
</th>
<th className='text-center text-xl p-5 font-semibold'>Status</th>
</tr>
</thead>
<tbody>
{categoryProducts.map((item, index) => {
const stdHpp = DUMMY_STD_HPP
const realHpp = DUMMY_REAL_HPP
const status = getHppStatus(stdHpp, realHpp)
return (
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className='p-5 text-xl font-medium text-gray-800 border-r border-gray-200'>
{item.product_name}
</td>
<td className='p-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 className='bg-gray-200 border-t-2 border-gray-400'>
<td className='p-5 text-xl font-bold text-gray-800 border-r border-gray-400'>
Subtotal {categoryName}
</td>
<td className='p-5 text-xl text-center font-bold text-gray-800 border-r border-gray-400'>
{categoryTotalQty}
</td>
<td className='p-5 text-xl text-right font-bold text-gray-800 border-r border-gray-400'>
{formatCurrency(categoryTotalRevenue)}
</td>
<td className='p-5 border-r border-gray-400'></td>
<td className='p-5 border-r border-gray-400'></td>
<td className='p-5'></td>
</tr>
</tfoot>
</table>
</div>
</div>
)
})}
{/* Grand Total */}
<div className='bg-purple-50 rounded-lg border-2 border-purple-300 mt-6'>
<table className='w-full' style={{ borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '28%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '22%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '14%' }} />
</colgroup>
<tfoot>
<tr className='text-gray-800'>
<td className='p-5 text-2xl font-bold border-r-2 border-purple-300' style={{ color: '#36175e' }}>
TOTAL KESELURUHAN
</td> </td>
<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> </tr>
)) || []} </tfoot>
</tbody> </table>
<tfoot> </div>
<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>
</tfoot>
</table>
</div> </div>
</div> </div>
{/* Transaction Summary */}
<div className='px-8 pb-8'>
<h3 className='text-xl font-bold mb-6' style={{ color: '#36175e' }}>
4. Ringkasan Item
</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-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>
</thead>
<tbody>
{products?.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 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>
</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>
</tfoot>
</table>
</div>
</div>
{/* Profit Loss Product Table */}
{/* Footer */} {/* Footer */}
<div className='px-8 py-6 border-t-2 border-gray-200 mt-8'> <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>© 2025 Apskel - Sistem POS Terpadu</p>
<p></p> <p></p>
<p>Dicetak pada: {now.toLocaleDateString('id-ID')}</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 { useState } from 'react'
import Loading from '../../../../../../components/layout/shared/Loading' import Loading from '../../../../../../components/layout/shared/Loading'
import { useDashboardAnalytics } from '../../../../../../services/queries/analytics' 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 OrdersReport from '../../../../../../views/dashboards/orders/OrdersReport'
import PaymentMethodReport from '../../../../../../views/dashboards/payment-methods/PaymentMethodReport' import PaymentMethodReport from '../../../../../../views/dashboards/payment-methods/PaymentMethodReport'
import ProductSales from '../../../../../../views/dashboards/products/ProductSales' import ProductSales from '../../../../../../views/dashboards/products/ProductSales'
@@ -115,14 +120,14 @@ const DashboardOverview = () => {
<MetricCard <MetricCard
iconClass='tabler-cash' iconClass='tabler-cash'
title='Total Sales' title='Total Sales'
value={formatShortCurrency(salesData.overview.total_sales)} value={formatCurrency(salesData.overview.total_sales)}
bgColor='bg-green-500' bgColor='bg-green-500'
isCurrency={true} isCurrency={true}
/> />
<MetricCard <MetricCard
iconClass='tabler-trending-up' iconClass='tabler-trending-up'
title='Average Order Value' title='Average Order Value'
value={formatShortCurrency(salesData.overview.average_order_value)} value={formatCurrency(salesData.overview.average_order_value)}
bgColor='bg-purple-500' bgColor='bg-purple-500'
isCurrency={true} isCurrency={true}
/> />
@@ -162,21 +162,21 @@ const DashboardProfitloss = () => {
<MetricCard <MetricCard
iconClass='tabler-currency-dollar' iconClass='tabler-currency-dollar'
title='Total Revenue' title='Total Revenue'
value={formatShortCurrency(profitData.summary.total_revenue)} value={formatCurrency(profitData.summary.total_revenue)}
bgColor='bg-green-500' bgColor='bg-green-500'
isCurrency={true} isCurrency={true}
/> />
<MetricCard <MetricCard
iconClass='tabler-receipt' iconClass='tabler-receipt'
title='Total Cost' title='Total Cost'
value={formatShortCurrency(profitData.summary.total_cost)} value={formatCurrency(profitData.summary.total_cost)}
bgColor='bg-red-500' bgColor='bg-red-500'
isCurrency={true} isCurrency={true}
/> />
<MetricCard <MetricCard
iconClass='tabler-trending-up' iconClass='tabler-trending-up'
title='Gross Profit' title='Gross Profit'
value={formatShortCurrency(profitData.summary.gross_profit)} value={formatCurrency(profitData.summary.gross_profit)}
subtitle={`Margin: ${formatPercentage(profitData.summary.gross_profit_margin)}`} subtitle={`Margin: ${formatPercentage(profitData.summary.gross_profit_margin)}`}
bgColor='bg-blue-500' bgColor='bg-blue-500'
isNegative={profitData.summary.gross_profit < 0} isNegative={profitData.summary.gross_profit < 0}
@@ -186,7 +186,7 @@ const DashboardProfitloss = () => {
iconClass='tabler-percentage' iconClass='tabler-percentage'
title='Profitability Ratio' title='Profitability Ratio'
value={formatPercentage(profitData.summary.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' bgColor='bg-purple-500'
/> />
</div> </div>
@@ -199,7 +199,7 @@ const DashboardProfitloss = () => {
<h3 className='text-lg font-semibold text-gray-900'>Net Profit</h3> <h3 className='text-lg font-semibold text-gray-900'>Net Profit</h3>
</div> </div>
<p className='text-3xl font-bold text-green-600 mb-2'> <p className='text-3xl font-bold text-green-600 mb-2'>
Rp {formatShortCurrency(profitData.summary.net_profit)} {formatCurrency(profitData.summary.net_profit)}
</p> </p>
<p className='text-sm text-gray-600'> <p className='text-sm text-gray-600'>
Margin: {formatPercentage(profitData.summary.net_profit_margin)} Margin: {formatPercentage(profitData.summary.net_profit_margin)}
+465
View File
@@ -0,0 +1,465 @@
'use client'
// React Imports
import { useEffect, useState } from 'react'
// MUI Imports
import type { BoxProps } from '@mui/material/Box'
import Button from '@mui/material/Button'
import IconButton from '@mui/material/IconButton'
import List from '@mui/material/List'
import ListItem from '@mui/material/ListItem'
import Typography from '@mui/material/Typography'
import Chip from '@mui/material/Chip'
import LinearProgress from '@mui/material/LinearProgress'
import { styled } from '@mui/material/styles'
// Third-party Imports
import { useDropzone } from 'react-dropzone'
// Component Imports
import Link from '@components/Link'
import CustomAvatar from '@core/components/mui/Avatar'
// Styled Component Imports
import AppReactDropzone from '@/libs/styles/AppReactDropzone'
type FileProp = {
name: string
type: string
size: number
}
type UploadedImage = {
id: string
url: string
name: string
size: number
}
type UploadProgress = {
[fileId: string]: number
}
interface MultipleImageUploadProps {
// Required props
onUpload: (files: File[]) => Promise<string[]> | string[] // Returns array of image URLs
onSingleUpload?: (file: File) => Promise<string> | string // For individual file upload
// Optional customization props
title?: string | null
currentImages?: UploadedImage[]
onImagesChange?: (images: UploadedImage[]) => void
onImageRemove?: (imageId: string) => void
// Upload state
isUploading?: boolean
uploadProgress?: UploadProgress
// Limits
maxFiles?: number
maxFileSize?: number // in bytes
acceptedFileTypes?: string[]
// UI customization
showUrlOption?: boolean
uploadButtonText?: string
browseButtonText?: string
dragDropText?: string
replaceText?: string
maxFilesText?: string
// Style customization
className?: string
disabled?: boolean
// Upload modes
uploadMode?: 'batch' | 'individual' // batch: upload all at once, individual: upload one by one
}
// Styled Dropzone Component
const Dropzone = styled(AppReactDropzone)<BoxProps>(({ theme }) => ({
'& .dropzone': {
minHeight: 'unset',
padding: theme.spacing(12),
[theme.breakpoints.down('sm')]: {
paddingInline: theme.spacing(5)
},
'&+.MuiList-root .MuiListItem-root .file-name': {
fontWeight: theme.typography.body1.fontWeight
}
}
}))
const MultipleImageUpload: React.FC<MultipleImageUploadProps> = ({
onUpload,
onSingleUpload,
title = null,
currentImages = [],
onImagesChange,
onImageRemove,
isUploading = false,
uploadProgress = {},
maxFiles = 10,
maxFileSize = 5 * 1024 * 1024, // 5MB default
acceptedFileTypes = ['image/*'],
showUrlOption = true,
uploadButtonText = 'Upload All',
browseButtonText = 'Browse Images',
dragDropText = 'Drag and Drop Your Images Here.',
replaceText = 'Drop Images to Add More',
maxFilesText = 'Maximum {max} files allowed',
className = '',
disabled = false,
uploadMode = 'batch'
}) => {
// States
const [files, setFiles] = useState<File[]>([])
const [error, setError] = useState<string>('')
const [individualUploading, setIndividualUploading] = useState<Set<string>>(new Set())
const handleBatchUpload = async () => {
if (!files.length) return
try {
setError('')
const imageUrls = await onUpload(files)
if (Array.isArray(imageUrls)) {
const newImages: UploadedImage[] = files.map((file, index) => ({
id: `${Date.now()}-${index}`,
url: imageUrls[index],
name: file.name,
size: file.size
}))
const updatedImages = [...currentImages, ...newImages]
onImagesChange?.(updatedImages)
setFiles([]) // Clear files after successful upload
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Upload failed')
}
}
const handleIndividualUpload = async (file: File, fileIndex: number) => {
if (!onSingleUpload) return
const fileId = `${file.name}-${fileIndex}`
setIndividualUploading(prev => new Set(prev).add(fileId))
try {
setError('')
const imageUrl = await onSingleUpload(file)
if (typeof imageUrl === 'string') {
const newImage: UploadedImage = {
id: `${Date.now()}-${fileIndex}`,
url: imageUrl,
name: file.name,
size: file.size
}
const updatedImages = [...currentImages, newImage]
onImagesChange?.(updatedImages)
// Remove uploaded file from pending files
setFiles(prev => prev.filter((_, index) => index !== fileIndex))
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Upload failed')
} finally {
setIndividualUploading(prev => {
const newSet = new Set(prev)
newSet.delete(fileId)
return newSet
})
}
}
// Hooks
const { getRootProps, getInputProps } = useDropzone({
onDrop: (acceptedFiles: File[]) => {
setError('')
if (acceptedFiles.length === 0) return
const totalFiles = currentImages.length + files.length + acceptedFiles.length
if (totalFiles > maxFiles) {
setError(`Cannot upload more than ${maxFiles} files. Current: ${currentImages.length + files.length}`)
return
}
// Validate file sizes
const invalidFiles = acceptedFiles.filter(file => file.size > maxFileSize)
if (invalidFiles.length > 0) {
setError(`Some files exceed ${formatFileSize(maxFileSize)} limit`)
return
}
// Add to existing files
setFiles(prev => [...prev, ...acceptedFiles])
},
accept: acceptedFileTypes.reduce((acc, type) => ({ ...acc, [type]: [] }), {}),
disabled: disabled || isUploading,
multiple: true
})
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const renderFilePreview = (file: FileProp) => {
if (file.type.startsWith('image')) {
return (
<img
width={38}
height={38}
alt={file.name}
src={URL.createObjectURL(file as any)}
className='rounded object-cover'
/>
)
} else {
return <i className='tabler-file-description' />
}
}
const handleRemoveFile = (fileIndex: number) => {
setFiles(prev => prev.filter((_, index) => index !== fileIndex))
setError('')
}
const handleRemoveCurrentImage = (imageId: string) => {
onImageRemove?.(imageId)
}
const handleRemoveAllFiles = () => {
setFiles([])
setError('')
}
const isIndividualUploading = (file: File, index: number) => {
const fileId = `${file.name}-${index}`
return individualUploading.has(fileId)
}
const fileList = files.map((file: File, index: number) => {
const isFileUploading = isIndividualUploading(file, index)
const progress = uploadProgress[`${file.name}-${index}`] || 0
return (
<ListItem key={`${file.name}-${index}`} className='pis-4 plb-3'>
<div className='file-details flex-1'>
<div className='file-preview'>{renderFilePreview(file)}</div>
<div className='flex-1'>
<Typography className='file-name font-medium' color='text.primary'>
{file.name}
</Typography>
<Typography className='file-size' variant='body2'>
{formatFileSize(file.size)}
</Typography>
{isFileUploading && progress > 0 && (
<LinearProgress variant='determinate' value={progress} className='mt-1' />
)}
</div>
</div>
<div className='flex items-center gap-2'>
{uploadMode === 'individual' && onSingleUpload && (
<Button
variant='outlined'
size='small'
onClick={() => handleIndividualUpload(file, index)}
disabled={isUploading || isFileUploading}
>
{isFileUploading ? 'Uploading...' : 'Upload'}
</Button>
)}
<IconButton onClick={() => handleRemoveFile(index)} disabled={isUploading || isFileUploading}>
<i className='tabler-x text-xl' />
</IconButton>
</div>
</ListItem>
)
})
const currentImagesList = currentImages.map(image => (
<ListItem key={image.id} className='pis-4 plb-3'>
<div className='file-details flex-1'>
<div className='file-preview'>
<img width={38} height={38} alt={image.name} src={image.url} className='rounded object-cover' />
</div>
<div className='flex-1'>
<Typography className='file-name font-medium' color='text.primary'>
{image.name}
</Typography>
<Typography className='file-size' variant='body2'>
{formatFileSize(image.size)}
</Typography>
</div>
</div>
<div className='flex items-center gap-2'>
<Chip label='Uploaded' color='success' size='small' />
{onImageRemove && (
<IconButton onClick={() => handleRemoveCurrentImage(image.id)} color='error' disabled={isUploading}>
<i className='tabler-x text-xl' />
</IconButton>
)}
</div>
</ListItem>
))
return (
<Dropzone className={className}>
{/* Conditional title and URL option header */}
{title && (
<div className='flex justify-between items-center mb-4'>
<Typography variant='h6' component='h2'>
{title}
</Typography>
{showUrlOption && (
<Typography component={Link} color='primary.main' className='font-medium'>
Add media from URL
</Typography>
)}
</div>
)}
{/* File limits info */}
<div className='flex justify-between items-center mb-4'>
<Typography variant='body2' color='text.secondary'>
{maxFilesText.replace('{max}', maxFiles.toString())}
</Typography>
<Typography variant='body2' color='text.secondary'>
{currentImages.length + files.length} / {maxFiles} files
</Typography>
</div>
<div {...getRootProps({ className: 'dropzone' })}>
<input {...getInputProps()} />
<div className='flex items-center flex-col gap-2 text-center'>
<CustomAvatar variant='rounded' skin='light' color='secondary'>
<i className='tabler-upload' />
</CustomAvatar>
<Typography variant='h4'>
{currentImages.length > 0 || files.length > 0 ? replaceText : dragDropText}
</Typography>
<Typography color='text.disabled'>or</Typography>
<Button variant='tonal' size='small' disabled={disabled || isUploading}>
{browseButtonText}
</Button>
</div>
</div>
{/* Error Message */}
{error && (
<Typography color='error' variant='body2' className='mt-2 text-center'>
{error}
</Typography>
)}
{/* Current uploaded images */}
{currentImages.length > 0 && (
<div className='mt-4'>
<Typography variant='subtitle2' className='mb-2'>
Uploaded Images ({currentImages.length}):
</Typography>
<List>{currentImagesList}</List>
</div>
)}
{/* Pending files list and upload buttons */}
{files.length > 0 && (
<div className='mt-4'>
<Typography variant='subtitle2' className='mb-2'>
Pending Files ({files.length}):
</Typography>
<List>{fileList}</List>
<div className='buttons flex gap-2 mt-3'>
<Button color='error' variant='tonal' onClick={handleRemoveAllFiles} disabled={isUploading}>
Remove All
</Button>
{uploadMode === 'batch' && (
<Button variant='contained' onClick={handleBatchUpload} disabled={isUploading || files.length === 0}>
{isUploading ? 'Uploading...' : `${uploadButtonText} (${files.length})`}
</Button>
)}
</div>
</div>
)}
</Dropzone>
)
}
export default MultipleImageUpload
// ===== USAGE EXAMPLES =====
// 1. Batch upload mode (upload all files at once)
// const [images, setImages] = useState<UploadedImage[]>([])
//
// <MultipleImageUpload
// title="Product Images"
// onUpload={handleBatchUpload}
// currentImages={images}
// onImagesChange={setImages}
// onImageRemove={(id) => setImages(prev => prev.filter(img => img.id !== id))}
// maxFiles={5}
// uploadMode="batch"
// />
// 2. Individual upload mode (upload files one by one)
// <MultipleImageUpload
// title="Gallery Images"
// onUpload={handleBatchUpload}
// onSingleUpload={handleSingleUpload}
// currentImages={images}
// onImagesChange={setImages}
// onImageRemove={(id) => setImages(prev => prev.filter(img => img.id !== id))}
// maxFiles={10}
// uploadMode="individual"
// uploadProgress={uploadProgress}
// />
// 3. Without title, custom limits
// <MultipleImageUpload
// title={null}
// onUpload={handleBatchUpload}
// currentImages={images}
// onImagesChange={setImages}
// maxFiles={3}
// maxFileSize={2 * 1024 * 1024} // 2MB
// acceptedFileTypes={['image/jpeg', 'image/png']}
// />
// 4. Example upload handlers
// const handleBatchUpload = async (files: File[]): Promise<string[]> => {
// const formData = new FormData()
// files.forEach(file => formData.append('images', file))
//
// const response = await fetch('/api/upload-multiple', {
// method: 'POST',
// body: formData
// })
//
// const result = await response.json()
// return result.urls // Array of uploaded image URLs
// }
//
// const handleSingleUpload = async (file: File): Promise<string> => {
// const formData = new FormData()
// formData.append('image', file)
//
// const response = await fetch('/api/upload-single', {
// method: 'POST',
// body: formData
// })
//
// const result = await response.json()
// return result.url // Single uploaded image URL
// }
@@ -154,13 +154,13 @@ const VerticalMenu = ({ dictionary, scrollMenu }: Props) => {
{dictionary['navigation'].reports} {dictionary['navigation'].reports}
</MenuItem> </MenuItem>
<SubMenu label={dictionary['navigation'].marketing} icon={<i className='tabler-shopping-cart' />}> <SubMenu label={dictionary['navigation'].marketing} icon={<i className='tabler-shopping-cart' />}>
<MenuItem href={`/${locale}/apps/marketing/loyalty`}>{dictionary['navigation'].loyalty}</MenuItem> {/* <MenuItem href={`/${locale}/apps/marketing/loyalty`}>{dictionary['navigation'].loyalty}</MenuItem> */}
<MenuItem href={`/${locale}/apps/marketing/reward`}>{dictionary['navigation'].reward}</MenuItem> <MenuItem href={`/${locale}/apps/marketing/reward`}>{dictionary['navigation'].reward}</MenuItem>
<SubMenu label={dictionary['navigation'].gamification}> {/* <SubMenu label={dictionary['navigation'].gamification}>
<MenuItem href={`/${locale}/apps/marketing/gamification/wheel-spin`}> <MenuItem href={`/${locale}/apps/marketing/gamification/wheel-spin`}>
{dictionary['navigation'].wheel_spin} {dictionary['navigation'].wheel_spin}
</MenuItem> </MenuItem>
</SubMenu> </SubMenu> */}
<SubMenu label={dictionary['navigation'].games}> <SubMenu label={dictionary['navigation'].games}>
<MenuItem href={`/${locale}/apps/marketing/games/list`}>{dictionary['navigation'].list}</MenuItem> <MenuItem href={`/${locale}/apps/marketing/games/list`}>{dictionary['navigation'].list}</MenuItem>
<MenuItem href={`/${locale}/apps/marketing/games/game-prizes`}> <MenuItem href={`/${locale}/apps/marketing/games/game-prizes`}>
@@ -168,11 +168,12 @@ const VerticalMenu = ({ dictionary, scrollMenu }: Props) => {
</MenuItem> </MenuItem>
</SubMenu> </SubMenu>
<MenuItem href={`/${locale}/apps/marketing/campaign`}>{dictionary['navigation'].campaign}</MenuItem> <MenuItem href={`/${locale}/apps/marketing/campaign`}>{dictionary['navigation'].campaign}</MenuItem>
<MenuItem href={`/${locale}/apps/marketing/customer-analytics`}> {/* <MenuItem href={`/${locale}/apps/marketing/customer-analytics`}>
{dictionary['navigation'].customer_analytics} {dictionary['navigation'].customer_analytics}
</MenuItem> </MenuItem> */}
<MenuItem href={`/${locale}/apps/marketing/voucher`}>{dictionary['navigation'].voucher}</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/tier`}>{dictionary['navigation'].tiers_text}</MenuItem>
{/* <MenuItem href={`/${locale}/apps/marketing/discount`}>{dictionary['navigation'].discount}</MenuItem> */}
</SubMenu> </SubMenu>
<SubMenu label={dictionary['navigation'].inventory} icon={<i className='tabler-salad' />}> <SubMenu label={dictionary['navigation'].inventory} icon={<i className='tabler-salad' />}>
<SubMenu label={dictionary['navigation'].products}> <SubMenu label={dictionary['navigation'].products}>
+2 -1
View File
@@ -137,6 +137,7 @@
"voucher": "Voucher", "voucher": "Voucher",
"tiers_text": "Tiers", "tiers_text": "Tiers",
"games": "Games", "games": "Games",
"game_prizes": "Game Prizes" "game_prizes": "Game Prizes",
"discount": "Discount"
} }
} }
+2 -1
View File
@@ -137,6 +137,7 @@
"voucher": "Vocher", "voucher": "Vocher",
"tiers_text": "Tiers", "tiers_text": "Tiers",
"games": "Permainan", "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 organizationReducer from '@/redux-store/slices/organization'
import userReducer from '@/redux-store/slices/user' import userReducer from '@/redux-store/slices/user'
import vendorReducer from '@/redux-store/slices/vendor' import vendorReducer from '@/redux-store/slices/vendor'
import discountReducer from '@/redux-store/slices/discount'
export const store = configureStore({ export const store = configureStore({
reducer: { reducer: {
@@ -21,7 +22,8 @@ export const store = configureStore({
productRecipeReducer, productRecipeReducer,
organizationReducer, organizationReducer,
userReducer, userReducer,
vendorReducer vendorReducer,
discountReducer
}, },
middleware: getDefaultMiddleware => getDefaultMiddleware({ serializableCheck: false }) 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)
)
}
+2 -2
View File
@@ -6,8 +6,8 @@ const getToken = () => {
} }
export const api = axios.create({ export const api = axios.create({
// baseURL: 'https://enaklo-pos-be.altru.id/api/v1', baseURL: 'https://api-pos.apskel.id/api/v1',
baseURL: 'http://127.0.0.1:4000/api/v1', // baseURL: 'http://127.0.0.1:4000/api/v1',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
+100
View File
@@ -0,0 +1,100 @@
import { CampaignRequest, CampaignRuleRequest } from '@/types/services/campaign'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'react-toastify'
import { api } from '../api'
export const useCampaignsMutation = () => {
const queryClient = useQueryClient()
const createCampaign = useMutation({
mutationFn: async (newCampaign: CampaignRequest) => {
const response = await api.post('/marketing/campaigns', newCampaign)
return response.data
},
onSuccess: () => {
toast.success('Campaign created successfully!')
queryClient.invalidateQueries({ queryKey: ['campaigns'] })
},
onError: (error: any) => {
toast.error(error.response?.data?.errors?.[0]?.cause || 'Create failed')
}
})
const updateCampaign = useMutation({
mutationFn: async ({ id, payload }: { id: string; payload: CampaignRequest }) => {
const response = await api.put(`/marketing/campaigns/${id}`, payload)
return response.data
},
onSuccess: () => {
toast.success('Campaign updated successfully!')
queryClient.invalidateQueries({ queryKey: ['campaigns'] })
},
onError: (error: any) => {
toast.error(error.response?.data?.errors?.[0]?.cause || 'Update failed')
}
})
const deleteCampaign = useMutation({
mutationFn: async (id: string) => {
const response = await api.delete(`/marketing/campaigns/${id}`)
return response.data
},
onSuccess: () => {
toast.success('Campaign deleted successfully!')
queryClient.invalidateQueries({ queryKey: ['campaigns'] })
},
onError: (error: any) => {
toast.error(error.response?.data?.errors?.[0]?.cause || 'Delete failed')
}
})
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: () => { onSuccess: () => {
toast.success('Purchase Order created successfully!') toast.success('Purchase Order created successfully!')
queryClient.invalidateQueries({ queryKey: ['purchase-orders'] }) queryClient.invalidateQueries({ queryKey: ['purchase-orders', 'ingredients'] })
}, },
onError: (error: any) => { onError: (error: any) => {
toast.error(error.response?.data?.errors?.[0]?.cause || 'Create failed') toast.error(error.response?.data?.errors?.[0]?.cause || 'Create failed')
@@ -22,15 +22,15 @@ export const usePurchaseOrdersMutation = () => {
const sendPaymentPurchaseOrder = useMutation({ const sendPaymentPurchaseOrder = useMutation({
mutationFn: async ({ id, payload }: { id: string; payload: SendPaymentPurchaseOrderRequest }) => { 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 return response.data
}, },
onSuccess: () => { onSuccess: () => {
toast.success('Purchase Order created successfully!') toast.success('Send Payment Order successfully!')
queryClient.invalidateQueries({ queryKey: ['purchase-orders'] }) queryClient.invalidateQueries({ queryKey: ['purchase-orders'] })
}, },
onError: (error: any) => { 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')
} }
}) })
+52
View File
@@ -0,0 +1,52 @@
import { RewardRequest } from '@/types/services/reward'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'react-toastify'
import { api } from '../api'
export const useRewardsMutation = () => {
const queryClient = useQueryClient()
const createReward = useMutation({
mutationFn: async (newReward: RewardRequest) => {
const response = await api.post('/marketing/rewards', newReward)
return response.data
},
onSuccess: () => {
toast.success('Reward created successfully!')
queryClient.invalidateQueries({ queryKey: ['rewards'] })
},
onError: (error: any) => {
toast.error(error.response?.data?.errors?.[0]?.cause || 'Create failed')
}
})
const updateReward = useMutation({
mutationFn: async ({ id, payload }: { id: string; payload: RewardRequest }) => {
const response = await api.put(`/marketing/rewards/${id}`, payload)
return response.data
},
onSuccess: () => {
toast.success('Reward updated successfully!')
queryClient.invalidateQueries({ queryKey: ['rewards'] })
},
onError: (error: any) => {
toast.error(error.response?.data?.errors?.[0]?.cause || 'Update failed')
}
})
const deleteReward = useMutation({
mutationFn: async (id: string) => {
const response = await api.delete(`/marketing/rewards/${id}`)
return response.data
},
onSuccess: () => {
toast.success('Reward deleted successfully!')
queryClient.invalidateQueries({ queryKey: ['rewards'] })
},
onError: (error: any) => {
toast.error(error.response?.data?.errors?.[0]?.cause || 'Delete failed')
}
})
return { createReward, updateReward, deleteReward }
}
+56
View File
@@ -0,0 +1,56 @@
import { useQuery } from '@tanstack/react-query'
import { api } from '../api'
import { Campaign, CampaignRule, Campaigns } from '@/types/services/campaign'
interface CampaignQueryParams {
page?: number
limit?: number
search?: string
}
export function useCampaigns(params: CampaignQueryParams = {}) {
const { page = 1, limit = 10, search = '', ...filters } = params
return useQuery<Campaigns>({
queryKey: ['campaigns', { 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(`/marketing/campaigns?${queryParams.toString()}`)
return res.data.data
}
})
}
export function useCampaignById(id: string) {
return useQuery<Campaign>({
queryKey: ['campaigns', id],
queryFn: async () => {
const res = await api.get(`/marketing/campaigns/${id}`)
return res.data.data
}
})
}
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 = {}) { export function useCategories(params: CategoriesQueryParams = {}) {
const { page = 1, limit = 10, search = '', ...filters } = params const { page = 1, limit = 50, search = '', ...filters } = params
return useQuery<Categories>({ return useQuery<Categories>({
queryKey: ['categories', { page, limit, search, ...filters }], queryKey: ['categories', { page, limit, search, ...filters }],
@@ -30,7 +30,9 @@ export function useCategories(params: CategoriesQueryParams = {}) {
}) })
const res = await api.get(`/categories?${queryParams.toString()}`) 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 page?: number
limit?: number limit?: number
search?: string search?: string
date_from?: string
date_to?: string
} }
export function useOrders(params: OrdersQueryParams = {}) { export function useOrders(params: OrdersQueryParams = {}) {
+46
View File
@@ -0,0 +1,46 @@
import { useQuery } from '@tanstack/react-query'
import { api } from '../api'
import { Reward, Rewards } from '@/types/services/reward'
interface RewardQueryParams {
page?: number
limit?: number
search?: string
}
export function useRewards(params: RewardQueryParams = {}) {
const { page = 1, limit = 10, search = '', ...filters } = params
return useQuery<Rewards>({
queryKey: ['rewards', { 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(`/marketing/rewards?${queryParams.toString()}`)
return res.data.data
}
})
}
export function useRewardById(id: string) {
return useQuery<Reward>({
queryKey: ['rewards', id],
queryFn: async () => {
const res = await api.get(`/marketing/rewards/${id}`)
return res.data.data
}
})
}
+2
View File
@@ -31,8 +31,10 @@ export interface SalesReport {
export interface ProductData { export interface ProductData {
product_id: string product_id: string
product_name: string product_name: string
product_sku: string
category_id: string category_id: string
category_name: string category_name: string
category_order: number
quantity_sold: number quantity_sold: number
revenue: number revenue: number
average_price: number average_price: number
+63 -9
View File
@@ -1,13 +1,67 @@
export type CampaignType = 'REWARD' | 'POINTS' | 'TOKENS' | 'MIXED'
export type RuleType = 'TIER' | 'SPEND' | 'PRODUCT' | 'CATEGORY' | 'DAY' | 'LOCATION'
export type RewardType = 'POINTS' | 'TOKENS' | 'REWARD'
export interface Campaign { export interface Campaign {
id: string id: string // UUID
name: string name: string
description?: string description?: string
minimumPurchase: number type: CampaignType
rewardType: 'point' | 'voucher' | 'discount' start_date: string // ISO string
rewardValue: number end_date: string // ISO string
startDate: Date is_active: boolean
endDate: Date show_on_app: boolean
isActive: boolean position: number
createdAt: Date metadata?: {
updatedAt: Date banner_url?: string
}
created_at: string // ISO string
updated_at: string // ISO string
}
export interface CampaignRule {
id: string // UUID
campaign_id: string // UUID
rule_type: RuleType
condition_value?: string
reward_type: RewardType
reward_value?: number
reward_subtype?: string
reward_ref_id?: string // UUID
metadata?: Record<string, any>
created_at: string // ISO string
updated_at: string // ISO string
}
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?: {
banner_url?: string
}
}
export interface CampaignRuleRequest {
campaign_id: string
rule_type: 'TIER' | 'SPEND' | 'PRODUCT' | 'CATEGORY' | 'DAY' | 'LOCATION'
condition_value?: string
reward_type: 'POINTS' | 'TOKENS' | 'REWARD'
reward_value?: number
reward_subtype?: string
reward_ref_id?: string
}
export interface Campaigns {
campaigns: Campaign[]
total: number
page: number
limit: number
} }
+18 -17
View File
@@ -1,25 +1,26 @@
export interface Category { export interface Category {
id: string; id: string
organization_id: string; organization_id: string
name: string; name: string
description: string | null; description: string | null
business_type: string; business_type: string
metadata: Record<string, any>; order: number
created_at: string; metadata: Record<string, any>
updated_at: string; created_at: string
updated_at: string
} }
export interface Categories { export interface Categories {
categories: Category[]; categories: Category[]
total_count: number; total_count: number
page: number; page: number
limit: number; limit: number
total_pages: number; total_pages: number
} }
export interface CategoryRequest { export interface CategoryRequest {
name: string; name: string
description: string | null; description: string | null
business_type: string; 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
}
+36 -9
View File
@@ -1,12 +1,39 @@
export interface RewardCatalogType { export interface Reward {
id: string id: string // uuid
name: string name: string
description?: string reward_type: 'VOUCHER' | 'PHYSICAL' | 'DIGITAL'
pointCost: number cost_points: number
stock?: number stock?: number
isActive: boolean max_per_customer: number
validUntil?: Date tnc?: TermsAndConditions
imageUrl?: string metadata?: Record<string, any>
createdAt: Date images?: string[]
updatedAt: Date created_at: string // ISO date-time
updated_at: string // ISO date-time
}
export interface Rewards {
rewards: Reward[]
total: number
page: number
limit: number
}
export interface TermsAndConditions {
sections: TncSection[]
expiry_days: number
}
export interface TncSection {
title: string
rules: string[]
}
export interface RewardRequest {
name: string // required, 1150 chars
reward_type: 'VOUCHER' | 'PHYSICAL' | 'DIGITAL' // enum
cost_points: number // min 1
stock?: number
max_per_customer: number // min 1
tnc?: TermsAndConditions
} }
+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}` 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) => { export const formatForInputDate = (dateString: Date | string) => {
const date = new Date(dateString) const date = new Date(dateString)
const day = String(date.getDate()).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0')
@@ -51,7 +60,6 @@ export const formatForInputDate = (dateString: Date | string) => {
return `${year}-${month}-${day}` return `${year}-${month}-${day}`
} }
export const formatDatetime = (dateString: string | number | Date) => { export const formatDatetime = (dateString: string | number | Date) => {
const date = new Date(dateString) const date = new Date(dateString)
@@ -13,8 +13,10 @@ import Checkbox from '@mui/material/Checkbox'
import Chip from '@mui/material/Chip' import Chip from '@mui/material/Chip'
import MenuItem from '@mui/material/MenuItem' import MenuItem from '@mui/material/MenuItem'
import TablePagination from '@mui/material/TablePagination' import TablePagination from '@mui/material/TablePagination'
import TextField from '@mui/material/TextField'
import type { TextFieldProps } from '@mui/material/TextField' import type { TextFieldProps } from '@mui/material/TextField'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { Box, CircularProgress, useTheme } from '@mui/material'
// Third-party Imports // Third-party Imports
import type { RankingInfo } from '@tanstack/match-sorter-utils' import type { RankingInfo } from '@tanstack/match-sorter-utils'
@@ -40,11 +42,10 @@ import { getLocalizedUrl } from '@/utils/i18n'
// Style Imports // Style Imports
import tableStyles from '@core/styles/table.module.css' import tableStyles from '@core/styles/table.module.css'
import { Box, CircularProgress } from '@mui/material'
import Loading from '../../../../../components/layout/shared/Loading' import Loading from '../../../../../components/layout/shared/Loading'
import { useOrders } from '../../../../../services/queries/orders' import { useOrders } from '../../../../../services/queries/orders'
import { Order } from '../../../../../types/services/order' import { Order } from '../../../../../types/services/order'
import { formatCurrency } from '../../../../../utils/transform' import { formatCurrency, formatDateDDMMYYYY, formatForInputDate } from '../../../../../utils/transform'
declare module '@tanstack/table-core' { declare module '@tanstack/table-core' {
interface FilterFns { interface FilterFns {
@@ -105,15 +106,28 @@ const DebouncedInput = ({
const columnHelper = createColumnHelper<ECommerceOrderTypeWithAction>() const columnHelper = createColumnHelper<ECommerceOrderTypeWithAction>()
const OrderListTable = () => { const OrderListTable = () => {
const theme = useTheme()
// States // States
const [rowSelection, setRowSelection] = useState({}) const [rowSelection, setRowSelection] = useState({})
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(10) const [pageSize, setPageSize] = useState(10)
const [search, setSearch] = useState('') 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({ const { data, isLoading, error, isFetching } = useOrders({
page: currentPage, page: currentPage,
limit: pageSize, limit: pageSize,
date_from: formatDateDDMMYYYY(new Date(filter.date_from)),
date_to: formatDateDDMMYYYY(new Date(filter.date_to)),
search search
}) })
@@ -163,7 +177,7 @@ const OrderListTable = () => {
cell: ({ row }) => ( cell: ({ row }) => (
<Typography <Typography
component={Link} 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' color='primary.main'
>{`#${row.original.order_number}`}</Typography> >{`#${row.original.order_number}`}</Typography>
) )
@@ -203,6 +217,10 @@ const OrderListTable = () => {
header: 'Discount', header: 'Discount',
cell: ({ row }) => <Typography>{formatCurrency(row.original.discount_amount)}</Typography> 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', { columnHelper.accessor('action', {
header: 'Action', header: 'Action',
cell: ({ row }) => ( cell: ({ row }) => (
@@ -214,10 +232,7 @@ const OrderListTable = () => {
{ {
text: 'View', text: 'View',
icon: 'tabler-eye', icon: 'tabler-eye',
href: getLocalizedUrl( href: getLocalizedUrl(`/apps/inventory/orders/${row.original.id}/details`, locale as Locale),
`/apps/ecommerce/orders/${row.original.id}/details`,
locale as Locale
),
linkProps: { className: 'flex items-center gap-2 is-full plb-2 pli-4' } linkProps: { className: 'flex items-center gap-2 is-full plb-2 pli-4' }
}, },
{ {
@@ -248,49 +263,104 @@ const OrderListTable = () => {
state: { state: {
rowSelection, rowSelection,
pagination: { pagination: {
pageIndex: currentPage, // <= penting! pageIndex: currentPage,
pageSize pageSize
} }
}, },
enableRowSelection: true, //enable row selection for all rows enableRowSelection: true,
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
// Disable client-side pagination since we're handling it server-side
manualPagination: true, manualPagination: true,
pageCount: Math.ceil(totalCount / pageSize) pageCount: Math.ceil(totalCount / pageSize)
}) })
return ( return (
<Card> <Card>
<CardContent className='flex justify-between max-sm:flex-col sm:items-center gap-4'> <CardContent className='flex flex-col gap-4'>
<DebouncedInput {/* Date Range Filter */}
value={search} <div className='flex items-center gap-4 max-sm:flex-col'>
onChange={value => setSearch(value as string)} <Typography variant='body2' className='min-w-fit'>
placeholder='Search Order' Filter by Date:
className='sm:is-auto' </Typography>
/> <TextField
<div className='flex items-center max-sm:flex-col gap-4 max-sm:is-full is-auto'> type='date'
<CustomTextField value={filter.date_from}
select onChange={e => {
value={pageSize} setFilter({
onChange={handlePageSizeChange} ...filter,
className='is-[70px] max-sm:is-full' date_from: e.target.value
> })
<MenuItem value='10'>10</MenuItem> setCurrentPage(1)
<MenuItem value='25'>25</MenuItem> }}
<MenuItem value='50'>50</MenuItem> size='small'
<MenuItem value='100'>100</MenuItem> sx={{
</CustomTextField> '& .MuiOutlinedInput-root': {
<Button '&.Mui-focused fieldset': {
variant='tonal' borderColor: 'primary.main'
color='secondary' },
startIcon={<i className='tabler-upload' />} '& fieldset': {
className='max-sm:is-full is-auto' borderColor: theme.palette.mode === 'dark' ? 'rgba(231, 227, 252, 0.22)' : theme.palette.divider
> }
Export }
</Button> }}
/>
<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)}
placeholder='Search Order'
className='sm:is-auto'
/>
<div className='flex items-center max-sm:flex-col gap-4 max-sm:is-full is-auto'>
<CustomTextField
select
value={pageSize}
onChange={handlePageSizeChange}
className='is-[70px] max-sm:is-full'
>
<MenuItem value='10'>10</MenuItem>
<MenuItem value='25'>25</MenuItem>
<MenuItem value='50'>50</MenuItem>
<MenuItem value='100'>100</MenuItem>
</CustomTextField>
<Button
variant='tonal'
color='secondary'
startIcon={<i className='tabler-upload' />}
className='max-sm:is-full is-auto'
>
Export
</Button>
</div>
</div> </div>
</CardContent> </CardContent>
<div className='overflow-x-auto'> <div className='overflow-x-auto'>
{isLoading ? ( {isLoading ? (
<Loading /> <Loading />
@@ -82,6 +82,8 @@ const ProductOrganize = () => {
onChange={e => handleSelectChange('printer_type', e.target.value)} onChange={e => handleSelectChange('printer_type', e.target.value)}
> >
<MenuItem value={`kitchen`}>Kitchen</MenuItem> <MenuItem value={`kitchen`}>Kitchen</MenuItem>
<MenuItem value={`bar`}>Bar</MenuItem>
<MenuItem value={`ticket`}>Ticket</MenuItem>
</CustomTextField> </CustomTextField>
</form> </form>
</CardContent> </CardContent>
@@ -33,7 +33,8 @@ const AddCategoryDrawer = (props: Props) => {
const [formData, setFormData] = useState<CategoryRequest>({ const [formData, setFormData] = useState<CategoryRequest>({
name: '', name: '',
description: '', description: '',
business_type: '' business_type: '',
order: 0
}) })
// Handle Form Submit // 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 // Handle Form Reset
const handleReset = () => { const handleReset = () => {
handleClose() handleClose()
setFormData({ setFormData({
name: '', name: '',
description: '', description: '',
business_type: '' business_type: '',
order: 0
}) })
} }
@@ -99,6 +108,15 @@ const AddCategoryDrawer = (props: Props) => {
> >
<MenuItem value='restaurant'>Restaurant</MenuItem> <MenuItem value='restaurant'>Restaurant</MenuItem>
</CustomTextField> </CustomTextField>
<CustomTextField
fullWidth
label='Order'
name='order'
type='number'
value={formData.order}
onChange={handleOrderChange}
placeholder='0'
/>
<CustomTextField <CustomTextField
fullWidth fullWidth
label='Description' label='Description'
@@ -34,7 +34,8 @@ const EditCategoryDrawer = (props: Props) => {
const [formData, setFormData] = useState<CategoryRequest>({ const [formData, setFormData] = useState<CategoryRequest>({
name: '', name: '',
description: '', description: '',
business_type: '' business_type: '',
order: 0
}) })
useEffect(() => { useEffect(() => {
@@ -42,7 +43,8 @@ const EditCategoryDrawer = (props: Props) => {
setFormData({ setFormData({
name: data.name, name: data.name,
description: data.description, description: data.description,
business_type: data.business_type business_type: data.business_type,
order: data.order
}) })
} }
}, [data]) }, [data])
@@ -51,11 +53,14 @@ const EditCategoryDrawer = (props: Props) => {
const handleFormSubmit = (e: any) => { const handleFormSubmit = (e: any) => {
e.preventDefault() e.preventDefault()
updateCategory({ id: data.id, payload: formData }, { updateCategory(
onSuccess: () => { { id: data.id, payload: formData },
handleReset() {
onSuccess: () => {
handleReset()
}
} }
}) )
} }
const handleInputChange = (e: any) => { 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 // Handle Form Reset
const handleReset = () => { const handleReset = () => {
handleClose() handleClose()
setFormData({ setFormData({
name: '', name: '',
description: '', description: '',
business_type: '' business_type: '',
order: 0
}) })
} }
@@ -110,6 +123,15 @@ const EditCategoryDrawer = (props: Props) => {
> >
<MenuItem value='restaurant'>Restaurant</MenuItem> <MenuItem value='restaurant'>Restaurant</MenuItem>
</CustomTextField> </CustomTextField>
<CustomTextField
fullWidth
label='Order'
name='order'
type='number'
value={formData.order}
onChange={handleOrderChange}
placeholder='0'
/>
<CustomTextField <CustomTextField
fullWidth fullWidth
label='Description' label='Description'
@@ -12,6 +12,7 @@ import MenuItem from '@mui/material/MenuItem'
import TablePagination from '@mui/material/TablePagination' import TablePagination from '@mui/material/TablePagination'
import type { TextFieldProps } from '@mui/material/TextField' import type { TextFieldProps } from '@mui/material/TextField'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { Box, CircularProgress } from '@mui/material'
// Third-party Imports // Third-party Imports
import type { RankingInfo } from '@tanstack/match-sorter-utils' 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 type { ColumnDef, FilterFn } from '@tanstack/react-table'
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table' import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'
import classnames from 'classnames' import classnames from 'classnames'
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd'
// Component Imports // Component Imports
import TablePaginationComponent from '@components/TablePaginationComponent' import TablePaginationComponent from '@components/TablePaginationComponent'
import CustomTextField from '@core/components/mui/TextField' import CustomTextField from '@core/components/mui/TextField'
import OptionMenu from '@core/components/option-menu' import OptionMenu from '@core/components/option-menu'
import AddCategoryDrawer from './AddCategoryDrawer' import AddCategoryDrawer from './AddCategoryDrawer'
import EditCategoryDrawer from './EditCategoryDrawer'
// Style Imports
import tableStyles from '@core/styles/table.module.css'
import { Box, CircularProgress } from '@mui/material'
import ConfirmDeleteDialog from '../../../../../components/dialogs/confirm-delete' import ConfirmDeleteDialog from '../../../../../components/dialogs/confirm-delete'
import Loading from '../../../../../components/layout/shared/Loading' import Loading from '../../../../../components/layout/shared/Loading'
// Services Imports
import { useCategoriesMutation } from '../../../../../services/mutations/categories' import { useCategoriesMutation } from '../../../../../services/mutations/categories'
import { useCategories } from '../../../../../services/queries/categories' import { useCategories } from '../../../../../services/queries/categories'
import { Category } from '../../../../../types/services/category' import { Category } from '../../../../../types/services/category'
import EditCategoryDrawer from './EditCategoryDrawer'
import { formatDate } from '../../../../../utils/transform' import { formatDate } from '../../../../../utils/transform'
// Style Imports
import tableStyles from '@core/styles/table.module.css'
declare module '@tanstack/table-core' { declare module '@tanstack/table-core' {
interface FilterFns { interface FilterFns {
fuzzy: FilterFn<unknown> fuzzy: FilterFn<unknown>
@@ -51,15 +54,8 @@ type CategoryWithActionsType = Category & {
} }
const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => { const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
// Rank the item
const itemRank = rankItem(row.getValue(columnId), value) const itemRank = rankItem(row.getValue(columnId), value)
addMeta({ itemRank })
// Store the itemRank info
addMeta({
itemRank
})
// Return if the item should be filtered in/out
return itemRank.passed return itemRank.passed
} }
@@ -73,7 +69,6 @@ const DebouncedInput = ({
onChange: (value: string | number) => void onChange: (value: string | number) => void
debounce?: number debounce?: number
} & Omit<TextFieldProps, 'onChange'>) => { } & Omit<TextFieldProps, 'onChange'>) => {
// States
const [value, setValue] = useState(initialValue) const [value, setValue] = useState(initialValue)
useEffect(() => { useEffect(() => {
@@ -86,47 +81,51 @@ const DebouncedInput = ({
}, debounce) }, debounce)
return () => clearTimeout(timeout) return () => clearTimeout(timeout)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]) }, [value])
return <CustomTextField {...props} value={value} onChange={e => setValue(e.target.value)} /> return <CustomTextField {...props} value={value} onChange={e => setValue(e.target.value)} />
} }
// Column Definitions
const columnHelper = createColumnHelper<CategoryWithActionsType>() const columnHelper = createColumnHelper<CategoryWithActionsType>()
const ProductCategoryTable = () => { const ProductCategoryTable = () => {
// States
const [addCategoryOpen, setAddCategoryOpen] = useState(false) const [addCategoryOpen, setAddCategoryOpen] = useState(false)
const [editCategoryOpen, setEditCategoryOpen] = useState(false) const [editCategoryOpen, setEditCategoryOpen] = useState(false)
const [rowSelection, setRowSelection] = useState({}) const [rowSelection, setRowSelection] = useState({})
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(10) const [pageSize, setPageSize] = useState(50)
const [categoryId, setCategoryId] = useState('') const [categoryId, setCategoryId] = useState('')
const [openConfirm, setOpenConfirm] = useState(false) const [openConfirm, setOpenConfirm] = useState(false)
const [currentCategory, setCurrentCategory] = useState<Category>() const [currentCategory, setCurrentCategory] = useState<Category>()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [localCategories, setLocalCategories] = useState<Category[]>([])
// Fetch products with pagination and search
const { data, isLoading, error, isFetching } = useCategories({ const { data, isLoading, error, isFetching } = useCategories({
page: currentPage, page: currentPage,
limit: pageSize limit: pageSize
}) })
const { mutate: deleteCategory, isPending: isDeleting } = useCategoriesMutation().deleteCategory const { mutate: deleteCategory, isPending: isDeleting } = useCategoriesMutation().deleteCategory
const { mutate: updateCategory } = useCategoriesMutation().updateCategory
const categories = data?.categories ?? [] const categories = data?.categories ?? []
const totalCount = data?.total_count ?? 0 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) => { const handlePageChange = useCallback((event: unknown, newPage: number) => {
setCurrentPage(newPage) setCurrentPage(newPage)
}, []) }, [])
// Handle page size change
const handlePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => { const handlePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const newPageSize = parseInt(event.target.value, 10) const newPageSize = parseInt(event.target.value, 50)
setPageSize(newPageSize) setPageSize(newPageSize)
setCurrentPage(1) // Reset to first page setCurrentPage(1)
}, []) }, [])
const handleDelete = () => { 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>[]>( 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', id: 'select',
header: ({ table }) => ( header: ({ table }) => (
@@ -163,7 +210,6 @@ const ProductCategoryTable = () => {
header: 'Name', header: 'Name',
cell: ({ row }) => ( cell: ({ row }) => (
<div className='flex items-center gap-3'> <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'> <div className='flex flex-col items-start'>
<Typography className='font-medium' color='text.primary'> <Typography className='font-medium' color='text.primary'>
{row.original.name} {row.original.name}
@@ -173,7 +219,7 @@ const ProductCategoryTable = () => {
) )
}), }),
columnHelper.accessor('description', { columnHelper.accessor('description', {
header: 'Decription', header: 'Description',
cell: ({ row }) => <Typography>{row.original.description || '-'}</Typography> cell: ({ row }) => <Typography>{row.original.description || '-'}</Typography>
}), }),
columnHelper.accessor('business_type', { columnHelper.accessor('business_type', {
@@ -219,12 +265,11 @@ const ProductCategoryTable = () => {
enableSorting: false enableSorting: false
}) })
], ],
// eslint-disable-next-line react-hooks/exhaustive-deps
[data] [data]
) )
const table = useReactTable({ const table = useReactTable({
data: categories as Category[], data: localCategories as Category[],
columns, columns,
filterFns: { filterFns: {
fuzzy: fuzzyFilter fuzzy: fuzzyFilter
@@ -232,14 +277,13 @@ const ProductCategoryTable = () => {
state: { state: {
rowSelection, rowSelection,
pagination: { pagination: {
pageIndex: currentPage, // <= penting! pageIndex: currentPage,
pageSize pageSize
} }
}, },
enableRowSelection: true, //enable row selection for all rows enableRowSelection: true,
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
// Disable client-side pagination since we're handling it server-side
manualPagination: true, manualPagination: true,
pageCount: Math.ceil(totalCount / pageSize) pageCount: Math.ceil(totalCount / pageSize)
}) })
@@ -261,9 +305,9 @@ const ProductCategoryTable = () => {
onChange={handlePageSizeChange} onChange={handlePageSizeChange}
className='flex-auto max-sm:is-full sm:is-[70px]' className='flex-auto max-sm:is-full sm:is-[70px]'
> >
<MenuItem value='10'>10</MenuItem> <MenuItem value='50'>50</MenuItem>
<MenuItem value='15'>15</MenuItem> <MenuItem value='75'>75</MenuItem>
<MenuItem value='25'>25</MenuItem> <MenuItem value='100'>100</MenuItem>
</CustomTextField> </CustomTextField>
<Button <Button
variant='contained' variant='contained'
@@ -275,18 +319,18 @@ const ProductCategoryTable = () => {
</Button> </Button>
</div> </div>
</div> </div>
<div className='overflow-x-auto'> <div className='overflow-x-auto relative'>
{isLoading ? ( {isLoading ? (
<Loading /> <Loading />
) : ( ) : (
<table className={tableStyles.table}> <DragDropContext onDragEnd={handleDragEnd}>
<thead> <table className={tableStyles.table}>
{table.getHeaderGroups().map(headerGroup => ( <thead>
<tr key={headerGroup.id}> {table.getHeaderGroups().map(headerGroup => (
{headerGroup.headers.map(header => ( <tr key={headerGroup.id}>
<th key={header.id}> {headerGroup.headers.map(header => (
{header.isPlaceholder ? null : ( <th key={header.id}>
<> {header.isPlaceholder ? null : (
<div <div
className={classnames({ className={classnames({
'flex items-center': header.column.getIsSorted(), 'flex items-center': header.column.getIsSorted(),
@@ -300,38 +344,58 @@ const ProductCategoryTable = () => {
desc: <i className='tabler-chevron-down text-xl' /> desc: <i className='tabler-chevron-down text-xl' />
}[header.column.getIsSorted() as 'asc' | 'desc'] ?? null} }[header.column.getIsSorted() as 'asc' | 'desc'] ?? null}
</div> </div>
</> )}
)} </th>
</th> ))}
))} </tr>
</tr> ))}
))} </thead>
</thead> <Droppable droppableId='category-table'>
{table.getFilteredRowModel().rows.length === 0 ? ( {provided => (
<tbody> <tbody {...provided.droppableProps} ref={provided.innerRef}>
<tr> {table.getFilteredRowModel().rows.length === 0 ? (
<td colSpan={table.getVisibleFlatColumns().length} className='text-center'> <tr>
No data available <td colSpan={table.getVisibleFlatColumns().length} className='text-center'>
</td> No data available
</tr> </td>
</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> </tr>
) ) : (
})} table
</tbody> .getRowModel()
)} .rows.slice(0, table.getState().pagination.pageSize)
</table> .map((row, index) => {
const categoryId = row.original.id
return (
<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 && ( {isFetching && !isLoading && (
@@ -365,7 +429,7 @@ const ProductCategoryTable = () => {
page={currentPage} page={currentPage}
onPageChange={handlePageChange} onPageChange={handlePageChange}
onRowsPerPageChange={handlePageSizeChange} onRowsPerPageChange={handlePageSizeChange}
rowsPerPageOptions={[10, 25, 50]} rowsPerPageOptions={[50, 75, 100]}
disabled={isLoading} disabled={isLoading}
/> />
</Card> </Card>
@@ -7,45 +7,22 @@ import Drawer from '@mui/material/Drawer'
import IconButton from '@mui/material/IconButton' import IconButton from '@mui/material/IconButton'
import MenuItem from '@mui/material/MenuItem' import MenuItem from '@mui/material/MenuItem'
import Typography from '@mui/material/Typography' 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 Box from '@mui/material/Box'
import Switch from '@mui/material/Switch' import Switch from '@mui/material/Switch'
import FormControlLabel from '@mui/material/FormControlLabel' import FormControlLabel from '@mui/material/FormControlLabel'
import Chip from '@mui/material/Chip'
import InputAdornment from '@mui/material/InputAdornment'
// Third-party Imports // Third-party Imports
import { useForm, Controller, useFieldArray } from 'react-hook-form' import { useForm, Controller } from 'react-hook-form'
// Component Imports // Component Imports
import CustomTextField from '@core/components/mui/TextField' import CustomTextField from '@core/components/mui/TextField'
import ImageUpload from '@/components/ImageUpload'
// Types // Types
export interface Campaign { import { Campaign, CampaignRequest, CampaignType } from '@/types/services/campaign'
id: string import { useCampaignsMutation } from '@/services/mutations/campaign'
name: string import { useFilesMutation } from '@/services/mutations/files'
description?: string import { formatDateYYYYMMDD } from '@/utils/transform'
minimumPurchase: number
rewardType: 'point' | 'voucher' | 'discount'
rewardValue: number
startDate: Date
endDate: Date
isActive: boolean
createdAt: Date
updatedAt: Date
}
export interface CampaignRequest {
name: string
description?: string
minimumPurchase: number
rewardType: 'point' | 'voucher' | 'discount'
rewardValue: number
startDate: Date
endDate: Date
isActive: boolean
}
type Props = { type Props = {
open: boolean open: boolean
@@ -56,43 +33,26 @@ type Props = {
type FormValidateType = { type FormValidateType = {
name: string name: string
description: string description: string
minimumPurchase: number type: CampaignType
rewardType: 'point' | 'voucher' | 'discount' start_date: string
rewardValue: number end_date: string
startDate: string is_active: boolean
endDate: string show_on_app: boolean
isActive: boolean position: number
banner_url: string
} }
// Initial form data // Initial form data
const initialData: FormValidateType = { const initialData: FormValidateType = {
name: '', name: '',
description: '', description: '',
minimumPurchase: 0, type: 'POINTS',
rewardType: 'point', start_date: '',
rewardValue: 0, end_date: '',
startDate: '', is_active: true,
endDate: '', show_on_app: true,
isActive: true position: 1,
} banner_url: ''
// Mock mutation hooks (replace with actual hooks)
const useCampaignMutation = () => {
const createCampaign = {
mutate: (data: CampaignRequest, options?: { onSuccess?: () => void }) => {
console.log('Creating campaign:', data)
setTimeout(() => options?.onSuccess?.(), 1000)
}
}
const updateCampaign = {
mutate: (data: { id: string; payload: CampaignRequest }, options?: { onSuccess?: () => void }) => {
console.log('Updating campaign:', data)
setTimeout(() => options?.onSuccess?.(), 1000)
}
}
return { createCampaign, updateCampaign }
} }
const AddEditCampaignDrawer = (props: Props) => { const AddEditCampaignDrawer = (props: Props) => {
@@ -102,8 +62,10 @@ const AddEditCampaignDrawer = (props: Props) => {
// States // States
const [showMore, setShowMore] = useState(false) const [showMore, setShowMore] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [bannerUrl, setBannerUrl] = useState<string>('')
const { createCampaign, updateCampaign } = useCampaignMutation() const { createCampaign, updateCampaign } = useCampaignsMutation()
const { mutate: uploadFile, isPending: isUploadPending } = useFilesMutation().uploadFile
// Determine if this is edit mode // Determine if this is edit mode
const isEditMode = Boolean(data?.id) const isEditMode = Boolean(data?.id)
@@ -120,30 +82,31 @@ const AddEditCampaignDrawer = (props: Props) => {
defaultValues: initialData defaultValues: initialData
}) })
const watchedRewardType = watch('rewardType') const watchedStartDate = watch('start_date')
const watchedStartDate = watch('startDate') const watchedEndDate = watch('end_date')
const watchedEndDate = watch('endDate')
// Effect to populate form when editing // Effect to populate form when editing
useEffect(() => { useEffect(() => {
if (isEditMode && data) { if (isEditMode && data) {
// Populate form with existing data
const formData: FormValidateType = { const formData: FormValidateType = {
name: data.name || '', name: data.name || '',
description: data.description || '', description: data.description || '',
minimumPurchase: data.minimumPurchase || 0, type: data.type || 'POINTS',
rewardType: data.rewardType || 'point', start_date: data.start_date ? new Date(data.start_date).toISOString().split('T')[0] : '',
rewardValue: data.rewardValue || 0, end_date: data.end_date ? new Date(data.end_date).toISOString().split('T')[0] : '',
startDate: data.startDate ? new Date(data.startDate).toISOString().split('T')[0] : '', is_active: data.is_active ?? true,
endDate: data.endDate ? new Date(data.endDate).toISOString().split('T')[0] : '', show_on_app: data.show_on_app ?? true,
isActive: data.isActive ?? true position: data.position || 1,
banner_url: data.metadata?.banner_url || ''
} }
resetForm(formData) resetForm(formData)
setBannerUrl(data.metadata?.banner_url || '')
setShowMore(true) // Always show more for edit mode setShowMore(true) // Always show more for edit mode
} else { } else {
// Reset to initial data for add mode // Reset to initial data for add mode
resetForm(initialData) resetForm(initialData)
setBannerUrl('')
setShowMore(false) setShowMore(false)
} }
}, [data, isEditMode, resetForm]) }, [data, isEditMode, resetForm])
@@ -152,16 +115,23 @@ const AddEditCampaignDrawer = (props: Props) => {
try { try {
setIsSubmitting(true) setIsSubmitting(true)
// Create metadata object
const metadata: { banner_url?: string } = {}
if (bannerUrl.trim()) {
metadata.banner_url = bannerUrl.trim()
}
// Create CampaignRequest object // Create CampaignRequest object
const campaignRequest: CampaignRequest = { const campaignRequest: CampaignRequest = {
name: formData.name, name: formData.name,
description: formData.description || undefined, description: formData.description || undefined,
minimumPurchase: formData.minimumPurchase, type: formData.type,
rewardType: formData.rewardType, start_date: formatDateYYYYMMDD(formData.start_date),
rewardValue: formData.rewardValue, end_date: formatDateYYYYMMDD(formData.end_date),
startDate: new Date(formData.startDate), is_active: formData.is_active,
endDate: new Date(formData.endDate), show_on_app: formData.show_on_app,
isActive: formData.isActive position: formData.position,
metadata: Object.keys(metadata).length > 0 ? metadata : undefined
} }
if (isEditMode && data?.id) { if (isEditMode && data?.id) {
@@ -195,63 +165,29 @@ const AddEditCampaignDrawer = (props: Props) => {
const handleReset = () => { const handleReset = () => {
handleClose() handleClose()
resetForm(initialData) resetForm(initialData)
setBannerUrl('')
setShowMore(false) setShowMore(false)
} }
const formatCurrency = (value: number) => { // Handle file upload
return new Intl.NumberFormat('id-ID', { const handleBannerUpload = async (file: File): Promise<string> => {
style: 'currency', return new Promise((resolve, reject) => {
currency: 'IDR', const formData = new FormData()
minimumFractionDigits: 0 formData.append('file', file)
}).format(value) formData.append('file_type', 'image')
} formData.append('description', 'campaign banner upload')
const getRewardTypeLabel = (type: 'point' | 'voucher' | 'discount') => { uploadFile(formData, {
switch (type) { onSuccess: r => {
case 'point': setBannerUrl(r.file_url)
return 'Poin' setValue('banner_url', r.file_url) // Update form value
case 'voucher': resolve(r.id)
return 'Voucher' },
case 'discount': onError: er => {
return 'Diskon' reject(er)
default:
return type
}
}
const getRewardValuePlaceholder = (type: 'point' | 'voucher' | 'discount') => {
switch (type) {
case 'point':
return 'Jumlah poin yang diberikan'
case 'voucher':
return 'Nilai voucher dalam Rupiah'
case 'discount':
return 'Persentase diskon (1-100)'
default:
return 'Nilai reward'
}
}
const getRewardValueRules = (type: 'point' | 'voucher' | 'discount') => {
const baseRules = {
required: 'Nilai reward wajib diisi',
min: {
value: 1,
message: 'Nilai reward minimal 1'
}
}
if (type === 'discount') {
return {
...baseRules,
max: {
value: 100,
message: 'Persentase diskon maksimal 100%'
} }
} })
} })
return baseRules
} }
return ( return (
@@ -314,71 +250,39 @@ const AddEditCampaignDrawer = (props: Props) => {
/> />
</div> </div>
{/* Minimum Purchase */} {/* Jenis Kampanye */}
<div> <div>
<Typography variant='body2' className='mb-2'> <Typography variant='body2' className='mb-2'>
Minimum Pembelian <span className='text-red-500'>*</span> Jenis Kampanye <span className='text-red-500'>*</span>
</Typography> </Typography>
<Controller <Controller
name='minimumPurchase' name='type'
control={control} control={control}
rules={{ rules={{ required: 'Jenis kampanye wajib dipilih' }}
required: 'Minimum pembelian wajib diisi',
min: {
value: 0,
message: 'Minimum pembelian tidak boleh negatif'
}
}}
render={({ field }) => ( render={({ field }) => (
<CustomTextField <CustomTextField {...field} select fullWidth error={!!errors.type} helperText={errors.type?.message}>
{...field} <MenuItem value='POINTS'>
fullWidth
type='number'
placeholder='0'
error={!!errors.minimumPurchase}
helperText={errors.minimumPurchase?.message || (field.value > 0 ? formatCurrency(field.value) : '')}
InputProps={{
startAdornment: <InputAdornment position='start'>Rp</InputAdornment>
}}
onChange={e => field.onChange(Number(e.target.value))}
/>
)}
/>
</div>
{/* Jenis Reward */}
<div>
<Typography variant='body2' className='mb-2'>
Jenis Reward <span className='text-red-500'>*</span>
</Typography>
<Controller
name='rewardType'
control={control}
rules={{ required: 'Jenis reward wajib dipilih' }}
render={({ field }) => (
<CustomTextField
{...field}
select
fullWidth
error={!!errors.rewardType}
helperText={errors.rewardType?.message}
>
<MenuItem value='point'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<i className='tabler-coins text-primary' /> <i className='tabler-coins text-primary' />
Poin Points
</div> </div>
</MenuItem> </MenuItem>
<MenuItem value='voucher'> <MenuItem value='TOKENS'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<i className='tabler-ticket text-success' /> <i className='tabler-ticket text-success' />
Voucher Tokens
</div> </div>
</MenuItem> </MenuItem>
<MenuItem value='discount'> <MenuItem value='REWARD'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<i className='tabler-percentage text-warning' /> <i className='tabler-gift text-warning' />
Diskon Reward
</div>
</MenuItem>
<MenuItem value='MIXED'>
<div className='flex items-center gap-2'>
<i className='tabler-layers-intersect text-info' />
Mixed
</div> </div>
</MenuItem> </MenuItem>
</CustomTextField> </CustomTextField>
@@ -386,48 +290,13 @@ const AddEditCampaignDrawer = (props: Props) => {
/> />
</div> </div>
{/* Nilai Reward */}
<div>
<Typography variant='body2' className='mb-2'>
Nilai {getRewardTypeLabel(watchedRewardType)} <span className='text-red-500'>*</span>
</Typography>
<Controller
name='rewardValue'
control={control}
rules={getRewardValueRules(watchedRewardType)}
render={({ field }) => (
<CustomTextField
{...field}
fullWidth
type='number'
placeholder={getRewardValuePlaceholder(watchedRewardType)}
error={!!errors.rewardValue}
helperText={errors.rewardValue?.message}
InputProps={{
startAdornment:
watchedRewardType === 'voucher' ? (
<InputAdornment position='start'>Rp</InputAdornment>
) : undefined,
endAdornment:
watchedRewardType === 'discount' ? (
<InputAdornment position='end'>%</InputAdornment>
) : watchedRewardType === 'point' ? (
<InputAdornment position='end'>Poin</InputAdornment>
) : undefined
}}
onChange={e => field.onChange(Number(e.target.value))}
/>
)}
/>
</div>
{/* Tanggal Mulai */} {/* Tanggal Mulai */}
<div> <div>
<Typography variant='body2' className='mb-2'> <Typography variant='body2' className='mb-2'>
Tanggal Mulai <span className='text-red-500'>*</span> Tanggal Mulai <span className='text-red-500'>*</span>
</Typography> </Typography>
<Controller <Controller
name='startDate' name='start_date'
control={control} control={control}
rules={{ required: 'Tanggal mulai wajib diisi' }} rules={{ required: 'Tanggal mulai wajib diisi' }}
render={({ field }) => ( render={({ field }) => (
@@ -435,8 +304,8 @@ const AddEditCampaignDrawer = (props: Props) => {
{...field} {...field}
fullWidth fullWidth
type='date' type='date'
error={!!errors.startDate} error={!!errors.start_date}
helperText={errors.startDate?.message} helperText={errors.start_date?.message}
InputLabelProps={{ InputLabelProps={{
shrink: true shrink: true
}} }}
@@ -451,7 +320,7 @@ const AddEditCampaignDrawer = (props: Props) => {
Tanggal Berakhir <span className='text-red-500'>*</span> Tanggal Berakhir <span className='text-red-500'>*</span>
</Typography> </Typography>
<Controller <Controller
name='endDate' name='end_date'
control={control} control={control}
rules={{ rules={{
required: 'Tanggal berakhir wajib diisi', required: 'Tanggal berakhir wajib diisi',
@@ -469,8 +338,8 @@ const AddEditCampaignDrawer = (props: Props) => {
{...field} {...field}
fullWidth fullWidth
type='date' type='date'
error={!!errors.endDate} error={!!errors.end_date}
helperText={errors.endDate?.message} helperText={errors.end_date?.message}
InputLabelProps={{ InputLabelProps={{
shrink: true shrink: true
}} }}
@@ -485,7 +354,7 @@ const AddEditCampaignDrawer = (props: Props) => {
{/* Status Aktif */} {/* Status Aktif */}
<div> <div>
<Controller <Controller
name='isActive' name='is_active'
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<FormControlLabel <FormControlLabel
@@ -496,6 +365,20 @@ const AddEditCampaignDrawer = (props: Props) => {
/> />
</div> </div>
{/* Show on App */}
<div>
<Controller
name='show_on_app'
control={control}
render={({ field }) => (
<FormControlLabel
control={<Switch checked={field.value} onChange={field.onChange} color='primary' />}
label='Tampilkan di Aplikasi'
/>
)}
/>
</div>
{/* Tampilkan selengkapnya */} {/* Tampilkan selengkapnya */}
{!showMore && ( {!showMore && (
<Button <Button
@@ -532,6 +415,48 @@ const AddEditCampaignDrawer = (props: Props) => {
/> />
</div> </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'>
Posisi Kampanye
</Typography>
<Controller
name='position'
control={control}
rules={{
min: { value: 1, message: 'Posisi minimal 1' }
}}
render={({ field }) => (
<CustomTextField
{...field}
fullWidth
type='number'
placeholder='Urutan tampilan kampanye'
error={!!errors.position}
helperText={errors.position?.message || 'Semakin kecil angka, semakin atas posisinya'}
onChange={e => field.onChange(Number(e.target.value))}
/>
)}
/>
</div>
{/* Sembunyikan */} {/* Sembunyikan */}
<Button <Button
variant='text' variant='text'
@@ -57,21 +57,10 @@ import { formatCurrency } from '@/utils/transform'
import tableStyles from '@core/styles/table.module.css' import tableStyles from '@core/styles/table.module.css'
import Loading from '@/components/layout/shared/Loading' import Loading from '@/components/layout/shared/Loading'
import AddEditCampaignDrawer from './AddEditCampaignDrawer' import AddEditCampaignDrawer from './AddEditCampaignDrawer'
import DeleteCampaignDialog from './DeleteCampaignDialog'
// Campaign Type Interface import { Campaign } from '@/types/services/campaign'
export interface Campaign { import { useCampaigns } from '@/services/queries/campaign'
id: string import { useCampaignsMutation } from '@/services/mutations/campaign'
name: string
description?: string
minimumPurchase: number
rewardType: 'point' | 'voucher' | 'discount'
rewardValue: number
startDate: Date
endDate: Date
isActive: boolean
createdAt: Date
updatedAt: Date
}
declare module '@tanstack/table-core' { declare module '@tanstack/table-core' {
interface FilterFns { interface FilterFns {
@@ -131,192 +120,24 @@ const DebouncedInput = ({
return <CustomTextField {...props} value={value} onChange={e => setValue(e.target.value)} /> return <CustomTextField {...props} value={value} onChange={e => setValue(e.target.value)} />
} }
// Dummy data for campaigns // Helper function untuk format points - SAMA SEPERTI REWARD TABLE
const DUMMY_CAMPAIGN_DATA: Campaign[] = [ const formatPoints = (points: number) => {
{ return new Intl.NumberFormat('id-ID').format(points)
id: '1',
name: 'Summer Sale Campaign',
description: 'Get extra points during summer season',
minimumPurchase: 500000,
rewardType: 'point',
rewardValue: 100,
startDate: new Date('2024-06-01'),
endDate: new Date('2024-08-31'),
isActive: true,
createdAt: new Date('2024-05-15'),
updatedAt: new Date('2024-06-01')
},
{
id: '2',
name: 'Welcome Bonus',
description: 'Special discount for new customers',
minimumPurchase: 200000,
rewardType: 'discount',
rewardValue: 15,
startDate: new Date('2024-01-01'),
endDate: new Date('2024-12-31'),
isActive: true,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-03-15')
},
{
id: '3',
name: 'Flash Sale Weekend',
description: 'Weekend special voucher campaign',
minimumPurchase: 1000000,
rewardType: 'voucher',
rewardValue: 50000,
startDate: new Date('2024-07-06'),
endDate: new Date('2024-07-07'),
isActive: false,
createdAt: new Date('2024-07-01'),
updatedAt: new Date('2024-07-08')
},
{
id: '4',
name: 'Loyalty Rewards',
description: 'Extra points for loyal customers',
minimumPurchase: 2000000,
rewardType: 'point',
rewardValue: 300,
startDate: new Date('2024-03-01'),
endDate: new Date('2024-09-30'),
isActive: true,
createdAt: new Date('2024-02-25'),
updatedAt: new Date('2024-05-10')
},
{
id: '5',
name: 'Black Friday Special',
description: 'Biggest discount of the year',
minimumPurchase: 800000,
rewardType: 'discount',
rewardValue: 25,
startDate: new Date('2024-11-29'),
endDate: new Date('2024-11-29'),
isActive: false,
createdAt: new Date('2024-11-01'),
updatedAt: new Date('2024-11-30')
},
{
id: '6',
name: 'Student Promo',
description: 'Special voucher for students',
minimumPurchase: 300000,
rewardType: 'voucher',
rewardValue: 25000,
startDate: new Date('2024-09-01'),
endDate: new Date('2024-12-20'),
isActive: true,
createdAt: new Date('2024-08-25'),
updatedAt: new Date('2024-09-01')
},
{
id: '7',
name: 'Holiday Celebration',
description: 'Special points during holidays',
minimumPurchase: 1500000,
rewardType: 'point',
rewardValue: 200,
startDate: new Date('2024-12-15'),
endDate: new Date('2025-01-15'),
isActive: true,
createdAt: new Date('2024-12-01'),
updatedAt: new Date('2024-12-15')
},
{
id: '8',
name: 'Mid Year Sale',
description: 'Mid year discount campaign',
minimumPurchase: 600000,
rewardType: 'discount',
rewardValue: 20,
startDate: new Date('2024-06-15'),
endDate: new Date('2024-07-15'),
isActive: false,
createdAt: new Date('2024-06-01'),
updatedAt: new Date('2024-07-16')
}
]
// Mock data hook with dummy data
const useCampaigns = ({ page, limit, search }: { page: number; limit: number; search: string }) => {
const [isLoading, setIsLoading] = useState(false)
// Simulate loading
useEffect(() => {
setIsLoading(true)
const timer = setTimeout(() => setIsLoading(false), 500)
return () => clearTimeout(timer)
}, [page, limit, search])
// Filter data based on search
const filteredData = useMemo(() => {
if (!search) return DUMMY_CAMPAIGN_DATA
return DUMMY_CAMPAIGN_DATA.filter(
campaign =>
campaign.name.toLowerCase().includes(search.toLowerCase()) ||
campaign.description?.toLowerCase().includes(search.toLowerCase()) ||
campaign.rewardType.toLowerCase().includes(search.toLowerCase())
)
}, [search])
// Paginate data
const paginatedData = useMemo(() => {
const startIndex = (page - 1) * limit
const endIndex = startIndex + limit
return filteredData.slice(startIndex, endIndex)
}, [filteredData, page, limit])
return {
data: {
campaigns: paginatedData,
total_count: filteredData.length
},
isLoading,
error: null,
isFetching: isLoading
}
} }
// Utility functions // Helper functions untuk campaign utilities
const getRewardTypeColor = (rewardType: Campaign['rewardType']): ThemeColor => { const getCampaignTypeColor = (type: string): ThemeColor => {
switch (rewardType) { switch (type) {
case 'point': case 'POINTS':
return 'primary' return 'primary'
case 'voucher': case 'TOKENS':
return 'success' return 'success'
case 'discount': case 'REWARD':
return 'warning' return 'warning'
default: case 'MIXED':
return 'info' return 'info'
}
}
const getRewardTypeIcon = (rewardType: Campaign['rewardType']): string => {
switch (rewardType) {
case 'point':
return 'tabler-coins'
case 'voucher':
return 'tabler-ticket'
case 'discount':
return 'tabler-percentage'
default: default:
return 'tabler-gift' return 'secondary'
}
}
const formatRewardValue = (rewardType: Campaign['rewardType'], rewardValue: number): string => {
switch (rewardType) {
case 'point':
return `${rewardValue} Poin`
case 'voucher':
return formatCurrency(rewardValue)
case 'discount':
return `${rewardValue}%`
default:
return rewardValue.toString()
} }
} }
@@ -324,23 +145,29 @@ const formatRewardValue = (rewardType: Campaign['rewardType'], rewardValue: numb
const columnHelper = createColumnHelper<CampaignWithAction>() const columnHelper = createColumnHelper<CampaignWithAction>()
const CampaignListTable = () => { const CampaignListTable = () => {
// States // States - PERSIS SAMA SEPERTI REWARD TABLE
const [addCampaignOpen, setAddCampaignOpen] = useState(false) const [addCampaignOpen, setAddCampaignOpen] = useState(false)
const [editCampaignData, setEditCampaignData] = useState<Campaign | undefined>(undefined) const [editCampaignData, setEditCampaignData] = useState<Campaign | undefined>(undefined)
const [rowSelection, setRowSelection] = useState({}) const [rowSelection, setRowSelection] = useState({})
const [globalFilter, setGlobalFilter] = useState('') const [globalFilter, setGlobalFilter] = useState('')
const [deleteCampaignOpen, setDeleteCampaignOpen] = useState(false)
const [campaignToDelete, setCampaignToDelete] = useState<Campaign | null>(null)
// FIX 1: PAGINATION SAMA SEPERTI REWARD (1-based, bukan 0-based)
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(10) const [pageSize, setPageSize] = useState(10)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const { deleteCampaign } = useCampaignsMutation()
const { data, isLoading, error, isFetching } = useCampaigns({ const { data, isLoading, error, isFetching } = useCampaigns({
page: currentPage, page: currentPage, // SAMA SEPERTI REWARD - langsung currentPage
limit: pageSize, limit: pageSize,
search search
}) })
const campaigns = data?.campaigns ?? [] const campaigns = data?.campaigns ?? []
const totalCount = data?.total_count ?? 0 const totalCount = data?.total ?? 0
// Hooks // Hooks
const { lang: locale } = useParams() const { lang: locale } = useParams()
@@ -360,11 +187,34 @@ const CampaignListTable = () => {
setAddCampaignOpen(true) setAddCampaignOpen(true)
} }
const handleDeleteCampaign = (campaignId: string) => { const handleDeleteCampaign = (campaign: Campaign) => {
if (confirm('Apakah Anda yakin ingin menghapus kampanye ini?')) { setCampaignToDelete(campaign)
console.log('Deleting campaign:', campaignId) setDeleteCampaignOpen(true)
// Add your delete logic here }
// deleteCampaign.mutate(campaignId)
// ADD NEW HANDLERS FOR DELETE DIALOG
const handleConfirmDelete = () => {
if (campaignToDelete) {
deleteCampaign.mutate(campaignToDelete.id, {
onSuccess: () => {
console.log('Campaign deleted successfully')
setDeleteCampaignOpen(false)
setCampaignToDelete(null)
// You might want to refetch data here
// refetch()
},
onError: error => {
console.error('Error deleting campaign:', error)
// Handle error (show toast, etc.)
}
})
}
}
const handleCloseDeleteDialog = () => {
if (!deleteCampaign.isPending) {
setDeleteCampaignOpen(false)
setCampaignToDelete(null)
} }
} }
@@ -402,7 +252,7 @@ const CampaignListTable = () => {
cell: ({ row }) => ( cell: ({ row }) => (
<div className='flex items-center gap-4'> <div className='flex items-center gap-4'>
<div className='flex flex-col'> <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'> <Typography className='font-medium cursor-pointer hover:underline text-primary'>
{row.original.name} {row.original.name}
</Typography> </Typography>
@@ -416,38 +266,23 @@ const CampaignListTable = () => {
</div> </div>
) )
}), }),
columnHelper.accessor('minimumPurchase', { columnHelper.accessor('type', {
header: 'Minimum Pembelian', header: 'Jenis Kampanye',
cell: ({ row }) => ( cell: ({ row }) => (
<div className='flex items-center gap-2'> <Chip
<Icon className='tabler-coin' sx={{ color: 'var(--mui-palette-primary-main)' }} /> label={row.original.type}
<Typography color='text.primary'>{formatCurrency(row.original.minimumPurchase)}</Typography> color={getCampaignTypeColor(row.original.type)}
</div> variant='tonal'
size='small'
/>
) )
}), }),
columnHelper.accessor('rewardType', { columnHelper.accessor('start_date', {
header: 'Jenis Reward',
cell: ({ row }) => (
<div className='flex items-center gap-2'>
<Icon
className={getRewardTypeIcon(row.original.rewardType)}
sx={{ color: `var(--mui-palette-${getRewardTypeColor(row.original.rewardType)}-main)` }}
/>
<Chip
label={formatRewardValue(row.original.rewardType, row.original.rewardValue)}
color={getRewardTypeColor(row.original.rewardType)}
variant='tonal'
size='small'
/>
</div>
)
}),
columnHelper.accessor('startDate', {
header: 'Periode Kampanye', header: 'Periode Kampanye',
cell: ({ row }) => ( cell: ({ row }) => (
<div className='flex flex-col'> <div className='flex flex-col'>
<Typography variant='body2' color='text.primary'> <Typography variant='body2' color='text.primary'>
{new Date(row.original.startDate).toLocaleDateString('id-ID', { {new Date(row.original.start_date).toLocaleDateString('id-ID', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric' day: 'numeric'
@@ -455,7 +290,7 @@ const CampaignListTable = () => {
</Typography> </Typography>
<Typography variant='body2' color='text.secondary'> <Typography variant='body2' color='text.secondary'>
s/d{' '} s/d{' '}
{new Date(row.original.endDate).toLocaleDateString('id-ID', { {new Date(row.original.end_date).toLocaleDateString('id-ID', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric' day: 'numeric'
@@ -464,22 +299,35 @@ const CampaignListTable = () => {
</div> </div>
) )
}), }),
columnHelper.accessor('isActive', { columnHelper.accessor('is_active', {
header: 'Status', header: 'Status',
cell: ({ row }) => ( cell: ({ row }) => {
<Chip const isActive = row.original.is_active
label={row.original.isActive ? 'Aktif' : 'Tidak Aktif'} const showOnApp = row.original.show_on_app
color={row.original.isActive ? 'success' : 'error'}
variant='tonal' return (
size='small' <div className='flex flex-col gap-1'>
/> <Chip
) label={isActive ? 'Aktif' : 'Tidak Aktif'}
color={isActive ? 'success' : 'error'}
variant='tonal'
size='small'
/>
{showOnApp && <Chip label='Tampil di App' color='info' variant='outlined' size='small' />}
</div>
)
}
}), }),
columnHelper.accessor('createdAt', { columnHelper.accessor('position', {
header: 'Posisi',
cell: ({ row }) => <Typography color='text.primary'>#{row.original.position}</Typography>
}),
columnHelper.accessor('created_at', {
header: 'Tanggal Dibuat', header: 'Tanggal Dibuat',
cell: ({ row }) => ( cell: ({ row }) => (
<Typography color='text.primary'> <Typography color='text.primary'>
{new Date(row.original.createdAt).toLocaleDateString('id-ID', { {/* FIX 3: FORMAT DATE YANG SAMA SEPERTI REWARD */}
{new Date(row.original.created_at).toLocaleDateString('id-ID', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric' day: 'numeric'
@@ -509,7 +357,7 @@ const CampaignListTable = () => {
icon: 'tabler-trash text-[22px]', icon: 'tabler-trash text-[22px]',
menuItemProps: { menuItemProps: {
className: 'flex items-center gap-2 text-textSecondary', className: 'flex items-center gap-2 text-textSecondary',
onClick: () => handleDeleteCampaign(row.original.id) onClick: () => handleDeleteCampaign(row.original)
} }
} }
]} ]}
@@ -523,8 +371,9 @@ const CampaignListTable = () => {
[locale, handleEditCampaign, handleDeleteCampaign] [locale, handleEditCampaign, handleDeleteCampaign]
) )
// FIX 4: TABLE CONFIG YANG SAMA PERSIS SEPERTI REWARD
const table = useReactTable({ const table = useReactTable({
data: campaigns as Campaign[], data: campaigns as Campaign[], // SAMA SEPERTI REWARD
columns, columns,
filterFns: { filterFns: {
fuzzy: fuzzyFilter fuzzy: fuzzyFilter
@@ -533,15 +382,15 @@ const CampaignListTable = () => {
rowSelection, rowSelection,
globalFilter, globalFilter,
pagination: { pagination: {
pageIndex: currentPage, pageIndex: currentPage, // SAMA SEPERTI REWARD - langsung currentPage
pageSize pageSize
} }
}, },
enableRowSelection: true, enableRowSelection: true,
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
manualPagination: true, manualPagination: true, // SAMA SEPERTI REWARD
pageCount: Math.ceil(totalCount / pageSize) pageCount: Math.ceil(totalCount / pageSize) // SAMA SEPERTI REWARD
}) })
return ( return (
@@ -661,6 +510,13 @@ const CampaignListTable = () => {
/> />
</Card> </Card>
<AddEditCampaignDrawer open={addCampaignOpen} handleClose={handleCloseCampaignDrawer} data={editCampaignData} /> <AddEditCampaignDrawer open={addCampaignOpen} handleClose={handleCloseCampaignDrawer} data={editCampaignData} />
<DeleteCampaignDialog
open={deleteCampaignOpen}
onClose={handleCloseDeleteDialog}
onConfirm={handleConfirmDelete}
campaign={campaignToDelete}
isDeleting={deleteCampaign?.isPending || false}
/>
</> </>
) )
} }
@@ -0,0 +1,157 @@
// React Imports
import { useState } from 'react'
// MUI Imports
import Dialog from '@mui/material/Dialog'
import DialogTitle from '@mui/material/DialogTitle'
import DialogContent from '@mui/material/DialogContent'
import DialogActions from '@mui/material/DialogActions'
import DialogContentText from '@mui/material/DialogContentText'
import Button from '@mui/material/Button'
import Typography from '@mui/material/Typography'
import Box from '@mui/material/Box'
import Alert from '@mui/material/Alert'
import Chip from '@mui/material/Chip'
// Types
import { Campaign } from '@/types/services/campaign'
type Props = {
open: boolean
onClose: () => void
onConfirm: () => void
campaign: Campaign | null
isDeleting?: boolean
}
const DeleteCampaignDialog = ({ open, onClose, onConfirm, campaign, isDeleting = false }: Props) => {
if (!campaign) return null
const getCampaignTypeColor = (type: string) => {
switch (type) {
case 'POINTS':
return 'primary'
case 'TOKENS':
return 'success'
case 'REWARD':
return 'warning'
case 'MIXED':
return 'info'
default:
return 'secondary'
}
}
return (
<Dialog
open={open}
onClose={onClose}
maxWidth='sm'
fullWidth
aria-labelledby='delete-dialog-title'
aria-describedby='delete-dialog-description'
>
<DialogTitle id='delete-dialog-title'>
<Box display='flex' alignItems='center' gap={2}>
<i className='tabler-trash text-red-500 text-2xl' />
<Typography variant='h6'>Hapus Kampanye</Typography>
</Box>
</DialogTitle>
<DialogContent>
<DialogContentText id='delete-dialog-description' className='mb-4'>
Apakah Anda yakin ingin menghapus kampanye berikut?
</DialogContentText>
<Box
sx={{
backgroundColor: 'grey.50',
p: 2,
borderRadius: 1,
border: '1px solid',
borderColor: 'grey.200',
mb: 2
}}
>
<Box display='flex' alignItems='center' gap={2} className='mb-2'>
<Typography variant='subtitle2' className='font-medium'>
{campaign.name}
</Typography>
<Chip label={campaign.type} color={getCampaignTypeColor(campaign.type)} variant='tonal' size='small' />
</Box>
{campaign.description && (
<Typography variant='body2' color='text.secondary' className='mb-2'>
{campaign.description}
</Typography>
)}
<Box display='flex' flexDirection='column' gap={1}>
<Typography variant='body2' color='text.secondary'>
Periode:{' '}
{new Date(campaign.start_date).toLocaleDateString('id-ID', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}{' '}
-{' '}
{new Date(campaign.end_date).toLocaleDateString('id-ID', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</Typography>
<Typography variant='body2' color='text.secondary'>
Status: {campaign.is_active ? 'Aktif' : 'Tidak Aktif'}
{campaign.show_on_app && ' • Tampil di App'}
</Typography>
<Typography variant='body2' color='text.secondary'>
Dibuat:{' '}
{new Date(campaign.created_at).toLocaleDateString('id-ID', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</Typography>
</Box>
</Box>
<Alert severity='warning' sx={{ mb: 2 }}>
<Typography variant='body2'>
<strong>Peringatan:</strong> Tindakan ini tidak dapat dibatalkan. Semua data yang terkait dengan kampanye
ini akan dihapus secara permanen, termasuk:
</Typography>
<Box component='ul' sx={{ mt: 1, mb: 0, pl: 2 }}>
<li>Aturan kampanye (rules)</li>
<li>Riwayat penggunaan kampanye</li>
<li>Data analitik kampanye</li>
</Box>
</Alert>
<DialogContentText>
Pastikan tidak ada pengguna yang masih menggunakan kampanye ini dan tidak ada transaksi yang sedang berjalan
sebelum menghapus.
</DialogContentText>
</DialogContent>
<DialogActions className='p-4'>
<Button onClick={onClose} variant='outlined' disabled={isDeleting}>
Batal
</Button>
<Button
onClick={onConfirm}
color='error'
variant='contained'
disabled={isDeleting}
startIcon={isDeleting ? <i className='tabler-loader animate-spin' /> : <i className='tabler-trash' />}
>
{isDeleting ? 'Menghapus...' : 'Hapus'}
</Button>
</DialogActions>
</Dialog>
)
}
export default DeleteCampaignDialog
@@ -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
@@ -18,103 +18,110 @@ import Avatar from '@mui/material/Avatar'
import Card from '@mui/material/Card' import Card from '@mui/material/Card'
import CardContent from '@mui/material/CardContent' import CardContent from '@mui/material/CardContent'
import FormHelperText from '@mui/material/FormHelperText' import FormHelperText from '@mui/material/FormHelperText'
import TextField from '@mui/material/TextField'
import Accordion from '@mui/material/Accordion'
import AccordionSummary from '@mui/material/AccordionSummary'
import AccordionDetails from '@mui/material/AccordionDetails'
import List from '@mui/material/List'
import ListItem from '@mui/material/ListItem'
import ListItemText from '@mui/material/ListItemText'
import ListItemSecondaryAction from '@mui/material/ListItemSecondaryAction'
// Third-party Imports // Third-party Imports
import { useForm, Controller } from 'react-hook-form' import { useForm, Controller, useFieldArray } from 'react-hook-form'
// Component Imports // Component Imports
import CustomTextField from '@core/components/mui/TextField' import CustomTextField from '@core/components/mui/TextField'
import MultipleImageUpload from '@/components/MultipleImageUpload' // Import the component
// Types // Import the actual upload mutation
export interface RewardCatalogType { import { useFilesMutation } from '@/services/mutations/files'
id: string import { useRewardsMutation } from '@/services/mutations/reward'
name: string
description?: string // Updated Types based on new API structure
pointCost: number export interface TermsAndConditions {
stock?: number sections: TncSection[]
isActive: boolean expiry_days: number
validUntil?: Date }
imageUrl?: string
createdAt: Date export interface TncSection {
updatedAt: Date title: string
rules: string[]
} }
export interface RewardRequest { export interface RewardRequest {
name: string name: string // required, 1150 chars
description?: string reward_type: 'VOUCHER' | 'PHYSICAL' | 'DIGITAL' // enum
pointCost: number cost_points: number // min 1
stock?: number stock?: number
isActive: boolean max_per_customer: number // min 1
validUntil?: Date tnc?: TermsAndConditions
imageUrl?: string images?: string[] // Add images to request
category?: string }
terms?: string
export interface Reward {
id: string // uuid
name: string
reward_type: 'VOUCHER' | 'PHYSICAL' | 'DIGITAL'
cost_points: number
stock?: number
max_per_customer: number
tnc?: TermsAndConditions
metadata?: Record<string, any>
images?: string[]
created_at: string // ISO date-time
updated_at: string // ISO date-time
}
// Type for uploaded image in the component
type UploadedImage = {
id: string
url: string
name: string
size: number
} }
type Props = { type Props = {
open: boolean open: boolean
handleClose: () => void handleClose: () => void
data?: RewardCatalogType // Data reward untuk edit (jika ada) data?: Reward // Data reward untuk edit (jika ada)
} }
type FormValidateType = { type FormValidateType = {
name: string name: string
description: string reward_type: 'VOUCHER' | 'PHYSICAL' | 'DIGITAL'
pointCost: number cost_points: number
stock: number | '' stock: number | ''
isActive: boolean max_per_customer: number
validUntil: string
imageUrl: string
category: string
terms: string
hasUnlimitedStock: boolean hasUnlimitedStock: boolean
hasValidUntil: boolean hasTnc: boolean
tnc_expiry_days: number
tnc_sections: TncSection[]
uploadedImages: UploadedImage[] // Changed from images array to uploaded images
metadata: Record<string, any>
} }
// Initial form data // Initial form data
const initialData: FormValidateType = { const initialData: FormValidateType = {
name: '', name: '',
description: '', reward_type: 'VOUCHER',
pointCost: 100, cost_points: 100,
stock: '', stock: '',
isActive: true, max_per_customer: 1,
validUntil: '',
imageUrl: '',
category: 'voucher',
terms: '',
hasUnlimitedStock: false, hasUnlimitedStock: false,
hasValidUntil: false hasTnc: false,
tnc_expiry_days: 30,
tnc_sections: [],
uploadedImages: [], // Initialize as empty array
metadata: {}
} }
// Mock mutation hooks (replace with actual hooks) // Reward types
const useRewardMutation = () => { const REWARD_TYPES = [
const createReward = { { value: 'VOUCHER', label: 'Voucher' },
mutate: (data: RewardRequest, options?: { onSuccess?: () => void }) => { { value: 'PHYSICAL', label: 'Barang Fisik' },
console.log('Creating reward:', data) { value: 'DIGITAL', label: 'Digital' }
setTimeout(() => options?.onSuccess?.(), 1000) ] as const
}
}
const updateReward = {
mutate: (data: { id: string; payload: RewardRequest }, options?: { onSuccess?: () => void }) => {
console.log('Updating reward:', data)
setTimeout(() => options?.onSuccess?.(), 1000)
}
}
return { createReward, updateReward }
}
// Reward categories
const REWARD_CATEGORIES = [
{ value: 'voucher', label: 'Voucher Diskon' },
{ value: 'cashback', label: 'Cashback' },
{ value: 'shipping', label: 'Gratis Ongkir' },
{ value: 'gift_card', label: 'Gift Card' },
{ value: 'physical', label: 'Barang Fisik' },
{ value: 'experience', label: 'Pengalaman' },
{ value: 'service', label: 'Layanan' }
]
const AddEditRewardDrawer = (props: Props) => { const AddEditRewardDrawer = (props: Props) => {
// Props // Props
@@ -123,9 +130,10 @@ const AddEditRewardDrawer = (props: Props) => {
// States // States
const [showMore, setShowMore] = useState(false) const [showMore, setShowMore] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [imagePreview, setImagePreview] = useState<string | null>(null) const [uploadingFiles, setUploadingFiles] = useState<Set<string>>(new Set())
const { createReward, updateReward } = useRewardMutation() const { createReward, updateReward } = useRewardsMutation()
const { mutate: uploadFile, isPending: isFileUploading } = useFilesMutation().uploadFile
// Determine if this is edit mode // Determine if this is edit mode
const isEditMode = Boolean(data?.id) const isEditMode = Boolean(data?.id)
@@ -142,50 +150,57 @@ const AddEditRewardDrawer = (props: Props) => {
defaultValues: initialData defaultValues: initialData
}) })
const watchedImageUrl = watch('imageUrl') // Field arrays for dynamic sections
const {
fields: tncSectionFields,
append: appendTncSection,
remove: removeTncSection
} = useFieldArray({
control,
name: 'tnc_sections'
})
const watchedUploadedImages = watch('uploadedImages')
const watchedHasUnlimitedStock = watch('hasUnlimitedStock') const watchedHasUnlimitedStock = watch('hasUnlimitedStock')
const watchedHasValidUntil = watch('hasValidUntil') const watchedHasTnc = watch('hasTnc')
const watchedStock = watch('stock') const watchedStock = watch('stock')
const watchedPointCost = watch('pointCost') const watchedCostPoints = watch('cost_points')
// Effect to populate form when editing // Effect to populate form when editing
useEffect(() => { useEffect(() => {
if (isEditMode && data) { if (isEditMode && data) {
// Convert existing images to UploadedImage format
const existingImages: UploadedImage[] = (data.images || []).map((url, index) => ({
id: `existing_${index}`,
url: url,
name: `Image ${index + 1}`,
size: 0 // We don't have size info for existing images
}))
// Populate form with existing data // Populate form with existing data
const formData: FormValidateType = { const formData: FormValidateType = {
name: data.name || '', name: data.name || '',
description: data.description || '', reward_type: data.reward_type || 'VOUCHER',
pointCost: data.pointCost || 100, cost_points: data.cost_points || 100,
stock: data.stock ?? '', stock: data.stock ?? '',
isActive: data.isActive ?? true, max_per_customer: data.max_per_customer || 1,
validUntil: data.validUntil ? new Date(data.validUntil).toISOString().split('T')[0] : '',
imageUrl: data.imageUrl || '',
category: 'voucher', // Default category
terms: '',
hasUnlimitedStock: data.stock === undefined || data.stock === null, hasUnlimitedStock: data.stock === undefined || data.stock === null,
hasValidUntil: Boolean(data.validUntil) hasTnc: Boolean(data.tnc),
tnc_expiry_days: data.tnc?.expiry_days || 30,
tnc_sections: data.tnc?.sections || [],
uploadedImages: existingImages,
metadata: data.metadata || {}
} }
resetForm(formData) resetForm(formData)
setShowMore(true) // Always show more for edit mode setShowMore(true) // Always show more for edit mode
setImagePreview(data.imageUrl || null)
} else { } else {
// Reset to initial data for add mode // Reset to initial data for add mode
resetForm(initialData) resetForm(initialData)
setShowMore(false) setShowMore(false)
setImagePreview(null)
} }
}, [data, isEditMode, resetForm]) }, [data, isEditMode, resetForm])
// Handle image URL change
useEffect(() => {
if (watchedImageUrl) {
setImagePreview(watchedImageUrl)
} else {
setImagePreview(null)
}
}, [watchedImageUrl])
// Handle unlimited stock toggle // Handle unlimited stock toggle
useEffect(() => { useEffect(() => {
if (watchedHasUnlimitedStock) { if (watchedHasUnlimitedStock) {
@@ -193,28 +208,90 @@ const AddEditRewardDrawer = (props: Props) => {
} }
}, [watchedHasUnlimitedStock, setValue]) }, [watchedHasUnlimitedStock, setValue])
// Handle valid until toggle // Image upload handlers
useEffect(() => { const handleSingleUpload = async (file: File): Promise<string> => {
if (!watchedHasValidUntil) { const fileId = `${file.name}-${Date.now()}`
setValue('validUntil', '')
return new Promise((resolve, reject) => {
// Add file to uploading set
setUploadingFiles(prev => new Set(prev).add(fileId))
const formData = new FormData()
formData.append('file', file)
formData.append('file_type', 'image')
formData.append('description', 'reward image upload')
uploadFile(formData, {
onSuccess: response => {
// Remove file from uploading set
setUploadingFiles(prev => {
const newSet = new Set(prev)
newSet.delete(fileId)
return newSet
})
resolve(response.file_url)
},
onError: error => {
// Remove file from uploading set
setUploadingFiles(prev => {
const newSet = new Set(prev)
newSet.delete(fileId)
return newSet
})
reject(error)
}
})
})
}
const handleMultipleUpload = async (files: File[]): Promise<string[]> => {
const uploadedUrls: string[] = []
try {
// Sequential upload to avoid overwhelming the server
for (const file of files) {
const url = await handleSingleUpload(file)
uploadedUrls.push(url)
}
return uploadedUrls
} catch (error) {
console.error('Failed to upload images:', error)
throw error
} }
}, [watchedHasValidUntil, setValue]) }
const handleImagesChange = (images: UploadedImage[]) => {
setValue('uploadedImages', images)
}
const handleImageRemove = (imageId: string) => {
const currentImages = watchedUploadedImages || []
const updatedImages = currentImages.filter(img => img.id !== imageId)
setValue('uploadedImages', updatedImages)
}
const handleFormSubmit = async (formData: FormValidateType) => { const handleFormSubmit = async (formData: FormValidateType) => {
try { try {
setIsSubmitting(true) setIsSubmitting(true)
// Extract image URLs from uploaded images
const imageUrls = formData.uploadedImages.map(img => img.url)
// Create RewardRequest object // Create RewardRequest object
const rewardRequest: RewardRequest = { const rewardRequest: RewardRequest = {
name: formData.name, name: formData.name,
description: formData.description || undefined, reward_type: formData.reward_type,
pointCost: formData.pointCost, cost_points: formData.cost_points,
stock: formData.hasUnlimitedStock ? undefined : (formData.stock as number) || undefined, stock: formData.hasUnlimitedStock ? undefined : (formData.stock as number) || undefined,
isActive: formData.isActive, max_per_customer: formData.max_per_customer,
validUntil: formData.hasValidUntil && formData.validUntil ? new Date(formData.validUntil) : undefined, images: imageUrls.length > 0 ? imageUrls : undefined, // Include images in request
imageUrl: formData.imageUrl || undefined, tnc:
category: formData.category || undefined, formData.hasTnc && formData.tnc_sections.length > 0
terms: formData.terms || undefined ? {
sections: formData.tnc_sections,
expiry_days: formData.tnc_expiry_days
}
: undefined
} }
if (isEditMode && data?.id) { if (isEditMode && data?.id) {
@@ -249,7 +326,7 @@ const AddEditRewardDrawer = (props: Props) => {
handleClose() handleClose()
resetForm(initialData) resetForm(initialData)
setShowMore(false) setShowMore(false)
setImagePreview(null) setUploadingFiles(new Set())
} }
const formatPoints = (value: number) => { const formatPoints = (value: number) => {
@@ -262,6 +339,13 @@ const AddEditRewardDrawer = (props: Props) => {
return `${watchedStock} item` return `${watchedStock} item`
} }
const addTncSection = () => {
appendTncSection({ title: '', rules: [''] })
}
// Check if any files are currently uploading
const isAnyFileUploading = uploadingFiles.size > 0 || isFileUploading
return ( return (
<Drawer <Drawer
open={open} open={open}
@@ -301,29 +385,6 @@ const AddEditRewardDrawer = (props: Props) => {
<Box sx={{ flex: 1, overflowY: 'auto' }}> <Box sx={{ flex: 1, overflowY: 'auto' }}>
<form id='reward-form' onSubmit={handleSubmit(handleFormSubmit)}> <form id='reward-form' onSubmit={handleSubmit(handleFormSubmit)}>
<div className='flex flex-col gap-6 p-6'> <div className='flex flex-col gap-6 p-6'>
{/* Image Preview */}
{imagePreview && (
<Card variant='outlined' sx={{ mb: 2 }}>
<CardContent sx={{ p: 2 }}>
<Typography variant='subtitle2' className='mb-2'>
Preview Gambar
</Typography>
<Avatar
src={imagePreview}
sx={{
width: 80,
height: 80,
mx: 'auto',
mb: 1
}}
variant='rounded'
>
<i className='tabler-gift text-2xl' />
</Avatar>
</CardContent>
</Card>
)}
{/* Nama Reward */} {/* Nama Reward */}
<div> <div>
<Typography variant='body2' className='mb-2'> <Typography variant='body2' className='mb-2'>
@@ -332,39 +393,43 @@ const AddEditRewardDrawer = (props: Props) => {
<Controller <Controller
name='name' name='name'
control={control} control={control}
rules={{ required: 'Nama reward wajib diisi' }} rules={{
required: 'Nama reward wajib diisi',
minLength: { value: 1, message: 'Nama reward minimal 1 karakter' },
maxLength: { value: 150, message: 'Nama reward maksimal 150 karakter' }
}}
render={({ field }) => ( render={({ field }) => (
<CustomTextField <CustomTextField
{...field} {...field}
fullWidth fullWidth
placeholder='Masukkan nama reward' placeholder='Masukkan nama reward'
error={!!errors.name} error={!!errors.name}
helperText={errors.name?.message} helperText={errors.name?.message || `${field.value.length}/150 karakter`}
/> />
)} )}
/> />
</div> </div>
{/* Kategori Reward */} {/* Tipe Reward */}
<div> <div>
<Typography variant='body2' className='mb-2'> <Typography variant='body2' className='mb-2'>
Kategori Reward <span className='text-red-500'>*</span> Tipe Reward <span className='text-red-500'>*</span>
</Typography> </Typography>
<Controller <Controller
name='category' name='reward_type'
control={control} control={control}
rules={{ required: 'Kategori reward wajib dipilih' }} rules={{ required: 'Tipe reward wajib dipilih' }}
render={({ field }) => ( render={({ field }) => (
<CustomTextField <CustomTextField
{...field} {...field}
select select
fullWidth fullWidth
error={!!errors.category} error={!!errors.reward_type}
helperText={errors.category?.message} helperText={errors.reward_type?.message}
> >
{REWARD_CATEGORIES.map(category => ( {REWARD_TYPES.map(type => (
<MenuItem key={category.value} value={category.value}> <MenuItem key={type.value} value={type.value}>
{category.label} {type.label}
</MenuItem> </MenuItem>
))} ))}
</CustomTextField> </CustomTextField>
@@ -372,13 +437,13 @@ const AddEditRewardDrawer = (props: Props) => {
/> />
</div> </div>
{/* Point Cost */} {/* Cost Points */}
<div> <div>
<Typography variant='body2' className='mb-2'> <Typography variant='body2' className='mb-2'>
Biaya Poin <span className='text-red-500'>*</span> Biaya Poin <span className='text-red-500'>*</span>
</Typography> </Typography>
<Controller <Controller
name='pointCost' name='cost_points'
control={control} control={control}
rules={{ rules={{
required: 'Biaya poin wajib diisi', required: 'Biaya poin wajib diisi',
@@ -393,8 +458,8 @@ const AddEditRewardDrawer = (props: Props) => {
fullWidth fullWidth
type='number' type='number'
placeholder='100' placeholder='100'
error={!!errors.pointCost} error={!!errors.cost_points}
helperText={errors.pointCost?.message || (field.value > 0 ? formatPoints(field.value) : '')} helperText={errors.cost_points?.message || (field.value > 0 ? formatPoints(field.value) : '')}
InputProps={{ InputProps={{
startAdornment: ( startAdornment: (
<InputAdornment position='start'> <InputAdornment position='start'>
@@ -408,6 +473,38 @@ const AddEditRewardDrawer = (props: Props) => {
/> />
</div> </div>
{/* Max Per Customer */}
<div>
<Typography variant='body2' className='mb-2'>
Maksimal per Pelanggan <span className='text-red-500'>*</span>
</Typography>
<Controller
name='max_per_customer'
control={control}
rules={{
required: 'Maksimal per pelanggan wajib diisi',
min: {
value: 1,
message: 'Minimal 1 per pelanggan'
}
}}
render={({ field }) => (
<CustomTextField
{...field}
fullWidth
type='number'
placeholder='1'
error={!!errors.max_per_customer}
helperText={errors.max_per_customer?.message}
InputProps={{
startAdornment: <InputAdornment position='start'>Max</InputAdornment>
}}
onChange={e => field.onChange(Number(e.target.value))}
/>
)}
/>
</div>
{/* Stock Management */} {/* Stock Management */}
<div> <div>
<Typography variant='body2' className='mb-2'> <Typography variant='body2' className='mb-2'>
@@ -454,20 +551,6 @@ const AddEditRewardDrawer = (props: Props) => {
)} )}
</div> </div>
{/* Status Aktif */}
<div>
<Controller
name='isActive'
control={control}
render={({ field }) => (
<FormControlLabel
control={<Switch checked={field.value} onChange={field.onChange} color='primary' />}
label='Reward Aktif'
/>
)}
/>
</div>
{/* Tampilkan selengkapnya */} {/* Tampilkan selengkapnya */}
{!showMore && ( {!showMore && (
<Button <Button
@@ -484,89 +567,32 @@ const AddEditRewardDrawer = (props: Props) => {
{/* Konten tambahan */} {/* Konten tambahan */}
{showMore && ( {showMore && (
<> <>
{/* Description */} {/* Multiple Image Upload Section */}
<div> <div>
<Typography variant='body2' className='mb-2'> <Typography variant='body2' className='mb-3'>
Deskripsi Reward Gambar Reward
</Typography> </Typography>
<Controller <MultipleImageUpload
name='description' title={null} // No title since we have our own
control={control} onUpload={handleMultipleUpload}
render={({ field }) => ( onSingleUpload={handleSingleUpload}
<CustomTextField currentImages={watchedUploadedImages || []}
{...field} onImagesChange={handleImagesChange}
fullWidth onImageRemove={handleImageRemove}
placeholder='Deskripsi detail tentang reward' isUploading={isAnyFileUploading}
multiline maxFiles={5}
rows={3} maxFileSize={5 * 1024 * 1024} // 5MB
/> acceptedFileTypes={['image/jpeg', 'image/png', 'image/webp']}
)} showUrlOption={false}
uploadMode='individual'
uploadButtonText='Upload Gambar'
browseButtonText='Pilih Gambar'
dragDropText='Drag & drop gambar reward di sini'
replaceText='Drop gambar untuk menambah lebih banyak'
maxFilesText='Maksimal {max} gambar'
disabled={isSubmitting}
/> />
</div> <FormHelperText>Format yang didukung: JPG, PNG, WebP. Maksimal 5MB per file.</FormHelperText>
{/* Image URL */}
<div>
<Typography variant='body2' className='mb-2'>
URL Gambar
</Typography>
<Controller
name='imageUrl'
control={control}
render={({ field }) => (
<CustomTextField
{...field}
fullWidth
placeholder='https://example.com/image.jpg'
type='url'
InputProps={{
startAdornment: (
<InputAdornment position='start'>
<i className='tabler-photo' />
</InputAdornment>
)
}}
/>
)}
/>
</div>
{/* Valid Until */}
<div>
<Typography variant='body2' className='mb-2'>
Masa Berlaku
</Typography>
<Controller
name='hasValidUntil'
control={control}
render={({ field }) => (
<FormControlLabel
control={<Switch checked={field.value} onChange={field.onChange} color='primary' />}
label='Memiliki batas waktu'
className='mb-2'
/>
)}
/>
{watchedHasValidUntil && (
<Controller
name='validUntil'
control={control}
rules={{
required: watchedHasValidUntil ? 'Tanggal kadaluarsa wajib diisi' : false
}}
render={({ field }) => (
<CustomTextField
{...field}
fullWidth
type='date'
error={!!errors.validUntil}
helperText={errors.validUntil?.message}
InputLabelProps={{
shrink: true
}}
/>
)}
/>
)}
</div> </div>
{/* Terms & Conditions */} {/* Terms & Conditions */}
@@ -575,18 +601,112 @@ const AddEditRewardDrawer = (props: Props) => {
Syarat & Ketentuan Syarat & Ketentuan
</Typography> </Typography>
<Controller <Controller
name='terms' name='hasTnc'
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<CustomTextField <FormControlLabel
{...field} control={<Switch checked={field.value} onChange={field.onChange} color='primary' />}
fullWidth label='Memiliki syarat & ketentuan'
placeholder='Syarat dan ketentuan penggunaan reward' className='mb-2'
multiline
rows={3}
/> />
)} )}
/> />
{watchedHasTnc && (
<>
<Controller
name='tnc_expiry_days'
control={control}
rules={{
required: watchedHasTnc ? 'Masa berlaku T&C wajib diisi' : false,
min: { value: 1, message: 'Minimal 1 hari' }
}}
render={({ field }) => (
<CustomTextField
{...field}
fullWidth
type='number'
label='Masa Berlaku T&C (hari)'
placeholder='30'
error={!!errors.tnc_expiry_days}
helperText={errors.tnc_expiry_days?.message}
className='mb-4'
onChange={e => field.onChange(Number(e.target.value))}
/>
)}
/>
{tncSectionFields.map((section, sectionIndex) => (
<Accordion key={section.id} className='mb-2'>
<AccordionSummary expandIcon={<i className='tabler-chevron-down' />}>
<Typography>Bagian {sectionIndex + 1}</Typography>
</AccordionSummary>
<AccordionDetails>
<Controller
name={`tnc_sections.${sectionIndex}.title`}
control={control}
rules={{ required: 'Judul bagian wajib diisi' }}
render={({ field }) => (
<CustomTextField
{...field}
fullWidth
label='Judul Bagian'
placeholder='Judul bagian'
className='mb-2'
/>
)}
/>
<Typography variant='body2' className='mb-2'>
Aturan:
</Typography>
{section.rules?.map((rule, ruleIndex) => (
<Controller
key={ruleIndex}
name={`tnc_sections.${sectionIndex}.rules.${ruleIndex}`}
control={control}
render={({ field }) => (
<CustomTextField
{...field}
fullWidth
placeholder={`Aturan ${ruleIndex + 1}`}
className='mb-1'
multiline
rows={2}
/>
)}
/>
))}
<Box className='flex gap-2 mt-2'>
<Button
size='small'
onClick={() => {
const currentSection = watch(`tnc_sections.${sectionIndex}`)
setValue(`tnc_sections.${sectionIndex}.rules`, [...(currentSection.rules || []), ''])
}}
>
+ Aturan
</Button>
<Button size='small' color='error' onClick={() => removeTncSection(sectionIndex)}>
Hapus Bagian
</Button>
</Box>
</AccordionDetails>
</Accordion>
))}
<Button
variant='outlined'
size='small'
onClick={addTncSection}
startIcon={<i className='tabler-plus' />}
className='mb-4'
>
Tambah Bagian T&C
</Button>
</>
)}
</div> </div>
{/* Sembunyikan */} {/* Sembunyikan */}
@@ -618,13 +738,18 @@ const AddEditRewardDrawer = (props: Props) => {
}} }}
> >
<div className='flex items-center gap-4'> <div className='flex items-center gap-4'>
<Button variant='contained' type='submit' form='reward-form' disabled={isSubmitting}> <Button variant='contained' type='submit' form='reward-form' disabled={isSubmitting || isAnyFileUploading}>
{isSubmitting ? (isEditMode ? 'Mengupdate...' : 'Menyimpan...') : isEditMode ? 'Update' : 'Simpan'} {isSubmitting ? (isEditMode ? 'Mengupdate...' : 'Menyimpan...') : isEditMode ? 'Update' : 'Simpan'}
</Button> </Button>
<Button variant='outlined' color='error' onClick={handleReset} disabled={isSubmitting}> <Button variant='outlined' color='error' onClick={handleReset} disabled={isSubmitting || isAnyFileUploading}>
Batal Batal
</Button> </Button>
</div> </div>
{isAnyFileUploading && (
<Typography variant='caption' color='textSecondary' className='mt-2'>
Sedang mengupload gambar... ({uploadingFiles.size} file)
</Typography>
)}
</Box> </Box>
</Drawer> </Drawer>
) )
@@ -0,0 +1,164 @@
// React Imports
import { useState } from 'react'
// MUI Imports
import Dialog from '@mui/material/Dialog'
import DialogTitle from '@mui/material/DialogTitle'
import DialogContent from '@mui/material/DialogContent'
import DialogActions from '@mui/material/DialogActions'
import DialogContentText from '@mui/material/DialogContentText'
import Button from '@mui/material/Button'
import Typography from '@mui/material/Typography'
import Box from '@mui/material/Box'
import Alert from '@mui/material/Alert'
import Chip from '@mui/material/Chip'
// Component Imports
import CustomAvatar from '@core/components/mui/Avatar'
// Utils
import { getInitials } from '@/utils/getInitials'
// Types
import { Reward } from '@/types/services/reward'
import type { ThemeColor } from '@core/types'
type Props = {
open: boolean
onClose: () => void
onConfirm: () => void
reward: Reward | null
isDeleting?: boolean
}
// Helper function to get reward type color
const getRewardTypeColor = (type: string): ThemeColor => {
switch (type) {
case 'VOUCHER':
return 'info'
case 'PHYSICAL':
return 'success'
case 'DIGITAL':
return 'warning'
default:
return 'primary'
}
}
const DeleteRewardDialog = ({ open, onClose, onConfirm, reward, isDeleting = false }: Props) => {
if (!reward) return null
return (
<Dialog
open={open}
onClose={onClose}
maxWidth='sm'
fullWidth
aria-labelledby='delete-dialog-title'
aria-describedby='delete-dialog-description'
>
<DialogTitle id='delete-dialog-title'>
<Box display='flex' alignItems='center' gap={2}>
<i className='tabler-trash text-red-500 text-2xl' />
<Typography variant='h6'>Hapus Reward</Typography>
</Box>
</DialogTitle>
<DialogContent>
<DialogContentText id='delete-dialog-description' className='mb-4'>
Apakah Anda yakin ingin menghapus reward berikut?
</DialogContentText>
<Box
sx={{
backgroundColor: 'grey.50',
p: 2,
borderRadius: 1,
border: '1px solid',
borderColor: 'grey.200',
mb: 2
}}
>
{/* Reward Info with Avatar */}
<Box display='flex' alignItems='center' gap={2} className='mb-3'>
<CustomAvatar src={reward.images?.[0]} size={50}>
{getInitials(reward.name)}
</CustomAvatar>
<Box>
<Typography variant='subtitle2' className='font-medium mb-1'>
{reward.name}
</Typography>
<Chip
label={reward.reward_type}
color={getRewardTypeColor(reward.reward_type)}
variant='tonal'
size='small'
/>
</Box>
</Box>
{/* Reward Details */}
<Box display='flex' flexDirection='column' gap={1}>
<Typography variant='body2' color='text.secondary'>
<strong>Biaya Poin:</strong> {new Intl.NumberFormat('id-ID').format(reward.cost_points)} poin
</Typography>
{reward.stock !== undefined && (
<Typography variant='body2' color='text.secondary'>
<strong>Stok:</strong>{' '}
{reward.stock === 0 ? 'Habis' : reward.stock === null ? 'Unlimited' : reward.stock}
</Typography>
)}
<Typography variant='body2' color='text.secondary'>
<strong>Maks per Customer:</strong> {reward.max_per_customer} item
</Typography>
{reward.tnc?.expiry_days && (
<Typography variant='body2' color='text.secondary'>
<strong>Berlaku Hingga:</strong> {reward.tnc.expiry_days} hari
</Typography>
)}
<Typography variant='body2' color='text.secondary'>
<strong>Dibuat:</strong>{' '}
{new Date(reward.created_at).toLocaleDateString('id-ID', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</Typography>
</Box>
</Box>
<Alert severity='warning' sx={{ mb: 2 }}>
<Typography variant='body2'>
<strong>Peringatan:</strong> Tindakan ini tidak dapat dibatalkan. Semua data yang terkait dengan reward ini
akan dihapus secara permanen.
</Typography>
</Alert>
<DialogContentText>
Pastikan tidak ada pengguna yang masih memiliki atau menukarkan reward ini sebelum menghapus.
</DialogContentText>
</DialogContent>
<DialogActions className='p-4'>
<Button onClick={onClose} variant='outlined' disabled={isDeleting}>
Batal
</Button>
<Button
onClick={onConfirm}
color='error'
variant='contained'
disabled={isDeleting}
startIcon={isDeleting ? <i className='tabler-loader animate-spin' /> : <i className='tabler-trash' />}
>
{isDeleting ? 'Menghapus...' : 'Hapus'}
</Button>
</DialogActions>
</Dialog>
)
}
export default DeleteRewardDialog
@@ -57,20 +57,10 @@ import { formatCurrency } from '@/utils/transform'
import tableStyles from '@core/styles/table.module.css' import tableStyles from '@core/styles/table.module.css'
import Loading from '@/components/layout/shared/Loading' import Loading from '@/components/layout/shared/Loading'
import AddEditRewardDrawer from './AddEditRewardDrawer' import AddEditRewardDrawer from './AddEditRewardDrawer'
import { Reward } from '@/types/services/reward'
// Reward Catalog Type Interface import { useRewards } from '@/services/queries/reward'
export interface RewardCatalogType { import { useRewardsMutation } from '@/services/mutations/reward'
id: string import DeleteRewardDialog from './DeleteRewardDialog'
name: string
description?: string
pointCost: number
stock?: number
isActive: boolean
validUntil?: Date
imageUrl?: string
createdAt: Date
updatedAt: Date
}
declare module '@tanstack/table-core' { declare module '@tanstack/table-core' {
interface FilterFns { interface FilterFns {
@@ -81,7 +71,7 @@ declare module '@tanstack/table-core' {
} }
} }
type RewardCatalogTypeWithAction = RewardCatalogType & { type RewardWithAction = Reward & {
action?: string action?: string
} }
@@ -130,206 +120,38 @@ const DebouncedInput = ({
return <CustomTextField {...props} value={value} onChange={e => setValue(e.target.value)} /> return <CustomTextField {...props} value={value} onChange={e => setValue(e.target.value)} />
} }
// Dummy data for reward catalog // Helper function untuk format points - SAMA SEPERTI TIER TABLE
const DUMMY_REWARD_DATA: RewardCatalogType[] = [ const formatPoints = (points: number) => {
{ return new Intl.NumberFormat('id-ID').format(points)
id: '1',
name: 'Voucher Diskon 50K',
description: 'Voucher diskon Rp 50.000 untuk pembelian minimal Rp 200.000',
pointCost: 500,
stock: 100,
isActive: true,
validUntil: new Date('2024-12-31'),
imageUrl: 'https://example.com/voucher-50k.jpg',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-02-10')
},
{
id: '2',
name: 'Free Shipping Voucher',
description: 'Gratis ongkos kirim untuk seluruh Indonesia',
pointCost: 200,
stock: 500,
isActive: true,
validUntil: new Date('2024-06-30'),
imageUrl: 'https://example.com/free-shipping.jpg',
createdAt: new Date('2024-01-20'),
updatedAt: new Date('2024-02-15')
},
{
id: '3',
name: 'Bluetooth Speaker Premium',
description: 'Speaker bluetooth kualitas premium dengan bass yang menggelegar',
pointCost: 2500,
stock: 25,
isActive: true,
validUntil: new Date('2024-09-30'),
imageUrl: 'https://example.com/bluetooth-speaker.jpg',
createdAt: new Date('2024-01-25'),
updatedAt: new Date('2024-02-20')
},
{
id: '4',
name: 'Voucher Cashback 20%',
description: 'Cashback 20% maksimal Rp 100.000 untuk kategori elektronik',
pointCost: 800,
stock: 200,
isActive: true,
validUntil: new Date('2024-08-31'),
createdAt: new Date('2024-02-01'),
updatedAt: new Date('2024-02-25')
},
{
id: '5',
name: 'Smartwatch Fitness',
description: 'Smartwatch dengan fitur fitness tracking dan heart rate monitor',
pointCost: 5000,
stock: 15,
isActive: true,
validUntil: new Date('2024-12-31'),
createdAt: new Date('2024-02-05'),
updatedAt: new Date('2024-03-01')
},
{
id: '6',
name: 'Tumbler Stainless Premium',
description: 'Tumbler stainless steel 500ml dengan desain eksklusif',
pointCost: 1200,
stock: 50,
isActive: true,
validUntil: new Date('2024-10-31'),
createdAt: new Date('2024-02-10'),
updatedAt: new Date('2024-03-05')
},
{
id: '7',
name: 'Gift Card 100K',
description: 'Gift card senilai Rp 100.000 yang bisa digunakan untuk semua produk',
pointCost: 1000,
stock: 300,
isActive: true,
validUntil: new Date('2024-12-31'),
createdAt: new Date('2024-02-15'),
updatedAt: new Date('2024-03-10')
},
{
id: '8',
name: 'Wireless Earbuds',
description: 'Earbuds wireless dengan noise cancellation dan case charging',
pointCost: 3500,
stock: 30,
isActive: true,
validUntil: new Date('2024-11-30'),
createdAt: new Date('2024-03-01'),
updatedAt: new Date('2024-03-15')
},
{
id: '9',
name: 'Voucher Buy 1 Get 1',
description: 'Beli 1 gratis 1 untuk kategori fashion wanita',
pointCost: 600,
stock: 150,
isActive: false,
validUntil: new Date('2024-07-31'),
createdAt: new Date('2024-03-05'),
updatedAt: new Date('2024-03-20')
},
{
id: '10',
name: 'Power Bank 20000mAh',
description: 'Power bank fast charging 20000mAh dengan 3 port USB',
pointCost: 1800,
stock: 40,
isActive: true,
validUntil: new Date('2024-12-31'),
createdAt: new Date('2024-03-10'),
updatedAt: new Date('2024-03-25')
},
{
id: '11',
name: 'Backpack Travel Exclusive',
description: 'Tas ransel travel anti air dengan compartment laptop',
pointCost: 2200,
stock: 20,
isActive: true,
validUntil: new Date('2024-09-30'),
createdAt: new Date('2024-03-15'),
updatedAt: new Date('2024-03-30')
},
{
id: '12',
name: 'Voucher Anniversary 75K',
description: 'Voucher spesial anniversary diskon Rp 75.000 tanpa minimum pembelian',
pointCost: 750,
stock: 0,
isActive: true,
validUntil: new Date('2024-12-31'),
createdAt: new Date('2024-03-20'),
updatedAt: new Date('2024-04-05')
}
]
// Mock data hook with dummy data
const useRewardCatalog = ({ page, limit, search }: { page: number; limit: number; search: string }) => {
const [isLoading, setIsLoading] = useState(false)
// Simulate loading
useEffect(() => {
setIsLoading(true)
const timer = setTimeout(() => setIsLoading(false), 500)
return () => clearTimeout(timer)
}, [page, limit, search])
// Filter data based on search
const filteredData = useMemo(() => {
if (!search) return DUMMY_REWARD_DATA
return DUMMY_REWARD_DATA.filter(
reward =>
reward.name.toLowerCase().includes(search.toLowerCase()) ||
reward.description?.toLowerCase().includes(search.toLowerCase())
)
}, [search])
// Paginate data
const paginatedData = useMemo(() => {
const startIndex = (page - 1) * limit
const endIndex = startIndex + limit
return filteredData.slice(startIndex, endIndex)
}, [filteredData, page, limit])
return {
data: {
rewards: paginatedData,
total_count: filteredData.length
},
isLoading,
error: null,
isFetching: isLoading
}
} }
// Column Definitions // Column Definitions
const columnHelper = createColumnHelper<RewardCatalogTypeWithAction>() const columnHelper = createColumnHelper<RewardWithAction>()
const RewardListTable = () => { const RewardListTable = () => {
// States // States - PERSIS SAMA SEPERTI TIER TABLE
const [addRewardOpen, setAddRewardOpen] = useState(false) const [addRewardOpen, setAddRewardOpen] = useState(false)
const [editRewardData, setEditRewardData] = useState<RewardCatalogType | undefined>(undefined) const [editRewardData, setEditRewardData] = useState<Reward | undefined>(undefined)
const [rowSelection, setRowSelection] = useState({}) const [rowSelection, setRowSelection] = useState({})
const [globalFilter, setGlobalFilter] = useState('') const [globalFilter, setGlobalFilter] = useState('')
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [rewardToDelete, setRewardToDelete] = useState<Reward | null>(null)
// FIX 1: PAGINATION SAMA SEPERTI TIER (1-based, bukan 0-based)
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(10) const [pageSize, setPageSize] = useState(10)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const { data, isLoading, error, isFetching } = useRewardCatalog({ const { deleteReward } = useRewardsMutation()
page: currentPage,
const { data, isLoading, error, isFetching } = useRewards({
page: currentPage, // SAMA SEPERTI TIER - langsung currentPage
limit: pageSize, limit: pageSize,
search search
}) })
const rewards = data?.rewards ?? [] const rewards = data?.rewards ?? []
const totalCount = data?.total_count ?? 0 const totalCount = data?.total ?? 0
// Hooks // Hooks
const { lang: locale } = useParams() const { lang: locale } = useParams()
@@ -344,23 +166,40 @@ const RewardListTable = () => {
setCurrentPage(1) // Reset to first page setCurrentPage(1) // Reset to first page
}, []) }, [])
const handleEditReward = (reward: RewardCatalogType) => { const handleEditReward = (reward: Reward) => {
setEditRewardData(reward) setEditRewardData(reward)
setAddRewardOpen(true) setAddRewardOpen(true)
} }
const handleDeleteReward = (rewardId: string) => { const handleDeleteReward = (reward: Reward) => {
if (confirm('Apakah Anda yakin ingin menghapus reward ini?')) { setRewardToDelete(reward)
console.log('Deleting reward:', rewardId) setDeleteDialogOpen(true)
// Add your delete logic here }
// deleteReward.mutate(rewardId)
// ADD NEW HANDLERS FOR DELETE DIALOG
const handleConfirmDelete = () => {
if (rewardToDelete) {
deleteReward.mutate(rewardToDelete.id, {
onSuccess: () => {
console.log('Reward deleted successfully')
setDeleteDialogOpen(false)
setRewardToDelete(null)
// You might want to refetch data here
// refetch()
},
onError: error => {
console.error('Error deleting reward:', error)
// Handle error (show toast, etc.)
}
})
} }
} }
const handleToggleActive = (rewardId: string, currentStatus: boolean) => { const handleCloseDeleteDialog = () => {
console.log('Toggling active status for reward:', rewardId, !currentStatus) if (!deleteReward.isPending) {
// Add your toggle logic here setDeleteDialogOpen(false)
// toggleRewardStatus.mutate({ id: rewardId, isActive: !currentStatus }) setRewardToDelete(null)
}
} }
const handleCloseRewardDrawer = () => { const handleCloseRewardDrawer = () => {
@@ -368,7 +207,21 @@ const RewardListTable = () => {
setEditRewardData(undefined) setEditRewardData(undefined)
} }
const columns = useMemo<ColumnDef<RewardCatalogTypeWithAction, any>[]>( // Helper function to get reward type color
const getRewardTypeColor = (type: string): ThemeColor => {
switch (type) {
case 'VOUCHER':
return 'info'
case 'PHYSICAL':
return 'success'
case 'DIGITAL':
return 'warning'
default:
return 'primary'
}
}
const columns = useMemo<ColumnDef<RewardWithAction, any>[]>(
() => [ () => [
{ {
id: 'select', id: 'select',
@@ -396,7 +249,7 @@ const RewardListTable = () => {
header: 'Nama Reward', header: 'Nama Reward',
cell: ({ row }) => ( cell: ({ row }) => (
<div className='flex items-center gap-4'> <div className='flex items-center gap-4'>
<CustomAvatar src={row.original.imageUrl} size={40}> <CustomAvatar src={row.original.images?.[0]} size={40}>
{getInitials(row.original.name)} {getInitials(row.original.name)}
</CustomAvatar> </CustomAvatar>
<div className='flex flex-col'> <div className='flex flex-col'>
@@ -405,22 +258,29 @@ const RewardListTable = () => {
{row.original.name} {row.original.name}
</Typography> </Typography>
</Link> </Link>
{row.original.description && (
<Typography variant='caption' color='textSecondary' className='max-w-xs truncate'>
{row.original.description}
</Typography>
)}
</div> </div>
</div> </div>
) )
}), }),
columnHelper.accessor('pointCost', { columnHelper.accessor('reward_type', {
header: 'Tipe Reward',
cell: ({ row }) => (
<Chip
label={row.original.reward_type}
color={getRewardTypeColor(row.original.reward_type)}
variant='tonal'
size='small'
/>
)
}),
columnHelper.accessor('cost_points', {
header: 'Biaya Poin', header: 'Biaya Poin',
cell: ({ row }) => ( cell: ({ row }) => (
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Icon className='tabler-star-filled' sx={{ color: 'var(--mui-palette-warning-main)' }} /> <Icon className='tabler-star-filled' sx={{ color: 'var(--mui-palette-warning-main)' }} />
<Typography color='text.primary' className='font-medium'> <Typography color='text.primary' className='font-medium'>
{row.original.pointCost.toLocaleString('id-ID')} poin {/* FIX 2: GUNAKAN formatPoints YANG SAMA SEPERTI TIER */}
{formatPoints(row.original.cost_points)} poin
</Typography> </Typography>
</div> </div>
) )
@@ -435,36 +295,20 @@ const RewardListTable = () => {
return <Chip label={stockText} color={stockColor} variant='tonal' size='small' /> return <Chip label={stockText} color={stockColor} variant='tonal' size='small' />
} }
}), }),
columnHelper.accessor('isActive', { columnHelper.accessor('max_per_customer', {
header: 'Status', header: 'Maks/Customer',
cell: ({ row }) => ( cell: ({ row }) => <Typography color='text.primary'>{row.original.max_per_customer} item</Typography>
<Chip
label={row.original.isActive ? 'Aktif' : 'Nonaktif'}
color={row.original.isActive ? 'success' : 'error'}
variant='tonal'
size='small'
/>
)
}), }),
columnHelper.accessor('validUntil', { columnHelper.accessor('tnc', {
header: 'Berlaku Hingga', header: 'Berlaku Hingga',
cell: ({ row }) => ( cell: ({ row }) => <Typography color='text.primary'>{row.original.tnc?.expiry_days} days</Typography>
<Typography color='text.primary'>
{row.original.validUntil
? new Date(row.original.validUntil).toLocaleDateString('id-ID', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
: 'Tidak terbatas'}
</Typography>
)
}), }),
columnHelper.accessor('createdAt', { columnHelper.accessor('created_at', {
header: 'Tanggal Dibuat', header: 'Tanggal Dibuat',
cell: ({ row }) => ( cell: ({ row }) => (
<Typography color='text.primary'> <Typography color='text.primary'>
{new Date(row.original.createdAt).toLocaleDateString('id-ID', { {/* FIX 3: FORMAT DATE YANG SAMA SEPERTI TIER */}
{new Date(row.original.created_at).toLocaleDateString('id-ID', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric' day: 'numeric'
@@ -481,14 +325,6 @@ const RewardListTable = () => {
iconButtonProps={{ size: 'medium' }} iconButtonProps={{ size: 'medium' }}
iconClassName='text-textSecondary text-[22px]' iconClassName='text-textSecondary text-[22px]'
options={[ options={[
{
text: row.original.isActive ? 'Nonaktifkan' : 'Aktifkan',
icon: row.original.isActive ? 'tabler-eye-off text-[22px]' : 'tabler-eye text-[22px]',
menuItemProps: {
className: 'flex items-center gap-2 text-textSecondary',
onClick: () => handleToggleActive(row.original.id, row.original.isActive)
}
},
{ {
text: 'Edit', text: 'Edit',
icon: 'tabler-edit text-[22px]', icon: 'tabler-edit text-[22px]',
@@ -502,7 +338,7 @@ const RewardListTable = () => {
icon: 'tabler-trash text-[22px]', icon: 'tabler-trash text-[22px]',
menuItemProps: { menuItemProps: {
className: 'flex items-center gap-2 text-textSecondary', className: 'flex items-center gap-2 text-textSecondary',
onClick: () => handleDeleteReward(row.original.id) onClick: () => handleDeleteReward(row.original)
} }
} }
]} ]}
@@ -513,11 +349,12 @@ const RewardListTable = () => {
} }
], ],
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[locale, handleEditReward, handleDeleteReward, handleToggleActive] [locale, handleEditReward, handleDeleteReward]
) )
// FIX 4: TABLE CONFIG YANG SAMA PERSIS SEPERTI TIER
const table = useReactTable({ const table = useReactTable({
data: rewards as RewardCatalogType[], data: rewards as Reward[], // SAMA SEPERTI TIER
columns, columns,
filterFns: { filterFns: {
fuzzy: fuzzyFilter fuzzy: fuzzyFilter
@@ -526,15 +363,15 @@ const RewardListTable = () => {
rowSelection, rowSelection,
globalFilter, globalFilter,
pagination: { pagination: {
pageIndex: currentPage, pageIndex: currentPage, // SAMA SEPERTI TIER - langsung currentPage
pageSize pageSize
} }
}, },
enableRowSelection: true, enableRowSelection: true,
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
manualPagination: true, manualPagination: true, // SAMA SEPERTI TIER
pageCount: Math.ceil(totalCount / pageSize) pageCount: Math.ceil(totalCount / pageSize) // SAMA SEPERTI TIER
}) })
return ( return (
@@ -654,6 +491,13 @@ const RewardListTable = () => {
/> />
</Card> </Card>
<AddEditRewardDrawer open={addRewardOpen} handleClose={handleCloseRewardDrawer} data={editRewardData} /> <AddEditRewardDrawer open={addRewardOpen} handleClose={handleCloseRewardDrawer} data={editRewardData} />
<DeleteRewardDialog
open={deleteDialogOpen}
onClose={handleCloseDeleteDialog}
onConfirm={handleConfirmDelete}
reward={rewardToDelete}
isDeleting={deleteReward?.isPending || false}
/>
</> </>
) )
} }
@@ -407,7 +407,7 @@ const PurchaseAddForm: React.FC = () => {
description: item.description || undefined, description: item.description || undefined,
quantity: item.quantity, quantity: item.quantity,
unit_id: item.unit!.value, 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 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 CustomAvatar, { CustomAvatarProps } from '../../../@core/components/mui/Avatar'
import { ThemeColor } from '../../../@core/types' import { ThemeColor } from '../../../@core/types'
import { Skeleton, Typography } from '@mui/material' import { Skeleton, Typography } from '@mui/material'
import { formatShortCurrency } from '../../../utils/transform' import { formatCurrency, formatShortCurrency } from '../../../utils/transform'
type Props = { type Props = {
title: string title: string
@@ -46,7 +46,7 @@ const DistributedBarChartOrder = ({
{title} {title}
</Typography> </Typography>
<Typography color='text.primary' variant='h4'> <Typography color='text.primary' variant='h4'>
{isCurrency ? 'Rp ' + formatShortCurrency(value) : formatShortCurrency(value)} {isCurrency ? formatCurrency(value) : formatShortCurrency(value)}
</Typography> </Typography>
</div> </div>
<CustomAvatar variant='rounded' skin={avatarSkin} size={52} color={avatarColor}> <CustomAvatar variant='rounded' skin={avatarSkin} size={52} color={avatarColor}>
@@ -59,6 +59,7 @@ interface ReportGeneratorProps {
onSingleDateChange: (date: Date) => void onSingleDateChange: (date: Date) => void
onDateRangeChange: (dateRange: DateRange) => void onDateRangeChange: (dateRange: DateRange) => void
onGeneratePDF: () => void onGeneratePDF: () => void
onGenerateExcel: () => void
// Optional props dengan default values // Optional props dengan default values
maxWidth?: string maxWidth?: string
@@ -66,8 +67,10 @@ interface ReportGeneratorProps {
customQuickActions?: CustomQuickActions | null customQuickActions?: CustomQuickActions | null
periodFormat?: string periodFormat?: string
downloadButtonText?: string downloadButtonText?: string
downloadExcelButtonText?: string
cardShadow?: string cardShadow?: string
primaryColor?: string primaryColor?: string
excelButtonColor?: string
// Optional helper functions // Optional helper functions
formatDateForInput?: ((date: Date) => string) | null formatDateForInput?: ((date: Date) => string) | null
@@ -80,6 +83,7 @@ interface ReportGeneratorProps {
// Loading state // Loading state
isGenerating?: boolean isGenerating?: boolean
isGeneratingExcel?: boolean
// Custom labels // Custom labels
labels?: 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 }) => ({ const QuickActionButton = styled(Button)(({ theme }) => ({
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(231, 227, 252, 0.08)' : '#f8f7fa', backgroundColor: theme.palette.mode === 'dark' ? 'rgba(231, 227, 252, 0.08)' : '#f8f7fa',
color: theme.palette.mode === 'dark' ? theme.palette.text.secondary : '#6f6b7d', color: theme.palette.mode === 'dark' ? theme.palette.text.secondary : '#6f6b7d',
@@ -140,6 +160,7 @@ const ReportGeneratorComponent: React.FC<ReportGeneratorProps> = ({
onSingleDateChange, onSingleDateChange,
onDateRangeChange, onDateRangeChange,
onGeneratePDF, onGeneratePDF,
onGenerateExcel,
// Optional props dengan default values // Optional props dengan default values
maxWidth = '1024px', maxWidth = '1024px',
@@ -147,8 +168,10 @@ const ReportGeneratorComponent: React.FC<ReportGeneratorProps> = ({
customQuickActions = null, customQuickActions = null,
periodFormat = 'id-ID', periodFormat = 'id-ID',
downloadButtonText = 'Download PDF', downloadButtonText = 'Download PDF',
downloadExcelButtonText = 'Download Excel',
cardShadow, cardShadow,
primaryColor = '#36175e', primaryColor = '#36175e',
excelButtonColor = '#16a34a',
// Optional helper functions // Optional helper functions
formatDateForInput = null, formatDateForInput = null,
@@ -161,6 +184,7 @@ const ReportGeneratorComponent: React.FC<ReportGeneratorProps> = ({
// Loading state // Loading state
isGenerating = false, isGenerating = false,
isGeneratingExcel = false,
// Custom labels // Custom labels
labels = { labels = {
@@ -175,7 +199,7 @@ const ReportGeneratorComponent: React.FC<ReportGeneratorProps> = ({
last7DaysLabel: '7 Hari Terakhir', last7DaysLabel: '7 Hari Terakhir',
last30DaysLabel: '30 Hari Terakhir', last30DaysLabel: '30 Hari Terakhir',
periodLabel: 'Periode:', 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() const theme = useTheme()
@@ -271,14 +295,24 @@ const ReportGeneratorComponent: React.FC<ReportGeneratorProps> = ({
</Typography> </Typography>
} }
action={ action={
<PurpleButton <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
onClick={onGeneratePDF} <PurpleButton
variant='contained' onClick={onGeneratePDF}
disabled={isGenerating} variant='contained'
sx={{ backgroundColor: primaryColor }} disabled={isGenerating}
> sx={{ backgroundColor: primaryColor }}
{isGenerating ? 'Generating...' : downloadButtonText} >
</PurpleButton> {isGenerating ? 'Generating...' : downloadButtonText}
</PurpleButton>
<GreenButton
onClick={onGenerateExcel}
variant='contained'
disabled={isGeneratingExcel}
sx={{ backgroundColor: excelButtonColor }}
>
{isGeneratingExcel ? 'Generating...' : downloadExcelButtonText}
</GreenButton>
</Box>
} }
sx={{ pb: 2 }} sx={{ pb: 2 }}
/> />
@@ -505,7 +539,7 @@ const ReportGeneratorComponent: React.FC<ReportGeneratorProps> = ({
mt: 1 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> </Typography>
</InfoBox> </InfoBox>
@@ -7,9 +7,6 @@ import type { FC } from 'react'
import { Box, Typography, Divider } from '@mui/material' import { Box, Typography, Divider } from '@mui/material'
import { useTheme } from '@mui/material/styles' import { useTheme } from '@mui/material/styles'
// Component Imports
import Logo from '@core/svg/Logo'
// Type Imports // Type Imports
import type { Outlet } from '@/types/services/outlet' import type { Outlet } from '@/types/services/outlet'
@@ -39,75 +36,28 @@ const ReportHeader: FC<ReportHeaderProps> = ({
return ( return (
<Box className={className}> <Box className={className}>
<Box sx={{ p: theme.spacing(8, 8, 6) }}> <Box sx={{ p: theme.spacing(8, 8, 6) }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}> <Box sx={{ textAlign: 'center' }}>
{/* Left Section - Brand & Outlet Info */} <Typography
<Box sx={{ display: 'flex', alignItems: 'center', gap: 4 }}> variant='h1'
<Logo /> component='h1'
<Box> sx={{
<Typography fontWeight: 700,
variant='h3' color: '#222222'
component='h1' }}
sx={{ >
fontWeight: 700, {reportTitle}
color: brandColor || theme.palette.primary.main </Typography>
}} {periode && (
>
{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 <Typography
variant='h4' variant='h5'
component='h3'
sx={{ sx={{
fontWeight: 700, color: '#222222',
color: theme.palette.text.primary mt: 2
}} }}
> >
{reportTitle} {periode}
</Typography> </Typography>
{periode && ( )}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 2, mt: 2 }}>
<Typography variant='body2' sx={{ color: theme.palette.text.secondary }}>
{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>
</Box> </Box>
<Divider <Divider
@@ -19,22 +19,25 @@ const PaymentMethodReport = ({ payments }: { payments: PaymentDataItem[] }) => {
<h2 className='text-xl font-semibold text-gray-900'>Payment Methods</h2> <h2 className='text-xl font-semibold text-gray-900'>Payment Methods</h2>
</div> </div>
<div className='space-y-6'> <div className='space-y-6'>
{payments && payments.map(method => ( {payments &&
<div key={method.payment_method_id} className='border-b border-gray-200 pb-4 last:border-b-0'> payments.map(method => (
<div className='flex justify-between items-center mb-2'> <div key={method.payment_method_id} className='border-b border-gray-200 pb-4 last:border-b-0'>
<span className='text-sm font-medium text-gray-900'>{method.payment_method_name}</span> <div className='flex justify-between items-center mb-2'>
<span className='text-sm text-gray-600'>{method.percentage.toFixed(1)}%</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
percentage={method.percentage}
color={method.payment_method_type === 'cash' ? 'bg-green-500' : 'bg-blue-500'}
/>
<div className='flex justify-between items-center mt-2 text-xs text-gray-600'>
<span>{formatCurrency(method.total_amount)}</span>
<span>{method.order_count} orders</span>
</div>
</div> </div>
<ProgressBar ))}
percentage={method.percentage}
color={method.payment_method_type === 'cash' ? 'bg-green-500' : 'bg-blue-500'}
/>
<div className='flex justify-between items-center mt-2 text-xs text-gray-600'>
<span>{formatCurrency(method.total_amount)}</span>
<span>{method.order_count} orders</span>
</div>
</div>
))}
</div> </div>
</div> </div>
</div> </div>