From 851af85672d5e767777ca7deec858f942ace658b Mon Sep 17 00:00:00 2001 From: bagusajisaputroo Date: Fri, 30 May 2025 13:09:00 +0700 Subject: [PATCH] [skip ci] adding sub module SearchMember on Members Module --- .../members/search-member/SearchMember.tsx | 47 +++ .../search-member/blocks/DetailMember.tsx | 377 ++++++++++++++++++ .../search-member/blocks/FilterCard.tsx | 88 ++++ .../hooks/ManageSearchMember.tsx | 338 ++++++++++++++++ .../hooks/useManageSearchMemberContext.tsx | 11 + src/routing/AppRoutingSetup.tsx | 3 + 6 files changed, 864 insertions(+) create mode 100644 src/pages/members/search-member/SearchMember.tsx create mode 100644 src/pages/members/search-member/blocks/DetailMember.tsx create mode 100644 src/pages/members/search-member/blocks/FilterCard.tsx create mode 100644 src/pages/members/search-member/hooks/ManageSearchMember.tsx create mode 100644 src/pages/members/search-member/hooks/useManageSearchMemberContext.tsx diff --git a/src/pages/members/search-member/SearchMember.tsx b/src/pages/members/search-member/SearchMember.tsx new file mode 100644 index 0000000..83e951d --- /dev/null +++ b/src/pages/members/search-member/SearchMember.tsx @@ -0,0 +1,47 @@ +import { Container, DataGridInner } from '@/components'; +// import { +// ManageTransferTypeContext, +// ManageTransferTypeContextProvider +// } from './hooks/ManageTransferTypeContext'; +// import AddDialog from './blocks/AddDialog'; +// import { Breadcrumbs, Link } from '@mui/material'; +// import DeleteDialog from './blocks/DeleteDialog'; +// import { EditDialog } from './blocks/EditDialog'; +import { Helmet } from 'react-helmet'; +import { ManageSearchMemberContextProvider } from './hooks/ManageSearchMember'; +import { Breadcrumbs, Link } from '@mui/material'; + +const SearchMember = () => { + + return ( + <> + + TPAY | Manage Search Member + + + +

Search Member

