From de1fe9865a31f4d721d777749739b419655f94a7 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 5 Dec 2025 17:25:12 +0900 Subject: [PATCH] =?UTF-8?q?refactor(UniversalFormModal):=20=EB=8B=A4?= =?UTF-8?q?=EC=A4=91=20=EC=BB=AC=EB=9F=BC=20=EC=A0=80=EC=9E=A5=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EC=9D=84=20=ED=95=84=EB=93=9C=20=EB=A0=88=EB=B2=A8?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 섹션 레벨 linkedFieldGroups 제거, 필드 레벨 linkedFieldGroup으로 변경 - FormFieldConfig에 linkedFieldGroup 속성 추가 (enabled, sourceTable, displayColumn, displayFormat, mappings) - select 필드 렌더링에서 linkedFieldGroup 활성화 시 다중 컬럼 저장 처리 - API 응답 파싱 개선 (responseData.data 구조 지원) - 저장 실패 시 상세 에러 메시지 표시 - ConfigPanel에 다중 컬럼 저장 설정 UI 및 HelpText 추가 --- .../UniversalFormModalComponent.tsx | 299 ++++----- .../UniversalFormModalConfigPanel.tsx | 606 ++++++++---------- .../components/universal-form-modal/types.ts | 9 + 3 files changed, 384 insertions(+), 530 deletions(-) diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 65e079ae..4f2f5c6b 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -33,7 +33,6 @@ import { FormDataState, RepeatSectionItem, SelectOptionConfig, - LinkedFieldGroup, } from "./types"; import { defaultConfig, generateUniqueId } from "./config"; @@ -121,6 +120,33 @@ export function UniversalFormModalComponent({ initializeForm(); }, [config, initialData]); + // 필드 레벨 linkedFieldGroup 데이터 로드 + useEffect(() => { + const loadData = async () => { + const tablesToLoad = new Set(); + + // 모든 섹션의 필드에서 linkedFieldGroup.sourceTable 수집 + config.sections.forEach((section) => { + section.fields.forEach((field) => { + if (field.linkedFieldGroup?.enabled && field.linkedFieldGroup?.sourceTable) { + tablesToLoad.add(field.linkedFieldGroup.sourceTable); + } + }); + }); + + // 각 테이블 데이터 로드 + for (const tableName of tablesToLoad) { + if (!linkedFieldDataCache[tableName]) { + console.log(`[UniversalFormModal] linkedFieldGroup 데이터 로드: ${tableName}`); + await loadLinkedFieldData(tableName); + } + } + }; + + loadData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config.sections]); + // 폼 초기화 const initializeForm = useCallback(async () => { const newFormData: FormDataState = {}; @@ -364,18 +390,22 @@ export function UniversalFormModalComponent({ const response = await apiClient.post(`/table-management/tables/${sourceTable}/data`, { page: 1, size: 1000, - autoFilter: true, // 현재 회사 기준 자동 필터링 + autoFilter: { enabled: true, filterColumn: "company_code" }, // 현재 회사 기준 자동 필터링 }); console.log(`[연동필드] ${sourceTable} API 응답:`, response.data); if (response.data?.success) { - // data가 배열인지 확인 + // data 구조 확인: { data: { data: [...], total, page, ... } } 또는 { data: [...] } const responseData = response.data?.data; if (Array.isArray(responseData)) { + // 직접 배열인 경우 data = responseData; + } else if (responseData?.data && Array.isArray(responseData.data)) { + // { data: [...], total: ... } 형태 (tableManagementService 응답) + data = responseData.data; } else if (responseData?.rows && Array.isArray(responseData.rows)) { - // { rows: [...], total: ... } 형태일 수 있음 + // { rows: [...], total: ... } 형태 (다른 API 응답) data = responseData.rows; } console.log(`[연동필드] ${sourceTable} 파싱된 데이터 ${data.length}개:`, data.slice(0, 3)); @@ -394,79 +424,6 @@ export function UniversalFormModalComponent({ [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[] = []; @@ -532,7 +489,13 @@ export function UniversalFormModalComponent({ } } catch (error: any) { console.error("저장 실패:", error); - toast.error(error.message || "저장에 실패했습니다."); + // axios 에러의 경우 서버 응답 메시지 추출 + const errorMessage = + error.response?.data?.message || + error.response?.data?.error?.details || + error.message || + "저장에 실패했습니다."; + toast.error(errorMessage); } finally { setSaving(false); } @@ -749,7 +712,88 @@ export function UniversalFormModalComponent({ ); - case "select": + case "select": { + // 다중 컬럼 저장이 활성화된 경우 + const lfgMappings = field.linkedFieldGroup?.mappings; + if (field.linkedFieldGroup?.enabled && field.linkedFieldGroup?.sourceTable && lfgMappings && lfgMappings.length > 0) { + const lfg = field.linkedFieldGroup; + const sourceTableName = lfg.sourceTable as string; + const cachedData = linkedFieldDataCache[sourceTableName]; + const sourceData = Array.isArray(cachedData) ? cachedData : []; + + // 첫 번째 매핑의 sourceColumn을 드롭다운 값으로 사용 + const valueColumn = lfgMappings[0].sourceColumn || ""; + + // 데이터 로드 (아직 없으면) + if (!cachedData && sourceTableName) { + loadLinkedFieldData(sourceTableName); + } + + // 표시 텍스트 생성 함수 + const getDisplayText = (row: Record): string => { + const displayVal = row[lfg.displayColumn || ""] || ""; + const valueVal = row[valueColumn] || ""; + switch (lfg.displayFormat) { + case "code_name": + return `${valueVal} - ${displayVal}`; + case "name_code": + return `${displayVal} (${valueVal})`; + case "name_only": + default: + return String(displayVal); + } + }; + + return ( + + ); + } + + // 일반 select 필드 return ( ); + } case "date": return ( @@ -854,64 +899,6 @@ 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 @@ -999,18 +986,6 @@ 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, - ); - })} @@ -1033,19 +1008,6 @@ 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, - ); - })} @@ -1105,19 +1067,6 @@ 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 dc35a77e..acc53acc 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -37,7 +37,6 @@ import { UniversalFormModalConfigPanelProps, FormSectionConfig, FormFieldConfig, - LinkedFieldGroup, LinkedFieldMapping, FIELD_TYPE_OPTIONS, MODAL_SIZE_OPTIONS, @@ -49,11 +48,8 @@ import { defaultSectionConfig, defaultNumberingRuleConfig, defaultSelectOptionsConfig, - defaultLinkedFieldGroupConfig, - defaultLinkedFieldMappingConfig, generateSectionId, generateFieldId, - generateLinkedFieldGroupId, } from "./config"; // 도움말 텍스트 컴포넌트 @@ -93,13 +89,14 @@ 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); + // 필드 레벨의 linkedFieldGroup 확인 + section.fields.forEach((field) => { + if (field.linkedFieldGroup?.sourceTable) { + allSourceTables.add(field.linkedFieldGroup.sourceTable); } }); }); @@ -578,47 +575,6 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor 겸직 등 반복 데이터가 있는 섹션 - - - -
- - - updateSaveConfig({ - multiRowSave: { ...config.saveConfig.multiRowSave, typeColumn: e.target.value }, - }) - } - placeholder="employment_type" - className="h-6 text-[10px] mt-1" - /> - 메인/서브를 구분하는 컬럼명 -
-
- - - updateSaveConfig({ - multiRowSave: { ...config.saveConfig.multiRowSave, mainTypeValue: e.target.value }, - }) - } - className="h-6 text-[10px] mt-1" - /> -
-
- - - updateSaveConfig({ - multiRowSave: { ...config.saveConfig.multiRowSave, subTypeValue: e.target.value }, - }) - } - className="h-6 text-[10px] mt-1" - /> -
)} @@ -683,7 +639,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor { @@ -866,305 +822,6 @@ 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" - /> -
-
-
- ))} -
- )} -
- {/* 필드 목록 */} @@ -1467,7 +1124,8 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor {/* Select 옵션 설정 */} {selectedField.fieldType === "select" && (
- + + 드롭다운에 표시될 옵션 목록을 어디서 가져올지 설정합니다. + {selectedField.selectOptions?.type === "static" && ( + 직접 입력: 옵션을 수동으로 입력합니다. (현재 미구현 - 테이블 참조 사용 권장) + )} + {selectedField.selectOptions?.type === "table" && (
+ 테이블 참조: DB 테이블에서 옵션 목록을 가져옵니다.
- + + 예: dept_info (부서 테이블)
- + @@ -1530,12 +1194,13 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor }, }) } - placeholder="code" + placeholder="dept_code" className="h-6 text-[10px] mt-1" /> + 선택 시 실제 저장되는 값 (예: D001)
- + @@ -1546,15 +1211,17 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor }, }) } - placeholder="name" + placeholder="dept_name" className="h-6 text-[10px] mt-1" /> + 드롭다운에 보여질 텍스트 (예: 영업부)
)} {selectedField.selectOptions?.type === "code" && (
+ 공통코드: 공통코드 테이블에서 옵션을 가져옵니다. + 예: POSITION_CODE (직급), STATUS_CODE (상태) 등 +
+ )} +
+ )} + + {/* 다중 컬럼 저장 (select 타입만) */} + {selectedField.fieldType === "select" && ( +
+
+ 다중 컬럼 저장 + + updateField(selectedSection.id, selectedField.id, { + linkedFieldGroup: { + ...selectedField.linkedFieldGroup, + enabled: checked, + }, + }) + } + /> +
+ + 드롭다운 선택 시 여러 컬럼에 동시 저장합니다. +
예: 부서 선택 시 부서코드 + 부서명을 각각 다른 컬럼에 저장 +
+ + {selectedField.linkedFieldGroup?.enabled && ( +
+ {/* 소스 테이블 */} +
+ + + 드롭다운 옵션을 가져올 테이블 +
+ + {/* 표시 형식 */} +
+ + +
+ + {/* 표시 컬럼 / 값 컬럼 */} +
+
+ + + 사용자가 드롭다운에서 보게 될 텍스트 (예: 영업부, 개발부) +
+
+ + {/* 저장할 컬럼 매핑 */} +
+
+ + +
+ 드롭다운 선택 시 소스 테이블의 어떤 값을 어떤 컬럼에 저장할지 설정 + + {(selectedField.linkedFieldGroup?.mappings || []).map((mapping, mappingIndex) => ( +
+
+ 매핑 #{mappingIndex + 1} + +
+
+ + +
+
+ + +
+
+ ))} + + {(selectedField.linkedFieldGroup?.mappings || []).length === 0 && ( +

+ + 버튼을 눌러 매핑을 추가하세요 +

+ )} +
)}
diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index 11ccfd25..de2526c2 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -74,6 +74,15 @@ export interface FormFieldConfig { // Select 옵션 selectOptions?: SelectOptionConfig; + // 다중 컬럼 저장 (드롭다운 선택 시 여러 컬럼에 동시 저장) + linkedFieldGroup?: { + enabled?: boolean; // 사용 여부 + sourceTable?: string; // 소스 테이블 (예: dept_info) + displayColumn?: string; // 표시할 컬럼 (예: dept_name) - 드롭다운에 보여줄 텍스트 + displayFormat?: "name_only" | "code_name" | "name_code"; // 표시 형식 + mappings?: LinkedFieldMapping[]; // 저장할 컬럼 매핑 (첫 번째 매핑의 sourceColumn이 드롭다운 값으로 사용됨) + }; + // 유효성 검사 validation?: FieldValidationConfig;