antBUtton 생성

topButton 이미지 변경
엑셀 버튼 조정
tab 컨트론 생성
detailGrid, layout 생성
modal motion 적용
This commit is contained in:
2025-06-27 09:25:41 +09:00
parent b2b579ead1
commit 0368bf77e7
12 changed files with 717 additions and 38 deletions

View File

@@ -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'

View File

@@ -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
},
}
}
};

View File

@@ -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: '의상/타투' },

View File

@@ -158,4 +158,10 @@ export const currencyCodeTypes = {
sapphire: "19010002",
ruby: "19010005",
calium: "19010003"
}
}
export const languageNames = {
'KO': '한국어',
'EN': '영어',
'JA': '일본어',
};

View File

@@ -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 <Input
{...commonProps}
value={currentValue}
onChange={(e) => onChange(key, e.target.value, handler)}
placeholder={placeholder || `${label} 입력`}
/>;
case 'number':
return <InputNumber
{...commonProps}
value={currentValue}
min={min}
max={max}
onChange={(value) => onChange(key, value, handler)}
placeholder={placeholder || `${label} 입력`}
/>;
case 'select':
return (
<Select
{...commonProps}
value={currentValue}
onChange={(value) => onChange(key, value, handler)}
placeholder={placeholder || `${label} 선택`}
>
{options && options.map((option) => (
<Select.Option key={option.value} value={option.value}>
{option.label}
</Select.Option>
))}
</Select>
);
case 'date':
return (
<DatePicker
{...commonProps}
value={currentValue ? dayjs(currentValue) : null}
format={format || 'YYYY-MM-DD'}
onChange={(date) => onChange(key, date, handler)}
placeholder={placeholder || `${label} 선택`}
/>
);
case 'dateRange':
return (
<RangePicker
{...commonProps}
showTime={showTime !== false}
value={keys ? [
formData[keys.start] ? dayjs(formData[keys.start]) : null,
formData[keys.end] ? dayjs(formData[keys.end]) : null
] : (currentValue ? [
currentValue.start ? dayjs(currentValue.start) : null,
currentValue.end ? dayjs(currentValue.end) : null
] : null)}
format={format || 'YYYY-MM-DD HH:mm:ss'}
onChange={(dates, dateStrings) => {
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 (
<TimePicker
{...commonProps}
value={currentValue ? dayjs(currentValue, 'HH:mm') : null}
format={format || 'HH:mm'}
onChange={(time) => onChange(key, time, handler)}
placeholder={placeholder || `${label} 선택`}
/>
);
case 'switch':
return (
<Switch
checked={currentValue}
onChange={(checked) => onChange(key, checked, handler)}
/>
);
case 'checkbox':
return (
<Checkbox
checked={currentValue}
disabled={disabled || itemDisabled}
onChange={(e) => onChange(key, e.target.checked, handler)}
>
{item.checkboxLabel}
</Checkbox>
);
case 'status':
return <StatusDisplay status={currentValue} />;
case 'tab':
return <AnimatedTabs
items={tabItems}
activeKey={activeLanguage}
onChange={handleTabChange}
/>
case 'custom':
return item.render ? item.render(formData, onChange) : null;
default:
return <div>{currentValue}</div>;
}
};
// 각 셀의 폭 계산 (Ant Design의 24-컬럼 시스템 기준)
const colWidth = 24 / columns;
return (
<GridContainer>
<Form layout="horizontal">
{rows.map(rowIndex => {
const rowItems = positionedItems[rowIndex];
const cols = Object.keys(rowItems).map(Number).sort((a, b) => a - b);
return (
<Row key={`row-${rowIndex}`} gutter={[16, 16]}>
{cols.map(colIndex => {
const item = rowItems[colIndex];
const itemColSpan = Math.min(item.colSpan * colWidth, 24);
return (
<Col
key={`${item.key}-${rowIndex}-${colIndex}`}
span={itemColSpan}
xs={24}
sm={itemColSpan}
>
<Form.Item
label={item.label}
required={item.required}
tooltip={item.tooltip}
>
{renderComponent(item)}
</Form.Item>
</Col>
);
})}
</Row>
);
})}
</Form>
</GridContainer>
);
};
// 상태 표시 컴포넌트
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 (
<StatusTag color={color}>
{text}
</StatusTag>
);
};
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;

View File

@@ -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 (
<DetailWrapper>
{itemGroups.map((group, index) => (
<Card
key={`group-${index}`}
title={group.title}
style={{ marginBottom: 16 }}
>
<DetailGrid
items={group.items}
formData={formData}
onChange={handleChange}
disabled={disabled || group.disabled}
columnCount={group.columnCount || columnCount}
/>
</Card>
))}
{children}
</DetailWrapper>
);
};
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: ''
// }
// ];

View File

@@ -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 = <SearchOutlined />;
break;
case 'cancel':
color = 'danger';
break;
default:
break;
}
return (
<AntBtn
type={buttonType}
shape={shape}
icon={icon}
variant={variant}
color={color}
htmlType={type === 'submit' ? 'submit' : 'button'}
{...buttonProps}
>
{text}
</AntBtn>
);
};
export default AntButton;

View File

@@ -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]);

View File

@@ -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="맨 위로 이동"
>
<VerticalAlignTopOutlined />
</Button>
);
};

View File

@@ -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 (
<StyledTabs
activeKey={activeKey}
onChange={onChange}
centered={true}
>
{items.map(item => (
<Tabs.TabPane
tab={item.label}
key={item.key}
>
<AnimatePresence mode="wait">
<motion.div
key={activeKey}
initial={{ opacity: 0, x: 50 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -50 }}
transition={{
type: "spring",
stiffness: 300,
damping: 30
}}
>
{item.children}
</motion.div>
</AnimatePresence>
</Tabs.TabPane>
))}
</StyledTabs>
);
};
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;

View File

@@ -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
};

View File

@@ -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 (
<>
<ModalBg $view={$view} $bgcolor={$bgcolor}>
<ModalWrapper $padding={$padding} min={min}>
{children}
</ModalWrapper>
</ModalBg>
<AnimatePresence>
{isVisible && (
<ModalBg
$bgcolor={$bgcolor}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
<ModalContainer>
<ModalWrapper
$padding={$padding}
min={min}
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
transition={{
type: "spring",
stiffness: 300,
damping: 30
}}
>
{children}
</ModalWrapper>
</ModalContainer>
</ModalBg>
)}
</AnimatePresence>
</>
);
};
// const Modal = ({ children, $padding, min, $view, $bgcolor }) => {
// return (
// <>
// <ModalBg $view={$view} $bgcolor={$bgcolor}>
// <ModalWrapper $padding={$padding} min={min}>
// {children}
// </ModalWrapper>
// </ModalBg>
// </>
// );
// };
export default Modal;