+ + + Dashboard + + + + Members + + + + Manage Search Member + + +
+ +
+ +
+
+ + ); +}; + +export default SearchMember; diff --git a/src/pages/members/search-member/blocks/DetailMember.tsx b/src/pages/members/search-member/blocks/DetailMember.tsx new file mode 100644 index 0000000..b802e33 --- /dev/null +++ b/src/pages/members/search-member/blocks/DetailMember.tsx @@ -0,0 +1,377 @@ +import { useState, useContext, useRef } from 'react'; +import axios from 'axios'; +import { toast } from 'sonner'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogBody +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select'; +import ConfirmDialog from '@/components/confirm'; +import { apiConfig } from '@/config/api.config'; +import { useManageSearchMemberContext } from '../hooks/useManageSearchMemberContext'; + +const BASE_URL_CUSTOMER = apiConfig.service_customer; + +const DetailMember = () => { + const { + showEditDialog, + handleEditDialog, + selectedCustomerData, + doGetSearchMemberListData, + refreshTable + } = useManageSearchMemberContext(); + + const [confirmDialog, setConfirmDialog] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [previewImg, setPreviewImg] = useState({ + status: false, + img: '' + }); + const parentRef = useRef(null); + + const formatDate = (dateString: string) => { + if (!dateString) return 'No Data'; + try { + return new Date(dateString).toLocaleDateString('en-GB'); + } catch { + return 'Invalid Date'; + } + }; + + const getPinStatus = (status: string) => { + if (status === 'Y') + return { + msg: 'Active', + btn: 'Block PIN', + canBlock: true, + variant: 'destructive' as const + }; + if (status === 'N') + return { + msg: 'Not Active', + btn: 'PIN Not Active', + canBlock: false, + variant: 'secondary' as const + }; + if (status === 'P') + return { + msg: 'Suspend PIN', + btn: 'PIN Blocked', + canBlock: false, + variant: 'secondary' as const + }; + if (status === 'O') + return { + msg: 'Suspend OTP', + btn: 'OTP Blocked', + canBlock: false, + variant: 'secondary' as const + }; + return { + msg: 'Unknown Status', + btn: 'Unknown', + canBlock: false, + variant: 'secondary' as const + }; + }; + + const handleBlockPin = async () => { + if (!selectedCustomerData?.id) { + toast.error('Customer data not available'); + return; + } + + try { + setIsUpdating(true); + await axios.put(`${BASE_URL_CUSTOMER}/customer/statuspin`, { + customerid: selectedCustomerData.id, + status: 'Block' + }); + + toast.success('PIN successfully blocked'); + + handleEditDialog(false, null); + + setTimeout(() => { + refreshTable(); + }, 500); + + } catch (error: any) { + toast.error(error.message || 'Failed to block PIN'); + } finally { + setIsUpdating(false); + setConfirmDialog(false); + } + }; + + const handleAddDialog = (show: boolean) => { + handleEditDialog(show, show ? selectedCustomerData : null); + }; + + const imagePreview = (file: any) => { + if (file && typeof file === 'object' && file.type && file.type.startsWith('image/')) { + const previewURL = URL.createObjectURL(file); + return previewURL; + } + return file; + }; + + const handlePrevImg = (par: boolean) => { + if (par) setPreviewImg({ status: true, img: '' }); + if (!par) setPreviewImg({ status: false, img: '' }); + }; + + const onClickImg = (img: any) => { + setPreviewImg({ status: true, img }); + }; + + const generateInput = (label: string, name: string, value: any, type: string = 'text', required: boolean = false, disabled: boolean = true) => { + const displayValue = value || ''; + + function generateDate(isoString: string) { + return isoString.slice(0, 10); + } + + return ( +
+
+ + {}} + /> +
+
+ ); + }; + + const generateImage = (label: string, name: string, photoUrl: any) => { + return ( + <> + {previewImg.status && ( + + )} +
+ +
+ {photoUrl ? ( +
+ {name} onClickImg(imagePreview(photoUrl))} + /> +
+ ) : ( + No Data + )} +
+
+ + ); + }; + + const generatePinStatusField = (label: string, status: string) => { + const pinStatus = getPinStatus(status); + + return ( +
+
+ +
+ {pinStatus.msg} + {pinStatus.canBlock && ( + + )} + {!pinStatus.canBlock && ( + + )} +
+
+
+ ); + }; + + if (!selectedCustomerData) { + return ( + handleAddDialog(open)}> + + + Member - Detail + + + +
No data available
+
+
+
+ ); + } + + return ( + <> + handleAddDialog(open)}> + + + Member - Detail + + + + +
+
+ + {/* Basic Information */} + {generateInput('Group', 'group_name', selectedCustomerData.group_name || selectedCustomerData.destinationGroup, 'text', true)} + {generateInput('Username', 'username', selectedCustomerData.username, 'text', true)} + {generateInput('Full Name', 'fullname', selectedCustomerData.fullname, 'text', true)} + {generateInput('Gender', 'gender', selectedCustomerData.gender, 'text', true)} + {generateInput('Date of Birth', 'date_birth', selectedCustomerData.date_birth, 'date', true)} + {generateInput('Phone Number', 'msisdn', selectedCustomerData.msisdn, 'text', true)} + {generateInput('Email', 'email', selectedCustomerData.email, 'text', true)} + {generateInput('Mother Fullname', 'mother_fullname', selectedCustomerData.mother_fullname, 'text', true)} + {generateInput('Identity Type', 'identity_type', selectedCustomerData.identity_type)} + {generateInput('Identity Number', 'identity_number', selectedCustomerData.identity_number)} + {generateInput('Address', 'address', selectedCustomerData.address, 'text', true)} + + {/* Location Information */} + {generateInput('Profession', 'profession', selectedCustomerData.profession)} + {generateInput('Municipio', 'municipio_name', selectedCustomerData.municipio_name)} + {generateInput('Posto', 'posto_adms_name', selectedCustomerData.posto_adms_name)} + {generateInput('Suco', 'suco_name', selectedCustomerData.suco_name)} + {generateInput('Aldeia', 'aldeia_name', selectedCustomerData.aldeia_name)} + {generateInput('Nationality', 'nationality', selectedCustomerData.nationality, 'text', true)} + {generateImage('Photo', 'photouser', selectedCustomerData.photouser)} +
+
+
+
+ + {/* Bank Information Section */} +
+

Bank Information

