This commit is contained in:
Raja Oktafrianto
2025-06-05 15:06:50 +07:00
parent 51348bb89d
commit a7e0fab1ff
5 changed files with 420 additions and 66 deletions

View File

@ -50,10 +50,13 @@
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
"date-fns": "^3.0.0",
"file-saver": "^2.0.5",
"formik": "^2.4.6",
"helmet": "^8.1.0",
"html2canvas": "^1.4.1",
"https": "^1.0.0",
"jspdf": "^3.0.1",
"jspdf-autotable": "^5.0.2",
"leaflet": "^1.9.4",
"lucide-react": "^0.456.0",
"metronic-tailwind-react": "file:",
@ -86,10 +89,12 @@
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7",
"vite-plugin-windicss": "^1.9.3",
"xlsx": "^0.18.5",
"yup": "^1.4.0"
},
"devDependencies": {
"@eslint/js": "^9.14.0",
"@types/file-saver": "^2.0.7",
"@types/leaflet": "^1.9.14",
"@types/node": "^22.9.0",
"@types/react": "^18.3.12",

View File

@ -28,6 +28,7 @@ import { doSaveLogActivity } from '@/actions/GlobalActions';
import { CustomerProps } from '@/pages/master/provider/blocks/AddDialog';
import { Check, ChevronDown } from 'lucide-react';
import { initialStateNotification, selectedNotification, validateFormNotification } from './Types';
import { useDebounce } from './useDebounce';
const API_URL_CUSTOMER = apiConfig.service_customer;
const API_URL_NOTIFICATION = apiConfig.service_notification;
@ -39,6 +40,8 @@ const AddDialog = () => {
const [alert, setAlert] = useState({ show: false, message: '' });
const isSubmittingRef = useRef(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [searchQuery, setSearchQuery] = useState('');
const debouncedSearchQuery = useDebounce(searchQuery, 500);
const [formField, setFormField] = useState<selectedNotification>(initialStateNotification);
const [open, setOpen] = useState(false);
@ -95,7 +98,6 @@ const AddDialog = () => {
}
} catch (err) {
toast.error('Something went wrong');
console.log(err);
} finally {
isSubmittingRef.current = false;
setIsSubmitting(false);
@ -104,22 +106,35 @@ const AddDialog = () => {
[PostData, formField, handleAddDialog, reload]
);
const getCustomerList = async (sorting: any) => {
try {
sorting = sorting.length == 0 ? [{ id: 'name', desc: false }] : sorting;
const response = await GetData(`${API_URL_CUSTOMER}/customer/list`, {
limit: 100,
page: 1,
with_deleted: false,
order_field: sorting[0].id,
order_direction: sorting[0].desc == false ? 'ASC' : 'DESC'
});
useEffect(() => {
const fetchCustomers = async () => {
try {
const filters = [];
setCustomers(response?.data.list);
} catch (error) {
console.error('Error fetching customer', error);
}
};
if (debouncedSearchQuery) {
filters.push({
id: 'fullname',
value: debouncedSearchQuery
});
}
const response = await GetData(`${API_URL_CUSTOMER}/customer/list`, {
limit: 100,
page: 1,
with_deleted: false,
order_field: 'fullname',
order_direction: 'ASC',
filter: JSON.stringify(filters),
specialFilter: filters.length > 0
});
setCustomers(response?.data.list);
} catch (error) {
console.error('Error fetching customers', error);
}
};
fetchCustomers();
}, [debouncedSearchQuery]);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
@ -127,10 +142,6 @@ const AddDialog = () => {
doCreateNotification(e);
};
useEffect(() => {
getCustomerList([{ id: 'id', desc: false }]);
}, []);
useEffect(() => {
if (showAddDialog === false) {
resetForm();
@ -217,7 +228,11 @@ const AddDialog = () => {
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0">
<Command>
<CommandInput placeholder="Search Customer..." />
<CommandInput
placeholder="Search Customer..."
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList
className="max-h-[300px] overflow-y-auto"
style={{ touchAction: 'pan-y' }}
@ -232,24 +247,20 @@ const AddDialog = () => {
return (
<CommandItem
key={customer.id}
value={customer.username}
value={customer.fullname}
onSelect={() => {
const customerId = customer.id;
setFormField((prev) => {
const isSelected = prev.customers.includes(customerId);
return {
...prev,
customers: isSelected
? prev.customers.filter((id) => id !== customerId)
: [...prev.customers, customerId]
};
});
setFormField((prev) => ({
...prev,
customers: isSelected
? prev.customers.filter((id) => id !== customer.id)
: [...prev.customers, customer.id]
}));
}}
className={isSelected ? 'bg-accent font-semibold' : ''}
>
<span className="flex items-center gap-2">
{isSelected && <Check className="w-4 h-4 text-green-600" />}
{customer.username}
{customer.fullname} ({customer.msisdn})
</span>
</CommandItem>
);

View File

@ -0,0 +1,17 @@
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}

View File

@ -1,4 +1,4 @@
import React, { useCallback, useState, useEffect } from 'react';
import React, { useCallback, useState, useEffect, useContext } from 'react';
import { Button } from '@/components/ui/button';
import {
Select,
@ -11,6 +11,7 @@ import { DefaultTooltip, KeenIcon } from '@/components';
import { toAbsoluteUrl } from '@/utils';
import { useCallApi } from '@/hooks';
import { apiConfig } from '@/config/api.config';
import { showDetailWalletContext } from './ShowDetailDialog';
interface TransferType {
id: string;
@ -40,24 +41,44 @@ const FilterSection: React.FC<FilterSectionProps> = ({
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [category, setCategory] = useState<string[]>([]);
const [searchValue, setSearchValue] = useState('');
const { doExportExcel, doExportPDF } = useContext(showDetailWalletContext);
const handleExportExcel = async () => {
try {
await doExportExcel();
// console.log('Excel export initiated successfully');
} catch (error) {
console.error('Error during Excel export:', error);
}
};
const handleExportPDF = async () => {
try {
await doExportPDF();
// console.log('PDF export initiated successfully');
} catch (error) {
console.error('Error during PDF export:', error);
}
};
const fetchTransferType = useCallback(async () => {
try {
const response = await GetData(`${apiConfig.service_transaction}/transactiontype/list`, {
limit: 100,
page: 1,
with_deleted: false,
order_field: 'created_at',
order_direction: 'ASC'
});
const sortedData = (response?.data?.list || [])
.sort((a: any, b: any) => a.name.localeCompare(b.name));
setTransferType(sortedData);
} catch (error) {
console.error('Error fetching transfer types', error);
}
}, [GetData]);
try {
const response = await GetData(`${apiConfig.service_transaction}/transactiontype/list`, {
limit: 100,
page: 1,
with_deleted: false,
order_field: 'created_at',
order_direction: 'ASC'
});
const sortedData = (response?.data?.list || []).sort((a: any, b: any) =>
a.name.localeCompare(b.name)
);
setTransferType(sortedData);
} catch (error) {
console.error('Error fetching transfer types', error);
}
}, [GetData]);
useEffect(() => {
fetchTransferType();
@ -70,8 +91,8 @@ const FilterSection: React.FC<FilterSectionProps> = ({
);
useEffect(() => {
setCategory(categories);
}, [transaction, categoryMap]);
setCategory(categories);
}, [transaction, categoryMap]);
const reverseCategoryMap = Object.fromEntries(
Object.entries(categoryMap).map(([key, value]) => [value, key])
@ -108,7 +129,7 @@ const FilterSection: React.FC<FilterSectionProps> = ({
};
}
if (selectedTransferType) {
if (selectedTransferType) {
appliedFilters.id_transaction_type = selectedTransferType;
}
@ -200,20 +221,22 @@ const FilterSection: React.FC<FilterSectionProps> = ({
Filter Data
</Button>
<DefaultTooltip title={'Export Data'} placement={'top'}>
<Button variant={'outline'} className="h-7.5 min-w-[58px]">
<img src={toAbsoluteUrl('/media/file-types/xls.svg')} className="" alt="" />
</Button>
</DefaultTooltip>
<div className="flex gap-2">
<DefaultTooltip title={'Export to Excel'} placement={'top'}>
<Button variant={'outline'} className="h-7.5 min-w-[58px]" onClick={handleExportExcel}>
<img src={toAbsoluteUrl('/media/file-types/xls.svg')} className="" alt="Excel" />
</Button>
</DefaultTooltip>
<DefaultTooltip title={'Export Data'} placement={'top'}>
<Button variant={'outline'} className="h-7.5 min-w-[58px]">
<img src={toAbsoluteUrl('/media/file-types/pdf.svg')} className="" alt="" />
</Button>
</DefaultTooltip>
<DefaultTooltip title={'Export to PDF'} placement={'top'}>
<Button variant={'outline'} className="h-7.5 min-w-[58px]" onClick={handleExportPDF}>
<img src={toAbsoluteUrl('/media/file-types/pdf.svg')} className="" alt="PDF" />
</Button>
</DefaultTooltip>
</div>
</div>
</div>
);
};
export default FilterSection;
export default FilterSection;

View File

@ -14,13 +14,20 @@ import { useManageStatementContext } from '../hooks/useManageWalletStatementCont
import { Toaster } from 'sonner';
import { format } from 'date-fns';
import FilterSection from './FilterSection';
import * as XLSX from 'xlsx';
import jsPDF from 'jspdf';
import 'jspdf-autotable';
interface ContextProps {
doExportData: (sorting: any, filter: any) => Promise<any>;
doExportExcel: () => Promise<void>;
doExportPDF: () => Promise<void>;
}
const initialProps: ContextProps = {
doExportData: async () => ({ data: [], totalCount: 0 })
doExportData: async () => ({ data: [], totalCount: 0 }),
doExportExcel: async () => {},
doExportPDF: async () => {}
};
export const showDetailWalletContext = createContext<ContextProps>(initialProps);
@ -174,6 +181,297 @@ const ShowDetailWalletDialog = () => {
[GetData, filters, selectedWallet]
);
const getAllTransactionData = async () => {
try {
const response = await GetData(
`${apiConfig.service_wallet}/dashboard/balance/list-balance-detail/${selectedWallet?.ID}`,
{
limit: 10000,
page: 1,
with_deleted: false,
order_field: 'amount',
order_direction: 'DESC',
filter: JSON.stringify(filters)
}
);
return response?.data?.list || [];
} catch (error) {
console.error('Error fetching all transaction data', error);
return [];
}
};
const formatTransactionData = (data: any[]) => {
return data.map((item) => ({
'Transaction Code': item.transaction_code || '-',
'MSISDN Reffer': item.msisdn_reff || '-',
'Transaction Type': item.transaction_type?.name || '-',
Type: item.type || '-',
'Pre Amount': item.pre_amount?.toFixed(2) || '0.00',
Amount: item.amount?.toFixed(2) || '0.00',
'Post Amount': item.post_amount?.toFixed(2) || '0.00',
Category: categoryMap[item.category] || '-',
Date: item.date
? new Date(item.date).toLocaleString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
: '-',
Purpose: item.purpose || '-',
Notes: item.notes || '-'
}));
};
const doExportExcel = async () => {
try {
const allData = await getAllTransactionData();
const formattedData = formatTransactionData(allData);
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.json_to_sheet([]);
const companyHeader = [
['T•PAY'],
['Telin Digital Solution'],
['Av. Pres. Nicolau Lobato, Dili'],
['Tel: (+670) 74147147 / 147'],
['www.t-pay.tl'],
[''],
['Wallet Statement Details'],
[`Export Date: ${format(new Date(), 'dd MMM yyyy HH:mm:ss')}`],
[`Wallet ID: ${selectedWallet?.ID || '-'}`],
['']
];
const columnHeaders = [
[
'Transaction Code',
'MSISDN Reffer',
'Transaction Type',
'Type',
'Pre Amount',
'Amount',
'Post Amount',
'Category',
'Date',
'Purpose',
'Notes'
]
];
const excelData = [
...companyHeader,
...columnHeaders,
...formattedData.map((item) => Object.values(item))
];
XLSX.utils.sheet_add_aoa(ws, excelData);
const colWidths = [
{ wch: 20 },
{ wch: 15 },
{ wch: 25 },
{ wch: 10 },
{ wch: 12 },
{ wch: 12 },
{ wch: 12 },
{ wch: 15 },
{ wch: 20 },
{ wch: 25 },
{ wch: 25 }
];
ws['!cols'] = colWidths;
const tpayCell = XLSX.utils.encode_cell({ r: 0, c: 0 });
if (ws[tpayCell]) {
ws[tpayCell].s = {
font: { bold: true, sz: 18, color: { rgb: '000000' } },
alignment: { horizontal: 'center' },
fill: { fgColor: { rgb: 'F4C2C2' } }
};
}
for (let i = 1; i < 5; i++) {
const cellRef = XLSX.utils.encode_cell({ r: i, c: 0 });
if (ws[cellRef]) {
ws[cellRef].s = {
font: { sz: 12, color: { rgb: '000000' } },
alignment: { horizontal: 'center' },
fill: { fgColor: { rgb: 'F4C2C2' } }
};
}
}
const titleCell = XLSX.utils.encode_cell({ r: 6, c: 0 });
if (ws[titleCell]) {
ws[titleCell].s = {
font: { bold: true, sz: 14 },
alignment: { horizontal: 'center' }
};
}
for (let i = 7; i < 9; i++) {
const cellRef = XLSX.utils.encode_cell({ r: i, c: 0 });
if (ws[cellRef]) {
ws[cellRef].s = {
font: { sz: 11 },
alignment: { horizontal: 'left' }
};
}
}
for (let i = 0; i < 6; i++) {
const range = XLSX.utils.encode_range({ s: { r: i, c: 0 }, e: { r: i, c: 10 } });
if (!ws['!merges']) ws['!merges'] = [];
ws['!merges'].push(XLSX.utils.decode_range(range));
}
for (let col = 0; col < columnHeaders[0].length; col++) {
const cellRef = XLSX.utils.encode_cell({ r: 10, c: col });
if (ws[cellRef]) {
ws[cellRef].s = {
font: { bold: true, color: { rgb: 'FFFFFF' } },
fill: { fgColor: { rgb: '4472C4' } },
alignment: { horizontal: 'center' },
border: {
top: { style: 'thin', color: { rgb: '000000' } },
bottom: { style: 'thin', color: { rgb: '000000' } },
left: { style: 'thin', color: { rgb: '000000' } },
right: { style: 'thin', color: { rgb: '000000' } }
}
};
}
}
for (let row = 11; row < 11 + formattedData.length; row++) {
for (let col = 0; col < columnHeaders[0].length; col++) {
const cellRef = XLSX.utils.encode_cell({ r: row, c: col });
if (ws[cellRef]) {
ws[cellRef].s = {
border: {
top: { style: 'thin', color: { rgb: 'D9D9D9' } },
bottom: { style: 'thin', color: { rgb: 'D9D9D9' } },
left: { style: 'thin', color: { rgb: 'D9D9D9' } },
right: { style: 'thin', color: { rgb: 'D9D9D9' } }
}
};
if ([4, 5, 6].includes(col)) {
ws[cellRef].s.alignment = { horizontal: 'right' };
}
}
}
}
XLSX.utils.book_append_sheet(wb, ws, 'Wallet Statement');
const fileName = `wallet_statement_${selectedWallet?.ID}_${format(new Date(), 'yyyyMMdd_HHmmss')}.xlsx`;
XLSX.writeFile(wb, fileName);
// console.log('Excel export completed successfully');
} catch (error) {
console.error('Error exporting to Excel:', error);
}
};
const doExportPDF = async () => {
try {
const allData = await getAllTransactionData();
const formattedData = formatTransactionData(allData);
const pdf = new jsPDF('l', 'mm', 'a4');
pdf.setFontSize(20);
pdf.setFont('helvetica', 'bold');
pdf.text('T•PAY', 148, 20, { align: 'center' });
pdf.setFontSize(12);
pdf.setFont('helvetica', 'normal');
pdf.text('Telin Digital Solution', 148, 30, { align: 'center' });
pdf.text('Av. Pres. Nicolau Lobato, Dili', 148, 36, { align: 'center' });
pdf.text('Tel : (+670) 7414747 / 147', 148, 42, { align: 'center' });
pdf.text('www.t-pay.tl', 148, 48, { align: 'center' });
pdf.setFontSize(14);
pdf.setFont('helvetica', 'bold');
pdf.text('Wallet Statement Details', 148, 60, { align: 'center' });
pdf.setFontSize(10);
pdf.setFont('helvetica', 'normal');
pdf.text(`Export Date: ${format(new Date(), 'dd MMM yyyy HH:mm:ss')}`, 20, 70);
pdf.text(`Wallet ID: ${selectedWallet?.ID || '-'}`, 20, 76);
const tableData = formattedData.map((item) => [
item['Transaction Code'],
item['MSISDN Reffer'],
item['Transaction Type'],
item['Type'],
item['Pre Amount'],
item['Amount'],
item['Post Amount'],
item['Category'],
item['Date'],
item['Purpose'],
item['Notes']
]);
(pdf as any).autoTable({
head: [
[
'Transaction Code',
'MSISDN Reffer',
'Transaction Type',
'Type',
'Pre Amount',
'Amount',
'Post Amount',
'Category',
'Date',
'Purpose',
'Notes'
]
],
body: tableData,
startY: 85,
styles: {
fontSize: 8,
cellPadding: 2
},
headStyles: {
fillColor: [66, 66, 66],
textColor: [255, 255, 255],
fontStyle: 'bold'
},
columnStyles: {
0: { cellWidth: 20 },
1: { cellWidth: 20 },
2: { cellWidth: 25 },
3: { cellWidth: 15 },
4: { cellWidth: 18 },
5: { cellWidth: 18 },
6: { cellWidth: 18 },
7: { cellWidth: 25 },
8: { cellWidth: 25 },
9: { cellWidth: 30 },
10: { cellWidth: 30 }
},
margin: { top: 85, left: 10, right: 10 }
});
const fileName = `wallet_statement_${selectedWallet?.ID}_${format(new Date(), 'yyyyMMdd_HHmmss')}.pdf`;
pdf.save(fileName);
// console.log('PDF export completed successfully');
} catch (error) {
console.error('Error exporting to PDF:', error);
}
};
const doExportData = async (sorting: any, filter: any) => {
const user = localStorage.getItem(
`${import.meta.env.VITE_APP_NAME}-auth-v${import.meta.env.VITE_APP_VERSION}`
@ -219,7 +517,7 @@ const ShowDetailWalletDialog = () => {
};
return (
<showDetailWalletContext.Provider value={{ doExportData }}>
<showDetailWalletContext.Provider value={{ doExportData, doExportExcel, doExportPDF }}>
<Toaster expand visibleToasts={9} duration={3000} />
<Dialog open={showDetailDialog} onOpenChange={setShowDetailDialog}>
<DialogContent className="w-screen max-w-screen max-h-screen p-4 overflow-hidden">