diff --git a/src/config/api.config.ts b/src/config/api.config.ts index 2c6cef5..81e8652 100644 --- a/src/config/api.config.ts +++ b/src/config/api.config.ts @@ -6,6 +6,7 @@ interface apiConfigProps { service_wallet: string; transaction: string; nationality: string; + service_disbursement: string; } const API_URL = import.meta.env.VITE_APP_API_URL; @@ -18,6 +19,7 @@ const apiConfig: apiConfigProps = { service_transaction: `${API_URL}/tt`, service_wallet: `${API_URL}/w`, transaction: `${API_URL}/x`, + service_disbursement: `${API_URL}/s`, nationality: `https://tpay.shiblysolution.id/cms/api/mobile/list-country/ ` }; diff --git a/src/hooks/useCallApi.ts b/src/hooks/useCallApi.ts index a359418..c610dd0 100644 --- a/src/hooks/useCallApi.ts +++ b/src/hooks/useCallApi.ts @@ -57,6 +57,24 @@ const useCallApi = () => { } }, []); + const PostDataFile = useCallback(async (url: string, field: any, config = {}) => { + try { + const response = await axios.post(url, field, config); + const result = response.data; + + if (result.status) { + return { status: true, message: result.data }; + } + return { status: false, message: result.message }; + } catch (error: any) { + const { response } = error; + return { + status: false, + message: response?.data?.error ?? response?.data?.message ?? 'Something went wrong' + }; + } + }, []); + const PutData = useCallback(async (url: string, field: any) => { try { const response = await axios.put(url, field); @@ -85,7 +103,7 @@ const useCallApi = () => { } }, []); - return { GetData, PostData, PutData, DeleteData, GetExportData }; + return { GetData, PostData, PutData, DeleteData, GetExportData, PostDataFile }; }; export { useCallApi }; diff --git a/src/pages/disbursement/history-transaction/HistoryTransaction.tsx b/src/pages/disbursement/history-transaction/HistoryTransaction.tsx new file mode 100644 index 0000000..dc6c51f --- /dev/null +++ b/src/pages/disbursement/history-transaction/HistoryTransaction.tsx @@ -0,0 +1,39 @@ +import { Container, DataGridInner } from '@/components'; +import { TransactionProvider } from './hooks/TransactionContext'; +import { Breadcrumbs, Link } from '@mui/material'; +import { Helmet } from 'react-helmet'; +import { UploadBatchDialog } from './blocks/UploadBatchDialog'; + +const HistoryTransactionDisbursement = () => { + return ( + <> + + TPAY | History Disbursement + + + +

DISBURSEMENT

+ + + Dashboard + + + + Disbursement + + + + History Transaction + + +
+ +
+ +
+
+ + ); +}; + +export default HistoryTransactionDisbursement; diff --git a/src/pages/disbursement/history-transaction/blocks/DetailTransaction.tsx b/src/pages/disbursement/history-transaction/blocks/DetailTransaction.tsx new file mode 100644 index 0000000..28df134 --- /dev/null +++ b/src/pages/disbursement/history-transaction/blocks/DetailTransaction.tsx @@ -0,0 +1,137 @@ +import { useTransactionContext } from '../hooks/useTransactionContext'; +import { useCallApi } from '@/hooks'; +import { apiConfig } from '@/config/api.config'; +import { useEffect, useState } from 'react'; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog'; + +const API_URL = apiConfig.service_disbursement; + +type StatusCode = 'W' | 'P' | 'F' | 'D' | 'Y'; + +interface StatusInfo { + label: string; + bg: string; + text: string; +} + +const statusMap: Record = { + W: { label: 'Waiting', bg: 'bg-yellow-100', text: 'text-yellow-600' }, + P: { label: 'Pending', bg: 'bg-blue-100', text: 'text-blue-600' }, + F: { label: 'Fail', bg: 'bg-red-100', text: 'text-red-600' }, + D: { label: 'Done', bg: 'bg-green-100', text: 'text-green-600' }, + Y: { label: 'Active', bg: 'bg-green-100', text: 'text-green-600' }, +}; + +export const renderStatusBadge = (statusRaw: string | null | undefined) => { + const status = statusRaw as StatusCode; + const { label, bg, text } = statusMap[status] ?? { + label: 'Unknown', + bg: 'bg-gray-100', + text: 'text-gray-600', + }; + + return ( + + {label} + + ); + }; + +const DetailTransaction = () => { + const { GetData } = useCallApi(); + const { + showDetailDialog, + setShowDetailDialog, + selectedTransactionId + } = useTransactionContext(); + + const [transactionDetails, setTransactionDetails] = useState(null); + + useEffect(() => { + const fetchTransactionDetails = async () => { + console.log(selectedTransactionId) + if (selectedTransactionId) { + try { + const response = await GetData(`${API_URL}/transaction/history/${selectedTransactionId}`, { + id: selectedTransactionId + }); + // console.log(response?.data); + setTransactionDetails(response?.data); + } catch (error) { + console.error('Error fetching transaction', error); + } + } + }; + + if (showDetailDialog && selectedTransactionId) { + fetchTransactionDetails(); + } + }, [showDetailDialog, selectedTransactionId, GetData]); + + const [activeTab, setActiveTab] = useState('detail'); // 'detail', 'log', 'approve' + + return ( + + + + Transaction Details + + + {/* Tab Content */} +
+
+
+ + + + + + + + + + + + + + + {transactionDetails?.log && transactionDetails?.log.length > 0 ? ( + transactionDetails.log.map((log: { customer: any, amount: number, remark: string, reference: string, response_date: string, payment_response: string, status: string}, index: number) => ( + + + + + + + + + + + )) + ) : ( + + + + )} + +
UsernameNameTransfer AmountDescriptionInvoice NumberProcess DateResponseStatus
{log.customer.username ?? '-'}{log.customer.fullname ?? '-'}{log.amount ?? '-'}{log.remark ?? '-'}{log.reference ?? '-'}{log.response_date ?? '-'}{log.payment_response ?? '-'}{renderStatusBadge(log.status) ?? '-'}
+ No logs available +
+
+
+ +
+
+
+
+ ); +}; + +export default DetailTransaction; diff --git a/src/pages/disbursement/history-transaction/blocks/ListToolbar.tsx b/src/pages/disbursement/history-transaction/blocks/ListToolbar.tsx new file mode 100644 index 0000000..4b6c69f --- /dev/null +++ b/src/pages/disbursement/history-transaction/blocks/ListToolbar.tsx @@ -0,0 +1,70 @@ +import { DefaultTooltip, KeenIcon, useDataGrid } from '@/components'; +import { useTransactionContext } from '../hooks/useTransactionContext'; +import { Button } from '@/components/ui/button'; +import { useCallback, useState, useEffect } from 'react'; +import { toast } from 'sonner'; + +const ListToolbar = () => { + const { table, reload } = useDataGrid(); + const { handleUploadBatchDialog } = useTransactionContext(); + + // Set the initial state for trxDate + const [trxDate, settrxDate] = useState({ from: '', to: '' }); + + // Function to format date to YYYY-MM-DD + const formatDate = (date: Date): string => { + return date.toISOString().split('T')[0]; + }; + + // useEffect to set the default date values + useEffect(() => { + const today = new Date(); + const nextWeek = new Date(today); + nextWeek.setDate(today.getDate() + 7); + + settrxDate({ + from: formatDate(today), // Set 'from' to today + to: formatDate(nextWeek), // Set 'to' to 7 days later + }); + }, []); + + const handleFilterData = useCallback(() => { + try { + table.getColumn('transaction_date')?.setFilterValue(trxDate); + } catch (error) { + toast.error('Error applying filter'); + console.error('Error applying filter:', error); + } + }, [trxDate, table]); + + useEffect(() => { + if (trxDate.from && trxDate.to) { + handleFilterData(); + } + }, [trxDate]); + + return ( +
+
+
+
+ + + + +
+
+
+
+ ); +}; + +export default ListToolbar; diff --git a/src/pages/disbursement/history-transaction/blocks/UploadBatchDialog.tsx b/src/pages/disbursement/history-transaction/blocks/UploadBatchDialog.tsx new file mode 100644 index 0000000..237a72e --- /dev/null +++ b/src/pages/disbursement/history-transaction/blocks/UploadBatchDialog.tsx @@ -0,0 +1,194 @@ +import { MouseEvent, useCallback, useEffect, useRef, useState } from 'react'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select'; + +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog'; +import { useTransactionContext } from '../hooks'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { apiConfig } from '@/config/api.config'; +import { Alert, KeenIcon, useDataGrid } from '@/components'; +import { toast } from 'sonner'; +import { useCallApi } from '@/hooks'; +import { doSaveLogActivity } from '@/actions/GlobalActions'; +import clsx from 'clsx'; + +const API_URL = apiConfig.service_disbursement; + +const UploadBatchDialog = () => { + const parentRef = useRef(null); + const { showUploadBatchDialog, handleUploadBatchDialog } = useTransactionContext(); + const { reload } = useDataGrid(); + const { PostData, PostDataFile, GetData } = useCallApi(); + const [alert, setAlert] = useState({ + show: false, + message: '' + }); + const initialState: { + execution_date: string, + file: File | null; + } = { + execution_date: '', + file: null + } + const [formField, setFormField] = useState(initialState); + + const resetForm = () => { + setFormField(initialState); + setAlert({ show: false, message: '' }); + }; + + /* actions */ + const doUploadBatch = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + + const formData = new FormData(); + formData.append('execution_date', formField.execution_date); + if (formField.file) { + formData.append('file', formField.file); + } + + try { + const response = await PostDataFile(`${API_URL}/upload-excel`, formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + + if (response?.status) { + handleUploadBatchDialog(false); + resetForm(); + reload(); + + const createActivity = { + module: 'Disbursement', + description: `Create New Disbursement => ${formField.file?.name}`, + action: 'C' + }; + + doSaveLogActivity(createActivity); + toast.success('Success Create Disbursement'); + } else { + toast.error('Failed to create disbursement'); + setAlert({ show: true, message: response?.message ?? 'Failed to create disbursement.' }); + } + } catch (error) { + toast.error('Error uploading batch'); + setAlert({ show: true, message: 'Something went wrong. Please try again.' }); + } + }, + [formField] + ); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + console.log('Form data before submit:', formField); + + if ( + formField.execution_date.trim() === '' || + formField.file === null + ) { + setAlert({ show: true, message: 'Please fill in all required fields.' }); + return; + } + + doUploadBatch(e); + // console.log(formField); + setAlert({ show: false, message: '' }); + }; + + useEffect(() => { + if (showUploadBatchDialog === false) { + resetForm(); + } + }, [showUploadBatchDialog]); + + return ( + handleUploadBatchDialog(open)}> + + + + +
+
+

