diff --git a/src/components/layout/vertical/VerticalMenu.tsx b/src/components/layout/vertical/VerticalMenu.tsx index 037fd04..86d4623 100644 --- a/src/components/layout/vertical/VerticalMenu.tsx +++ b/src/components/layout/vertical/VerticalMenu.tsx @@ -166,7 +166,7 @@ const VerticalMenu = ({ dictionary, scrollMenu }: Props) => { {dictionary['navigation'].customer_analytics} {dictionary['navigation'].voucher} - {dictionary['navigation'].tiers} + {dictionary['navigation'].tiers_text} }> diff --git a/src/data/dictionaries/en.json b/src/data/dictionaries/en.json index 568dd97..3a3c7b1 100644 --- a/src/data/dictionaries/en.json +++ b/src/data/dictionaries/en.json @@ -135,6 +135,6 @@ "campaign": "Campaign", "customer_analytics": "Customer Analytics", "voucher": "Voucher", - "tiers": "Tiers" + "tiers_text": "Tiers" } } diff --git a/src/data/dictionaries/id.json b/src/data/dictionaries/id.json index f55b6b6..dd3b696 100644 --- a/src/data/dictionaries/id.json +++ b/src/data/dictionaries/id.json @@ -135,6 +135,6 @@ "campaign": "Kampanye", "customer_analytics": "Analisis Pelanggan", "voucher": "Vocher", - "tiers": "Tier" + "tiers_text": "Tiers" } } diff --git a/src/services/mutations/tier.ts b/src/services/mutations/tier.ts new file mode 100644 index 0000000..76175f0 --- /dev/null +++ b/src/services/mutations/tier.ts @@ -0,0 +1,52 @@ +import { TierRequest } from '@/types/services/tier' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'react-toastify' +import { api } from '../api' + +export const useTiersMutation = () => { + const queryClient = useQueryClient() + + const createTier = useMutation({ + mutationFn: async (newTier: TierRequest) => { + const response = await api.post('/marketing/tiers', newTier) + return response.data + }, + onSuccess: () => { + toast.success('Tier created successfully!') + queryClient.invalidateQueries({ queryKey: ['tiers'] }) + }, + onError: (error: any) => { + toast.error(error.response?.data?.errors?.[0]?.cause || 'Create failed') + } + }) + + const updateTier = useMutation({ + mutationFn: async ({ id, payload }: { id: string; payload: TierRequest }) => { + const response = await api.put(`/marketing/tiers/${id}`, payload) + return response.data + }, + onSuccess: () => { + toast.success('Tier updated successfully!') + queryClient.invalidateQueries({ queryKey: ['tiers'] }) + }, + onError: (error: any) => { + toast.error(error.response?.data?.errors?.[0]?.cause || 'Update failed') + } + }) + + const deleteTier = useMutation({ + mutationFn: async (id: string) => { + const response = await api.delete(`/marketing/tiers/${id}`) + return response.data + }, + onSuccess: () => { + toast.success('Tier deleted successfully!') + queryClient.invalidateQueries({ queryKey: ['tiers'] }) + }, + onError: (error: any) => { + toast.error(error.response?.data?.errors?.[0]?.cause || 'Delete failed') + } + }) + + return { createTier, updateTier, deleteTier } +} diff --git a/src/services/queries/tier.ts b/src/services/queries/tier.ts new file mode 100644 index 0000000..bd69442 --- /dev/null +++ b/src/services/queries/tier.ts @@ -0,0 +1,46 @@ +import { useQuery } from '@tanstack/react-query' +import { api } from '../api' +import { Tier, Tiers } from '@/types/services/tier' + +interface TierQueryParams { + page?: number + limit?: number + search?: string +} + +export function useTiers(params: TierQueryParams = {}) { + const { page = 1, limit = 10, search = '', ...filters } = params + + return useQuery({ + queryKey: ['tiers', { 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/tiers?${queryParams.toString()}`) + return res.data.data + } + }) +} + +export function useTierById(id: string) { + return useQuery({ + queryKey: ['tiers', id], + queryFn: async () => { + const res = await api.get(`/marketing/tiers/${id}`) + return res.data.data + } + }) +} diff --git a/src/types/services/tier.ts b/src/types/services/tier.ts index 4c9d539..3ae6c76 100644 --- a/src/types/services/tier.ts +++ b/src/types/services/tier.ts @@ -1,4 +1,4 @@ -export type TierType = { +export interface Tier { id: string // uuid name: string min_points: number @@ -6,3 +6,17 @@ export type TierType = { created_at: string // ISO datetime updated_at: string // ISO datetime } + +export interface Tiers { + data: Tier[] + total_count: number + page: number + limit: number + total_pages: number +} + +export interface TierRequest { + name: string + min_points: number + benefits: Record +} diff --git a/src/views/apps/marketing/tier/AddTierDrawer.tsx b/src/views/apps/marketing/tier/AddTierDrawer.tsx index 5efb37d..8049c83 100644 --- a/src/views/apps/marketing/tier/AddTierDrawer.tsx +++ b/src/views/apps/marketing/tier/AddTierDrawer.tsx @@ -14,40 +14,38 @@ import Switch from '@mui/material/Switch' import FormControlLabel from '@mui/material/FormControlLabel' import Chip from '@mui/material/Chip' import InputAdornment from '@mui/material/InputAdornment' +import Select from '@mui/material/Select' +import FormControl from '@mui/material/FormControl' +import InputLabel from '@mui/material/InputLabel' // Third-party Imports -import { useForm, Controller, useFieldArray } from 'react-hook-form' +import { useForm, Controller } from 'react-hook-form' // Component Imports import CustomTextField from '@core/components/mui/TextField' - -// Types -export type TierType = { - id: string // uuid - name: string - min_points: number - benefits: Record - created_at: string // ISO datetime - updated_at: string // ISO datetime -} - -export interface TierRequest { - name: string - min_points: number - benefits: Record -} +import { Tier, TierRequest } from '@/types/services/tier' +import { useTiersMutation } from '@/services/mutations/tier' type Props = { open: boolean handleClose: () => void - data?: TierType // Data tier untuk edit (jika ada) + data?: Tier // Data tier untuk edit (jika ada) +} + +// Benefit item type +type BenefitItem = { + key: string + value: any + type: 'boolean' | 'number' | 'string' } type FormValidateType = { name: string min_points: number - benefits: string[] // Array of benefit names for easier form handling - newBenefit: string // Temporary field for adding new benefits + benefits: BenefitItem[] + newBenefitKey: string + newBenefitValue: string + newBenefitType: 'boolean' | 'number' | 'string' } // Initial form data @@ -55,26 +53,9 @@ const initialData: FormValidateType = { name: '', min_points: 0, benefits: [], - newBenefit: '' -} - -// Mock mutation hooks (replace with actual hooks) -const useTierMutation = () => { - const createTier = { - mutate: (data: TierRequest, options?: { onSuccess?: () => void }) => { - console.log('Creating tier:', data) - setTimeout(() => options?.onSuccess?.(), 1000) - } - } - - const updateTier = { - mutate: (data: { id: string; payload: TierRequest }, options?: { onSuccess?: () => void }) => { - console.log('Updating tier:', data) - setTimeout(() => options?.onSuccess?.(), 1000) - } - } - - return { createTier, updateTier } + newBenefitKey: '', + newBenefitValue: '', + newBenefitType: 'boolean' } const AddEditTierDrawer = (props: Props) => { @@ -85,7 +66,7 @@ const AddEditTierDrawer = (props: Props) => { const [showMore, setShowMore] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) - const { createTier, updateTier } = useTierMutation() + const { createTier, updateTier } = useTiersMutation() // Determine if this is edit mode const isEditMode = Boolean(data?.id) @@ -103,23 +84,53 @@ const AddEditTierDrawer = (props: Props) => { }) const watchedBenefits = watch('benefits') - const watchedNewBenefit = watch('newBenefit') + const watchedNewBenefitKey = watch('newBenefitKey') + const watchedNewBenefitValue = watch('newBenefitValue') + const watchedNewBenefitType = watch('newBenefitType') - // Helper function to convert benefits object to string array - const convertBenefitsToArray = (benefits: Record): string[] => { + // Helper function to convert benefits object to BenefitItem array + const convertBenefitsToArray = (benefits: Record): BenefitItem[] => { if (!benefits) return [] - return Object.keys(benefits).filter(key => benefits[key] === true || benefits[key] === 'true') + return Object.entries(benefits).map(([key, value]) => ({ + key, + value, + type: typeof value === 'boolean' ? 'boolean' : typeof value === 'number' ? 'number' : 'string' + })) } - // Helper function to convert benefits array to object - const convertBenefitsToObject = (benefits: string[]): Record => { + // Helper function to convert BenefitItem array to benefits object + const convertBenefitsToObject = (benefits: BenefitItem[]): Record => { const benefitsObj: Record = {} benefits.forEach(benefit => { - benefitsObj[benefit] = true + let value = benefit.value + // Convert string values to appropriate types + if (benefit.type === 'boolean') { + value = value === true || value === 'true' || value === 'yes' + } else if (benefit.type === 'number') { + value = Number(value) + } + benefitsObj[benefit.key] = value }) return benefitsObj } + // Helper function to format benefit display + const formatBenefitDisplay = (item: BenefitItem): string => { + const readableKey = item.key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) + + if (item.type === 'boolean') { + return `${readableKey}: ${item.value ? 'Ya' : 'Tidak'}` + } else if (item.type === 'number') { + if (item.key.includes('multiplier')) { + return `${readableKey}: ${item.value}x` + } else if (item.key.includes('discount') || item.key.includes('bonus')) { + return `${readableKey}: ${item.value}%` + } + return `${readableKey}: ${item.value}` + } + return `${readableKey}: ${item.value}` + } + // Effect to populate form when editing useEffect(() => { if (isEditMode && data) { @@ -131,7 +142,9 @@ const AddEditTierDrawer = (props: Props) => { name: data.name || '', min_points: data.min_points || 0, benefits: benefitsArray, - newBenefit: '' + newBenefitKey: '', + newBenefitValue: '', + newBenefitType: 'boolean' } resetForm(formData) @@ -144,10 +157,40 @@ const AddEditTierDrawer = (props: Props) => { }, [data, isEditMode, resetForm]) const handleAddBenefit = () => { - if (watchedNewBenefit.trim()) { + const key = watchedNewBenefitKey.trim() + const value = watchedNewBenefitValue.trim() + const type = watchedNewBenefitType + + if (key && value) { + // Check if key already exists + const existingKeys = watchedBenefits.map(b => b.key) + if (existingKeys.includes(key)) { + alert('Key benefit sudah ada!') + return + } + + let processedValue: any = value + if (type === 'boolean') { + processedValue = value === 'true' || value === 'yes' || value === '1' + } else if (type === 'number') { + processedValue = Number(value) + if (isNaN(processedValue)) { + alert('Nilai harus berupa angka!') + return + } + } + + const newBenefit: BenefitItem = { + key, + value: processedValue, + type + } + const currentBenefits = watchedBenefits || [] - setValue('benefits', [...currentBenefits, watchedNewBenefit.trim()]) - setValue('newBenefit', '') + setValue('benefits', [...currentBenefits, newBenefit]) + setValue('newBenefitKey', '') + setValue('newBenefitValue', '') + setValue('newBenefitType', 'boolean') } } @@ -178,6 +221,8 @@ const AddEditTierDrawer = (props: Props) => { benefits: benefitsObj } + console.log('Submitting tier data:', tierRequest) + if (isEditMode && data?.id) { // Update existing tier updateTier.mutate( @@ -318,77 +363,106 @@ const AddEditTierDrawer = (props: Props) => { {/* Display current benefits */} {watchedBenefits && watchedBenefits.length > 0 && ( -
+
{watchedBenefits.map((benefit, index) => ( handleRemoveBenefit(index)} color='primary' variant='outlined' size='small' + sx={{ + justifyContent: 'space-between', + '& .MuiChip-label': { + overflow: 'visible', + textOverflow: 'unset', + whiteSpace: 'normal' + } + }} /> ))}
)} - {/* Add new benefit */} - ( - - - - ) - }} - /> - )} - /> + {/* Add new benefit - Key */} +
+ ( + + )} + /> +
+ + {/* Type selector */} +
+ ( + + Tipe Value + + + )} + /> +
+ + {/* Add new benefit - Value */} +
+ ( + + + + ) + }} + /> + )} + /> +
+ {(!watchedBenefits || watchedBenefits.length === 0) && ( Minimal satu manfaat harus ditambahkan )}
- - {/* Tampilkan selengkapnya */} - {!showMore && ( - - )} - - {/* Konten tambahan */} - {showMore && ( - <> - {/* Sembunyikan */} - - - )} @@ -411,6 +485,7 @@ const AddEditTierDrawer = (props: Props) => { type='submit' form='tier-form' disabled={isSubmitting || !watchedBenefits || watchedBenefits.length === 0} + startIcon={isSubmitting ? : null} > {isSubmitting ? (isEditMode ? 'Mengupdate...' : 'Menyimpan...') : isEditMode ? 'Update' : 'Simpan'} diff --git a/src/views/apps/marketing/tier/DeleteTierDialog.tsx b/src/views/apps/marketing/tier/DeleteTierDialog.tsx new file mode 100644 index 0000000..0a32104 --- /dev/null +++ b/src/views/apps/marketing/tier/DeleteTierDialog.tsx @@ -0,0 +1,106 @@ +// 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' + +// Types +import { Tier } from '@/types/services/tier' + +type Props = { + open: boolean + onClose: () => void + onConfirm: () => void + tier: Tier | null + isDeleting?: boolean +} + +const DeleteTierDialog = ({ open, onClose, onConfirm, tier, isDeleting = false }: Props) => { + if (!tier) return null + + return ( + + + + + Hapus Tier + + + + + + Apakah Anda yakin ingin menghapus tier berikut? + + + + + {tier.name} + + + Minimum Poin: {new Intl.NumberFormat('id-ID').format(tier.min_points)} poin + + + Dibuat:{' '} + {new Date(tier.created_at).toLocaleDateString('id-ID', { + year: 'numeric', + month: 'long', + day: 'numeric' + })} + + + + + + Peringatan: Tindakan ini tidak dapat dibatalkan. Semua data yang terkait dengan tier ini + akan dihapus secara permanen. + + + + + Pastikan tidak ada pengguna yang masih menggunakan tier ini sebelum menghapus. + + + + + + + + + ) +} + +export default DeleteTierDialog diff --git a/src/views/apps/marketing/tier/TierListTable.tsx b/src/views/apps/marketing/tier/TierListTable.tsx index 1d3c526..9bd42cc 100644 --- a/src/views/apps/marketing/tier/TierListTable.tsx +++ b/src/views/apps/marketing/tier/TierListTable.tsx @@ -57,16 +57,10 @@ import { formatCurrency } from '@/utils/transform' import tableStyles from '@core/styles/table.module.css' import Loading from '@/components/layout/shared/Loading' import AddEditTierDrawer from './AddTierDrawer' - -// Tier Type Interface -export type TierType = { - id: string // uuid - name: string - min_points: number - benefits: Record - created_at: string // ISO datetime - updated_at: string // ISO datetime -} +import DeleteTierDialog from './DeleteTierDialog' +import { Tier } from '@/types/services/tier' +import { useTiers } from '@/services/queries/tier' +import { useTiersMutation } from '@/services/mutations/tier' declare module '@tanstack/table-core' { interface FilterFns { @@ -77,7 +71,7 @@ declare module '@tanstack/table-core' { } } -type TierTypeWithAction = TierType & { +type TierTypeWithAction = Tier & { action?: string } @@ -126,130 +120,41 @@ const DebouncedInput = ({ return setValue(e.target.value)} /> } -// Dummy data for tiers -const DUMMY_TIER_DATA: TierType[] = [ - { - id: '1', - name: 'Bronze', - min_points: 0, - benefits: { - 'Gratis ongkir': true, - 'Diskon 5%': true, - 'Priority customer service': true - }, - created_at: '2024-01-15T00:00:00Z', - updated_at: '2024-02-10T00:00:00Z' - }, - { - id: '2', - name: 'Silver', - min_points: 1000, - benefits: { - 'Gratis ongkir': true, - 'Diskon 10%': true, - 'Birthday bonus': true, - 'Priority customer service': true, - 'Akses early sale': true - }, - created_at: '2024-01-20T00:00:00Z', - updated_at: '2024-02-15T00:00:00Z' - }, - { - id: '3', - name: 'Gold', - min_points: 5000, - benefits: { - 'Gratis ongkir': true, - 'Diskon 15%': true, - 'Birthday bonus': true, - 'Dedicated account manager': true, - 'VIP event access': true, - 'Personal shopper': true - }, - created_at: '2024-01-25T00:00:00Z', - updated_at: '2024-02-20T00:00:00Z' - }, - { - id: '4', - name: 'Platinum', - min_points: 15000, - benefits: { - 'Gratis ongkir': true, - 'Diskon 20%': true, - 'Birthday bonus': true, - 'Dedicated account manager': true, - 'VIP event access': true, - 'Personal shopper': true, - 'Annual gift': true, - 'Luxury experiences': true - }, - created_at: '2024-02-01T00:00:00Z', - updated_at: '2024-02-25T00:00:00Z' - }, - { - id: '5', - name: 'Diamond', - min_points: 50000, - benefits: { - 'Gratis ongkir': true, - 'Diskon 25%': true, - 'Birthday bonus': true, - 'Dedicated account manager': true, - 'VIP event access': true, - 'Personal shopper': true, - 'Annual gift': true, - 'Luxury experiences': true, - 'Exclusive events': true, - 'Concierge service': true - }, - created_at: '2024-02-05T00:00:00Z', - updated_at: '2024-03-01T00:00:00Z' - } -] - -// Mock data hook with dummy data -const useTiers = ({ 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_TIER_DATA - - return DUMMY_TIER_DATA.filter( - tier => - tier.name.toLowerCase().includes(search.toLowerCase()) || - Object.keys(tier.benefits).some(benefit => benefit.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: { - tiers: paginatedData, - total_count: filteredData.length - }, - isLoading, - error: null, - isFetching: isLoading - } +// Helper function to get all benefits as array +const getAllBenefits = (benefits: Record): Array<{ key: string; value: any; display: string }> => { + return Object.entries(benefits).map(([key, value]) => ({ + key, + value, + display: formatBenefitDisplay(key, value) + })) } -// Helper function to get active benefits as array -const getActiveBenefits = (benefits: Record): string[] => { - return Object.keys(benefits).filter(key => benefits[key] === true || benefits[key] === 'true') +const formatBenefitDisplay = (key: string, value: any): string => { + // Convert snake_case to readable format + const readableKey = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) + + if (value === true) { + return readableKey + } + + if (value === false) { + return `${readableKey} (Tidak Aktif)` + } + + if (typeof value === 'number') { + // Handle multipliers + if (key.includes('multiplier')) { + return `${readableKey} ${value}x` + } + // Handle percentages + if (key.includes('discount') || key.includes('bonus')) { + return `${readableKey} ${value}%` + } + // Default number formatting + return `${readableKey} ${value}` + } + + return `${readableKey}: ${value}` } // Helper function to format points @@ -257,13 +162,17 @@ const formatPoints = (points: number) => { return new Intl.NumberFormat('id-ID').format(points) } +// Mock mutation hook for delete (replace with actual hook) + // Column Definitions const columnHelper = createColumnHelper() const TierListTable = () => { // States const [addTierOpen, setAddTierOpen] = useState(false) - const [editTierData, setEditTierData] = useState(undefined) + const [editTierData, setEditTierData] = useState(undefined) + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [tierToDelete, setTierToDelete] = useState(null) const [rowSelection, setRowSelection] = useState({}) const [globalFilter, setGlobalFilter] = useState('') const [currentPage, setCurrentPage] = useState(1) @@ -276,7 +185,9 @@ const TierListTable = () => { search }) - const tiers = data?.tiers ?? [] + const { deleteTier } = useTiersMutation() + + const tiers = data?.data ?? [] const totalCount = data?.total_count ?? 0 // Hooks @@ -292,16 +203,38 @@ const TierListTable = () => { setCurrentPage(1) // Reset to first page }, []) - const handleEditTier = (tier: TierType) => { + const handleEditTier = (tier: Tier) => { setEditTierData(tier) setAddTierOpen(true) } - const handleDeleteTier = (tierId: string) => { - if (confirm('Apakah Anda yakin ingin menghapus tier ini?')) { - console.log('Deleting tier:', tierId) - // Add your delete logic here - // deleteTier.mutate(tierId) + const handleDeleteTier = (tier: Tier) => { + setTierToDelete(tier) + setDeleteDialogOpen(true) + } + + const handleConfirmDelete = () => { + if (tierToDelete) { + deleteTier.mutate(tierToDelete.id, { + onSuccess: () => { + console.log('Tier deleted successfully') + setDeleteDialogOpen(false) + setTierToDelete(null) + // You might want to refetch data here + // refetch() + }, + onError: error => { + console.error('Error deleting tier:', error) + // Handle error (show toast, etc.) + } + }) + } + } + + const handleCloseDeleteDialog = () => { + if (!deleteTier.isPending) { + setDeleteDialogOpen(false) + setTierToDelete(null) } } @@ -360,14 +293,53 @@ const TierListTable = () => { columnHelper.accessor('benefits', { header: 'Manfaat', cell: ({ row }) => { - const activeBenefits = getActiveBenefits(row.original.benefits) + const allBenefits = getAllBenefits(row.original.benefits) + + if (allBenefits.length === 0) { + return ( + + Tidak ada manfaat + + ) + } + return (
- {activeBenefits.slice(0, 2).map((benefit, index) => ( - - ))} - {activeBenefits.length > 2 && ( - + {allBenefits.slice(0, 2).map((benefit, index) => { + // Different colors for different value types + let chipColor: 'default' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' = + 'secondary' + + if (benefit.value === false) { + chipColor = 'default' + } else if (benefit.value === true) { + chipColor = 'success' + } else if (typeof benefit.value === 'number') { + chipColor = 'info' + } + + return ( + + ) + })} + {allBenefits.length > 2 && ( + b.display) + .join(', ')} // Show remaining benefits in tooltip + /> )}
) @@ -407,7 +379,7 @@ const TierListTable = () => { icon: 'tabler-trash text-[22px]', menuItemProps: { className: 'flex items-center gap-2 text-textSecondary', - onClick: () => handleDeleteTier(row.original.id) + onClick: () => handleDeleteTier(row.original) } } ]} @@ -422,7 +394,7 @@ const TierListTable = () => { ) const table = useReactTable({ - data: tiers as TierType[], + data: tiers as Tier[], columns, filterFns: { fuzzy: fuzzyFilter @@ -558,7 +530,18 @@ const TierListTable = () => { disabled={isLoading} /> + + {/* Add/Edit Tier Drawer */} + + {/* Delete Confirmation Dialog */} + ) }