Add multisender functionality with UI components and blockchain integration

This commit is contained in:
2025-11-20 16:25:41 +09:00
parent b455d5699a
commit 20eb3a0e81
9 changed files with 813 additions and 139 deletions

237
multisender_sample.html Normal file
View File

@@ -0,0 +1,237 @@
<html>
<head>
<title>MULTI SENDER</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no">
<style>
:root {
--gap: 20px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
margin: 0;
font-family: sans-serif;
font-size: 12px;
scroll-snap-type: y mandatory;
scroll-behavior: smooth;
scroll-padding-top: 0;
overscroll-behavior: contain;
}
html, body {
margin: 0;
font-family: sans-serif;
}
.wrap {
display: flex;
flex-direction: row;
justify-content: center;
align-items: start;
padding: var(--gap);
gap: var(--gap);
}
.wrap fieldset {
flex-grow: 1;
padding: 1.5rem;
border: 1px solid #CCC;
border-radius: 0.5rem;
height: calc(100vh - 80px);
}
.wrap fieldset:first-child {
flex-grow: 0;
flex-basis: 500px;
}
.wrap fieldset legend {
font-size: 1.5rem;
font-weight: bold;
padding: 0 2rem 0 .75rem;
}
fieldset ul {
list-style: none;
padding: 0;
}
fieldset ul > li {
display: flex;
flex-direction: column;
margin-bottom: 2rem;
}
fieldset label {
font-size: 1.2rem;
font-weight: bold;
margin-bottom: 0.5rem;
padding-left: 0.375rem;
}
fieldset label+input {
display: block;
width: 100%;
height: 3rem;
padding: .375rem .75rem;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #495057;
background-color: #fff;
background-clip: padding-box;
border: 1px solid #ced4da;
border-radius: .5rem;
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
}
fieldset label+input:focus {
color: #495057;
background-color: #fff;
border-color: #808080;
outline: 0;
box-shadow: 0 0 0 .2rem rgba(100, 100, 100, .25);
}
fieldset label+input[type="file"] {
display: none;
}
fieldset input[type="file"]+label {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 10rem;
border: 2px dotted #CCC;
border-radius: .5rem;
}
fieldset input[type="file"]+label::before {
content: '업로드 할 파일을 선택하거나 드레그&드롭하세요.';
color: rgba(0,0,0,.5);
font-weight: 400;
text-shadow: 0 0 2px rgba(0,0,0,.7);
}
fieldset table {
display: table;
width: 100%;
caption-side: bottom;
border-collapse: separate;
box-sizing: border-box;
border-spacing: 1px;
border-color: gray;
background-color: #aaa;
font-size: 1.2rem;
}
fieldset thead {
background-color: rgba(0,0,0,.7);
border-bottom: 2px solid rgba(0,0,0,1);
color: white;
}
fieldset th {
text-align: center;
padding: .375rem .725rem;
}
fieldset td {
background-color: rgba(255,255,255,.9);
padding: .2rem .5rem;
font-size: 1rem;
}
fieldset tr[row-type="sum"] {
border-top: 2px solid #aaa;
}
[data-type="number"] {
text-align: right;
padding: .2rem 1rem;
}
@media screen and (max-width: 1200px) {
.wrap {
flex-direction: column;
}
.wrap fieldset {
flex-grow: 1 !important;
flex-basis: unset;
width: 100%;
height: unset;
}
}
</style>
</head>
<body>
<div class="wrap">
<fieldset>
<legend>UPLOAD</legend>
<ul>
<li>
<label for="contract">토큰 컨트랙트</label>
<input type="text" id="contract" value="0x36b8dE7c6B06B3f170003452114f0B8E6BcFEE18" />
</li>
<li>
<label for="file">보내는 계좌</label>
<input type="file" id="file" accept=".xls,.xlsx,.csv" />
<label for="file"></label>
</li>
</ul>
</fieldset>
<fieldset>
<legend>DETAIL</legend>
<table>
<thead>
<tr>
<th>항목1</th>
<th>항목2</th>
<th>항목3</th>
<th>항목4</th>
<th>항목5</th>
</tr>
</thead>
<tbody>
<tr>
<td>값1</td>
<td>값2</td>
<td>값3</td>
<td>값4</td>
<td data-type="number">1.25</td>
</tr>
<tr row-type="sum">
<td colspan="4">항목5 합계</td>
<td data-type="number">1.25</td>
</tr>
</tbody>
</table>
</fieldset>
</div>
<script>
</script>
</body>
</html>