Upload Batch

+
+
+
{ + handleUploadBatchDialog(false); + resetForm(); + }} + > + +
+
+
+ +
+ {alert.show && ( + +

{alert.message}

+
+ )} +
+
+
+
+ + + setFormField((prev) => ({ ...prev, execution_date: target.value })) + } + /> +
+
+
+
+ + + setFormField((prev) => ({ ...prev, file: target.files?.[0] ?? null })) + } + /> +
+
+
+ +
+
+
+
+
+
+
+ ); +}; + +export { UploadBatchDialog }; diff --git a/src/pages/disbursement/history-transaction/hooks/TransactionContext.tsx b/src/pages/disbursement/history-transaction/hooks/TransactionContext.tsx new file mode 100644 index 0000000..19e2382 --- /dev/null +++ b/src/pages/disbursement/history-transaction/hooks/TransactionContext.tsx @@ -0,0 +1,289 @@ +import { DataGridColumnHeader, DataGridProvider, KeenIcon } from '@/components'; +import { Toaster } from '@/components/ui/sonner'; +import { apiConfig } from '@/config/api.config'; +import { ColumnDef } from '@tanstack/react-table'; +import axios from 'axios'; +import React, { createContext, useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallApi } from '@/hooks'; +import ListToolbar from '../blocks/ListToolbar'; +import { Button } from '@/components/ui/button'; +import { useNavigate } from 'react-router'; +import moment from 'moment'; +import DetailTransaction from '../blocks/DetailTransaction'; + +interface TransactionProps { + id: number; + name: string; +} + +interface ContextProps { + getTransactionLists: ( + limit: number, + page: number, + with_deleted: boolean, + order_field: any, + order_direction: any, + filter: any + ) => Promise<{ data: TransactionProps[]; totalCount: number } | undefined>; + showDetailDialog: boolean; + setShowDetailDialog: React.Dispatch>; + selectedTransactionId: number | null; + setSelectedTransactionId: React.Dispatch>; + showUploadBatchDialog: boolean; + handleUploadBatchDialog: (show: boolean) => void; +} + +const initialProps: ContextProps = { + getTransactionLists: async () => ({ data: [], totalCount: 0 }), + showDetailDialog: false, + setShowDetailDialog: () => { }, + selectedTransactionId: null, + setSelectedTransactionId: () => { }, + showUploadBatchDialog: false, + handleUploadBatchDialog: (show: boolean) => {}, +}; + +type StatusCode = 'W' | 'P' | 'F' | 'D' | 'Y'; + +interface StatusInfo { + label: string; + bg: string; + text: string; +} + +const statusMap: Record = { + W: { label: 'Waiting', bg: 'bg-yellow-100', text: 'text-yellow-600' }, + P: { label: 'Pending', bg: 'bg-blue-100', text: 'text-blue-600' }, + F: { label: 'Fail', bg: 'bg-red-100', text: 'text-red-600' }, + D: { label: 'Done', bg: 'bg-green-100', text: 'text-green-600' }, + Y: { label: 'Active', bg: 'bg-green-100', text: 'text-green-600' }, +}; + + +const ManageTransactionContext = createContext(initialProps); +const API_URL = apiConfig.service_disbursement; + +const TransactionProvider = ({ children }: { children: React.ReactNode }) => { + const [showDetailDialog, setShowDetailDialog] = useState(false); + const [selectedTransactionId, setSelectedTransactionId] = useState(null); + const [showUploadBatchDialog, setShowUploadBatchDialog] = useState(false); + const [transaction, setTransaction] = useState([]); + const { GetData } = useCallApi(); + const navigate = useNavigate(); + + const handleUploadBatchDialog = useCallback((show: boolean) => { + setShowUploadBatchDialog(show); + }, []); + + const columns = useMemo[]>( + () => [ + // { + // accessorKey: 'transaction_date', + // header: ({ column }) => , + // enableSorting: false, + // enableHiding: false, + // meta: { + // headerClassName: 'w-[250px]' + // } + // }, + { + accessorKey: 'file_name', + header: ({ column }) => , + enableSorting: false, + enableHiding: false, + meta: { + headerClassName: 'w-[250px]' + }, + }, + { + accessorFn: (row) => { + return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(row.amount); + }, + id: 'amount', + header: ({ column }) => , + enableSorting: false, + enableHiding: false, + meta: { + headerClassName: 'w-[250px]', + }, + }, + { + accessorKey: 'total_record', + header: ({ column }) => , + enableSorting: false, + enableHiding: false, + meta: { + headerClassName: 'w-[250px]' + }, + }, + { + accessorKey: 'total_success', + header: ({ column }) => , + enableSorting: false, + enableHiding: false, + meta: { + headerClassName: 'w-[250px]' + }, + }, + { + accessorKey: 'total_fail', + header: ({ column }) => , + enableSorting: false, + enableHiding: false, + meta: { + headerClassName: 'w-[250px]' + }, + }, + { + accessorKey: 'total_pending', + header: ({ column }) => , + enableSorting: false, + enableHiding: false, + meta: { + headerClassName: 'w-[250px]' + }, + }, + { + accessorFn: (row) => row.status, + id: 'status', + header: ({ column }) => , + enableSorting: false, + enableHiding: false, + cell: ({ row }) => { + const status = row.original.status as StatusCode; + const { label, bg, text } = statusMap[status] ?? { + label: 'Unknown', + bg: 'bg-gray-100', + text: 'text-gray-600', + }; + + return ( + + {label} + + ); + }, + meta: { + headerClassName: 'w-[100px]', + cellClassName: 'text-center', + }, + }, + { + accessorFn: (row) => row.execution_date, + id: 'execution_date', + header: ({ column }) => , + enableSorting: true, + enableHiding: false, + cell: ({ row }) => moment(row.original.execution_date).format('YYYY-MM-DD HH:mm:ss') + }, + { + accessorFn: (row) => row.done_date, + id: 'done_date', + header: ({ column }) => , + enableSorting: true, + enableHiding: false, + cell: ({ row }) => row.original.done_date ? moment(row.original.done_date).format('YYYY-MM-DD HH:mm:ss') : '' + }, + { + id: 'actions', + header: ({ column }) => , + enableSorting: false, + enableHiding: false, + cell: (data) => { + const row = data.row.original; + return ( +
+ +
+ ); + }, + meta: { + headerClassName: 'w-[100px]', + cellClassName: 'text-center' + } + } + ], + []); + + const getTransactionLists = async (page: number, limit: number, sorting: any, filter: any) => { + try { + let startdate; + let enddate; + let formattedFilter; + + if (filter == undefined || filter.length == 0) { + const today = new Date(); + const nextWeek = new Date(); + nextWeek.setDate(today.getDate() + 7); + + startdate = today.toISOString().split('T')[0]; + enddate = nextWeek.toISOString().split('T')[0]; + } else if (filter != undefined || filter.length != 0) { + startdate = filter[0].value.from; + enddate = filter[0].value.to; + } + + formattedFilter = { + + }; + + const response = await GetData(`${API_URL}/transaction/history`, { + limit, + page: page + 1, + with_deleted: false, + order_field: "id", + order_direction: 'DESC', + filter: JSON.stringify(formattedFilter) + }); + + setTransaction(response?.data.list); + return { data: response?.data.list, totalCount: response?.data.total_count }; + } catch (error) { + console.error('Error fetching transaction', error); + } + }; + + return ( + + + + + } + layout={{ card: true }} + sorting={[{ id: 'id', desc: false }]} + serverSide={true} + onFetchData={({ pageIndex, pageSize, sorting, columnFilters }) => + getTransactionLists(pageIndex, pageSize, sorting, columnFilters) + } + > + {children} + + + ); +}; + +export { TransactionProvider, ManageTransactionContext }; +export type { TransactionProps }; diff --git a/src/pages/disbursement/history-transaction/hooks/index.tsx b/src/pages/disbursement/history-transaction/hooks/index.tsx new file mode 100644 index 0000000..d25a2f6 --- /dev/null +++ b/src/pages/disbursement/history-transaction/hooks/index.tsx @@ -0,0 +1,2 @@ +export * from './TransactionContext'; +export * from './useTransactionContext'; diff --git a/src/pages/disbursement/history-transaction/hooks/useTransactionContext.tsx b/src/pages/disbursement/history-transaction/hooks/useTransactionContext.tsx new file mode 100644 index 0000000..4af7036 --- /dev/null +++ b/src/pages/disbursement/history-transaction/hooks/useTransactionContext.tsx @@ -0,0 +1,12 @@ +import { useContext } from 'react'; +import { ManageTransactionContext } from './TransactionContext'; + +const useTransactionContext = () => { + const context = useContext(ManageTransactionContext); + + if (!context) throw new Error('useTransactionContext must be used within AuthProvider'); + + return context; +}; + +export { useTransactionContext }; diff --git a/src/routing/AppRoutingSetup.tsx b/src/routing/AppRoutingSetup.tsx index 98f78b3..b7ce41f 100644 --- a/src/routing/AppRoutingSetup.tsx +++ b/src/routing/AppRoutingSetup.tsx @@ -40,6 +40,10 @@ import WalletHistory from '@/pages/wallet/wallet-history/WalletHistory'; import WalletMaster from '@/pages/master/wallet/WalletMaster'; import CurrencyMaster from '@/pages/master/currency/CurrencyMaster'; +// DISBURSEMENT +import HistoryTransactionDisbursement from '@/pages/disbursement/history-transaction/HistoryTransaction' +// DISBURSEMENT + const AppRoutingSetup = (): ReactElement => { return ( @@ -100,6 +104,11 @@ const AppRoutingSetup = (): ReactElement => { path="/settings/user-management/manage-position" element={} /> + + {/* DISBURSEMENT */} + } /> + {/* DISBURSEMENT */} + } />