diff --git a/src/assets/data/options.js b/src/assets/data/options.js
index 017f8fd..df01888 100644
--- a/src/assets/data/options.js
+++ b/src/assets/data/options.js
@@ -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" },
diff --git a/src/components/ServiceManage/index.js b/src/components/ServiceManage/index.js
index f33d458..9800b63 100644
--- a/src/components/ServiceManage/index.js
+++ b/src/components/ServiceManage/index.js
@@ -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,
diff --git a/src/components/ServiceManage/searchBar/BusinessLogSearchBar.js b/src/components/ServiceManage/searchBar/BusinessLogSearchBar.js
index 24c5d01..f453349 100644
--- a/src/components/ServiceManage/searchBar/BusinessLogSearchBar.js
+++ b/src/components/ServiceManage/searchBar/BusinessLogSearchBar.js
@@ -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 ;
+
+ const filterComponent = (
+ onSearch({filters: e.target.value })} />
+ );
+
+ return ;
};
export default BusinessLogSearchBar;
\ No newline at end of file
diff --git a/src/components/ServiceManage/searchBar/SearchFilter.js b/src/components/ServiceManage/searchBar/SearchFilter.js
new file mode 100644
index 0000000..67a4016
--- /dev/null
+++ b/src/components/ServiceManage/searchBar/SearchFilter.js
@@ -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 (
+
+
+ 필터 {isOpen ? '▲' : '▼'}
+
+
+ {isOpen && (
+ <>
+
+ {filters.length > 0 && (
+
+ {filters.map((filter, index) => (
+
+ {filter.field_name}:
+ {filter.value}
+ handleRemoveFilter(index)}>×
+
+ ))}
+
+ )}
+
+ {filterSections.map((section, index) => (
+
+ 속성 이름
+ handleInputChange(index, 'field_name', e.target.value)}
+ />
+
+ 속성 유형
+ handleInputChange(index, 'field_type', e.target.value)}
+ >
+ {opInputType.map((data, index) => (
+
+ ))}
+
+
+ 속성 값
+ handleInputChange(index, 'value', e.target.value)}
+ />
+
+ handleAddFilter(index)}>추가
+
+ {filterSections.length > 1 && (
+ handleRemoveFilterSection(index)}>×
+ )}
+
+ ))}
+
+ {/**/}
+ {/* 필터 추가*/}
+ {/**/}
+ >
+ )}
+
+ );
+};
+
+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;
+ }
+`;
\ No newline at end of file
diff --git a/src/components/common/CircularProgress.js b/src/components/common/CircularProgress.js
new file mode 100644
index 0000000..df34063
--- /dev/null
+++ b/src/components/common/CircularProgress.js
@@ -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 (
+
+
+ {showText && (
+
+ {`${Math.round(progress)}%`}
+
+ )}
+
+ );
+};
+
+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};
+`;
\ No newline at end of file
diff --git a/src/components/common/DownloadProgress.js b/src/components/common/DownloadProgress.js
new file mode 100644
index 0000000..0946e53
--- /dev/null
+++ b/src/components/common/DownloadProgress.js
@@ -0,0 +1,42 @@
+import styled from 'styled-components';
+
+const DownloadProgress = ({ progress }) => {
+ return (
+
+ 다운로드 중... {progress}%
+
+
+
+
+ );
+};
+
+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;
+`;
\ No newline at end of file
diff --git a/src/components/common/Pagination/FrontPagination.js b/src/components/common/Pagination/FrontPagination.js
new file mode 100644
index 0000000..10044cf
--- /dev/null
+++ b/src/components/common/Pagination/FrontPagination.js
@@ -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 (
+ <>
+
+
+
+
+ {pArr.map(number => (
+ clickPage(number)}
+ >
+ {number}
+
+ ))}
+
+
+
+
+ >
+ );
+};
+
+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;
+ }
+`;
\ No newline at end of file
diff --git a/src/components/common/SearchBar/SearchBarLayout.js b/src/components/common/SearchBar/SearchBarLayout.js
index 808bc20..f92c3b0 100644
--- a/src/components/common/SearchBar/SearchBarLayout.js
+++ b/src/components/common/SearchBar/SearchBarLayout.js
@@ -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 (
@@ -17,6 +17,11 @@ const SearchBarLayout = ({ firstColumnData, secondColumnData, direction, onReset
))}
)}
+ {filter && (
+
+ {filter}
+
+ )}
diff --git a/src/components/common/button/ExcelDownButton.js b/src/components/common/button/ExcelDownButton.js
index 4678c4e..b379520 100644
--- a/src/components/common/button/ExcelDownButton.js
+++ b/src/components/common/button/ExcelDownButton.js
@@ -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 (
-
- 엑셀 다운로드
+
+ {isDownloading ? '다운로드 중...' : '엑셀 다운로드'}
);
};
diff --git a/src/components/common/button/TopButton.js b/src/components/common/button/TopButton.js
index d9ba237..2b4ab90 100644
--- a/src/components/common/button/TopButton.js
+++ b/src/components/common/button/TopButton.js
@@ -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 (