diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 85133424..65e079ae 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -33,6 +33,7 @@ import { FormDataState, RepeatSectionItem, SelectOptionConfig, + LinkedFieldGroup, } from "./types"; import { defaultConfig, generateUniqueId } from "./config"; @@ -100,6 +101,11 @@ export function UniversalFormModalComponent({ [key: string]: { value: string; label: string }[]; }>({}); + // 연동 필드 그룹 데이터 캐시 (테이블별 데이터) + const [linkedFieldDataCache, setLinkedFieldDataCache] = useState<{ + [tableKey: string]: Record[]; + }>({}); + // 로딩 상태 const [saving, setSaving] = useState(false); @@ -342,6 +348,125 @@ export function UniversalFormModalComponent({ [selectOptionsCache], ); + // 연동 필드 그룹 데이터 로드 + const loadLinkedFieldData = useCallback( + async (sourceTable: string): Promise[]> => { + // 캐시 확인 - 이미 배열로 캐시되어 있으면 반환 + if (Array.isArray(linkedFieldDataCache[sourceTable]) && linkedFieldDataCache[sourceTable].length > 0) { + return linkedFieldDataCache[sourceTable]; + } + + let data: Record[] = []; + + try { + console.log(`[연동필드] ${sourceTable} 데이터 로드 시작`); + // 현재 회사 기준으로 데이터 조회 (POST 메서드, autoFilter 사용) + const response = await apiClient.post(`/table-management/tables/${sourceTable}/data`, { + page: 1, + size: 1000, + autoFilter: true, // 현재 회사 기준 자동 필터링 + }); + + console.log(`[연동필드] ${sourceTable} API 응답:`, response.data); + + if (response.data?.success) { + // data가 배열인지 확인 + const responseData = response.data?.data; + if (Array.isArray(responseData)) { + data = responseData; + } else if (responseData?.rows && Array.isArray(responseData.rows)) { + // { rows: [...], total: ... } 형태일 수 있음 + data = responseData.rows; + } + console.log(`[연동필드] ${sourceTable} 파싱된 데이터 ${data.length}개:`, data.slice(0, 3)); + } + + // 캐시 저장 (빈 배열이라도 저장하여 중복 요청 방지) + setLinkedFieldDataCache((prev) => ({ ...prev, [sourceTable]: data })); + } catch (error) { + console.error(`연동 필드 데이터 로드 실패 (${sourceTable}):`, error); + // 실패해도 빈 배열로 캐시하여 무한 요청 방지 + setLinkedFieldDataCache((prev) => ({ ...prev, [sourceTable]: [] })); + } + + return data; + }, + [linkedFieldDataCache], + ); + + // 연동 필드 그룹 선택 시 매핑된 필드에 값 설정 + const handleLinkedFieldSelect = useCallback( + ( + group: LinkedFieldGroup, + selectedValue: string, + sectionId: string, + repeatItemId?: string + ) => { + // 캐시에서 데이터 찾기 + const sourceData = linkedFieldDataCache[group.sourceTable] || []; + const selectedRow = sourceData.find( + (row) => String(row[group.valueColumn]) === selectedValue + ); + + if (!selectedRow) { + console.warn("선택된 항목을 찾을 수 없습니다:", selectedValue); + return; + } + + // 매핑된 필드에 값 설정 + if (repeatItemId) { + // 반복 섹션 내 아이템 업데이트 + setRepeatSections((prev) => { + const sectionItems = prev[sectionId] || []; + const updatedItems = sectionItems.map((item) => { + if (item._id === repeatItemId) { + const updatedItem = { ...item }; + for (const mapping of group.mappings) { + updatedItem[mapping.targetColumn] = selectedRow[mapping.sourceColumn]; + } + return updatedItem; + } + return item; + }); + return { ...prev, [sectionId]: updatedItems }; + }); + } else { + // 일반 섹션 필드 업데이트 + setFormData((prev) => { + const newData = { ...prev }; + for (const mapping of group.mappings) { + newData[mapping.targetColumn] = selectedRow[mapping.sourceColumn]; + } + if (onChange) { + setTimeout(() => onChange(newData), 0); + } + return newData; + }); + } + }, + [linkedFieldDataCache, onChange], + ); + + // 연동 필드 그룹 표시 텍스트 생성 + const getLinkedFieldDisplayText = useCallback( + (group: LinkedFieldGroup, row: Record): string => { + const code = row[group.valueColumn] || ""; + const name = row[group.displayColumn] || ""; + + switch (group.displayFormat) { + case "name_only": + return name; + case "code_name": + return `${code} - ${name}`; + case "name_code": + return `${name} (${code})`; + default: + return name; + } + }, + [], + ); + // 필수 필드 검증 const validateRequiredFields = useCallback((): { valid: boolean; missingFields: string[] } => { const missingFields: string[] = []; @@ -729,6 +854,64 @@ export function UniversalFormModalComponent({ })(); }; + // 연동 필드 그룹 드롭다운 렌더링 + const renderLinkedFieldGroup = ( + group: LinkedFieldGroup, + sectionId: string, + repeatItemId?: string, + currentValue?: string, + sectionColumns: number = 2, + ) => { + const fieldKey = `linked_${group.id}_${repeatItemId || "main"}`; + const cachedData = linkedFieldDataCache[group.sourceTable]; + // 배열인지 확인하고, 아니면 빈 배열 사용 + const sourceData = Array.isArray(cachedData) ? cachedData : []; + const defaultSpan = Math.floor(12 / sectionColumns); + const actualGridSpan = sectionColumns === 1 ? 12 : group.gridSpan || defaultSpan; + + // 데이터 로드 (아직 없으면, 그리고 캐시에 없을 때만) + if (!cachedData && group.sourceTable) { + loadLinkedFieldData(group.sourceTable); + } + + return ( +
+ + +
+ ); + }; + // 섹션의 열 수에 따른 기본 gridSpan 계산 const getDefaultGridSpan = (sectionColumns: number = 2): number => { // 12칸 그리드 기준: 1열=12, 2열=6, 3열=4, 4열=3 @@ -806,6 +989,7 @@ export function UniversalFormModalComponent({
+ {/* 일반 필드 렌더링 */} {section.fields.map((field) => renderFieldWithColumns( field, @@ -815,6 +999,18 @@ export function UniversalFormModalComponent({ sectionColumns, ), )} + {/* 연동 필드 그룹 렌더링 */} + {(section.linkedFieldGroups || []).map((group) => { + const firstMapping = group.mappings?.[0]; + const currentValue = firstMapping ? formData[firstMapping.targetColumn] : undefined; + return renderLinkedFieldGroup( + group, + section.id, + undefined, + currentValue ? String(currentValue) : undefined, + sectionColumns, + ); + })}
@@ -827,6 +1023,7 @@ export function UniversalFormModalComponent({
+ {/* 일반 필드 렌더링 */} {section.fields.map((field) => renderFieldWithColumns( field, @@ -836,6 +1033,19 @@ export function UniversalFormModalComponent({ sectionColumns, ), )} + {/* 연동 필드 그룹 렌더링 */} + {(section.linkedFieldGroups || []).map((group) => { + // 매핑된 첫 번째 타겟 컬럼의 현재 값을 찾아서 선택 상태 표시 + const firstMapping = group.mappings?.[0]; + const currentValue = firstMapping ? formData[firstMapping.targetColumn] : undefined; + return renderLinkedFieldGroup( + group, + section.id, + undefined, + currentValue ? String(currentValue) : undefined, + sectionColumns, + ); + })}
@@ -885,6 +1095,7 @@ export function UniversalFormModalComponent({
+ {/* 일반 필드 렌더링 */} {section.fields.map((field) => renderFieldWithColumns( field, @@ -894,6 +1105,19 @@ export function UniversalFormModalComponent({ sectionColumns, ), )} + {/* 연동 필드 그룹 렌더링 (반복 섹션 내) */} + {(section.linkedFieldGroups || []).map((group) => { + // 반복 섹션 아이템 내의 매핑된 첫 번째 타겟 컬럼 값 + const firstMapping = group.mappings?.[0]; + const currentValue = firstMapping ? item[firstMapping.targetColumn] : undefined; + return renderLinkedFieldGroup( + group, + section.id, + item._id, + currentValue ? String(currentValue) : undefined, + sectionColumns, + ); + })}
))} diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index 90c9d64b..dc35a77e 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -37,17 +37,23 @@ import { UniversalFormModalConfigPanelProps, FormSectionConfig, FormFieldConfig, + LinkedFieldGroup, + LinkedFieldMapping, FIELD_TYPE_OPTIONS, MODAL_SIZE_OPTIONS, SELECT_OPTION_TYPE_OPTIONS, + LINKED_FIELD_DISPLAY_FORMAT_OPTIONS, } from "./types"; import { defaultFieldConfig, defaultSectionConfig, defaultNumberingRuleConfig, defaultSelectOptionsConfig, + defaultLinkedFieldGroupConfig, + defaultLinkedFieldMappingConfig, generateSectionId, generateFieldId, + generateLinkedFieldGroupId, } from "./config"; // 도움말 텍스트 컴포넌트 @@ -87,6 +93,24 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor // eslint-disable-next-line react-hooks/exhaustive-deps }, [config.saveConfig.tableName]); + // 연동 필드 그룹의 소스 테이블 컬럼 로드 + useEffect(() => { + const allSourceTables = new Set(); + config.sections.forEach((section) => { + (section.linkedFieldGroups || []).forEach((group) => { + if (group.sourceTable) { + allSourceTables.add(group.sourceTable); + } + }); + }); + allSourceTables.forEach((tableName) => { + if (!tableColumns[tableName]) { + loadTableColumns(tableName); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config.sections]); + const loadTables = async () => { try { const response = await apiClient.get("/table-management/tables"); @@ -842,6 +866,305 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor )} + {/* 연동 필드 그룹 설정 */} +
+
+ 연동 필드 그룹 + +
+

+ 부서코드/부서명 연동 저장 +

+ + {(selectedSection.linkedFieldGroups || []).length > 0 && ( +
+ {(selectedSection.linkedFieldGroups || []).map((group, groupIndex) => ( +
+
+ + #{groupIndex + 1} + + +
+ + {/* 라벨 */} +
+ + { + const updatedGroups = (selectedSection.linkedFieldGroups || []).map((g) => + g.id === group.id ? { ...g, label: e.target.value } : g + ); + updateSection(selectedSection.id, { linkedFieldGroups: updatedGroups }); + }} + placeholder="예: 겸직부서" + className="h-5 text-[9px] mt-0.5" + /> +
+ + {/* 소스 테이블 */} +
+ + +
+ + {/* 표시 형식 */} +
+ + +
+ + {/* 표시 컬럼 / 값 컬럼 */} +
+
+ + +
+
+ + +
+
+ + {/* 필드 매핑 */} +
+
+ + +
+ + {(group.mappings || []).map((mapping, mappingIndex) => ( +
+ + -> + + +
+ ))} +
+ + {/* 기타 옵션 */} +
+
+ { + const updatedGroups = (selectedSection.linkedFieldGroups || []).map((g) => + g.id === group.id ? { ...g, required: !!checked } : g + ); + updateSection(selectedSection.id, { linkedFieldGroups: updatedGroups }); + }} + className="h-3 w-3" + /> + +
+
+ + { + const updatedGroups = (selectedSection.linkedFieldGroups || []).map((g) => + g.id === group.id ? { ...g, gridSpan: parseInt(e.target.value) || 6 } : g + ); + updateSection(selectedSection.id, { linkedFieldGroups: updatedGroups }); + }} + className="h-4 w-8 text-[8px] px-1" + /> +
+
+
+ ))} +
+ )} +
+ {/* 필드 목록 */} diff --git a/frontend/lib/registry/components/universal-form-modal/config.ts b/frontend/lib/registry/components/universal-form-modal/config.ts index 9da7b46c..5383512b 100644 --- a/frontend/lib/registry/components/universal-form-modal/config.ts +++ b/frontend/lib/registry/components/universal-form-modal/config.ts @@ -90,6 +90,27 @@ export const defaultSectionConfig = { itemTitle: "항목 {index}", confirmRemove: false, }, + linkedFieldGroups: [], +}; + +// 기본 연동 필드 그룹 설정 +export const defaultLinkedFieldGroupConfig = { + id: "", + label: "연동 필드", + sourceTable: "dept_info", + displayFormat: "code_name" as const, + displayColumn: "dept_name", + valueColumn: "dept_code", + mappings: [], + required: false, + placeholder: "선택하세요", + gridSpan: 6, +}; + +// 기본 연동 필드 매핑 설정 +export const defaultLinkedFieldMappingConfig = { + sourceColumn: "", + targetColumn: "", }; // 기본 채번규칙 설정 @@ -136,3 +157,8 @@ export const generateSectionId = (): string => { export const generateFieldId = (): string => { return generateUniqueId("field"); }; + +// 유틸리티: 연동 필드 그룹 ID 생성 +export const generateLinkedFieldGroupId = (): string => { + return generateUniqueId("linked"); +}; diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index e8d2ffd6..11ccfd25 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -96,6 +96,27 @@ export interface FormFieldConfig { }; } +// 연동 필드 매핑 설정 +export interface LinkedFieldMapping { + sourceColumn: string; // 소스 테이블 컬럼 (예: "dept_code") + targetColumn: string; // 저장할 컬럼 (예: "position_code") +} + +// 연동 필드 그룹 설정 (섹션 레벨) +// 하나의 드롭다운에서 선택 시 여러 컬럼에 자동 저장 +export interface LinkedFieldGroup { + id: string; + label: string; // 드롭다운 라벨 (예: "겸직부서") + sourceTable: string; // 소스 테이블 (예: "dept_info") + displayFormat: "name_only" | "code_name" | "name_code"; // 표시 형식 + displayColumn: string; // 표시할 컬럼 (예: "dept_name") + valueColumn: string; // 값으로 사용할 컬럼 (예: "dept_code") + mappings: LinkedFieldMapping[]; // 필드 매핑 목록 + required?: boolean; // 필수 여부 + placeholder?: string; // 플레이스홀더 + gridSpan?: number; // 그리드 스팬 (1-12) +} + // 반복 섹션 설정 export interface RepeatSectionConfig { minItems?: number; // 최소 항목 수 (기본: 0) @@ -119,6 +140,9 @@ export interface FormSectionConfig { repeatable?: boolean; repeatConfig?: RepeatSectionConfig; + // 연동 필드 그룹 (부서코드/부서명 등 연동 저장) + linkedFieldGroups?: LinkedFieldGroup[]; + // 섹션 레이아웃 columns?: number; // 필드 배치 컬럼 수 (기본: 2) gap?: string; // 필드 간 간격 @@ -257,3 +281,10 @@ export const SELECT_OPTION_TYPE_OPTIONS = [ { value: "table", label: "테이블 참조" }, { value: "code", label: "공통코드" }, ] as const; + +// 연동 필드 표시 형식 옵션 +export const LINKED_FIELD_DISPLAY_FORMAT_OPTIONS = [ + { value: "name_only", label: "이름만 (예: 영업부)" }, + { value: "code_name", label: "코드 - 이름 (예: SALES - 영업부)" }, + { value: "name_code", label: "이름 (코드) (예: 영업부 (SALES))" }, +] as const;