This commit is contained in:
efrilm 2025-09-18 03:04:06 +07:00
parent 3a56e56c69
commit 4640d14cb7
7 changed files with 1232 additions and 509 deletions

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
// }

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 }
}

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
}
})
}

View File

@ -1,12 +1,39 @@
export interface RewardCatalogType {
id: string
export interface Reward {
id: string // uuid
name: string
description?: string
pointCost: number
reward_type: 'VOUCHER' | 'PHYSICAL' | 'DIGITAL'
cost_points: number
stock?: number
isActive: boolean
validUntil?: Date
imageUrl?: string
createdAt: Date
updatedAt: Date
max_per_customer: number
tnc?: TermsAndConditions
metadata?: Record<string, any>
images?: string[]
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
}

View File

@ -18,103 +18,110 @@ import Avatar from '@mui/material/Avatar'
import Card from '@mui/material/Card'
import CardContent from '@mui/material/CardContent'
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
import { useForm, Controller } from 'react-hook-form'
import { useForm, Controller, useFieldArray } from 'react-hook-form'
// Component Imports
import CustomTextField from '@core/components/mui/TextField'
import MultipleImageUpload from '@/components/MultipleImageUpload' // Import the component
// Types
export interface RewardCatalogType {
id: string
name: string
description?: string
pointCost: number
stock?: number
isActive: boolean
validUntil?: Date
imageUrl?: string
createdAt: Date
updatedAt: Date
// Import the actual upload mutation
import { useFilesMutation } from '@/services/mutations/files'
import { useRewardsMutation } from '@/services/mutations/reward'
// Updated Types based on new API structure
export interface TermsAndConditions {
sections: TncSection[]
expiry_days: number
}
export interface TncSection {
title: string
rules: string[]
}
export interface RewardRequest {
name: string
description?: string
pointCost: number
name: string // required, 1150 chars
reward_type: 'VOUCHER' | 'PHYSICAL' | 'DIGITAL' // enum
cost_points: number // min 1
stock?: number
isActive: boolean
validUntil?: Date
imageUrl?: string
category?: string
terms?: string
max_per_customer: number // min 1
tnc?: TermsAndConditions
images?: string[] // Add images to request
}
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 = {
open: boolean
handleClose: () => void
data?: RewardCatalogType // Data reward untuk edit (jika ada)
data?: Reward // Data reward untuk edit (jika ada)
}
type FormValidateType = {
name: string
description: string
pointCost: number
reward_type: 'VOUCHER' | 'PHYSICAL' | 'DIGITAL'
cost_points: number
stock: number | ''
isActive: boolean
validUntil: string
imageUrl: string
category: string
terms: string
max_per_customer: number
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
const initialData: FormValidateType = {
name: '',
description: '',
pointCost: 100,
reward_type: 'VOUCHER',
cost_points: 100,
stock: '',
isActive: true,
validUntil: '',
imageUrl: '',
category: 'voucher',
terms: '',
max_per_customer: 1,
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)
const useRewardMutation = () => {
const createReward = {
mutate: (data: RewardRequest, options?: { onSuccess?: () => void }) => {
console.log('Creating reward:', data)
setTimeout(() => options?.onSuccess?.(), 1000)
}
}
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' }
]
// Reward types
const REWARD_TYPES = [
{ value: 'VOUCHER', label: 'Voucher' },
{ value: 'PHYSICAL', label: 'Barang Fisik' },
{ value: 'DIGITAL', label: 'Digital' }
] as const
const AddEditRewardDrawer = (props: Props) => {
// Props
@ -123,9 +130,10 @@ const AddEditRewardDrawer = (props: Props) => {
// States
const [showMore, setShowMore] = 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
const isEditMode = Boolean(data?.id)
@ -142,50 +150,57 @@ const AddEditRewardDrawer = (props: Props) => {
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 watchedHasValidUntil = watch('hasValidUntil')
const watchedHasTnc = watch('hasTnc')
const watchedStock = watch('stock')
const watchedPointCost = watch('pointCost')
const watchedCostPoints = watch('cost_points')
// Effect to populate form when editing
useEffect(() => {
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
const formData: FormValidateType = {
name: data.name || '',
description: data.description || '',
pointCost: data.pointCost || 100,
reward_type: data.reward_type || 'VOUCHER',
cost_points: data.cost_points || 100,
stock: data.stock ?? '',
isActive: data.isActive ?? true,
validUntil: data.validUntil ? new Date(data.validUntil).toISOString().split('T')[0] : '',
imageUrl: data.imageUrl || '',
category: 'voucher', // Default category
terms: '',
max_per_customer: data.max_per_customer || 1,
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)
setShowMore(true) // Always show more for edit mode
setImagePreview(data.imageUrl || null)
} else {
// Reset to initial data for add mode
resetForm(initialData)
setShowMore(false)
setImagePreview(null)
}
}, [data, isEditMode, resetForm])
// Handle image URL change
useEffect(() => {
if (watchedImageUrl) {
setImagePreview(watchedImageUrl)
} else {
setImagePreview(null)
}
}, [watchedImageUrl])
// Handle unlimited stock toggle
useEffect(() => {
if (watchedHasUnlimitedStock) {
@ -193,28 +208,90 @@ const AddEditRewardDrawer = (props: Props) => {
}
}, [watchedHasUnlimitedStock, setValue])
// Handle valid until toggle
useEffect(() => {
if (!watchedHasValidUntil) {
setValue('validUntil', '')
// Image upload handlers
const handleSingleUpload = async (file: File): Promise<string> => {
const fileId = `${file.name}-${Date.now()}`
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) => {
try {
setIsSubmitting(true)
// Extract image URLs from uploaded images
const imageUrls = formData.uploadedImages.map(img => img.url)
// Create RewardRequest object
const rewardRequest: RewardRequest = {
name: formData.name,
description: formData.description || undefined,
pointCost: formData.pointCost,
reward_type: formData.reward_type,
cost_points: formData.cost_points,
stock: formData.hasUnlimitedStock ? undefined : (formData.stock as number) || undefined,
isActive: formData.isActive,
validUntil: formData.hasValidUntil && formData.validUntil ? new Date(formData.validUntil) : undefined,
imageUrl: formData.imageUrl || undefined,
category: formData.category || undefined,
terms: formData.terms || undefined
max_per_customer: formData.max_per_customer,
images: imageUrls.length > 0 ? imageUrls : undefined, // Include images in request
tnc:
formData.hasTnc && formData.tnc_sections.length > 0
? {
sections: formData.tnc_sections,
expiry_days: formData.tnc_expiry_days
}
: undefined
}
if (isEditMode && data?.id) {
@ -249,7 +326,7 @@ const AddEditRewardDrawer = (props: Props) => {
handleClose()
resetForm(initialData)
setShowMore(false)
setImagePreview(null)
setUploadingFiles(new Set())
}
const formatPoints = (value: number) => {
@ -262,6 +339,13 @@ const AddEditRewardDrawer = (props: Props) => {
return `${watchedStock} item`
}
const addTncSection = () => {
appendTncSection({ title: '', rules: [''] })
}
// Check if any files are currently uploading
const isAnyFileUploading = uploadingFiles.size > 0 || isFileUploading
return (
<Drawer
open={open}
@ -301,29 +385,6 @@ const AddEditRewardDrawer = (props: Props) => {
<Box sx={{ flex: 1, overflowY: 'auto' }}>
<form id='reward-form' onSubmit={handleSubmit(handleFormSubmit)}>
<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 */}
<div>
<Typography variant='body2' className='mb-2'>
@ -332,39 +393,43 @@ const AddEditRewardDrawer = (props: Props) => {
<Controller
name='name'
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 }) => (
<CustomTextField
{...field}
fullWidth
placeholder='Masukkan nama reward'
error={!!errors.name}
helperText={errors.name?.message}
helperText={errors.name?.message || `${field.value.length}/150 karakter`}
/>
)}
/>
</div>
{/* Kategori Reward */}
{/* Tipe Reward */}
<div>
<Typography variant='body2' className='mb-2'>
Kategori Reward <span className='text-red-500'>*</span>
Tipe Reward <span className='text-red-500'>*</span>
</Typography>
<Controller
name='category'
name='reward_type'
control={control}
rules={{ required: 'Kategori reward wajib dipilih' }}
rules={{ required: 'Tipe reward wajib dipilih' }}
render={({ field }) => (
<CustomTextField
{...field}
select
fullWidth
error={!!errors.category}
helperText={errors.category?.message}
error={!!errors.reward_type}
helperText={errors.reward_type?.message}
>
{REWARD_CATEGORIES.map(category => (
<MenuItem key={category.value} value={category.value}>
{category.label}
{REWARD_TYPES.map(type => (
<MenuItem key={type.value} value={type.value}>
{type.label}
</MenuItem>
))}
</CustomTextField>
@ -372,13 +437,13 @@ const AddEditRewardDrawer = (props: Props) => {
/>
</div>
{/* Point Cost */}
{/* Cost Points */}
<div>
<Typography variant='body2' className='mb-2'>
Biaya Poin <span className='text-red-500'>*</span>
</Typography>
<Controller
name='pointCost'
name='cost_points'
control={control}
rules={{
required: 'Biaya poin wajib diisi',
@ -393,8 +458,8 @@ const AddEditRewardDrawer = (props: Props) => {
fullWidth
type='number'
placeholder='100'
error={!!errors.pointCost}
helperText={errors.pointCost?.message || (field.value > 0 ? formatPoints(field.value) : '')}
error={!!errors.cost_points}
helperText={errors.cost_points?.message || (field.value > 0 ? formatPoints(field.value) : '')}
InputProps={{
startAdornment: (
<InputAdornment position='start'>
@ -408,6 +473,38 @@ const AddEditRewardDrawer = (props: Props) => {
/>
</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 */}
<div>
<Typography variant='body2' className='mb-2'>
@ -454,20 +551,6 @@ const AddEditRewardDrawer = (props: Props) => {
)}
</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 */}
{!showMore && (
<Button
@ -484,89 +567,32 @@ const AddEditRewardDrawer = (props: Props) => {
{/* Konten tambahan */}
{showMore && (
<>
{/* Description */}
{/* Multiple Image Upload Section */}
<div>
<Typography variant='body2' className='mb-2'>
Deskripsi Reward
<Typography variant='body2' className='mb-3'>
Gambar Reward
</Typography>
<Controller
name='description'
control={control}
render={({ field }) => (
<CustomTextField
{...field}
fullWidth
placeholder='Deskripsi detail tentang reward'
multiline
rows={3}
/>
)}
<MultipleImageUpload
title={null} // No title since we have our own
onUpload={handleMultipleUpload}
onSingleUpload={handleSingleUpload}
currentImages={watchedUploadedImages || []}
onImagesChange={handleImagesChange}
onImageRemove={handleImageRemove}
isUploading={isAnyFileUploading}
maxFiles={5}
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>
{/* 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
}}
/>
)}
/>
)}
<FormHelperText>Format yang didukung: JPG, PNG, WebP. Maksimal 5MB per file.</FormHelperText>
</div>
{/* Terms & Conditions */}
@ -575,18 +601,112 @@ const AddEditRewardDrawer = (props: Props) => {
Syarat & Ketentuan
</Typography>
<Controller
name='terms'
name='hasTnc'
control={control}
render={({ field }) => (
<CustomTextField
{...field}
fullWidth
placeholder='Syarat dan ketentuan penggunaan reward'
multiline
rows={3}
<FormControlLabel
control={<Switch checked={field.value} onChange={field.onChange} color='primary' />}
label='Memiliki syarat & ketentuan'
className='mb-2'
/>
)}
/>
{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>
{/* Sembunyikan */}
@ -618,13 +738,18 @@ const AddEditRewardDrawer = (props: Props) => {
}}
>
<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'}
</Button>
<Button variant='outlined' color='error' onClick={handleReset} disabled={isSubmitting}>
<Button variant='outlined' color='error' onClick={handleReset} disabled={isSubmitting || isAnyFileUploading}>
Batal
</Button>
</div>
{isAnyFileUploading && (
<Typography variant='caption' color='textSecondary' className='mt-2'>
Sedang mengupload gambar... ({uploadingFiles.size} file)
</Typography>
)}
</Box>
</Drawer>
)

View File

@ -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

View File

@ -57,20 +57,10 @@ import { formatCurrency } from '@/utils/transform'
import tableStyles from '@core/styles/table.module.css'
import Loading from '@/components/layout/shared/Loading'
import AddEditRewardDrawer from './AddEditRewardDrawer'
// Reward Catalog Type Interface
export interface RewardCatalogType {
id: string
name: string
description?: string
pointCost: number
stock?: number
isActive: boolean
validUntil?: Date
imageUrl?: string
createdAt: Date
updatedAt: Date
}
import { Reward } from '@/types/services/reward'
import { useRewards } from '@/services/queries/reward'
import { useRewardsMutation } from '@/services/mutations/reward'
import DeleteRewardDialog from './DeleteRewardDialog'
declare module '@tanstack/table-core' {
interface FilterFns {
@ -81,7 +71,7 @@ declare module '@tanstack/table-core' {
}
}
type RewardCatalogTypeWithAction = RewardCatalogType & {
type RewardWithAction = Reward & {
action?: string
}
@ -130,206 +120,38 @@ const DebouncedInput = ({
return <CustomTextField {...props} value={value} onChange={e => setValue(e.target.value)} />
}
// Dummy data for reward catalog
const DUMMY_REWARD_DATA: RewardCatalogType[] = [
{
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
}
// Helper function untuk format points - SAMA SEPERTI TIER TABLE
const formatPoints = (points: number) => {
return new Intl.NumberFormat('id-ID').format(points)
}
// Column Definitions
const columnHelper = createColumnHelper<RewardCatalogTypeWithAction>()
const columnHelper = createColumnHelper<RewardWithAction>()
const RewardListTable = () => {
// States
// States - PERSIS SAMA SEPERTI TIER TABLE
const [addRewardOpen, setAddRewardOpen] = useState(false)
const [editRewardData, setEditRewardData] = useState<RewardCatalogType | undefined>(undefined)
const [editRewardData, setEditRewardData] = useState<Reward | undefined>(undefined)
const [rowSelection, setRowSelection] = 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 [pageSize, setPageSize] = useState(10)
const [search, setSearch] = useState('')
const { data, isLoading, error, isFetching } = useRewardCatalog({
page: currentPage,
const { deleteReward } = useRewardsMutation()
const { data, isLoading, error, isFetching } = useRewards({
page: currentPage, // SAMA SEPERTI TIER - langsung currentPage
limit: pageSize,
search
})
const rewards = data?.rewards ?? []
const totalCount = data?.total_count ?? 0
const totalCount = data?.total ?? 0
// Hooks
const { lang: locale } = useParams()
@ -344,23 +166,40 @@ const RewardListTable = () => {
setCurrentPage(1) // Reset to first page
}, [])
const handleEditReward = (reward: RewardCatalogType) => {
const handleEditReward = (reward: Reward) => {
setEditRewardData(reward)
setAddRewardOpen(true)
}
const handleDeleteReward = (rewardId: string) => {
if (confirm('Apakah Anda yakin ingin menghapus reward ini?')) {
console.log('Deleting reward:', rewardId)
// Add your delete logic here
// deleteReward.mutate(rewardId)
const handleDeleteReward = (reward: Reward) => {
setRewardToDelete(reward)
setDeleteDialogOpen(true)
}
// 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) => {
console.log('Toggling active status for reward:', rewardId, !currentStatus)
// Add your toggle logic here
// toggleRewardStatus.mutate({ id: rewardId, isActive: !currentStatus })
const handleCloseDeleteDialog = () => {
if (!deleteReward.isPending) {
setDeleteDialogOpen(false)
setRewardToDelete(null)
}
}
const handleCloseRewardDrawer = () => {
@ -368,7 +207,21 @@ const RewardListTable = () => {
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',
@ -396,7 +249,7 @@ const RewardListTable = () => {
header: 'Nama Reward',
cell: ({ row }) => (
<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)}
</CustomAvatar>
<div className='flex flex-col'>
@ -405,22 +258,29 @@ const RewardListTable = () => {
{row.original.name}
</Typography>
</Link>
{row.original.description && (
<Typography variant='caption' color='textSecondary' className='max-w-xs truncate'>
{row.original.description}
</Typography>
)}
</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',
cell: ({ row }) => (
<div className='flex items-center gap-2'>
<Icon className='tabler-star-filled' sx={{ color: 'var(--mui-palette-warning-main)' }} />
<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>
</div>
)
@ -435,36 +295,20 @@ const RewardListTable = () => {
return <Chip label={stockText} color={stockColor} variant='tonal' size='small' />
}
}),
columnHelper.accessor('isActive', {
header: 'Status',
cell: ({ row }) => (
<Chip
label={row.original.isActive ? 'Aktif' : 'Nonaktif'}
color={row.original.isActive ? 'success' : 'error'}
variant='tonal'
size='small'
/>
)
columnHelper.accessor('max_per_customer', {
header: 'Maks/Customer',
cell: ({ row }) => <Typography color='text.primary'>{row.original.max_per_customer} item</Typography>
}),
columnHelper.accessor('validUntil', {
columnHelper.accessor('tnc', {
header: 'Berlaku Hingga',
cell: ({ row }) => (
<Typography color='text.primary'>
{row.original.validUntil
? new Date(row.original.validUntil).toLocaleDateString('id-ID', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
: 'Tidak terbatas'}
</Typography>
)
cell: ({ row }) => <Typography color='text.primary'>{row.original.tnc?.expiry_days} days</Typography>
}),
columnHelper.accessor('createdAt', {
columnHelper.accessor('created_at', {
header: 'Tanggal Dibuat',
cell: ({ row }) => (
<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',
month: 'short',
day: 'numeric'
@ -481,14 +325,6 @@ const RewardListTable = () => {
iconButtonProps={{ size: 'medium' }}
iconClassName='text-textSecondary text-[22px]'
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',
icon: 'tabler-edit text-[22px]',
@ -502,7 +338,7 @@ const RewardListTable = () => {
icon: 'tabler-trash text-[22px]',
menuItemProps: {
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
[locale, handleEditReward, handleDeleteReward, handleToggleActive]
[locale, handleEditReward, handleDeleteReward]
)
// FIX 4: TABLE CONFIG YANG SAMA PERSIS SEPERTI TIER
const table = useReactTable({
data: rewards as RewardCatalogType[],
data: rewards as Reward[], // SAMA SEPERTI TIER
columns,
filterFns: {
fuzzy: fuzzyFilter
@ -526,15 +363,15 @@ const RewardListTable = () => {
rowSelection,
globalFilter,
pagination: {
pageIndex: currentPage,
pageIndex: currentPage, // SAMA SEPERTI TIER - langsung currentPage
pageSize
}
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
pageCount: Math.ceil(totalCount / pageSize)
manualPagination: true, // SAMA SEPERTI TIER
pageCount: Math.ceil(totalCount / pageSize) // SAMA SEPERTI TIER
})
return (
@ -654,6 +491,13 @@ const RewardListTable = () => {
/>
</Card>
<AddEditRewardDrawer open={addRewardOpen} handleClose={handleCloseRewardDrawer} data={editRewardData} />
<DeleteRewardDialog
open={deleteDialogOpen}
onClose={handleCloseDeleteDialog}
onConfirm={handleConfirmDelete}
reward={rewardToDelete}
isDeleting={deleteReward?.isPending || false}
/>
</>
)
}