From 0368bf77e79e4ae99d347e549387dcc05a1e7b86 Mon Sep 17 00:00:00 2001 From: bcjang Date: Fri, 27 Jun 2025 09:25:41 +0900 Subject: [PATCH] =?UTF-8?q?antBUtton=20=EC=83=9D=EC=84=B1=20topButton=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=B3=80=EA=B2=BD=20=EC=97=91?= =?UTF-8?q?=EC=85=80=20=EB=B2=84=ED=8A=BC=20=EC=A1=B0=EC=A0=95=20tab=20?= =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A0=20=EC=83=9D=EC=84=B1=20detailGrid,?= =?UTF-8?q?=20layout=20=EC=83=9D=EC=84=B1=20modal=20motion=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/Log.js | 3 +- src/assets/data/menuConfig.js | 20 +- src/assets/data/options.js | 2 +- src/assets/data/types.js | 8 +- src/components/common/Layout/DetailGrid.js | 301 ++++++++++++++++++ src/components/common/Layout/DetailLayout.js | 127 ++++++++ src/components/common/button/AntButton.js | 84 +++++ .../common/button/ExcelExportButton.js | 23 +- src/components/common/button/TopButton.js | 7 +- src/components/common/control/AnimatedTabs.js | 78 +++++ src/components/common/index.js | 6 +- src/components/common/modal/Modal.js | 96 +++++- 12 files changed, 717 insertions(+), 38 deletions(-) create mode 100644 src/components/common/Layout/DetailGrid.js create mode 100644 src/components/common/Layout/DetailLayout.js create mode 100644 src/components/common/button/AntButton.js create mode 100644 src/components/common/control/AnimatedTabs.js diff --git a/src/apis/Log.js b/src/apis/Log.js index 1d0261e..62254f8 100644 --- a/src/apis/Log.js +++ b/src/apis/Log.js @@ -22,8 +22,7 @@ export const BusinessLogExport = async (token, params) => { try { await Axios.post(`/api/v1/log/generic/excel-export`, params, { headers: { Authorization: `Bearer ${token}` }, - responseType: 'blob', - timeout: 300000 + responseType: 'blob' }).then(response => { responseFileDownload(response, { defaultFileName: 'businessLog' diff --git a/src/assets/data/menuConfig.js b/src/assets/data/menuConfig.js index 125c3f7..ccd1bba 100644 --- a/src/assets/data/menuConfig.js +++ b/src/assets/data/menuConfig.js @@ -204,16 +204,16 @@ export const menuConfig = { view: true, authLevel: adminAuthLevel.NONE }, - // menubanner: { - // title: '메뉴 배너 관리', - // permissions: { - // read: authType.menuBannerRead, - // update: authType.menuBannerUpdate, - // delete: authType.menuBannerDelete - // }, - // view: true, - // authLevel: adminAuthLevel.NONE - // }, + menubanner: { + title: '메뉴 배너 관리', + permissions: { + read: authType.menuBannerRead, + update: authType.menuBannerUpdate, + delete: authType.menuBannerDelete + }, + view: true, + authLevel: adminAuthLevel.NONE + }, } } }; \ No newline at end of file diff --git a/src/assets/data/options.js b/src/assets/data/options.js index 02e2558..f2ebabf 100644 --- a/src/assets/data/options.js +++ b/src/assets/data/options.js @@ -11,7 +11,7 @@ export const TabGameLogList = [ ]; export const TabEconomicIndexList = [ - { value: 'CURRENCY', name: '재화' }, + { value: 'CURRENCY', name: '재화(유저)' }, // { value: 'ITEM', name: '아이템' }, // { value: 'VBP', name: 'VBP' }, // { value: 'deco', name: '의상/타투' }, diff --git a/src/assets/data/types.js b/src/assets/data/types.js index b4d6af3..3ea799a 100644 --- a/src/assets/data/types.js +++ b/src/assets/data/types.js @@ -158,4 +158,10 @@ export const currencyCodeTypes = { sapphire: "19010002", ruby: "19010005", calium: "19010003" -} \ No newline at end of file +} + +export const languageNames = { + 'KO': '한국어', + 'EN': '영어', + 'JA': '일본어', +}; diff --git a/src/components/common/Layout/DetailGrid.js b/src/components/common/Layout/DetailGrid.js new file mode 100644 index 0000000..baca099 --- /dev/null +++ b/src/components/common/Layout/DetailGrid.js @@ -0,0 +1,301 @@ +import React from 'react'; +import { Row, Col, Form, Input, Select, DatePicker, TimePicker, InputNumber, Switch, Button, Checkbox } from 'antd'; +import styled from 'styled-components'; +import dayjs from 'dayjs'; +const { RangePicker } = DatePicker; + +/** + * 위치 지정 가능한 그리드 형태 상세 정보 표시 컴포넌트 + * @param {Array} items - 표시할 항목 배열 (row, col, rowSpan, colSpan 속성 추가) + * @param {Object} formData - 폼 데이터 객체 + * @param {Function} onChange - 값 변경 시 호출할 함수 + * @param {boolean} disabled - 전체 비활성화 여부 + * @param {number} columns - 그리드의 총 컬럼 수 (기본값: 4) + */ +const DetailGrid = ({ items, formData, onChange, disabled = false, columns = 4 }) => { + // 항목을 행과 열 위치별로 그룹화 + const positionedItems = {}; + + // 각 항목의 위치 및 span 정보 처리 + items.forEach(item => { + const rowIndex = item.row || 0; + const colIndex = item.col || 0; + + if (!positionedItems[rowIndex]) { + positionedItems[rowIndex] = {}; + } + + positionedItems[rowIndex][colIndex] = { + ...item, + rowSpan: item.rowSpan || 1, + colSpan: item.colSpan || 1 + }; + }); + + // 행 번호 목록 (정렬) + const rows = Object.keys(positionedItems).map(Number).sort((a, b) => a - b); + + // 항목에 따른 컴포넌트 렌더링 함수 + const renderComponent = (item) => { + const { + type, + key, + keys, + label, + value, + options, + placeholder, + disabled: itemDisabled, + width, + handler, + min, + max, + format, + required, + showTime + } = item; + + // 현재 값 가져오기 (formData에서 또는 항목에서) + const currentValue = formData[key] !== undefined ? formData[key] : value; + + // 컴포넌트 공통 속성 + const commonProps = { + id: key, + disabled: disabled || itemDisabled, + style: width ? { width, fontSize: '15px' } : { width: '100%', fontSize: '15px' } + }; + + // 항목 타입에 따른 컴포넌트 렌더링 + switch (type) { + case 'text': + return onChange(key, e.target.value, handler)} + placeholder={placeholder || `${label} 입력`} + />; + + case 'number': + return onChange(key, value, handler)} + placeholder={placeholder || `${label} 입력`} + />; + + case 'select': + return ( + + ); + + case 'date': + return ( + onChange(key, date, handler)} + placeholder={placeholder || `${label} 선택`} + /> + ); + + case 'dateRange': + return ( + { + if (dates) { + // 두 개의 별도 필드에 각각 업데이트 + if (item.keys) { + onChange(item.keys.start, dates[0], handler); + onChange(item.keys.end, dates[1], handler); + } else { + // 기존 방식 지원 (하위 호환성) + onChange(key, { + start: dates[0], + end: dates[1] + }, handler); + } + } else { + // 두 필드 모두 비우기 + if (item.keys) { + onChange(item.keys.start, null, handler); + onChange(item.keys.end, null, handler); + } else { + onChange(key, { start: null, end: null }, handler); + } + } + }} + placeholder={[ + item.startLabel || '시작 일시', + item.endLabel || '종료 일시' + ]} + /> + ); + + case 'time': + return ( + onChange(key, time, handler)} + placeholder={placeholder || `${label} 선택`} + /> + ); + + case 'switch': + return ( + onChange(key, checked, handler)} + /> + ); + + case 'checkbox': + return ( + onChange(key, e.target.checked, handler)} + > + {item.checkboxLabel} + + ); + + case 'status': + return ; + + case 'tab': + return + + case 'custom': + return item.render ? item.render(formData, onChange) : null; + + default: + return
{currentValue}
; + } + }; + + // 각 셀의 폭 계산 (Ant Design의 24-컬럼 시스템 기준) + const colWidth = 24 / columns; + + return ( + +
+ {rows.map(rowIndex => { + const rowItems = positionedItems[rowIndex]; + const cols = Object.keys(rowItems).map(Number).sort((a, b) => a - b); + + return ( + + {cols.map(colIndex => { + const item = rowItems[colIndex]; + const itemColSpan = Math.min(item.colSpan * colWidth, 24); + + return ( + + + {renderComponent(item)} + + + ); + })} + + ); + })} +
+
+ ); +}; + +// 상태 표시 컴포넌트 +const StatusDisplay = ({ status }) => { + let color = ''; + let text = ''; + + switch (status) { + case 'wait': + color = '#faad14'; + text = '대기'; + break; + case 'running': + color = '#52c41a'; + text = '진행중'; + break; + case 'finish': + color = '#d9d9d9'; + text = '만료'; + break; + case 'fail': + color = '#ff4d4f'; + text = '실패'; + break; + case 'delete': + color = '#ff4d4f'; + text = '삭제'; + break; + default: + color = '#1890ff'; + text = status; + } + + return ( + + {text} + + ); +}; + +const GridContainer = styled.div` + width: 100%; + padding: 16px 0; + font-size: 15px; +`; + +const StatusTag = styled.div` + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + background-color: ${props => props.color}; + color: white; + font-size: 15px; + font-weight: 500; +`; + +export default DetailGrid; \ No newline at end of file diff --git a/src/components/common/Layout/DetailLayout.js b/src/components/common/Layout/DetailLayout.js new file mode 100644 index 0000000..a3107d2 --- /dev/null +++ b/src/components/common/Layout/DetailLayout.js @@ -0,0 +1,127 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Card } from 'antd'; +import DetailGrid from './DetailGrid'; + +/** + * Ant Design 방식의 DetailModalWrapper 컴포넌트 + * @param {Array} itemGroups - 표시할 항목 그룹 배열 + * @param {Object} formData - 폼 데이터 객체 + * @param {Function} onChange - 값 변경 시 호출할 함수 + * @param {boolean} disabled - 전체 비활성화 여부 + * @param {number} columnCount - 한 행에 표시할 컬럼 수 (기본값: 4) + * @param {ReactNode} children - 추가 컨텐츠 + */ +const DetailLayout = ({ + itemGroups, + formData, + onChange, + disabled = false, + columnCount = 4, + children + }) => { + // 값 변경 핸들러 + const handleChange = (key, value, handler) => { + // 핸들러가 있으면 핸들러 실행 + if (handler) { + handler(value, key, formData); + } + + // 키가 점 표기법이면 중첩 객체 업데이트 + if (key.includes('.')) { + const [parentKey, childKey] = key.split('.'); + onChange({ + ...formData, + [parentKey]: { + ...formData[parentKey], + [childKey]: value + } + }); + } else { + // 일반 키는 직접 업데이트 + onChange({ ...formData, [key]: value }); + } + }; + + return ( + + {itemGroups.map((group, index) => ( + + + + ))} + {children} + + ); +}; + +const DetailWrapper = styled.div` + width: 100%; + max-width: 1200px; + margin: 0 auto; +`; + +export default DetailLayout; + +//예시 +// const itemGroupsExample = [ +// { +// title: '기본 정보', +// items: [ +// { +// row: 0, +// col: 0, +// colSpan: 2, +// type: 'text', +// key: 'title', +// label: '제목', +// required: true, +// disabled: !isView('title'), +// width: '300px', +// }, +// { +// row: 0, +// col: 2, +// colSpan: 2, +// type: 'number', +// key: 'order_id', +// label: '순서', +// required: true, +// disabled: !isView('order_id'), +// width: '200px', +// min: 1, +// }, +// { +// row: 1, +// col: 0, +// colSpan: 2, +// type: 'status', +// key: 'status', +// label: '상태', +// value: resultData.status, +// }, +// { +// row: 1, +// col: 2, +// colSpan: 2, +// type: 'switch', +// key: 'is_link', +// label: '링크 사용', +// disabled: !isView('is_link'), +// }, +// ] +// }, +// { +// title: '' +// } +// ]; \ No newline at end of file diff --git a/src/components/common/button/AntButton.js b/src/components/common/button/AntButton.js new file mode 100644 index 0000000..a524527 --- /dev/null +++ b/src/components/common/button/AntButton.js @@ -0,0 +1,84 @@ +import React from 'react'; +import { Button as AntBtn} from 'antd'; +import { SearchOutlined } from '@ant-design/icons'; + +/** + * Ant Design의 Button을 사용한 버튼 컴포넌트 + * @param {string} text - 버튼 텍스트 + * @param {string} type - 버튼 타입 (primary, default, dashed, link, text) + * @param {function} onClick - 클릭 이벤트 핸들러 + * @param {string} theme - 커스텀 테마 (primary, line, disable, reset, gray, search, find) + * @param {boolean} disabled - 비활성화 여부 + * @param {string} name - 버튼 이름 + * @param {string} width - 버튼 너비 + * @param {string} height - 버튼 높이 + */ +const AntButton = ({ + text, + type = 'button', + onClick, + theme, + disabled, + name, + width, + height, + ...props + }) => { + // theme에 따른 Ant Design 버튼 타입 설정 + let buttonType = 'default'; + let shape = 'default'; + let icon = ''; + let color = 'default'; + let variant = 'outlined'; + let buttonProps = { + disabled, + onClick, + name, + style: { + width: width || 'auto', + height: height || '35px', + minWidth: 'fit-content', + fontSize: '15px', + }, + ...props + }; + + // 테마에 따른 스타일 설정 + switch (theme) { + case 'primary': + case 'submit': + case 'line': + buttonType = 'default'; + break; + case 'disable': + buttonProps.disabled = true; + break; + case 'gray': + break; + case 'search': + case 'find': + icon = ; + break; + case 'cancel': + color = 'danger'; + break; + default: + break; + } + + return ( + + {text} + + ); +}; + +export default AntButton; \ No newline at end of file diff --git a/src/components/common/button/ExcelExportButton.js b/src/components/common/button/ExcelExportButton.js index 9272d3e..a004982 100644 --- a/src/components/common/button/ExcelExportButton.js +++ b/src/components/common/button/ExcelExportButton.js @@ -34,13 +34,19 @@ const ExcelDownloadButton = ({ functionName, params, fileName = 'download.xlsx', // 100% 완료 시 폴링 중지 if (percentage >= 100) { + // console.log("pollProgress data exists polling stop"); clearInterval(intervalRef.current); intervalRef.current = null; } } else { // 진행률 정보가 없으면 폴링 중지 + console.log("pollProgress data not exists polling stop"); clearInterval(intervalRef.current); intervalRef.current = null; + + if (onLoadingChange) { + onLoadingChange({ loading: false, progress: 0 }); + } } } catch (error) { console.error('Progress polling error:', error); @@ -61,7 +67,13 @@ const ExcelDownloadButton = ({ functionName, params, fileName = 'download.xlsx', useEffect(() => { return () => { if (intervalRef.current) { + console.log("unmount polling stop"); + onLoadingChange({ loading: false, progress: 0 }); clearInterval(intervalRef.current); + + if (onLoadingChange) { + onLoadingChange({ loading: false, progress: 0 }); + } } }; }, []); @@ -77,7 +89,6 @@ const ExcelDownloadButton = ({ functionName, params, fileName = 'download.xlsx', const taskId = `excel_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; taskIdRef.current = taskId; - setIsDownloading(true); if (onLoadingChange) onLoadingChange({loading: true, progress: 0}); @@ -118,10 +129,10 @@ const ExcelDownloadButton = ({ functionName, params, fileName = 'download.xlsx', setTimeout(() => { setIsDownloading(false); - if (onLoadingChange) { - onLoadingChange({ loading: false, progress: 100 }); - showToast('DOWNLOAD_COMPLETE', { type: alertTypes.success }); - } + // console.log("handleDownload finally polling stop"); + + onLoadingChange({ loading: false, progress: 100 }); + showToast('DOWNLOAD_COMPLETE', { type: alertTypes.success }); // 폴링 완전 중지 if (intervalRef.current) { @@ -132,7 +143,7 @@ const ExcelDownloadButton = ({ functionName, params, fileName = 'download.xlsx', // const end = Date.now(); // showToast(`처리 시간: ${end - start}ms`, { type: alertTypes.info }); // console.log(`처리 시간: ${end - start}ms`); - }, 2000); + }, 1000); } }, [params, isDownloading, onLoadingChange, dataSize, pollProgress, showToast]); diff --git a/src/components/common/button/TopButton.js b/src/components/common/button/TopButton.js index 2b4ab90..1ec9035 100644 --- a/src/components/common/button/TopButton.js +++ b/src/components/common/button/TopButton.js @@ -1,12 +1,13 @@ import React, { useState, useEffect } from 'react'; import styled from 'styled-components'; +import { VerticalAlignTopOutlined } from '@ant-design/icons'; const Button = styled.button` position: fixed; bottom: 30px; right: 30px; - width: 50px; - height: 50px; + width: 40px; + height: 40px; border-radius: 50%; background-color: #666666; color: white; @@ -54,7 +55,7 @@ const TopButton = () => { onClick={scrollToTop} title="맨 위로 이동" > - ↑ + ); }; diff --git a/src/components/common/control/AnimatedTabs.js b/src/components/common/control/AnimatedTabs.js new file mode 100644 index 0000000..5dcaef9 --- /dev/null +++ b/src/components/common/control/AnimatedTabs.js @@ -0,0 +1,78 @@ +import React from 'react'; +import { Tabs } from 'antd'; +import styled from 'styled-components'; +import { motion, AnimatePresence } from 'framer-motion'; + +// 통합된 애니메이션 탭 컴포넌트 +const AnimatedTabs = ({ items, activeKey, onChange }) => { + return ( + + {items.map(item => ( + + + + {item.children} + + + + ))} + + ); +}; + +const StyledTabs = styled(Tabs)` + margin-top: 20px; + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + + .ant-tabs-nav { + margin-bottom: 16px; + width: 80%; + } + + .ant-tabs-nav-wrap { + justify-content: center; + } + + .ant-tabs-tab { + padding: 8px 16px; + font-size: 14px; + font-weight: 500; + transition: all 0.3s; + } + + .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn { + color: #1890ff; + font-weight: 600; + } + + .ant-tabs-ink-bar { + background-color: #1890ff; + } + + .ant-tabs-content-holder { + width: 100%; + overflow: hidden; + } +`; + +export default AnimatedTabs; diff --git a/src/components/common/index.js b/src/components/common/index.js index d198906..595585c 100644 --- a/src/components/common/index.js +++ b/src/components/common/index.js @@ -19,6 +19,8 @@ import Loading from './Loading'; import DownloadProgress from './DownloadProgress'; import CDivider from './CDivider'; import TopButton from './button/TopButton'; +import AntButton from './button/AntButton'; +import DetailLayout from './Layout/DetailLayout'; import CaliTable from './Custom/CaliTable' @@ -36,6 +38,7 @@ export { DateTimeInput, CheckBox, Radio, Button, + AntButton, ExcelDownButton, AuthModal, CompletedModal, @@ -52,5 +55,6 @@ export { DateTimeInput, DynamoPagination, FrontPagination, DownloadProgress, - CaliTable + CaliTable, + DetailLayout }; \ No newline at end of file diff --git a/src/components/common/modal/Modal.js b/src/components/common/modal/Modal.js index 349be6e..7ef1f9e 100644 --- a/src/components/common/modal/Modal.js +++ b/src/components/common/modal/Modal.js @@ -1,6 +1,34 @@ import styled from 'styled-components'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Modal as AntModal } from 'antd'; -const ModalBg = styled.div` +// const ModalBg = styled.div` +// position: fixed; +// background: ${props => props.$bgcolor || 'rgba(0, 0, 0, 0.5)'}; +// width: 100%; +// height: 100%; +// top: 0; +// left: 0; +// min-width: 1080px; +// display: ${props => (props.$view === 'hidden' ? 'none' : 'block')}; +// z-index: 20; +// `; +// +// const ModalWrapper = styled.div` +// position: absolute; +// background: #fff; +// left: 50%; +// top: 50%; +// transform: translate(-50%, -50%); +// min-width: ${props => props.min || 'auto'}; +// padding: ${props => props.$padding || '30px'}; +// border-radius: 30px; +// max-height: 90%; +// overflow: auto; +// box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.3); +// `; + +const ModalBg = styled(motion.div)` position: fixed; background: ${props => props.$bgcolor || 'rgba(0, 0, 0, 0.5)'}; width: 100%; @@ -12,30 +40,70 @@ const ModalBg = styled.div` z-index: 20; `; -const ModalWrapper = styled.div` +const ModalContainer = styled.div` position: absolute; - background: #fff; left: 50%; top: 50%; transform: translate(-50%, -50%); - min-width: ${props => props.min || 'auto'}; - padding: ${props => props.$padding || '30px'}; - border-radius: 30px; - max-height: 90%; - overflow: auto; - box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.3); +`; + +const ModalWrapper = styled(motion.div)` + background: #fff; + min-width: ${props => props.min || 'auto'}; + padding: ${props => props.$padding || '30px'}; + border-radius: 30px; + max-height: 90%; + overflow: auto; + box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.3); `; const Modal = ({ children, $padding, min, $view, $bgcolor }) => { + const isVisible = $view !== 'hidden'; + return ( <> - - - {children} - - + + {isVisible && ( + + + + {children} + + + + )} + ); }; +// const Modal = ({ children, $padding, min, $view, $bgcolor }) => { +// return ( +// <> +// +// +// {children} +// +// +// +// ); +// }; + export default Modal;