detailGrid 탭 추가

This commit is contained in:
2025-07-01 14:41:07 +09:00
parent 38dac99278
commit d4db33bcf0
4 changed files with 75 additions and 558 deletions

View File

@@ -1,526 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { getOptionsArray } from '../../../utils';
import {
SelectInput,
SearchBarAlert
} from '../../../styles/Components';
import {
FormInput, FormInputSuffix, FormInputSuffixWrapper,
FormLabel,
FormRowGroup,
FormStatusBar,
FormStatusLabel,
FormStatusWarning,
} from '../../../styles/ModuleComponents';
import { CheckBox, SingleDatePicker, SingleTimePicker } from '../../common';
import Button from '../../common/button/Button';
import styled from 'styled-components';
import ImageUploadBtn from '../../ServiceManage/ImageUploadBtn';
const CaliForm = ({
config, // 폼 설정 JSON
mode, // 'create', 'update', 'view' 중 하나
initialData, // 초기 데이터
externalData, // 외부 데이터(옵션 등)
onSubmit, // 제출 핸들러
onCancel, // 취소 핸들러
className, // 추가 CSS 클래스
onFieldValidation, // 필드 유효성 검사 콜백
formRef // 폼 ref
}) => {
const { t } = useTranslation();
const [formData, setFormData] = useState({ ...(config?.initData || {}), ...(initialData || {}) });
const [errors, setErrors] = useState({});
const [isFormValid, setIsFormValid] = useState(false);
// 필드 변경 핸들러
const handleFieldChange = (fieldId, value) => {
setFormData(prev => ({
...prev,
[fieldId]: value
}));
};
// 날짜 변경 핸들러
const handleDateChange = (fieldId, date) => {
if (!date) return;
setFormData(prev => ({
...prev,
[fieldId]: date
}));
};
// 시간 변경 핸들러
const handleTimeChange = (fieldId, time) => {
if (!time) return;
const newDateTime = formData[fieldId] ? new Date(formData[fieldId]) : new Date();
newDateTime.setHours(time.getHours(), time.getMinutes(), 0, 0);
setFormData(prev => ({
...prev,
[fieldId]: newDateTime
}));
};
// 폼 유효성 검사
useEffect(() => {
const validateForm = () => {
const newErrors = {};
let isValid = true;
if (!config) return false;
// 필수 필드 검사
const requiredFields = config.fields
.filter(f =>
f.visibleOn.includes(mode) &&
f.validations?.includes("required")
)
.map(f => f.id);
requiredFields.forEach(fieldId => {
if (!formData[fieldId] && formData[fieldId] !== 0) {
newErrors[fieldId] = t('REQUIRED_FIELD');
isValid = false;
}
});
// 조건부 유효성 검사
if (config.validations && config.validations[mode]) {
for (const validation of config.validations[mode]) {
const conditionResult = evaluateCondition(validation.condition, {
...formData,
current_time: new Date().getTime()
});
if (conditionResult) {
// 전체 폼 검증 오류
newErrors._form = t(validation.message);
isValid = false;
}
}
}
setErrors(newErrors);
setIsFormValid(isValid);
if (onFieldValidation) {
onFieldValidation(isValid, newErrors);
}
return isValid;
};
validateForm();
}, [config, formData, mode, t, onFieldValidation]);
// 간단한 조건식 평가 함수
const evaluateCondition = (conditionStr, context) => {
try {
const fn = new Function(...Object.keys(context), `return ${conditionStr}`);
return fn(...Object.values(context));
} catch (e) {
console.error('Error evaluating condition:', e);
return false;
}
};
// 필드 렌더링
const renderField = (field) => {
const isEditable = field.editableOn.includes(mode);
const value = formData[field.id] !== undefined ? formData[field.id] : '';
const hasError = errors[field.id];
switch (field.type) {
case 'text':
return (
<div className="form-field">
<FormInput
type="text"
value={value}
onChange={e => handleFieldChange(field.id, e.target.value)}
disabled={!isEditable}
width={field.width}
className={hasError ? 'error' : ''}
/>
{hasError && <div className="field-error">{hasError}</div>}
</div>
);
case 'number':
return (
<div className="form-field">
<FormInput
type="number"
value={value}
onChange={e => handleFieldChange(field.id, Number(e.target.value))}
disabled={!isEditable}
width={field.width}
min={field.min}
max={field.max}
step={field.step || 1}
className={hasError ? 'error' : ''}
/>
{hasError && <div className="field-error">{hasError}</div>}
</div>
);
case 'select':
let options = [];
if (field.optionsKey) {
// 옵션 설정에서 가져오기
options = getOptionsArray(field.optionsKey);
} else if (field.dataSource && externalData) {
// 외부 데이터 소스 사용
const dataSource = externalData[field.dataSource] || [];
options = dataSource.map(item => ({
value: item[field.valueField],
label: field.displayFormat
? field.displayFormat.replace('{value}', item[field.valueField])
.replace('{display}', item[field.displayField])
: `${item[field.displayField]}(${item[field.valueField]})`
}));
} else if (field.options) {
options = field.options;
}
return (
<div className="form-field">
<SelectInput
value={value}
onChange={e => handleFieldChange(field.id, e.target.value)}
disabled={!isEditable}
width={field.width}
className={hasError ? 'error' : ''}
>
{options.map((option, index) => (
<option key={index} value={option.value}>
{option.label}
</option>
))}
</SelectInput>
{hasError && <div className="field-error">{hasError}</div>}
</div>
);
case 'datePicker':
return (
<div className="form-field">
<SingleDatePicker
label={field.label}
disabled={!isEditable}
dateLabel={field.dateLabel}
onDateChange={date => handleDateChange(field.id, date)}
selectedDate={value}
minDate={field.minDate}
maxDate={field.maxDate}
className={hasError ? 'error' : ''}
/>
{hasError && <div className="field-error">{hasError}</div>}
</div>
);
case 'timePicker':
return (
<div className="form-field">
<SingleTimePicker
label={field.label}
disabled={!isEditable}
selectedTime={value}
onTimeChange={time => handleTimeChange(field.id, time)}
className={hasError ? 'error' : ''}
/>
{hasError && <div className="field-error">{hasError}</div>}
</div>
);
case 'status':
let statusText = "";
if (field.optionsKey && formData[field.statusField]) {
const statusOptions = getOptionsArray(field.optionsKey);
const statusItem = statusOptions.find(item => item.value === formData[field.statusField]);
statusText = statusItem ? statusItem.name : "등록";
}
return (
<div className="form-field">
<FormStatusBar>
<FormStatusLabel>
{field.label}: {statusText}
</FormStatusLabel>
{mode === 'update' && field.warningMessage && (
<FormStatusWarning>
{t(field.warningMessage)}
</FormStatusWarning>
)}
</FormStatusBar>
</div>
);
case 'dateTimeRange':
return (
<div className="form-field">
<div className="date-time-range">
<SingleDatePicker
label={field.startDateLabel}
disabled={!isEditable}
dateLabel={field.startDateLabel}
onDateChange={date => handleDateChange(field.startDateField, date)}
selectedDate={formData[field.startDateField]}
/>
<SingleTimePicker
disabled={!isEditable}
selectedTime={formData[field.startDateField]}
onTimeChange={time => handleTimeChange(field.startDateField, time)}
/>
<SingleDatePicker
label={field.endDateLabel}
disabled={!isEditable}
dateLabel={field.endDateLabel}
onDateChange={date => handleDateChange(field.endDateField, date)}
selectedDate={formData[field.endDateField]}
/>
<SingleTimePicker
disabled={!isEditable}
selectedTime={formData[field.endDateField]}
onTimeChange={time => handleTimeChange(field.endDateField, time)}
/>
</div>
{hasError && <div className="field-error">{hasError}</div>}
</div>
);
case 'imageUpload':
const imageLanguage = field.language;
const imageList = formData.image_list || [];
const imageData = imageList.find(img => img.language === imageLanguage) || { content: '' };
return (
<div className="form-field">
<LanguageWrapper>
<LanguageLabel>{imageLanguage}</LanguageLabel>
<ImageUploadBtn
onImageUpload={(file, fileName) => {
const updatedImageList = [...imageList];
const index = updatedImageList.findIndex(img => img.language === imageLanguage);
if (index !== -1) {
updatedImageList[index] = {
...updatedImageList[index],
content: fileName
};
} else {
updatedImageList.push({
language: imageLanguage,
content: fileName
});
}
handleFieldChange('image_list', updatedImageList);
}}
onFileDelete={() => {
const updatedImageList = [...imageList];
const index = updatedImageList.findIndex(img => img.language === imageLanguage);
if (index !== -1) {
updatedImageList[index] = {
...updatedImageList[index],
content: ''
};
handleFieldChange('image_list', updatedImageList);
}
}}
fileName={imageData.content}
disabled={!isEditable}
/>
</LanguageWrapper>
{hasError && <div className="field-error">{hasError}</div>}
</div>
);
case 'checkbox':
return (
<div className="form-field">
<CheckBox
label={field.label}
id={field.id}
checked={formData[field.id] || false}
setData={e => handleFieldChange(field.id, e.target.checked)}
disabled={!isEditable}
/>
{hasError && <div className="field-error">{hasError}</div>}
</div>
);
case 'textWithSuffix':
const linkLanguage = field.suffix;
const linkList = formData.link_list || [];
const linkData = linkList.find(link => link.language === linkLanguage) || { content: '' };
return (
<div className="form-field">
{field.label && <FormLabel>{field.label}</FormLabel>}
<FormInputSuffixWrapper>
<FormInput
type="text"
value={linkData.content}
onChange={e => {
const updatedLinkList = [...linkList];
const index = updatedLinkList.findIndex(link => link.language === linkLanguage);
if (index !== -1) {
updatedLinkList[index] = {
...updatedLinkList[index],
content: e.target.value
};
} else {
updatedLinkList.push({
language: linkLanguage,
content: e.target.value
});
}
handleFieldChange('link_list', updatedLinkList);
}}
disabled={!isEditable}
width={field.width}
suffix="true"
/>
<FormInputSuffix>{linkLanguage}</FormInputSuffix>
</FormInputSuffixWrapper>
{hasError && <div className="field-error">{hasError}</div>}
</div>
);
default:
return null;
}
};
// 조건부 렌더링을 위한 필드 필터링
const getVisibleFields = () => {
if (!config) return [];
return config.fields.filter(field => {
if (!field.visibleOn.includes(mode)) return false;
// 조건부 표시 필드 처리
if (field.conditional) {
const { field: condField, operator, value } = field.conditional;
if (operator === "==" && formData[condField] !== value) return false;
if (operator === "!=" && formData[condField] === value) return false;
}
return true;
});
};
// 그리드 기반 필드 렌더링
const renderGridFields = () => {
if (!config) return null;
const visibleFields = getVisibleFields();
const { rows, columns } = config.grid;
// 그리드 레이아웃 생성
return (
<div className="form-grid" style={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: '10px' }}>
{visibleFields.map((field) => {
const { row, col, width } = field.position;
return (
<div
key={field.id}
className="form-cell"
style={{
gridRow: row + 1,
gridColumn: `${col + 1} / span ${width}`,
padding: '5px'
}}
>
<FormRowGroup>
<FormLabel>{field.label}{field.validations?.includes("required") && <span className="required">*</span>}</FormLabel>
{renderField(field)}
</FormRowGroup>
</div>
);
})}
</div>
);
};
// 버튼 렌더링
const renderButtons = () => {
if (!config || !config.actions || !config.actions[mode]) return null;
return (
<div className="form-actions">
{config.actions[mode].map(action => (
<Button
key={action.id}
text={action.label}
theme={action.theme}
handleClick={() => {
if (action.action === 'submit') {
if (isFormValid) {
onSubmit(formData);
}
} else if (action.action === 'close' || action.action === 'cancel') {
onCancel();
}
}}
disabled={action.action === 'submit' && !isFormValid}
/>
))}
</div>
);
};
if (!config) return <div>로딩 ...</div>;
return (
<div className={`json-config-form ${className || ''}`} ref={formRef}>
<div className="form-content">
{renderGridFields()}
{errors._form && (
<SearchBarAlert $marginTop="15px" $align="right">
{errors._form}
</SearchBarAlert>
)}
</div>
<div className="form-footer">
{renderButtons()}
</div>
</div>
);
};
export default CaliForm;
const LanguageWrapper = styled.div`
width: ${props => props.width || '100%'};
//margin-bottom: 20px;
padding-bottom: 20px;
padding-left: 90px;
&:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
`;
const LanguageLabel = styled.h4`
color: #444;
margin: 0 0 10px 20px;
font-size: 16px;
font-weight: 500;
`;

