- DynamicFieldRenderer에 신규 필드 타입 추가 (Currency, File, MultiSelect, Radio, Reference, Toggle, UnitValue, Computed) - DynamicTableSection 및 TableCellRenderer 추가 - 필드 프리셋 및 설정 구조 분리 - 컴포넌트 레지스트리 개발 도구 페이지 추가 - UniversalListPage 개선 - 근태관리 코드 정리 - 즐겨찾기 기능 및 동적 필드 타입 백엔드 스펙 문서 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
266 lines
7.7 KiB
TypeScript
266 lines
7.7 KiB
TypeScript
/**
|
|
* Field Service
|
|
* 필드 관련 도메인 로직 중앙화
|
|
* - validation
|
|
* - parsing (field_key 등)
|
|
* - transform (폼 ↔ API)
|
|
* - defaults
|
|
*/
|
|
|
|
import type { ItemField, FieldDisplayCondition } from '@/contexts/ItemMasterContext';
|
|
import type { ItemFieldType } from '@/types/item-master-api';
|
|
|
|
// ===== Types =====
|
|
|
|
export type FieldType = ItemFieldType;
|
|
|
|
export interface FieldFormData {
|
|
name: string;
|
|
key: string;
|
|
inputType: FieldType;
|
|
required: boolean;
|
|
options: string; // 콤마 구분 문자열
|
|
description: string;
|
|
// 텍스트박스 컬럼
|
|
columns?: Array<{ id: string; name: string; key: string }>;
|
|
// 조건부 표시
|
|
conditionEnabled?: boolean;
|
|
conditionTargetType?: 'field' | 'section';
|
|
conditionFields?: Array<{ fieldKey: string; expectedValue: string }>;
|
|
conditionSections?: string[];
|
|
}
|
|
|
|
export interface FieldValidationResult {
|
|
valid: boolean;
|
|
errors: {
|
|
field_name?: string;
|
|
field_key?: string;
|
|
field_type?: string;
|
|
};
|
|
}
|
|
|
|
export interface FieldValidationError {
|
|
valid: false;
|
|
error: string;
|
|
}
|
|
|
|
export interface FieldValidationSuccess {
|
|
valid: true;
|
|
}
|
|
|
|
export type SingleFieldValidation = FieldValidationError | FieldValidationSuccess;
|
|
|
|
// ===== Service =====
|
|
|
|
export const fieldService = {
|
|
// ===== Validation =====
|
|
|
|
/**
|
|
* 전체 필드 폼 유효성 검사
|
|
*/
|
|
validate: (data: Partial<FieldFormData>): FieldValidationResult => {
|
|
const errors: FieldValidationResult['errors'] = {};
|
|
|
|
// 필드명 검증
|
|
const nameValidation = fieldService.validateFieldName(data.name || '');
|
|
if (!nameValidation.valid) {
|
|
errors.field_name = (nameValidation as FieldValidationError).error;
|
|
}
|
|
|
|
// 필드 키 검증
|
|
const keyValidation = fieldService.validateFieldKey(data.key || '');
|
|
if (!keyValidation.valid) {
|
|
errors.field_key = (keyValidation as FieldValidationError).error;
|
|
}
|
|
|
|
return {
|
|
valid: Object.keys(errors).length === 0,
|
|
errors,
|
|
};
|
|
},
|
|
|
|
/**
|
|
* 필드명 유효성 검사
|
|
*/
|
|
validateFieldName: (name: string): SingleFieldValidation => {
|
|
if (!name || !name.trim()) {
|
|
return { valid: false, error: '항목명을 입력해주세요' };
|
|
}
|
|
return { valid: true };
|
|
},
|
|
|
|
/**
|
|
* 필드 키 유효성 검사
|
|
* - 필수 입력
|
|
* - 영문, 숫자, 언더스코어만 허용
|
|
* 2025-12-16: 숫자로 시작하는 키도 허용 (예: 105_state)
|
|
*/
|
|
validateFieldKey: (key: string): SingleFieldValidation => {
|
|
if (!key || !key.trim()) {
|
|
return { valid: false, error: '필드 키를 입력해주세요' };
|
|
}
|
|
if (!/^[a-zA-Z0-9_]+$/.test(key)) {
|
|
return { valid: false, error: '영문, 숫자, 언더스코어만 사용 가능합니다' };
|
|
}
|
|
return { valid: true };
|
|
},
|
|
|
|
/**
|
|
* 필드 키 패턴 정규식
|
|
* UI에서 직접 사용 가능
|
|
* 2025-12-16: 숫자로 시작하는 키도 허용
|
|
*/
|
|
fieldKeyPattern: /^[a-zA-Z0-9_]+$/,
|
|
|
|
/**
|
|
* 필드 키가 유효한지 간단 체크 (boolean 반환)
|
|
*/
|
|
isFieldKeyValid: (key: string): boolean => {
|
|
if (!key || !key.trim()) return false;
|
|
return fieldService.fieldKeyPattern.test(key);
|
|
},
|
|
|
|
// ===== Parsing =====
|
|
|
|
/**
|
|
* field_key 반환 (전체 키 그대로 반환)
|
|
* 2025-12-16: 전체 field_key 표시로 변경 (예: "105_state" 그대로 표시)
|
|
*/
|
|
extractUserInputFromFieldKey: (fieldKey: string | null | undefined): string => {
|
|
if (!fieldKey) return '';
|
|
return fieldKey;
|
|
},
|
|
|
|
/**
|
|
* 옵션 문자열을 배열로 파싱
|
|
* "옵션1, 옵션2, 옵션3" → [{ label: "옵션1", value: "옵션1" }, ...]
|
|
*/
|
|
parseOptionsFromString: (optionsString: string): Array<{ label: string; value: string }> | null => {
|
|
if (!optionsString || !optionsString.trim()) return null;
|
|
|
|
return optionsString
|
|
.split(',')
|
|
.map(opt => opt.trim())
|
|
.filter(opt => opt.length > 0)
|
|
.map(opt => ({ label: opt, value: opt }));
|
|
},
|
|
|
|
/**
|
|
* 옵션 배열을 문자열로 변환
|
|
* [{ label: "옵션1", value: "옵션1" }, ...] → "옵션1, 옵션2"
|
|
*/
|
|
optionsToString: (options: Array<{ label: string; value: string }> | null | undefined): string => {
|
|
if (!options || options.length === 0) return '';
|
|
return options.map(opt => opt.value || opt.label).join(', ');
|
|
},
|
|
|
|
// ===== Transform =====
|
|
|
|
/**
|
|
* 폼 데이터 → API 요청 객체 변환
|
|
*/
|
|
toApiRequest: (
|
|
formData: FieldFormData,
|
|
sectionId: number,
|
|
options?: {
|
|
editingFieldId?: number | null;
|
|
masterFieldId?: number | null;
|
|
}
|
|
): Omit<ItemField, 'created_at' | 'updated_at'> => {
|
|
const { editingFieldId, masterFieldId } = options || {};
|
|
|
|
// 조건부 표시 설정
|
|
const displayCondition: FieldDisplayCondition | undefined = formData.conditionEnabled
|
|
? {
|
|
targetType: formData.conditionTargetType || 'field',
|
|
fieldConditions: formData.conditionTargetType === 'field' && formData.conditionFields?.length
|
|
? formData.conditionFields
|
|
: undefined,
|
|
sectionIds: formData.conditionTargetType === 'section' && formData.conditionSections?.length
|
|
? formData.conditionSections
|
|
: undefined,
|
|
}
|
|
: undefined;
|
|
|
|
// 텍스트박스 컬럼 설정
|
|
const hasColumns = formData.inputType === 'textbox' && formData.columns && formData.columns.length > 0;
|
|
|
|
return {
|
|
id: editingFieldId || Date.now(),
|
|
section_id: sectionId,
|
|
master_field_id: masterFieldId || null,
|
|
field_name: formData.name,
|
|
field_key: formData.key,
|
|
field_type: formData.inputType,
|
|
order_no: 0,
|
|
is_required: formData.required,
|
|
placeholder: formData.description || null,
|
|
default_value: null,
|
|
display_condition: displayCondition as Record<string, any> | null || null,
|
|
validation_rules: null,
|
|
options: formData.inputType === 'dropdown'
|
|
? fieldService.parseOptionsFromString(formData.options)
|
|
: null,
|
|
properties: hasColumns
|
|
? {
|
|
multiColumn: true,
|
|
columnCount: formData.columns!.length,
|
|
columnNames: formData.columns!.map(c => c.name),
|
|
}
|
|
: null,
|
|
};
|
|
},
|
|
|
|
/**
|
|
* ItemField → 폼 데이터 변환 (수정 시 폼에 채우기 위함)
|
|
*/
|
|
toFormData: (field: ItemField): FieldFormData => {
|
|
return {
|
|
name: field.field_name,
|
|
key: fieldService.extractUserInputFromFieldKey(field.field_key),
|
|
inputType: field.field_type,
|
|
required: field.is_required || (field.properties as any)?.required || false,
|
|
options: fieldService.optionsToString(field.options),
|
|
description: field.placeholder || '',
|
|
// 조건부 표시
|
|
conditionEnabled: !!field.display_condition,
|
|
conditionTargetType: (field.display_condition as any)?.targetType || 'field',
|
|
conditionFields: (field.display_condition as any)?.fieldConditions || [],
|
|
conditionSections: (field.display_condition as any)?.sectionIds || [],
|
|
// 텍스트박스 컬럼 (properties에서 복원 - 필요시 구현)
|
|
columns: [],
|
|
};
|
|
},
|
|
|
|
// ===== Defaults =====
|
|
|
|
/**
|
|
* 새 필드 생성 시 기본값
|
|
*/
|
|
getDefaultFormData: (): FieldFormData => ({
|
|
name: '',
|
|
key: '',
|
|
inputType: 'textbox',
|
|
required: false,
|
|
options: '',
|
|
description: '',
|
|
columns: [],
|
|
conditionEnabled: false,
|
|
conditionTargetType: 'field',
|
|
conditionFields: [],
|
|
conditionSections: [],
|
|
}),
|
|
|
|
/**
|
|
* 지원하는 필드 타입 목록
|
|
*/
|
|
fieldTypes: [
|
|
{ value: 'textbox', label: '텍스트박스' },
|
|
{ value: 'number', label: '숫자' },
|
|
{ value: 'dropdown', label: '드롭다운' },
|
|
{ value: 'checkbox', label: '체크박스' },
|
|
{ value: 'date', label: '날짜' },
|
|
{ value: 'textarea', label: '텍스트영역' },
|
|
] as const,
|
|
};
|