Reward
This commit is contained in:
@@ -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, 1–150 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>
|
||||
)
|
||||
|
||||
@@ -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 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user