View File

@@ -2,6 +2,7 @@ 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';
import { AnimatedTabs } from '../index';
const { RangePicker } = DatePicker;
/**
@@ -52,7 +53,10 @@ const DetailGrid = ({ items, formData, onChange, disabled = false, columns = 4 }
max,
format,
required,
showTime
showTime,
tabItems,
activeKey,
onTabChange
} = item;
// 현재 값 가져오기 (formData에서 또는 항목에서)
@@ -105,6 +109,7 @@ const DetailGrid = ({ items, formData, onChange, disabled = false, columns = 4 }
return (
<DatePicker
{...commonProps}
showTime={showTime || false}
value={currentValue ? dayjs(currentValue) : null}
format={format || 'YYYY-MM-DD'}
onChange={(date) => onChange(key, date, handler)}
@@ -191,8 +196,8 @@ const DetailGrid = ({ items, formData, onChange, disabled = false, columns = 4 }
case 'tab':
return <AnimatedTabs
items={tabItems}
activeKey={activeLanguage}
onChange={handleTabChange}
activeKey={activeKey}
onChange={onTabChange}
/>
case 'custom':
@@ -249,13 +254,15 @@ const StatusDisplay = ({ status }) => {
let color = '';
let text = '';
switch (status) {
const lowerStatus = typeof status === 'string' ? status.toLowerCase() : status;
switch (lowerStatus) {
case 'wait':
color = '#faad14';
color = '#FAAD14';
text = '대기';
break;
case 'running':
color = '#52c41a';
color = '#4287f5';
text = '진행중';
break;
case 'finish':
@@ -271,7 +278,7 @@ const StatusDisplay = ({ status }) => {
text = '삭제';
break;
default:
color = '#1890ff';
color = '#DEBB46';
text = status;
}

View File

@@ -5,38 +5,72 @@ import { motion, AnimatePresence } from 'framer-motion';
// 통합된 애니메이션 탭 컴포넌트
const AnimatedTabs = ({ items, activeKey, onChange }) => {
// 각 항목의 children을 애니메이션 래퍼로 감싸기
const tabItems = items.map(item => ({
key: item.key,
label: item.label,
children: (
<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>
)
}));
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>
items={tabItems}
/>
);
};
// 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%;

View File

@@ -21,6 +21,7 @@ import CDivider from './CDivider';
import TopButton from './button/TopButton';
import AntButton from './button/AntButton';
import DetailLayout from './Layout/DetailLayout';
import AnimatedTabs from './control/AnimatedTabs';
import CaliTable from './Custom/CaliTable'
@@ -56,5 +57,6 @@ export { DateTimeInput,
FrontPagination,
DownloadProgress,
CaliTable,
DetailLayout
DetailLayout,
AnimatedTabs
};