526 lines
14 KiB
JavaScript
526 lines
14 KiB
JavaScript
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;
|
|
`; |