From 18ee652731390ae006251f82401598d1d366dd99 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 17 Sep 2025 01:51:59 +0700 Subject: [PATCH] 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