+
+ {generateInput('Bank Name', 'bank_name', selectedCustomerData.bank_name)} + {generateInput('Bank Account', 'bank_account', selectedCustomerData.bank_account)} + {generateInput('IBank Number', 'ibank_number', selectedCustomerData.ibank_number)} +
+
+ + {/* Agent Information Section */} +
+

Agent Information

+
+ {generateInput('License Number', 'license_number', selectedCustomerData.license_number)} + {generateInput('Merchant Name', 'agent_name', selectedCustomerData.agent_name)} + {generateInput('Merchant Address', 'merchantaddress', selectedCustomerData.merchantaddress)} + {generateInput('Longitude Merchant', 'longitudemerchant', selectedCustomerData.longitudemerchant)} + {generateInput('Latitude Merchant', 'latitudemerchant', selectedCustomerData.latitudemerchant)} + {generateImage('Photo Merchant', 'photomerchant', selectedCustomerData.photomerchant)} + {generateImage('File Selfie', 'file_selfie', selectedCustomerData.file_selfie)} + {generateImage('File Document', 'file_document_id', selectedCustomerData.file_document_id)} + {generateImage('File Document & Selfie', 'file_document_id_selfie', selectedCustomerData.file_document_id_selfie)} + {generateImage('File Commercial License', 'file_commercial_license', selectedCustomerData.file_commercial_license)} +
+
+ + {/* Status Information Section */} +
+

Status Information

