From f527b6f0048656041430e744c8a274ab0fac3724 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 17 Sep 2025 00:10:14 +0700 Subject: [PATCH 01/18] Loyalty Page --- .../(private)/apps/marketing/loyalty/page.tsx | 7 + .../layout/vertical/VerticalMenu.tsx | 3 + src/data/dictionaries/en.json | 4 +- src/data/dictionaries/id.json | 4 +- src/types/services/loyalty.ts | 9 + .../marketing/loyalty/AddLoyaltiDrawer.tsx | 495 ++++++++++++++ .../marketing/loyalty/LoyaltyListTable.tsx | 635 ++++++++++++++++++ src/views/apps/marketing/loyalty/index.tsx | 17 + 8 files changed, 1172 insertions(+), 2 deletions(-) create mode 100644 src/app/[lang]/(dashboard)/(private)/apps/marketing/loyalty/page.tsx create mode 100644 src/types/services/loyalty.ts create mode 100644 src/views/apps/marketing/loyalty/AddLoyaltiDrawer.tsx create mode 100644 src/views/apps/marketing/loyalty/LoyaltyListTable.tsx create mode 100644 src/views/apps/marketing/loyalty/index.tsx diff --git a/src/app/[lang]/(dashboard)/(private)/apps/marketing/loyalty/page.tsx b/src/app/[lang]/(dashboard)/(private)/apps/marketing/loyalty/page.tsx new file mode 100644 index 0000000..0b09f4d --- /dev/null +++ b/src/app/[lang]/(dashboard)/(private)/apps/marketing/loyalty/page.tsx @@ -0,0 +1,7 @@ +import LoyaltyList from '@/views/apps/marketing/loyalty' + +const LoyaltiPage = () => { + return +} + +export default LoyaltiPage diff --git a/src/components/layout/vertical/VerticalMenu.tsx b/src/components/layout/vertical/VerticalMenu.tsx index c9b634b..11228c0 100644 --- a/src/components/layout/vertical/VerticalMenu.tsx +++ b/src/components/layout/vertical/VerticalMenu.tsx @@ -153,6 +153,9 @@ const VerticalMenu = ({ dictionary, scrollMenu }: Props) => { > {dictionary['navigation'].reports} + }> + {dictionary['navigation'].loyalty} + }> {dictionary['navigation'].list} diff --git a/src/data/dictionaries/en.json b/src/data/dictionaries/en.json index 2194f85..2e1e690 100644 --- a/src/data/dictionaries/en.json +++ b/src/data/dictionaries/en.json @@ -126,6 +126,8 @@ "expenses": "Expenses", "cash_and_bank": "Cash & Bank", "account": "Account", - "fixed_assets": "Fixed Assets" + "fixed_assets": "Fixed Assets", + "marketing": "Marketing", + "loyalty": "Loyalty" } } diff --git a/src/data/dictionaries/id.json b/src/data/dictionaries/id.json index 054b387..19bcaf3 100644 --- a/src/data/dictionaries/id.json +++ b/src/data/dictionaries/id.json @@ -126,6 +126,8 @@ "expenses": "Biaya", "cash_and_bank": "Kas & Bank", "account": "Akun", - "fixed_assets": "Aset Tetap" + "fixed_assets": "Aset Tetap", + "marketing": "Pemasaran", + "loyalty": "Loyalti" } } diff --git a/src/types/services/loyalty.ts b/src/types/services/loyalty.ts new file mode 100644 index 0000000..5e2e896 --- /dev/null +++ b/src/types/services/loyalty.ts @@ -0,0 +1,9 @@ +export interface LoyaltyType { + id: string + name: string + minimumPurchase: number + pointMultiplier: number + benefits: string[] + createdAt: Date + updatedAt: Date +} diff --git a/src/views/apps/marketing/loyalty/AddLoyaltiDrawer.tsx b/src/views/apps/marketing/loyalty/AddLoyaltiDrawer.tsx new file mode 100644 index 0000000..927ae75 --- /dev/null +++ b/src/views/apps/marketing/loyalty/AddLoyaltiDrawer.tsx @@ -0,0 +1,495 @@ +// React Imports +import { useState, useEffect } from 'react' + +// MUI Imports +import Button from '@mui/material/Button' +import Drawer from '@mui/material/Drawer' +import IconButton from '@mui/material/IconButton' +import MenuItem from '@mui/material/MenuItem' +import Typography from '@mui/material/Typography' +import Divider from '@mui/material/Divider' +import Grid from '@mui/material/Grid2' +import Box from '@mui/material/Box' +import Switch from '@mui/material/Switch' +import FormControlLabel from '@mui/material/FormControlLabel' +import Chip from '@mui/material/Chip' +import InputAdornment from '@mui/material/InputAdornment' + +// Third-party Imports +import { useForm, Controller, useFieldArray } from 'react-hook-form' + +// Component Imports +import CustomTextField from '@core/components/mui/TextField' + +// Types +export interface LoyaltyType { + id: string + name: string + minimumPurchase: number + pointMultiplier: number + benefits: string[] + createdAt: Date + updatedAt: Date +} + +export interface LoyaltyRequest { + name: string + minimumPurchase: number + pointMultiplier: number + benefits: string[] + description?: string + isActive?: boolean +} + +type Props = { + open: boolean + handleClose: () => void + data?: LoyaltyType // Data loyalty untuk edit (jika ada) +} + +type FormValidateType = { + name: string + minimumPurchase: number + pointMultiplier: number + benefits: string[] + description: string + isActive: boolean + newBenefit: string // Temporary field for adding new benefits +} + +// Initial form data +const initialData: FormValidateType = { + name: '', + minimumPurchase: 0, + pointMultiplier: 1, + benefits: [], + description: '', + isActive: true, + newBenefit: '' +} + +// Mock mutation hooks (replace with actual hooks) +const useLoyaltyMutation = () => { + const createLoyalty = { + mutate: (data: LoyaltyRequest, options?: { onSuccess?: () => void }) => { + console.log('Creating loyalty:', data) + setTimeout(() => options?.onSuccess?.(), 1000) + } + } + + const updateLoyalty = { + mutate: (data: { id: string; payload: LoyaltyRequest }, options?: { onSuccess?: () => void }) => { + console.log('Updating loyalty:', data) + setTimeout(() => options?.onSuccess?.(), 1000) + } + } + + return { createLoyalty, updateLoyalty } +} + +const AddEditLoyaltyDrawer = (props: Props) => { + // Props + const { open, handleClose, data } = props + + // States + const [showMore, setShowMore] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + + const { createLoyalty, updateLoyalty } = useLoyaltyMutation() + + // Determine if this is edit mode + const isEditMode = Boolean(data?.id) + + // Hooks + const { + control, + reset: resetForm, + handleSubmit, + watch, + setValue, + formState: { errors } + } = useForm({ + defaultValues: initialData + }) + + const watchedBenefits = watch('benefits') + const watchedNewBenefit = watch('newBenefit') + + // Effect to populate form when editing + useEffect(() => { + if (isEditMode && data) { + // Populate form with existing data + const formData: FormValidateType = { + name: data.name || '', + minimumPurchase: data.minimumPurchase || 0, + pointMultiplier: data.pointMultiplier || 1, + benefits: data.benefits || [], + description: '', // Add description field if available in your data + isActive: true, // Add isActive field if available in your data + newBenefit: '' + } + + resetForm(formData) + setShowMore(true) // Always show more for edit mode + } else { + // Reset to initial data for add mode + resetForm(initialData) + setShowMore(false) + } + }, [data, isEditMode, resetForm]) + + const handleAddBenefit = () => { + if (watchedNewBenefit.trim()) { + const currentBenefits = watchedBenefits || [] + setValue('benefits', [...currentBenefits, watchedNewBenefit.trim()]) + setValue('newBenefit', '') + } + } + + const handleRemoveBenefit = (index: number) => { + const currentBenefits = watchedBenefits || [] + const newBenefits = currentBenefits.filter((_, i) => i !== index) + setValue('benefits', newBenefits) + } + + const handleKeyPress = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault() + handleAddBenefit() + } + } + + const handleFormSubmit = async (formData: FormValidateType) => { + try { + setIsSubmitting(true) + + // Create LoyaltyRequest object + const loyaltyRequest: LoyaltyRequest = { + name: formData.name, + minimumPurchase: formData.minimumPurchase, + pointMultiplier: formData.pointMultiplier, + benefits: formData.benefits, + description: formData.description || undefined, + isActive: formData.isActive + } + + if (isEditMode && data?.id) { + // Update existing loyalty + updateLoyalty.mutate( + { id: data.id, payload: loyaltyRequest }, + { + onSuccess: () => { + handleReset() + handleClose() + } + } + ) + } else { + // Create new loyalty + createLoyalty.mutate(loyaltyRequest, { + onSuccess: () => { + handleReset() + handleClose() + } + }) + } + } catch (error) { + console.error('Error submitting loyalty:', error) + // Handle error (show toast, etc.) + } finally { + setIsSubmitting(false) + } + } + + const handleReset = () => { + handleClose() + resetForm(initialData) + setShowMore(false) + } + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR', + minimumFractionDigits: 0 + }).format(value) + } + + return ( + + {/* Sticky Header */} + +
+ {isEditMode ? 'Edit Program Loyalty' : 'Tambah Program Loyalty Baru'} + + + +
+
+ + {/* Scrollable Content */} + +
+
+ {/* Nama Program Loyalty */} +
+ + Nama Program Loyalty * + + ( + + )} + /> +
+ + {/* Minimum Purchase */} +
+ + Minimum Pembelian * + + ( + 0 ? formatCurrency(field.value) : '')} + InputProps={{ + startAdornment: Rp + }} + onChange={e => field.onChange(Number(e.target.value))} + /> + )} + /> +
+ + {/* Point Multiplier */} +
+ + Pengali Poin * + + ( + field.onChange(Number(e.target.value))} + > + {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(multiplier => ( + + {multiplier}x + + ))} + + )} + /> +
+ + {/* Benefits */} +
+ + Manfaat Program * + + + {/* Display current benefits */} + {watchedBenefits && watchedBenefits.length > 0 && ( +
+ {watchedBenefits.map((benefit, index) => ( + handleRemoveBenefit(index)} + color='primary' + variant='outlined' + size='small' + /> + ))} +
+ )} + + {/* Add new benefit */} + ( + + + + ) + }} + /> + )} + /> + {(!watchedBenefits || watchedBenefits.length === 0) && ( + + Minimal satu manfaat harus ditambahkan + + )} +
+ + {/* Status Aktif */} +
+ ( + } + label='Program Aktif' + /> + )} + /> +
+ + {/* Tampilkan selengkapnya */} + {!showMore && ( + + )} + + {/* Konten tambahan */} + {showMore && ( + <> + {/* Description */} +
+ + Deskripsi Program + + ( + + )} + /> +
+ + {/* Sembunyikan */} + + + )} +
+
+
+ + {/* Sticky Footer */} + +
+ + +
+
+
+ ) +} + +export default AddEditLoyaltyDrawer diff --git a/src/views/apps/marketing/loyalty/LoyaltyListTable.tsx b/src/views/apps/marketing/loyalty/LoyaltyListTable.tsx new file mode 100644 index 0000000..b887d50 --- /dev/null +++ b/src/views/apps/marketing/loyalty/LoyaltyListTable.tsx @@ -0,0 +1,635 @@ +'use client' + +// React Imports +import { useEffect, useState, useMemo, useCallback } from 'react' + +// Next Imports +import Link from 'next/link' +import { useParams } from 'next/navigation' + +// MUI Imports +import Card from '@mui/material/Card' +import CardHeader from '@mui/material/CardHeader' +import Button from '@mui/material/Button' +import Typography from '@mui/material/Typography' +import Chip from '@mui/material/Chip' +import Checkbox from '@mui/material/Checkbox' +import IconButton from '@mui/material/IconButton' +import { styled } from '@mui/material/styles' +import TablePagination from '@mui/material/TablePagination' +import type { TextFieldProps } from '@mui/material/TextField' +import MenuItem from '@mui/material/MenuItem' + +// Third-party Imports +import classnames from 'classnames' +import { rankItem } from '@tanstack/match-sorter-utils' +import { + createColumnHelper, + flexRender, + getCoreRowModel, + useReactTable, + getFilteredRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFacetedMinMaxValues, + getPaginationRowModel, + getSortedRowModel +} from '@tanstack/react-table' +import type { ColumnDef, FilterFn } from '@tanstack/react-table' +import type { RankingInfo } from '@tanstack/match-sorter-utils' + +// Type Imports +import type { ThemeColor } from '@core/types' +import type { Locale } from '@configs/i18n' + +// Component Imports +import OptionMenu from '@core/components/option-menu' +import TablePaginationComponent from '@components/TablePaginationComponent' +import CustomTextField from '@core/components/mui/TextField' +import CustomAvatar from '@core/components/mui/Avatar' + +// Util Imports +import { getInitials } from '@/utils/getInitials' +import { getLocalizedUrl } from '@/utils/i18n' +import { formatCurrency } from '@/utils/transform' + +// Style Imports +import tableStyles from '@core/styles/table.module.css' +import Loading from '@/components/layout/shared/Loading' +import AddEditLoyaltyDrawer from './AddLoyaltiDrawer' + +// Loyalty Type Interface +export interface LoyaltyType { + id: string + name: string + minimumPurchase: number + pointMultiplier: number + benefits: string[] + createdAt: Date + updatedAt: Date +} + +declare module '@tanstack/table-core' { + interface FilterFns { + fuzzy: FilterFn + } + interface FilterMeta { + itemRank: RankingInfo + } +} + +type LoyaltyTypeWithAction = LoyaltyType & { + action?: string +} + +// Styled Components +const Icon = styled('i')({}) + +const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { + // Rank the item + const itemRank = rankItem(row.getValue(columnId), value) + + // Store the itemRank info + addMeta({ + itemRank + }) + + // Return if the item should be filtered in/out + return itemRank.passed +} + +const DebouncedInput = ({ + value: initialValue, + onChange, + debounce = 500, + ...props +}: { + value: string | number + onChange: (value: string | number) => void + debounce?: number +} & Omit) => { + // States + const [value, setValue] = useState(initialValue) + + useEffect(() => { + setValue(initialValue) + }, [initialValue]) + + useEffect(() => { + const timeout = setTimeout(() => { + onChange(value) + }, debounce) + + return () => clearTimeout(timeout) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]) + + return setValue(e.target.value)} /> +} + +// Dummy data for loyalty programs +const DUMMY_LOYALTY_DATA: LoyaltyType[] = [ + { + id: '1', + name: 'Silver Member', + minimumPurchase: 500000, + pointMultiplier: 1, + benefits: ['Gratis ongkir', 'Diskon 5%', 'Priority customer service'], + createdAt: new Date('2024-01-15'), + updatedAt: new Date('2024-02-10') + }, + { + id: '2', + name: 'Gold Member', + minimumPurchase: 2000000, + pointMultiplier: 2, + benefits: ['Gratis ongkir', 'Diskon 10%', 'Birthday bonus', 'Priority customer service', 'Akses early sale'], + createdAt: new Date('2024-01-20'), + updatedAt: new Date('2024-02-15') + }, + { + id: '3', + name: 'Platinum Member', + minimumPurchase: 5000000, + pointMultiplier: 3, + benefits: [ + 'Gratis ongkir', + 'Diskon 15%', + 'Birthday bonus', + 'Dedicated account manager', + 'VIP event access', + 'Personal shopper' + ], + createdAt: new Date('2024-01-25'), + updatedAt: new Date('2024-02-20') + }, + { + id: '4', + name: 'Diamond Member', + minimumPurchase: 10000000, + pointMultiplier: 5, + benefits: [ + 'Gratis ongkir', + 'Diskon 20%', + 'Birthday bonus', + 'Dedicated account manager', + 'VIP event access', + 'Personal shopper', + 'Annual gift', + 'Luxury experiences' + ], + createdAt: new Date('2024-02-01'), + updatedAt: new Date('2024-02-25') + }, + { + id: '5', + name: 'Student Discount', + minimumPurchase: 100000, + pointMultiplier: 1, + benefits: ['Diskon 10% khusus mahasiswa', 'Gratis ongkir untuk pembelian minimal 200k'], + createdAt: new Date('2024-02-05'), + updatedAt: new Date('2024-03-01') + }, + { + id: '6', + name: 'Senior Citizen', + minimumPurchase: 200000, + pointMultiplier: 2, + benefits: ['Diskon 15% untuk usia 60+', 'Gratis ongkir', 'Konsultasi gratis', 'Priority support'], + createdAt: new Date('2024-02-10'), + updatedAt: new Date('2024-03-05') + }, + { + id: '7', + name: 'Corporate Partner', + minimumPurchase: 15000000, + pointMultiplier: 4, + benefits: [ + 'Diskon 25% untuk pembelian korporat', + 'Payment terms 30 hari', + 'Dedicated sales rep', + 'Bulk discount', + 'Invoice payment' + ], + createdAt: new Date('2024-02-15'), + updatedAt: new Date('2024-03-10') + }, + { + id: '8', + name: 'New Customer Bonus', + minimumPurchase: 0, + pointMultiplier: 1, + benefits: ['Welcome bonus 50 poin', 'Diskon 15% pembelian pertama', 'Gratis ongkir'], + createdAt: new Date('2024-03-01'), + updatedAt: new Date('2024-03-15') + }, + { + id: '9', + name: 'Family Package', + minimumPurchase: 1000000, + pointMultiplier: 2, + benefits: [ + 'Diskon 12% untuk keluarga', + 'Poin dapat dibagi ke anggota keluarga', + 'Family rewards', + 'Group discount' + ], + createdAt: new Date('2024-03-05'), + updatedAt: new Date('2024-03-20') + }, + { + id: '10', + name: 'Loyalty Plus', + minimumPurchase: 3000000, + pointMultiplier: 3, + benefits: [ + 'Cashback 8%', + 'Exclusive member-only products', + 'Free premium packaging', + 'Extended warranty', + 'Member appreciation events' + ], + createdAt: new Date('2024-03-10'), + updatedAt: new Date('2024-03-25') + }, + { + id: '11', + name: 'VIP Collector', + minimumPurchase: 7500000, + pointMultiplier: 4, + benefits: [ + 'Limited edition access', + 'Collector item discounts', + 'Pre-order privileges', + 'Authentication service', + 'Storage solutions' + ], + createdAt: new Date('2024-03-15'), + updatedAt: new Date('2024-03-30') + }, + { + id: '12', + name: 'Seasonal Member', + minimumPurchase: 800000, + pointMultiplier: 2, + benefits: ['Seasonal sale access', 'Holiday bonuses', 'Festival discounts', 'Seasonal gift wrapping'], + createdAt: new Date('2024-03-20'), + updatedAt: new Date('2024-04-05') + } +] + +// Mock data hook with dummy data +const useLoyalty = ({ 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_LOYALTY_DATA + + return DUMMY_LOYALTY_DATA.filter( + loyalty => + loyalty.name.toLowerCase().includes(search.toLowerCase()) || + loyalty.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: { + loyalties: paginatedData, + total_count: filteredData.length + }, + isLoading, + error: null, + isFetching: isLoading + } +} + +// Column Definitions +const columnHelper = createColumnHelper() + +const LoyaltyListTable = () => { + // States + const [addLoyaltyOpen, setAddLoyaltyOpen] = useState(false) + const [editLoyaltyData, setEditLoyaltyData] = useState(undefined) + const [rowSelection, setRowSelection] = useState({}) + const [globalFilter, setGlobalFilter] = useState('') + const [currentPage, setCurrentPage] = useState(1) + const [pageSize, setPageSize] = useState(10) + const [search, setSearch] = useState('') + + const { data, isLoading, error, isFetching } = useLoyalty({ + page: currentPage, + limit: pageSize, + search + }) + + const loyalties = data?.loyalties ?? [] + const totalCount = data?.total_count ?? 0 + + // Hooks + const { lang: locale } = useParams() + + const handlePageChange = useCallback((event: unknown, newPage: number) => { + setCurrentPage(newPage) + }, []) + + const handlePageSizeChange = useCallback((event: React.ChangeEvent) => { + const newPageSize = parseInt(event.target.value, 10) + setPageSize(newPageSize) + setCurrentPage(1) // Reset to first page + }, []) + + const handleEditLoyalty = (loyalty: LoyaltyType) => { + setEditLoyaltyData(loyalty) + setAddLoyaltyOpen(true) + } + + const handleDeleteLoyalty = (loyaltyId: string) => { + if (confirm('Apakah Anda yakin ingin menghapus program loyalty ini?')) { + console.log('Deleting loyalty:', loyaltyId) + // Add your delete logic here + // deleteLoyalty.mutate(loyaltyId) + } + } + + const handleCloseLoyaltyDrawer = () => { + setAddLoyaltyOpen(false) + setEditLoyaltyData(undefined) + } + + const columns = useMemo[]>( + () => [ + { + id: 'select', + header: ({ table }) => ( + + ), + cell: ({ row }) => ( + + ) + }, + columnHelper.accessor('name', { + header: 'Program Loyalty', + cell: ({ row }) => ( +
+
+ + + {row.original.name} + + +
+
+ ) + }), + columnHelper.accessor('minimumPurchase', { + header: 'Minimum Pembelian', + cell: ({ row }) => ( +
+ + {formatCurrency(row.original.minimumPurchase)} +
+ ) + }), + columnHelper.accessor('pointMultiplier', { + header: 'Pengali Poin', + cell: ({ row }) => ( + + ) + }), + columnHelper.accessor('benefits', { + header: 'Manfaat', + cell: ({ row }) => ( +
+ {row.original.benefits.slice(0, 2).map((benefit, index) => ( + + ))} + {row.original.benefits.length > 2 && ( + + )} +
+ ) + }), + columnHelper.accessor('createdAt', { + header: 'Tanggal Dibuat', + cell: ({ row }) => ( + + {new Date(row.original.createdAt).toLocaleDateString('id-ID', { + year: 'numeric', + month: 'short', + day: 'numeric' + })} + + ) + }), + { + id: 'actions', + header: 'Aksi', + cell: ({ row }) => ( +
+ handleEditLoyalty(row.original) + } + }, + { + text: 'Hapus', + icon: 'tabler-trash text-[22px]', + menuItemProps: { + className: 'flex items-center gap-2 text-textSecondary', + onClick: () => handleDeleteLoyalty(row.original.id) + } + } + ]} + /> +
+ ), + enableSorting: false + } + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [locale, handleEditLoyalty, handleDeleteLoyalty] + ) + + const table = useReactTable({ + data: loyalties as LoyaltyType[], + columns, + filterFns: { + fuzzy: fuzzyFilter + }, + state: { + rowSelection, + globalFilter, + pagination: { + pageIndex: currentPage, + pageSize + } + }, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + pageCount: Math.ceil(totalCount / pageSize) + }) + + return ( + <> + +
+ table.setPageSize(Number(e.target.value))} + className='max-sm:is-full sm:is-[70px]' + > + 10 + 25 + 50 + +
+ setSearch(value as string)} + placeholder='Cari Program Loyalty' + className='max-sm:is-full' + /> + + +
+
+
+ {isLoading ? ( + + ) : ( + + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + ))} + + ))} + + {table.getFilteredRowModel().rows.length === 0 ? ( + + + + + + ) : ( + + {table + .getRowModel() + .rows.slice(0, table.getState().pagination.pageSize) + .map(row => { + return ( + + {row.getVisibleCells().map(cell => ( + + ))} + + ) + })} + + )} +
+ {header.isPlaceholder ? null : ( + <> +
+ {flexRender(header.column.columnDef.header, header.getContext())} + {{ + asc: , + desc: + }[header.column.getIsSorted() as 'asc' | 'desc'] ?? null} +
+ + )} +
+ Tidak ada data tersedia +
{flexRender(cell.column.columnDef.cell, cell.getContext())}
+ )} +
+ ( + + )} + count={totalCount} + rowsPerPage={pageSize} + page={currentPage} + onPageChange={handlePageChange} + onRowsPerPageChange={handlePageSizeChange} + rowsPerPageOptions={[10, 25, 50]} + disabled={isLoading} + /> +
+ + + ) +} + +export default LoyaltyListTable diff --git a/src/views/apps/marketing/loyalty/index.tsx b/src/views/apps/marketing/loyalty/index.tsx new file mode 100644 index 0000000..d5edcd1 --- /dev/null +++ b/src/views/apps/marketing/loyalty/index.tsx @@ -0,0 +1,17 @@ +// MUI Imports +import Grid from '@mui/material/Grid2' +import LoyaltyListTable from './LoyaltyListTable' + +// Type Imports + +const LoyaltyList = () => { + return ( + + + + + + ) +} + +export default LoyaltyList -- 2.47.2 From 1c5740a85ba5f3ab8cf75117ca41918767ab7989 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 17 Sep 2025 00:56:56 +0700 Subject: [PATCH 02/18] reward --- .../(private)/apps/marketing/reward/page.tsx | 7 + .../layout/vertical/VerticalMenu.tsx | 1 + src/data/dictionaries/en.json | 3 +- src/data/dictionaries/id.json | 3 +- src/types/services/reward.ts | 12 + .../marketing/reward/AddEditRewardDrawer.tsx | 633 +++++++++++++++++ .../apps/marketing/reward/RewardListTable.tsx | 661 ++++++++++++++++++ src/views/apps/marketing/reward/index.tsx | 17 + 8 files changed, 1335 insertions(+), 2 deletions(-) create mode 100644 src/app/[lang]/(dashboard)/(private)/apps/marketing/reward/page.tsx create mode 100644 src/types/services/reward.ts create mode 100644 src/views/apps/marketing/reward/AddEditRewardDrawer.tsx create mode 100644 src/views/apps/marketing/reward/RewardListTable.tsx create mode 100644 src/views/apps/marketing/reward/index.tsx diff --git a/src/app/[lang]/(dashboard)/(private)/apps/marketing/reward/page.tsx b/src/app/[lang]/(dashboard)/(private)/apps/marketing/reward/page.tsx new file mode 100644 index 0000000..1ae3da9 --- /dev/null +++ b/src/app/[lang]/(dashboard)/(private)/apps/marketing/reward/page.tsx @@ -0,0 +1,7 @@ +import RewardList from '@/views/apps/marketing/reward' + +const RewardPage = () => { + return +} + +export default RewardPage diff --git a/src/components/layout/vertical/VerticalMenu.tsx b/src/components/layout/vertical/VerticalMenu.tsx index 11228c0..c817458 100644 --- a/src/components/layout/vertical/VerticalMenu.tsx +++ b/src/components/layout/vertical/VerticalMenu.tsx @@ -155,6 +155,7 @@ const VerticalMenu = ({ dictionary, scrollMenu }: Props) => { }> {dictionary['navigation'].loyalty} + {dictionary['navigation'].reward} }> diff --git a/src/data/dictionaries/en.json b/src/data/dictionaries/en.json index 2e1e690..c91b7ec 100644 --- a/src/data/dictionaries/en.json +++ b/src/data/dictionaries/en.json @@ -128,6 +128,7 @@ "account": "Account", "fixed_assets": "Fixed Assets", "marketing": "Marketing", - "loyalty": "Loyalty" + "loyalty": "Loyalty", + "reward": "Reward" } } diff --git a/src/data/dictionaries/id.json b/src/data/dictionaries/id.json index 19bcaf3..7bb1d23 100644 --- a/src/data/dictionaries/id.json +++ b/src/data/dictionaries/id.json @@ -128,6 +128,7 @@ "account": "Akun", "fixed_assets": "Aset Tetap", "marketing": "Pemasaran", - "loyalty": "Loyalti" + "loyalty": "Loyalti", + "reward": "Reward" } } diff --git a/src/types/services/reward.ts b/src/types/services/reward.ts new file mode 100644 index 0000000..d9a3238 --- /dev/null +++ b/src/types/services/reward.ts @@ -0,0 +1,12 @@ +export interface RewardCatalog { + id: string + name: string + description?: string + pointCost: number + stock?: number + isActive: boolean + validUntil?: Date + imageUrl?: string + createdAt: Date + updatedAt: Date +} diff --git a/src/views/apps/marketing/reward/AddEditRewardDrawer.tsx b/src/views/apps/marketing/reward/AddEditRewardDrawer.tsx new file mode 100644 index 0000000..4c81ca1 --- /dev/null +++ b/src/views/apps/marketing/reward/AddEditRewardDrawer.tsx @@ -0,0 +1,633 @@ +// React Imports +import { useState, useEffect } from 'react' + +// MUI Imports +import Button from '@mui/material/Button' +import Drawer from '@mui/material/Drawer' +import IconButton from '@mui/material/IconButton' +import MenuItem from '@mui/material/MenuItem' +import Typography from '@mui/material/Typography' +import Divider from '@mui/material/Divider' +import Grid from '@mui/material/Grid2' +import Box from '@mui/material/Box' +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 Avatar from '@mui/material/Avatar' +import Card from '@mui/material/Card' +import CardContent from '@mui/material/CardContent' +import FormHelperText from '@mui/material/FormHelperText' + +// Third-party Imports +import { useForm, Controller } from 'react-hook-form' + +// Component Imports +import CustomTextField from '@core/components/mui/TextField' + +// Types +export interface RewardCatalogType { + id: string + name: string + description?: string + pointCost: number + stock?: number + isActive: boolean + validUntil?: Date + imageUrl?: string + createdAt: Date + updatedAt: Date +} + +export interface RewardRequest { + name: string + description?: string + pointCost: number + stock?: number + isActive: boolean + validUntil?: Date + imageUrl?: string + category?: string + terms?: string +} + +type Props = { + open: boolean + handleClose: () => void + data?: RewardCatalogType // Data reward untuk edit (jika ada) +} + +type FormValidateType = { + name: string + description: string + pointCost: number + stock: number | '' + isActive: boolean + validUntil: string + imageUrl: string + category: string + terms: string + hasUnlimitedStock: boolean + hasValidUntil: boolean +} + +// Initial form data +const initialData: FormValidateType = { + name: '', + description: '', + pointCost: 100, + stock: '', + isActive: true, + validUntil: '', + imageUrl: '', + category: 'voucher', + terms: '', + hasUnlimitedStock: false, + hasValidUntil: false +} + +// 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' } +] + +const AddEditRewardDrawer = (props: Props) => { + // Props + const { open, handleClose, data } = props + + // States + const [showMore, setShowMore] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + const [imagePreview, setImagePreview] = useState(null) + + const { createReward, updateReward } = useRewardMutation() + + // Determine if this is edit mode + const isEditMode = Boolean(data?.id) + + // Hooks + const { + control, + reset: resetForm, + handleSubmit, + watch, + setValue, + formState: { errors } + } = useForm({ + defaultValues: initialData + }) + + const watchedImageUrl = watch('imageUrl') + const watchedHasUnlimitedStock = watch('hasUnlimitedStock') + const watchedHasValidUntil = watch('hasValidUntil') + const watchedStock = watch('stock') + const watchedPointCost = watch('pointCost') + + // Effect to populate form when editing + useEffect(() => { + if (isEditMode && data) { + // Populate form with existing data + const formData: FormValidateType = { + name: data.name || '', + description: data.description || '', + pointCost: data.pointCost || 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: '', + hasUnlimitedStock: data.stock === undefined || data.stock === null, + hasValidUntil: Boolean(data.validUntil) + } + + 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) { + setValue('stock', '') + } + }, [watchedHasUnlimitedStock, setValue]) + + // Handle valid until toggle + useEffect(() => { + if (!watchedHasValidUntil) { + setValue('validUntil', '') + } + }, [watchedHasValidUntil, setValue]) + + const handleFormSubmit = async (formData: FormValidateType) => { + try { + setIsSubmitting(true) + + // Create RewardRequest object + const rewardRequest: RewardRequest = { + name: formData.name, + description: formData.description || undefined, + pointCost: formData.pointCost, + 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 + } + + if (isEditMode && data?.id) { + // Update existing reward + updateReward.mutate( + { id: data.id, payload: rewardRequest }, + { + onSuccess: () => { + handleReset() + handleClose() + } + } + ) + } else { + // Create new reward + createReward.mutate(rewardRequest, { + onSuccess: () => { + handleReset() + handleClose() + } + }) + } + } catch (error) { + console.error('Error submitting reward:', error) + // Handle error (show toast, etc.) + } finally { + setIsSubmitting(false) + } + } + + const handleReset = () => { + handleClose() + resetForm(initialData) + setShowMore(false) + setImagePreview(null) + } + + const formatPoints = (value: number) => { + return value.toLocaleString('id-ID') + ' poin' + } + + const getStockDisplay = () => { + if (watchedHasUnlimitedStock) return 'Unlimited' + if (watchedStock === '' || watchedStock === 0) return 'Tidak ada stok' + return `${watchedStock} item` + } + + return ( + + {/* Sticky Header */} + +
+ {isEditMode ? 'Edit Reward' : 'Tambah Reward Baru'} + + + +
+
+ + {/* Scrollable Content */} + +
+
+ {/* Image Preview */} + {imagePreview && ( + + + + Preview Gambar + + + + + + + )} + + {/* Nama Reward */} +
+ + Nama Reward * + + ( + + )} + /> +
+ + {/* Kategori Reward */} +
+ + Kategori Reward * + + ( + + {REWARD_CATEGORIES.map(category => ( + + {category.label} + + ))} + + )} + /> +
+ + {/* Point Cost */} +
+ + Biaya Poin * + + ( + 0 ? formatPoints(field.value) : '')} + InputProps={{ + startAdornment: ( + + + + ) + }} + onChange={e => field.onChange(Number(e.target.value))} + /> + )} + /> +
+ + {/* Stock Management */} +
+ + Manajemen Stok + + ( + } + label='Stok Unlimited' + className='mb-2' + /> + )} + /> + {!watchedHasUnlimitedStock && ( + ( + Qty + }} + onChange={e => field.onChange(e.target.value ? Number(e.target.value) : '')} + value={field.value === '' ? '' : field.value} + /> + )} + /> + )} +
+ + {/* Status Aktif */} +
+ ( + } + label='Reward Aktif' + /> + )} + /> +
+ + {/* Tampilkan selengkapnya */} + {!showMore && ( + + )} + + {/* Konten tambahan */} + {showMore && ( + <> + {/* Description */} +
+ + Deskripsi Reward + + ( + + )} + /> +
+ + {/* Image URL */} +
+ + URL Gambar + + ( + + + + ) + }} + /> + )} + /> +
+ + {/* Valid Until */} +
+ + Masa Berlaku + + ( + } + label='Memiliki batas waktu' + className='mb-2' + /> + )} + /> + {watchedHasValidUntil && ( + ( + + )} + /> + )} +
+ + {/* Terms & Conditions */} +
+ + Syarat & Ketentuan + + ( + + )} + /> +
+ + {/* Sembunyikan */} + + + )} +
+ +
+ + {/* Sticky Footer */} + +
+ + +
+
+
+ ) +} + +export default AddEditRewardDrawer diff --git a/src/views/apps/marketing/reward/RewardListTable.tsx b/src/views/apps/marketing/reward/RewardListTable.tsx new file mode 100644 index 0000000..1b741bd --- /dev/null +++ b/src/views/apps/marketing/reward/RewardListTable.tsx @@ -0,0 +1,661 @@ +'use client' + +// React Imports +import { useEffect, useState, useMemo, useCallback } from 'react' + +// Next Imports +import Link from 'next/link' +import { useParams } from 'next/navigation' + +// MUI Imports +import Card from '@mui/material/Card' +import CardHeader from '@mui/material/CardHeader' +import Button from '@mui/material/Button' +import Typography from '@mui/material/Typography' +import Chip from '@mui/material/Chip' +import Checkbox from '@mui/material/Checkbox' +import IconButton from '@mui/material/IconButton' +import { styled } from '@mui/material/styles' +import TablePagination from '@mui/material/TablePagination' +import type { TextFieldProps } from '@mui/material/TextField' +import MenuItem from '@mui/material/MenuItem' + +// Third-party Imports +import classnames from 'classnames' +import { rankItem } from '@tanstack/match-sorter-utils' +import { + createColumnHelper, + flexRender, + getCoreRowModel, + useReactTable, + getFilteredRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFacetedMinMaxValues, + getPaginationRowModel, + getSortedRowModel +} from '@tanstack/react-table' +import type { ColumnDef, FilterFn } from '@tanstack/react-table' +import type { RankingInfo } from '@tanstack/match-sorter-utils' + +// Type Imports +import type { ThemeColor } from '@core/types' +import type { Locale } from '@configs/i18n' + +// Component Imports +import OptionMenu from '@core/components/option-menu' +import TablePaginationComponent from '@components/TablePaginationComponent' +import CustomTextField from '@core/components/mui/TextField' +import CustomAvatar from '@core/components/mui/Avatar' + +// Util Imports +import { getInitials } from '@/utils/getInitials' +import { getLocalizedUrl } from '@/utils/i18n' +import { formatCurrency } from '@/utils/transform' + +// Style Imports +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 +} + +declare module '@tanstack/table-core' { + interface FilterFns { + fuzzy: FilterFn + } + interface FilterMeta { + itemRank: RankingInfo + } +} + +type RewardCatalogTypeWithAction = RewardCatalogType & { + action?: string +} + +// Styled Components +const Icon = styled('i')({}) + +const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { + // Rank the item + const itemRank = rankItem(row.getValue(columnId), value) + + // Store the itemRank info + addMeta({ + itemRank + }) + + // Return if the item should be filtered in/out + return itemRank.passed +} + +const DebouncedInput = ({ + value: initialValue, + onChange, + debounce = 500, + ...props +}: { + value: string | number + onChange: (value: string | number) => void + debounce?: number +} & Omit) => { + // States + const [value, setValue] = useState(initialValue) + + useEffect(() => { + setValue(initialValue) + }, [initialValue]) + + useEffect(() => { + const timeout = setTimeout(() => { + onChange(value) + }, debounce) + + return () => clearTimeout(timeout) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]) + + 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 + } +} + +// Column Definitions +const columnHelper = createColumnHelper() + +const RewardListTable = () => { + // States + const [addRewardOpen, setAddRewardOpen] = useState(false) + const [editRewardData, setEditRewardData] = useState(undefined) + const [rowSelection, setRowSelection] = useState({}) + const [globalFilter, setGlobalFilter] = useState('') + const [currentPage, setCurrentPage] = useState(1) + const [pageSize, setPageSize] = useState(10) + const [search, setSearch] = useState('') + + const { data, isLoading, error, isFetching } = useRewardCatalog({ + page: currentPage, + limit: pageSize, + search + }) + + const rewards = data?.rewards ?? [] + const totalCount = data?.total_count ?? 0 + + // Hooks + const { lang: locale } = useParams() + + const handlePageChange = useCallback((event: unknown, newPage: number) => { + setCurrentPage(newPage) + }, []) + + const handlePageSizeChange = useCallback((event: React.ChangeEvent) => { + const newPageSize = parseInt(event.target.value, 10) + setPageSize(newPageSize) + setCurrentPage(1) // Reset to first page + }, []) + + const handleEditReward = (reward: RewardCatalogType) => { + 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 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 handleCloseRewardDrawer = () => { + setAddRewardOpen(false) + setEditRewardData(undefined) + } + + const columns = useMemo[]>( + () => [ + { + id: 'select', + header: ({ table }) => ( + + ), + cell: ({ row }) => ( + + ) + }, + columnHelper.accessor('name', { + header: 'Nama Reward', + cell: ({ row }) => ( +
+ + {getInitials(row.original.name)} + +
+ + + {row.original.name} + + + {row.original.description && ( + + {row.original.description} + + )} +
+
+ ) + }), + columnHelper.accessor('pointCost', { + header: 'Biaya Poin', + cell: ({ row }) => ( +
+ + + {row.original.pointCost.toLocaleString('id-ID')} poin + +
+ ) + }), + columnHelper.accessor('stock', { + header: 'Stok', + cell: ({ row }) => { + const stock = row.original.stock + const stockColor = stock === 0 ? 'error' : stock && stock <= 10 ? 'warning' : 'success' + const stockText = stock === undefined ? 'Unlimited' : stock === 0 ? 'Habis' : stock.toString() + + return + } + }), + columnHelper.accessor('isActive', { + header: 'Status', + cell: ({ row }) => ( + + ) + }), + columnHelper.accessor('validUntil', { + header: 'Berlaku Hingga', + cell: ({ row }) => ( + + {row.original.validUntil + ? new Date(row.original.validUntil).toLocaleDateString('id-ID', { + year: 'numeric', + month: 'short', + day: 'numeric' + }) + : 'Tidak terbatas'} + + ) + }), + columnHelper.accessor('createdAt', { + header: 'Tanggal Dibuat', + cell: ({ row }) => ( + + {new Date(row.original.createdAt).toLocaleDateString('id-ID', { + year: 'numeric', + month: 'short', + day: 'numeric' + })} + + ) + }), + { + id: 'actions', + header: 'Aksi', + cell: ({ row }) => ( +
+ handleToggleActive(row.original.id, row.original.isActive) + } + }, + { + text: 'Edit', + icon: 'tabler-edit text-[22px]', + menuItemProps: { + className: 'flex items-center gap-2 text-textSecondary', + onClick: () => handleEditReward(row.original) + } + }, + { + text: 'Hapus', + icon: 'tabler-trash text-[22px]', + menuItemProps: { + className: 'flex items-center gap-2 text-textSecondary', + onClick: () => handleDeleteReward(row.original.id) + } + } + ]} + /> +
+ ), + enableSorting: false + } + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [locale, handleEditReward, handleDeleteReward, handleToggleActive] + ) + + const table = useReactTable({ + data: rewards as RewardCatalogType[], + columns, + filterFns: { + fuzzy: fuzzyFilter + }, + state: { + rowSelection, + globalFilter, + pagination: { + pageIndex: currentPage, + pageSize + } + }, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + pageCount: Math.ceil(totalCount / pageSize) + }) + + return ( + <> + +
+ table.setPageSize(Number(e.target.value))} + className='max-sm:is-full sm:is-[70px]' + > + 10 + 25 + 50 + +
+ setSearch(value as string)} + placeholder='Cari Reward' + className='max-sm:is-full' + /> + + +
+
+
+ {isLoading ? ( + + ) : ( + + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + ))} + + ))} + + {table.getFilteredRowModel().rows.length === 0 ? ( + + + + + + ) : ( + + {table + .getRowModel() + .rows.slice(0, table.getState().pagination.pageSize) + .map(row => { + return ( + + {row.getVisibleCells().map(cell => ( + + ))} + + ) + })} + + )} +
+ {header.isPlaceholder ? null : ( + <> +
+ {flexRender(header.column.columnDef.header, header.getContext())} + {{ + asc: , + desc: + }[header.column.getIsSorted() as 'asc' | 'desc'] ?? null} +
+ + )} +
+ Tidak ada data tersedia +
{flexRender(cell.column.columnDef.cell, cell.getContext())}
+ )} +
+ ( + + )} + count={totalCount} + rowsPerPage={pageSize} + page={currentPage} + onPageChange={handlePageChange} + onRowsPerPageChange={handlePageSizeChange} + rowsPerPageOptions={[10, 25, 50]} + disabled={isLoading} + /> +
+ + + ) +} + +export default RewardListTable diff --git a/src/views/apps/marketing/reward/index.tsx b/src/views/apps/marketing/reward/index.tsx new file mode 100644 index 0000000..c08a57c --- /dev/null +++ b/src/views/apps/marketing/reward/index.tsx @@ -0,0 +1,17 @@ +// MUI Imports +import Grid from '@mui/material/Grid2' +import RewardListTable from './RewardListTable' + +// Type Imports + +const RewardList = () => { + return ( + + + + + + ) +} + +export default RewardList -- 2.47.2 From 966081214f90bf175d3559f0a58a2c7e625d87ff Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 17 Sep 2025 01:35:01 +0700 Subject: [PATCH 03/18] Wheel Spin --- .../gamification/wheel-spin/page.tsx | 7 + .../layout/vertical/VerticalMenu.tsx | 5 + src/data/dictionaries/en.json | 4 +- src/data/dictionaries/id.json | 4 +- src/types/services/reward.ts | 2 +- src/types/services/wheelSpin.ts | 10 + .../wheel-spin/AddEditWheelSpinDrawer.tsx | 513 +++++++++++++++ .../wheel-spin/WheelSpinListTable.tsx | 599 ++++++++++++++++++ .../gamification/wheel-spin/index.tsx | 17 + 9 files changed, 1158 insertions(+), 3 deletions(-) create mode 100644 src/app/[lang]/(dashboard)/(private)/apps/marketing/gamification/wheel-spin/page.tsx create mode 100644 src/types/services/wheelSpin.ts create mode 100644 src/views/apps/marketing/gamification/wheel-spin/AddEditWheelSpinDrawer.tsx create mode 100644 src/views/apps/marketing/gamification/wheel-spin/WheelSpinListTable.tsx create mode 100644 src/views/apps/marketing/gamification/wheel-spin/index.tsx diff --git a/src/app/[lang]/(dashboard)/(private)/apps/marketing/gamification/wheel-spin/page.tsx b/src/app/[lang]/(dashboard)/(private)/apps/marketing/gamification/wheel-spin/page.tsx new file mode 100644 index 0000000..bd53c1d --- /dev/null +++ b/src/app/[lang]/(dashboard)/(private)/apps/marketing/gamification/wheel-spin/page.tsx @@ -0,0 +1,7 @@ +import WheelSpinList from '@/views/apps/marketing/gamification/wheel-spin' + +const WheelSpinPage = () => { + return +} + +export default WheelSpinPage diff --git a/src/components/layout/vertical/VerticalMenu.tsx b/src/components/layout/vertical/VerticalMenu.tsx index c817458..5a33103 100644 --- a/src/components/layout/vertical/VerticalMenu.tsx +++ b/src/components/layout/vertical/VerticalMenu.tsx @@ -156,6 +156,11 @@ const VerticalMenu = ({ dictionary, scrollMenu }: Props) => { }> {dictionary['navigation'].loyalty} {dictionary['navigation'].reward} + + + {dictionary['navigation'].wheel_spin} + + }> diff --git a/src/data/dictionaries/en.json b/src/data/dictionaries/en.json index c91b7ec..9ef9e1e 100644 --- a/src/data/dictionaries/en.json +++ b/src/data/dictionaries/en.json @@ -129,6 +129,8 @@ "fixed_assets": "Fixed Assets", "marketing": "Marketing", "loyalty": "Loyalty", - "reward": "Reward" + "reward": "Reward", + "gamification": "Gamification", + "wheel_spin": "Wheel Spin" } } diff --git a/src/data/dictionaries/id.json b/src/data/dictionaries/id.json index 7bb1d23..03fdd66 100644 --- a/src/data/dictionaries/id.json +++ b/src/data/dictionaries/id.json @@ -129,6 +129,8 @@ "fixed_assets": "Aset Tetap", "marketing": "Pemasaran", "loyalty": "Loyalti", - "reward": "Reward" + "reward": "Reward", + "gamification": "Gamifikasi", + "wheel_spin": "Wheel Spin" } } diff --git a/src/types/services/reward.ts b/src/types/services/reward.ts index d9a3238..ebf0c08 100644 --- a/src/types/services/reward.ts +++ b/src/types/services/reward.ts @@ -1,4 +1,4 @@ -export interface RewardCatalog { +export interface RewardCatalogType { id: string name: string description?: string diff --git a/src/types/services/wheelSpin.ts b/src/types/services/wheelSpin.ts new file mode 100644 index 0000000..67a0d82 --- /dev/null +++ b/src/types/services/wheelSpin.ts @@ -0,0 +1,10 @@ +export interface WheelSpinType { + id: string + name: string + weight: number + omset: number + fallback: boolean + stock?: number + createdAt: Date + updatedAt: Date +} diff --git a/src/views/apps/marketing/gamification/wheel-spin/AddEditWheelSpinDrawer.tsx b/src/views/apps/marketing/gamification/wheel-spin/AddEditWheelSpinDrawer.tsx new file mode 100644 index 0000000..758e23d --- /dev/null +++ b/src/views/apps/marketing/gamification/wheel-spin/AddEditWheelSpinDrawer.tsx @@ -0,0 +1,513 @@ +// React Imports +import { useState, useEffect } from 'react' + +// MUI Imports +import Button from '@mui/material/Button' +import Drawer from '@mui/material/Drawer' +import IconButton from '@mui/material/IconButton' +import MenuItem from '@mui/material/MenuItem' +import Typography from '@mui/material/Typography' +import Divider from '@mui/material/Divider' +import Grid from '@mui/material/Grid2' +import Box from '@mui/material/Box' +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 Alert from '@mui/material/Alert' + +// Third-party Imports +import { useForm, Controller } from 'react-hook-form' + +// Component Imports +import CustomTextField from '@core/components/mui/TextField' + +// Types +export interface WheelSpinType { + id: string + name: string + weight: number + omset: number + fallback: boolean + stock?: number + createdAt: Date + updatedAt: Date +} + +export interface WheelSpinRequest { + name: string + weight: number + omset: number + fallback: boolean + stock?: number + description?: string + isActive?: boolean +} + +type Props = { + open: boolean + handleClose: () => void + data?: WheelSpinType // Data wheel spin untuk edit (jika ada) +} + +type FormValidateType = { + name: string + weight: number + omset: number + fallback: boolean + stock?: number + description: string + isActive: boolean +} + +// Initial form data +const initialData: FormValidateType = { + name: '', + weight: 10, + omset: 0, + fallback: false, + stock: undefined, + description: '', + isActive: true +} + +// Mock mutation hooks (replace with actual hooks) +const useWheelSpinMutation = () => { + const createWheelSpin = { + mutate: (data: WheelSpinRequest, options?: { onSuccess?: () => void }) => { + console.log('Creating wheel spin:', data) + setTimeout(() => options?.onSuccess?.(), 1000) + } + } + + const updateWheelSpin = { + mutate: (data: { id: string; payload: WheelSpinRequest }, options?: { onSuccess?: () => void }) => { + console.log('Updating wheel spin:', data) + setTimeout(() => options?.onSuccess?.(), 1000) + } + } + + return { createWheelSpin, updateWheelSpin } +} + +const AddEditWheelSpinDrawer = (props: Props) => { + // Props + const { open, handleClose, data } = props + + // States + const [showMore, setShowMore] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + const [hasStock, setHasStock] = useState(false) + + const { createWheelSpin, updateWheelSpin } = useWheelSpinMutation() + + // Determine if this is edit mode + const isEditMode = Boolean(data?.id) + + // Hooks + const { + control, + reset: resetForm, + handleSubmit, + watch, + setValue, + formState: { errors } + } = useForm({ + defaultValues: initialData + }) + + const watchedFallback = watch('fallback') + const watchedStock = watch('stock') + const watchedWeight = watch('weight') + + // Effect to populate form when editing + useEffect(() => { + if (isEditMode && data) { + // Populate form with existing data + const formData: FormValidateType = { + name: data.name || '', + weight: data.weight || 10, + omset: data.omset || 0, + fallback: data.fallback || false, + stock: data.stock, + description: '', // Add description field if available in your data + isActive: true // Add isActive field if available in your data + } + + resetForm(formData) + setHasStock(data.stock !== undefined) + setShowMore(true) // Always show more for edit mode + } else { + // Reset to initial data for add mode + resetForm(initialData) + setHasStock(false) + setShowMore(false) + } + }, [data, isEditMode, resetForm]) + + // Handle stock toggle + const handleStockToggle = (checked: boolean) => { + setHasStock(checked) + if (!checked) { + setValue('stock', undefined) + } else { + setValue('stock', 0) + } + } + + const handleFormSubmit = async (formData: FormValidateType) => { + try { + setIsSubmitting(true) + + // Create WheelSpinRequest object + const wheelSpinRequest: WheelSpinRequest = { + name: formData.name, + weight: formData.weight, + omset: formData.omset, + fallback: formData.fallback, + stock: hasStock ? formData.stock : undefined, + description: formData.description || undefined, + isActive: formData.isActive + } + + if (isEditMode && data?.id) { + // Update existing wheel spin + updateWheelSpin.mutate( + { id: data.id, payload: wheelSpinRequest }, + { + onSuccess: () => { + handleReset() + handleClose() + } + } + ) + } else { + // Create new wheel spin + createWheelSpin.mutate(wheelSpinRequest, { + onSuccess: () => { + handleReset() + handleClose() + } + }) + } + } catch (error) { + console.error('Error submitting wheel spin:', error) + // Handle error (show toast, etc.) + } finally { + setIsSubmitting(false) + } + } + + const handleReset = () => { + handleClose() + resetForm(initialData) + setShowMore(false) + setHasStock(false) + } + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR', + minimumFractionDigits: 0 + }).format(value) + } + + return ( + + {/* Sticky Header */} + +
+ {isEditMode ? 'Edit Hadiah Wheel Spin' : 'Tambah Hadiah Baru'} + + + +
+
+ + {/* Scrollable Content */} + +
+
+ {/* Nama Hadiah */} +
+ + Nama Hadiah * + + ( + + )} + /> +
+ + {/* Weight */} +
+ + Bobot Kemunculan * + + ( + % + }} + onChange={e => field.onChange(Number(e.target.value))} + /> + )} + /> +
+ + {/* Minimum Omset */} +
+ + Minimum Omset * + + ( + 0 ? formatCurrency(field.value) : 'Omset minimum untuk mendapatkan hadiah ini') + } + InputProps={{ + startAdornment: Rp + }} + onChange={e => field.onChange(Number(e.target.value))} + /> + )} + /> +
+ + {/* Fallback Option */} +
+ ( + } + label='Hadiah Fallback' + /> + )} + /> + {watchedFallback && ( + + Hadiah fallback akan muncul ketika hadiah lain tidak tersedia atau habis + + )} +
+ + {/* Stock Management */} +
+ handleStockToggle(e.target.checked)} color='primary' /> + } + label='Batasi Stok' + /> + + {hasStock && ( +
+ + Jumlah Stok * + + ( + field.onChange(Number(e.target.value))} + /> + )} + /> +
+ )} +
+ + {/* Status Aktif */} +
+ ( + } + label='Hadiah Aktif' + /> + )} + /> +
+ + {/* Tampilkan selengkapnya */} + {!showMore && ( + + )} + + {/* Konten tambahan */} + {showMore && ( + <> + {/* Description */} +
+ + Deskripsi Hadiah + + ( + + )} + /> +
+ + {/* Tips */} + + + Tips: +
+ • Bobot yang lebih tinggi = peluang menang lebih besar +
+ • Hadiah fallback sebaiknya selalu ada untuk backup +
• Minimum omset membantu mendorong transaksi lebih besar +
+
+ + {/* Sembunyikan */} + + + )} +
+
+
+ + {/* Sticky Footer */} + +
+ + +
+
+
+ ) +} + +export default AddEditWheelSpinDrawer diff --git a/src/views/apps/marketing/gamification/wheel-spin/WheelSpinListTable.tsx b/src/views/apps/marketing/gamification/wheel-spin/WheelSpinListTable.tsx new file mode 100644 index 0000000..9268421 --- /dev/null +++ b/src/views/apps/marketing/gamification/wheel-spin/WheelSpinListTable.tsx @@ -0,0 +1,599 @@ +'use client' + +// React Imports +import { useEffect, useState, useMemo, useCallback } from 'react' + +// Next Imports +import Link from 'next/link' +import { useParams } from 'next/navigation' + +// MUI Imports +import Card from '@mui/material/Card' +import CardHeader from '@mui/material/CardHeader' +import Button from '@mui/material/Button' +import Typography from '@mui/material/Typography' +import Chip from '@mui/material/Chip' +import Checkbox from '@mui/material/Checkbox' +import IconButton from '@mui/material/IconButton' +import { styled } from '@mui/material/styles' +import TablePagination from '@mui/material/TablePagination' +import type { TextFieldProps } from '@mui/material/TextField' +import MenuItem from '@mui/material/MenuItem' + +// Third-party Imports +import classnames from 'classnames' +import { rankItem } from '@tanstack/match-sorter-utils' +import { + createColumnHelper, + flexRender, + getCoreRowModel, + useReactTable, + getFilteredRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFacetedMinMaxValues, + getPaginationRowModel, + getSortedRowModel +} from '@tanstack/react-table' +import type { ColumnDef, FilterFn } from '@tanstack/react-table' +import type { RankingInfo } from '@tanstack/match-sorter-utils' + +// Type Imports +import type { ThemeColor } from '@core/types' +import type { Locale } from '@configs/i18n' + +// Component Imports +import OptionMenu from '@core/components/option-menu' +import TablePaginationComponent from '@components/TablePaginationComponent' +import CustomTextField from '@core/components/mui/TextField' +import CustomAvatar from '@core/components/mui/Avatar' + +// Util Imports +import { getInitials } from '@/utils/getInitials' +import { getLocalizedUrl } from '@/utils/i18n' +import { formatCurrency } from '@/utils/transform' + +// Style Imports +import tableStyles from '@core/styles/table.module.css' +import Loading from '@/components/layout/shared/Loading' +import AddEditWheelSpinDrawer from './AddEditWheelSpinDrawer' + +// WheelSpin Type Interface +export interface WheelSpinType { + id: string + name: string + weight: number + omset: number + fallback: boolean + stock?: number + createdAt: Date + updatedAt: Date +} + +declare module '@tanstack/table-core' { + interface FilterFns { + fuzzy: FilterFn + } + interface FilterMeta { + itemRank: RankingInfo + } +} + +type WheelSpinTypeWithAction = WheelSpinType & { + action?: string +} + +// Styled Components +const Icon = styled('i')({}) + +const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { + // Rank the item + const itemRank = rankItem(row.getValue(columnId), value) + + // Store the itemRank info + addMeta({ + itemRank + }) + + // Return if the item should be filtered in/out + return itemRank.passed +} + +const DebouncedInput = ({ + value: initialValue, + onChange, + debounce = 500, + ...props +}: { + value: string | number + onChange: (value: string | number) => void + debounce?: number +} & Omit) => { + // States + const [value, setValue] = useState(initialValue) + + useEffect(() => { + setValue(initialValue) + }, [initialValue]) + + useEffect(() => { + const timeout = setTimeout(() => { + onChange(value) + }, debounce) + + return () => clearTimeout(timeout) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]) + + return setValue(e.target.value)} /> +} + +// Dummy data for wheel spin prizes +const DUMMY_WHEEL_SPIN_DATA: WheelSpinType[] = [ + { + id: '1', + name: 'Diskon 10%', + weight: 25, + omset: 500000, + fallback: false, + stock: 100, + createdAt: new Date('2024-01-15'), + updatedAt: new Date('2024-02-10') + }, + { + id: '2', + name: 'Gratis Ongkir', + weight: 30, + omset: 200000, + fallback: false, + stock: 200, + createdAt: new Date('2024-01-20'), + updatedAt: new Date('2024-02-15') + }, + { + id: '3', + name: 'Cashback 50k', + weight: 15, + omset: 1000000, + fallback: false, + stock: 50, + createdAt: new Date('2024-01-25'), + updatedAt: new Date('2024-02-20') + }, + { + id: '4', + name: 'Voucher 100k', + weight: 10, + omset: 2000000, + fallback: false, + stock: 25, + createdAt: new Date('2024-02-01'), + updatedAt: new Date('2024-02-25') + }, + { + id: '5', + name: 'Poin 500', + weight: 35, + omset: 100000, + fallback: false, + stock: 500, + createdAt: new Date('2024-02-05'), + updatedAt: new Date('2024-03-01') + }, + { + id: '6', + name: 'Produk Gratis', + weight: 5, + omset: 5000000, + fallback: false, + stock: 10, + createdAt: new Date('2024-02-10'), + updatedAt: new Date('2024-03-05') + }, + { + id: '7', + name: 'Coba Lagi', + weight: 50, + omset: 0, + fallback: true, + createdAt: new Date('2024-02-15'), + updatedAt: new Date('2024-03-10') + }, + { + id: '8', + name: 'Double Poin', + weight: 20, + omset: 750000, + fallback: false, + stock: 75, + createdAt: new Date('2024-03-01'), + updatedAt: new Date('2024-03-15') + }, + { + id: '9', + name: 'Mystery Box', + weight: 8, + omset: 1500000, + fallback: false, + stock: 30, + createdAt: new Date('2024-03-05'), + updatedAt: new Date('2024-03-20') + }, + { + id: '10', + name: 'Diskon 25%', + weight: 12, + omset: 3000000, + fallback: false, + stock: 20, + createdAt: new Date('2024-03-10'), + updatedAt: new Date('2024-03-25') + } +] + +// Mock data hook with dummy data +const useWheelSpin = ({ 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_WHEEL_SPIN_DATA + + return DUMMY_WHEEL_SPIN_DATA.filter(wheelSpin => wheelSpin.name.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: { + wheelSpins: paginatedData, + total_count: filteredData.length + }, + isLoading, + error: null, + isFetching: isLoading + } +} + +// Column Definitions +const columnHelper = createColumnHelper() + +const WheelSpinListTable = () => { + // States + const [addWheelSpinOpen, setAddWheelSpinOpen] = useState(false) + const [editWheelSpinData, setEditWheelSpinData] = useState(undefined) + const [rowSelection, setRowSelection] = useState({}) + const [globalFilter, setGlobalFilter] = useState('') + const [currentPage, setCurrentPage] = useState(1) + const [pageSize, setPageSize] = useState(10) + const [search, setSearch] = useState('') + + const { data, isLoading, error, isFetching } = useWheelSpin({ + page: currentPage, + limit: pageSize, + search + }) + + const wheelSpins = data?.wheelSpins ?? [] + const totalCount = data?.total_count ?? 0 + + // Hooks + const { lang: locale } = useParams() + + const handlePageChange = useCallback((event: unknown, newPage: number) => { + setCurrentPage(newPage) + }, []) + + const handlePageSizeChange = useCallback((event: React.ChangeEvent) => { + const newPageSize = parseInt(event.target.value, 10) + setPageSize(newPageSize) + setCurrentPage(1) // Reset to first page + }, []) + + const handleEditWheelSpin = (wheelSpin: WheelSpinType) => { + setEditWheelSpinData(wheelSpin) + setAddWheelSpinOpen(true) + } + + const handleDeleteWheelSpin = (wheelSpinId: string) => { + if (confirm('Apakah Anda yakin ingin menghapus hadiah wheel spin ini?')) { + console.log('Deleting wheel spin:', wheelSpinId) + // Add your delete logic here + // deleteWheelSpin.mutate(wheelSpinId) + } + } + + const handleCloseWheelSpinDrawer = () => { + setAddWheelSpinOpen(false) + setEditWheelSpinData(undefined) + } + + const columns = useMemo[]>( + () => [ + { + id: 'select', + header: ({ table }) => ( + + ), + cell: ({ row }) => ( + + ) + }, + columnHelper.accessor('name', { + header: 'Nama Hadiah', + cell: ({ row }) => ( +
+
+ + + {row.original.name} + + +
+
+ ) + }), + columnHelper.accessor('weight', { + header: 'Bobot', + cell: ({ row }) => ( +
+ + {row.original.weight}% +
+ ) + }), + columnHelper.accessor('omset', { + header: 'Min. Omset', + cell: ({ row }) => ( +
+ + {formatCurrency(row.original.omset)} +
+ ) + }), + columnHelper.accessor('stock', { + header: 'Stok', + cell: ({ row }) => ( +
+ {row.original.stock !== undefined ? ( + <> + + {row.original.stock} + + ) : ( + Unlimited + )} +
+ ) + }), + columnHelper.accessor('fallback', { + header: 'Status', + cell: ({ row }) => ( + + ) + }), + columnHelper.accessor('createdAt', { + header: 'Tanggal Dibuat', + cell: ({ row }) => ( + + {new Date(row.original.createdAt).toLocaleDateString('id-ID', { + year: 'numeric', + month: 'short', + day: 'numeric' + })} + + ) + }), + { + id: 'actions', + header: 'Aksi', + cell: ({ row }) => ( +
+ handleEditWheelSpin(row.original) + } + }, + { + text: 'Hapus', + icon: 'tabler-trash text-[22px]', + menuItemProps: { + className: 'flex items-center gap-2 text-textSecondary', + onClick: () => handleDeleteWheelSpin(row.original.id) + } + } + ]} + /> +
+ ), + enableSorting: false + } + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [locale, handleEditWheelSpin, handleDeleteWheelSpin] + ) + + const table = useReactTable({ + data: wheelSpins as WheelSpinType[], + columns, + filterFns: { + fuzzy: fuzzyFilter + }, + state: { + rowSelection, + globalFilter, + pagination: { + pageIndex: currentPage, + pageSize + } + }, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + pageCount: Math.ceil(totalCount / pageSize) + }) + + return ( + <> + +
+ table.setPageSize(Number(e.target.value))} + className='max-sm:is-full sm:is-[70px]' + > + 10 + 25 + 50 + +
+ setSearch(value as string)} + placeholder='Cari Hadiah Wheel Spin' + className='max-sm:is-full' + /> + + +
+
+
+ {isLoading ? ( + + ) : ( + + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + ))} + + ))} + + {table.getFilteredRowModel().rows.length === 0 ? ( + + + + + + ) : ( + + {table + .getRowModel() + .rows.slice(0, table.getState().pagination.pageSize) + .map(row => { + return ( + + {row.getVisibleCells().map(cell => ( + + ))} + + ) + })} + + )} +
+ {header.isPlaceholder ? null : ( + <> +
+ {flexRender(header.column.columnDef.header, header.getContext())} + {{ + asc: , + desc: + }[header.column.getIsSorted() as 'asc' | 'desc'] ?? null} +
+ + )} +
+ Tidak ada data tersedia +
{flexRender(cell.column.columnDef.cell, cell.getContext())}
+ )} +
+ ( + + )} + count={totalCount} + rowsPerPage={pageSize} + page={currentPage} + onPageChange={handlePageChange} + onRowsPerPageChange={handlePageSizeChange} + rowsPerPageOptions={[10, 25, 50]} + disabled={isLoading} + /> +
+ + + ) +} + +export default WheelSpinListTable diff --git a/src/views/apps/marketing/gamification/wheel-spin/index.tsx b/src/views/apps/marketing/gamification/wheel-spin/index.tsx new file mode 100644 index 0000000..fc47158 --- /dev/null +++ b/src/views/apps/marketing/gamification/wheel-spin/index.tsx @@ -0,0 +1,17 @@ +// MUI Imports +import Grid from '@mui/material/Grid2' +import WheelSpinListTable from './WheelSpinListTable' + +// Type Imports + +const WheelSpinList = () => { + return ( + + + + + + ) +} + +export default WheelSpinList -- 2.47.2 From 3d1b6eee39280af4965c3ef1ca81b2d4466f4e43 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 17 Sep 2025 01:45:05 +0700 Subject: [PATCH 04/18] Campaign --- .../apps/marketing/campaign/page.tsx | 7 + .../layout/vertical/VerticalMenu.tsx | 1 + src/data/dictionaries/en.json | 3 +- src/data/dictionaries/id.json | 3 +- src/types/services/campaign.ts | 13 + .../campaign/AddEditCampaignDrawer.tsx | 576 +++++++++++++++ .../marketing/campaign/CampaignListTable.tsx | 668 ++++++++++++++++++ src/views/apps/marketing/campaign/index.tsx | 17 + 8 files changed, 1286 insertions(+), 2 deletions(-) create mode 100644 src/app/[lang]/(dashboard)/(private)/apps/marketing/campaign/page.tsx create mode 100644 src/types/services/campaign.ts create mode 100644 src/views/apps/marketing/campaign/AddEditCampaignDrawer.tsx create mode 100644 src/views/apps/marketing/campaign/CampaignListTable.tsx create mode 100644 src/views/apps/marketing/campaign/index.tsx diff --git a/src/app/[lang]/(dashboard)/(private)/apps/marketing/campaign/page.tsx b/src/app/[lang]/(dashboard)/(private)/apps/marketing/campaign/page.tsx new file mode 100644 index 0000000..9e66987 --- /dev/null +++ b/src/app/[lang]/(dashboard)/(private)/apps/marketing/campaign/page.tsx @@ -0,0 +1,7 @@ +import CampaignListTable from '@/views/apps/marketing/campaign/CampaignListTable' + +const CampaignPage = () => { + return +} + +export default CampaignPage diff --git a/src/components/layout/vertical/VerticalMenu.tsx b/src/components/layout/vertical/VerticalMenu.tsx index 5a33103..fbc2db4 100644 --- a/src/components/layout/vertical/VerticalMenu.tsx +++ b/src/components/layout/vertical/VerticalMenu.tsx @@ -161,6 +161,7 @@ const VerticalMenu = ({ dictionary, scrollMenu }: Props) => { {dictionary['navigation'].wheel_spin}
+ {dictionary['navigation'].campaign}
}> diff --git a/src/data/dictionaries/en.json b/src/data/dictionaries/en.json index 9ef9e1e..51b36a7 100644 --- a/src/data/dictionaries/en.json +++ b/src/data/dictionaries/en.json @@ -131,6 +131,7 @@ "loyalty": "Loyalty", "reward": "Reward", "gamification": "Gamification", - "wheel_spin": "Wheel Spin" + "wheel_spin": "Wheel Spin", + "campaign": "Campaign" } } diff --git a/src/data/dictionaries/id.json b/src/data/dictionaries/id.json index 03fdd66..0b32e4d 100644 --- a/src/data/dictionaries/id.json +++ b/src/data/dictionaries/id.json @@ -131,6 +131,7 @@ "loyalty": "Loyalti", "reward": "Reward", "gamification": "Gamifikasi", - "wheel_spin": "Wheel Spin" + "wheel_spin": "Wheel Spin", + "campaign": "Kampanye" } } diff --git a/src/types/services/campaign.ts b/src/types/services/campaign.ts new file mode 100644 index 0000000..440cc49 --- /dev/null +++ b/src/types/services/campaign.ts @@ -0,0 +1,13 @@ +export interface Campaign { + id: string + name: string + description?: string + minimumPurchase: number + rewardType: 'point' | 'voucher' | 'discount' + rewardValue: number + startDate: Date + endDate: Date + isActive: boolean + createdAt: Date + updatedAt: Date +} diff --git a/src/views/apps/marketing/campaign/AddEditCampaignDrawer.tsx b/src/views/apps/marketing/campaign/AddEditCampaignDrawer.tsx new file mode 100644 index 0000000..207bffd --- /dev/null +++ b/src/views/apps/marketing/campaign/AddEditCampaignDrawer.tsx @@ -0,0 +1,576 @@ +// React Imports +import { useState, useEffect } from 'react' + +// MUI Imports +import Button from '@mui/material/Button' +import Drawer from '@mui/material/Drawer' +import IconButton from '@mui/material/IconButton' +import MenuItem from '@mui/material/MenuItem' +import Typography from '@mui/material/Typography' +import Divider from '@mui/material/Divider' +import Grid from '@mui/material/Grid2' +import Box from '@mui/material/Box' +import Switch from '@mui/material/Switch' +import FormControlLabel from '@mui/material/FormControlLabel' +import Chip from '@mui/material/Chip' +import InputAdornment from '@mui/material/InputAdornment' + +// Third-party Imports +import { useForm, Controller, useFieldArray } from 'react-hook-form' + +// Component Imports +import CustomTextField from '@core/components/mui/TextField' + +// Types +export interface Campaign { + id: string + name: string + description?: string + minimumPurchase: number + rewardType: 'point' | 'voucher' | 'discount' + rewardValue: number + startDate: Date + endDate: Date + isActive: boolean + createdAt: Date + updatedAt: Date +} + +export interface CampaignRequest { + name: string + description?: string + minimumPurchase: number + rewardType: 'point' | 'voucher' | 'discount' + rewardValue: number + startDate: Date + endDate: Date + isActive: boolean +} + +type Props = { + open: boolean + handleClose: () => void + data?: Campaign // Data campaign untuk edit (jika ada) +} + +type FormValidateType = { + name: string + description: string + minimumPurchase: number + rewardType: 'point' | 'voucher' | 'discount' + rewardValue: number + startDate: string + endDate: string + isActive: boolean +} + +// Initial form data +const initialData: FormValidateType = { + name: '', + description: '', + minimumPurchase: 0, + rewardType: 'point', + rewardValue: 0, + startDate: '', + endDate: '', + isActive: true +} + +// Mock mutation hooks (replace with actual hooks) +const useCampaignMutation = () => { + const createCampaign = { + mutate: (data: CampaignRequest, options?: { onSuccess?: () => void }) => { + console.log('Creating campaign:', data) + setTimeout(() => options?.onSuccess?.(), 1000) + } + } + + const updateCampaign = { + mutate: (data: { id: string; payload: CampaignRequest }, options?: { onSuccess?: () => void }) => { + console.log('Updating campaign:', data) + setTimeout(() => options?.onSuccess?.(), 1000) + } + } + + return { createCampaign, updateCampaign } +} + +const AddEditCampaignDrawer = (props: Props) => { + // Props + const { open, handleClose, data } = props + + // States + const [showMore, setShowMore] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + + const { createCampaign, updateCampaign } = useCampaignMutation() + + // Determine if this is edit mode + const isEditMode = Boolean(data?.id) + + // Hooks + const { + control, + reset: resetForm, + handleSubmit, + watch, + setValue, + formState: { errors } + } = useForm({ + defaultValues: initialData + }) + + const watchedRewardType = watch('rewardType') + const watchedStartDate = watch('startDate') + const watchedEndDate = watch('endDate') + + // Effect to populate form when editing + useEffect(() => { + if (isEditMode && data) { + // Populate form with existing data + const formData: FormValidateType = { + name: data.name || '', + description: data.description || '', + minimumPurchase: data.minimumPurchase || 0, + rewardType: data.rewardType || 'point', + rewardValue: data.rewardValue || 0, + startDate: data.startDate ? new Date(data.startDate).toISOString().split('T')[0] : '', + endDate: data.endDate ? new Date(data.endDate).toISOString().split('T')[0] : '', + isActive: data.isActive ?? true + } + + resetForm(formData) + setShowMore(true) // Always show more for edit mode + } else { + // Reset to initial data for add mode + resetForm(initialData) + setShowMore(false) + } + }, [data, isEditMode, resetForm]) + + const handleFormSubmit = async (formData: FormValidateType) => { + try { + setIsSubmitting(true) + + // Create CampaignRequest object + const campaignRequest: CampaignRequest = { + name: formData.name, + description: formData.description || undefined, + minimumPurchase: formData.minimumPurchase, + rewardType: formData.rewardType, + rewardValue: formData.rewardValue, + startDate: new Date(formData.startDate), + endDate: new Date(formData.endDate), + isActive: formData.isActive + } + + if (isEditMode && data?.id) { + // Update existing campaign + updateCampaign.mutate( + { id: data.id, payload: campaignRequest }, + { + onSuccess: () => { + handleReset() + handleClose() + } + } + ) + } else { + // Create new campaign + createCampaign.mutate(campaignRequest, { + onSuccess: () => { + handleReset() + handleClose() + } + }) + } + } catch (error) { + console.error('Error submitting campaign:', error) + // Handle error (show toast, etc.) + } finally { + setIsSubmitting(false) + } + } + + const handleReset = () => { + handleClose() + resetForm(initialData) + setShowMore(false) + } + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR', + minimumFractionDigits: 0 + }).format(value) + } + + const getRewardTypeLabel = (type: 'point' | 'voucher' | 'discount') => { + switch (type) { + case 'point': + return 'Poin' + case 'voucher': + return 'Voucher' + case 'discount': + return 'Diskon' + default: + return type + } + } + + const getRewardValuePlaceholder = (type: 'point' | 'voucher' | 'discount') => { + switch (type) { + case 'point': + return 'Jumlah poin yang diberikan' + case 'voucher': + return 'Nilai voucher dalam Rupiah' + case 'discount': + return 'Persentase diskon (1-100)' + default: + return 'Nilai reward' + } + } + + const getRewardValueRules = (type: 'point' | 'voucher' | 'discount') => { + const baseRules = { + required: 'Nilai reward wajib diisi', + min: { + value: 1, + message: 'Nilai reward minimal 1' + } + } + + if (type === 'discount') { + return { + ...baseRules, + max: { + value: 100, + message: 'Persentase diskon maksimal 100%' + } + } + } + + return baseRules + } + + return ( + + {/* Sticky Header */} + +
+ {isEditMode ? 'Edit Kampanye' : 'Tambah Kampanye Baru'} + + + +
+
+ + {/* Scrollable Content */} + +
+
+ {/* Nama Kampanye */} +
+ + Nama Kampanye * + + ( + + )} + /> +
+ + {/* Minimum Purchase */} +
+ + Minimum Pembelian * + + ( + 0 ? formatCurrency(field.value) : '')} + InputProps={{ + startAdornment: Rp + }} + onChange={e => field.onChange(Number(e.target.value))} + /> + )} + /> +
+ + {/* Jenis Reward */} +
+ + Jenis Reward * + + ( + + +
+ + Poin +
+
+ +
+ + Voucher +
+
+ +
+ + Diskon +
+
+
+ )} + /> +
+ + {/* Nilai Reward */} +
+ + Nilai {getRewardTypeLabel(watchedRewardType)} * + + ( + Rp + ) : undefined, + endAdornment: + watchedRewardType === 'discount' ? ( + % + ) : watchedRewardType === 'point' ? ( + Poin + ) : undefined + }} + onChange={e => field.onChange(Number(e.target.value))} + /> + )} + /> +
+ + {/* Tanggal Mulai */} +
+ + Tanggal Mulai * + + ( + + )} + /> +
+ + {/* Tanggal Berakhir */} +
+ + Tanggal Berakhir * + + { + if (watchedStartDate && value) { + return ( + new Date(value) >= new Date(watchedStartDate) || 'Tanggal berakhir harus setelah tanggal mulai' + ) + } + return true + } + }} + render={({ field }) => ( + + )} + /> +
+ + {/* Status Aktif */} +
+ ( + } + label='Kampanye Aktif' + /> + )} + /> +
+ + {/* Tampilkan selengkapnya */} + {!showMore && ( + + )} + + {/* Konten tambahan */} + {showMore && ( + <> + {/* Description */} +
+ + Deskripsi Kampanye + + ( + + )} + /> +
+ + {/* Sembunyikan */} + + + )} +
+ +
+ + {/* Sticky Footer */} + +
+ + +
+
+
+ ) +} + +export default AddEditCampaignDrawer diff --git a/src/views/apps/marketing/campaign/CampaignListTable.tsx b/src/views/apps/marketing/campaign/CampaignListTable.tsx new file mode 100644 index 0000000..f48ef23 --- /dev/null +++ b/src/views/apps/marketing/campaign/CampaignListTable.tsx @@ -0,0 +1,668 @@ +'use client' + +// React Imports +import { useEffect, useState, useMemo, useCallback } from 'react' + +// Next Imports +import Link from 'next/link' +import { useParams } from 'next/navigation' + +// MUI Imports +import Card from '@mui/material/Card' +import CardHeader from '@mui/material/CardHeader' +import Button from '@mui/material/Button' +import Typography from '@mui/material/Typography' +import Chip from '@mui/material/Chip' +import Checkbox from '@mui/material/Checkbox' +import IconButton from '@mui/material/IconButton' +import { styled } from '@mui/material/styles' +import TablePagination from '@mui/material/TablePagination' +import type { TextFieldProps } from '@mui/material/TextField' +import MenuItem from '@mui/material/MenuItem' + +// Third-party Imports +import classnames from 'classnames' +import { rankItem } from '@tanstack/match-sorter-utils' +import { + createColumnHelper, + flexRender, + getCoreRowModel, + useReactTable, + getFilteredRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFacetedMinMaxValues, + getPaginationRowModel, + getSortedRowModel +} from '@tanstack/react-table' +import type { ColumnDef, FilterFn } from '@tanstack/react-table' +import type { RankingInfo } from '@tanstack/match-sorter-utils' + +// Type Imports +import type { ThemeColor } from '@core/types' +import type { Locale } from '@configs/i18n' + +// Component Imports +import OptionMenu from '@core/components/option-menu' +import TablePaginationComponent from '@components/TablePaginationComponent' +import CustomTextField from '@core/components/mui/TextField' +import CustomAvatar from '@core/components/mui/Avatar' + +// Util Imports +import { getInitials } from '@/utils/getInitials' +import { getLocalizedUrl } from '@/utils/i18n' +import { formatCurrency } from '@/utils/transform' + +// Style Imports +import tableStyles from '@core/styles/table.module.css' +import Loading from '@/components/layout/shared/Loading' +import AddEditCampaignDrawer from './AddEditCampaignDrawer' + +// Campaign Type Interface +export interface Campaign { + id: string + name: string + description?: string + minimumPurchase: number + rewardType: 'point' | 'voucher' | 'discount' + rewardValue: number + startDate: Date + endDate: Date + isActive: boolean + createdAt: Date + updatedAt: Date +} + +declare module '@tanstack/table-core' { + interface FilterFns { + fuzzy: FilterFn + } + interface FilterMeta { + itemRank: RankingInfo + } +} + +type CampaignWithAction = Campaign & { + action?: string +} + +// Styled Components +const Icon = styled('i')({}) + +const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { + // Rank the item + const itemRank = rankItem(row.getValue(columnId), value) + + // Store the itemRank info + addMeta({ + itemRank + }) + + // Return if the item should be filtered in/out + return itemRank.passed +} + +const DebouncedInput = ({ + value: initialValue, + onChange, + debounce = 500, + ...props +}: { + value: string | number + onChange: (value: string | number) => void + debounce?: number +} & Omit) => { + // States + const [value, setValue] = useState(initialValue) + + useEffect(() => { + setValue(initialValue) + }, [initialValue]) + + useEffect(() => { + const timeout = setTimeout(() => { + onChange(value) + }, debounce) + + return () => clearTimeout(timeout) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]) + + return setValue(e.target.value)} /> +} + +// Dummy data for campaigns +const DUMMY_CAMPAIGN_DATA: Campaign[] = [ + { + id: '1', + name: 'Summer Sale Campaign', + description: 'Get extra points during summer season', + minimumPurchase: 500000, + rewardType: 'point', + rewardValue: 100, + startDate: new Date('2024-06-01'), + endDate: new Date('2024-08-31'), + isActive: true, + createdAt: new Date('2024-05-15'), + updatedAt: new Date('2024-06-01') + }, + { + id: '2', + name: 'Welcome Bonus', + description: 'Special discount for new customers', + minimumPurchase: 200000, + rewardType: 'discount', + rewardValue: 15, + startDate: new Date('2024-01-01'), + endDate: new Date('2024-12-31'), + isActive: true, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-03-15') + }, + { + id: '3', + name: 'Flash Sale Weekend', + description: 'Weekend special voucher campaign', + minimumPurchase: 1000000, + rewardType: 'voucher', + rewardValue: 50000, + startDate: new Date('2024-07-06'), + endDate: new Date('2024-07-07'), + isActive: false, + createdAt: new Date('2024-07-01'), + updatedAt: new Date('2024-07-08') + }, + { + id: '4', + name: 'Loyalty Rewards', + description: 'Extra points for loyal customers', + minimumPurchase: 2000000, + rewardType: 'point', + rewardValue: 300, + startDate: new Date('2024-03-01'), + endDate: new Date('2024-09-30'), + isActive: true, + createdAt: new Date('2024-02-25'), + updatedAt: new Date('2024-05-10') + }, + { + id: '5', + name: 'Black Friday Special', + description: 'Biggest discount of the year', + minimumPurchase: 800000, + rewardType: 'discount', + rewardValue: 25, + startDate: new Date('2024-11-29'), + endDate: new Date('2024-11-29'), + isActive: false, + createdAt: new Date('2024-11-01'), + updatedAt: new Date('2024-11-30') + }, + { + id: '6', + name: 'Student Promo', + description: 'Special voucher for students', + minimumPurchase: 300000, + rewardType: 'voucher', + rewardValue: 25000, + startDate: new Date('2024-09-01'), + endDate: new Date('2024-12-20'), + isActive: true, + createdAt: new Date('2024-08-25'), + updatedAt: new Date('2024-09-01') + }, + { + id: '7', + name: 'Holiday Celebration', + description: 'Special points during holidays', + minimumPurchase: 1500000, + rewardType: 'point', + rewardValue: 200, + startDate: new Date('2024-12-15'), + endDate: new Date('2025-01-15'), + isActive: true, + createdAt: new Date('2024-12-01'), + updatedAt: new Date('2024-12-15') + }, + { + id: '8', + name: 'Mid Year Sale', + description: 'Mid year discount campaign', + minimumPurchase: 600000, + rewardType: 'discount', + rewardValue: 20, + startDate: new Date('2024-06-15'), + endDate: new Date('2024-07-15'), + isActive: false, + createdAt: new Date('2024-06-01'), + updatedAt: new Date('2024-07-16') + } +] + +// Mock data hook with dummy data +const useCampaigns = ({ 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_CAMPAIGN_DATA + + return DUMMY_CAMPAIGN_DATA.filter( + campaign => + campaign.name.toLowerCase().includes(search.toLowerCase()) || + campaign.description?.toLowerCase().includes(search.toLowerCase()) || + campaign.rewardType.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: { + campaigns: paginatedData, + total_count: filteredData.length + }, + isLoading, + error: null, + isFetching: isLoading + } +} + +// Utility functions +const getRewardTypeColor = (rewardType: Campaign['rewardType']): ThemeColor => { + switch (rewardType) { + case 'point': + return 'primary' + case 'voucher': + return 'success' + case 'discount': + return 'warning' + default: + return 'info' + } +} + +const getRewardTypeIcon = (rewardType: Campaign['rewardType']): string => { + switch (rewardType) { + case 'point': + return 'tabler-coins' + case 'voucher': + return 'tabler-ticket' + case 'discount': + return 'tabler-percentage' + default: + return 'tabler-gift' + } +} + +const formatRewardValue = (rewardType: Campaign['rewardType'], rewardValue: number): string => { + switch (rewardType) { + case 'point': + return `${rewardValue} Poin` + case 'voucher': + return formatCurrency(rewardValue) + case 'discount': + return `${rewardValue}%` + default: + return rewardValue.toString() + } +} + +// Column Definitions +const columnHelper = createColumnHelper() + +const CampaignListTable = () => { + // States + const [addCampaignOpen, setAddCampaignOpen] = useState(false) + const [editCampaignData, setEditCampaignData] = useState(undefined) + const [rowSelection, setRowSelection] = useState({}) + const [globalFilter, setGlobalFilter] = useState('') + const [currentPage, setCurrentPage] = useState(1) + const [pageSize, setPageSize] = useState(10) + const [search, setSearch] = useState('') + + const { data, isLoading, error, isFetching } = useCampaigns({ + page: currentPage, + limit: pageSize, + search + }) + + const campaigns = data?.campaigns ?? [] + const totalCount = data?.total_count ?? 0 + + // Hooks + const { lang: locale } = useParams() + + const handlePageChange = useCallback((event: unknown, newPage: number) => { + setCurrentPage(newPage) + }, []) + + const handlePageSizeChange = useCallback((event: React.ChangeEvent) => { + const newPageSize = parseInt(event.target.value, 10) + setPageSize(newPageSize) + setCurrentPage(1) // Reset to first page + }, []) + + const handleEditCampaign = (campaign: Campaign) => { + setEditCampaignData(campaign) + setAddCampaignOpen(true) + } + + const handleDeleteCampaign = (campaignId: string) => { + if (confirm('Apakah Anda yakin ingin menghapus kampanye ini?')) { + console.log('Deleting campaign:', campaignId) + // Add your delete logic here + // deleteCampaign.mutate(campaignId) + } + } + + const handleCloseCampaignDrawer = () => { + setAddCampaignOpen(false) + setEditCampaignData(undefined) + } + + const columns = useMemo[]>( + () => [ + { + id: 'select', + header: ({ table }) => ( + + ), + cell: ({ row }) => ( + + ) + }, + columnHelper.accessor('name', { + header: 'Nama Kampanye', + cell: ({ row }) => ( +
+
+ + + {row.original.name} + + + {row.original.description && ( + + {row.original.description} + + )} +
+
+ ) + }), + columnHelper.accessor('minimumPurchase', { + header: 'Minimum Pembelian', + cell: ({ row }) => ( +
+ + {formatCurrency(row.original.minimumPurchase)} +
+ ) + }), + columnHelper.accessor('rewardType', { + header: 'Jenis Reward', + cell: ({ row }) => ( +
+ + +
+ ) + }), + columnHelper.accessor('startDate', { + header: 'Periode Kampanye', + cell: ({ row }) => ( +
+ + {new Date(row.original.startDate).toLocaleDateString('id-ID', { + year: 'numeric', + month: 'short', + day: 'numeric' + })} + + + s/d{' '} + {new Date(row.original.endDate).toLocaleDateString('id-ID', { + year: 'numeric', + month: 'short', + day: 'numeric' + })} + +
+ ) + }), + columnHelper.accessor('isActive', { + header: 'Status', + cell: ({ row }) => ( + + ) + }), + columnHelper.accessor('createdAt', { + header: 'Tanggal Dibuat', + cell: ({ row }) => ( + + {new Date(row.original.createdAt).toLocaleDateString('id-ID', { + year: 'numeric', + month: 'short', + day: 'numeric' + })} + + ) + }), + { + id: 'actions', + header: 'Aksi', + cell: ({ row }) => ( +
+ handleEditCampaign(row.original) + } + }, + { + text: 'Hapus', + icon: 'tabler-trash text-[22px]', + menuItemProps: { + className: 'flex items-center gap-2 text-textSecondary', + onClick: () => handleDeleteCampaign(row.original.id) + } + } + ]} + /> +
+ ), + enableSorting: false + } + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [locale, handleEditCampaign, handleDeleteCampaign] + ) + + const table = useReactTable({ + data: campaigns as Campaign[], + columns, + filterFns: { + fuzzy: fuzzyFilter + }, + state: { + rowSelection, + globalFilter, + pagination: { + pageIndex: currentPage, + pageSize + } + }, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + pageCount: Math.ceil(totalCount / pageSize) + }) + + return ( + <> + +
+ table.setPageSize(Number(e.target.value))} + className='max-sm:is-full sm:is-[70px]' + > + 10 + 25 + 50 + +
+ setSearch(value as string)} + placeholder='Cari Kampanye' + className='max-sm:is-full' + /> + + +
+
+
+ {isLoading ? ( + + ) : ( + + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + ))} + + ))} + + {table.getFilteredRowModel().rows.length === 0 ? ( + + + + + + ) : ( + + {table + .getRowModel() + .rows.slice(0, table.getState().pagination.pageSize) + .map(row => { + return ( + + {row.getVisibleCells().map(cell => ( + + ))} + + ) + })} + + )} +
+ {header.isPlaceholder ? null : ( + <> +
+ {flexRender(header.column.columnDef.header, header.getContext())} + {{ + asc: , + desc: + }[header.column.getIsSorted() as 'asc' | 'desc'] ?? null} +
+ + )} +
+ Tidak ada data tersedia +
{flexRender(cell.column.columnDef.cell, cell.getContext())}
+ )} +
+ ( + + )} + count={totalCount} + rowsPerPage={pageSize} + page={currentPage} + onPageChange={handlePageChange} + onRowsPerPageChange={handlePageSizeChange} + rowsPerPageOptions={[10, 25, 50]} + disabled={isLoading} + /> +
+ + + ) +} + +export default CampaignListTable diff --git a/src/views/apps/marketing/campaign/index.tsx b/src/views/apps/marketing/campaign/index.tsx new file mode 100644 index 0000000..852c444 --- /dev/null +++ b/src/views/apps/marketing/campaign/index.tsx @@ -0,0 +1,17 @@ +// MUI Imports +import Grid from '@mui/material/Grid2' +import LoyaltyListTable from './CampaignListTable' + +// Type Imports + +const LoyaltyList = () => { + return ( + + + + + + ) +} + +export default LoyaltyList -- 2.47.2 From 18ee652731390ae006251f82401598d1d366dd99 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 17 Sep 2025 01:51:59 +0700 Subject: [PATCH 05/18] Customer Royalti --- .../marketing/customer-analytics/page.tsx | 7 + .../layout/vertical/VerticalMenu.tsx | 3 + src/data/dictionaries/en.json | 3 +- src/data/dictionaries/id.json | 3 +- src/types/services/customer.ts | 53 +- .../CustomerAnalyticListTable.tsx | 593 ++++++++++++++++++ .../marketing/customer-analytics/index.tsx | 17 + 7 files changed, 656 insertions(+), 23 deletions(-) create mode 100644 src/app/[lang]/(dashboard)/(private)/apps/marketing/customer-analytics/page.tsx create mode 100644 src/views/apps/marketing/customer-analytics/CustomerAnalyticListTable.tsx create mode 100644 src/views/apps/marketing/customer-analytics/index.tsx diff --git a/src/app/[lang]/(dashboard)/(private)/apps/marketing/customer-analytics/page.tsx b/src/app/[lang]/(dashboard)/(private)/apps/marketing/customer-analytics/page.tsx new file mode 100644 index 0000000..fa1576d --- /dev/null +++ b/src/app/[lang]/(dashboard)/(private)/apps/marketing/customer-analytics/page.tsx @@ -0,0 +1,7 @@ +import CustomerAnalyticList from '@/views/apps/marketing/customer-analytics' + +const CustomerAnalyticsPage = () => { + return +} + +export default CustomerAnalyticsPage diff --git a/src/components/layout/vertical/VerticalMenu.tsx b/src/components/layout/vertical/VerticalMenu.tsx index fbc2db4..5c4ce34 100644 --- a/src/components/layout/vertical/VerticalMenu.tsx +++ b/src/components/layout/vertical/VerticalMenu.tsx @@ -162,6 +162,9 @@ const VerticalMenu = ({ dictionary, scrollMenu }: Props) => {
{dictionary['navigation'].campaign} + + {dictionary['navigation'].customer_analytics} +
}> diff --git a/src/data/dictionaries/en.json b/src/data/dictionaries/en.json index 51b36a7..ecaea77 100644 --- a/src/data/dictionaries/en.json +++ b/src/data/dictionaries/en.json @@ -132,6 +132,7 @@ "reward": "Reward", "gamification": "Gamification", "wheel_spin": "Wheel Spin", - "campaign": "Campaign" + "campaign": "Campaign", + "customer_analytics": "Customer Analytics" } } diff --git a/src/data/dictionaries/id.json b/src/data/dictionaries/id.json index 0b32e4d..9b0a4a0 100644 --- a/src/data/dictionaries/id.json +++ b/src/data/dictionaries/id.json @@ -132,6 +132,7 @@ "reward": "Reward", "gamification": "Gamifikasi", "wheel_spin": "Wheel Spin", - "campaign": "Kampanye" + "campaign": "Kampanye", + "customer_analytics": "Analisis Pelanggan" } } diff --git a/src/types/services/customer.ts b/src/types/services/customer.ts index 327db98..9817c54 100644 --- a/src/types/services/customer.ts +++ b/src/types/services/customer.ts @@ -1,29 +1,40 @@ export interface Customer { - id: string; - organization_id: string; - name: string; - email?: string; - phone?: string; - address?: string; - is_default: boolean; - is_active: boolean; - metadata: Record; - created_at: string; - updated_at: string; + id: string + organization_id: string + name: string + email?: string + phone?: string + address?: string + is_default: boolean + is_active: boolean + metadata: Record + created_at: string + updated_at: string } export interface Customers { - data: Customer[]; - total_count: number; - page: number; - limit: number; - total_pages: number; + data: Customer[] + total_count: number + page: number + limit: number + total_pages: number } export interface CustomerRequest { - name: string; - email?: string; - phone?: string; - address?: string; - is_active?: boolean; + name: string + email?: string + phone?: string + address?: string + is_active?: boolean +} + +export interface CustomerAnalytics { + id: string + name: string + email?: string + phone?: string + totalPoints: number + totalSpent?: number + loyaltyTier?: string // Bronze, Silver, Gold, dst + lastTransactionDate?: Date } diff --git a/src/views/apps/marketing/customer-analytics/CustomerAnalyticListTable.tsx b/src/views/apps/marketing/customer-analytics/CustomerAnalyticListTable.tsx new file mode 100644 index 0000000..f2ad05e --- /dev/null +++ b/src/views/apps/marketing/customer-analytics/CustomerAnalyticListTable.tsx @@ -0,0 +1,593 @@ +'use client' + +// React Imports +import { useEffect, useState, useMemo, useCallback } from 'react' + +// Next Imports +import Link from 'next/link' +import { useParams } from 'next/navigation' + +// MUI Imports +import Card from '@mui/material/Card' +import CardHeader from '@mui/material/CardHeader' +import Button from '@mui/material/Button' +import Typography from '@mui/material/Typography' +import Chip from '@mui/material/Chip' +import Checkbox from '@mui/material/Checkbox' +import IconButton from '@mui/material/IconButton' +import { styled } from '@mui/material/styles' +import TablePagination from '@mui/material/TablePagination' +import type { TextFieldProps } from '@mui/material/TextField' +import MenuItem from '@mui/material/MenuItem' + +// Third-party Imports +import classnames from 'classnames' +import { rankItem } from '@tanstack/match-sorter-utils' +import { + createColumnHelper, + flexRender, + getCoreRowModel, + useReactTable, + getFilteredRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFacetedMinMaxValues, + getPaginationRowModel, + getSortedRowModel +} from '@tanstack/react-table' +import type { ColumnDef, FilterFn } from '@tanstack/react-table' +import type { RankingInfo } from '@tanstack/match-sorter-utils' + +// Type Imports +import type { ThemeColor } from '@core/types' +import type { Locale } from '@configs/i18n' + +// Component Imports +import OptionMenu from '@core/components/option-menu' +import TablePaginationComponent from '@components/TablePaginationComponent' +import CustomTextField from '@core/components/mui/TextField' +import CustomAvatar from '@core/components/mui/Avatar' + +// Util Imports +import { getInitials } from '@/utils/getInitials' +import { getLocalizedUrl } from '@/utils/i18n' +import { formatCurrency } from '@/utils/transform' + +// Style Imports +import tableStyles from '@core/styles/table.module.css' +import Loading from '@/components/layout/shared/Loading' + +// Customer Analytics Interface +export interface CustomerAnalytics { + id: string + name: string + email?: string + phone?: string + totalPoints: number + totalSpent?: number + loyaltyTier?: string // Bronze, Silver, Gold, dst + lastTransactionDate?: Date +} + +declare module '@tanstack/table-core' { + interface FilterFns { + fuzzy: FilterFn + } + interface FilterMeta { + itemRank: RankingInfo + } +} + +type CustomerAnalyticsWithAction = CustomerAnalytics & { + action?: string +} + +// Styled Components +const Icon = styled('i')({}) + +const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { + // Rank the item + const itemRank = rankItem(row.getValue(columnId), value) + + // Store the itemRank info + addMeta({ + itemRank + }) + + // Return if the item should be filtered in/out + return itemRank.passed +} + +const DebouncedInput = ({ + value: initialValue, + onChange, + debounce = 500, + ...props +}: { + value: string | number + onChange: (value: string | number) => void + debounce?: number +} & Omit) => { + // States + const [value, setValue] = useState(initialValue) + + useEffect(() => { + setValue(initialValue) + }, [initialValue]) + + useEffect(() => { + const timeout = setTimeout(() => { + onChange(value) + }, debounce) + + return () => clearTimeout(timeout) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]) + + return setValue(e.target.value)} /> +} + +// Dummy data for customer analytics +const DUMMY_CUSTOMER_ANALYTICS_DATA: CustomerAnalytics[] = [ + { + id: '1', + name: 'Ahmad Wijaya', + email: 'ahmad.wijaya@email.com', + phone: '+628123456789', + totalPoints: 1250, + totalSpent: 5500000, + loyaltyTier: 'Gold', + lastTransactionDate: new Date('2024-09-10') + }, + { + id: '2', + name: 'Siti Nurhaliza', + email: 'siti.nurhaliza@email.com', + phone: '+628234567890', + totalPoints: 850, + totalSpent: 3200000, + loyaltyTier: 'Silver', + lastTransactionDate: new Date('2024-09-15') + }, + { + id: '3', + name: 'Budi Santoso', + email: 'budi.santoso@email.com', + phone: '+628345678901', + totalPoints: 2100, + totalSpent: 8750000, + loyaltyTier: 'Gold', + lastTransactionDate: new Date('2024-09-12') + }, + { + id: '4', + name: 'Maya Puspita', + email: 'maya.puspita@email.com', + phone: '+628456789012', + totalPoints: 450, + totalSpent: 1800000, + loyaltyTier: 'Bronze', + lastTransactionDate: new Date('2024-09-08') + }, + { + id: '5', + name: 'Rizki Pratama', + email: 'rizki.pratama@email.com', + phone: '+628567890123', + totalPoints: 1680, + totalSpent: 6300000, + loyaltyTier: 'Gold', + lastTransactionDate: new Date('2024-09-16') + }, + { + id: '6', + name: 'Dewi Lestari', + email: 'dewi.lestari@email.com', + phone: '+628678901234', + totalPoints: 320, + totalSpent: 1200000, + loyaltyTier: 'Bronze', + lastTransactionDate: new Date('2024-09-05') + }, + { + id: '7', + name: 'Eko Prasetyo', + email: 'eko.prasetyo@email.com', + phone: '+628789012345', + totalPoints: 975, + totalSpent: 4100000, + loyaltyTier: 'Silver', + lastTransactionDate: new Date('2024-09-14') + }, + { + id: '8', + name: 'Lina Marlina', + email: 'lina.marlina@email.com', + phone: '+628890123456', + totalPoints: 1580, + totalSpent: 6800000, + loyaltyTier: 'Gold', + lastTransactionDate: new Date('2024-09-11') + } +] + +// Mock data hook with dummy data +const useCustomerAnalytics = ({ 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_CUSTOMER_ANALYTICS_DATA + + return DUMMY_CUSTOMER_ANALYTICS_DATA.filter( + customer => + customer.name.toLowerCase().includes(search.toLowerCase()) || + customer.email?.toLowerCase().includes(search.toLowerCase()) || + customer.phone?.toLowerCase().includes(search.toLowerCase()) || + customer.loyaltyTier?.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: { + customers: paginatedData, + total_count: filteredData.length + }, + isLoading, + error: null, + isFetching: isLoading + } +} + +// Utility functions +const getLoyaltyTierColor = (loyaltyTier?: string): ThemeColor => { + switch (loyaltyTier?.toLowerCase()) { + case 'bronze': + return 'warning' + case 'silver': + return 'info' + case 'gold': + return 'success' + default: + return 'secondary' + } +} + +const getLoyaltyTierIcon = (loyaltyTier?: string): string => { + switch (loyaltyTier?.toLowerCase()) { + case 'bronze': + return 'tabler-medal' + case 'silver': + return 'tabler-medal-2' + case 'gold': + return 'tabler-crown' + default: + return 'tabler-user' + } +} + +// Column Definitions +const columnHelper = createColumnHelper() + +const CustomerAnalyticListTable = () => { + // States + const [rowSelection, setRowSelection] = useState({}) + const [globalFilter, setGlobalFilter] = useState('') + const [currentPage, setCurrentPage] = useState(1) + const [pageSize, setPageSize] = useState(10) + const [search, setSearch] = useState('') + + const { data, isLoading, error, isFetching } = useCustomerAnalytics({ + page: currentPage, + limit: pageSize, + search + }) + + const customers = data?.customers ?? [] + const totalCount = data?.total_count ?? 0 + + // Hooks + const { lang: locale } = useParams() + + const handlePageChange = useCallback((event: unknown, newPage: number) => { + setCurrentPage(newPage) + }, []) + + const handlePageSizeChange = useCallback((event: React.ChangeEvent) => { + const newPageSize = parseInt(event.target.value, 10) + setPageSize(newPageSize) + setCurrentPage(1) // Reset to first page + }, []) + + const handleViewCustomer = (customerId: string) => { + console.log('Viewing customer:', customerId) + // Add your view logic here + } + + const handleDeleteCustomer = (customerId: string) => { + if (confirm('Apakah Anda yakin ingin menghapus data pelanggan ini?')) { + console.log('Deleting customer:', customerId) + // Add your delete logic here + } + } + + const columns = useMemo[]>( + () => [ + { + id: 'select', + header: ({ table }) => ( + + ), + cell: ({ row }) => ( + + ) + }, + columnHelper.accessor('name', { + header: 'Pelanggan', + cell: ({ row }) => ( +
+ {getInitials(row.original.name)} +
+ + + {row.original.name} + + + {row.original.email && ( + + {row.original.email} + + )} +
+
+ ) + }), + columnHelper.accessor('phone', { + header: 'No. Telepon', + cell: ({ row }) => {row.original.phone || '-'} + }), + columnHelper.accessor('totalPoints', { + header: 'Total Poin', + cell: ({ row }) => ( +
+ + + {row.original.totalPoints.toLocaleString('id-ID')} Poin + +
+ ) + }), + columnHelper.accessor('totalSpent', { + header: 'Total Belanja', + cell: ({ row }) => ( + + {row.original.totalSpent ? formatCurrency(row.original.totalSpent) : '-'} + + ) + }), + columnHelper.accessor('loyaltyTier', { + header: 'Tier Loyalitas', + cell: ({ row }) => ( +
+ + +
+ ) + }), + columnHelper.accessor('lastTransactionDate', { + header: 'Transaksi Terakhir', + cell: ({ row }) => ( + + {row.original.lastTransactionDate + ? new Date(row.original.lastTransactionDate).toLocaleDateString('id-ID', { + year: 'numeric', + month: 'short', + day: 'numeric' + }) + : '-'} + + ) + }), + { + id: 'actions', + header: 'Aksi', + cell: ({ row }) => ( +
+ handleViewCustomer(row.original.id) + } + }, + { + text: 'Hapus', + icon: 'tabler-trash text-[22px]', + menuItemProps: { + className: 'flex items-center gap-2 text-textSecondary', + onClick: () => handleDeleteCustomer(row.original.id) + } + } + ]} + /> +
+ ), + enableSorting: false + } + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [locale, handleViewCustomer, handleDeleteCustomer] + ) + + const table = useReactTable({ + data: customers as CustomerAnalytics[], + columns, + filterFns: { + fuzzy: fuzzyFilter + }, + state: { + rowSelection, + globalFilter, + pagination: { + pageIndex: currentPage, + pageSize + } + }, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + pageCount: Math.ceil(totalCount / pageSize) + }) + + return ( + +
+ table.setPageSize(Number(e.target.value))} + className='max-sm:is-full sm:is-[70px]' + > + 10 + 25 + 50 + +
+ setSearch(value as string)} + placeholder='Cari Pelanggan' + className='max-sm:is-full' + /> + +
+
+
+ {isLoading ? ( + + ) : ( + + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + ))} + + ))} + + {table.getFilteredRowModel().rows.length === 0 ? ( + + + + + + ) : ( + + {table + .getRowModel() + .rows.slice(0, table.getState().pagination.pageSize) + .map(row => { + return ( + + {row.getVisibleCells().map(cell => ( + + ))} + + ) + })} + + )} +
+ {header.isPlaceholder ? null : ( + <> +
+ {flexRender(header.column.columnDef.header, header.getContext())} + {{ + asc: , + desc: + }[header.column.getIsSorted() as 'asc' | 'desc'] ?? null} +
+ + )} +
+ Tidak ada data tersedia +
{flexRender(cell.column.columnDef.cell, cell.getContext())}
+ )} +
+ ( + + )} + count={totalCount} + rowsPerPage={pageSize} + page={currentPage} + onPageChange={handlePageChange} + onRowsPerPageChange={handlePageSizeChange} + rowsPerPageOptions={[10, 25, 50]} + disabled={isLoading} + /> +
+ ) +} + +export default CustomerAnalyticListTable diff --git a/src/views/apps/marketing/customer-analytics/index.tsx b/src/views/apps/marketing/customer-analytics/index.tsx new file mode 100644 index 0000000..ee9f0dd --- /dev/null +++ b/src/views/apps/marketing/customer-analytics/index.tsx @@ -0,0 +1,17 @@ +// MUI Imports +import Grid from '@mui/material/Grid2' +import CustomerAnalyticListTable from './CustomerAnalyticListTable' + +// Type Imports + +const CustomerAnalyticList = () => { + return ( + + + + + + ) +} + +export default CustomerAnalyticList -- 2.47.2 From 2643963b4957467bb80711662b21c39ec302f13d Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 17 Sep 2025 02:21:02 +0700 Subject: [PATCH 06/18] Voucher --- .../(private)/apps/marketing/voucher/page.tsx | 7 + .../layout/vertical/VerticalMenu.tsx | 1 + src/data/dictionaries/en.json | 3 +- src/data/dictionaries/id.json | 3 +- src/types/services/voucher.ts | 14 +- .../voucher/AddEditVoucherDrawer.tsx | 896 ++++++++++++++++++ .../marketing/voucher/VoucherListTable.tsx | 755 +++++++++++++++ src/views/apps/marketing/voucher/index.tsx | 17 + 8 files changed, 1693 insertions(+), 3 deletions(-) create mode 100644 src/app/[lang]/(dashboard)/(private)/apps/marketing/voucher/page.tsx create mode 100644 src/views/apps/marketing/voucher/AddEditVoucherDrawer.tsx create mode 100644 src/views/apps/marketing/voucher/VoucherListTable.tsx create mode 100644 src/views/apps/marketing/voucher/index.tsx diff --git a/src/app/[lang]/(dashboard)/(private)/apps/marketing/voucher/page.tsx b/src/app/[lang]/(dashboard)/(private)/apps/marketing/voucher/page.tsx new file mode 100644 index 0000000..0d76719 --- /dev/null +++ b/src/app/[lang]/(dashboard)/(private)/apps/marketing/voucher/page.tsx @@ -0,0 +1,7 @@ +import VoucherList from '@/views/apps/marketing/voucher' + +const VoucherPage = () => { + return +} + +export default VoucherPage diff --git a/src/components/layout/vertical/VerticalMenu.tsx b/src/components/layout/vertical/VerticalMenu.tsx index 5c4ce34..2234b27 100644 --- a/src/components/layout/vertical/VerticalMenu.tsx +++ b/src/components/layout/vertical/VerticalMenu.tsx @@ -165,6 +165,7 @@ const VerticalMenu = ({ dictionary, scrollMenu }: Props) => { {dictionary['navigation'].customer_analytics} + {dictionary['navigation'].voucher}
}> diff --git a/src/data/dictionaries/en.json b/src/data/dictionaries/en.json index ecaea77..be52350 100644 --- a/src/data/dictionaries/en.json +++ b/src/data/dictionaries/en.json @@ -133,6 +133,7 @@ "gamification": "Gamification", "wheel_spin": "Wheel Spin", "campaign": "Campaign", - "customer_analytics": "Customer Analytics" + "customer_analytics": "Customer Analytics", + "voucher": "Voucher" } } diff --git a/src/data/dictionaries/id.json b/src/data/dictionaries/id.json index 9b0a4a0..66f3bcf 100644 --- a/src/data/dictionaries/id.json +++ b/src/data/dictionaries/id.json @@ -133,6 +133,7 @@ "gamification": "Gamifikasi", "wheel_spin": "Wheel Spin", "campaign": "Kampanye", - "customer_analytics": "Analisis Pelanggan" + "customer_analytics": "Analisis Pelanggan", + "voucher": "Vocher" } } diff --git a/src/types/services/voucher.ts b/src/types/services/voucher.ts index 03eb4b9..9a4c13b 100644 --- a/src/types/services/voucher.ts +++ b/src/types/services/voucher.ts @@ -20,4 +20,16 @@ export interface VoucherApiResponse { success: boolean data: VoucherRowsResponse errors: any -} \ No newline at end of file +} + +export interface VoucherType { + id: number + code: string + type: 'discount' | 'cashback' | 'free_shipping' | 'product' + discountType?: 'fixed' | 'percent' + discountValue?: number + minPurchase?: number + validFrom: string + validUntil: string + isActive: boolean +} diff --git a/src/views/apps/marketing/voucher/AddEditVoucherDrawer.tsx b/src/views/apps/marketing/voucher/AddEditVoucherDrawer.tsx new file mode 100644 index 0000000..6870648 --- /dev/null +++ b/src/views/apps/marketing/voucher/AddEditVoucherDrawer.tsx @@ -0,0 +1,896 @@ +// React Imports +import { useState, useEffect } from 'react' + +// MUI Imports +import Button from '@mui/material/Button' +import Drawer from '@mui/material/Drawer' +import IconButton from '@mui/material/IconButton' +import MenuItem from '@mui/material/MenuItem' +import Typography from '@mui/material/Typography' +import Divider from '@mui/material/Divider' +import Grid from '@mui/material/Grid2' +import Box from '@mui/material/Box' +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 Avatar from '@mui/material/Avatar' +import Card from '@mui/material/Card' +import CardContent from '@mui/material/CardContent' +import FormHelperText from '@mui/material/FormHelperText' + +// Third-party Imports +import { useForm, Controller } from 'react-hook-form' + +// Component Imports +import CustomTextField from '@core/components/mui/TextField' + +// Types - Updated to match the integrated voucher structure +export interface VoucherCatalogType { + id: string + name: string + description?: string + pointCost: number + stock?: number + isActive: boolean + validUntil?: Date + imageUrl?: string + createdAt: Date + updatedAt: Date + // Voucher-specific fields + code: string + type: 'discount' | 'cashback' | 'free_shipping' | 'product' + discountType?: 'fixed' | 'percent' + discountValue?: number + minPurchase?: number + validFrom: string +} + +export interface VoucherRequest { + name: string + description?: string + pointCost: number + stock?: number + isActive: boolean + validUntil?: Date + imageUrl?: string + code: string + type: 'discount' | 'cashback' | 'free_shipping' | 'product' + discountType?: 'fixed' | 'percent' + discountValue?: number + minPurchase?: number + validFrom: string + terms?: string +} + +type Props = { + open: boolean + handleClose: () => void + data?: VoucherCatalogType // Data voucher untuk edit (jika ada) +} + +type FormValidateType = { + name: string + description: string + pointCost: number + stock: number | '' + isActive: boolean + validUntil: string + imageUrl: string + code: string + type: 'discount' | 'cashback' | 'free_shipping' | 'product' + discountType: 'fixed' | 'percent' + discountValue: number | '' + minPurchase: number | '' + validFrom: string + terms: string + hasUnlimitedStock: boolean + hasValidUntil: boolean + hasMinPurchase: boolean +} + +// Initial form data +const initialData: FormValidateType = { + name: '', + description: '', + pointCost: 100, + stock: '', + isActive: true, + validUntil: '', + imageUrl: '', + code: '', + type: 'discount', + discountType: 'fixed', + discountValue: '', + minPurchase: '', + validFrom: new Date().toISOString().split('T')[0], + terms: '', + hasUnlimitedStock: false, + hasValidUntil: false, + hasMinPurchase: false +} + +// Mock mutation hooks (replace with actual hooks) +const useVoucherMutation = () => { + const createVoucher = { + mutate: (data: VoucherRequest, options?: { onSuccess?: () => void }) => { + console.log('Creating voucher:', data) + setTimeout(() => options?.onSuccess?.(), 1000) + } + } + + const updateVoucher = { + mutate: (data: { id: string; payload: VoucherRequest }, options?: { onSuccess?: () => void }) => { + console.log('Updating voucher:', data) + setTimeout(() => options?.onSuccess?.(), 1000) + } + } + + return { createVoucher, updateVoucher } +} + +// Voucher types +const VOUCHER_TYPES = [ + { value: 'discount', label: 'Diskon', icon: 'tabler-percentage' }, + { value: 'cashback', label: 'Cashback', icon: 'tabler-cash' }, + { value: 'free_shipping', label: 'Gratis Ongkir', icon: 'tabler-truck-delivery' }, + { value: 'product', label: 'Produk Fisik', icon: 'tabler-package' } +] + +// Discount types +const DISCOUNT_TYPES = [ + { value: 'fixed', label: 'Nilai Tetap (Rp)' }, + { value: 'percent', label: 'Persentase (%)' } +] + +const AddEditVoucherDrawer = (props: Props) => { + // Props + const { open, handleClose, data } = props + + // States + const [showMore, setShowMore] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + const [imagePreview, setImagePreview] = useState(null) + + const { createVoucher, updateVoucher } = useVoucherMutation() + + // Determine if this is edit mode + const isEditMode = Boolean(data?.id) + + // Hooks + const { + control, + reset: resetForm, + handleSubmit, + watch, + setValue, + formState: { errors } + } = useForm({ + defaultValues: initialData + }) + + const watchedImageUrl = watch('imageUrl') + const watchedHasUnlimitedStock = watch('hasUnlimitedStock') + const watchedHasValidUntil = watch('hasValidUntil') + const watchedHasMinPurchase = watch('hasMinPurchase') + const watchedStock = watch('stock') + const watchedPointCost = watch('pointCost') + const watchedType = watch('type') + const watchedDiscountType = watch('discountType') + const watchedDiscountValue = watch('discountValue') + + // Effect to populate form when editing + useEffect(() => { + if (isEditMode && data) { + // Populate form with existing data + const formData: FormValidateType = { + name: data.name || '', + description: data.description || '', + pointCost: data.pointCost || 100, + stock: data.stock ?? '', + isActive: data.isActive ?? true, + validUntil: data.validUntil ? new Date(data.validUntil).toISOString().split('T')[0] : '', + imageUrl: data.imageUrl || '', + code: data.code || '', + type: data.type || 'discount', + discountType: data.discountType || 'fixed', + discountValue: data.discountValue ?? '', + minPurchase: data.minPurchase ?? '', + validFrom: data.validFrom + ? new Date(data.validFrom).toISOString().split('T')[0] + : new Date().toISOString().split('T')[0], + terms: '', + hasUnlimitedStock: data.stock === undefined || data.stock === null, + hasValidUntil: Boolean(data.validUntil), + hasMinPurchase: Boolean(data.minPurchase) + } + + 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) { + setValue('stock', '') + } + }, [watchedHasUnlimitedStock, setValue]) + + // Handle valid until toggle + useEffect(() => { + if (!watchedHasValidUntil) { + setValue('validUntil', '') + } + }, [watchedHasValidUntil, setValue]) + + // Handle minimum purchase toggle + useEffect(() => { + if (!watchedHasMinPurchase) { + setValue('minPurchase', '') + } + }, [watchedHasMinPurchase, setValue]) + + // Auto-generate voucher code + const generateVoucherCode = () => { + const prefix = watchedType.toUpperCase() + const randomSuffix = Math.random().toString(36).substring(2, 8).toUpperCase() + return `${prefix}_${randomSuffix}` + } + + const handleFormSubmit = async (formData: FormValidateType) => { + try { + setIsSubmitting(true) + + // Create VoucherRequest object + const voucherRequest: VoucherRequest = { + name: formData.name, + description: formData.description || undefined, + pointCost: formData.pointCost, + 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, + code: formData.code, + type: formData.type, + discountType: ['discount', 'cashback'].includes(formData.type) ? formData.discountType : undefined, + discountValue: + ['discount', 'cashback'].includes(formData.type) && formData.discountValue + ? Number(formData.discountValue) + : undefined, + minPurchase: formData.hasMinPurchase && formData.minPurchase ? Number(formData.minPurchase) : undefined, + validFrom: formData.validFrom, + terms: formData.terms || undefined + } + + if (isEditMode && data?.id) { + // Update existing voucher + updateVoucher.mutate( + { id: data.id, payload: voucherRequest }, + { + onSuccess: () => { + handleReset() + handleClose() + } + } + ) + } else { + // Create new voucher + createVoucher.mutate(voucherRequest, { + onSuccess: () => { + handleReset() + handleClose() + } + }) + } + } catch (error) { + console.error('Error submitting voucher:', error) + // Handle error (show toast, etc.) + } finally { + setIsSubmitting(false) + } + } + + const handleReset = () => { + handleClose() + resetForm(initialData) + setShowMore(false) + setImagePreview(null) + } + + const formatPoints = (value: number) => { + return value.toLocaleString('id-ID') + ' poin' + } + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR' + }).format(value) + } + + const getStockDisplay = () => { + if (watchedHasUnlimitedStock) return 'Unlimited' + if (watchedStock === '' || watchedStock === 0) return 'Tidak ada stok' + return `${watchedStock} item` + } + + const getDiscountValueDisplay = () => { + if (!watchedDiscountValue) return '' + if (watchedDiscountType === 'percent') { + return `${watchedDiscountValue}%` + } else { + return formatCurrency(Number(watchedDiscountValue)) + } + } + + const shouldShowDiscountFields = ['discount', 'cashback'].includes(watchedType) + + return ( + + {/* Sticky Header */} + +
+ {isEditMode ? 'Edit Voucher' : 'Tambah Voucher Baru'} + + + +
+
+ + {/* Scrollable Content */} + +
+
+ {/* Image Preview */} + {imagePreview && ( + + + + Preview Gambar + + + + + + + )} + + {/* Nama Voucher */} +
+ + Nama Voucher * + + ( + + )} + /> +
+ + {/* Tipe Voucher */} +
+ + Tipe Voucher * + + ( + + {VOUCHER_TYPES.map(type => ( + +
+ + {type.label} +
+
+ ))} +
+ )} + /> +
+ + {/* Kode Voucher */} +
+ + Kode Voucher * + + ( + + + + ) + }} + onChange={e => field.onChange(e.target.value.toUpperCase())} + /> + )} + /> +
+ + {/* Discount Fields - Only show for discount and cashback */} + {shouldShowDiscountFields && ( + <> + {/* Tipe Diskon */} +
+ + Tipe Diskon * + + ( + + {DISCOUNT_TYPES.map(type => ( + + {type.label} + + ))} + + )} + /> +
+ + {/* Nilai Diskon */} +
+ + Nilai Diskon * + + ( + + {watchedDiscountType === 'percent' ? '%' : 'Rp'} + + ) + }} + onChange={e => field.onChange(e.target.value ? Number(e.target.value) : '')} + value={field.value === '' ? '' : field.value} + /> + )} + /> +
+ + )} + + {/* Point Cost */} +
+ + Biaya Poin * + + ( + 0 ? formatPoints(field.value) : '')} + InputProps={{ + startAdornment: ( + + + + ) + }} + onChange={e => field.onChange(Number(e.target.value))} + /> + )} + /> +
+ + {/* Valid From */} +
+ + Berlaku Mulai * + + ( + + )} + /> +
+ + {/* Minimum Purchase */} +
+ + Pembelian Minimum + + ( + } + label='Memiliki syarat pembelian minimum' + className='mb-2' + /> + )} + /> + {watchedHasMinPurchase && ( + ( + Rp + }} + onChange={e => field.onChange(e.target.value ? Number(e.target.value) : '')} + value={field.value === '' ? '' : field.value} + /> + )} + /> + )} +
+ + {/* Stock Management */} +
+ + Manajemen Stok + + ( + } + label='Stok Unlimited' + className='mb-2' + /> + )} + /> + {!watchedHasUnlimitedStock && ( + ( + Qty + }} + onChange={e => field.onChange(e.target.value ? Number(e.target.value) : '')} + value={field.value === '' ? '' : field.value} + /> + )} + /> + )} +
+ + {/* Status Aktif */} +
+ ( + } + label='Voucher Aktif' + /> + )} + /> +
+ + {/* Tampilkan selengkapnya */} + {!showMore && ( + + )} + + {/* Konten tambahan */} + {showMore && ( + <> + {/* Description */} +
+ + Deskripsi Voucher + + ( + + )} + /> +
+ + {/* Image URL */} +
+ + URL Gambar + + ( + + + + ) + }} + /> + )} + /> +
+ + {/* Valid Until */} +
+ + Masa Berlaku + + ( + } + label='Memiliki batas waktu' + className='mb-2' + /> + )} + /> + {watchedHasValidUntil && ( + ( + + )} + /> + )} +
+ + {/* Terms & Conditions */} +
+ + Syarat & Ketentuan + + ( + + )} + /> +
+ + {/* Sembunyikan */} + + + )} +
+ +
+ + {/* Sticky Footer */} + +
+ + +
+
+
+ ) +} + +export default AddEditVoucherDrawer diff --git a/src/views/apps/marketing/voucher/VoucherListTable.tsx b/src/views/apps/marketing/voucher/VoucherListTable.tsx new file mode 100644 index 0000000..8b5e385 --- /dev/null +++ b/src/views/apps/marketing/voucher/VoucherListTable.tsx @@ -0,0 +1,755 @@ +'use client' + +// React Imports +import { useEffect, useState, useMemo, useCallback } from 'react' + +// Next Imports +import Link from 'next/link' +import { useParams } from 'next/navigation' + +// MUI Imports +import Card from '@mui/material/Card' +import CardHeader from '@mui/material/CardHeader' +import Button from '@mui/material/Button' +import Typography from '@mui/material/Typography' +import Chip from '@mui/material/Chip' +import Checkbox from '@mui/material/Checkbox' +import IconButton from '@mui/material/IconButton' +import { styled } from '@mui/material/styles' +import TablePagination from '@mui/material/TablePagination' +import type { TextFieldProps } from '@mui/material/TextField' +import MenuItem from '@mui/material/MenuItem' + +// Third-party Imports +import classnames from 'classnames' +import { rankItem } from '@tanstack/match-sorter-utils' +import { + createColumnHelper, + flexRender, + getCoreRowModel, + useReactTable, + getFilteredRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFacetedMinMaxValues, + getPaginationRowModel, + getSortedRowModel +} from '@tanstack/react-table' +import type { ColumnDef, FilterFn } from '@tanstack/react-table' +import type { RankingInfo } from '@tanstack/match-sorter-utils' + +// Type Imports +import type { ThemeColor } from '@core/types' +import type { Locale } from '@configs/i18n' + +// Component Imports +import OptionMenu from '@core/components/option-menu' +import TablePaginationComponent from '@components/TablePaginationComponent' +import CustomTextField from '@core/components/mui/TextField' +import CustomAvatar from '@core/components/mui/Avatar' + +// Util Imports +import { getInitials } from '@/utils/getInitials' +import { getLocalizedUrl } from '@/utils/i18n' +import { formatCurrency } from '@/utils/transform' + +// Style Imports +import tableStyles from '@core/styles/table.module.css' +import Loading from '@/components/layout/shared/Loading' +import AddEditVoucherDrawer from './AddEditVoucherDrawer' + +// Voucher Type Interface +export interface VoucherType { + id: number + code: string + type: 'discount' | 'cashback' | 'free_shipping' | 'product' + discountType?: 'fixed' | 'percent' + discountValue?: number + minPurchase?: number + validFrom: string + validUntil: string + isActive: boolean +} + +// Main Voucher Catalog Type Interface +export interface VoucherCatalogType { + id: string + name: string + description?: string + pointCost: number + stock?: number + isActive: boolean + validUntil?: Date + imageUrl?: string + createdAt: Date + updatedAt: Date + // Voucher-specific fields + code: string + type: 'discount' | 'cashback' | 'free_shipping' | 'product' + discountType?: 'fixed' | 'percent' + discountValue?: number + minPurchase?: number + validFrom: string +} + +declare module '@tanstack/table-core' { + interface FilterFns { + fuzzy: FilterFn + } + interface FilterMeta { + itemRank: RankingInfo + } +} + +type VoucherCatalogTypeWithAction = VoucherCatalogType & { + action?: string +} + +// Styled Components +const Icon = styled('i')({}) + +const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { + // Rank the item + const itemRank = rankItem(row.getValue(columnId), value) + + // Store the itemRank info + addMeta({ + itemRank + }) + + // Return if the item should be filtered in/out + return itemRank.passed +} + +const DebouncedInput = ({ + value: initialValue, + onChange, + debounce = 500, + ...props +}: { + value: string | number + onChange: (value: string | number) => void + debounce?: number +} & Omit) => { + // States + const [value, setValue] = useState(initialValue) + + useEffect(() => { + setValue(initialValue) + }, [initialValue]) + + useEffect(() => { + const timeout = setTimeout(() => { + onChange(value) + }, debounce) + + return () => clearTimeout(timeout) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]) + + return setValue(e.target.value)} /> +} + +// Updated dummy data with integrated voucher information +const DUMMY_VOUCHER_DATA: VoucherCatalogType[] = [ + { + 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'), + code: 'DISC50K', + type: 'discount', + discountType: 'fixed', + discountValue: 50000, + minPurchase: 200000, + validFrom: '2024-01-15' + }, + { + 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'), + code: 'FREESHIP', + type: 'free_shipping', + validFrom: '2024-01-20' + }, + { + 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'), + code: 'SPEAKER25', + type: 'product', + validFrom: '2024-01-25' + }, + { + 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'), + code: 'CASHBACK20', + type: 'cashback', + discountType: 'percent', + discountValue: 20, + minPurchase: 100000, + validFrom: '2024-02-01' + }, + { + 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'), + code: 'WATCH15', + type: 'product', + validFrom: '2024-02-05' + }, + { + 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'), + code: 'TUMBLER50', + type: 'product', + validFrom: '2024-02-10' + }, + { + 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'), + code: 'GIFT100K', + type: 'discount', + discountType: 'fixed', + discountValue: 100000, + validFrom: '2024-02-15' + }, + { + 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'), + code: 'EARBUDS30', + type: 'product', + validFrom: '2024-03-01' + }, + { + 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'), + code: 'BUY1GET1', + type: 'discount', + discountType: 'percent', + discountValue: 50, + minPurchase: 50000, + validFrom: '2024-03-05' + }, + { + 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'), + code: 'POWERBANK40', + type: 'product', + validFrom: '2024-03-10' + } +] + +// Helper function to get voucher type display text +const getVoucherTypeDisplay = (type: string) => { + const typeMap = { + discount: 'Diskon', + cashback: 'Cashback', + free_shipping: 'Gratis Ongkir', + product: 'Produk' + } + return typeMap[type as keyof typeof typeMap] || type +} + +// Helper function to get voucher value display +const getVoucherValueDisplay = (voucher: VoucherCatalogType) => { + if (voucher.type === 'free_shipping') return 'Gratis Ongkir' + if (voucher.type === 'product') return 'Produk Fisik' + + if (voucher.discountValue) { + if (voucher.discountType === 'percent') { + return `${voucher.discountValue}%` + } else { + return formatCurrency(voucher.discountValue) + } + } + + return '-' +} + +// Mock data hook with dummy data +const useVoucherCatalog = ({ 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_VOUCHER_DATA + + return DUMMY_VOUCHER_DATA.filter( + voucher => + voucher.name.toLowerCase().includes(search.toLowerCase()) || + voucher.description?.toLowerCase().includes(search.toLowerCase()) || + voucher.code.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: { + vouchers: paginatedData, + total_count: filteredData.length + }, + isLoading, + error: null, + isFetching: isLoading + } +} + +// Column Definitions +const columnHelper = createColumnHelper() + +const VoucherListTable = () => { + // States + const [addVoucherOpen, setAddVoucherOpen] = useState(false) + const [editVoucherData, setEditVoucherData] = useState(undefined) + const [rowSelection, setRowSelection] = useState({}) + const [globalFilter, setGlobalFilter] = useState('') + const [currentPage, setCurrentPage] = useState(1) + const [pageSize, setPageSize] = useState(10) + const [search, setSearch] = useState('') + + const { data, isLoading, error, isFetching } = useVoucherCatalog({ + page: currentPage, + limit: pageSize, + search + }) + + const vouchers = data?.vouchers ?? [] + const totalCount = data?.total_count ?? 0 + + // Hooks + const { lang: locale } = useParams() + + const handlePageChange = useCallback((event: unknown, newPage: number) => { + setCurrentPage(newPage) + }, []) + + const handlePageSizeChange = useCallback((event: React.ChangeEvent) => { + const newPageSize = parseInt(event.target.value, 10) + setPageSize(newPageSize) + setCurrentPage(1) // Reset to first page + }, []) + + const handleEditVoucher = (voucher: VoucherCatalogType) => { + setEditVoucherData(voucher) + setAddVoucherOpen(true) + } + + const handleDeleteVoucher = (voucherId: string) => { + if (confirm('Apakah Anda yakin ingin menghapus voucher ini?')) { + console.log('Deleting voucher:', voucherId) + // Add your delete logic here + // deleteVoucher.mutate(voucherId) + } + } + + const handleToggleActive = (voucherId: string, currentStatus: boolean) => { + console.log('Toggling active status for voucher:', voucherId, !currentStatus) + // Add your toggle logic here + // toggleVoucherStatus.mutate({ id: voucherId, isActive: !currentStatus }) + } + + const handleCloseVoucherDrawer = () => { + setAddVoucherOpen(false) + setEditVoucherData(undefined) + } + + const columns = useMemo[]>( + () => [ + { + id: 'select', + header: ({ table }) => ( + + ), + cell: ({ row }) => ( + + ) + }, + columnHelper.accessor('name', { + header: 'Nama Voucher', + cell: ({ row }) => ( +
+ + {getInitials(row.original.name)} + +
+ + + {row.original.name} + + + {row.original.description && ( + + {row.original.description} + + )} + + {row.original.code} + +
+
+ ) + }), + columnHelper.accessor('type', { + header: 'Tipe Voucher', + cell: ({ row }) => { + const typeColors = { + discount: 'primary', + cashback: 'success', + free_shipping: 'info', + product: 'warning' + } as const + + return ( + + ) + } + }), + columnHelper.accessor('discountValue', { + header: 'Nilai Voucher', + cell: ({ row }) => ( + + {getVoucherValueDisplay(row.original)} + + ) + }), + columnHelper.accessor('pointCost', { + header: 'Biaya Poin', + cell: ({ row }) => ( +
+ + + {row.original.pointCost.toLocaleString('id-ID')} poin + +
+ ) + }), + columnHelper.accessor('minPurchase', { + header: 'Min. Pembelian', + cell: ({ row }) => ( + + {row.original.minPurchase ? formatCurrency(row.original.minPurchase) : '-'} + + ) + }), + columnHelper.accessor('stock', { + header: 'Stok', + cell: ({ row }) => { + const stock = row.original.stock + const stockColor = stock === 0 ? 'error' : stock && stock <= 10 ? 'warning' : 'success' + const stockText = stock === undefined ? 'Unlimited' : stock === 0 ? 'Habis' : stock.toString() + + return + } + }), + columnHelper.accessor('isActive', { + header: 'Status', + cell: ({ row }) => ( + + ) + }), + columnHelper.accessor('validUntil', { + header: 'Berlaku Hingga', + cell: ({ row }) => ( + + {row.original.validUntil + ? new Date(row.original.validUntil).toLocaleDateString('id-ID', { + year: 'numeric', + month: 'short', + day: 'numeric' + }) + : 'Tidak terbatas'} + + ) + }), + { + id: 'actions', + header: 'Aksi', + cell: ({ row }) => ( +
+ handleToggleActive(row.original.id, row.original.isActive) + } + }, + { + text: 'Edit', + icon: 'tabler-edit text-[22px]', + menuItemProps: { + className: 'flex items-center gap-2 text-textSecondary', + onClick: () => handleEditVoucher(row.original) + } + }, + { + text: 'Hapus', + icon: 'tabler-trash text-[22px]', + menuItemProps: { + className: 'flex items-center gap-2 text-textSecondary', + onClick: () => handleDeleteVoucher(row.original.id) + } + } + ]} + /> +
+ ), + enableSorting: false + } + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [locale, handleEditVoucher, handleDeleteVoucher, handleToggleActive] + ) + + const table = useReactTable({ + data: vouchers as VoucherCatalogType[], + columns, + filterFns: { + fuzzy: fuzzyFilter + }, + state: { + rowSelection, + globalFilter, + pagination: { + pageIndex: currentPage, + pageSize + } + }, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + pageCount: Math.ceil(totalCount / pageSize) + }) + + return ( + <> + +
+ table.setPageSize(Number(e.target.value))} + className='max-sm:is-full sm:is-[70px]' + > + 10 + 25 + 50 + +
+ setSearch(value as string)} + placeholder='Cari Voucher' + className='max-sm:is-full' + /> + + +
+
+
+ {isLoading ? ( + + ) : ( + + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + ))} + + ))} + + {table.getFilteredRowModel().rows.length === 0 ? ( + + + + + + ) : ( + + {table + .getRowModel() + .rows.slice(0, table.getState().pagination.pageSize) + .map(row => { + return ( + + {row.getVisibleCells().map(cell => ( + + ))} + + ) + })} + + )} +
+ {header.isPlaceholder ? null : ( + <> +
+ {flexRender(header.column.columnDef.header, header.getContext())} + {{ + asc: , + desc: + }[header.column.getIsSorted() as 'asc' | 'desc'] ?? null} +
+ + )} +
+ Tidak ada data tersedia +
{flexRender(cell.column.columnDef.cell, cell.getContext())}
+ )} +
+ ( + + )} + count={totalCount} + rowsPerPage={pageSize} + page={currentPage} + onPageChange={handlePageChange} + onRowsPerPageChange={handlePageSizeChange} + rowsPerPageOptions={[10, 25, 50]} + disabled={isLoading} + /> +
+ + + ) +} + +export default VoucherListTable diff --git a/src/views/apps/marketing/voucher/index.tsx b/src/views/apps/marketing/voucher/index.tsx new file mode 100644 index 0000000..3d779f4 --- /dev/null +++ b/src/views/apps/marketing/voucher/index.tsx @@ -0,0 +1,17 @@ +// MUI Imports +import Grid from '@mui/material/Grid2' +import VoucherListTable from './VoucherListTable' + +// Type Imports + +const VoucherList = () => { + return ( + + + + + + ) +} + +export default VoucherList -- 2.47.2 From 185edb94cb754c987060b933037f6f873e30c779 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 17 Sep 2025 18:43:14 +0700 Subject: [PATCH 07/18] fix remove local --- src/services/api.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/services/api.ts b/src/services/api.ts index 5f57fcc..3c05905 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -30,9 +30,8 @@ api.interceptors.response.use( response => response, error => { const status = error.response?.status - const currentPath = window.location.pathname - if (status === 401 && !currentPath.endsWith('/login')) { + if (status === 401) { localStorage.removeItem('user') localStorage.removeItem('authToken') window.location.href = '/login' -- 2.47.2 From 4655411b243e9b6596d351a2f850599341438507 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 17 Sep 2025 20:21:28 +0700 Subject: [PATCH 08/18] tier page --- .../(private)/apps/marketing/tier/page.tsx | 7 + .../layout/vertical/VerticalMenu.tsx | 1 + src/data/dictionaries/en.json | 3 +- src/data/dictionaries/id.json | 3 +- src/services/api.ts | 3 +- src/types/services/tier.ts | 8 + .../apps/marketing/tier/AddTierDrawer.tsx | 426 +++++++++++++ .../apps/marketing/tier/TierListTable.tsx | 566 ++++++++++++++++++ src/views/apps/marketing/tier/index.tsx | 17 + 9 files changed, 1031 insertions(+), 3 deletions(-) create mode 100644 src/app/[lang]/(dashboard)/(private)/apps/marketing/tier/page.tsx create mode 100644 src/types/services/tier.ts create mode 100644 src/views/apps/marketing/tier/AddTierDrawer.tsx create mode 100644 src/views/apps/marketing/tier/TierListTable.tsx create mode 100644 src/views/apps/marketing/tier/index.tsx diff --git a/src/app/[lang]/(dashboard)/(private)/apps/marketing/tier/page.tsx b/src/app/[lang]/(dashboard)/(private)/apps/marketing/tier/page.tsx new file mode 100644 index 0000000..703e513 --- /dev/null +++ b/src/app/[lang]/(dashboard)/(private)/apps/marketing/tier/page.tsx @@ -0,0 +1,7 @@ +import TierList from '@/views/apps/marketing/tier' + +const TierPage = () => { + return +} + +export default TierPage diff --git a/src/components/layout/vertical/VerticalMenu.tsx b/src/components/layout/vertical/VerticalMenu.tsx index 2234b27..037fd04 100644 --- a/src/components/layout/vertical/VerticalMenu.tsx +++ b/src/components/layout/vertical/VerticalMenu.tsx @@ -166,6 +166,7 @@ const VerticalMenu = ({ dictionary, scrollMenu }: Props) => { {dictionary['navigation'].customer_analytics} {dictionary['navigation'].voucher} + {dictionary['navigation'].tiers}
}> diff --git a/src/data/dictionaries/en.json b/src/data/dictionaries/en.json index be52350..568dd97 100644 --- a/src/data/dictionaries/en.json +++ b/src/data/dictionaries/en.json @@ -134,6 +134,7 @@ "wheel_spin": "Wheel Spin", "campaign": "Campaign", "customer_analytics": "Customer Analytics", - "voucher": "Voucher" + "voucher": "Voucher", + "tiers": "Tiers" } } diff --git a/src/data/dictionaries/id.json b/src/data/dictionaries/id.json index 66f3bcf..f55b6b6 100644 --- a/src/data/dictionaries/id.json +++ b/src/data/dictionaries/id.json @@ -134,6 +134,7 @@ "wheel_spin": "Wheel Spin", "campaign": "Kampanye", "customer_analytics": "Analisis Pelanggan", - "voucher": "Vocher" + "voucher": "Vocher", + "tiers": "Tier" } } diff --git a/src/services/api.ts b/src/services/api.ts index 3c05905..10348bc 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -6,7 +6,8 @@ const getToken = () => { } export const api = axios.create({ - baseURL: 'https://enaklo-pos-be.altru.id/api/v1', + // baseURL: 'https://enaklo-pos-be.altru.id/api/v1', + baseURL: 'http://127.0.0.1:4000/api/v1', headers: { 'Content-Type': 'application/json' }, diff --git a/src/types/services/tier.ts b/src/types/services/tier.ts new file mode 100644 index 0000000..4c9d539 --- /dev/null +++ b/src/types/services/tier.ts @@ -0,0 +1,8 @@ +export type TierType = { + id: string // uuid + name: string + min_points: number + benefits: Record + created_at: string // ISO datetime + updated_at: string // ISO datetime +} diff --git a/src/views/apps/marketing/tier/AddTierDrawer.tsx b/src/views/apps/marketing/tier/AddTierDrawer.tsx new file mode 100644 index 0000000..5efb37d --- /dev/null +++ b/src/views/apps/marketing/tier/AddTierDrawer.tsx @@ -0,0 +1,426 @@ +// React Imports +import { useState, useEffect } from 'react' + +// MUI Imports +import Button from '@mui/material/Button' +import Drawer from '@mui/material/Drawer' +import IconButton from '@mui/material/IconButton' +import MenuItem from '@mui/material/MenuItem' +import Typography from '@mui/material/Typography' +import Divider from '@mui/material/Divider' +import Grid from '@mui/material/Grid2' +import Box from '@mui/material/Box' +import Switch from '@mui/material/Switch' +import FormControlLabel from '@mui/material/FormControlLabel' +import Chip from '@mui/material/Chip' +import InputAdornment from '@mui/material/InputAdornment' + +// Third-party Imports +import { useForm, Controller, useFieldArray } 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 +} + +type Props = { + open: boolean + handleClose: () => void + data?: TierType // Data tier untuk edit (jika ada) +} + +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 +} + +// Initial form data +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 } +} + +const AddEditTierDrawer = (props: Props) => { + // Props + const { open, handleClose, data } = props + + // States + const [showMore, setShowMore] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + + const { createTier, updateTier } = useTierMutation() + + // Determine if this is edit mode + const isEditMode = Boolean(data?.id) + + // Hooks + const { + control, + reset: resetForm, + handleSubmit, + watch, + setValue, + formState: { errors } + } = useForm({ + defaultValues: initialData + }) + + const watchedBenefits = watch('benefits') + const watchedNewBenefit = watch('newBenefit') + + // Helper function to convert benefits object to string array + const convertBenefitsToArray = (benefits: Record): string[] => { + if (!benefits) return [] + return Object.keys(benefits).filter(key => benefits[key] === true || benefits[key] === 'true') + } + + // Helper function to convert benefits array to object + const convertBenefitsToObject = (benefits: string[]): Record => { + const benefitsObj: Record = {} + benefits.forEach(benefit => { + benefitsObj[benefit] = true + }) + return benefitsObj + } + + // Effect to populate form when editing + useEffect(() => { + if (isEditMode && data) { + // Convert benefits object to array for form handling + const benefitsArray = convertBenefitsToArray(data.benefits) + + // Populate form with existing data + const formData: FormValidateType = { + name: data.name || '', + min_points: data.min_points || 0, + benefits: benefitsArray, + newBenefit: '' + } + + resetForm(formData) + setShowMore(true) // Always show more for edit mode + } else { + // Reset to initial data for add mode + resetForm(initialData) + setShowMore(false) + } + }, [data, isEditMode, resetForm]) + + const handleAddBenefit = () => { + if (watchedNewBenefit.trim()) { + const currentBenefits = watchedBenefits || [] + setValue('benefits', [...currentBenefits, watchedNewBenefit.trim()]) + setValue('newBenefit', '') + } + } + + const handleRemoveBenefit = (index: number) => { + const currentBenefits = watchedBenefits || [] + const newBenefits = currentBenefits.filter((_, i) => i !== index) + setValue('benefits', newBenefits) + } + + const handleKeyPress = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault() + handleAddBenefit() + } + } + + const handleFormSubmit = async (formData: FormValidateType) => { + try { + setIsSubmitting(true) + + // Convert benefits array back to object format + const benefitsObj = convertBenefitsToObject(formData.benefits) + + // Create TierRequest object + const tierRequest: TierRequest = { + name: formData.name, + min_points: formData.min_points, + benefits: benefitsObj + } + + if (isEditMode && data?.id) { + // Update existing tier + updateTier.mutate( + { id: data.id, payload: tierRequest }, + { + onSuccess: () => { + handleReset() + handleClose() + } + } + ) + } else { + // Create new tier + createTier.mutate(tierRequest, { + onSuccess: () => { + handleReset() + handleClose() + } + }) + } + } catch (error) { + console.error('Error submitting tier:', error) + // Handle error (show toast, etc.) + } finally { + setIsSubmitting(false) + } + } + + const handleReset = () => { + handleClose() + resetForm(initialData) + setShowMore(false) + } + + const formatNumber = (value: number) => { + return new Intl.NumberFormat('id-ID').format(value) + } + + return ( + + {/* Sticky Header */} + +
+ {isEditMode ? 'Edit Tier' : 'Tambah Tier Baru'} + + + +
+
+ + {/* Scrollable Content */} + +
+
+ {/* Nama Tier */} +
+ + Nama Tier * + + ( + + )} + /> +
+ + {/* Minimum Points */} +
+ + Minimum Poin * + + ( + 0 ? `${formatNumber(field.value)} poin` : '') + } + InputProps={{ + endAdornment: poin + }} + onChange={e => field.onChange(Number(e.target.value))} + /> + )} + /> +
+ + {/* Benefits */} +
+ + Manfaat Tier * + + + {/* Display current benefits */} + {watchedBenefits && watchedBenefits.length > 0 && ( +
+ {watchedBenefits.map((benefit, index) => ( + handleRemoveBenefit(index)} + color='primary' + variant='outlined' + size='small' + /> + ))} +
+ )} + + {/* Add new benefit */} + ( + + + + ) + }} + /> + )} + /> + {(!watchedBenefits || watchedBenefits.length === 0) && ( + + Minimal satu manfaat harus ditambahkan + + )} +
+ + {/* Tampilkan selengkapnya */} + {!showMore && ( + + )} + + {/* Konten tambahan */} + {showMore && ( + <> + {/* Sembunyikan */} + + + )} +
+
+
+ + {/* Sticky Footer */} + +
+ + +
+
+
+ ) +} + +export default AddEditTierDrawer diff --git a/src/views/apps/marketing/tier/TierListTable.tsx b/src/views/apps/marketing/tier/TierListTable.tsx new file mode 100644 index 0000000..1d3c526 --- /dev/null +++ b/src/views/apps/marketing/tier/TierListTable.tsx @@ -0,0 +1,566 @@ +'use client' + +// React Imports +import { useEffect, useState, useMemo, useCallback } from 'react' + +// Next Imports +import Link from 'next/link' +import { useParams } from 'next/navigation' + +// MUI Imports +import Card from '@mui/material/Card' +import CardHeader from '@mui/material/CardHeader' +import Button from '@mui/material/Button' +import Typography from '@mui/material/Typography' +import Chip from '@mui/material/Chip' +import Checkbox from '@mui/material/Checkbox' +import IconButton from '@mui/material/IconButton' +import { styled } from '@mui/material/styles' +import TablePagination from '@mui/material/TablePagination' +import type { TextFieldProps } from '@mui/material/TextField' +import MenuItem from '@mui/material/MenuItem' + +// Third-party Imports +import classnames from 'classnames' +import { rankItem } from '@tanstack/match-sorter-utils' +import { + createColumnHelper, + flexRender, + getCoreRowModel, + useReactTable, + getFilteredRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFacetedMinMaxValues, + getPaginationRowModel, + getSortedRowModel +} from '@tanstack/react-table' +import type { ColumnDef, FilterFn } from '@tanstack/react-table' +import type { RankingInfo } from '@tanstack/match-sorter-utils' + +// Type Imports +import type { ThemeColor } from '@core/types' +import type { Locale } from '@configs/i18n' + +// Component Imports +import OptionMenu from '@core/components/option-menu' +import TablePaginationComponent from '@components/TablePaginationComponent' +import CustomTextField from '@core/components/mui/TextField' +import CustomAvatar from '@core/components/mui/Avatar' + +// Util Imports +import { getInitials } from '@/utils/getInitials' +import { getLocalizedUrl } from '@/utils/i18n' +import { formatCurrency } from '@/utils/transform' + +// Style Imports +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 +} + +declare module '@tanstack/table-core' { + interface FilterFns { + fuzzy: FilterFn + } + interface FilterMeta { + itemRank: RankingInfo + } +} + +type TierTypeWithAction = TierType & { + action?: string +} + +// Styled Components +const Icon = styled('i')({}) + +const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { + // Rank the item + const itemRank = rankItem(row.getValue(columnId), value) + + // Store the itemRank info + addMeta({ + itemRank + }) + + // Return if the item should be filtered in/out + return itemRank.passed +} + +const DebouncedInput = ({ + value: initialValue, + onChange, + debounce = 500, + ...props +}: { + value: string | number + onChange: (value: string | number) => void + debounce?: number +} & Omit) => { + // States + const [value, setValue] = useState(initialValue) + + useEffect(() => { + setValue(initialValue) + }, [initialValue]) + + useEffect(() => { + const timeout = setTimeout(() => { + onChange(value) + }, debounce) + + return () => clearTimeout(timeout) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]) + + 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 active benefits as array +const getActiveBenefits = (benefits: Record): string[] => { + return Object.keys(benefits).filter(key => benefits[key] === true || benefits[key] === 'true') +} + +// Helper function to format points +const formatPoints = (points: number) => { + return new Intl.NumberFormat('id-ID').format(points) +} + +// Column Definitions +const columnHelper = createColumnHelper() + +const TierListTable = () => { + // States + const [addTierOpen, setAddTierOpen] = useState(false) + const [editTierData, setEditTierData] = useState(undefined) + const [rowSelection, setRowSelection] = useState({}) + const [globalFilter, setGlobalFilter] = useState('') + const [currentPage, setCurrentPage] = useState(1) + const [pageSize, setPageSize] = useState(10) + const [search, setSearch] = useState('') + + const { data, isLoading, error, isFetching } = useTiers({ + page: currentPage, + limit: pageSize, + search + }) + + const tiers = data?.tiers ?? [] + const totalCount = data?.total_count ?? 0 + + // Hooks + const { lang: locale } = useParams() + + const handlePageChange = useCallback((event: unknown, newPage: number) => { + setCurrentPage(newPage) + }, []) + + const handlePageSizeChange = useCallback((event: React.ChangeEvent) => { + const newPageSize = parseInt(event.target.value, 10) + setPageSize(newPageSize) + setCurrentPage(1) // Reset to first page + }, []) + + const handleEditTier = (tier: TierType) => { + 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 handleCloseTierDrawer = () => { + setAddTierOpen(false) + setEditTierData(undefined) + } + + const columns = useMemo[]>( + () => [ + { + id: 'select', + header: ({ table }) => ( + + ), + cell: ({ row }) => ( + + ) + }, + columnHelper.accessor('name', { + header: 'Nama Tier', + cell: ({ row }) => ( +
+
+ + + {row.original.name} + + +
+
+ ) + }), + columnHelper.accessor('min_points', { + header: 'Minimum Poin', + cell: ({ row }) => ( +
+ + {formatPoints(row.original.min_points)} poin +
+ ) + }), + columnHelper.accessor('benefits', { + header: 'Manfaat', + cell: ({ row }) => { + const activeBenefits = getActiveBenefits(row.original.benefits) + return ( +
+ {activeBenefits.slice(0, 2).map((benefit, index) => ( + + ))} + {activeBenefits.length > 2 && ( + + )} +
+ ) + } + }), + columnHelper.accessor('created_at', { + header: 'Tanggal Dibuat', + cell: ({ row }) => ( + + {new Date(row.original.created_at).toLocaleDateString('id-ID', { + year: 'numeric', + month: 'short', + day: 'numeric' + })} + + ) + }), + { + id: 'actions', + header: 'Aksi', + cell: ({ row }) => ( +
+ handleEditTier(row.original) + } + }, + { + text: 'Hapus', + icon: 'tabler-trash text-[22px]', + menuItemProps: { + className: 'flex items-center gap-2 text-textSecondary', + onClick: () => handleDeleteTier(row.original.id) + } + } + ]} + /> +
+ ), + enableSorting: false + } + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [locale, handleEditTier, handleDeleteTier] + ) + + const table = useReactTable({ + data: tiers as TierType[], + columns, + filterFns: { + fuzzy: fuzzyFilter + }, + state: { + rowSelection, + globalFilter, + pagination: { + pageIndex: currentPage, + pageSize + } + }, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + pageCount: Math.ceil(totalCount / pageSize) + }) + + return ( + <> + +
+ table.setPageSize(Number(e.target.value))} + className='max-sm:is-full sm:is-[70px]' + > + 10 + 25 + 50 + +
+ setSearch(value as string)} + placeholder='Cari Tier' + className='max-sm:is-full' + /> + + +
+
+
+ {isLoading ? ( + + ) : ( + + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + ))} + + ))} + + {table.getFilteredRowModel().rows.length === 0 ? ( + + + + + + ) : ( + + {table + .getRowModel() + .rows.slice(0, table.getState().pagination.pageSize) + .map(row => { + return ( + + {row.getVisibleCells().map(cell => ( + + ))} + + ) + })} + + )} +
+ {header.isPlaceholder ? null : ( + <> +
+ {flexRender(header.column.columnDef.header, header.getContext())} + {{ + asc: , + desc: + }[header.column.getIsSorted() as 'asc' | 'desc'] ?? null} +
+ + )} +
+ Tidak ada data tersedia +
{flexRender(cell.column.columnDef.cell, cell.getContext())}
+ )} +
+ ( + + )} + count={totalCount} + rowsPerPage={pageSize} + page={currentPage} + onPageChange={handlePageChange} + onRowsPerPageChange={handlePageSizeChange} + rowsPerPageOptions={[10, 25, 50]} + disabled={isLoading} + /> +
+ + + ) +} + +export default TierListTable diff --git a/src/views/apps/marketing/tier/index.tsx b/src/views/apps/marketing/tier/index.tsx new file mode 100644 index 0000000..1951f86 --- /dev/null +++ b/src/views/apps/marketing/tier/index.tsx @@ -0,0 +1,17 @@ +// MUI Imports +import Grid from '@mui/material/Grid2' +import TierListTable from './TierListTable' + +// Type Imports + +const TierList = () => { + return ( + + + + + + ) +} + +export default TierList -- 2.47.2 From 8ed2786bc2a3bc3e6bbbdaf6a3c3cb19bd84f516 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 17 Sep 2025 20:54:54 +0700 Subject: [PATCH 09/18] tier --- .../layout/vertical/VerticalMenu.tsx | 2 +- src/data/dictionaries/en.json | 2 +- src/data/dictionaries/id.json | 2 +- src/services/mutations/tier.ts | 52 ++++ src/services/queries/tier.ts | 46 +++ src/types/services/tier.ts | 16 +- .../apps/marketing/tier/AddTierDrawer.tsx | 285 +++++++++++------- .../apps/marketing/tier/DeleteTierDialog.tsx | 106 +++++++ .../apps/marketing/tier/TierListTable.tsx | 281 ++++++++--------- 9 files changed, 534 insertions(+), 258 deletions(-) create mode 100644 src/services/mutations/tier.ts create mode 100644 src/services/queries/tier.ts create mode 100644 src/views/apps/marketing/tier/DeleteTierDialog.tsx 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 */} + ) } -- 2.47.2 From a6f80bbd02741e35066c0c40fa337fc4fb0f3aaa Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 17 Sep 2025 21:20:49 +0700 Subject: [PATCH 10/18] Games Page --- .../apps/marketing/games/list/page.tsx | 7 + .../layout/vertical/VerticalMenu.tsx | 3 + src/data/dictionaries/en.json | 3 +- src/data/dictionaries/id.json | 3 +- src/types/services/game.ts | 17 + .../games/list/AddEditGamesDrawer.tsx | 407 +++++++++++ .../marketing/games/list/GameListTable.tsx | 640 ++++++++++++++++++ src/views/apps/marketing/games/list/index.tsx | 17 + 8 files changed, 1095 insertions(+), 2 deletions(-) create mode 100644 src/app/[lang]/(dashboard)/(private)/apps/marketing/games/list/page.tsx create mode 100644 src/types/services/game.ts create mode 100644 src/views/apps/marketing/games/list/AddEditGamesDrawer.tsx create mode 100644 src/views/apps/marketing/games/list/GameListTable.tsx create mode 100644 src/views/apps/marketing/games/list/index.tsx diff --git a/src/app/[lang]/(dashboard)/(private)/apps/marketing/games/list/page.tsx b/src/app/[lang]/(dashboard)/(private)/apps/marketing/games/list/page.tsx new file mode 100644 index 0000000..b94c7cf --- /dev/null +++ b/src/app/[lang]/(dashboard)/(private)/apps/marketing/games/list/page.tsx @@ -0,0 +1,7 @@ +import GamesList from '@/views/apps/marketing/games/list' + +const GamePage = () => { + return +} + +export default GamePage diff --git a/src/components/layout/vertical/VerticalMenu.tsx b/src/components/layout/vertical/VerticalMenu.tsx index 86d4623..3434cb1 100644 --- a/src/components/layout/vertical/VerticalMenu.tsx +++ b/src/components/layout/vertical/VerticalMenu.tsx @@ -161,6 +161,9 @@ const VerticalMenu = ({ dictionary, scrollMenu }: Props) => { {dictionary['navigation'].wheel_spin}
+ + {dictionary['navigation'].list} + {dictionary['navigation'].campaign} {dictionary['navigation'].customer_analytics} diff --git a/src/data/dictionaries/en.json b/src/data/dictionaries/en.json index 3a3c7b1..1545ad5 100644 --- a/src/data/dictionaries/en.json +++ b/src/data/dictionaries/en.json @@ -135,6 +135,7 @@ "campaign": "Campaign", "customer_analytics": "Customer Analytics", "voucher": "Voucher", - "tiers_text": "Tiers" + "tiers_text": "Tiers", + "games": "Games" } } diff --git a/src/data/dictionaries/id.json b/src/data/dictionaries/id.json index dd3b696..460e162 100644 --- a/src/data/dictionaries/id.json +++ b/src/data/dictionaries/id.json @@ -135,6 +135,7 @@ "campaign": "Kampanye", "customer_analytics": "Analisis Pelanggan", "voucher": "Vocher", - "tiers_text": "Tiers" + "tiers_text": "Tiers", + "games": "Permaninan" } } diff --git a/src/types/services/game.ts b/src/types/services/game.ts new file mode 100644 index 0000000..c95bf42 --- /dev/null +++ b/src/types/services/game.ts @@ -0,0 +1,17 @@ +export interface Game { + id: string // uuid + name: string + type: string + is_active: boolean + metadata: Record + created_at: string // ISO datetime + updated_at: string // ISO datetime +} + +export interface Games { + data: Game[] + total_count: number + page: number + limit: number + total_pages: number +} diff --git a/src/views/apps/marketing/games/list/AddEditGamesDrawer.tsx b/src/views/apps/marketing/games/list/AddEditGamesDrawer.tsx new file mode 100644 index 0000000..04a2356 --- /dev/null +++ b/src/views/apps/marketing/games/list/AddEditGamesDrawer.tsx @@ -0,0 +1,407 @@ +// React Imports +import { useState, useEffect } from 'react' + +// MUI Imports +import Button from '@mui/material/Button' +import Drawer from '@mui/material/Drawer' +import IconButton from '@mui/material/IconButton' +import MenuItem from '@mui/material/MenuItem' +import Typography from '@mui/material/Typography' +import Divider from '@mui/material/Divider' +import Grid from '@mui/material/Grid2' +import Box from '@mui/material/Box' +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 Avatar from '@mui/material/Avatar' +import Card from '@mui/material/Card' +import CardContent from '@mui/material/CardContent' +import FormHelperText from '@mui/material/FormHelperText' + +// Third-party Imports +import { useForm, Controller } from 'react-hook-form' + +// Component Imports +import CustomTextField from '@core/components/mui/TextField' + +// Types +export interface Game { + id: string // uuid + name: string + type: string + is_active: boolean + metadata: Record + created_at: string // ISO datetime + updated_at: string // ISO datetime +} + +export interface GameRequest { + name: string + type: string + is_active: boolean + metadata: { + imageUrl?: string + } +} + +type Props = { + open: boolean + handleClose: () => void + data?: Game // Game data for edit (if exists) +} + +type FormValidateType = { + name: string + type: string + is_active: boolean + imageUrl: string +} + +// Initial form data +const initialData: FormValidateType = { + name: '', + type: 'quiz', + is_active: true, + imageUrl: '' +} + +// Mock mutation hooks (replace with actual hooks) +const useGameMutation = () => { + const createGame = { + mutate: (data: GameRequest, options?: { onSuccess?: () => void }) => { + console.log('Creating game:', data) + setTimeout(() => options?.onSuccess?.(), 1000) + } + } + + const updateGame = { + mutate: (data: { id: string; payload: GameRequest }, options?: { onSuccess?: () => void }) => { + console.log('Updating game:', data) + setTimeout(() => options?.onSuccess?.(), 1000) + } + } + + return { createGame, updateGame } +} + +// Game types +const GAME_TYPES = [ + { value: 'quiz', label: 'Quiz' }, + { value: 'puzzle', label: 'Puzzle' }, + { value: 'memory', label: 'Memory Game' }, + { value: 'trivia', label: 'Trivia' }, + { value: 'word', label: 'Word Game' }, + { value: 'math', label: 'Math Game' }, + { value: 'arcade', label: 'Arcade' }, + { value: 'strategy', label: 'Strategy' } +] + +// Game categories +const GAME_CATEGORIES = [ + { value: 'trivia', label: 'Trivia' }, + { value: 'educational', label: 'Educational' }, + { value: 'entertainment', label: 'Entertainment' }, + { value: 'brain_training', label: 'Brain Training' }, + { value: 'casual', label: 'Casual' }, + { value: 'competitive', label: 'Competitive' } +] + +// Difficulty levels +const DIFFICULTY_LEVELS = [ + { value: 'easy', label: 'Easy' }, + { value: 'medium', label: 'Medium' }, + { value: 'hard', label: 'Hard' }, + { value: 'expert', label: 'Expert' } +] + +const AddEditGamesDrawer = (props: Props) => { + // Props + const { open, handleClose, data } = props + + // States + const [showMore, setShowMore] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + const [imagePreview, setImagePreview] = useState(null) + + const { createGame, updateGame } = useGameMutation() + + // Determine if this is edit mode + const isEditMode = Boolean(data?.id) + + // Hooks + const { + control, + reset: resetForm, + handleSubmit, + watch, + setValue, + formState: { errors } + } = useForm({ + defaultValues: initialData + }) + + const watchedImageUrl = watch('imageUrl') + + // Effect to populate form when editing + useEffect(() => { + if (isEditMode && data) { + // Extract imageUrl from metadata + const imageUrl = data.metadata?.imageUrl || '' + + // Populate form with existing data + const formData: FormValidateType = { + name: data.name || '', + type: data.type || 'quiz', + is_active: data.is_active ?? true, + imageUrl: imageUrl + } + + resetForm(formData) + setImagePreview(imageUrl || null) + } else { + // Reset to initial data for add mode + resetForm(initialData) + setImagePreview(null) + } + }, [data, isEditMode, resetForm]) + + // Handle image URL change + useEffect(() => { + if (watchedImageUrl) { + setImagePreview(watchedImageUrl) + } else { + setImagePreview(null) + } + }, [watchedImageUrl]) + + // Handle unlimited stock toggle + useEffect(() => { + if (watchedImageUrl) { + setImagePreview(watchedImageUrl) + } else { + setImagePreview(null) + } + }, [watchedImageUrl]) + + const handleFormSubmit = async (formData: FormValidateType) => { + try { + setIsSubmitting(true) + + // Create GameRequest object + const gameRequest: GameRequest = { + name: formData.name, + type: formData.type, + is_active: formData.is_active, + metadata: { + imageUrl: formData.imageUrl || undefined + } + } + + if (isEditMode && data?.id) { + // Update existing game + updateGame.mutate( + { id: data.id, payload: gameRequest }, + { + onSuccess: () => { + handleReset() + handleClose() + } + } + ) + } else { + // Create new game + createGame.mutate(gameRequest, { + onSuccess: () => { + handleReset() + handleClose() + } + }) + } + } catch (error) { + console.error('Error submitting game:', error) + // Handle error (show toast, etc.) + } finally { + setIsSubmitting(false) + } + } + + const handleReset = () => { + handleClose() + resetForm(initialData) + setImagePreview(null) + } + + return ( + + {/* Sticky Header */} + +
+ {isEditMode ? 'Edit Game' : 'Tambah Game Baru'} + + + +
+
+ + {/* Scrollable Content */} + +
+
+ {/* Image Preview */} + {imagePreview && ( + + + + Preview Gambar + + + + + + + )} + + {/* Nama Game */} +
+ + Nama Game * + + ( + + )} + /> +
+ + {/* Tipe Game */} +
+ + Tipe Game * + + ( + + {GAME_TYPES.map(type => ( + + {type.label} + + ))} + + )} + /> +
+ + {/* Image URL */} +
+ + URL Gambar + + ( + + + + ) + }} + /> + )} + /> +
+ + {/* Status Aktif */} +
+ ( + } + label='Game Aktif' + /> + )} + /> +
+
+ +
+ + {/* Sticky Footer */} + +
+ + +
+
+
+ ) +} + +export default AddEditGamesDrawer diff --git a/src/views/apps/marketing/games/list/GameListTable.tsx b/src/views/apps/marketing/games/list/GameListTable.tsx new file mode 100644 index 0000000..de740e8 --- /dev/null +++ b/src/views/apps/marketing/games/list/GameListTable.tsx @@ -0,0 +1,640 @@ +'use client' + +// React Imports +import { useEffect, useState, useMemo, useCallback } from 'react' + +// Next Imports +import Link from 'next/link' +import { useParams } from 'next/navigation' + +// MUI Imports +import Card from '@mui/material/Card' +import CardHeader from '@mui/material/CardHeader' +import Button from '@mui/material/Button' +import Typography from '@mui/material/Typography' +import Chip from '@mui/material/Chip' +import Checkbox from '@mui/material/Checkbox' +import IconButton from '@mui/material/IconButton' +import { styled } from '@mui/material/styles' +import TablePagination from '@mui/material/TablePagination' +import type { TextFieldProps } from '@mui/material/TextField' +import MenuItem from '@mui/material/MenuItem' + +// Third-party Imports +import classnames from 'classnames' +import { rankItem } from '@tanstack/match-sorter-utils' +import { + createColumnHelper, + flexRender, + getCoreRowModel, + useReactTable, + getFilteredRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFacetedMinMaxValues, + getPaginationRowModel, + getSortedRowModel +} from '@tanstack/react-table' +import type { ColumnDef, FilterFn } from '@tanstack/react-table' +import type { RankingInfo } from '@tanstack/match-sorter-utils' + +// Type Imports +import type { ThemeColor } from '@core/types' +import type { Locale } from '@configs/i18n' + +// Component Imports +import OptionMenu from '@core/components/option-menu' +import TablePaginationComponent from '@components/TablePaginationComponent' +import CustomTextField from '@core/components/mui/TextField' +import CustomAvatar from '@core/components/mui/Avatar' + +// Util Imports +import { getInitials } from '@/utils/getInitials' +import { getLocalizedUrl } from '@/utils/i18n' +import { formatCurrency } from '@/utils/transform' + +// Style Imports +import tableStyles from '@core/styles/table.module.css' +import Loading from '@/components/layout/shared/Loading' +import AddEditGamesDrawer from './AddEditGamesDrawer' + +// Game Interface +export interface Game { + id: string // uuid + name: string + type: string + is_active: boolean + metadata: Record + created_at: string // ISO datetime + updated_at: string // ISO datetime +} + +declare module '@tanstack/table-core' { + interface FilterFns { + fuzzy: FilterFn + } + interface FilterMeta { + itemRank: RankingInfo + } +} + +type GameWithAction = Game & { + action?: string +} + +// Styled Components +const Icon = styled('i')({}) + +const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { + // Rank the item + const itemRank = rankItem(row.getValue(columnId), value) + + // Store the itemRank info + addMeta({ + itemRank + }) + + // Return if the item should be filtered in/out + return itemRank.passed +} + +const DebouncedInput = ({ + value: initialValue, + onChange, + debounce = 500, + ...props +}: { + value: string | number + onChange: (value: string | number) => void + debounce?: number +} & Omit) => { + // States + const [value, setValue] = useState(initialValue) + + useEffect(() => { + setValue(initialValue) + }, [initialValue]) + + useEffect(() => { + const timeout = setTimeout(() => { + onChange(value) + }, debounce) + + return () => clearTimeout(timeout) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]) + + return setValue(e.target.value)} /> +} + +// Dummy data for games +const DUMMY_GAME_DATA: Game[] = [ + { + id: '1', + name: 'Quiz Master Challenge', + type: 'quiz', + is_active: true, + metadata: { + imageUrl: 'https://example.com/quiz-master.jpg' + }, + created_at: '2024-01-15T08:30:00Z', + updated_at: '2024-02-10T10:15:00Z' + }, + { + id: '2', + name: 'Memory Palace', + type: 'memory', + is_active: true, + metadata: { + imageUrl: 'https://example.com/memory-palace.jpg' + }, + created_at: '2024-01-20T09:00:00Z', + updated_at: '2024-02-15T11:30:00Z' + }, + { + id: '3', + name: 'Word Wizard', + type: 'word', + is_active: true, + metadata: {}, + created_at: '2024-01-25T14:20:00Z', + updated_at: '2024-02-20T16:45:00Z' + }, + { + id: '4', + name: 'Math Sprint', + type: 'math', + is_active: true, + metadata: {}, + created_at: '2024-02-01T07:15:00Z', + updated_at: '2024-02-25T13:20:00Z' + }, + { + id: '5', + name: 'Puzzle Paradise', + type: 'puzzle', + is_active: true, + metadata: { + imageUrl: 'https://example.com/puzzle-paradise.jpg' + }, + created_at: '2024-02-05T11:40:00Z', + updated_at: '2024-03-01T09:25:00Z' + }, + { + id: '6', + name: 'Trivia Tournament', + type: 'trivia', + is_active: true, + metadata: {}, + created_at: '2024-02-10T16:00:00Z', + updated_at: '2024-03-05T12:10:00Z' + }, + { + id: '7', + name: 'Speed Arcade', + type: 'arcade', + is_active: true, + metadata: { + imageUrl: 'https://example.com/speed-arcade.jpg' + }, + created_at: '2024-02-15T13:30:00Z', + updated_at: '2024-03-10T15:45:00Z' + }, + { + id: '8', + name: 'Strategy Kingdom', + type: 'strategy', + is_active: true, + metadata: {}, + created_at: '2024-03-01T10:20:00Z', + updated_at: '2024-03-15T08:55:00Z' + }, + { + id: '9', + name: 'Quick Quiz', + type: 'quiz', + is_active: false, + metadata: {}, + created_at: '2024-03-05T12:45:00Z', + updated_at: '2024-03-20T14:30:00Z' + }, + { + id: '10', + name: 'Brain Teaser Deluxe', + type: 'puzzle', + is_active: true, + metadata: { + imageUrl: 'https://example.com/brain-teaser.jpg' + }, + created_at: '2024-03-10T09:15:00Z', + updated_at: '2024-03-25T11:40:00Z' + } +] + +// Mock data hook with dummy data +const useGameCatalog = ({ 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_GAME_DATA + + return DUMMY_GAME_DATA.filter( + game => + game.name.toLowerCase().includes(search.toLowerCase()) || + game.type.toLowerCase().includes(search.toLowerCase()) || + game.metadata?.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: { + games: paginatedData, + total_count: filteredData.length + }, + isLoading, + error: null, + isFetching: isLoading + } +} + +// Helper functions +const getGameTypeLabel = (type: string) => { + const typeMap: Record = { + quiz: 'Quiz', + puzzle: 'Puzzle', + memory: 'Memory', + trivia: 'Trivia', + word: 'Word Game', + math: 'Math Game', + arcade: 'Arcade', + strategy: 'Strategy' + } + return typeMap[type] || type +} + +const getDifficultyColor = (difficulty: string): ThemeColor => { + switch (difficulty) { + case 'easy': + return 'success' + case 'medium': + return 'warning' + case 'hard': + return 'error' + case 'expert': + return 'primary' + default: + return 'secondary' + } +} + +const getDifficultyLabel = (difficulty: string) => { + const difficultyMap: Record = { + easy: 'Mudah', + medium: 'Sedang', + hard: 'Sulit', + expert: 'Ahli' + } + return difficultyMap[difficulty] || difficulty +} + +const formatDuration = (seconds: number) => { + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + const remainingSeconds = seconds % 60 + return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m` +} + +// Column Definitions +const columnHelper = createColumnHelper() + +const GameListTable = () => { + // States + const [addGameOpen, setAddGameOpen] = useState(false) + const [editGameData, setEditGameData] = useState(undefined) + const [rowSelection, setRowSelection] = useState({}) + const [globalFilter, setGlobalFilter] = useState('') + const [currentPage, setCurrentPage] = useState(1) + const [pageSize, setPageSize] = useState(10) + const [search, setSearch] = useState('') + + const { data, isLoading, error, isFetching } = useGameCatalog({ + page: currentPage, + limit: pageSize, + search + }) + + const games = data?.games ?? [] + const totalCount = data?.total_count ?? 0 + + // Hooks + const { lang: locale } = useParams() + + const handlePageChange = useCallback((event: unknown, newPage: number) => { + setCurrentPage(newPage) + }, []) + + const handlePageSizeChange = useCallback((event: React.ChangeEvent) => { + const newPageSize = parseInt(event.target.value, 10) + setPageSize(newPageSize) + setCurrentPage(1) // Reset to first page + }, []) + + const handleEditGame = (game: Game) => { + setEditGameData(game) + setAddGameOpen(true) + } + + const handleDeleteGame = (gameId: string) => { + if (confirm('Apakah Anda yakin ingin menghapus game ini?')) { + console.log('Deleting game:', gameId) + // Add your delete logic here + // deleteGame.mutate(gameId) + } + } + + const handleToggleActive = (gameId: string, currentStatus: boolean) => { + console.log('Toggling active status for game:', gameId, !currentStatus) + // Add your toggle logic here + // toggleGameStatus.mutate({ id: gameId, is_active: !currentStatus }) + } + + const handleCloseGameDrawer = () => { + setAddGameOpen(false) + setEditGameData(undefined) + } + + const columns = useMemo[]>( + () => [ + { + id: 'select', + header: ({ table }) => ( + + ), + cell: ({ row }) => ( + + ) + }, + columnHelper.accessor('name', { + header: 'Nama Game', + cell: ({ row }) => ( +
+ + {getInitials(row.original.name)} + +
+ + + {row.original.name} + + + + {getGameTypeLabel(row.original.type)} + +
+
+ ) + }), + columnHelper.accessor('type', { + header: 'Tipe Game', + cell: ({ row }) => ( + + ) + }), + columnHelper.accessor('is_active', { + header: 'Status', + cell: ({ row }) => ( + + ) + }), + columnHelper.accessor('created_at', { + header: 'Tanggal Dibuat', + cell: ({ row }) => ( + + {new Date(row.original.created_at).toLocaleDateString('id-ID', { + year: 'numeric', + month: 'short', + day: 'numeric' + })} + + ) + }), + { + id: 'actions', + header: 'Aksi', + cell: ({ row }) => ( +
+ handleToggleActive(row.original.id, row.original.is_active) + } + }, + { + text: 'Edit', + icon: 'tabler-edit text-[22px]', + menuItemProps: { + className: 'flex items-center gap-2 text-textSecondary', + onClick: () => handleEditGame(row.original) + } + }, + { + text: 'Hapus', + icon: 'tabler-trash text-[22px]', + menuItemProps: { + className: 'flex items-center gap-2 text-textSecondary', + onClick: () => handleDeleteGame(row.original.id) + } + } + ]} + /> +
+ ), + enableSorting: false + } + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [locale, handleEditGame, handleDeleteGame, handleToggleActive] + ) + + const table = useReactTable({ + data: games as Game[], + columns, + filterFns: { + fuzzy: fuzzyFilter + }, + state: { + rowSelection, + globalFilter, + pagination: { + pageIndex: currentPage, + pageSize + } + }, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + pageCount: Math.ceil(totalCount / pageSize) + }) + + return ( + <> + +
+ table.setPageSize(Number(e.target.value))} + className='max-sm:is-full sm:is-[70px]' + > + 10 + 25 + 50 + +
+ setSearch(value as string)} + placeholder='Cari Game' + className='max-sm:is-full' + /> + + +
+
+
+ {isLoading ? ( + + ) : ( + + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + ))} + + ))} + + {table.getFilteredRowModel().rows.length === 0 ? ( + + + + + + ) : ( + + {table + .getRowModel() + .rows.slice(0, table.getState().pagination.pageSize) + .map(row => { + return ( + + {row.getVisibleCells().map(cell => ( + + ))} + + ) + })} + + )} +
+ {header.isPlaceholder ? null : ( + <> +
+ {flexRender(header.column.columnDef.header, header.getContext())} + {{ + asc: , + desc: + }[header.column.getIsSorted() as 'asc' | 'desc'] ?? null} +
+ + )} +
+ Tidak ada data tersedia +
{flexRender(cell.column.columnDef.cell, cell.getContext())}
+ )} +
+ ( + + )} + count={totalCount} + rowsPerPage={pageSize} + page={currentPage} + onPageChange={handlePageChange} + onRowsPerPageChange={handlePageSizeChange} + rowsPerPageOptions={[10, 25, 50]} + disabled={isLoading} + /> +
+ + + ) +} + +export default GameListTable diff --git a/src/views/apps/marketing/games/list/index.tsx b/src/views/apps/marketing/games/list/index.tsx new file mode 100644 index 0000000..5d88053 --- /dev/null +++ b/src/views/apps/marketing/games/list/index.tsx @@ -0,0 +1,17 @@ +// MUI Imports +import Grid from '@mui/material/Grid2' +import GameListTable from './GameListTable' + +// Type Imports + +const GamesList = () => { + return ( + + + + + + ) +} + +export default GamesList -- 2.47.2 From 757d9f524af83393a82fa186a24c44767083df44 Mon Sep 17 00:00:00 2001 From: efrilm Date: Thu, 18 Sep 2025 00:10:19 +0700 Subject: [PATCH 11/18] Game --- src/services/mutations/game.ts | 52 ++++ src/services/queries/game.ts | 46 +++ src/types/services/game.ts | 9 + .../games/list/AddEditGamesDrawer.tsx | 190 ++++-------- .../games/list/DeleteGamesDialog.tsx | 103 +++++++ .../marketing/games/list/GameListTable.tsx | 273 ++++-------------- 6 files changed, 314 insertions(+), 359 deletions(-) create mode 100644 src/services/mutations/game.ts create mode 100644 src/services/queries/game.ts create mode 100644 src/views/apps/marketing/games/list/DeleteGamesDialog.tsx diff --git a/src/services/mutations/game.ts b/src/services/mutations/game.ts new file mode 100644 index 0000000..785f6f6 --- /dev/null +++ b/src/services/mutations/game.ts @@ -0,0 +1,52 @@ +import { GameRequest } from '@/types/services/game' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'react-toastify' +import { api } from '../api' + +export const useGamesMutation = () => { + const queryClient = useQueryClient() + + const createGame = useMutation({ + mutationFn: async (newGame: GameRequest) => { + const response = await api.post('/marketing/games', newGame) + return response.data + }, + onSuccess: () => { + toast.success('Game created successfully!') + queryClient.invalidateQueries({ queryKey: ['games'] }) + }, + onError: (error: any) => { + toast.error(error.response?.data?.errors?.[0]?.cause || 'Create failed') + } + }) + + const updateGame = useMutation({ + mutationFn: async ({ id, payload }: { id: string; payload: GameRequest }) => { + const response = await api.put(`/marketing/games/${id}`, payload) + return response.data + }, + onSuccess: () => { + toast.success('Game updated successfully!') + queryClient.invalidateQueries({ queryKey: ['games'] }) + }, + onError: (error: any) => { + toast.error(error.response?.data?.errors?.[0]?.cause || 'Update failed') + } + }) + + const deleteGame = useMutation({ + mutationFn: async (id: string) => { + const response = await api.delete(`/marketing/games/${id}`) + return response.data + }, + onSuccess: () => { + toast.success('Game deleted successfully!') + queryClient.invalidateQueries({ queryKey: ['games'] }) + }, + onError: (error: any) => { + toast.error(error.response?.data?.errors?.[0]?.cause || 'Delete failed') + } + }) + + return { createGame, updateGame, deleteGame } +} diff --git a/src/services/queries/game.ts b/src/services/queries/game.ts new file mode 100644 index 0000000..cfd1f5b --- /dev/null +++ b/src/services/queries/game.ts @@ -0,0 +1,46 @@ +import { useQuery } from '@tanstack/react-query' +import { api } from '../api' +import { Game, Games } from '@/types/services/game' + +interface GameQueryParams { + page?: number + limit?: number + search?: string +} + +export function useGames(params: GameQueryParams = {}) { + const { page = 1, limit = 10, search = '', ...filters } = params + + return useQuery({ + queryKey: ['games', { 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/games?${queryParams.toString()}`) + return res.data.data + } + }) +} + +export function useGameById(id: string) { + return useQuery({ + queryKey: ['games', id], + queryFn: async () => { + const res = await api.get(`/marketing/games/${id}`) + return res.data.data + } + }) +} diff --git a/src/types/services/game.ts b/src/types/services/game.ts index c95bf42..bd94dba 100644 --- a/src/types/services/game.ts +++ b/src/types/services/game.ts @@ -15,3 +15,12 @@ export interface Games { limit: number total_pages: number } + +export interface GameRequest { + name: string + type: string + is_active: boolean + metadata: { + imageUrl?: string + } +} diff --git a/src/views/apps/marketing/games/list/AddEditGamesDrawer.tsx b/src/views/apps/marketing/games/list/AddEditGamesDrawer.tsx index 04a2356..6678543 100644 --- a/src/views/apps/marketing/games/list/AddEditGamesDrawer.tsx +++ b/src/views/apps/marketing/games/list/AddEditGamesDrawer.tsx @@ -7,43 +7,21 @@ import Drawer from '@mui/material/Drawer' import IconButton from '@mui/material/IconButton' import MenuItem from '@mui/material/MenuItem' import Typography from '@mui/material/Typography' -import Divider from '@mui/material/Divider' -import Grid from '@mui/material/Grid2' import Box from '@mui/material/Box' 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 Avatar from '@mui/material/Avatar' -import Card from '@mui/material/Card' -import CardContent from '@mui/material/CardContent' -import FormHelperText from '@mui/material/FormHelperText' // Third-party Imports import { useForm, Controller } from 'react-hook-form' // Component Imports import CustomTextField from '@core/components/mui/TextField' +import ImageUpload from '@/components/ImageUpload' -// Types -export interface Game { - id: string // uuid - name: string - type: string - is_active: boolean - metadata: Record - created_at: string // ISO datetime - updated_at: string // ISO datetime -} - -export interface GameRequest { - name: string - type: string - is_active: boolean - metadata: { - imageUrl?: string - } -} +// Services +import { useFilesMutation } from '@/services/mutations/files' +import { Game, GameRequest } from '@/types/services/game' +import { useGamesMutation } from '@/services/mutations/game' type Props = { open: boolean @@ -67,52 +45,12 @@ const initialData: FormValidateType = { } // Mock mutation hooks (replace with actual hooks) -const useGameMutation = () => { - const createGame = { - mutate: (data: GameRequest, options?: { onSuccess?: () => void }) => { - console.log('Creating game:', data) - setTimeout(() => options?.onSuccess?.(), 1000) - } - } - - const updateGame = { - mutate: (data: { id: string; payload: GameRequest }, options?: { onSuccess?: () => void }) => { - console.log('Updating game:', data) - setTimeout(() => options?.onSuccess?.(), 1000) - } - } - - return { createGame, updateGame } -} // Game types const GAME_TYPES = [ - { value: 'quiz', label: 'Quiz' }, - { value: 'puzzle', label: 'Puzzle' }, - { value: 'memory', label: 'Memory Game' }, - { value: 'trivia', label: 'Trivia' }, - { value: 'word', label: 'Word Game' }, - { value: 'math', label: 'Math Game' }, - { value: 'arcade', label: 'Arcade' }, - { value: 'strategy', label: 'Strategy' } -] - -// Game categories -const GAME_CATEGORIES = [ - { value: 'trivia', label: 'Trivia' }, - { value: 'educational', label: 'Educational' }, - { value: 'entertainment', label: 'Entertainment' }, - { value: 'brain_training', label: 'Brain Training' }, - { value: 'casual', label: 'Casual' }, - { value: 'competitive', label: 'Competitive' } -] - -// Difficulty levels -const DIFFICULTY_LEVELS = [ - { value: 'easy', label: 'Easy' }, - { value: 'medium', label: 'Medium' }, - { value: 'hard', label: 'Hard' }, - { value: 'expert', label: 'Expert' } + { value: 'SPIN', label: 'Spin' }, + { value: 'RUFFLE', label: 'Ruffle' }, + { value: 'MINIGAME', label: 'Mini Game' } ] const AddEditGamesDrawer = (props: Props) => { @@ -120,15 +58,36 @@ const AddEditGamesDrawer = (props: Props) => { const { open, handleClose, data } = props // States - const [showMore, setShowMore] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) - const [imagePreview, setImagePreview] = useState(null) + const [imageUrl, setImageUrl] = useState('') - const { createGame, updateGame } = useGameMutation() + const { createGame, updateGame } = useGamesMutation() + const { mutate, isPending } = useFilesMutation().uploadFile // Determine if this is edit mode const isEditMode = Boolean(data?.id) + // Handle file upload + const handleUpload = async (file: File): Promise => { + return new Promise((resolve, reject) => { + const formData = new FormData() + formData.append('file', file) + formData.append('file_type', 'image') + formData.append('description', 'game image upload') + + mutate(formData, { + onSuccess: r => { + setImageUrl(r.file_url) + setValue('imageUrl', r.file_url) // Update form value + resolve(r.id) + }, + onError: er => { + reject(er) + } + }) + }) + } + // Hooks const { control, @@ -147,42 +106,31 @@ const AddEditGamesDrawer = (props: Props) => { useEffect(() => { if (isEditMode && data) { // Extract imageUrl from metadata - const imageUrl = data.metadata?.imageUrl || '' + const imageUrlFromData = data.metadata?.imageUrl || '' // Populate form with existing data const formData: FormValidateType = { name: data.name || '', - type: data.type || 'quiz', + type: data.type || 'SPIN', is_active: data.is_active ?? true, - imageUrl: imageUrl + imageUrl: imageUrlFromData } resetForm(formData) - setImagePreview(imageUrl || null) + setImageUrl(imageUrlFromData) } else { // Reset to initial data for add mode resetForm(initialData) - setImagePreview(null) + setImageUrl('') } }, [data, isEditMode, resetForm]) - // Handle image URL change + // Sync imageUrl state with form value useEffect(() => { - if (watchedImageUrl) { - setImagePreview(watchedImageUrl) - } else { - setImagePreview(null) + if (watchedImageUrl !== imageUrl) { + setImageUrl(watchedImageUrl) } - }, [watchedImageUrl]) - - // Handle unlimited stock toggle - useEffect(() => { - if (watchedImageUrl) { - setImagePreview(watchedImageUrl) - } else { - setImagePreview(null) - } - }, [watchedImageUrl]) + }, [watchedImageUrl, imageUrl]) const handleFormSubmit = async (formData: FormValidateType) => { try { @@ -229,7 +177,7 @@ const AddEditGamesDrawer = (props: Props) => { const handleReset = () => { handleClose() resetForm(initialData) - setImagePreview(null) + setImageUrl('') } return ( @@ -271,29 +219,6 @@ const AddEditGamesDrawer = (props: Props) => {
- {/* Image Preview */} - {imagePreview && ( - - - - Preview Gambar - - - - - - - )} - {/* Nama Game */}
@@ -336,29 +261,22 @@ const AddEditGamesDrawer = (props: Props) => { />
- {/* Image URL */} + {/* Image Upload */}
- URL Gambar + Gambar Game + ( - - - - ) - }} - /> - )} + render={({ field }) => } />
@@ -392,10 +310,10 @@ const AddEditGamesDrawer = (props: Props) => { }} >
- -
diff --git a/src/views/apps/marketing/games/list/DeleteGamesDialog.tsx b/src/views/apps/marketing/games/list/DeleteGamesDialog.tsx new file mode 100644 index 0000000..9823195 --- /dev/null +++ b/src/views/apps/marketing/games/list/DeleteGamesDialog.tsx @@ -0,0 +1,103 @@ +// 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 { Game } from '@/types/services/game' + +type Props = { + open: boolean + onClose: () => void + onConfirm: () => void + game: Game | null + isDeleting?: boolean +} + +const DeleteGameDialog = ({ open, onClose, onConfirm, game, isDeleting = false }: Props) => { + if (!game) return null + + return ( + + + + + Hapus Game + + + + + + Apakah Anda yakin ingin menghapus game berikut? + + + + + {game.name} + + + Dibuat:{' '} + {new Date(game.created_at).toLocaleDateString('id-ID', { + year: 'numeric', + month: 'long', + day: 'numeric' + })} + + + + + + Peringatan: Tindakan ini tidak dapat dibatalkan. Semua data yang terkait dengan game ini + akan dihapus secara permanen. + + + + + Pastikan tidak ada pengguna yang masih menggunakan game ini sebelum menghapus. + + + + + + + + + ) +} + +export default DeleteGameDialog diff --git a/src/views/apps/marketing/games/list/GameListTable.tsx b/src/views/apps/marketing/games/list/GameListTable.tsx index de740e8..d34ed4c 100644 --- a/src/views/apps/marketing/games/list/GameListTable.tsx +++ b/src/views/apps/marketing/games/list/GameListTable.tsx @@ -57,17 +57,10 @@ import { formatCurrency } from '@/utils/transform' import tableStyles from '@core/styles/table.module.css' import Loading from '@/components/layout/shared/Loading' import AddEditGamesDrawer from './AddEditGamesDrawer' - -// Game Interface -export interface Game { - id: string // uuid - name: string - type: string - is_active: boolean - metadata: Record - created_at: string // ISO datetime - updated_at: string // ISO datetime -} +import DeleteGameDialog from './DeleteGamesDialog' +import { Game } from '@/types/services/game' +import { useGames } from '@/services/queries/game' +import { useGamesMutation } from '@/services/mutations/game' declare module '@tanstack/table-core' { interface FilterFns { @@ -127,198 +120,6 @@ const DebouncedInput = ({ return setValue(e.target.value)} /> } -// Dummy data for games -const DUMMY_GAME_DATA: Game[] = [ - { - id: '1', - name: 'Quiz Master Challenge', - type: 'quiz', - is_active: true, - metadata: { - imageUrl: 'https://example.com/quiz-master.jpg' - }, - created_at: '2024-01-15T08:30:00Z', - updated_at: '2024-02-10T10:15:00Z' - }, - { - id: '2', - name: 'Memory Palace', - type: 'memory', - is_active: true, - metadata: { - imageUrl: 'https://example.com/memory-palace.jpg' - }, - created_at: '2024-01-20T09:00:00Z', - updated_at: '2024-02-15T11:30:00Z' - }, - { - id: '3', - name: 'Word Wizard', - type: 'word', - is_active: true, - metadata: {}, - created_at: '2024-01-25T14:20:00Z', - updated_at: '2024-02-20T16:45:00Z' - }, - { - id: '4', - name: 'Math Sprint', - type: 'math', - is_active: true, - metadata: {}, - created_at: '2024-02-01T07:15:00Z', - updated_at: '2024-02-25T13:20:00Z' - }, - { - id: '5', - name: 'Puzzle Paradise', - type: 'puzzle', - is_active: true, - metadata: { - imageUrl: 'https://example.com/puzzle-paradise.jpg' - }, - created_at: '2024-02-05T11:40:00Z', - updated_at: '2024-03-01T09:25:00Z' - }, - { - id: '6', - name: 'Trivia Tournament', - type: 'trivia', - is_active: true, - metadata: {}, - created_at: '2024-02-10T16:00:00Z', - updated_at: '2024-03-05T12:10:00Z' - }, - { - id: '7', - name: 'Speed Arcade', - type: 'arcade', - is_active: true, - metadata: { - imageUrl: 'https://example.com/speed-arcade.jpg' - }, - created_at: '2024-02-15T13:30:00Z', - updated_at: '2024-03-10T15:45:00Z' - }, - { - id: '8', - name: 'Strategy Kingdom', - type: 'strategy', - is_active: true, - metadata: {}, - created_at: '2024-03-01T10:20:00Z', - updated_at: '2024-03-15T08:55:00Z' - }, - { - id: '9', - name: 'Quick Quiz', - type: 'quiz', - is_active: false, - metadata: {}, - created_at: '2024-03-05T12:45:00Z', - updated_at: '2024-03-20T14:30:00Z' - }, - { - id: '10', - name: 'Brain Teaser Deluxe', - type: 'puzzle', - is_active: true, - metadata: { - imageUrl: 'https://example.com/brain-teaser.jpg' - }, - created_at: '2024-03-10T09:15:00Z', - updated_at: '2024-03-25T11:40:00Z' - } -] - -// Mock data hook with dummy data -const useGameCatalog = ({ 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_GAME_DATA - - return DUMMY_GAME_DATA.filter( - game => - game.name.toLowerCase().includes(search.toLowerCase()) || - game.type.toLowerCase().includes(search.toLowerCase()) || - game.metadata?.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: { - games: paginatedData, - total_count: filteredData.length - }, - isLoading, - error: null, - isFetching: isLoading - } -} - -// Helper functions -const getGameTypeLabel = (type: string) => { - const typeMap: Record = { - quiz: 'Quiz', - puzzle: 'Puzzle', - memory: 'Memory', - trivia: 'Trivia', - word: 'Word Game', - math: 'Math Game', - arcade: 'Arcade', - strategy: 'Strategy' - } - return typeMap[type] || type -} - -const getDifficultyColor = (difficulty: string): ThemeColor => { - switch (difficulty) { - case 'easy': - return 'success' - case 'medium': - return 'warning' - case 'hard': - return 'error' - case 'expert': - return 'primary' - default: - return 'secondary' - } -} - -const getDifficultyLabel = (difficulty: string) => { - const difficultyMap: Record = { - easy: 'Mudah', - medium: 'Sedang', - hard: 'Sulit', - expert: 'Ahli' - } - return difficultyMap[difficulty] || difficulty -} - -const formatDuration = (seconds: number) => { - if (seconds < 60) return `${seconds}s` - const minutes = Math.floor(seconds / 60) - const remainingSeconds = seconds % 60 - return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m` -} - // Column Definitions const columnHelper = createColumnHelper() @@ -326,19 +127,23 @@ const GameListTable = () => { // States const [addGameOpen, setAddGameOpen] = useState(false) const [editGameData, setEditGameData] = useState(undefined) + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [gameToDelete, setGameToDelete] = useState(null) const [rowSelection, setRowSelection] = useState({}) const [globalFilter, setGlobalFilter] = useState('') const [currentPage, setCurrentPage] = useState(1) const [pageSize, setPageSize] = useState(10) const [search, setSearch] = useState('') - const { data, isLoading, error, isFetching } = useGameCatalog({ + const { data, isLoading, error, isFetching } = useGames({ page: currentPage, limit: pageSize, search }) - const games = data?.games ?? [] + const { deleteGame } = useGamesMutation() + + const games = data?.data ?? [] const totalCount = data?.total_count ?? 0 // Hooks @@ -359,14 +164,35 @@ const GameListTable = () => { setAddGameOpen(true) } - const handleDeleteGame = (gameId: string) => { - if (confirm('Apakah Anda yakin ingin menghapus game ini?')) { - console.log('Deleting game:', gameId) - // Add your delete logic here - // deleteGame.mutate(gameId) + const handleDeleteGame = (game: Game) => { + setGameToDelete(game) + setDeleteDialogOpen(true) + } + + const handleConfirmDelete = () => { + if (gameToDelete) { + deleteGame.mutate(gameToDelete.id, { + onSuccess: () => { + console.log('Game deleted successfully') + setDeleteDialogOpen(false) + setGameToDelete(null) + // You might want to refetch data here + // refetch() + }, + onError: error => { + console.error('Error deleting game:', error) + // Handle error (show toast, etc.) + } + }) } } + const handleCloseDeleteDialog = () => { + if (deleteGame.isPending) return // Prevent closing while deleting + setDeleteDialogOpen(false) + setGameToDelete(null) + } + const handleToggleActive = (gameId: string, currentStatus: boolean) => { console.log('Toggling active status for game:', gameId, !currentStatus) // Add your toggle logic here @@ -416,7 +242,7 @@ const GameListTable = () => { - {getGameTypeLabel(row.original.type)} + {row.original.type}
@@ -424,9 +250,7 @@ const GameListTable = () => { }), columnHelper.accessor('type', { header: 'Tipe Game', - cell: ({ row }) => ( - - ) + cell: ({ row }) => }), columnHelper.accessor('is_active', { header: 'Status', @@ -460,14 +284,6 @@ const GameListTable = () => { iconButtonProps={{ size: 'medium' }} iconClassName='text-textSecondary text-[22px]' options={[ - { - text: row.original.is_active ? 'Nonaktifkan' : 'Aktifkan', - icon: row.original.is_active ? '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.is_active) - } - }, { text: 'Edit', icon: 'tabler-edit text-[22px]', @@ -481,7 +297,7 @@ const GameListTable = () => { icon: 'tabler-trash text-[22px]', menuItemProps: { className: 'flex items-center gap-2 text-textSecondary', - onClick: () => handleDeleteGame(row.original.id) + onClick: () => handleDeleteGame(row.original) } } ]} @@ -632,7 +448,18 @@ const GameListTable = () => { disabled={isLoading} /> + + {/* Add/Edit Game Drawer */} + + {/* Delete Game Dialog */} + ) } -- 2.47.2 From 7252c05569d534edc05fde7a58d47a523aeb72b2 Mon Sep 17 00:00:00 2001 From: efrilm Date: Thu, 18 Sep 2025 01:07:13 +0700 Subject: [PATCH 12/18] Game Prize --- .../apps/marketing/games/game-prizes/page.tsx | 7 + .../layout/vertical/VerticalMenu.tsx | 3 + src/data/dictionaries/en.json | 3 +- src/data/dictionaries/id.json | 3 +- src/services/mutations/gamePrize.ts | 52 ++ src/services/queries/gamePrize.ts | 46 ++ src/types/services/gamePrize.ts | 35 ++ .../game-prizes/AddEditGamePrizeDrawer.tsx | 430 ++++++++++++++++ .../game-prizes/DeleteGamePrizeDialog.tsx | 104 ++++ .../games/game-prizes/GamePrizeListTable.tsx | 483 ++++++++++++++++++ .../marketing/games/game-prizes/index.tsx | 17 + 11 files changed, 1181 insertions(+), 2 deletions(-) create mode 100644 src/app/[lang]/(dashboard)/(private)/apps/marketing/games/game-prizes/page.tsx create mode 100644 src/services/mutations/gamePrize.ts create mode 100644 src/services/queries/gamePrize.ts create mode 100644 src/types/services/gamePrize.ts create mode 100644 src/views/apps/marketing/games/game-prizes/AddEditGamePrizeDrawer.tsx create mode 100644 src/views/apps/marketing/games/game-prizes/DeleteGamePrizeDialog.tsx create mode 100644 src/views/apps/marketing/games/game-prizes/GamePrizeListTable.tsx create mode 100644 src/views/apps/marketing/games/game-prizes/index.tsx diff --git a/src/app/[lang]/(dashboard)/(private)/apps/marketing/games/game-prizes/page.tsx b/src/app/[lang]/(dashboard)/(private)/apps/marketing/games/game-prizes/page.tsx new file mode 100644 index 0000000..2432bf9 --- /dev/null +++ b/src/app/[lang]/(dashboard)/(private)/apps/marketing/games/game-prizes/page.tsx @@ -0,0 +1,7 @@ +import GamePrizeList from '@/views/apps/marketing/games/game-prizes' + +const GamePrizePage = () => { + return +} + +export default GamePrizePage diff --git a/src/components/layout/vertical/VerticalMenu.tsx b/src/components/layout/vertical/VerticalMenu.tsx index 3434cb1..476a452 100644 --- a/src/components/layout/vertical/VerticalMenu.tsx +++ b/src/components/layout/vertical/VerticalMenu.tsx @@ -163,6 +163,9 @@ const VerticalMenu = ({ dictionary, scrollMenu }: Props) => { {dictionary['navigation'].list} + + {dictionary['navigation'].game_prizes} + {dictionary['navigation'].campaign} diff --git a/src/data/dictionaries/en.json b/src/data/dictionaries/en.json index 1545ad5..2a15cd5 100644 --- a/src/data/dictionaries/en.json +++ b/src/data/dictionaries/en.json @@ -136,6 +136,7 @@ "customer_analytics": "Customer Analytics", "voucher": "Voucher", "tiers_text": "Tiers", - "games": "Games" + "games": "Games", + "game_prizes": "Game Prizes" } } diff --git a/src/data/dictionaries/id.json b/src/data/dictionaries/id.json index 460e162..f23fb6b 100644 --- a/src/data/dictionaries/id.json +++ b/src/data/dictionaries/id.json @@ -136,6 +136,7 @@ "customer_analytics": "Analisis Pelanggan", "voucher": "Vocher", "tiers_text": "Tiers", - "games": "Permaninan" + "games": "Permainan", + "game_prizes": "Hadiah Permainan" } } diff --git a/src/services/mutations/gamePrize.ts b/src/services/mutations/gamePrize.ts new file mode 100644 index 0000000..2aac02a --- /dev/null +++ b/src/services/mutations/gamePrize.ts @@ -0,0 +1,52 @@ +import { GamePrizeRequest } from '@/types/services/gamePrize' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'react-toastify' +import { api } from '../api' + +export const useGamePrizesMutation = () => { + const queryClient = useQueryClient() + + const createGamePrize = useMutation({ + mutationFn: async (newGamePrize: GamePrizeRequest) => { + const response = await api.post('/marketing/game-prizes', newGamePrize) + return response.data + }, + onSuccess: () => { + toast.success('GamePrize created successfully!') + queryClient.invalidateQueries({ queryKey: ['gamePrizes'] }) + }, + onError: (error: any) => { + toast.error(error.response?.data?.errors?.[0]?.cause || 'Create failed') + } + }) + + const updateGamePrize = useMutation({ + mutationFn: async ({ id, payload }: { id: string; payload: GamePrizeRequest }) => { + const response = await api.put(`/marketing/game-prizes/${id}`, payload) + return response.data + }, + onSuccess: () => { + toast.success('GamePrize updated successfully!') + queryClient.invalidateQueries({ queryKey: ['gamePrizes'] }) + }, + onError: (error: any) => { + toast.error(error.response?.data?.errors?.[0]?.cause || 'Update failed') + } + }) + + const deleteGamePrize = useMutation({ + mutationFn: async (id: string) => { + const response = await api.delete(`/marketing/game-prizes/${id}`) + return response.data + }, + onSuccess: () => { + toast.success('GamePrize deleted successfully!') + queryClient.invalidateQueries({ queryKey: ['gamePrizes'] }) + }, + onError: (error: any) => { + toast.error(error.response?.data?.errors?.[0]?.cause || 'Delete failed') + } + }) + + return { createGamePrize, updateGamePrize, deleteGamePrize } +} diff --git a/src/services/queries/gamePrize.ts b/src/services/queries/gamePrize.ts new file mode 100644 index 0000000..5712fcc --- /dev/null +++ b/src/services/queries/gamePrize.ts @@ -0,0 +1,46 @@ +import { useQuery } from '@tanstack/react-query' +import { api } from '../api' +import { GamePrize, GamePrizes } from '@/types/services/gamePrize' + +interface GamePrizeQueryParams { + page?: number + limit?: number + search?: string +} + +export function useGamePrizes(params: GamePrizeQueryParams = {}) { + const { page = 1, limit = 10, search = '', ...filters } = params + + return useQuery({ + queryKey: ['gamePrizes', { 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/game-prizes?${queryParams.toString()}`) + return res.data.data + } + }) +} + +export function useGamePrizeById(id: string) { + return useQuery({ + queryKey: ['gamePrizes', id], + queryFn: async () => { + const res = await api.get(`/marketing/game-prizes/${id}`) + return res.data.data + } + }) +} diff --git a/src/types/services/gamePrize.ts b/src/types/services/gamePrize.ts new file mode 100644 index 0000000..21259f3 --- /dev/null +++ b/src/types/services/gamePrize.ts @@ -0,0 +1,35 @@ +import { Game } from './game' + +export interface GamePrize { + id: string // uuid + game_id: string // uuid + name: string + weight: number + stock: number + max_stock?: number + threshold?: number // int64 → number + fallback_prize_id?: string // uuid + metadata: Record + game?: Game // relasi ke GameResponse + fallback_prize?: GamePrize // self reference + created_at: string // ISO datetime + updated_at: string // ISO datetime +} + +export interface GamePrizes { + data: GamePrize[] + total_count: number + page: number + limit: number + total_pages: number +} + +export interface GamePrizeRequest { + game_id: string // uuid + name: string + weight: number // min 1 + stock: number // min 0 + max_stock?: number // optional + threshold?: number // optional (int64 → number in TS) + fallback_prize_id?: string // optional uuid +} diff --git a/src/views/apps/marketing/games/game-prizes/AddEditGamePrizeDrawer.tsx b/src/views/apps/marketing/games/game-prizes/AddEditGamePrizeDrawer.tsx new file mode 100644 index 0000000..7bbba2a --- /dev/null +++ b/src/views/apps/marketing/games/game-prizes/AddEditGamePrizeDrawer.tsx @@ -0,0 +1,430 @@ +// React Imports +import { useState, useEffect } from 'react' + +// MUI Imports +import Button from '@mui/material/Button' +import Drawer from '@mui/material/Drawer' +import IconButton from '@mui/material/IconButton' +import Typography from '@mui/material/Typography' +import Box from '@mui/material/Box' +import Switch from '@mui/material/Switch' +import FormControlLabel from '@mui/material/FormControlLabel' +import InputAdornment from '@mui/material/InputAdornment' + +// Third-party Imports +import { useForm, Controller } from 'react-hook-form' + +// Component Imports +import CustomTextField from '@core/components/mui/TextField' +import CustomAutocomplete from '@core/components/mui/Autocomplete' + +// Services +import { useGamePrizesMutation } from '@/services/mutations/gamePrize' +import { useGames } from '@/services/queries/game' +import { GamePrize, GamePrizeRequest } from '@/types/services/gamePrize' +import { useGamePrizes } from '@/services/queries/gamePrize' + +type Props = { + open: boolean + handleClose: () => void + data?: GamePrize // GamePrize data for edit (if exists) +} + +type FormValidateType = { + game_id: string + name: string + weight: number + stock: number + max_stock?: number + threshold?: number + fallback_prize_id?: string +} + +// Initial form data +const initialData: FormValidateType = { + game_id: '', + name: '', + weight: 1, + stock: 0, + max_stock: 0, + threshold: 0, + fallback_prize_id: undefined +} + +const AddEditGamePrizeDrawer = (props: Props) => { + // Props + const { open, handleClose, data } = props + + // States + const [isSubmitting, setIsSubmitting] = useState(false) + + // Queries + const { data: gamesData } = useGames({ page: 1, limit: 100, search: '' }) + const { data: gamePrizesData } = useGamePrizes({ page: 1, limit: 100, search: '' }) + + // Mutations + const { createGamePrize, updateGamePrize } = useGamePrizesMutation() + + // Determine if this is edit mode + const isEditMode = Boolean(data?.id) + + // Get available games and fallback prizes + const games = gamesData?.data ?? [] + const availablePrizes = gamePrizesData?.data?.filter(prize => prize.id !== data?.id) ?? [] + + // Hooks + const { + control, + reset: resetForm, + handleSubmit, + watch, + formState: { errors } + } = useForm({ + defaultValues: initialData + }) + + const watchedMaxStock = watch('max_stock') + + // Effect to populate form when editing + useEffect(() => { + if (isEditMode && data) { + // Populate form with existing data + const formData: FormValidateType = { + game_id: data.game_id || '', + name: data.name || '', + weight: data.weight || 1, + stock: data.stock || 0, + max_stock: data.max_stock, + threshold: data.threshold, + fallback_prize_id: data.fallback_prize_id + } + + resetForm(formData) + } else { + // Reset to initial data for add mode + resetForm(initialData) + } + }, [data, isEditMode, resetForm]) + + const handleFormSubmit = async (formData: FormValidateType) => { + try { + setIsSubmitting(true) + + // Create GamePrizeRequest object + const gamePrizeRequest: GamePrizeRequest = { + game_id: formData.game_id, + name: formData.name, + weight: typeof formData.weight === 'string' ? parseFloat(formData.weight) : formData.weight || 1, + stock: typeof formData.stock === 'string' ? parseInt(formData.stock, 10) : formData.stock || 0, + max_stock: formData.max_stock + ? typeof formData.max_stock === 'string' + ? parseInt(formData.max_stock, 10) + : formData.max_stock + : undefined, + threshold: formData.threshold + ? typeof formData.threshold === 'string' + ? parseInt(formData.threshold, 10) + : formData.threshold + : undefined, + fallback_prize_id: formData.fallback_prize_id || undefined + } + + if (isEditMode && data?.id) { + // Update existing game prize + updateGamePrize.mutate( + { id: data.id, payload: gamePrizeRequest }, + { + onSuccess: () => { + handleReset() + handleClose() + } + } + ) + } else { + // Create new game prize + createGamePrize.mutate(gamePrizeRequest, { + onSuccess: () => { + handleReset() + handleClose() + } + }) + } + } catch (error) { + console.error('Error submitting game prize:', error) + // Handle error (show toast, etc.) + } finally { + setIsSubmitting(false) + } + } + + const handleReset = () => { + handleClose() + resetForm(initialData) + } + + return ( + + {/* Sticky Header */} + +
+ {isEditMode ? 'Edit Hadiah Game' : 'Tambah Hadiah Game Baru'} + + + +
+
+ + {/* Scrollable Content */} + + +
+ {/* Game Selection */} +
+ + Pilih Game * + + ( + `${option.name} (${option.type})`} + value={games.find(game => game.id === value) || null} + onChange={(_, newValue) => onChange(newValue?.id || '')} + disabled={isEditMode} + renderInput={params => ( + + )} + isOptionEqualToValue={(option, value) => option.id === value?.id} + noOptionsText='Tidak ada game tersedia' + /> + )} + /> +
+ + {/* Nama Hadiah */} +
+ + Nama Hadiah * + + ( + + )} + /> +
+ + {/* Bobot */} +
+ + Bobot * + + ( + % + }} + inputProps={{ min: 1, max: 100 }} + /> + )} + /> +
+ + {/* Stock */} +
+ + Stok * + + { + if (watchedMaxStock && value > watchedMaxStock) { + return 'Stok tidak boleh melebihi maksimal stok' + } + return true + } + }} + render={({ field }) => ( + + )} + /> +
+ + {/* Max Stock */} +
+ + Maksimal Stok + + ( + + )} + /> +
+ + {/* Threshold */} +
+ + Threshold + + ( + + )} + /> +
+ + {/* Fallback Prize */} +
+ + Hadiah Cadangan + + ( + `${option.name} (Stok: ${option.stock})`} + value={availablePrizes.find(prize => prize.id === value) || null} + onChange={(_, newValue) => onChange(newValue?.id || '')} + renderInput={params => ( + + )} + isOptionEqualToValue={(option, value) => option.id === value?.id} + noOptionsText='Tidak ada hadiah tersedia' + clearText='Hapus' + /> + )} + /> +
+
+ +
+ + {/* Sticky Footer */} + +
+ + +
+
+
+ ) +} + +export default AddEditGamePrizeDrawer diff --git a/src/views/apps/marketing/games/game-prizes/DeleteGamePrizeDialog.tsx b/src/views/apps/marketing/games/game-prizes/DeleteGamePrizeDialog.tsx new file mode 100644 index 0000000..f0ba08e --- /dev/null +++ b/src/views/apps/marketing/games/game-prizes/DeleteGamePrizeDialog.tsx @@ -0,0 +1,104 @@ +// 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 { GamePrize } from '@/types/services/gamePrize' +import { useGamePrizes } from '@/services/queries/gamePrize' + +// Types + +type Props = { + open: boolean + onClose: () => void + onConfirm: () => void + gamePrize: GamePrize | null + isDeleting?: boolean +} + +const DeleteGamePrizeDialog = ({ open, onClose, onConfirm, gamePrize, isDeleting = false }: Props) => { + if (!gamePrize) return null + + return ( + + + + + Hapus Game + + + + + + Apakah Anda yakin ingin menghapus game berikut? + + + + + {gamePrize.name} + + + Dibuat:{' '} + {new Date(gamePrize.created_at).toLocaleDateString('id-ID', { + year: 'numeric', + month: 'long', + day: 'numeric' + })} + + + + + + Peringatan: Tindakan ini tidak dapat dibatalkan. Semua data yang terkait dengan game ini + akan dihapus secara permanen. + + + + + Pastikan tidak ada pengguna yang masih menggunakan game ini sebelum menghapus. + + + + + + + + + ) +} + +export default DeleteGamePrizeDialog diff --git a/src/views/apps/marketing/games/game-prizes/GamePrizeListTable.tsx b/src/views/apps/marketing/games/game-prizes/GamePrizeListTable.tsx new file mode 100644 index 0000000..2e4bfbe --- /dev/null +++ b/src/views/apps/marketing/games/game-prizes/GamePrizeListTable.tsx @@ -0,0 +1,483 @@ +'use client' + +// React Imports +import { useEffect, useState, useMemo, useCallback } from 'react' + +// Next Imports +import Link from 'next/link' +import { useParams } from 'next/navigation' + +// MUI Imports +import Card from '@mui/material/Card' +import CardHeader from '@mui/material/CardHeader' +import Button from '@mui/material/Button' +import Typography from '@mui/material/Typography' +import Chip from '@mui/material/Chip' +import Checkbox from '@mui/material/Checkbox' +import IconButton from '@mui/material/IconButton' +import { styled } from '@mui/material/styles' +import TablePagination from '@mui/material/TablePagination' +import type { TextFieldProps } from '@mui/material/TextField' +import MenuItem from '@mui/material/MenuItem' + +// Third-party Imports +import classnames from 'classnames' +import { rankItem } from '@tanstack/match-sorter-utils' +import { + createColumnHelper, + flexRender, + getCoreRowModel, + useReactTable, + getFilteredRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFacetedMinMaxValues, + getPaginationRowModel, + getSortedRowModel +} from '@tanstack/react-table' +import type { ColumnDef, FilterFn } from '@tanstack/react-table' +import type { RankingInfo } from '@tanstack/match-sorter-utils' + +// Type Imports +import type { ThemeColor } from '@core/types' +import type { Locale } from '@configs/i18n' + +// Component Imports +import OptionMenu from '@core/components/option-menu' +import TablePaginationComponent from '@components/TablePaginationComponent' +import CustomTextField from '@core/components/mui/TextField' +import CustomAvatar from '@core/components/mui/Avatar' + +// Util Imports +import { getInitials } from '@/utils/getInitials' +import { getLocalizedUrl } from '@/utils/i18n' +import { formatCurrency } from '@/utils/transform' + +// Style Imports +import tableStyles from '@core/styles/table.module.css' +import Loading from '@/components/layout/shared/Loading' +import AddEditGamePrizeDrawer from './AddEditGamePrizeDrawer' +import DeleteGamePrizeDialog from './DeleteGamePrizeDialog' +import { useGamePrizes } from '@/services/queries/gamePrize' +import { useGamePrizesMutation } from '@/services/mutations/gamePrize' +import { GamePrize } from '@/types/services/gamePrize' + +declare module '@tanstack/table-core' { + interface FilterFns { + fuzzy: FilterFn + } + interface FilterMeta { + itemRank: RankingInfo + } +} + +type GamePrizeWithAction = GamePrize & { + action?: string +} + +// Styled Components +const Icon = styled('i')({}) + +const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { + // Rank the item + const itemRank = rankItem(row.getValue(columnId), value) + + // Store the itemRank info + addMeta({ + itemRank + }) + + // Return if the item should be filtered in/out + return itemRank.passed +} + +const DebouncedInput = ({ + value: initialValue, + onChange, + debounce = 500, + ...props +}: { + value: string | number + onChange: (value: string | number) => void + debounce?: number +} & Omit) => { + // States + const [value, setValue] = useState(initialValue) + + useEffect(() => { + setValue(initialValue) + }, [initialValue]) + + useEffect(() => { + const timeout = setTimeout(() => { + onChange(value) + }, debounce) + + return () => clearTimeout(timeout) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]) + + return setValue(e.target.value)} /> +} + +// Column Definitions +const columnHelper = createColumnHelper() + +const GamePrizeListTable = () => { + // States + const [addGamePrizeOpen, setAddGamePrizeOpen] = useState(false) + const [editGamePrizeData, setEditGamePrizeData] = useState(undefined) + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [gamePrizeToDelete, setGamePrizeToDelete] = useState(null) + const [rowSelection, setRowSelection] = useState({}) + const [globalFilter, setGlobalFilter] = useState('') + const [currentPage, setCurrentPage] = useState(1) + const [pageSize, setPageSize] = useState(10) + const [search, setSearch] = useState('') + + // Replace with actual hook for game prizes + const { data, isLoading, error, isFetching } = useGamePrizes({ + page: currentPage, + limit: pageSize, + search + }) + + const { deleteGamePrize } = useGamePrizesMutation() + + const gamePrizes = data?.data ?? [] + const totalCount = data?.total_count ?? 0 + + // Hooks + const { lang: locale } = useParams() + + const handlePageChange = useCallback((event: unknown, newPage: number) => { + setCurrentPage(newPage) + }, []) + + const handlePageSizeChange = useCallback((event: React.ChangeEvent) => { + const newPageSize = parseInt(event.target.value, 10) + setPageSize(newPageSize) + setCurrentPage(1) // Reset to first page + }, []) + + const handleEditGamePrize = (gamePrize: GamePrize) => { + setEditGamePrizeData(gamePrize) + setAddGamePrizeOpen(true) + } + + const handleDeleteGamePrize = (gamePrize: GamePrize) => { + setGamePrizeToDelete(gamePrize) + setDeleteDialogOpen(true) + } + + const handleConfirmDelete = () => { + if (gamePrizeToDelete) { + deleteGamePrize.mutate(gamePrizeToDelete.id, { + onSuccess: () => { + console.log('Game Prize deleted successfully') + setDeleteDialogOpen(false) + setGamePrizeToDelete(null) + }, + onError: error => { + console.error('Error deleting game prize:', error) + } + }) + } + } + + const handleCloseDeleteDialog = () => { + if (deleteGamePrize.isPending) return + setDeleteDialogOpen(false) + setGamePrizeToDelete(null) + } + + const handleCloseGamePrizeDrawer = () => { + setAddGamePrizeOpen(false) + setEditGamePrizeData(undefined) + } + + const columns = useMemo[]>( + () => [ + { + id: 'select', + header: ({ table }) => ( + + ), + cell: ({ row }) => ( + + ) + }, + columnHelper.accessor('name', { + header: 'Nama Hadiah', + cell: ({ row }) => ( +
+ + {getInitials(row.original.name)} + +
+ + + {row.original.name} + + + + {row.original.game?.name || 'Game tidak tersedia'} + +
+
+ ) + }), + columnHelper.accessor('game', { + header: 'Game', + cell: ({ row }) => ( +
+ {row.original.game?.name || 'N/A'} + + {row.original.game?.type || ''} + +
+ ) + }), + columnHelper.accessor('weight', { + header: 'Bobot', + cell: ({ row }) => {row.original.weight}% + }), + columnHelper.accessor('stock', { + header: 'Stok', + cell: ({ row }) => ( +
+ + {row.original.stock} + {row.original.max_stock && ` / ${row.original.max_stock}`} + +
+ ) + }), + columnHelper.accessor('threshold', { + header: 'Threshold', + cell: ({ row }) => {row.original.threshold || 'Tidak Ada'} + }), + columnHelper.accessor('fallback_prize', { + header: 'Hadiah Cadangan', + cell: ({ row }) => ( + {row.original.fallback_prize?.name || 'Tidak Ada'} + ) + }), + columnHelper.accessor('created_at', { + header: 'Tanggal Dibuat', + cell: ({ row }) => ( + + {new Date(row.original.created_at).toLocaleDateString('id-ID', { + year: 'numeric', + month: 'short', + day: 'numeric' + })} + + ) + }), + { + id: 'actions', + header: 'Aksi', + cell: ({ row }) => ( +
+ handleEditGamePrize(row.original) + } + }, + { + text: 'Hapus', + icon: 'tabler-trash text-[22px]', + menuItemProps: { + className: 'flex items-center gap-2 text-textSecondary', + onClick: () => handleDeleteGamePrize(row.original) + } + } + ]} + /> +
+ ), + enableSorting: false + } + ], + [locale, handleEditGamePrize, handleDeleteGamePrize] + ) + + const table = useReactTable({ + data: gamePrizes as GamePrize[], + columns, + filterFns: { + fuzzy: fuzzyFilter + }, + state: { + rowSelection, + globalFilter, + pagination: { + pageIndex: currentPage, + pageSize + } + }, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + pageCount: Math.ceil(totalCount / pageSize) + }) + + return ( + <> + +
+ table.setPageSize(Number(e.target.value))} + className='max-sm:is-full sm:is-[70px]' + > + 10 + 25 + 50 + +
+ setSearch(value as string)} + placeholder='Cari Hadiah Game' + className='max-sm:is-full' + /> + + +
+
+
+ {isLoading ? ( + + ) : ( + + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + ))} + + ))} + + {table.getFilteredRowModel().rows.length === 0 ? ( + + + + + + ) : ( + + {table + .getRowModel() + .rows.slice(0, table.getState().pagination.pageSize) + .map(row => { + return ( + + {row.getVisibleCells().map(cell => ( + + ))} + + ) + })} + + )} +
+ {header.isPlaceholder ? null : ( + <> +
+ {flexRender(header.column.columnDef.header, header.getContext())} + {{ + asc: , + desc: + }[header.column.getIsSorted() as 'asc' | 'desc'] ?? null} +
+ + )} +
+ Tidak ada data tersedia +
{flexRender(cell.column.columnDef.cell, cell.getContext())}
+ )} +
+ ( + + )} + count={totalCount} + rowsPerPage={pageSize} + page={currentPage} + onPageChange={handlePageChange} + onRowsPerPageChange={handlePageSizeChange} + rowsPerPageOptions={[10, 25, 50]} + disabled={isLoading} + /> +
+ + {/* Add/Edit Game Prize Drawer */} + + + {/* Delete Game Prize Dialog */} + + + ) +} + +export default GamePrizeListTable diff --git a/src/views/apps/marketing/games/game-prizes/index.tsx b/src/views/apps/marketing/games/game-prizes/index.tsx new file mode 100644 index 0000000..7eb0440 --- /dev/null +++ b/src/views/apps/marketing/games/game-prizes/index.tsx @@ -0,0 +1,17 @@ +// MUI Imports +import Grid from '@mui/material/Grid2' +import GamePrizeListTable from './GamePrizeListTable' + +// Type Imports + +const GamePrizeList = () => { + return ( + + + + + + ) +} + +export default GamePrizeList -- 2.47.2 From 6d2e436b097f9db21fded4660fb254a5ae268064 Mon Sep 17 00:00:00 2001 From: efrilm Date: Thu, 18 Sep 2025 01:07:39 +0700 Subject: [PATCH 13/18] Game Prize --- src/types/services/gamePrize.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/types/services/gamePrize.ts b/src/types/services/gamePrize.ts index 21259f3..44602f0 100644 --- a/src/types/services/gamePrize.ts +++ b/src/types/services/gamePrize.ts @@ -33,3 +33,13 @@ export interface GamePrizeRequest { threshold?: number // optional (int64 → number in TS) fallback_prize_id?: string // optional uuid } + +export interface GamePrizeRequest { + game_id: string // uuid + name: string + weight: number // min 1 + stock: number // min 0 + max_stock?: number // optional + threshold?: number // optional (int64 → number in TS) + fallback_prize_id?: string // optional uuid +} -- 2.47.2 From 3a56e56c69491b3df0398f40044b9ebfed910b28 Mon Sep 17 00:00:00 2001 From: efrilm Date: Thu, 18 Sep 2025 01:21:51 +0700 Subject: [PATCH 14/18] Update Tier Drawer --- .../apps/marketing/tier/AddTierDrawer.tsx | 352 +++++++++--------- 1 file changed, 179 insertions(+), 173 deletions(-) diff --git a/src/views/apps/marketing/tier/AddTierDrawer.tsx b/src/views/apps/marketing/tier/AddTierDrawer.tsx index 8049c83..7fb2059 100644 --- a/src/views/apps/marketing/tier/AddTierDrawer.tsx +++ b/src/views/apps/marketing/tier/AddTierDrawer.tsx @@ -32,9 +32,40 @@ type Props = { data?: Tier // Data tier untuk edit (jika ada) } +// Static benefit keys with their configurations +const STATIC_BENEFIT_KEYS = { + birthday_bonus: { + label: 'Birthday Bonus', + type: 'boolean' as const, + description: 'Bonus ulang tahun khusus member' + }, + exclusive_discounts: { + label: 'Exclusive Discounts', + type: 'boolean' as const, + description: 'Akses diskon eksklusif' + }, + point_multiplier: { + label: 'Point Multiplier', + type: 'number' as const, + description: 'Pengali poin (contoh: 1.1 = +10%)', + suffix: 'x' + }, + priority_support: { + label: 'Priority Support', + type: 'boolean' as const, + description: 'Dukungan pelanggan prioritas' + }, + special_discount: { + label: 'Special Discount', + type: 'number' as const, + description: 'Diskon khusus dalam persen', + suffix: '%' + } +} as const + // Benefit item type type BenefitItem = { - key: string + key: keyof typeof STATIC_BENEFIT_KEYS value: any type: 'boolean' | 'number' | 'string' } @@ -43,19 +74,13 @@ type FormValidateType = { name: string min_points: number benefits: BenefitItem[] - newBenefitKey: string - newBenefitValue: string - newBenefitType: 'boolean' | 'number' | 'string' } // Initial form data const initialData: FormValidateType = { name: '', min_points: 0, - benefits: [], - newBenefitKey: '', - newBenefitValue: '', - newBenefitType: 'boolean' + benefits: [] } const AddEditTierDrawer = (props: Props) => { @@ -84,18 +109,17 @@ const AddEditTierDrawer = (props: Props) => { }) const watchedBenefits = watch('benefits') - const watchedNewBenefitKey = watch('newBenefitKey') - const watchedNewBenefitValue = watch('newBenefitValue') - const watchedNewBenefitType = watch('newBenefitType') // Helper function to convert benefits object to BenefitItem array const convertBenefitsToArray = (benefits: Record): BenefitItem[] => { if (!benefits) return [] - return Object.entries(benefits).map(([key, value]) => ({ - key, - value, - type: typeof value === 'boolean' ? 'boolean' : typeof value === 'number' ? 'number' : 'string' - })) + return Object.entries(benefits) + .filter(([key]) => key in STATIC_BENEFIT_KEYS) + .map(([key, value]) => ({ + key: key as keyof typeof STATIC_BENEFIT_KEYS, + value, + type: STATIC_BENEFIT_KEYS[key as keyof typeof STATIC_BENEFIT_KEYS].type + })) } // Helper function to convert BenefitItem array to benefits object @@ -116,19 +140,26 @@ const AddEditTierDrawer = (props: Props) => { // Helper function to format benefit display const formatBenefitDisplay = (item: BenefitItem): string => { - const readableKey = item.key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) + const config = STATIC_BENEFIT_KEYS[item.key] if (item.type === 'boolean') { - return `${readableKey}: ${item.value ? 'Ya' : 'Tidak'}` + return `${config.label}: ${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}` + const suffix = config.suffix || '' + return `${config.label}: ${item.value}${suffix}` } - return `${readableKey}: ${item.value}` + return `${config.label}: ${item.value}` + } + + // Get available benefit keys (not already added) + const getAvailableBenefitKeys = () => { + const usedKeys = watchedBenefits?.map(b => b.key) || [] + return Object.entries(STATIC_BENEFIT_KEYS) + .filter(([key]) => !usedKeys.includes(key as keyof typeof STATIC_BENEFIT_KEYS)) + .map(([key, config]) => ({ + key: key as keyof typeof STATIC_BENEFIT_KEYS, + ...config + })) } // Effect to populate form when editing @@ -141,10 +172,7 @@ const AddEditTierDrawer = (props: Props) => { const formData: FormValidateType = { name: data.name || '', min_points: data.min_points || 0, - benefits: benefitsArray, - newBenefitKey: '', - newBenefitValue: '', - newBenefitType: 'boolean' + benefits: benefitsArray } resetForm(formData) @@ -156,57 +184,6 @@ const AddEditTierDrawer = (props: Props) => { } }, [data, isEditMode, resetForm]) - const handleAddBenefit = () => { - 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, newBenefit]) - setValue('newBenefitKey', '') - setValue('newBenefitValue', '') - setValue('newBenefitType', 'boolean') - } - } - - const handleRemoveBenefit = (index: number) => { - const currentBenefits = watchedBenefits || [] - const newBenefits = currentBenefits.filter((_, i) => i !== index) - setValue('benefits', newBenefits) - } - - const handleKeyPress = (event: React.KeyboardEvent) => { - if (event.key === 'Enter') { - event.preventDefault() - handleAddBenefit() - } - } - const handleFormSubmit = async (formData: FormValidateType) => { try { setIsSubmitting(true) @@ -257,6 +234,11 @@ const AddEditTierDrawer = (props: Props) => { setShowMore(false) } + // Get placeholder and validation info based on selected benefit key + const getBenefitInputInfo = () => { + return { placeholder: 'Tidak diperlukan lagi', type: 'text' } + } + const formatNumber = (value: number) => { return new Intl.NumberFormat('id-ID').format(value) } @@ -357,109 +339,133 @@ const AddEditTierDrawer = (props: Props) => { {/* Benefits */}
- + Manfaat Tier * - {/* 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' - } - }} - /> - ))} -
- )} + {/* All Benefits in Horizontal Layout */} +
+ {Object.entries(STATIC_BENEFIT_KEYS).map(([key, config]) => { + const benefitKey = key as keyof typeof STATIC_BENEFIT_KEYS + const existingBenefit = watchedBenefits?.find(b => b.key === benefitKey) + const isActive = Boolean(existingBenefit) - {/* Add new benefit - Key */} -
- ( - - )} - /> -
+ return ( +
+
+
+ + {config.label} + + + {config.description} + +
+ { + if (e.target.checked) { + // Add default benefit + const defaultValue = + config.type === 'boolean' + ? true + : config.type === 'number' + ? benefitKey === 'point_multiplier' + ? 1.1 + : benefitKey === 'special_discount' + ? 5 + : 1 + : '' - {/* Type selector */} -
- ( - - Tipe Value - - - )} - /> -
+ const newBenefit: BenefitItem = { + key: benefitKey, + value: defaultValue, + type: config.type + } - {/* Add new benefit - Value */} -
- ( - - - - ) - }} - /> - )} - /> + /> + } + label='' + sx={{ margin: 0 }} + /> +
+ + {/* Value Input - Only show when active */} + {isActive && ( +
+ {config.type === 'boolean' ? ( + + + + ) : ( + { + const newValue = Number(e.target.value) + if (!isNaN(newValue)) { + const currentBenefits = watchedBenefits || [] + const updatedBenefits = currentBenefits.map(b => + b.key === benefitKey ? { ...b, value: newValue } : b + ) + setValue('benefits', updatedBenefits) + } + }} + placeholder={ + benefitKey === 'point_multiplier' + ? 'Contoh: 1.1, 1.5, 2.0' + : benefitKey === 'special_discount' + ? 'Contoh: 5, 10, 15' + : 'Masukkan angka' + } + InputProps={{ + endAdornment: config.suffix && ( + {config.suffix} + ), + inputProps: { + step: benefitKey === 'point_multiplier' ? '0.1' : '1', + min: benefitKey === 'point_multiplier' ? '0.1' : '0', + max: benefitKey === 'special_discount' ? '100' : undefined + } + }} + /> + )} +
+ )} +
+ ) + })}
{(!watchedBenefits || watchedBenefits.length === 0) && ( - - Minimal satu manfaat harus ditambahkan + + Minimal satu manfaat harus diaktifkan )}
-- 2.47.2 From 4640d14cb74b6b32e10c60b016ed279405bc6fa3 Mon Sep 17 00:00:00 2001 From: efrilm Date: Thu, 18 Sep 2025 03:04:06 +0700 Subject: [PATCH 15/18] 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 = () => { /> + ) } -- 2.47.2 From e82173c6e25cd23ee1678e4b6c6d9da0c6e4926c Mon Sep 17 00:00:00 2001 From: efrilm Date: Thu, 18 Sep 2025 03:36:05 +0700 Subject: [PATCH 16/18] Campaign --- src/services/mutations/campaign.ts | 52 ++ src/services/queries/campaign.ts | 46 ++ src/types/services/campaign.ts | 68 +- .../campaign/AddEditCampaignDrawer.tsx | 580 ++++++++++++------ .../marketing/campaign/CampaignListTable.tsx | 417 ++++++------- .../campaign/DeleteCampaignDialog.tsx | 157 +++++ 6 files changed, 904 insertions(+), 416 deletions(-) create mode 100644 src/services/mutations/campaign.ts create mode 100644 src/services/queries/campaign.ts create mode 100644 src/views/apps/marketing/campaign/DeleteCampaignDialog.tsx diff --git a/src/services/mutations/campaign.ts b/src/services/mutations/campaign.ts new file mode 100644 index 0000000..50f7e9f --- /dev/null +++ b/src/services/mutations/campaign.ts @@ -0,0 +1,52 @@ +import { CampaignRequest } from '@/types/services/campaign' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'react-toastify' +import { api } from '../api' + +export const useCampaignsMutation = () => { + const queryClient = useQueryClient() + + const createCampaign = useMutation({ + mutationFn: async (newCampaign: CampaignRequest) => { + const response = await api.post('/marketing/campaigns', newCampaign) + return response.data + }, + onSuccess: () => { + toast.success('Campaign created successfully!') + queryClient.invalidateQueries({ queryKey: ['campaigns'] }) + }, + onError: (error: any) => { + toast.error(error.response?.data?.errors?.[0]?.cause || 'Create failed') + } + }) + + const updateCampaign = useMutation({ + mutationFn: async ({ id, payload }: { id: string; payload: CampaignRequest }) => { + const response = await api.put(`/marketing/campaigns/${id}`, payload) + return response.data + }, + onSuccess: () => { + toast.success('Campaign updated successfully!') + queryClient.invalidateQueries({ queryKey: ['campaigns'] }) + }, + onError: (error: any) => { + toast.error(error.response?.data?.errors?.[0]?.cause || 'Update failed') + } + }) + + const deleteCampaign = useMutation({ + mutationFn: async (id: string) => { + const response = await api.delete(`/marketing/campaigns/${id}`) + return response.data + }, + onSuccess: () => { + toast.success('Campaign deleted successfully!') + queryClient.invalidateQueries({ queryKey: ['campaigns'] }) + }, + onError: (error: any) => { + toast.error(error.response?.data?.errors?.[0]?.cause || 'Delete failed') + } + }) + + return { createCampaign, updateCampaign, deleteCampaign } +} diff --git a/src/services/queries/campaign.ts b/src/services/queries/campaign.ts new file mode 100644 index 0000000..acd4fcf --- /dev/null +++ b/src/services/queries/campaign.ts @@ -0,0 +1,46 @@ +import { useQuery } from '@tanstack/react-query' +import { api } from '../api' +import { Campaign, Campaigns } from '@/types/services/campaign' + +interface CampaignQueryParams { + page?: number + limit?: number + search?: string +} + +export function useCampaigns(params: CampaignQueryParams = {}) { + const { page = 1, limit = 10, search = '', ...filters } = params + + return useQuery({ + queryKey: ['campaigns', { 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/campaigns?${queryParams.toString()}`) + return res.data.data + } + }) +} + +export function useCampaignById(id: string) { + return useQuery({ + queryKey: ['campaigns', id], + queryFn: async () => { + const res = await api.get(`/marketing/campaigns/${id}`) + return res.data.data + } + }) +} diff --git a/src/types/services/campaign.ts b/src/types/services/campaign.ts index 440cc49..e5657b0 100644 --- a/src/types/services/campaign.ts +++ b/src/types/services/campaign.ts @@ -1,13 +1,63 @@ +export type CampaignType = 'REWARD' | 'POINTS' | 'TOKENS' | 'MIXED' + +export type RuleType = 'TIER' | 'SPEND' | 'PRODUCT' | 'CATEGORY' | 'DAY' | 'LOCATION' + +export type RewardType = 'POINTS' | 'TOKENS' | 'REWARD' + export interface Campaign { - id: string + id: string // UUID name: string description?: string - minimumPurchase: number - rewardType: 'point' | 'voucher' | 'discount' - rewardValue: number - startDate: Date - endDate: Date - isActive: boolean - createdAt: Date - updatedAt: Date + type: CampaignType + start_date: string // ISO string + end_date: string // ISO string + is_active: boolean + show_on_app: boolean + position: number + metadata?: Record + rules?: CampaignRule[] + created_at: string // ISO string + updated_at: string // ISO string +} + +export interface CampaignRule { + id: string // UUID + campaign_id: string // UUID + rule_type: RuleType + condition_value?: string + reward_type: RewardType + reward_value?: number + reward_subtype?: string + reward_ref_id?: string // UUID + metadata?: Record + created_at: string // ISO string + updated_at: string // ISO string +} + +export interface CampaignRequest { + name: string + description?: string + type: CampaignType + start_date: string // ISO string + end_date: string // ISO string + is_active: boolean + show_on_app: boolean + position: number + metadata?: Record + rules: CampaignRuleRequest[] +} + +export interface CampaignRuleRequest { + rule_type: RuleType + condition_value?: string + reward_type: RewardType + reward_value?: number + reward_subtype?: string +} + +export interface Campaigns { + campaigns: Campaign[] + total: number + page: number + limit: number } diff --git a/src/views/apps/marketing/campaign/AddEditCampaignDrawer.tsx b/src/views/apps/marketing/campaign/AddEditCampaignDrawer.tsx index 207bffd..477ba94 100644 --- a/src/views/apps/marketing/campaign/AddEditCampaignDrawer.tsx +++ b/src/views/apps/marketing/campaign/AddEditCampaignDrawer.tsx @@ -22,29 +22,33 @@ import { useForm, Controller, useFieldArray } from 'react-hook-form' import CustomTextField from '@core/components/mui/TextField' // Types -export interface Campaign { - id: string - name: string - description?: string - minimumPurchase: number - rewardType: 'point' | 'voucher' | 'discount' - rewardValue: number - startDate: Date - endDate: Date - isActive: boolean - createdAt: Date - updatedAt: Date -} +import { Campaign } from '@/types/services/campaign' +import { useCampaignsMutation } from '@/services/mutations/campaign' + +// Updated Type Definitions +export type CampaignType = 'REWARD' | 'POINTS' | 'TOKENS' | 'MIXED' +export type RuleType = 'TIER' | 'SPEND' | 'PRODUCT' | 'CATEGORY' | 'DAY' | 'LOCATION' +export type RewardType = 'POINTS' | 'TOKENS' | 'REWARD' export interface CampaignRequest { name: string description?: string - minimumPurchase: number - rewardType: 'point' | 'voucher' | 'discount' - rewardValue: number - startDate: Date - endDate: Date - isActive: boolean + type: CampaignType + start_date: string // ISO string + end_date: string // ISO string + is_active: boolean + show_on_app: boolean + position: number + metadata?: Record + rules: CampaignRuleRequest[] +} + +export interface CampaignRuleRequest { + rule_type: RuleType + condition_value?: string + reward_type: RewardType + reward_value?: number + reward_subtype?: string } type Props = { @@ -56,43 +60,42 @@ type Props = { type FormValidateType = { name: string description: string - minimumPurchase: number - rewardType: 'point' | 'voucher' | 'discount' - rewardValue: number - startDate: string - endDate: string - isActive: boolean + type: CampaignType + start_date: string + end_date: string + is_active: boolean + show_on_app: boolean + position: number + // Rules array + rules: { + rule_type: RuleType + condition_value: string + reward_type: RewardType + reward_value: number + reward_subtype: string + }[] } // Initial form data const initialData: FormValidateType = { name: '', description: '', - minimumPurchase: 0, - rewardType: 'point', - rewardValue: 0, - startDate: '', - endDate: '', - isActive: true -} - -// Mock mutation hooks (replace with actual hooks) -const useCampaignMutation = () => { - const createCampaign = { - mutate: (data: CampaignRequest, options?: { onSuccess?: () => void }) => { - console.log('Creating campaign:', data) - setTimeout(() => options?.onSuccess?.(), 1000) + type: 'POINTS', + start_date: '', + end_date: '', + is_active: true, + show_on_app: true, + position: 1, + // Initial rule + rules: [ + { + rule_type: 'SPEND', + condition_value: '', + reward_type: 'POINTS', + reward_value: 0, + reward_subtype: '' } - } - - const updateCampaign = { - mutate: (data: { id: string; payload: CampaignRequest }, options?: { onSuccess?: () => void }) => { - console.log('Updating campaign:', data) - setTimeout(() => options?.onSuccess?.(), 1000) - } - } - - return { createCampaign, updateCampaign } + ] } const AddEditCampaignDrawer = (props: Props) => { @@ -103,7 +106,7 @@ const AddEditCampaignDrawer = (props: Props) => { const [showMore, setShowMore] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) - const { createCampaign, updateCampaign } = useCampaignMutation() + const { createCampaign, updateCampaign } = useCampaignsMutation() // Determine if this is edit mode const isEditMode = Boolean(data?.id) @@ -120,23 +123,43 @@ const AddEditCampaignDrawer = (props: Props) => { defaultValues: initialData }) - const watchedRewardType = watch('rewardType') - const watchedStartDate = watch('startDate') - const watchedEndDate = watch('endDate') + // Field array for rules + const { fields, append, remove } = useFieldArray({ + control, + name: 'rules' + }) + + const watchedStartDate = watch('start_date') + const watchedEndDate = watch('end_date') // Effect to populate form when editing useEffect(() => { if (isEditMode && data) { - // Populate form with existing data const formData: FormValidateType = { name: data.name || '', description: data.description || '', - minimumPurchase: data.minimumPurchase || 0, - rewardType: data.rewardType || 'point', - rewardValue: data.rewardValue || 0, - startDate: data.startDate ? new Date(data.startDate).toISOString().split('T')[0] : '', - endDate: data.endDate ? new Date(data.endDate).toISOString().split('T')[0] : '', - isActive: data.isActive ?? true + type: data.type || 'POINTS', + start_date: data.start_date ? new Date(data.start_date).toISOString().split('T')[0] : '', + end_date: data.end_date ? new Date(data.end_date).toISOString().split('T')[0] : '', + is_active: data.is_active ?? true, + show_on_app: data.show_on_app ?? true, + position: data.position || 1, + // Map existing rules + rules: data.rules?.map(rule => ({ + rule_type: rule.rule_type, + condition_value: rule.condition_value || '', + reward_type: rule.reward_type, + reward_value: rule.reward_value || 0, + reward_subtype: rule.reward_subtype || '' + })) || [ + { + rule_type: 'SPEND', + condition_value: '', + reward_type: 'POINTS', + reward_value: 0, + reward_subtype: '' + } + ] } resetForm(formData) @@ -152,16 +175,34 @@ const AddEditCampaignDrawer = (props: Props) => { try { setIsSubmitting(true) + // Create rules array + const rulesRequest: CampaignRuleRequest[] = formData.rules.map(rule => ({ + rule_type: rule.rule_type, + condition_value: rule.condition_value || undefined, + reward_type: rule.reward_type, + reward_value: rule.reward_value || undefined, + reward_subtype: rule.reward_subtype || undefined + })) + + // Create metadata from rules if needed + const metadata: Record = {} + const spendRule = formData.rules.find(rule => rule.rule_type === 'SPEND') + if (spendRule?.condition_value) { + metadata.minPurchase = parseInt(spendRule.condition_value) + } + // Create CampaignRequest object const campaignRequest: CampaignRequest = { name: formData.name, description: formData.description || undefined, - minimumPurchase: formData.minimumPurchase, - rewardType: formData.rewardType, - rewardValue: formData.rewardValue, - startDate: new Date(formData.startDate), - endDate: new Date(formData.endDate), - isActive: formData.isActive + type: formData.type, + start_date: new Date(formData.start_date).toISOString(), + end_date: new Date(formData.end_date).toISOString(), + is_active: formData.is_active, + show_on_app: formData.show_on_app, + position: formData.position, + metadata: Object.keys(metadata).length > 0 ? metadata : undefined, + rules: rulesRequest } if (isEditMode && data?.id) { @@ -206,52 +247,59 @@ const AddEditCampaignDrawer = (props: Props) => { }).format(value) } - const getRewardTypeLabel = (type: 'point' | 'voucher' | 'discount') => { + const getRewardTypeLabel = (type: RewardType) => { switch (type) { - case 'point': + case 'POINTS': return 'Poin' - case 'voucher': - return 'Voucher' - case 'discount': - return 'Diskon' + case 'TOKENS': + return 'Token' + case 'REWARD': + return 'Reward' default: return type } } - const getRewardValuePlaceholder = (type: 'point' | 'voucher' | 'discount') => { + const getRewardValuePlaceholder = (type: RewardType) => { switch (type) { - case 'point': + case 'POINTS': return 'Jumlah poin yang diberikan' - case 'voucher': - return 'Nilai voucher dalam Rupiah' - case 'discount': - return 'Persentase diskon (1-100)' + case 'TOKENS': + return 'Jumlah token yang diberikan' + case 'REWARD': + return 'Nilai reward' default: return 'Nilai reward' } } - const getRewardValueRules = (type: 'point' | 'voucher' | 'discount') => { - const baseRules = { - required: 'Nilai reward wajib diisi', - min: { - value: 1, - message: 'Nilai reward minimal 1' - } + const getConditionValuePlaceholder = (ruleType: RuleType) => { + switch (ruleType) { + case 'SPEND': + return 'Minimum pembelian (Rupiah)' + case 'TIER': + return 'Tier pelanggan (misal: GOLD, SILVER)' + case 'PRODUCT': + return 'ID atau nama produk' + case 'CATEGORY': + return 'Kategori produk' + case 'DAY': + return 'Hari dalam seminggu (misal: MONDAY)' + case 'LOCATION': + return 'Lokasi atau kota' + default: + return 'Nilai kondisi' } + } - if (type === 'discount') { + const getConditionValueInputProps = (ruleType: RuleType) => { + if (ruleType === 'SPEND') { return { - ...baseRules, - max: { - value: 100, - message: 'Persentase diskon maksimal 100%' - } + startAdornment: Rp, + type: 'number' as const } } - - return baseRules + return { type: 'text' as const } } return ( @@ -314,71 +362,39 @@ const AddEditCampaignDrawer = (props: Props) => { />
- {/* Minimum Purchase */} + {/* Jenis Kampanye */}
- Minimum Pembelian * + Jenis Kampanye * ( - 0 ? formatCurrency(field.value) : '')} - InputProps={{ - startAdornment: Rp - }} - onChange={e => field.onChange(Number(e.target.value))} - /> - )} - /> -
- - {/* Jenis Reward */} -
- - Jenis Reward * - - ( - - + +
- Poin + Points
- +
- Voucher + Tokens
- +
- - Diskon + + Reward +
+
+ +
+ + Mixed
@@ -386,39 +402,200 @@ const AddEditCampaignDrawer = (props: Props) => { />
- {/* Nilai Reward */} + {/* Rules Section */}
- - Nilai {getRewardTypeLabel(watchedRewardType)} * - - ( - Rp - ) : undefined, - endAdornment: - watchedRewardType === 'discount' ? ( - % - ) : watchedRewardType === 'point' ? ( - Poin - ) : undefined - }} - onChange={e => field.onChange(Number(e.target.value))} - /> - )} - /> + + Aturan Kampanye + + + + {fields.map((field, index) => ( + + + + Aturan {index + 1} + + {fields.length > 1 && ( + remove(index)}> + + + )} + + +
+ {/* Rule Type */} +
+ + Tipe Aturan * + + ( + + Minimum Pembelian + Tier Pelanggan + Produk Tertentu + Kategori Produk + Hari Tertentu + Lokasi Tertentu + + )} + /> +
+ + {/* Condition Value */} +
+ + Nilai Kondisi * + + { + const ruleType = watch(`rules.${index}.rule_type`) + return ( + + ) + }} + /> +
+ + {/* Reward Type */} +
+ + Jenis Reward * + + ( + + +
+ + Points +
+
+ +
+ + Tokens +
+
+ +
+ + Reward +
+
+
+ )} + /> +
+ + {/* Reward Value */} +
+ + Nilai Reward * + + { + const rewardType = watch(`rules.${index}.reward_type`) + return ( + Poin + ) : rewardType === 'TOKENS' ? ( + Token + ) : undefined + }} + onChange={e => field.onChange(Number(e.target.value))} + /> + ) + }} + /> +
+ + {/* Reward Subtype (jika reward type adalah REWARD) */} + {watch(`rules.${index}.reward_type`) === 'REWARD' && ( +
+ + Sub-tipe Reward + + ( + + Diskon Persentase + Diskon Nominal + Cashback + Gratis Ongkir + + )} + /> +
+ )} +
+
+ ))}
{/* Tanggal Mulai */} @@ -427,7 +604,7 @@ const AddEditCampaignDrawer = (props: Props) => { Tanggal Mulai * ( @@ -435,8 +612,8 @@ const AddEditCampaignDrawer = (props: Props) => { {...field} fullWidth type='date' - error={!!errors.startDate} - helperText={errors.startDate?.message} + error={!!errors.start_date} + helperText={errors.start_date?.message} InputLabelProps={{ shrink: true }} @@ -451,7 +628,7 @@ const AddEditCampaignDrawer = (props: Props) => { Tanggal Berakhir * { {...field} fullWidth type='date' - error={!!errors.endDate} - helperText={errors.endDate?.message} + error={!!errors.end_date} + helperText={errors.end_date?.message} InputLabelProps={{ shrink: true }} @@ -485,7 +662,7 @@ const AddEditCampaignDrawer = (props: Props) => { {/* Status Aktif */}
( { />
+ {/* Show on App */} +
+ ( + } + label='Tampilkan di Aplikasi' + /> + )} + /> +
+ {/* Tampilkan selengkapnya */} {!showMore && ( + + + + ) +} + +export default DeleteCampaignDialog -- 2.47.2 From 7570e973b8d85dfa0e299ef99b5fdff8ac2f0126 Mon Sep 17 00:00:00 2001 From: efrilm Date: Thu, 18 Sep 2025 10:55:37 +0700 Subject: [PATCH 17/18] update vertical menu --- src/components/layout/vertical/VerticalMenu.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/layout/vertical/VerticalMenu.tsx b/src/components/layout/vertical/VerticalMenu.tsx index 476a452..a1c3d69 100644 --- a/src/components/layout/vertical/VerticalMenu.tsx +++ b/src/components/layout/vertical/VerticalMenu.tsx @@ -154,13 +154,13 @@ const VerticalMenu = ({ dictionary, scrollMenu }: Props) => { {dictionary['navigation'].reports}
}> - {dictionary['navigation'].loyalty} + {/* {dictionary['navigation'].loyalty} */} {dictionary['navigation'].reward} - + {/* {dictionary['navigation'].wheel_spin} - + */} {dictionary['navigation'].list} @@ -168,10 +168,10 @@ const VerticalMenu = ({ dictionary, scrollMenu }: Props) => { {dictionary['navigation'].campaign} - + {/* {dictionary['navigation'].customer_analytics} - - {dictionary['navigation'].voucher} + */} + {/* {dictionary['navigation'].voucher} */} {dictionary['navigation'].tiers_text} }> -- 2.47.2 From 97ed613cc603eb93d0c1f333117504f2f3ebf113 Mon Sep 17 00:00:00 2001 From: efrilm Date: Thu, 18 Sep 2025 11:00:00 +0700 Subject: [PATCH 18/18] api --- src/services/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/api.ts b/src/services/api.ts index bbe7bbc..3c05905 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -31,7 +31,7 @@ api.interceptors.response.use( error => { const status = error.response?.status - if (status === 401 && !currentPath.endsWith('/login')) { + if (status === 401) { localStorage.removeItem('user') localStorage.removeItem('authToken') window.location.href = '/login' -- 2.47.2