Reward
This commit is contained in:
parent
3a56e56c69
commit
4640d14cb7
465
src/components/MultipleImageUpload.tsx
Normal file
465
src/components/MultipleImageUpload.tsx
Normal 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
|
||||||
|
// }
|
||||||
52
src/services/mutations/reward.ts
Normal file
52
src/services/mutations/reward.ts
Normal 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 }
|
||||||
|
}
|
||||||
46
src/services/queries/reward.ts
Normal file
46
src/services/queries/reward.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -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, 1–150 chars
|
||||||
|
reward_type: 'VOUCHER' | 'PHYSICAL' | 'DIGITAL' // enum
|
||||||
|
cost_points: number // min 1
|
||||||
|
stock?: number
|
||||||
|
max_per_customer: number // min 1
|
||||||
|
tnc?: TermsAndConditions
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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, 1–150 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}, [watchedHasValidUntil, setValue])
|
|
||||||
|
|
||||||
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}
|
||||||
/>
|
/>
|
||||||
)}
|
<FormHelperText>Format yang didukung: JPG, PNG, WebP. Maksimal 5MB per file.</FormHelperText>
|
||||||
/>
|
|
||||||
</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
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</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}
|
||||||
|
render={({ field }) => (
|
||||||
|
<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}
|
control={control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<CustomTextField
|
<CustomTextField
|
||||||
{...field}
|
{...field}
|
||||||
fullWidth
|
fullWidth
|
||||||
placeholder='Syarat dan ketentuan penggunaan reward'
|
placeholder={`Aturan ${ruleIndex + 1}`}
|
||||||
|
className='mb-1'
|
||||||
multiline
|
multiline
|
||||||
rows={3}
|
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>
|
||||||
)
|
)
|
||||||
|
|||||||
164
src/views/apps/marketing/reward/DeleteRewardDialog.tsx
Normal file
164
src/views/apps/marketing/reward/DeleteRewardDialog.tsx
Normal 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
|
||||||
@ -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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user