View File

@@ -0,0 +1,3 @@
[ZoneTransfer]
ZoneId=3
HostUrl=https://files.slack.com/files-pri/T08K8CRTBD0-F09RSV6BGPK/download/multisender_sample.html?origin_team=T08K8CRTBD0

View File

@@ -1,25 +1,25 @@
import './App.css';
import { read, utils } from "xlsx";
import { ethers, formatEther, parseEther } from "ethers";
import { ethers, parseEther,formatEther } from "ethers";
import { useCallback, useEffect, useRef, useState } from 'react';
import { tokenAbi } from "./tokenAbi";
const contractAddress = "0x1ebA64fDe3BF54545c86B9e3bB40c72f50f8D012";
import { Table } from './component/table';
import { getBalance, multisend } from './utile/blockchain';
import { divide } from './utile/chunk';
import { setComma } from './utile/setComma';
import { List } from './component/list';
function App() {
const abi = [{"inputs": [{"internalType": "address","name": "token","type": "address"},{"internalType": "address[]","name": "addresses","type": "address[]"},{"internalType": "uint256[]","name": "amounts","type": "uint256[]"},{"internalType": "bool","name": "isToken","type": "bool"}],"name": "batchTransferFrom","outputs": [{"internalType": "bool","name": "","type": "bool"}],"stateMutability": "payable","type": "function"}]
const [from, setFrom] = useState("");
const [toArray, setToArray] = useState([]);
const [amountArray, setAmountArray] = useState([]);
const isToken = true;
const [provider, setProvider] = useState();
const [text, setText] = useState("파일 업로드")
const tokenRef = useRef(null);
const [chunkArray, setChunkArray] = useState([]);
const [selectedChunks, setSelectedChunks] = useState([]); // 선택된 청크 인덱스 배열
const [balance, setBalance] = useState(0);
const [totalAmount, setTotalAmount] = useState(0);
const handleFile = (file) => {
if (!file) return;
setText(file.name);
const reader = new FileReader();
reader.onload = (evt) => {
@@ -29,24 +29,40 @@ function App() {
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const jsonData = utils.sheet_to_json(worksheet, {raw:true});
let total = totalAmount;
const filteredData = jsonData.filter(row => {
return (
typeof row.address === "string" &&
!row.address.toUpperCase().includes("EX") &&
!String(row.amount).toUpperCase().includes("EX")
);
if (typeof row.address !== "string" ||
row.address.toUpperCase().includes("EX") ||
String(row.amount).toUpperCase().includes("EX")) {
return false;
}
try {
ethers.getAddress(row.address.split("\r\n").join("").trim());
return true;
} catch (error) {
console.warn(`Invalid address: ${row.address}`, error);
return false;
}
});
const addresses = filteredData.map((row) => row.address);
const addresses = filteredData.map((row) => {
const a = row.address;
return a;
});
const amount = filteredData.map((row) => {
// 소수점 2자리까지 반올림 처리
const rounded = Math.round(Number(row.amount) * 100) / 100;
total += rounded
return parseEther(rounded.toFixed(2));
});
setToArray(addresses);
setAmountArray(amount);
setChunkArray(prev => [...prev, ...divide(addresses, amount)]);
console.log(total)
setTotalAmount(total)
} catch (error) {
console.error("Error:", error);
alert(error)
}
@@ -54,25 +70,21 @@ function App() {
reader.readAsArrayBuffer(file);
};
const handleDrop = (e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
handleFile(file);
};
const handleFileChange = (e) => {
const file = e.target.files[0];
handleFile(file);
};
// const handleCheckboxChange = (e) => {
// setIsToken(e.target.checked);
// };
const handleDragOver = (e) => {
e.preventDefault();
};
const handleDrop = (e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
handleFile(file);
};
useEffect(()=>{
if (!window.ethereum) return alert("MetaMask not installed");
setProvider(new ethers.BrowserProvider(window.ethereum))
@@ -113,7 +125,13 @@ function App() {
});
const signer = provider.getSigner();
const senderAddress = await signer;
const tokenAddress = tokenRef.current?.value;
const balance = await getBalance(tokenAddress, provider, senderAddress)
setBalance(balance)
setFrom(senderAddress.address)
},[provider])
useEffect(()=>{
@@ -130,132 +148,117 @@ function App() {
},[provider, walletProvider])
// 체크박스 토글
const toggleChunkSelection = (idx) => {
if (chunkArray[idx].status === 0) return;
setSelectedChunks(prev => {
if (prev.includes(idx)) {
return prev.filter(i => i !== idx);
} else {
return [...prev, idx];
}
});
};
const sendToken = async () => {
const tokenAddress = tokenRef.current?.value;
let failedIdxs = [];
let totalAmount = 0;
amountArray.map((row)=> totalAmount += parseFloat(formatEther(row)));
for (const idx of selectedChunks) {
const chunk = chunkArray[idx];
const signer = await provider.getSigner();
const contract = new ethers.Contract(contractAddress, abi, signer);
const batchSize = 200;
const loopCount = Math.ceil(toArray.length / batchSize);
let status;
if(toArray.length !== 0 && amountArray.length !== 0 && toArray.length === amountArray.length)
{
try {
const tokenContract = new ethers.Contract(tokenAddress, tokenAbi, signer);
let balance = isToken ? await tokenContract.balanceOf(from) : await provider.getBalance(from);
console.log(balance, parseEther(totalAmount.toString()))
if(balance >= parseEther(totalAmount.toString())){
if(isToken)
{
//token approve
const tx = await tokenContract.approve(
contractAddress,
parseEther(totalAmount.toString())
)
await provider.waitForTransaction(tx.hash);
}
} else {
throw new Error("잔액부족")
}
} catch (error) {
alert(error)
}
} else {
alert("양식이 맞지 않습니다.")
}
for (let i = 0; i < loopCount; i++)
{
const start = i * batchSize;
const end = start + batchSize;
const chunkAddresses = toArray.slice(start, end);
const chunkAmounts = amountArray.slice(start, end);
const chunkTotal = chunkAmounts.reduce((acc, amt) => acc + parseFloat(formatEther(amt)), 0);
console.log(tokenAddress, chunkAddresses, chunkAmounts, isToken,chunkTotal)
//multisender
try {
const tx = await contract.batchTransferFrom(
const status = await multisend(
tokenAddress,
chunkAddresses,
chunkAmounts,
isToken,
{value : isToken ? 0 : parseEther(chunkTotal.toString())}
provider,
from,
chunk.chunkAddresses,
chunk.chunkAmounts
);
status = await provider.waitForTransaction(tx.hash);
} catch (error) {
alert(error)
}
}
if (status) {
setText("파일 업로드")
setToArray([]);
setAmountArray([]);
}
setChunkArray(prev => prev.map((c, i) =>
i === idx ? {...c, status: 0} : c
));
} else {
setChunkArray(prev => prev.map((c, i) =>
i === idx ? {...c, status: 1} : c
));
failedIdxs.push(idx)
}
} catch (error) {
console.error(`Chunk ${idx + 1} 전송 실패:`, error);
alert(`Chunk ${idx + 1} 전송 중 오류가 발생했습니다.`);
break;
}
}
// 전송 완료
setSelectedChunks(failedIdxs);
};
return (
<div className="App">
<h1>Admin MultiSender</h1>
<div
<div className="wrap">
<fieldset>
<legend>UPLOAD</legend>
<ul>
<li>
<label htmlFor="tokenAddress">토큰 컨트랙트</label>
<input type="text" id="tokenAddress" defaultValue="0x36b8dE7c6B06B3f170003452114f0B8E6BcFEE18" ref={tokenRef}/>
</li>
<li>
<label htmlFor="contract">발송 주소</label>
<input type="text" id="contract" value={from} disabled/>
</li>
<li>
<div onDragOver={handleDragOver} onDrop={handleDrop}>
<label htmlFor="file">보내는 계좌</label>
<input type="file" id="file" accept=".xls,.xlsx,.csv" onChange={handleFileChange} onDrop={handleDrop} onDragOver={handleDragOver}/>
<label htmlFor="file"></label>
</div>
</li>
</ul>
<p>balance : {setComma(formatEther(balance))}</p>
<p>totalAmount : {setComma(totalAmount)}</p>
<List chunkArray={chunkArray} selectedChunks={selectedChunks} toggleChunkSelection={toggleChunkSelection}></List>
<button
className='transferButton'
type='button'
onClick={sendToken}
disabled={selectedChunks.length === 0}
style={{
width : "600px",
height : "100px",
margin : "0 Auto",
display : "flex",
flexDirection: "column",
opacity: selectedChunks.length === 0 ? 0.5 : 1,
cursor: selectedChunks.length === 0 ? "not-allowed" : "pointer"
}}
>
{/* <div>
<span style={{fontSize: "18px"}}>토큰 전송 </span>
<input type="checkbox" onChange={handleCheckboxChange} />
</div> */}
<div>
<span style={{fontSize: "18px"}}>토큰 컨트랙트 : </span>
<input type="text" ref={tokenRef} placeholder='' style={{fontSize: "18px", marginTop : "10px", width : "450px"}} defaultValue={"0x36b8dE7c6B06B3f170003452114f0B8E6BcFEE18"}/>
</div>
<div>
<span style={{fontSize: "18px"}}>보내는 계좌 : </span>
<input type='text' placeholder={from} style={{fontSize: "18px", marginTop : "10px", width: "450px", border: "none"}} disabled />
</div>
</div>
전송 ({selectedChunks.length})
</button>
<input type="file" id="fileInput" style={{ display: "none" }} onChange={handleFileChange} />
<label htmlFor="fileInput">
<div
id="upload"
onDrop={handleDrop}
onDragOver={handleDragOver}
style={{
// display : toArray.length == 0 ? "block" : "none",
border: '2px dashed gray',
padding: '50px',
textAlign: 'center',
marginTop: '50px',
marginBottom: '20px',
margin: "0 Auto",
width: "200px"
}}
>
{text}
</div>
</label>
</fieldset>
<button type='button' onClick={sendToken} style={{marginTop : "20px", padding: "8px", width: "100px", fontSize: "20px"}}>전송</button>
<fieldset>
<legend>DETAIL</legend>
<div className="flex-row">
{selectedChunks.length > 0 ? (
<Table
addresses={selectedChunks.flatMap(idx => chunkArray[idx].chunkAddresses)}
amounts={selectedChunks.flatMap(idx => chunkArray[idx].chunkAmounts)}
/>
) : ''}
</div>
</fieldset>
</div>
</div>
);
}
export default App;

59
src/component/list.jsx Normal file
View File

@@ -0,0 +1,59 @@
import { setComma } from "../utile/setComma"
export const List = ({ chunkArray,selectedChunks,toggleChunkSelection }) => {
return (
<>
{chunkArray.length > 0 && (
<div style={{marginTop: "20px"}}>
<h3>Chunks ({chunkArray.length})</h3>
{chunkArray.map((chunk, idx) => (
<div
key={idx}
style={{
padding: "10px",
margin: "5px 0",
border: "1px solid #ddd",
backgroundColor: chunk.status === 0 ? "#f0f0f0" : "white",
borderRadius: "4px",
display: "flex",
alignItems: "center",
justifyContent: "space-between"
}}
>
<div style={{display: "flex", alignItems: "center"}}>
<input
type="checkbox"
checked={selectedChunks.includes(idx)}
onChange={() => toggleChunkSelection(idx)}
disabled={chunk.status === 0}
style={{
marginRight: "10px",
cursor: chunk.status === 0 ? "not-allowed" : "pointer"
}}
/>
<span style={{color: chunk.status === 0 ? "#999" : "black"}}>
Chunk {idx + 1}: {chunk.chunkAddresses.length} addresses
- Total: {setComma(chunk.chunkTotal.toFixed(2))} tokens
</span>
</div>
{chunk.status === 0 ? (
<span style={{color: "green", fontWeight: "bold"}}>
전송완료
</span>
) :
chunk.status === 1 ?
(
<span style={{color: "red", fontWeight: "bold"}}>
X 전송실패
</span>
) : ''
}
</div>
))}
</div>
)}
</>
)
}

26
src/component/table.jsx Normal file
View File

@@ -0,0 +1,26 @@
import { formatEther } from "ethers";
export const Table = ({ addresses, amounts }) => {
return (
<table>
<thead>
<tr>
<th >Address</th>
<th >Amount</th>
</tr>
</thead>
<tbody>
{
addresses?.map((address, index) => (
<tr key={index}>
<td >{address}</td>
<td >{formatEther(amounts[index])}</td>
</tr>
))
}
</tbody>
</table>
)
}

View File

@@ -11,3 +11,220 @@ code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
:root {
--gap: 20px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
margin: 0;
font-family: sans-serif;
font-size: 12px;
scroll-snap-type: y mandatory;
scroll-behavior: smooth;
scroll-padding-top: 0;
overscroll-behavior: contain;
}
html, body {
margin: 0;
font-family: sans-serif;
}
.wrap {
display: flex;
flex-direction: row;
justify-content: center;
align-items: start;
padding: var(--gap);
gap: var(--gap);
}
.wrap fieldset {
flex-grow: 1;
padding: 1.5rem;
border: 1px solid #CCC;
border-radius: 0.5rem;
height: calc(100vh - 80px);
overflow: auto;
}
.wrap fieldset:first-child {
flex-grow: 0;
flex-basis: 500px;
}
.wrap fieldset legend {
font-size: 1.5rem;
font-weight: bold;
padding: 0 2rem 0 .75rem;
}
fieldset ul {
list-style: none;
padding: 0;
}
fieldset ul > li {
display: flex;
flex-direction: column;
margin-bottom: 2rem;
}
fieldset label {
font-size: 1.2rem;
font-weight: bold;
margin-bottom: 0.5rem;
padding-left: 0.375rem;
}
fieldset div label {
font-size: 1.2rem;
font-weight: bold;
margin-bottom: 0.5rem;
padding-left: 0.375rem;
}
fieldset label+input {
display: block;
width: 100%;
height: 3rem;
padding: .375rem .75rem;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #495057;
background-color: #fff;
background-clip: padding-box;
border: 1px solid #ced4da;
border-radius: .5rem;
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
}
fieldset div label+input {
display: block;
width: 100%;
height: 3rem;
padding: .375rem .75rem;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #495057;
background-color: #fff;
background-clip: padding-box;
border: 1px solid #ced4da;
border-radius: .5rem;
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
}
fieldset label+input:focus {
color: #495057;
background-color: #fff;
border-color: #808080;
outline: 0;
box-shadow: 0 0 0 .2rem rgba(100, 100, 100, .25);
}
fieldset label+input[type="file"] {
display: none;
}
fieldset input[type="file"]+label {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 10rem;
border: 2px dotted #CCC;
border-radius: .5rem;
}
fieldset input[type="file"]+label::before {
content: '업로드 할 파일을 선택하거나 드레그&드롭하세요.';
color: rgba(0,0,0,.5);
font-weight: 400;
text-shadow: 0 0 2px rgba(0,0,0,.7);
}
fieldset table {
display: table;
width: 100%;
caption-side: bottom;
border-collapse: separate;
box-sizing: border-box;
border-spacing: 1px;
border-color: gray;
background-color: #aaa;
font-size: 1.2rem;
margin-bottom: 30px;
}
fieldset thead {
background-color: rgba(0,0,0,.7);
border-bottom: 2px solid rgba(0,0,0,1);
color: white;
}
fieldset th {
text-align: center;
padding: .375rem .725rem;
}
fieldset td {
background-color: rgba(255,255,255,.9);
padding: .2rem .5rem;
font-size: 1rem;
}
fieldset tr[row-type="sum"] {
border-top: 2px solid #aaa;
}
[data-type="number"] {
text-align: right;
padding: .2rem 1rem;
}
/* row 처리 */
.flex-row {
position: relative;
display: flex;
flex-direction: row;
gap: 20px;
}
.flex-row > table {
flex-grow: 1;
}
@media screen and (max-width: 1200px) {
.wrap {
flex-direction: column;
}
.wrap fieldset {
flex-grow: 1 !important;
flex-basis: unset;
width: 100%;
height: unset;
}
.flex-row {
flex-direction: column;
}
}
.transferButton {
margin-top: 20px;
padding: 8px;
width: 100px;
font-size: 20px;
}

72
src/utile/blockchain.js Normal file
View File

@@ -0,0 +1,72 @@
import { tokenAbi } from "../tokenAbi"
import { ethers,formatEther,parseEther } from "ethers";
const contractAddress = "0x1ebA64fDe3BF54545c86B9e3bB40c72f50f8D012";
const abi = [{"inputs": [{"internalType": "address","name": "token","type": "address"},{"internalType": "address[]","name": "addresses","type": "address[]"},{"internalType": "uint256[]","name": "amounts","type": "uint256[]"},{"internalType": "bool","name": "isToken","type": "bool"}],"name": "batchTransferFrom","outputs": [{"internalType": "bool","name": "","type": "bool"}],"stateMutability": "payable","type": "function"}]
export const multisend = async (tokenAddress, provider, from, toArray, amountArray) => {
let totalAmount = 0;
let isToken = true;
let status;
//console.log(tokenAddress, provider, from)
amountArray.map((row)=> totalAmount += parseFloat(formatEther(row)));
const signer = await provider.getSigner();
const contract = new ethers.Contract(contractAddress, abi, signer);
if(toArray.length !== 0 && amountArray.length !== 0 && toArray.length === amountArray.length)
{
try {
const tokenContract = new ethers.Contract(tokenAddress, tokenAbi, signer);
let balance = isToken ? await tokenContract.balanceOf(from) : await provider.getBalance(from);
if(balance >= parseEther(totalAmount.toString())){
if(isToken)
{
////token approve
// const tx = await tokenContract.approve(
// contractAddress,
// parseEther(totalAmount.toString())
// )
// await provider.waitForTransaction(tx.hash);
}
} else {
throw new Error("잔액부족")
}
} catch (error) {
alert(error)
}
try {
const tx = await contract.batchTransferFrom(
tokenAddress,
toArray,
amountArray,
isToken,
{value : isToken ? 0 : parseEther(totalAmount.toString())}
);
status = await provider.waitForTransaction(tx.hash);
} catch (error) {
console.log(error)
}
} else {
alert("양식이 맞지 않습니다.")
}
return status;
}
export const getBalance = async (tokenAddress, provider, address) => {
const signer = await provider.getSigner();
const tokenContract = new ethers.Contract(tokenAddress, tokenAbi, signer);
let balance = await tokenContract.balanceOf(address)
return balance;
}

33
src/utile/chunk.js Normal file
View File

@@ -0,0 +1,33 @@
import { formatEther } from "ethers"
export const divide = (toArray, amountArray) => {
const bachArray = [];
const batchSize = 200;
const loopCount = Math.ceil(toArray.length / batchSize);
for (let i = 0; i < loopCount; i++)
{
const start = i * batchSize;
const end = start + batchSize;
const chunkAddresses = toArray.slice(start, end)
.map(addr => addr.trim())
const chunkAmounts = amountArray.slice(start, end);
const chunkTotal = chunkAmounts.reduce((acc, amt) => acc + parseFloat(formatEther(amt)), 0);
let chunkData =
{
chunkAddresses,
chunkAmounts,
chunkTotal,
status: 2 // 전송 완료 여부
}
bachArray.push(chunkData)
}
return bachArray
}

24
src/utile/setComma.js Normal file
View File

@@ -0,0 +1,24 @@
export const setComma = (value) => {
if (value == null) return "0";
const num = typeof value === "string" ? parseFloat(value) : value;
if (isNaN(num)) return value;
return num.toLocaleString("en-US", {
minimumFractionDigits: 0,
maximumFractionDigits: 2
});
};
export const setCommaRegex = (value) => {
if (value == null) return "0";
const str = String(value);
const parts = str.split(".");
// 정수 부분에 콤마 추가
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return parts.join(".");
};