조회조건 레이아웃 수정

엑셀다운버튼 수정
progress 추가
front pagenation 추가
This commit is contained in:
2025-04-09 15:44:51 +09:00
parent 80a3c0ab8a
commit a74376108a
11 changed files with 962 additions and 193 deletions

View File

@@ -272,6 +272,12 @@ export const opMailType = [
{ value: 'SEND', name: '보낸 우편' },
];
export const opInputType = [
{ value: 'String', name: '문자열' },
{ value: 'Number', name: '숫자' },
{ value: 'Boolean', name: '부울' },
];
// export const logAction = [
// { value: "None", name: "ALL" },
// { value: "AIChatDeleteCharacter", name: "NPC 삭제" },
@@ -567,7 +573,7 @@ export const opMailType = [
// ];
export const logAction = [
{ value: "None", name: "ALL" },
{ value: "None", name: "전체" },
{ value: "AIChatDeleteCharacter", name: "AIChatDeleteCharacter" },
{ value: "AIChatDeleteUser", name: "AIChatDeleteUser" },
{ value: "AIChatGetCharacter", name: "AIChatGetCharacter" },

View File

@@ -8,6 +8,7 @@ import ReportListDetailModal from './modal/ReportListDetailModal';
import UserBlockDetailModal from './modal/UserBlockDetailModal';
import OwnerChangeModal from './modal/OwnerChangeModal';
//searchbar
import SearchFilter from './searchBar/SearchFilter';
import ReportListSearchBar from './searchBar/ReportListSearchBar';
import UserBlockSearchBar from './searchBar/UserBlockSearchBar';
import ItemsSearchBar from './searchBar/ItemsSearchBar';
@@ -29,6 +30,7 @@ export {
BoardInfoModal,
BoardRegistModal,
MailDetailModal,
SearchFilter,
MailListSearchBar,
LandInfoSearchBar,
BusinessLogSearchBar,

View File

@@ -1,12 +1,10 @@
import { TextInput, BtnWrapper, InputLabel, SelectInput, InputGroup } from '../../../styles/Components';
import Button from '../../common/button/Button';
import { SearchBarLayout, SearchPeriod } from '../../common/SearchBar';
import { useCallback, useEffect, useState } from 'react';
import { LandAuctionView, LandInfoData } from '../../../apis';
import { landAuctionStatus, landSearchType, landSize, opLandCategoryType } from '../../../assets/data';
import { logAction, logDomain, opLandInfoStatusType, userSearchType2 } from '../../../assets/data/options';
import { logAction, logDomain, userSearchType2 } from '../../../assets/data/options';
import { BusinessLogList } from '../../../apis/Log';
import { useTranslation } from 'react-i18next';
import {SearchFilter} from '../';
export const useBusinessLogSearch = (token, initialPageSize, setAlertMsg) => {
const { t } = useTranslation();
@@ -27,6 +25,7 @@ export const useBusinessLogSearch = (token, initialPageSize, setAlertMsg) => {
date.setDate(date.getDate() - 1);
return date;
})(),
filters: [],
order_by: 'ASC',
page_size: initialPageSize,
page_no: 1
@@ -96,6 +95,7 @@ export const useBusinessLogSearch = (token, initialPageSize, setAlertMsg) => {
tran_id: '',
start_dt: now,
end_dt: now,
filters: [],
order_by: 'ASC',
page_size: initialPageSize,
page_no: 1
@@ -198,7 +198,12 @@ const BusinessLogSearchBar = ({ searchParams, onSearch, onReset }) => {
/>
</>,
];
return <SearchBarLayout firstColumnData={searchList} secondColumnData={optionList} direction={'column'} onReset={onReset} handleSubmit={handleSubmit} />;
const filterComponent = (
<SearchFilter value={searchParams.filters} onChange={e => onSearch({filters: e.target.value })} />
);
return <SearchBarLayout firstColumnData={searchList} secondColumnData={optionList} filter={filterComponent} direction={'column'} onReset={onReset} handleSubmit={handleSubmit} />;
};
export default BusinessLogSearchBar;

View File

@@ -0,0 +1,238 @@
import { useEffect, useState } from 'react';
import { styled } from 'styled-components';
import { TextInput, InputLabel, SelectInput } from '../../../styles/Components';
import { logDomain, opInputType } from '../../../assets/data/options';
const SearchFilter = ({ value = [], onChange }) => {
const [filters, setFilters] = useState(value || []);
const [filterSections, setFilterSections] = useState([{ field_name: '', field_type: 'String', value: '' }]);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
setFilters(value || []);
}, [value]);
const handleInputChange = (index, field, inputValue) => {
const updatedSections = [...filterSections];
updatedSections[index] = { ...updatedSections[index], [field]: inputValue };
setFilterSections(updatedSections);
};
const handleAddFilter = (index) => {
const filterToAdd = filterSections[index];
// Only add if both field_name and value are provided
if (filterToAdd.field_name && filterToAdd.value && filterToAdd.field_type) {
const updatedFilters = [...filters, { field_name: filterToAdd.field_name, field_type: filterToAdd.field_type, value: filterToAdd.value }];
setFilters(updatedFilters);
onChange({ target: { value: updatedFilters } });
// Reset this section
const updatedSections = [...filterSections];
updatedSections[index] = { field_name: '', field_type: 'String', value: '' };
setFilterSections(updatedSections);
}
};
const handleAddFilterSection = () => {
setFilterSections([...filterSections, { field_name: '', field_type: 'String', value: '' }]);
};
const handleRemoveFilterSection = (index) => {
if (filterSections.length > 1) {
const updatedSections = filterSections.filter((_, i) => i !== index);
setFilterSections(updatedSections);
}
};
const handleRemoveFilter = (index) => {
const updatedFilters = filters.filter((_, i) => i !== index);
setFilters(updatedFilters);
onChange({ target: { value: updatedFilters } });
};
const toggleFilters = () => {
setIsOpen(!isOpen);
};
return (
<FilterWrapper>
<FilterToggle onClick={toggleFilters}>
필터 {isOpen ? '▲' : '▼'}
</FilterToggle>
{isOpen && (
<>
{filters.length > 0 && (
<FilterSection>
{filters.map((filter, index) => (
<FilterItem key={index}>
<FilterName>{filter.field_name}:</FilterName>
<FilterValue>{filter.value}</FilterValue>
<RemoveButton onClick={() => handleRemoveFilter(index)}>×</RemoveButton>
</FilterItem>
))}
</FilterSection>
)}
{filterSections.map((section, index) => (
<FilterInputSection key={index}>
<InputLabel>속성 이름</InputLabel>
<TextInput
type="text"
placeholder="속성 이름 입력"
value={section.field_name}
onChange={(e) => handleInputChange(index, 'field_name', e.target.value)}
/>
<InputLabel>속성 유형</InputLabel>
<SelectInput
value={section.field_type}
onChange={(e) => handleInputChange(index, 'field_type', e.target.value)}
>
{opInputType.map((data, index) => (
<option key={index} value={data.value}>
{data.name}
</option>
))}
</SelectInput>
<InputLabel>속성 </InputLabel>
<TextInput
type="text"
placeholder="속성 값 입력"
value={section.value}
onChange={(e) => handleInputChange(index, 'value', e.target.value)}
/>
<AddButton onClick={() => handleAddFilter(index)}>추가</AddButton>
{filterSections.length > 1 && (
<RemoveButton onClick={() => handleRemoveFilterSection(index)}>×</RemoveButton>
)}
</FilterInputSection>
))}
{/*<AddFilterButton onClick={handleAddFilterSection}>*/}
{/* 필터 추가*/}
{/*</AddFilterButton>*/}
</>
)}
</FilterWrapper>
);
};
export default SearchFilter;
const FilterWrapper = styled.div`
width: 100%;
margin-bottom: 15px;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
`;
const FilterToggle = styled.div`
width: 100%;
padding: 10px 15px;
cursor: pointer;
font-weight: 500;
border-bottom: 1px solid #ddd;
display: flex;
justify-content: space-between;
align-items: center;
&:hover {
background: #e5e5e5;
}
`;
const FilterSection = styled.div`
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 15px;
`;
const FilterItem = styled.div`
display: flex;
align-items: center;
background: #f0f0f0;
border-radius: 4px;
padding: 6px 10px;
font-size: 13px;
`;
const FilterName = styled.span`
font-weight: 500;
margin-right: 5px;
`;
const FilterValue = styled.span`
color: #333;
`;
const RemoveButton = styled.button`
background: none;
border: none;
color: #888;
font-size: 16px;
cursor: pointer;
margin-left: 8px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
&:hover {
color: #ff4d4f;
}
`;
const AddButton = styled.button.attrs({
type: 'button'
})`
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
padding: 6px 12px;
cursor: pointer;
font-size: 13px;
&:hover {
background: #40a9ff;
}
`;
const FilterInputSection = styled.div`
display: flex;
padding: 15px;
margin: 0 15px 15px;
border-radius: 6px;
gap: 10px;
align-items: center;
${TextInput} {
width: 160px;
margin-right: 5px;
}
`;
const AddFilterButton = styled.button.attrs({
type: 'button'
})`
border: 1px dashed #ccc;
border-radius: 4px;
padding: 8px 15px;
margin: 0 15px 15px;
cursor: pointer;
font-size: 13px;
width: calc(100% - 30px);
text-align: center;
&:hover {
background: #e5e5e5;
}
`;

View File

@@ -0,0 +1,75 @@
import React from 'react';
import styled from 'styled-components';
// 원형 프로그레스 컴포넌트
const CircularProgress = ({
progress,
size = 40,
strokeWidth,
backgroundColor = '#E0E0E0',
progressColor = '#4A90E2',
textColor = '#4A90E2',
showText = true,
textSize,
className
}) => {
// 기본값 계산
const actualStrokeWidth = strokeWidth || size * 0.1; // 프로그레스 바 두께
const radius = (size - actualStrokeWidth) / 2; // 원의 반지름
const circumference = 2 * Math.PI * radius; // 원의 둘레
const strokeDashoffset = circumference - (progress / 100) * circumference; // 진행률에 따른 offset 계산
const actualTextSize = textSize || Math.max(10, size * 0.3); // 텍스트 크기
return (
<ProgressContainer size={size} className={className}>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
{/* 배경 원 */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={backgroundColor}
strokeWidth={actualStrokeWidth}
/>
{/* 진행률 표시 원 */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={progressColor}
strokeWidth={actualStrokeWidth}
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
</svg>
{showText && (
<ProgressText color={textColor} fontSize={actualTextSize}>
{`${Math.round(progress)}%`}
</ProgressText>
)}
</ProgressContainer>
);
};
export default CircularProgress;
// 스타일 컴포넌트
const ProgressContainer = styled.div`
position: relative;
width: ${props => props.size}px;
height: ${props => props.size}px;
display: flex;
align-items: center;
justify-content: center;
`;
const ProgressText = styled.div`
position: absolute;
font-size: ${props => props.fontSize}px;
font-weight: bold;
color: ${props => props.color};
`;

View File

@@ -0,0 +1,42 @@
import styled from 'styled-components';
const DownloadProgress = ({ progress }) => {
return (
<ProgressWrapper>
<ProgressText>다운로드 ... {progress}%</ProgressText>
<ProgressBarContainer>
<ProgressBarFill style={{ width: `${progress}%` }} />
</ProgressBarContainer>
</ProgressWrapper>
);
};
export default DownloadProgress;
const ProgressWrapper = styled.div`
margin: 10px 0;
padding: 10px;
background-color: #f9f9f9;
border-radius: 4px;
border: 1px solid #ddd;
`;
const ProgressText = styled.div`
margin-bottom: 5px;
text-align: center;
font-weight: bold;
color: #333;
`;
const ProgressBarContainer = styled.div`
height: 20px;
background-color: #e0e0e0;
border-radius: 10px;
overflow: hidden;
`;
const ProgressBarFill = styled.div`
height: 100%;
background-color: #4caf50;
transition: width 0.3s ease;
`;

View File

@@ -0,0 +1,151 @@
import React, { useCallback, useEffect, useState } from 'react';
import styled from 'styled-components';
import PaginationIcon from '../../../assets/img/icon/icon-pagination.png';
const FrontPagination = ({
data, // 전체 데이터 배열
itemsPerPage, // 페이지당 표시할 항목 수
currentPage, // 현재 페이지
setCurrentPage, // 현재 페이지 설정 함수
pageLimit = 10, // 페이지 네비게이션에 표시할 페이지 수
onPageChange // 페이지 변경 시 호출될 콜백 함수 (선택 사항)
}) => {
const [blockNum, setBlockNum] = useState(0);
// 전체 페이지 수 계산
const totalItems = data?.length || 0;
const maxPage = Math.ceil(totalItems / itemsPerPage);
// 페이지 번호 배열 생성
const pageNumbers = [];
for (let i = 1; i <= maxPage; i++) {
pageNumbers.push(i);
}
// 현재 블록에 표시할 페이지 번호
const v = blockNum * pageLimit;
const pArr = pageNumbers.slice(v, pageLimit + v);
const processPageData = useCallback(() => {
if (!data || !Array.isArray(data) || data.length === 0) return [];
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
return data.slice(startIndex, endIndex);
}, [data, currentPage, itemsPerPage]);
useEffect(() => {
if (onPageChange) {
const pageData = processPageData();
onPageChange(pageData);
}
}, [processPageData, onPageChange]);
// itemsPerPage나 데이터가 변경되면 블록 초기화
useEffect(() => {
setBlockNum(0);
}, [itemsPerPage, totalItems]);
// 첫 페이지로 이동
const firstPage = () => {
setBlockNum(0);
setCurrentPage(1);
};
// 마지막 페이지로 이동
const lastPage = () => {
setBlockNum(Math.ceil(maxPage / pageLimit) - 1);
setCurrentPage(maxPage);
};
// 이전 페이지로 이동
const prePage = () => {
if (currentPage <= 1) return;
if (currentPage - 1 <= pageLimit * blockNum) {
setBlockNum(n => n - 1);
}
setCurrentPage(n => n - 1);
};
// 다음 페이지로 이동
const nextPage = () => {
if (currentPage >= maxPage) return;
if (pageLimit * (blockNum + 1) <= currentPage) {
setBlockNum(n => n + 1);
}
setCurrentPage(n => n + 1);
};
// 특정 페이지로 이동
const clickPage = number => {
setCurrentPage(number);
};
// 현재 표시 중인 항목 범위 계산 (예: "1-10 / 총 100개")
const startItem = totalItems === 0 ? 0 : (currentPage - 1) * itemsPerPage + 1;
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
return (
<>
<PaginationWrapper>
<Button $position="0" onClick={firstPage} disabled={currentPage === 1} />
<Button $position="-20px" onClick={prePage} disabled={currentPage === 1} />
{pArr.map(number => (
<PageNum
$state={currentPage === number ? 'on' : ''}
key={number}
onClick={() => clickPage(number)}
>
{number}
</PageNum>
))}
<Button $position="-40px" onClick={nextPage} disabled={currentPage === maxPage} />
<Button $position="-60px" onClick={lastPage} disabled={currentPage === maxPage} />
</PaginationWrapper>
</>
);
};
export default FrontPagination;
const PaginationWrapper = styled.div`
display: flex;
justify-content: center;
align-items: center;
margin-top: 10px;
margin-bottom: 20px;
`;
const Button = styled.button`
background: url('${PaginationIcon}') no-repeat;
background-position: ${props => props.$position} 0;
width: 20px;
height: 20px;
opacity: ${props => props.disabled ? 0.5 : 1};
cursor: ${props => props.disabled ? 'default' : 'pointer'};
&:hover {
background-position: ${props => props.$position} ${props => props.disabled ? '0' : '-20px'};
}
`;
const PageNum = styled.div`
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
color: ${props => (props.$state === 'on' ? '#2c2c2c' : '#CECECE')};
cursor: pointer;
&:hover {
color: #2c2c2c;
}
`;

View File

@@ -2,7 +2,7 @@ import { styled } from 'styled-components';
import { TextInput, SelectInput, SearchBarAlert, BtnWrapper } from '../../../styles/Components';
import Button from '../button/Button';
const SearchBarLayout = ({ firstColumnData, secondColumnData, direction, onReset, handleSubmit }) => {
const SearchBarLayout = ({ firstColumnData, secondColumnData, filter, direction, onReset, handleSubmit }) => {
return (
<SearchbarStyle direction={direction}>
<SearchRow>
@@ -17,6 +17,11 @@ const SearchBarLayout = ({ firstColumnData, secondColumnData, direction, onReset
))}
</SearchRow>
)}
{filter && (
<SearchRow>
{filter}
</SearchRow>
)}
<SearchRow>
<BtnWrapper $gap="8px">
<Button theme="search" text="검색" handleClick={handleSubmit} type="button" />

View File

@@ -1,7 +1,34 @@
import * as XLSX from 'xlsx-js-style';
import { ExcelDownButton } from '../../../styles/ModuleComponents';
import { useCallback, useEffect, useState } from 'react';
const ExcelDownloadButton = ({ tableRef, data, fileName = 'download.xlsx', sheetName = 'Sheet1', onLoadingChange }) => {
const [isDownloading, setIsDownloading] = useState(false);
const [lastProgress, setLastProgress] = useState(0);
// 타임아웃 감지 및 처리
useEffect(() => {
let timeoutTimer;
if (isDownloading && lastProgress >= 95) {
// 최종 단계에서 타임아웃 감지 타이머 설정
timeoutTimer = setTimeout(() => {
// 진행 상태가 여전히 변하지 않았다면 타임아웃으로 간주
if (isDownloading && lastProgress >= 95) {
console.log("Excel download timeout detected, completing process");
setIsDownloading(false);
if (onLoadingChange) {
onLoadingChange({ loading: false, progress: 100 });
}
}
}, 15000); // 15초 타임아웃
}
return () => {
if (timeoutTimer) clearTimeout(timeoutTimer);
};
}, [isDownloading, lastProgress, onLoadingChange]);
const ExcelDownloadButton = ({ tableRef, data, fileName = 'download.xlsx', sheetName = 'Sheet1' }) => {
const isNumeric = (value) => {
// 숫자 또는 숫자 문자열인지 확인
return !isNaN(value) && !isNaN(parseFloat(value));
@@ -71,192 +98,409 @@ const ExcelDownloadButton = ({ tableRef, data, fileName = 'download.xlsx', sheet
}, {});
};
const downloadTableExcel = () => {
try {
if (!tableRef || !tableRef.current) {
alert('테이블 참조가 없습니다.');
return;
}
const tableElement = tableRef.current;
const headerRows = tableElement.getElementsByTagName('thead')[0].getElementsByTagName('tr');
const bodyRows = tableElement.getElementsByTagName('tbody')[0].getElementsByTagName('tr');
// 일반 행만 포함 (상세 행 제외)
const normalBodyRows = Array.from(bodyRows).filter(row => {
// 상세 행은 colspan 속성이 있는 td를 포함
const hasTdWithColspan = Array.from(row.cells).some(cell => cell.hasAttribute('colspan'));
return !hasTdWithColspan;
});
// 헤더 데이터 추출
const headers = Array.from(headerRows[0].cells).map(cell => cell.textContent);
// 바디 데이터 추출 및 숫자 타입 처리
const bodyData = normalBodyRows.map(row =>
Array.from(row.cells).map(cell => {
const value = cell.textContent;
return isNumeric(value) ? parseFloat(value) : value;
})
);
// 워크북 생성
const wb = XLSX.utils.book_new();
// 데이터에 스타일 적용
const wsData = [
// 헤더 행
headers.map(h => ({
v: h,
s: headerStyle
})),
// 데이터 행들
...bodyData.map(row =>
row.map(cell => ({
v: cell,
s: dataStyle
}))
)
];
// 워크시트 생성
const ws = XLSX.utils.aoa_to_sheet(wsData);
// 열 너비 설정 (최소 8, 최대 50)
ws['!cols'] = headers.map((_, index) => {
const maxLength = Math.max(
headers[index].length * 2,
...bodyData.map(row => String(row[index] || '').length * 1.2)
);
return { wch: Math.max(8, Math.min(50, maxLength)) };
});
// 워크시트를 워크북에 추가
XLSX.utils.book_append_sheet(wb, ws, sheetName);
// 엑셀 파일 다운로드
XLSX.writeFile(wb, fileName);
} catch (error) {
console.error('Excel download failed:', error);
alert('엑셀 다운로드 중 오류가 발생했습니다.');
const updateLoadingState = (newProgress) => {
setLastProgress(newProgress);
if (onLoadingChange && typeof onLoadingChange === 'function') {
onLoadingChange({loading: true, progress: newProgress});
}
};
const downloadDataExcel = () => {
try {
if (!data || !data || data.length === 0) {
alert('다운로드할 데이터가 없습니다.');
return;
const downloadTableExcel = async () => {
return new Promise((resolve, reject) => {
try {
if (!tableRef || !tableRef.current) {
reject(new Error('테이블 참조가 없습니다.'));
return;
}
// Worker에 전달할 데이터 추출
updateLoadingState(10);
// 메인 스레드에서 데이터 추출
const tableElement = tableRef.current;
const headerRows = tableElement.getElementsByTagName('thead')[0].getElementsByTagName('tr');
const bodyRows = tableElement.getElementsByTagName('tbody')[0].getElementsByTagName('tr');
// 일반 행만 포함 (상세 행 제외)
const normalBodyRows = Array.from(bodyRows).filter(row => {
const hasTdWithColspan = Array.from(row.cells).some(cell => cell.hasAttribute('colspan'));
return !hasTdWithColspan;
});
// 헤더 데이터 추출
const headers = Array.from(headerRows[0].cells).map(cell => cell.textContent);
// 바디 데이터 추출 및 숫자 타입 처리
const bodyData = normalBodyRows.map(row =>
Array.from(row.cells).map(cell => {
const value = cell.textContent;
return isNumeric(value) ? parseFloat(value) : value;
})
);
updateLoadingState(30);
// 큰 데이터셋 처리를 위해 setTimeout으로 이벤트 루프 차단 방지
setTimeout(() => {
try {
// 워크북 생성
const wb = XLSX.utils.book_new();
updateLoadingState(50);
// 처리는 여러 단계로 나누어 이벤트 루프 차단 최소화
setTimeout(() => {
try {
// 데이터에 스타일 적용
const wsData = [
// 헤더 행
headers.map(h => ({
v: h,
s: headerStyle
}))
];
// 데이터 행 추가 (메모리 사용량 최소화를 위해 별도 처리)
const chunkSize = 1000; // 한 번에 처리할 행 수
let currentIndex = 0;
function processDataChunk() {
updateLoadingState(50 + Math.floor((currentIndex / bodyData.length) * 30));
const end = Math.min(currentIndex + chunkSize, bodyData.length);
for (let i = currentIndex; i < end; i++) {
wsData.push(
bodyData[i].map(cell => ({
v: cell,
s: dataStyle
}))
);
}
currentIndex = end;
if (currentIndex < bodyData.length) {
// 아직 처리할 데이터가 남아있으면 다음 청크 처리 예약
setTimeout(processDataChunk, 0);
} else {
// 모든 데이터 처리 완료 후 워크시트 생성
finishExcelCreation(wsData, headers, wb);
}
}
// 첫 번째 청크 처리 시작
processDataChunk();
} catch (error) {
reject(error);
}
}, 0);
function finishExcelCreation(wsData, headers, wb) {
try {
updateLoadingState(80);
// 워크시트 생성
const ws = XLSX.utils.aoa_to_sheet(wsData);
// 열 너비 설정 (최소 8, 최대 50)
ws['!cols'] = headers.map((_, index) => {
// 데이터의 일부만 샘플링하여 열 너비 계산 (전체 계산 시 성능 문제)
const samplingSize = Math.min(bodyData.length, 500);
const sampledRows = [];
for (let i = 0; i < samplingSize; i++) {
const randomIndex = Math.floor(Math.random() * bodyData.length);
sampledRows.push(bodyData[randomIndex]);
}
const maxLength = Math.max(
headers[index].length * 2,
...sampledRows.map(row => {
if (row[index] === undefined) return 0;
return String(row[index] || '').length * 1.2;
})
);
return { wch: Math.max(8, Math.min(50, maxLength)) };
});
updateLoadingState(90);
// 최종 다운로드는 별도 타임아웃에서 수행하여 UI 업데이트 가능하게 함
setTimeout(() => {
try {
// 워크시트를 워크북에 추가
XLSX.utils.book_append_sheet(wb, ws, sheetName);
updateLoadingState(95);
// 파일 다운로드 전 마지막 UI 업데이트를 위한 지연
setTimeout(() => {
try {
// 엑셀 파일 다운로드
XLSX.writeFile(wb, fileName);
updateLoadingState(100);
resolve();
} catch (error) {
reject(error);
}
}, 100);
} catch (error) {
reject(error);
}
}, 0);
} catch (error) {
reject(error);
}
}
} catch (error) {
reject(error);
}
}, 0);
} catch (error) {
reject(error);
}
});
};
// 모든 로그 항목을 플랫한 구조로 변환
const flattenedData = data.map(item => {
// 기본 필드
const baseData = {
'logTime': item.logTime,
'GUID': item.userGuid === 'None' ? '' : item.userGuid,
'Nickname': item.userNickname === 'None' ? '' : item.userNickname,
'Account ID': item.accountId === 'None' ? '' : item.accountId,
'Action': item.action,
'Domain' : item.domain === 'None' ? '' : item.domain,
'Tran ID': item.tranId
};
const chunkArray = (array, chunkSize) => {
const chunks = [];
for (let i = 0; i < array.length; i += chunkSize) {
chunks.push(array.slice(i, i + chunkSize));
}
return chunks;
};
// Actor 데이터 플랫하게 추가
const actorData = item.header && item.header.Actor ?
flattenObject(item.header.Actor, 'Actor') : {};
// Infos 데이터 플랫하게 추가
let infosData = {};
if (item.body && item.body.Infos && Array.isArray(item.body.Infos)) {
item.body.Infos.forEach((info, index) => {
infosData = {
...infosData,
...flattenObject(info, `Info`)
const processDataChunk = async (chunk, headers, allDataRows, processedCount, totalCount) => {
return new Promise((resolve) => {
setTimeout(() => {
// 각 청크의 데이터를 처리
const rowsData = chunk.map(item => {
return headers.map(header => {
const value = item[header] !== undefined ? item[header] : '';
return {
v: value,
s: dataStyle
};
});
});
// 진행률 계산 및 콜백 호출
const newProgress = Math.min(95, Math.round(((processedCount + chunk.length) / totalCount) * 80) + 15);
updateLoadingState(newProgress);
// 처리된 데이터 행들을 전체 데이터 배열에 추가
allDataRows.push(...rowsData);
resolve();
}, 0);
});
};
const downloadDataExcel = async () => {
return new Promise(async (resolve, reject) => {
try {
if (!data || data.length === 0) {
reject(new Error('다운로드할 데이터가 없습니다.'));
return;
}
updateLoadingState(5);
// 데이터 플랫 변환 과정을 더 작은 청크로 나누기
const dataChunkSize = 2000; // 한 번에 처리할 데이터 아이템 수
const dataChunks = chunkArray(data, dataChunkSize);
let flattenedData = [];
for (let i = 0; i < dataChunks.length; i++) {
await new Promise(resolve => {
setTimeout(() => {
// 청크 내 아이템들을 플랫하게 변환
const chunkData = dataChunks[i].map(item => {
// 기본 필드
const baseData = {
'logTime': item.logTime,
'GUID': item.userGuid === 'None' ? '' : item.userGuid,
'Nickname': item.userNickname === 'None' ? '' : item.userNickname,
'Account ID': item.accountId === 'None' ? '' : item.accountId,
'Action': item.action,
'Domain': item.domain === 'None' ? '' : item.domain,
'Tran ID': item.tranId
};
// Actor 데이터 플랫하게 추가
const actorData = item.header && item.header.Actor ?
flattenObject(item.header.Actor, 'Actor') : {};
// Infos 데이터 플랫하게 추가
let infosData = {};
if (item.body && item.body.Infos && Array.isArray(item.body.Infos)) {
item.body.Infos.forEach((info) => {
infosData = {
...infosData,
...flattenObject(info, `Info`)
};
});
}
return {
...baseData,
...actorData,
...infosData
};
});
flattenedData = [...flattenedData, ...chunkData];
const progress = 5 + Math.floor((i + 1) / dataChunks.length * 10);
updateLoadingState(progress);
resolve();
}, 0);
});
}
return {
...baseData,
...actorData,
...infosData
};
});
// 모든 항목의 모든 키 수집하여 헤더 생성
const allKeys = new Set();
// 모든 항목의 모든 키 수집하여 헤더 생성
const allKeys = new Set();
flattenedData.forEach(item => {
Object.keys(item).forEach(key => allKeys.add(key));
});
const headers = Array.from(allKeys);
// 헤더 수집도 청크로 나누기
for (let i = 0; i < flattenedData.length; i += dataChunkSize) {
await new Promise(resolve => {
setTimeout(() => {
const end = Math.min(i + dataChunkSize, flattenedData.length);
for (let j = i; j < end; j++) {
Object.keys(flattenedData[j]).forEach(key => allKeys.add(key));
}
const progress = 15 + Math.floor((i + dataChunkSize) / flattenedData.length * 5);
updateLoadingState(progress);
resolve();
}, 0);
});
}
// 워크북 생성
const wb = XLSX.utils.book_new();
const headers = Array.from(allKeys);
updateLoadingState(20);
// 데이터 행들 생성
const dataRows = flattenedData.map(item => {
return headers.map(header => {
const value = item[header] !== undefined ? item[header] : '';
return {
v: value,
s: dataStyle
};
});
});
// 워크북 생성
const wb = XLSX.utils.book_new();
// 워크시트 데이터 구
const wsData = [
// 헤더 행
headers.map(h => ({
// 헤더 행 생
const headerRow = headers.map(h => ({
v: h,
s: headerStyle
})),
// 데이터 행들
...dataRows
];
}));
// 워크시트 생성
const ws = XLSX.utils.aoa_to_sheet(wsData);
// 청크로 데이터 나누기
const chunkSize = 500; // 한 번에 처리할 행의 수 (더 작게 조정)
const rowChunks = chunkArray(flattenedData, chunkSize);
const allDataRows = [];
// 열 너비 설정 (최소 10, 최대 50)
ws['!cols'] = headers.map((header) => {
// 헤더 길이와 데이터 길이 중 최대값으로 열 너비 결정
const maxLength = Math.max(
header.length * 1.5,
...flattenedData.map(item => {
const value = item[header];
return value !== undefined ? String(value).length * 1.2 : 0;
})
);
return { wch: Math.max(10, Math.min(50, maxLength)) };
});
// 각 청크 처리
let processedCount = 0;
for (const chunk of rowChunks) {
await processDataChunk(chunk, headers, allDataRows, processedCount, flattenedData.length);
processedCount += chunk.length;
// 워크시트를 워크북에 추가
XLSX.utils.book_append_sheet(wb, ws, sheetName);
// 메모리 정리를 위한 가비지 컬렉션 힌트
if (processedCount % (chunkSize * 10) === 0) {
// 5000행마다 짧은 지연을 두어 가비지 컬렉션 기회 제공
await new Promise(resolve => setTimeout(resolve, 10));
}
}
// 엑셀 파일 다운로드
XLSX.writeFile(wb, fileName);
updateLoadingState(95);
// 워크시트 데이터 구성 및 파일 다운로드를 메인 로직과 분리
setTimeout(() => {
try {
// 워크시트 데이터 구성
const wsData = [
// 헤더 행
headerRow,
// 데이터 행들
...allDataRows
];
// 워크시트 생성 (메모리 최적화)
const ws = XLSX.utils.aoa_to_sheet(wsData);
// 열 너비 설정 (성능 최적화를 위해 샘플링)
ws['!cols'] = headers.map((header) => {
// 헤더 길이와 샘플 데이터 길이를 기준으로 열 너비 결정
const sampleSize = Math.min(flattenedData.length, 500);
const samples = [];
for (let i = 0; i < sampleSize; i++) {
const randomIndex = Math.floor(Math.random() * flattenedData.length);
const item = flattenedData[randomIndex];
if (item[header] !== undefined) {
samples.push(String(item[header]).length * 1.2);
}
}
const maxLength = Math.max(
header.length * 1.5,
...samples
);
return { wch: Math.max(10, Math.min(50, maxLength)) };
});
// 최종 단계 분리
setTimeout(() => {
try {
// 워크시트를 워크북에 추가
XLSX.utils.book_append_sheet(wb, ws, sheetName);
// 엑셀 파일 다운로드
XLSX.writeFile(wb, fileName);
updateLoadingState(100);
resolve();
} catch (error) {
reject(error);
}
}, 100);
} catch (error) {
reject(error);
}
}, 0);
} catch (error) {
reject(error);
}
});
};
const handleDownload = useCallback(async () => {
if (isDownloading) return; // 이미 다운로드 중이면 중복 실행 방지
setIsDownloading(true);
setLastProgress(0);
if (onLoadingChange) onLoadingChange({loading: true, progress: 0});
try {
if (tableRef) {
await downloadTableExcel();
} else if (data) {
await downloadDataExcel();
} else {
alert('유효한 데이터 소스가 없습니다.');
}
} catch (error) {
console.error('Excel download failed:', error);
alert('엑셀 다운로드 중 오류가 발생했습니다.');
} finally {
// 다운로드 완료 후 짧은 지연 시간을 두어 100% 상태를 잠시 보여줌
setTimeout(() => {
setIsDownloading(false);
if (onLoadingChange) onLoadingChange({loading: false, progress: 100});
}, 500);
}
};
const handleDownload = () => {
if (tableRef) {
downloadTableExcel();
} else if (data) {
downloadDataExcel();
} else {
alert('유효한 데이터 소스가 없습니다.');
}
};
}, [tableRef, data, fileName, sheetName, isDownloading, onLoadingChange]);
return (
<ExcelDownButton onClick={handleDownload}>
엑셀 다운로드
<ExcelDownButton onClick={handleDownload} disabled={isDownloading}>
{isDownloading ? '다운로드 중...' : '엑셀 다운로드'}
</ExcelDownButton>
);
};

View File

@@ -2,29 +2,26 @@ import React, { useState, useEffect } from 'react';
import styled from 'styled-components';
const Button = styled.button`
position: fixed;
bottom: 30px;
right: 30px;
width: 50px;
height: 50px;
border-radius: 50%;
background-color: #666666;
color: white;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: ${props => (props.visible ? '1' : '0')};
visibility: ${props => (props.visible ? 'visible' : 'hidden')};
transition: opacity 0.3s, visibility 0.3s;
border: none;
box-shadow: 0px 2px 10px rgba(0, 0, 0, 0.2);
z-index: 999;
&:hover {
background-color: #333333;
}
position: fixed;
bottom: 30px;
right: 30px;
width: 50px;
height: 50px;
border-radius: 50%;
background-color: #666666;
color: white;
font-size: 16px;
display: ${props => (props.$show ? 'flex' : 'none')};
align-items: center;
justify-content: center;
cursor: pointer;
border: none;
box-shadow: 0px 2px 10px rgba(0, 0, 0, 0.2);
z-index: 999;
&:hover {
background-color: #333333;
}
`;
const TopButton = () => {
@@ -53,7 +50,7 @@ const TopButton = () => {
return (
<Button
visible={visible}
$show={visible}
onClick={scrollToTop}
title="맨 위로 이동"
>

View File

@@ -12,8 +12,10 @@ import CustomConfirmModal from './modal/CustomConfirmModal';
import Modal from './modal/Modal';
import Pagination from './Pagination/Pagination';
import DynamoPagination from './Pagination/DynamoPagination';
import FrontPagination from './Pagination/FrontPagination';
import ViewTableInfo from './Table/ViewTableInfo';
import Loading from './Loading';
import DownloadProgress from './DownloadProgress';
import CDivider from './CDivider';
import TopButton from './button/TopButton';
export {
@@ -42,5 +44,7 @@ export { DateTimeInput,
Loading,
CDivider,
TopButton,
DynamoPagination
DynamoPagination,
FrontPagination,
DownloadProgress
};