From 4640d14cb74b6b32e10c60b016ed279405bc6fa3 Mon Sep 17 00:00:00 2001 From: efrilm Date: Thu, 18 Sep 2025 03:04:06 +0700 Subject: [PATCH] Reward --- src/components/MultipleImageUpload.tsx | 465 +++++++++++++ src/services/mutations/reward.ts | 52 ++ src/services/queries/reward.ts | 46 ++ src/types/services/reward.ts | 45 +- .../marketing/reward/AddEditRewardDrawer.tsx | 615 +++++++++++------- .../marketing/reward/DeleteRewardDialog.tsx | 164 +++++ .../apps/marketing/reward/RewardListTable.tsx | 354 +++------- 7 files changed, 1232 insertions(+), 509 deletions(-) create mode 100644 src/components/MultipleImageUpload.tsx create mode 100644 src/services/mutations/reward.ts create mode 100644 src/services/queries/reward.ts create mode 100644 src/views/apps/marketing/reward/DeleteRewardDialog.tsx diff --git a/src/components/MultipleImageUpload.tsx b/src/components/MultipleImageUpload.tsx new file mode 100644 index 0000000..f679363 --- /dev/null +++ b/src/components/MultipleImageUpload.tsx @@ -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[] // Returns array of image URLs + onSingleUpload?: (file: File) => Promise | 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)(({ 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 = ({ + 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([]) + const [error, setError] = useState('') + const [individualUploading, setIndividualUploading] = useState>(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 ( + {file.name} + ) + } else { + return + } + } + + 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 ( + +
+
{renderFilePreview(file)}
+
+ + {file.name} + + + {formatFileSize(file.size)} + + {isFileUploading && progress > 0 && ( + + )} +
+
+
+ {uploadMode === 'individual' && onSingleUpload && ( + + )} + handleRemoveFile(index)} disabled={isUploading || isFileUploading}> + + +
+
+ ) + }) + + const currentImagesList = currentImages.map(image => ( + +
+
+ {image.name} +
+
+ + {image.name} + + + {formatFileSize(image.size)} + +
+
+
+ + {onImageRemove && ( + handleRemoveCurrentImage(image.id)} color='error' disabled={isUploading}> + + + )} +
+
+ )) + + return ( + + {/* Conditional title and URL option header */} + {title && ( +
+ + {title} + + {showUrlOption && ( + + Add media from URL + + )} +
+ )} + + {/* File limits info */} +
+ + {maxFilesText.replace('{max}', maxFiles.toString())} + + + {currentImages.length + files.length} / {maxFiles} files + +
+ +
+ +
+ + + + + {currentImages.length > 0 || files.length > 0 ? replaceText : dragDropText} + + or + +
+
+ + {/* Error Message */} + {error && ( + + {error} + + )} + + {/* Current uploaded images */} + {currentImages.length > 0 && ( +
+ + Uploaded Images ({currentImages.length}): + + {currentImagesList} +
+ )} + + {/* Pending files list and upload buttons */} + {files.length > 0 && ( +
+ + Pending Files ({files.length}): + + {fileList} +
+ + {uploadMode === 'batch' && ( + + )} +
+
+ )} +
+ ) +} + +export default MultipleImageUpload + +// ===== USAGE EXAMPLES ===== + +// 1. Batch upload mode (upload all files at once) +// const [images, setImages] = useState([]) +// +// setImages(prev => prev.filter(img => img.id !== id))} +// maxFiles={5} +// uploadMode="batch" +// /> + +// 2. Individual upload mode (upload files one by one) +// setImages(prev => prev.filter(img => img.id !== id))} +// maxFiles={10} +// uploadMode="individual" +// uploadProgress={uploadProgress} +// /> + +// 3. Without title, custom limits +// + +// 4. Example upload handlers +// const handleBatchUpload = async (files: File[]): Promise => { +// 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 => { +// 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 +// } diff --git a/src/services/mutations/reward.ts b/src/services/mutations/reward.ts new file mode 100644 index 0000000..6156c0e --- /dev/null +++ b/src/services/mutations/reward.ts @@ -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 } +} diff --git a/src/services/queries/reward.ts b/src/services/queries/reward.ts new file mode 100644 index 0000000..d0c2e48 --- /dev/null +++ b/src/services/queries/reward.ts @@ -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({ + 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({ + queryKey: ['rewards', id], + queryFn: async () => { + const res = await api.get(`/marketing/rewards/${id}`) + return res.data.data + } + }) +} diff --git a/src/types/services/reward.ts b/src/types/services/reward.ts index ebf0c08..5e9acbb 100644 --- a/src/types/services/reward.ts +++ b/src/types/services/reward.ts @@ -1,12 +1,39 @@ -export interface RewardCatalogType { - id: string +export interface Reward { + id: string // uuid name: string - description?: string - pointCost: number + reward_type: 'VOUCHER' | 'PHYSICAL' | 'DIGITAL' + cost_points: number stock?: number - isActive: boolean - validUntil?: Date - imageUrl?: string - createdAt: Date - updatedAt: Date + max_per_customer: number + tnc?: TermsAndConditions + metadata?: Record + images?: string[] + created_at: string // ISO date-time + updated_at: string // ISO date-time +} + +export interface Rewards { + rewards: Reward[] + total: number + page: number + limit: number +} + +export interface TermsAndConditions { + sections: TncSection[] + expiry_days: number +} + +export interface TncSection { + title: string + rules: string[] +} + +export interface RewardRequest { + name: string // required, 1–150 chars + reward_type: 'VOUCHER' | 'PHYSICAL' | 'DIGITAL' // enum + cost_points: number // min 1 + stock?: number + max_per_customer: number // min 1 + tnc?: TermsAndConditions } diff --git a/src/views/apps/marketing/reward/AddEditRewardDrawer.tsx b/src/views/apps/marketing/reward/AddEditRewardDrawer.tsx index 4c81ca1..71ce28a 100644 --- a/src/views/apps/marketing/reward/AddEditRewardDrawer.tsx +++ b/src/views/apps/marketing/reward/AddEditRewardDrawer.tsx @@ -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 + 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 } // 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(null) + const [uploadingFiles, setUploadingFiles] = useState>(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 => { + 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 => { + 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 ( {
- {/* Image Preview */} - {imagePreview && ( - - - - Preview Gambar - - - - - - - )} - {/* Nama Reward */}
@@ -332,39 +393,43 @@ const AddEditRewardDrawer = (props: Props) => { ( )} />
- {/* Kategori Reward */} + {/* Tipe Reward */}
- Kategori Reward * + Tipe Reward * ( - {REWARD_CATEGORIES.map(category => ( - - {category.label} + {REWARD_TYPES.map(type => ( + + {type.label} ))} @@ -372,13 +437,13 @@ const AddEditRewardDrawer = (props: Props) => { />
- {/* Point Cost */} + {/* Cost Points */}
Biaya Poin * { 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: ( @@ -408,6 +473,38 @@ const AddEditRewardDrawer = (props: Props) => { />
+ {/* Max Per Customer */} +
+ + Maksimal per Pelanggan * + + ( + Max + }} + onChange={e => field.onChange(Number(e.target.value))} + /> + )} + /> +
+ {/* Stock Management */}
@@ -454,20 +551,6 @@ const AddEditRewardDrawer = (props: Props) => { )}
- {/* Status Aktif */} -
- ( - } - label='Reward Aktif' - /> - )} - /> -
- {/* Tampilkan selengkapnya */} {!showMore && ( + + + + + ))} + + + + )}
{/* Sembunyikan */} @@ -618,13 +738,18 @@ const AddEditRewardDrawer = (props: Props) => { }} >
- -
+ {isAnyFileUploading && ( + + Sedang mengupload gambar... ({uploadingFiles.size} file) + + )} ) diff --git a/src/views/apps/marketing/reward/DeleteRewardDialog.tsx b/src/views/apps/marketing/reward/DeleteRewardDialog.tsx new file mode 100644 index 0000000..767ce4f --- /dev/null +++ b/src/views/apps/marketing/reward/DeleteRewardDialog.tsx @@ -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 ( + + + + + Hapus Reward + + + + + + Apakah Anda yakin ingin menghapus reward berikut? + + + + {/* Reward Info with Avatar */} + + + {getInitials(reward.name)} + + + + {reward.name} + + + + + + {/* Reward Details */} + + + Biaya Poin: {new Intl.NumberFormat('id-ID').format(reward.cost_points)} poin + + + {reward.stock !== undefined && ( + + Stok:{' '} + {reward.stock === 0 ? 'Habis' : reward.stock === null ? 'Unlimited' : reward.stock} + + )} + + + Maks per Customer: {reward.max_per_customer} item + + + {reward.tnc?.expiry_days && ( + + Berlaku Hingga: {reward.tnc.expiry_days} hari + + )} + + + Dibuat:{' '} + {new Date(reward.created_at).toLocaleDateString('id-ID', { + year: 'numeric', + month: 'long', + day: 'numeric' + })} + + + + + + + Peringatan: Tindakan ini tidak dapat dibatalkan. Semua data yang terkait dengan reward ini + akan dihapus secara permanen. + + + + + Pastikan tidak ada pengguna yang masih memiliki atau menukarkan reward ini sebelum menghapus. + + + + + + + + + ) +} + +export default DeleteRewardDialog diff --git a/src/views/apps/marketing/reward/RewardListTable.tsx b/src/views/apps/marketing/reward/RewardListTable.tsx index 1b741bd..e96ce93 100644 --- a/src/views/apps/marketing/reward/RewardListTable.tsx +++ b/src/views/apps/marketing/reward/RewardListTable.tsx @@ -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 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() +const columnHelper = createColumnHelper() const RewardListTable = () => { - // States + // States - PERSIS SAMA SEPERTI TIER TABLE const [addRewardOpen, setAddRewardOpen] = useState(false) - const [editRewardData, setEditRewardData] = useState(undefined) + const [editRewardData, setEditRewardData] = useState(undefined) const [rowSelection, setRowSelection] = useState({}) const [globalFilter, setGlobalFilter] = useState('') + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [rewardToDelete, setRewardToDelete] = useState(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[]>( + // 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[]>( () => [ { id: 'select', @@ -396,7 +249,7 @@ const RewardListTable = () => { header: 'Nama Reward', cell: ({ row }) => (
- + {getInitials(row.original.name)}
@@ -405,22 +258,29 @@ const RewardListTable = () => { {row.original.name} - {row.original.description && ( - - {row.original.description} - - )}
) }), - columnHelper.accessor('pointCost', { + columnHelper.accessor('reward_type', { + header: 'Tipe Reward', + cell: ({ row }) => ( + + ) + }), + columnHelper.accessor('cost_points', { header: 'Biaya Poin', cell: ({ row }) => (
- {row.original.pointCost.toLocaleString('id-ID')} poin + {/* FIX 2: GUNAKAN formatPoints YANG SAMA SEPERTI TIER */} + {formatPoints(row.original.cost_points)} poin
) @@ -435,36 +295,20 @@ const RewardListTable = () => { return } }), - columnHelper.accessor('isActive', { - header: 'Status', - cell: ({ row }) => ( - - ) + columnHelper.accessor('max_per_customer', { + header: 'Maks/Customer', + cell: ({ row }) => {row.original.max_per_customer} item }), - columnHelper.accessor('validUntil', { + columnHelper.accessor('tnc', { header: 'Berlaku Hingga', - cell: ({ row }) => ( - - {row.original.validUntil - ? new Date(row.original.validUntil).toLocaleDateString('id-ID', { - year: 'numeric', - month: 'short', - day: 'numeric' - }) - : 'Tidak terbatas'} - - ) + cell: ({ row }) => {row.original.tnc?.expiry_days} days }), - columnHelper.accessor('createdAt', { + columnHelper.accessor('created_at', { header: 'Tanggal Dibuat', cell: ({ row }) => ( - {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 = () => { /> + ) }