+
+ {generatePinStatusField('PIN Status', selectedCustomerData.status || '')} + {generateInput('Created Date', 'created_at', formatDate(selectedCustomerData.created_at))} + +
+
+ + {/* Action Buttons */} +
+ +
+
+
+
+
+
+ + setConfirmDialog(false)} + /> + + ); +}; + +function PreviewImage({previewImgDialog, handlePreviewImgDialog, modalImage}: any) { + return ( + handlePreviewImgDialog(open)}> + + + + + Preview + + + + ); +} + +export default DetailMember; \ No newline at end of file diff --git a/src/pages/members/search-member/blocks/FilterCard.tsx b/src/pages/members/search-member/blocks/FilterCard.tsx new file mode 100644 index 0000000..782793b --- /dev/null +++ b/src/pages/members/search-member/blocks/FilterCard.tsx @@ -0,0 +1,88 @@ +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { useState } from 'react'; + +const CustomerFilterForm = ({ + onFilter, + onReset +}: { + onFilter: (filters: { username?: string; msisdn?: string }) => void; + onReset?: () => void; +}) => { + const [username, setUsername] = useState(''); + const [msisdn, setMsisdn] = useState(''); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!username.trim() && !msisdn.trim()) { + alert('Please fill at least one search field'); + return; + } + + onFilter({ username, msisdn }); + }; + + const handleReset = () => { + setUsername(''); + setMsisdn(''); + if (onReset) { + onReset(); + } + }; + + return ( +
+
+
+
+
+ + setUsername(e.target.value)} + placeholder="Search Username" + className="w-full h-12 text-base" + /> +
+ +
+ + setMsisdn(e.target.value)} + placeholder="Search MSISDN" + className="w-full h-12 text-base" + /> +
+ +
+ + +
+
+
+
+
+ ); +}; + +export default CustomerFilterForm; \ No newline at end of file diff --git a/src/pages/members/search-member/hooks/ManageSearchMember.tsx b/src/pages/members/search-member/hooks/ManageSearchMember.tsx new file mode 100644 index 0000000..56f18e6 --- /dev/null +++ b/src/pages/members/search-member/hooks/ManageSearchMember.tsx @@ -0,0 +1,338 @@ +import { DataGridColumnHeader, DataGridProvider, KeenIcon } from '@/components'; +import { Toaster } from '@/components/ui/sonner'; +import { Button } from '@/components/ui/button'; +import { apiConfig } from '@/config/api.config'; +import { useCallApi } from '@/hooks'; +import { ColumnDef } from '@tanstack/react-table'; +import { createContext, useCallback, useMemo, useState } from 'react'; +import CustomerDetailDialog from '../blocks/DetailMember'; +import CustomerFilterForm from '../blocks/FilterCard'; + +interface AccountProps { + id: string; + name: string; +} + +interface CustomerData { + group_id: string; + group_name: string; + municipio_id: string; + municipio_name: string; + posto_adms_id: string; + posto_adms_name: string; + suco_id: string; + suco_name: string; + aldeia_id: string; + aldeia_name: string; + id: string; + msisdn: string; + email: string; + fullname: string; + username: string; + mother_fullname: string; + agent_name: string; + bank_name: string; + bank_account: string; + ibank_number: string; + address: string; + longitude: string; + latitude: string; + nationality: string; + file_selfie: string; + file_document_id: string; + file_document_id_selfie: string; + file_commercial_license: string; + identity_number: string; + merchantaddress: string; + license_number: string; + date_birth: string; + gender: string; + status: string; + created_at: string; + isneedapproval: string; + identity_type: string; + longitudemerchant: string; + latitudemerchant: string; + profession: string; + created_by: string; + approval_description_premium: string; + approval_description_agent: string; + photouser: string; + photomerchant: string; + destinationGroup: string; + statusApproval: string; +} + +interface ContextProps { + showEditDialog: boolean; + handleEditDialog: (show: boolean, selectedData: CustomerData | null) => void; + showAddDialog: boolean; + handleAddDialog: (show: boolean) => void; + selectedCustomerData: CustomerData | null; + doGetSearchMemberListData: ( + page: number, + limit: number, + sorting: any, + filter: any + ) => Promise<{ data: any[]; totalCount: number }>; + showTable: boolean; + showFilterCard: boolean; + currentFilters: any; + handleCustomFilter: (filters: { username?: string; msisdn?: string }) => void; + handleResetFilter: () => void; + handleShowSearchAgain: () => void; + refreshTable: () => void; +} + +const initialProps: ContextProps = { + showEditDialog: false, + handleEditDialog: () => {}, + showAddDialog: false, + handleAddDialog: () => {}, + selectedCustomerData: null, + doGetSearchMemberListData: async () => ({ data: [], totalCount: 0 }), + showTable: false, + showFilterCard: true, + currentFilters: {}, + handleCustomFilter: () => {}, + handleResetFilter: () => {}, + handleShowSearchAgain: () => {}, + refreshTable: () => {} +}; + +const ManageSearchMemberContext = createContext(initialProps); +const API_URL = apiConfig.service_customer; + +const ManageSearchMemberContextProvider = ({ children }: { children: React.ReactNode }) => { + const [showEditDialog, setShowEditDialog] = useState(false); + const [showAddDialog, setShowAddDialog] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [selectedCustomerData, setSelectedCustomerData] = useState(null); + const { GetData } = useCallApi(); + + const [showTable, setShowTable] = useState(false); + const [showFilterCard, setShowFilterCard] = useState(true); + const [currentFilters, setCurrentFilters] = useState({}); + const [refreshTrigger, setRefreshTrigger] = useState(0); + + const handleCustomFilter = useCallback((filters: { username?: string; msisdn?: string }) => { + const formattedFilters: any[] = []; + + if (filters.username?.trim()) { + formattedFilters.push({ id: "username", value: filters.username.trim() }); + } + + if (filters.msisdn?.trim()) { + formattedFilters.push({ id: "msisdn", value: filters.msisdn.trim() }); + } + + setCurrentFilters(formattedFilters); + setShowTable(true); + setShowFilterCard(false); + }, []); + + const handleResetFilter = useCallback(() => { + setCurrentFilters({}); + setShowTable(false); + setShowFilterCard(true); + }, []); + + const handleShowSearchAgain = useCallback(() => { + setShowFilterCard(true); + setShowTable(false); + }, []); + + const refreshTable = useCallback(() => { + setRefreshTrigger(prev => prev + 1); + }, []); + + const handleEditDialog = useCallback((show: boolean, selectedData: CustomerData | null) => { + setSelectedCustomerData(show ? selectedData : null); + setShowEditDialog(show); + }, []); + + const handleAddDialog = useCallback((show: boolean) => { + setShowAddDialog(show); + }, []); + + const doGetSearchMemberListData = useCallback( + async (page: any, limit: any, sorting: any, filter: any) => { + try { + const finalSorting = sorting.length === 0 ? [{ id: 'name', desc: true }] : sorting; + + const filterToUse = Object.keys(currentFilters).length > 0 ? currentFilters : (filter.length ? filter : []); + + const response = await GetData(`${API_URL}/customer/list`, { + limit: limit, + page: page + 1, + with_deleted: false, + order_field: 'username', + order_direction: 'DESC', + filter: JSON.stringify(filterToUse), + specialFilter: true + }); + + return { + data: response?.data?.list || [], + totalCount: response?.data?.total_count || 0 + }; + } catch (error) { + console.error('Fetch error:', error); + return { data: [], totalCount: 0 }; + } + }, + [GetData, currentFilters] + ); + + const columns = useMemo[]>( + () => [ + { + accessorFn: (row) => row.username, + id: 'username', + header: ({ column }) => , + enableSorting: true, + enableHiding: false + }, + { + accessorFn: (row) => row.fullname, + id: 'fullname', + header: ({ column }) => , + enableSorting: true, + enableHiding: false + }, + { + accessorFn: (row) => row.msisdn, + id: 'phone_number', + header: ({ column }) => , + enableSorting: true + }, + { + accessorFn: (row) => row.destinationGroup, + id: 'group', + header: ({ column }) => , + enableSorting: true + }, + { + accessorFn: (row) => row.email, + id: 'email', + header: ({ column }) => , + enableSorting: true + }, + { + accessorFn: (row) => new Date(row.created_at).toLocaleString(), + id: 'created_at', + header: ({ column }) => , + enableSorting: true + }, + { + id: 'actions', + header: ({ column }) => , + enableSorting: false, + enableHiding: false, + cell: (data) => { + const row = data.row.original; + return ( + <> + + + ); + }, + meta: { headerClassName: 'w-[100px]', cellClassName: 'text-center' } + } + ], + [handleEditDialog] + ); + + const defaultSorting = useMemo(() => [{ id: 'name', desc: true }], []); + + const contextValue = useMemo( + () => ({ + showEditDialog, + handleEditDialog, + showAddDialog, + handleAddDialog, + selectedCustomerData, + doGetSearchMemberListData, + showTable, + showFilterCard, + currentFilters, + handleCustomFilter, + handleResetFilter, + handleShowSearchAgain, + refreshTable + }), + [ + showEditDialog, + handleEditDialog, + showAddDialog, + handleAddDialog, + selectedCustomerData, + doGetSearchMemberListData, + showTable, + showFilterCard, + currentFilters, + handleCustomFilter, + handleResetFilter, + handleShowSearchAgain, + refreshTable + ] + ); + + return ( +
+ +
+ {showFilterCard && ( +
+
+

Search Members

+ +
+
+ )} + + {/* Table with Reset Button - Only visible after filter is applied */} + {showTable && ( +
+ {/* Reset Button above table */} +
+

Search Results

+ +
+ +
+ + doGetSearchMemberListData(pageIndex, pageSize, sorting, columnFilters) + } + > + {children} + +
+
+ )} +
+ +
+
+ ); +}; + +export {ManageSearchMemberContext, ManageSearchMemberContextProvider }; \ No newline at end of file diff --git a/src/pages/members/search-member/hooks/useManageSearchMemberContext.tsx b/src/pages/members/search-member/hooks/useManageSearchMemberContext.tsx new file mode 100644 index 0000000..042c29a --- /dev/null +++ b/src/pages/members/search-member/hooks/useManageSearchMemberContext.tsx @@ -0,0 +1,11 @@ +import { useContext } from 'react'; +// import { ManageSearchMemberContext } from '../ManageSearchMember'; +import {ManageSearchMemberContext } from './ManageSearchMember'; + +export const useManageSearchMemberContext = () => { + const context = useContext(ManageSearchMemberContext); + if (!context) { + throw new Error('useManageSearchMemberContext must be used within ManageSearchMemberContextProvider'); + } + return context; +}; \ No newline at end of file diff --git a/src/routing/AppRoutingSetup.tsx b/src/routing/AppRoutingSetup.tsx index ab42475..3544d1e 100644 --- a/src/routing/AppRoutingSetup.tsx +++ b/src/routing/AppRoutingSetup.tsx @@ -49,6 +49,7 @@ import AgentBalance from '@/pages/members/agent-balance/AgentBalance'; // DISBURSEMENT import HistoryTransactionDisbursement from '@/pages/disbursement/history-transaction/HistoryTransaction'; +import SearchMember from '@/pages/members/search-member/SearchMember'; import WithdrawalEmoney from '@/pages/transaction/withdrawl-emoney/WithdrawalEmoney'; // DISBURSEMENT @@ -94,6 +95,8 @@ const AppRoutingSetup = (): ReactElement => { } /> } /> } /> + } /> + } /> } />