From 06d5069566ee9cde0c85adbe2047ae1aba5bda32 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 30 Dec 2025 10:54:06 +0900 Subject: [PATCH 1/7] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EA=B4=80=EB=A0=A8=20=EB=82=B4=EC=9A=A9=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/table-type-sql-guide.mdc | 230 ++++++++++++++++++++++++- 1 file changed, 227 insertions(+), 3 deletions(-) diff --git a/.cursor/rules/table-type-sql-guide.mdc b/.cursor/rules/table-type-sql-guide.mdc index 501c3218..3c53c537 100644 --- a/.cursor/rules/table-type-sql-guide.mdc +++ b/.cursor/rules/table-type-sql-guide.mdc @@ -335,9 +335,222 @@ ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.colu --- -## 7. 체크리스트 +## 7. 로그 테이블 생성 (선택사항) -테이블 생성/수정 시 반드시 확인할 사항: +변경 이력 추적이 필요한 테이블에는 로그 테이블을 생성할 수 있습니다. + +### 7.1 로그 테이블 DDL 템플릿 + +```sql +-- 로그 테이블 생성 +CREATE TABLE 테이블명_log ( + log_id SERIAL PRIMARY KEY, + operation_type VARCHAR(10) NOT NULL, -- INSERT/UPDATE/DELETE + original_id VARCHAR(100), -- 원본 테이블 PK 값 + changed_column VARCHAR(100), -- 변경된 컬럼명 + old_value TEXT, -- 변경 전 값 + new_value TEXT, -- 변경 후 값 + changed_by VARCHAR(50), -- 변경자 ID + changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 변경 시각 + ip_address VARCHAR(50), -- 변경 요청 IP + user_agent TEXT, -- User Agent + full_row_before JSONB, -- 변경 전 전체 행 + full_row_after JSONB -- 변경 후 전체 행 +); + +-- 인덱스 생성 +CREATE INDEX idx_테이블명_log_original_id ON 테이블명_log(original_id); +CREATE INDEX idx_테이블명_log_changed_at ON 테이블명_log(changed_at); +CREATE INDEX idx_테이블명_log_operation ON 테이블명_log(operation_type); + +-- 코멘트 추가 +COMMENT ON TABLE 테이블명_log IS '테이블명 테이블 변경 이력'; +``` + +### 7.2 트리거 함수 DDL 템플릿 + +```sql +CREATE OR REPLACE FUNCTION 테이블명_log_trigger_func() +RETURNS TRIGGER AS $$ +DECLARE + v_column_name TEXT; + v_old_value TEXT; + v_new_value TEXT; + v_user_id VARCHAR(50); + v_ip_address VARCHAR(50); +BEGIN + v_user_id := current_setting('app.user_id', TRUE); + v_ip_address := current_setting('app.ip_address', TRUE); + + IF (TG_OP = 'INSERT') THEN + INSERT INTO 테이블명_log (operation_type, original_id, changed_by, ip_address, full_row_after) + VALUES ('INSERT', NEW.id, v_user_id, v_ip_address, row_to_json(NEW)::jsonb); + RETURN NEW; + + ELSIF (TG_OP = 'UPDATE') THEN + FOR v_column_name IN + SELECT column_name + FROM information_schema.columns + WHERE table_name = '테이블명' + AND table_schema = 'public' + LOOP + EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name) + INTO v_old_value, v_new_value + USING OLD, NEW; + + IF v_old_value IS DISTINCT FROM v_new_value THEN + INSERT INTO 테이블명_log ( + operation_type, original_id, changed_column, old_value, new_value, + changed_by, ip_address, full_row_before, full_row_after + ) + VALUES ( + 'UPDATE', NEW.id, v_column_name, v_old_value, v_new_value, + v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb + ); + END IF; + END LOOP; + RETURN NEW; + + ELSIF (TG_OP = 'DELETE') THEN + INSERT INTO 테이블명_log (operation_type, original_id, changed_by, ip_address, full_row_before) + VALUES ('DELETE', OLD.id, v_user_id, v_ip_address, row_to_json(OLD)::jsonb); + RETURN OLD; + END IF; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; +``` + +### 7.3 트리거 DDL 템플릿 + +```sql +CREATE TRIGGER 테이블명_audit_trigger +AFTER INSERT OR UPDATE OR DELETE ON 테이블명 +FOR EACH ROW EXECUTE FUNCTION 테이블명_log_trigger_func(); +``` + +### 7.4 로그 설정 등록 + +```sql +INSERT INTO table_log_config ( + original_table_name, log_table_name, trigger_name, + trigger_function_name, is_active, created_by, created_at +) VALUES ( + '테이블명', '테이블명_log', '테이블명_audit_trigger', + '테이블명_log_trigger_func', 'Y', '생성자ID', now() +); +``` + +### 7.5 table_labels에 use_log_table 플래그 설정 + +```sql +UPDATE table_labels +SET use_log_table = 'Y', updated_date = now() +WHERE table_name = '테이블명'; +``` + +### 7.6 전체 예시: order_info 로그 테이블 생성 + +```sql +-- Step 1: 로그 테이블 생성 +CREATE TABLE order_info_log ( + log_id SERIAL PRIMARY KEY, + operation_type VARCHAR(10) NOT NULL, + original_id VARCHAR(100), + changed_column VARCHAR(100), + old_value TEXT, + new_value TEXT, + changed_by VARCHAR(50), + changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ip_address VARCHAR(50), + user_agent TEXT, + full_row_before JSONB, + full_row_after JSONB +); + +CREATE INDEX idx_order_info_log_original_id ON order_info_log(original_id); +CREATE INDEX idx_order_info_log_changed_at ON order_info_log(changed_at); +CREATE INDEX idx_order_info_log_operation ON order_info_log(operation_type); + +COMMENT ON TABLE order_info_log IS 'order_info 테이블 변경 이력'; + +-- Step 2: 트리거 함수 생성 +CREATE OR REPLACE FUNCTION order_info_log_trigger_func() +RETURNS TRIGGER AS $$ +DECLARE + v_column_name TEXT; + v_old_value TEXT; + v_new_value TEXT; + v_user_id VARCHAR(50); + v_ip_address VARCHAR(50); +BEGIN + v_user_id := current_setting('app.user_id', TRUE); + v_ip_address := current_setting('app.ip_address', TRUE); + + IF (TG_OP = 'INSERT') THEN + INSERT INTO order_info_log (operation_type, original_id, changed_by, ip_address, full_row_after) + VALUES ('INSERT', NEW.id, v_user_id, v_ip_address, row_to_json(NEW)::jsonb); + RETURN NEW; + ELSIF (TG_OP = 'UPDATE') THEN + FOR v_column_name IN + SELECT column_name FROM information_schema.columns + WHERE table_name = 'order_info' AND table_schema = 'public' + LOOP + EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name) + INTO v_old_value, v_new_value USING OLD, NEW; + IF v_old_value IS DISTINCT FROM v_new_value THEN + INSERT INTO order_info_log (operation_type, original_id, changed_column, old_value, new_value, changed_by, ip_address, full_row_before, full_row_after) + VALUES ('UPDATE', NEW.id, v_column_name, v_old_value, v_new_value, v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb); + END IF; + END LOOP; + RETURN NEW; + ELSIF (TG_OP = 'DELETE') THEN + INSERT INTO order_info_log (operation_type, original_id, changed_by, ip_address, full_row_before) + VALUES ('DELETE', OLD.id, v_user_id, v_ip_address, row_to_json(OLD)::jsonb); + RETURN OLD; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +-- Step 3: 트리거 생성 +CREATE TRIGGER order_info_audit_trigger +AFTER INSERT OR UPDATE OR DELETE ON order_info +FOR EACH ROW EXECUTE FUNCTION order_info_log_trigger_func(); + +-- Step 4: 로그 설정 등록 +INSERT INTO table_log_config (original_table_name, log_table_name, trigger_name, trigger_function_name, is_active, created_by, created_at) +VALUES ('order_info', 'order_info_log', 'order_info_audit_trigger', 'order_info_log_trigger_func', 'Y', 'system', now()); + +-- Step 5: table_labels 플래그 업데이트 +UPDATE table_labels SET use_log_table = 'Y', updated_date = now() WHERE table_name = 'order_info'; +``` + +### 7.7 로그 테이블 삭제 + +```sql +-- 트리거 삭제 +DROP TRIGGER IF EXISTS 테이블명_audit_trigger ON 테이블명; + +-- 트리거 함수 삭제 +DROP FUNCTION IF EXISTS 테이블명_log_trigger_func(); + +-- 로그 테이블 삭제 +DROP TABLE IF EXISTS 테이블명_log; + +-- 로그 설정 삭제 +DELETE FROM table_log_config WHERE original_table_name = '테이블명'; + +-- table_labels 플래그 업데이트 +UPDATE table_labels SET use_log_table = 'N', updated_date = now() WHERE table_name = '테이블명'; +``` + +--- + +## 8. 체크리스트 + +### 테이블 생성/수정 시 반드시 확인할 사항: - [ ] DDL에 기본 5개 컬럼 포함 (id, created_date, updated_date, writer, company_code) - [ ] 모든 비즈니스 컬럼은 `VARCHAR(500)` 타입 사용 @@ -349,9 +562,18 @@ ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.colu - [ ] code/entity 타입은 detail_settings에 참조 정보 포함 - [ ] ON CONFLICT 절로 중복 시 UPDATE 처리 +### 로그 테이블 생성 시 확인할 사항 (선택): + +- [ ] 로그 테이블 생성 (`테이블명_log`) +- [ ] 인덱스 3개 생성 (original_id, changed_at, operation_type) +- [ ] 트리거 함수 생성 (`테이블명_log_trigger_func`) +- [ ] 트리거 생성 (`테이블명_audit_trigger`) +- [ ] `table_log_config`에 로그 설정 등록 +- [ ] `table_labels.use_log_table = 'Y'` 업데이트 + --- -## 8. 금지 사항 +## 9. 금지 사항 1. **DB 타입 직접 지정 금지**: NUMBER, INTEGER, DATE 등 DB 타입 직접 사용 금지 2. **VARCHAR 길이 변경 금지**: 반드시 `VARCHAR(500)` 사용 @@ -364,5 +586,7 @@ ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.colu ## 참조 파일 - `backend-node/src/services/ddlExecutionService.ts`: DDL 실행 서비스 +- `backend-node/src/services/tableManagementService.ts`: 로그 테이블 생성 서비스 - `backend-node/src/types/ddl.ts`: DDL 타입 정의 - `backend-node/src/controllers/ddlController.ts`: DDL API 컨트롤러 +- `backend-node/src/controllers/tableManagementController.ts`: 로그 테이블 API 컨트롤러 From 3c4e251e9b2cc6aebe166c134c8cf1f2fd47f322 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 30 Dec 2025 12:33:17 +0900 Subject: [PATCH 2/7] =?UTF-8?q?=ED=8F=BC=20=EB=8B=A4=EC=A4=91=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EC=A0=80=EC=9E=A5=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UniversalFormModalComponent.tsx | 82 +++-- frontend/lib/utils/buttonActions.ts | 309 +++++++++++++----- 2 files changed, 284 insertions(+), 107 deletions(-) diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index b4921a51..9edf4054 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -963,6 +963,13 @@ export function UniversalFormModalComponent({ } } + // 별도 테이블에 저장해야 하는 테이블 섹션 목록 + const tableSectionsForSeparateTable = config.sections.filter( + (s) => s.type === "table" && + s.tableConfig?.saveConfig?.targetTable && + s.tableConfig.saveConfig.targetTable !== config.saveConfig.tableName + ); + // 테이블 섹션이 있고 메인 테이블에 품목별로 저장하는 경우 (공통 + 개별 병합 저장) // targetTable이 없거나 메인 테이블과 같은 경우 const tableSectionsForMainTable = config.sections.filter( @@ -971,6 +978,12 @@ export function UniversalFormModalComponent({ s.tableConfig.saveConfig.targetTable === config.saveConfig.tableName) ); + console.log("[saveSingleRow] 메인 테이블:", config.saveConfig.tableName); + console.log("[saveSingleRow] 메인 테이블에 저장할 테이블 섹션:", tableSectionsForMainTable.map(s => s.id)); + console.log("[saveSingleRow] 별도 테이블에 저장할 테이블 섹션:", tableSectionsForSeparateTable.map(s => s.id)); + console.log("[saveSingleRow] 테이블 섹션 데이터 키:", Object.keys(tableSectionData)); + console.log("[saveSingleRow] dataToSave 키:", Object.keys(dataToSave)); + if (tableSectionsForMainTable.length > 0) { // 공통 저장 필드 수집 (sectionSaveModes 설정에 따라) const commonFieldsData: Record = {}; @@ -1050,35 +1063,51 @@ export function UniversalFormModalComponent({ // 메인 레코드 ID가 필요한 경우 (response.data에서 가져오기) const mainRecordId = response.data?.data?.id; - // 공통 저장 필드 수집 (sectionSaveModes 설정에 따라) + // 공통 저장 필드 수집: 다른 섹션(필드 타입)에서 공통 저장으로 설정된 필드 값 + // 기본값: 필드 타입 섹션은 'common', 테이블 타입 섹션은 'individual' const commonFieldsData: Record = {}; const { sectionSaveModes } = config.saveConfig; - if (sectionSaveModes && sectionSaveModes.length > 0) { - // 다른 섹션에서 공통 저장으로 설정된 필드 값 수집 - for (const otherSection of config.sections) { - if (otherSection.id === section.id) continue; // 현재 테이블 섹션은 건너뛰기 - - const sectionMode = sectionSaveModes.find((s) => s.sectionId === otherSection.id); - const defaultMode = otherSection.type === "table" ? "individual" : "common"; - const sectionSaveMode = sectionMode?.saveMode || defaultMode; - - // 필드 타입 섹션의 필드들 처리 - if (otherSection.type !== "table" && otherSection.fields) { - for (const field of otherSection.fields) { - // 필드별 오버라이드 확인 - const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName); - const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode; - - // 공통 저장이면 formData에서 값을 가져와 모든 품목에 적용 - if (fieldSaveMode === "common" && formData[field.columnName] !== undefined) { - commonFieldsData[field.columnName] = formData[field.columnName]; + // 다른 섹션에서 공통 저장으로 설정된 필드 값 수집 + for (const otherSection of config.sections) { + if (otherSection.id === section.id) continue; // 현재 테이블 섹션은 건너뛰기 + + const sectionMode = sectionSaveModes?.find((s) => s.sectionId === otherSection.id); + // 기본값: 필드 타입 섹션은 'common', 테이블 타입 섹션은 'individual' + const defaultMode = otherSection.type === "table" ? "individual" : "common"; + const sectionSaveMode = sectionMode?.saveMode || defaultMode; + + // 필드 타입 섹션의 필드들 처리 + if (otherSection.type !== "table" && otherSection.fields) { + for (const field of otherSection.fields) { + // 필드별 오버라이드 확인 + const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName); + const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode; + + // 공통 저장이면 formData에서 값을 가져와 모든 품목에 적용 + if (fieldSaveMode === "common" && formData[field.columnName] !== undefined) { + commonFieldsData[field.columnName] = formData[field.columnName]; + } + } + } + + // 🆕 선택적 필드 그룹 (optionalFieldGroups)도 처리 + if (otherSection.optionalFieldGroups && otherSection.optionalFieldGroups.length > 0) { + for (const optGroup of otherSection.optionalFieldGroups) { + if (optGroup.fields) { + for (const field of optGroup.fields) { + // 선택적 필드 그룹은 기본적으로 common 저장 + if (formData[field.columnName] !== undefined) { + commonFieldsData[field.columnName] = formData[field.columnName]; + } } } } } } + console.log("[saveSingleRow] 별도 테이블 저장 - 공통 필드:", Object.keys(commonFieldsData)); + for (const item of sectionData) { // 공통 필드 병합 + 개별 품목 데이터 const itemToSave = { ...commonFieldsData, ...item }; @@ -1091,15 +1120,26 @@ export function UniversalFormModalComponent({ } } + // _sourceData 등 내부 메타데이터 제거 + Object.keys(itemToSave).forEach((key) => { + if (key.startsWith("_")) { + delete itemToSave[key]; + } + }); + // 메인 레코드와 연결이 필요한 경우 if (mainRecordId && config.saveConfig.primaryKeyColumn) { itemToSave[config.saveConfig.primaryKeyColumn] = mainRecordId; } - await apiClient.post( + const saveResponse = await apiClient.post( `/table-management/tables/${section.tableConfig.saveConfig.targetTable}/add`, itemToSave ); + + if (!saveResponse.data?.success) { + throw new Error(saveResponse.data?.message || `${section.title || "테이블 섹션"} 저장 실패`); + } } } } diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index de98028a..9a6a606e 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -1491,6 +1491,7 @@ export class ButtonActionExecutor { * 🆕 Universal Form Modal 테이블 섹션 병합 저장 처리 * 범용_폼_모달 내부의 공통 필드 + _tableSection_ 데이터를 병합하여 품목별로 저장 * 수정 모드: INSERT/UPDATE/DELETE 지원 + * 🆕 섹션별 저장 테이블(targetTable) 지원 추가 */ private static async handleUniversalFormModalTableSectionSave( config: ButtonActionConfig, @@ -1514,7 +1515,66 @@ export class ButtonActionExecutor { console.log("🎯 [handleUniversalFormModalTableSectionSave] Universal Form Modal 감지:", universalFormModalKey); const modalData = formData[universalFormModalKey]; - + + // 🆕 universal-form-modal 컴포넌트 설정 가져오기 + // 1. componentConfigs에서 컴포넌트 ID로 찾기 + // 2. allComponents에서 columnName으로 찾기 + // 3. 화면 레이아웃 API에서 가져오기 + let modalComponentConfig = context.componentConfigs?.[universalFormModalKey]; + + // componentConfigs에서 직접 찾지 못한 경우, allComponents에서 columnName으로 찾기 + if (!modalComponentConfig && context.allComponents) { + const modalComponent = context.allComponents.find( + (comp: any) => + comp.columnName === universalFormModalKey || comp.properties?.columnName === universalFormModalKey, + ); + if (modalComponent) { + modalComponentConfig = modalComponent.componentConfig || modalComponent.properties?.componentConfig; + console.log("🎯 [handleUniversalFormModalTableSectionSave] allComponents에서 설정 찾음:", modalComponent.id); + } + } + + // 🆕 아직도 설정을 찾지 못했으면 화면 레이아웃 API에서 가져오기 + if (!modalComponentConfig && screenId) { + try { + console.log("🔍 [handleUniversalFormModalTableSectionSave] 화면 레이아웃 API에서 설정 조회:", screenId); + const { screenApi } = await import("@/lib/api/screen"); + const layoutData = await screenApi.getLayout(screenId); + + if (layoutData && layoutData.components) { + // 레이아웃에서 universal-form-modal 컴포넌트 찾기 + const modalLayout = (layoutData.components as any[]).find( + (comp) => + comp.properties?.columnName === universalFormModalKey || comp.columnName === universalFormModalKey, + ); + if (modalLayout) { + modalComponentConfig = modalLayout.properties?.componentConfig || modalLayout.componentConfig; + console.log( + "🎯 [handleUniversalFormModalTableSectionSave] 화면 레이아웃에서 설정 찾음:", + modalLayout.componentId, + ); + } + } + } catch (error) { + console.warn("⚠️ [handleUniversalFormModalTableSectionSave] 화면 레이아웃 조회 실패:", error); + } + } + + const sections: any[] = modalComponentConfig?.sections || []; + const saveConfig = modalComponentConfig?.saveConfig || {}; + + console.log("🎯 [handleUniversalFormModalTableSectionSave] 컴포넌트 설정:", { + hasComponentConfig: !!modalComponentConfig, + sectionsCount: sections.length, + mainTableName: saveConfig.tableName || tableName, + sectionSaveModes: saveConfig.sectionSaveModes, + sectionDetails: sections.map((s: any) => ({ + id: s.id, + type: s.type, + targetTable: s.tableConfig?.saveConfig?.targetTable, + })), + }); + // _tableSection_ 데이터 추출 const tableSectionData: Record = {}; const commonFieldsData: Record = {}; @@ -1564,10 +1624,64 @@ export class ButtonActionExecutor { let insertedCount = 0; let updatedCount = 0; let deletedCount = 0; + let mainRecordId: number | null = null; + + // 🆕 먼저 메인 테이블에 공통 데이터 저장 (별도 테이블이 있는 경우에만) + const hasSeparateTargetTable = sections.some( + (s) => + s.type === "table" && + s.tableConfig?.saveConfig?.targetTable && + s.tableConfig.saveConfig.targetTable !== tableName, + ); + + if (hasSeparateTargetTable && Object.keys(commonFieldsData).length > 0) { + console.log("📦 [handleUniversalFormModalTableSectionSave] 메인 테이블에 공통 데이터 저장:", tableName); + + const mainRowToSave = { ...commonFieldsData, ...userInfo }; + + // 메타데이터 제거 + Object.keys(mainRowToSave).forEach((key) => { + if (key.startsWith("_")) { + delete mainRowToSave[key]; + } + }); + + console.log("📦 [handleUniversalFormModalTableSectionSave] 메인 테이블 저장 데이터:", mainRowToSave); + + const mainSaveResult = await DynamicFormApi.saveFormData({ + screenId: screenId!, + tableName: tableName!, + data: mainRowToSave, + }); + + if (!mainSaveResult.success) { + throw new Error(mainSaveResult.message || "메인 데이터 저장 실패"); + } + + mainRecordId = mainSaveResult.data?.id || null; + console.log("✅ [handleUniversalFormModalTableSectionSave] 메인 테이블 저장 완료, ID:", mainRecordId); + } // 각 테이블 섹션 처리 for (const [sectionId, currentItems] of Object.entries(tableSectionData)) { - console.log(`🔄 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 처리 시작: ${currentItems.length}개 품목`); + console.log( + `🔄 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 처리 시작: ${currentItems.length}개 품목`, + ); + + // 🆕 해당 섹션의 설정 찾기 + const sectionConfig = sections.find((s) => s.id === sectionId); + const targetTableName = sectionConfig?.tableConfig?.saveConfig?.targetTable; + + // 🆕 실제 저장할 테이블 결정 + // - targetTable이 있으면 해당 테이블에 저장 + // - targetTable이 없으면 메인 테이블에 저장 + const saveTableName = targetTableName || tableName!; + + console.log(`📊 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 저장 테이블:`, { + targetTableName, + saveTableName, + isMainTable: saveTableName === tableName, + }); // 1️⃣ 신규 품목 INSERT (id가 없는 항목) const newItems = currentItems.filter((item) => !item.id); @@ -1581,11 +1695,16 @@ export class ButtonActionExecutor { } }); - console.log("➕ [INSERT] 신규 품목:", rowToSave); + // 🆕 메인 레코드 ID 연결 (별도 테이블에 저장하는 경우) + if (targetTableName && mainRecordId && saveConfig.primaryKeyColumn) { + rowToSave[saveConfig.primaryKeyColumn] = mainRecordId; + } + + console.log("➕ [INSERT] 신규 품목:", { tableName: saveTableName, data: rowToSave }); const saveResult = await DynamicFormApi.saveFormData({ screenId: screenId!, - tableName: tableName!, + tableName: saveTableName, data: rowToSave, }); @@ -1612,9 +1731,14 @@ export class ButtonActionExecutor { }); delete rowToSave.id; // id 제거하여 INSERT + // 🆕 메인 레코드 ID 연결 (별도 테이블에 저장하는 경우) + if (targetTableName && mainRecordId && saveConfig.primaryKeyColumn) { + rowToSave[saveConfig.primaryKeyColumn] = mainRecordId; + } + const saveResult = await DynamicFormApi.saveFormData({ screenId: screenId!, - tableName: tableName!, + tableName: saveTableName, data: rowToSave, }); @@ -1631,14 +1755,14 @@ export class ButtonActionExecutor { const hasChanges = this.checkForChanges(originalItem, currentDataWithCommon); if (hasChanges) { - console.log(`🔄 [UPDATE] 품목 수정: id=${item.id}`); + console.log(`🔄 [UPDATE] 품목 수정: id=${item.id}, tableName=${saveTableName}`); // 변경된 필드만 추출하여 부분 업데이트 const updateResult = await DynamicFormApi.updateFormDataPartial( item.id, originalItem, currentDataWithCommon, - tableName!, + saveTableName, ); if (!updateResult.success) { @@ -1656,9 +1780,9 @@ export class ButtonActionExecutor { const deletedItems = originalGroupedData.filter((orig) => orig.id && !currentIds.has(orig.id)); for (const deletedItem of deletedItems) { - console.log(`🗑️ [DELETE] 품목 삭제: id=${deletedItem.id}`); + console.log(`🗑️ [DELETE] 품목 삭제: id=${deletedItem.id}, tableName=${saveTableName}`); - const deleteResult = await DynamicFormApi.deleteFormDataFromTable(tableName!, deletedItem.id); + const deleteResult = await DynamicFormApi.deleteFormDataFromTable(saveTableName, deletedItem.id); if (!deleteResult.success) { throw new Error(deleteResult.message || "품목 삭제 실패"); @@ -1670,6 +1794,7 @@ export class ButtonActionExecutor { // 결과 메시지 생성 const resultParts: string[] = []; + if (mainRecordId) resultParts.push("메인 데이터 저장"); if (insertedCount > 0) resultParts.push(`${insertedCount}개 추가`); if (updatedCount > 0) resultParts.push(`${updatedCount}개 수정`); if (deletedCount > 0) resultParts.push(`${deletedCount}개 삭제`); @@ -2145,17 +2270,20 @@ export class ButtonActionExecutor { * 연관 데이터 버튼의 선택 데이터로 모달 열기 * RelatedDataButtons 컴포넌트에서 선택된 버튼 데이터를 모달로 전달 */ - private static async handleOpenRelatedModal(config: ButtonActionConfig, context: ButtonActionContext): Promise { + private static async handleOpenRelatedModal( + config: ButtonActionConfig, + context: ButtonActionContext, + ): Promise { // 버튼 설정에서 targetScreenId 가져오기 (여러 위치에서 확인) const targetScreenId = config.relatedModalConfig?.targetScreenId || config.targetScreenId; - + console.log("🔍 [openRelatedModal] 설정 확인:", { config, relatedModalConfig: config.relatedModalConfig, targetScreenId: config.targetScreenId, finalTargetScreenId: targetScreenId, }); - + if (!targetScreenId) { console.error("❌ [openRelatedModal] targetScreenId가 설정되지 않았습니다."); toast.error("모달 화면 ID가 설정되지 않았습니다."); @@ -2164,13 +2292,13 @@ export class ButtonActionExecutor { // RelatedDataButtons에서 선택된 데이터 가져오기 const relatedData = window.__relatedButtonsSelectedData; - + console.log("🔍 [openRelatedModal] RelatedDataButtons 데이터:", { relatedData, selectedItem: relatedData?.selectedItem, config: relatedData?.config, }); - + if (!relatedData?.selectedItem) { console.warn("⚠️ [openRelatedModal] 선택된 버튼이 없습니다."); toast.warning("먼저 버튼을 선택해주세요."); @@ -2181,14 +2309,14 @@ export class ButtonActionExecutor { // 데이터 매핑 적용 const initialData: Record = {}; - + console.log("🔍 [openRelatedModal] 매핑 설정:", { modalLink: relatedConfig?.modalLink, dataMapping: relatedConfig?.modalLink?.dataMapping, }); - + if (relatedConfig?.modalLink?.dataMapping && relatedConfig.modalLink.dataMapping.length > 0) { - relatedConfig.modalLink.dataMapping.forEach(mapping => { + relatedConfig.modalLink.dataMapping.forEach((mapping) => { console.log("🔍 [openRelatedModal] 매핑 처리:", { mapping, sourceField: mapping.sourceField, @@ -2197,7 +2325,7 @@ export class ButtonActionExecutor { selectedItemId: selectedItem.id, rawDataValue: selectedItem.rawData[mapping.sourceField], }); - + if (mapping.sourceField === "value") { initialData[mapping.targetField] = selectedItem.value; } else if (mapping.sourceField === "id") { @@ -2219,18 +2347,20 @@ export class ButtonActionExecutor { }); // 모달 열기 이벤트 발생 (ScreenModal은 editData를 사용) - window.dispatchEvent(new CustomEvent("openScreenModal", { - detail: { - screenId: targetScreenId, - title: config.modalTitle, - description: config.modalDescription, - editData: initialData, // ScreenModal은 editData로 폼 데이터를 받음 - onSuccess: () => { - // 성공 후 데이터 새로고침 - window.dispatchEvent(new CustomEvent("refreshTableData")); + window.dispatchEvent( + new CustomEvent("openScreenModal", { + detail: { + screenId: targetScreenId, + title: config.modalTitle, + description: config.modalDescription, + editData: initialData, // ScreenModal은 editData로 폼 데이터를 받음 + onSuccess: () => { + // 성공 후 데이터 새로고침 + window.dispatchEvent(new CustomEvent("refreshTableData")); + }, }, - }, - })); + }), + ); return true; } @@ -3296,10 +3426,7 @@ export class ButtonActionExecutor { * EditModal 등 외부에서도 호출 가능하도록 public으로 변경 * 다중 제어 순차 실행 지원 */ - public static async executeAfterSaveControl( - config: ButtonActionConfig, - context: ButtonActionContext, - ): Promise { + public static async executeAfterSaveControl(config: ButtonActionConfig, context: ButtonActionContext): Promise { console.log("🎯 저장 후 제어 실행:", { enableDataflowControl: config.enableDataflowControl, dataflowConfig: config.dataflowConfig, @@ -4742,7 +4869,7 @@ export class ButtonActionExecutor { // 추적 중인지 확인 (새로고침 후에도 DB 상태 기반 종료 가능하도록 수정) const isTrackingActive = !!this.trackingIntervalId; - + if (!isTrackingActive) { // 추적 중이 아니어도 DB 상태 변경은 진행 (새로고침 후 종료 지원) console.log("⚠️ [handleTrackingStop] trackingIntervalId 없음 - DB 상태 기반 종료 진행"); @@ -4758,25 +4885,26 @@ export class ButtonActionExecutor { let dbDeparture: string | null = null; let dbArrival: string | null = null; let dbVehicleId: string | null = null; - + const userId = context.userId || this.trackingUserId; if (userId) { try { const { apiClient } = await import("@/lib/api/client"); - const statusTableName = config.trackingStatusTableName || this.trackingConfig?.trackingStatusTableName || context.tableName || "vehicles"; + const statusTableName = + config.trackingStatusTableName || + this.trackingConfig?.trackingStatusTableName || + context.tableName || + "vehicles"; const keyField = config.trackingStatusKeyField || this.trackingConfig?.trackingStatusKeyField || "user_id"; - + // DB에서 현재 차량 정보 조회 - const vehicleResponse = await apiClient.post( - `/table-management/tables/${statusTableName}/data`, - { - page: 1, - size: 1, - search: { [keyField]: userId }, - autoFilter: true, - }, - ); - + const vehicleResponse = await apiClient.post(`/table-management/tables/${statusTableName}/data`, { + page: 1, + size: 1, + search: { [keyField]: userId }, + autoFilter: true, + }); + const vehicleData = vehicleResponse.data?.data?.data?.[0] || vehicleResponse.data?.data?.rows?.[0]; if (vehicleData) { dbDeparture = vehicleData.departure || null; @@ -4792,14 +4920,18 @@ export class ButtonActionExecutor { // 마지막 위치 저장 (추적 중이었던 경우에만) if (isTrackingActive) { // DB 값 우선, 없으면 formData 사용 - const departure = dbDeparture || - this.trackingContext?.formData?.[this.trackingConfig?.trackingDepartureField || "departure"] || null; - const arrival = dbArrival || - this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null; + const departure = + dbDeparture || + this.trackingContext?.formData?.[this.trackingConfig?.trackingDepartureField || "departure"] || + null; + const arrival = + dbArrival || this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null; const departureName = this.trackingContext?.formData?.["departure_name"] || null; const destinationName = this.trackingContext?.formData?.["destination_name"] || null; - const vehicleId = dbVehicleId || - this.trackingContext?.formData?.[this.trackingConfig?.trackingVehicleIdField || "vehicle_id"] || null; + const vehicleId = + dbVehicleId || + this.trackingContext?.formData?.[this.trackingConfig?.trackingVehicleIdField || "vehicle_id"] || + null; await this.saveLocationToHistory( tripId, @@ -5681,10 +5813,10 @@ export class ButtonActionExecutor { const columnMappings = quickInsertConfig.columnMappings || []; for (const mapping of columnMappings) { - console.log(`📍 매핑 처리 시작:`, mapping); - + console.log("📍 매핑 처리 시작:", mapping); + if (!mapping.targetColumn) { - console.log(`📍 targetColumn 없음, 스킵`); + console.log("📍 targetColumn 없음, 스킵"); continue; } @@ -5692,12 +5824,12 @@ export class ButtonActionExecutor { switch (mapping.sourceType) { case "component": - console.log(`📍 component 타입 처리:`, { + console.log("📍 component 타입 처리:", { sourceComponentId: mapping.sourceComponentId, sourceColumnName: mapping.sourceColumnName, targetColumn: mapping.targetColumn, }); - + // 컴포넌트의 현재 값 if (mapping.sourceComponentId) { // 1. sourceColumnName이 있으면 직접 사용 (가장 확실한 방법) @@ -5705,34 +5837,34 @@ export class ButtonActionExecutor { value = formData?.[mapping.sourceColumnName]; console.log(`📍 방법1 (sourceColumnName): ${mapping.sourceColumnName} = ${value}`); } - + // 2. 없으면 컴포넌트 ID로 직접 찾기 if (value === undefined) { value = formData?.[mapping.sourceComponentId]; console.log(`📍 방법2 (sourceComponentId): ${mapping.sourceComponentId} = ${value}`); } - + // 3. 없으면 allComponents에서 컴포넌트를 찾아 columnName으로 시도 if (value === undefined && context.allComponents) { const comp = context.allComponents.find((c: any) => c.id === mapping.sourceComponentId); - console.log(`📍 방법3 찾은 컴포넌트:`, comp); + console.log("📍 방법3 찾은 컴포넌트:", comp); if (comp?.columnName) { value = formData?.[comp.columnName]; console.log(`📍 방법3 (allComponents): ${mapping.sourceComponentId} → ${comp.columnName} = ${value}`); } } - + // 4. targetColumn과 같은 이름의 키가 formData에 있으면 사용 (폴백) if (value === undefined && mapping.targetColumn && formData?.[mapping.targetColumn] !== undefined) { value = formData[mapping.targetColumn]; console.log(`📍 방법4 (targetColumn 폴백): ${mapping.targetColumn} = ${value}`); } - + // 5. 그래도 없으면 formData의 모든 키를 확인하고 로깅 if (value === undefined) { console.log("📍 방법5: formData에서 값을 찾지 못함. formData 키들:", Object.keys(formData || {})); } - + // sourceColumn이 지정된 경우 해당 속성 추출 if (mapping.sourceColumn && value && typeof value === "object") { value = value[mapping.sourceColumn]; @@ -5742,7 +5874,7 @@ export class ButtonActionExecutor { break; case "leftPanel": - console.log(`📍 leftPanel 타입 처리:`, { + console.log("📍 leftPanel 타입 처리:", { sourceColumn: mapping.sourceColumn, selectedLeftData: splitPanelContext?.selectedLeftData, }); @@ -5775,18 +5907,18 @@ export class ButtonActionExecutor { } console.log(`📍 currentUser 값: ${value}`); break; - + default: console.log(`📍 알 수 없는 sourceType: ${mapping.sourceType}`); } console.log(`📍 매핑 결과: targetColumn=${mapping.targetColumn}, value=${value}, type=${typeof value}`); - + if (value !== undefined && value !== null && value !== "") { insertData[mapping.targetColumn] = value; console.log(`📍 insertData에 추가됨: ${mapping.targetColumn} = ${value}`); } else { - console.log(`📍 값이 비어있어서 insertData에 추가 안됨`); + console.log("📍 값이 비어있어서 insertData에 추가 안됨"); } } @@ -5794,12 +5926,12 @@ export class ButtonActionExecutor { if (splitPanelContext?.selectedLeftData) { const leftData = splitPanelContext.selectedLeftData; console.log("📍 좌측 패널 자동 매핑 시작:", leftData); - + // 대상 테이블의 컬럼 목록 조회 let targetTableColumns: string[] = []; try { const columnsResponse = await apiClient.get( - `/table-management/tables/${quickInsertConfig.targetTable}/columns` + `/table-management/tables/${quickInsertConfig.targetTable}/columns`, ); if (columnsResponse.data?.success && columnsResponse.data?.data) { const columnsData = columnsResponse.data.data.columns || columnsResponse.data.data; @@ -5809,35 +5941,35 @@ export class ButtonActionExecutor { } catch (error) { console.error("대상 테이블 컬럼 조회 실패:", error); } - + for (const [key, val] of Object.entries(leftData)) { // 이미 매핑된 컬럼은 스킵 if (insertData[key] !== undefined) { console.log(`📍 자동 매핑 스킵 (이미 존재): ${key}`); continue; } - + // 대상 테이블에 해당 컬럼이 없으면 스킵 if (targetTableColumns.length > 0 && !targetTableColumns.includes(key)) { console.log(`📍 자동 매핑 스킵 (대상 테이블에 없는 컬럼): ${key}`); continue; } - + // 시스템 컬럼 제외 (id, created_date, updated_date, writer 등) - const systemColumns = ['id', 'created_date', 'updated_date', 'writer', 'writer_name']; + const systemColumns = ["id", "created_date", "updated_date", "writer", "writer_name"]; if (systemColumns.includes(key)) { console.log(`📍 자동 매핑 스킵 (시스템 컬럼): ${key}`); continue; } - + // _label, _name 으로 끝나는 표시용 컬럼 제외 - if (key.endsWith('_label') || key.endsWith('_name')) { + if (key.endsWith("_label") || key.endsWith("_name")) { console.log(`📍 자동 매핑 스킵 (표시용 컬럼): ${key}`); continue; } - + // 값이 있으면 자동 추가 - if (val !== undefined && val !== null && val !== '') { + if (val !== undefined && val !== null && val !== "") { insertData[key] = val; console.log(`📍 자동 매핑 추가: ${key} = ${val}`); } @@ -5857,7 +5989,7 @@ export class ButtonActionExecutor { enabled: quickInsertConfig.duplicateCheck?.enabled, columns: quickInsertConfig.duplicateCheck?.columns, }); - + if (quickInsertConfig.duplicateCheck?.enabled && quickInsertConfig.duplicateCheck?.columns?.length > 0) { const duplicateCheckData: Record = {}; for (const col of quickInsertConfig.duplicateCheck.columns) { @@ -5877,15 +6009,20 @@ export class ButtonActionExecutor { page: 1, pageSize: 1, search: duplicateCheckData, - } + }, ); console.log("📍 중복 체크 응답:", checkResponse.data); // 응답 구조: { success: true, data: { data: [...], total: N } } 또는 { success: true, data: [...] } const existingData = checkResponse.data?.data?.data || checkResponse.data?.data || []; - console.log("📍 기존 데이터:", existingData, "길이:", Array.isArray(existingData) ? existingData.length : 0); - + console.log( + "📍 기존 데이터:", + existingData, + "길이:", + Array.isArray(existingData) ? existingData.length : 0, + ); + if (Array.isArray(existingData) && existingData.length > 0) { toast.error(quickInsertConfig.duplicateCheck.errorMessage || "이미 존재하는 데이터입니다."); return false; @@ -5902,20 +6039,20 @@ export class ButtonActionExecutor { // 데이터 저장 const response = await apiClient.post( `/table-management/tables/${quickInsertConfig.targetTable}/add`, - insertData + insertData, ); if (response.data?.success) { console.log("✅ Quick Insert 저장 성공"); - + // 저장 후 동작 설정 로그 console.log("📍 afterInsert 설정:", quickInsertConfig.afterInsert); - + // 🆕 데이터 새로고침 (테이블리스트, 카드 디스플레이 컴포넌트 새로고침) // refreshData가 명시적으로 false가 아니면 기본적으로 새로고침 실행 const shouldRefresh = quickInsertConfig.afterInsert?.refreshData !== false; console.log("📍 데이터 새로고침 여부:", shouldRefresh); - + if (shouldRefresh) { console.log("📍 데이터 새로고침 이벤트 발송"); // 전역 이벤트로 테이블/카드 컴포넌트들에게 새로고침 알림 From fd58e9cce2ce64d145ffae72b5af404c9600a195 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 30 Dec 2025 13:32:49 +0900 Subject: [PATCH 3/7] =?UTF-8?q?=ED=96=89=EC=B6=94=EA=B0=80,=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EB=8F=99=EC=8B=9C=EC=9E=85=EB=A0=A5=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TableSectionRenderer.tsx | 1099 +++++++++-------- .../modals/TableSectionSettingsModal.tsx | 91 +- .../components/universal-form-modal/types.ts | 12 +- 3 files changed, 639 insertions(+), 563 deletions(-) diff --git a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx index ba03d2b9..a1c0bd76 100644 --- a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx +++ b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx @@ -16,12 +16,7 @@ import { ItemSelectionModal } from "../modal-repeater-table/ItemSelectionModal"; import { RepeaterColumnConfig, CalculationRule } from "../modal-repeater-table/types"; // 타입 정의 -import { - TableSectionConfig, - TableColumnConfig, - TableJoinCondition, - FormDataState, -} from "./types"; +import { TableSectionConfig, TableColumnConfig, TableJoinCondition, FormDataState } from "./types"; interface TableSectionRendererProps { sectionId: string; @@ -56,7 +51,7 @@ function convertToRepeaterColumn(col: TableColumnConfig): RepeaterColumnConfig { selectOptions: col.selectOptions, // valueMapping은 별도로 처리 }; - + // lookup 설정을 dynamicDataSource로 변환 (새로운 조회 기능) if (col.lookup?.enabled && col.lookup.options && col.lookup.options.length > 0) { baseColumn.dynamicDataSource = { @@ -75,17 +70,19 @@ function convertToRepeaterColumn(col: TableColumnConfig): RepeaterColumnConfig { sourceField: cond.sourceField, targetField: cond.targetColumn, // sourceType에 따른 데이터 출처 설정 - sourceType: cond.sourceType, // "currentRow" | "sectionField" | "externalTable" + sourceType: cond.sourceType, // "currentRow" | "sectionField" | "externalTable" fromFormData: cond.sourceType === "sectionField", sectionId: cond.sectionId, // 외부 테이블 조회 설정 (sourceType이 "externalTable"인 경우) externalLookup: cond.externalLookup, // 값 변환 설정 전달 (레거시 호환) - transform: cond.transform?.enabled ? { - tableName: cond.transform.tableName, - matchColumn: cond.transform.matchColumn, - resultColumn: cond.transform.resultColumn, - } : undefined, + transform: cond.transform?.enabled + ? { + tableName: cond.transform.tableName, + matchColumn: cond.transform.matchColumn, + resultColumn: cond.transform.resultColumn, + } + : undefined, })), }, // 조회 유형 정보 추가 @@ -115,14 +112,18 @@ function convertToRepeaterColumn(col: TableColumnConfig): RepeaterColumnConfig { defaultOptionId: col.columnModes.find((m) => m.isDefault)?.id || col.columnModes[0]?.id, }; } - + return baseColumn; } /** * TableCalculationRule을 CalculationRule로 변환 */ -function convertToCalculationRule(calc: { resultField: string; formula: string; dependencies: string[] }): CalculationRule { +function convertToCalculationRule(calc: { + resultField: string; + formula: string; + dependencies: string[]; +}): CalculationRule { return { result: calc.resultField, formula: calc.formula, @@ -136,7 +137,7 @@ function convertToCalculationRule(calc: { resultField: string; formula: string; */ async function transformValue( value: any, - transform: { tableName: string; matchColumn: string; resultColumn: string } + transform: { tableName: string; matchColumn: string; resultColumn: string }, ): Promise { if (!value || !transform.tableName || !transform.matchColumn || !transform.resultColumn) { return value; @@ -144,19 +145,16 @@ async function transformValue( try { // 정확히 일치하는 검색 - const response = await apiClient.post( - `/table-management/tables/${transform.tableName}/data`, - { - search: { - [transform.matchColumn]: { - value: value, - operator: "equals" - } - }, - size: 1, - page: 1 - } - ); + const response = await apiClient.post(`/table-management/tables/${transform.tableName}/data`, { + search: { + [transform.matchColumn]: { + value: value, + operator: "equals", + }, + }, + size: 1, + page: 1, + }); if (response.data.success && response.data.data?.data?.length > 0) { const transformedValue = response.data.data.data[0][transform.resultColumn]; @@ -186,7 +184,7 @@ async function fetchExternalLookupValue( }, rowData: any, sourceData: any, - formData: FormDataState + formData: FormDataState, ): Promise { // 1. 비교 값 가져오기 let matchValue: any; @@ -199,31 +197,32 @@ async function fetchExternalLookupValue( } if (matchValue === undefined || matchValue === null || matchValue === "") { - console.warn(`외부 테이블 조회: 비교 값이 없습니다. (${externalLookup.matchSourceType}.${externalLookup.matchSourceField})`); + console.warn( + `외부 테이블 조회: 비교 값이 없습니다. (${externalLookup.matchSourceType}.${externalLookup.matchSourceField})`, + ); return undefined; } // 2. 외부 테이블에서 값 조회 (정확히 일치하는 검색) try { - const response = await apiClient.post( - `/table-management/tables/${externalLookup.tableName}/data`, - { - search: { - [externalLookup.matchColumn]: { - value: matchValue, - operator: "equals" - } - }, - size: 1, - page: 1 - } - ); + const response = await apiClient.post(`/table-management/tables/${externalLookup.tableName}/data`, { + search: { + [externalLookup.matchColumn]: { + value: matchValue, + operator: "equals", + }, + }, + size: 1, + page: 1, + }); if (response.data.success && response.data.data?.data?.length > 0) { return response.data.data.data[0][externalLookup.resultColumn]; } - console.warn(`외부 테이블 조회: ${externalLookup.tableName}.${externalLookup.matchColumn} = "${matchValue}" 인 행을 찾을 수 없습니다.`); + console.warn( + `외부 테이블 조회: ${externalLookup.tableName}.${externalLookup.matchColumn} = "${matchValue}" 인 행을 찾을 수 없습니다.`, + ); return undefined; } catch (error) { console.error("외부 테이블 조회 오류:", error); @@ -233,7 +232,7 @@ async function fetchExternalLookupValue( /** * 외부 테이블에서 값을 조회하는 함수 - * + * * @param tableName - 조회할 테이블명 * @param valueColumn - 가져올 컬럼명 * @param joinConditions - 조인 조건 목록 @@ -247,7 +246,7 @@ async function fetchExternalValue( joinConditions: TableJoinCondition[], rowData: any, sourceData: any, - formData: FormDataState + formData: FormDataState, ): Promise { if (joinConditions.length === 0) { return undefined; @@ -298,15 +297,16 @@ async function fetchExternalValue( // 정확히 일치하는 검색을 위해 operator: "equals" 사용 whereConditions[condition.targetColumn] = { value: convertedValue, - operator: "equals" + operator: "equals", }; } // API 호출 - const response = await apiClient.post( - `/table-management/tables/${tableName}/data`, - { search: whereConditions, size: 1, page: 1 } - ); + const response = await apiClient.post(`/table-management/tables/${tableName}/data`, { + search: whereConditions, + size: 1, + page: 1, + }); if (response.data.success && response.data.data?.data?.length > 0) { return response.data.data.data[0][valueColumn]; @@ -334,42 +334,42 @@ export function TableSectionRenderer({ }: TableSectionRendererProps) { // 테이블 데이터 상태 (일반 모드) const [tableData, setTableData] = useState([]); - + // 조건부 테이블 데이터 상태 (조건별로 분리) const [conditionalTableData, setConditionalTableData] = useState({}); - + // 조건부 테이블: 선택된 조건들 (체크박스 모드) const [selectedConditions, setSelectedConditions] = useState([]); - + // 조건부 테이블: 현재 활성 탭 const [activeConditionTab, setActiveConditionTab] = useState(""); - + // 조건부 테이블: 현재 모달이 열린 조건 (어떤 조건의 테이블에 추가할지) const [modalCondition, setModalCondition] = useState(""); - + // 모달 상태 const [modalOpen, setModalOpen] = useState(false); - + // 체크박스 선택 상태 (조건별로 분리) const [selectedRows, setSelectedRows] = useState>(new Set()); const [conditionalSelectedRows, setConditionalSelectedRows] = useState>>({}); - + // 너비 조정 트리거 (홀수: 자동맞춤, 짝수: 균등분배) const [widthTrigger, setWidthTrigger] = useState(0); - + // 동적 데이터 소스 활성화 상태 const [activeDataSources, setActiveDataSources] = useState>({}); - + // 날짜 일괄 적용 완료 플래그 (컬럼별로 한 번만 적용) const [batchAppliedFields, setBatchAppliedFields] = useState>(new Set()); // 초기 데이터 로드 완료 플래그 (무한 루프 방지) const initialDataLoadedRef = React.useRef(false); - + // 조건부 테이블 설정 const conditionalConfig = tableConfig.conditionalTable; const isConditionalMode = conditionalConfig?.enabled ?? false; - + // 조건부 테이블: 동적 옵션 로드 상태 const [dynamicOptions, setDynamicOptions] = useState<{ id: string; value: string; label: string }[]>([]); const [dynamicOptionsLoading, setDynamicOptionsLoading] = useState(false); @@ -380,51 +380,48 @@ export function TableSectionRenderer({ if (!isConditionalMode) return; if (!conditionalConfig?.optionSource?.enabled) return; if (dynamicOptionsLoadedRef.current) return; - + const { tableName, valueColumn, labelColumn, filterCondition } = conditionalConfig.optionSource; - + if (!tableName || !valueColumn) return; - + const loadDynamicOptions = async () => { setDynamicOptionsLoading(true); try { // DISTINCT 값을 가져오기 위한 API 호출 - const response = await apiClient.post( - `/table-management/tables/${tableName}/data`, - { - search: filterCondition ? { _raw: filterCondition } : {}, - size: 1000, - page: 1, - } - ); - + const response = await apiClient.post(`/table-management/tables/${tableName}/data`, { + search: filterCondition ? { _raw: filterCondition } : {}, + size: 1000, + page: 1, + }); + if (response.data.success && response.data.data?.data) { const rows = response.data.data.data; - + // 중복 제거하여 고유 값 추출 const uniqueValues = new Map(); for (const row of rows) { const value = row[valueColumn]; if (value && !uniqueValues.has(value)) { - const label = labelColumn ? (row[labelColumn] || value) : value; + const label = labelColumn ? row[labelColumn] || value : value; uniqueValues.set(value, label); } } - + // 옵션 배열로 변환 const options = Array.from(uniqueValues.entries()).map(([value, label], index) => ({ id: `dynamic_${index}`, value, label, })); - + console.log("[TableSectionRenderer] 동적 옵션 로드 완료:", { tableName, valueColumn, optionCount: options.length, options, }); - + setDynamicOptions(options); dynamicOptionsLoadedRef.current = true; } @@ -434,48 +431,45 @@ export function TableSectionRenderer({ setDynamicOptionsLoading(false); } }; - + loadDynamicOptions(); }, [isConditionalMode, conditionalConfig?.optionSource]); // ============================================ // 동적 Select 옵션 (소스 테이블에서 드롭다운 옵션 로드) // ============================================ - + // 소스 테이블 데이터 캐시 (동적 Select 옵션용) const [sourceDataCache, setSourceDataCache] = useState([]); const sourceDataLoadedRef = React.useRef(false); - + // 동적 Select 옵션이 있는 컬럼 확인 const hasDynamicSelectColumns = useMemo(() => { - return tableConfig.columns?.some(col => col.dynamicSelectOptions?.enabled); + return tableConfig.columns?.some((col) => col.dynamicSelectOptions?.enabled); }, [tableConfig.columns]); - + // 소스 테이블 데이터 로드 (동적 Select 옵션용) useEffect(() => { if (!hasDynamicSelectColumns) return; if (sourceDataLoadedRef.current) return; if (!tableConfig.source?.tableName) return; - + const loadSourceData = async () => { try { // 조건부 테이블 필터 조건 적용 const filterCondition: Record = {}; - + // 소스 필터가 활성화되어 있고 조건이 선택되어 있으면 필터 적용 if (conditionalConfig?.sourceFilter?.enabled && activeConditionTab) { filterCondition[conditionalConfig.sourceFilter.filterColumn] = activeConditionTab; } - - const response = await apiClient.post( - `/table-management/tables/${tableConfig.source.tableName}/data`, - { - search: filterCondition, - size: 1000, - page: 1, - } - ); - + + const response = await apiClient.post(`/table-management/tables/${tableConfig.source.tableName}/data`, { + search: filterCondition, + size: 1000, + page: 1, + }); + if (response.data.success && response.data.data?.data) { setSourceDataCache(response.data.data.data); sourceDataLoadedRef.current = true; @@ -489,36 +483,33 @@ export function TableSectionRenderer({ console.error("[TableSectionRenderer] 소스 데이터 로드 실패:", error); } }; - + loadSourceData(); }, [hasDynamicSelectColumns, tableConfig.source?.tableName, conditionalConfig?.sourceFilter, activeConditionTab]); - + // 조건 탭 변경 시 소스 데이터 다시 로드 useEffect(() => { if (!hasDynamicSelectColumns) return; if (!conditionalConfig?.sourceFilter?.enabled) return; if (!activeConditionTab) return; if (!tableConfig.source?.tableName) return; - + // 조건 변경 시 캐시 리셋하고 즉시 다시 로드 sourceDataLoadedRef.current = false; setSourceDataCache([]); - + // 즉시 데이터 다시 로드 (기존 useEffect에 의존하지 않고 직접 호출) const loadSourceData = async () => { try { const filterCondition: Record = {}; filterCondition[conditionalConfig.sourceFilter!.filterColumn] = activeConditionTab; - - const response = await apiClient.post( - `/table-management/tables/${tableConfig.source!.tableName}/data`, - { - search: filterCondition, - size: 1000, - page: 1, - } - ); - + + const response = await apiClient.post(`/table-management/tables/${tableConfig.source!.tableName}/data`, { + search: filterCondition, + size: 1000, + page: 1, + }); + if (response.data.success && response.data.data?.data) { setSourceDataCache(response.data.data.data); sourceDataLoadedRef.current = true; @@ -532,96 +523,100 @@ export function TableSectionRenderer({ console.error("[TableSectionRenderer] 소스 데이터 로드 실패:", error); } }; - + loadSourceData(); - }, [activeConditionTab, hasDynamicSelectColumns, conditionalConfig?.sourceFilter?.enabled, conditionalConfig?.sourceFilter?.filterColumn, tableConfig.source?.tableName]); - + }, [ + activeConditionTab, + hasDynamicSelectColumns, + conditionalConfig?.sourceFilter?.enabled, + conditionalConfig?.sourceFilter?.filterColumn, + tableConfig.source?.tableName, + ]); + // 컬럼별 동적 Select 옵션 생성 const dynamicSelectOptionsMap = useMemo(() => { const optionsMap: Record = {}; - + if (!sourceDataCache.length) return optionsMap; - + for (const col of tableConfig.columns || []) { if (!col.dynamicSelectOptions?.enabled) continue; - + const { sourceField, labelField, distinct = true } = col.dynamicSelectOptions; - + if (!sourceField) continue; - + // 소스 데이터에서 옵션 추출 const seenValues = new Set(); const options: { value: string; label: string }[] = []; - + for (const row of sourceDataCache) { const value = row[sourceField]; if (value === undefined || value === null || value === "") continue; - + const stringValue = String(value); - + if (distinct && seenValues.has(stringValue)) continue; seenValues.add(stringValue); - - const label = labelField ? (row[labelField] || stringValue) : stringValue; + + const label = labelField ? row[labelField] || stringValue : stringValue; options.push({ value: stringValue, label: String(label) }); } - + optionsMap[col.field] = options; } - + return optionsMap; }, [sourceDataCache, tableConfig.columns]); - + // 데이터 변경 핸들러 (날짜 일괄 적용 로직 포함) - 다른 함수에서 참조하므로 먼저 정의 const handleDataChange = useCallback( (newData: any[]) => { let processedData = newData; - + // 날짜 일괄 적용 로직: batchApply가 활성화된 날짜 컬럼 처리 - const batchApplyColumns = tableConfig.columns.filter( - (col) => col.type === "date" && col.batchApply === true - ); - + const batchApplyColumns = tableConfig.columns.filter((col) => col.type === "date" && col.batchApply === true); + for (const dateCol of batchApplyColumns) { // 이미 일괄 적용된 컬럼은 건너뜀 if (batchAppliedFields.has(dateCol.field)) continue; - + // 해당 컬럼에 값이 있는 행과 없는 행 분류 const itemsWithDate = processedData.filter((item) => item[dateCol.field]); const itemsWithoutDate = processedData.filter((item) => !item[dateCol.field]); - + // 조건: 정확히 1개만 날짜가 있고, 나머지는 모두 비어있을 때 if (itemsWithDate.length === 1 && itemsWithoutDate.length > 0) { const selectedDate = itemsWithDate[0][dateCol.field]; - + // 모든 행에 동일한 날짜 적용 processedData = processedData.map((item) => ({ ...item, [dateCol.field]: selectedDate, })); - + // 플래그 활성화 (이후 개별 수정 가능) setBatchAppliedFields((prev) => new Set([...prev, dateCol.field])); } } - + setTableData(processedData); onTableDataChange(processedData); }, - [onTableDataChange, tableConfig.columns, batchAppliedFields] + [onTableDataChange, tableConfig.columns, batchAppliedFields], ); - + // 행 선택 모드: 드롭다운 값 변경 시 같은 소스 행의 다른 컬럼들 자동 채움 const handleDynamicSelectChange = useCallback( (rowIndex: number, columnField: string, selectedValue: string, conditionValue?: string) => { - const column = tableConfig.columns?.find(col => col.field === columnField); + const column = tableConfig.columns?.find((col) => col.field === columnField); if (!column?.dynamicSelectOptions?.rowSelectionMode?.enabled) { // 행 선택 모드가 아니면 일반 값 변경만 if (conditionValue && isConditionalMode) { const currentData = conditionalTableData[conditionValue] || []; const newData = [...currentData]; newData[rowIndex] = { ...newData[rowIndex], [columnField]: selectedValue }; - setConditionalTableData(prev => ({ ...prev, [conditionValue]: newData })); + setConditionalTableData((prev) => ({ ...prev, [conditionValue]: newData })); onConditionalTableDataChange?.(conditionValue, newData); } else { const newData = [...tableData]; @@ -630,18 +625,18 @@ export function TableSectionRenderer({ } return; } - + // 행 선택 모드: 소스 데이터에서 해당 값을 가진 행 찾기 const { sourceField } = column.dynamicSelectOptions; const { autoFillColumns, sourceIdColumn, targetIdField } = column.dynamicSelectOptions.rowSelectionMode; - - const sourceRow = sourceDataCache.find(row => String(row[sourceField]) === selectedValue); - + + const sourceRow = sourceDataCache.find((row) => String(row[sourceField]) === selectedValue); + if (!sourceRow) { console.warn(`[TableSectionRenderer] 소스 행을 찾을 수 없음: ${sourceField} = ${selectedValue}`); return; } - + // 현재 행 데이터 가져오기 let currentData: any[]; if (conditionValue && isConditionalMode) { @@ -649,10 +644,10 @@ export function TableSectionRenderer({ } else { currentData = tableData; } - + const newData = [...currentData]; const updatedRow = { ...newData[rowIndex], [columnField]: selectedValue }; - + // 자동 채움 매핑 적용 if (autoFillColumns) { for (const mapping of autoFillColumns) { @@ -662,22 +657,22 @@ export function TableSectionRenderer({ } } } - + // 소스 ID 저장 if (sourceIdColumn && targetIdField) { updatedRow[targetIdField] = sourceRow[sourceIdColumn]; } - + newData[rowIndex] = updatedRow; - + // 데이터 업데이트 if (conditionValue && isConditionalMode) { - setConditionalTableData(prev => ({ ...prev, [conditionValue]: newData })); + setConditionalTableData((prev) => ({ ...prev, [conditionValue]: newData })); onConditionalTableDataChange?.(conditionValue, newData); } else { handleDataChange(newData); } - + console.log("[TableSectionRenderer] 행 선택 모드 자동 채움:", { columnField, selectedValue, @@ -685,93 +680,101 @@ export function TableSectionRenderer({ updatedRow, }); }, - [tableConfig.columns, sourceDataCache, tableData, conditionalTableData, isConditionalMode, handleDataChange, onConditionalTableDataChange] + [ + tableConfig.columns, + sourceDataCache, + tableData, + conditionalTableData, + isConditionalMode, + handleDataChange, + onConditionalTableDataChange, + ], ); // 참조 컬럼 값 조회 함수 (saveToTarget: false인 컬럼에 대해 소스 테이블 조회) - const loadReferenceColumnValues = useCallback(async (data: any[]) => { - // saveToTarget: false이고 referenceDisplay가 설정된 컬럼 찾기 - const referenceColumns = (tableConfig.columns || []).filter( - (col) => col.saveConfig?.saveToTarget === false && col.saveConfig?.referenceDisplay - ); - - if (referenceColumns.length === 0) return; - - const sourceTableName = tableConfig.source?.tableName; - if (!sourceTableName) { - console.warn("[TableSectionRenderer] 참조 조회를 위한 소스 테이블이 설정되지 않았습니다."); - return; - } - - // 참조 ID들 수집 (중복 제거) - const referenceIdSet = new Set(); - - for (const col of referenceColumns) { - const refDisplay = col.saveConfig!.referenceDisplay!; - - for (const row of data) { - const refId = row[refDisplay.referenceIdField]; - if (refId !== undefined && refId !== null && refId !== "") { - referenceIdSet.add(String(refId)); + const loadReferenceColumnValues = useCallback( + async (data: any[]) => { + // saveToTarget: false이고 referenceDisplay가 설정된 컬럼 찾기 + const referenceColumns = (tableConfig.columns || []).filter( + (col) => col.saveConfig?.saveToTarget === false && col.saveConfig?.referenceDisplay, + ); + + if (referenceColumns.length === 0) return; + + const sourceTableName = tableConfig.source?.tableName; + if (!sourceTableName) { + console.warn("[TableSectionRenderer] 참조 조회를 위한 소스 테이블이 설정되지 않았습니다."); + return; + } + + // 참조 ID들 수집 (중복 제거) + const referenceIdSet = new Set(); + + for (const col of referenceColumns) { + const refDisplay = col.saveConfig!.referenceDisplay!; + + for (const row of data) { + const refId = row[refDisplay.referenceIdField]; + if (refId !== undefined && refId !== null && refId !== "") { + referenceIdSet.add(String(refId)); + } } } - } - - if (referenceIdSet.size === 0) return; - - try { - // 소스 테이블에서 참조 ID에 해당하는 데이터 조회 - const response = await apiClient.post( - `/table-management/tables/${sourceTableName}/data`, - { + + if (referenceIdSet.size === 0) return; + + try { + // 소스 테이블에서 참조 ID에 해당하는 데이터 조회 + const response = await apiClient.post(`/table-management/tables/${sourceTableName}/data`, { search: { id: Array.from(referenceIdSet) }, // ID 배열로 조회 size: 1000, page: 1, + }); + + if (!response.data?.success || !response.data?.data?.data) { + console.warn("[TableSectionRenderer] 참조 데이터 조회 실패"); + return; } - ); - - if (!response.data?.success || !response.data?.data?.data) { - console.warn("[TableSectionRenderer] 참조 데이터 조회 실패"); - return; - } - - const sourceData: any[] = response.data.data.data; - - // ID를 키로 하는 맵 생성 - const sourceDataMap: Record = {}; - for (const sourceRow of sourceData) { - sourceDataMap[String(sourceRow.id)] = sourceRow; - } - - // 각 행에 참조 컬럼 값 채우기 - const updatedData = data.map((row) => { - const newRow = { ...row }; - - for (const col of referenceColumns) { - const refDisplay = col.saveConfig!.referenceDisplay!; - const refId = row[refDisplay.referenceIdField]; - - if (refId !== undefined && refId !== null && refId !== "") { - const sourceRow = sourceDataMap[String(refId)]; - if (sourceRow) { - newRow[col.field] = sourceRow[refDisplay.sourceColumn]; + + const sourceData: any[] = response.data.data.data; + + // ID를 키로 하는 맵 생성 + const sourceDataMap: Record = {}; + for (const sourceRow of sourceData) { + sourceDataMap[String(sourceRow.id)] = sourceRow; + } + + // 각 행에 참조 컬럼 값 채우기 + const updatedData = data.map((row) => { + const newRow = { ...row }; + + for (const col of referenceColumns) { + const refDisplay = col.saveConfig!.referenceDisplay!; + const refId = row[refDisplay.referenceIdField]; + + if (refId !== undefined && refId !== null && refId !== "") { + const sourceRow = sourceDataMap[String(refId)]; + if (sourceRow) { + newRow[col.field] = sourceRow[refDisplay.sourceColumn]; + } } } - } - - return newRow; - }); - - console.log("[TableSectionRenderer] 참조 컬럼 값 조회 완료:", { - referenceColumns: referenceColumns.map((c) => c.field), - updatedRowCount: updatedData.length, - }); - - setTableData(updatedData); - } catch (error) { - console.error("[TableSectionRenderer] 참조 데이터 조회 실패:", error); - } - }, [tableConfig.columns, tableConfig.source?.tableName]); + + return newRow; + }); + + console.log("[TableSectionRenderer] 참조 컬럼 값 조회 완료:", { + referenceColumns: referenceColumns.map((c) => c.field), + updatedRowCount: updatedData.length, + }); + + setTableData(updatedData); + } catch (error) { + console.error("[TableSectionRenderer] 참조 데이터 조회 실패:", error); + } + }, + [tableConfig.columns, tableConfig.source?.tableName], + ); // formData에서 초기 테이블 데이터 로드 (수정 모드에서 _groupedData 표시) useEffect(() => { @@ -788,7 +791,7 @@ export function TableSectionRenderer({ }); setTableData(initialData); initialDataLoadedRef.current = true; - + // 참조 컬럼 값 조회 (saveToTarget: false인 컬럼) loadReferenceColumnValues(initialData); } @@ -796,14 +799,14 @@ export function TableSectionRenderer({ // RepeaterColumnConfig로 변환 (동적 Select 옵션 반영) const columns: RepeaterColumnConfig[] = useMemo(() => { - return (tableConfig.columns || []).map(col => { + return (tableConfig.columns || []).map((col) => { const baseColumn = convertToRepeaterColumn(col); - + // 동적 Select 옵션이 있으면 적용 if (col.dynamicSelectOptions?.enabled && dynamicSelectOptionsMap[col.field]) { baseColumn.selectOptions = dynamicSelectOptionsMap[col.field]; } - + return baseColumn; }); }, [tableConfig.columns, dynamicSelectOptionsMap]); @@ -840,23 +843,24 @@ export function TableSectionRenderer({ return updatedRow; }, - [calculationRules] + [calculationRules], ); const calculateAll = useCallback( (data: any[]): any[] => { return data.map((row) => calculateRow(row)); }, - [calculateRow] + [calculateRow], ); // 행 변경 핸들러 (동적 Select 행 선택 모드 지원) const handleRowChange = useCallback( (index: number, newRow: any, conditionValue?: string) => { - const oldRow = conditionValue && isConditionalMode - ? (conditionalTableData[conditionValue]?.[index] || {}) - : (tableData[index] || {}); - + const oldRow = + conditionValue && isConditionalMode + ? conditionalTableData[conditionValue]?.[index] || {} + : tableData[index] || {}; + // 변경된 필드 찾기 const changedFields: string[] = []; for (const key of Object.keys(newRow)) { @@ -864,25 +868,25 @@ export function TableSectionRenderer({ changedFields.push(key); } } - + // 동적 Select 컬럼의 행 선택 모드 확인 for (const changedField of changedFields) { - const column = tableConfig.columns?.find(col => col.field === changedField); + const column = tableConfig.columns?.find((col) => col.field === changedField); if (column?.dynamicSelectOptions?.rowSelectionMode?.enabled) { // 행 선택 모드 처리 (자동 채움) handleDynamicSelectChange(index, changedField, newRow[changedField], conditionValue); return; // 행 선택 모드에서 처리 완료 } } - + // 일반 행 변경 처리 const calculatedRow = calculateRow(newRow); - + if (conditionValue && isConditionalMode) { const currentData = conditionalTableData[conditionValue] || []; const newData = [...currentData]; newData[index] = calculatedRow; - setConditionalTableData(prev => ({ ...prev, [conditionValue]: newData })); + setConditionalTableData((prev) => ({ ...prev, [conditionValue]: newData })); onConditionalTableDataChange?.(conditionValue, newData); } else { const newData = [...tableData]; @@ -890,7 +894,16 @@ export function TableSectionRenderer({ handleDataChange(newData); } }, - [tableData, conditionalTableData, isConditionalMode, tableConfig.columns, calculateRow, handleDataChange, handleDynamicSelectChange, onConditionalTableDataChange] + [ + tableData, + conditionalTableData, + isConditionalMode, + tableConfig.columns, + calculateRow, + handleDataChange, + handleDynamicSelectChange, + onConditionalTableDataChange, + ], ); // 행 삭제 핸들러 @@ -899,7 +912,7 @@ export function TableSectionRenderer({ const newData = tableData.filter((_, i) => i !== index); handleDataChange(newData); }, - [tableData, handleDataChange] + [tableData, handleDataChange], ); // 선택된 항목 일괄 삭제 @@ -908,7 +921,7 @@ export function TableSectionRenderer({ const newData = tableData.filter((_, index) => !selectedRows.has(index)); handleDataChange(newData); setSelectedRows(new Set()); - + // 데이터가 모두 삭제되면 일괄 적용 플래그도 리셋 if (newData.length === 0) { setBatchAppliedFields(new Set()); @@ -931,7 +944,7 @@ export function TableSectionRenderer({ // 현재 활성화된 옵션 또는 기본 옵션 사용 const activeOptionId = activeDataSources[col.field]; const defaultOption = col.lookup.options.find((o) => o.isDefault) || col.lookup.options[0]; - const selectedOption = activeOptionId + const selectedOption = activeOptionId ? col.lookup.options.find((o) => o.id === activeOptionId) || defaultOption : defaultOption; @@ -969,11 +982,13 @@ export function TableSectionRenderer({ // 외부 테이블 조회 설정 externalLookup: cond.externalLookup, // 값 변환 설정 전달 (레거시 호환) - transform: cond.transform?.enabled ? { - tableName: cond.transform.tableName, - matchColumn: cond.transform.matchColumn, - resultColumn: cond.transform.resultColumn, - } : undefined, + transform: cond.transform?.enabled + ? { + tableName: cond.transform.tableName, + matchColumn: cond.transform.matchColumn, + resultColumn: cond.transform.resultColumn, + } + : undefined, }; }); @@ -982,15 +997,15 @@ export function TableSectionRenderer({ selectedOption.tableName, selectedOption.valueColumn, joinConditions, - { ...sourceItem, ...newItem }, // rowData (현재 행) - sourceItem, // sourceData (소스 테이블 원본) - formData + { ...sourceItem, ...newItem }, // rowData (현재 행) + sourceItem, // sourceData (소스 테이블 원본) + formData, ); - + if (value !== undefined) { newItem[col.field] = value; } - + // _sourceData에 원본 저장 newItem._sourceData = sourceItem; } @@ -1045,8 +1060,8 @@ export function TableSectionRenderer({ valueColumn, joinConditions, { ...sourceItem, ...newItem }, // rowData - sourceItem, // sourceData - formData + sourceItem, // sourceData + formData, ); if (value !== undefined) { newItem[col.field] = value; @@ -1070,7 +1085,7 @@ export function TableSectionRenderer({ } return newItem; - }) + }), ); // 계산 필드 업데이트 @@ -1080,7 +1095,7 @@ export function TableSectionRenderer({ const newData = [...tableData, ...calculatedItems]; handleDataChange(newData); }, - [tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources] + [tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources], ); // 컬럼 모드/조회 옵션 변경 핸들러 @@ -1093,7 +1108,7 @@ export function TableSectionRenderer({ // 해당 컬럼의 모든 행 데이터 재조회 const column = tableConfig.columns.find((col) => col.field === columnField); - + // lookup 설정이 있는 경우 (새로운 조회 기능) if (column?.lookup?.enabled && column.lookup.options) { const selectedOption = column.lookup.options.find((opt) => opt.id === optionId); @@ -1140,11 +1155,13 @@ export function TableSectionRenderer({ // 외부 테이블 조회 설정 externalLookup: cond.externalLookup, // 값 변환 설정 전달 (레거시 호환) - transform: cond.transform?.enabled ? { - tableName: cond.transform.tableName, - matchColumn: cond.transform.matchColumn, - resultColumn: cond.transform.resultColumn, - } : undefined, + transform: cond.transform?.enabled + ? { + tableName: cond.transform.tableName, + matchColumn: cond.transform.matchColumn, + resultColumn: cond.transform.resultColumn, + } + : undefined, }; }); @@ -1156,15 +1173,15 @@ export function TableSectionRenderer({ joinConditions, row, sourceData, - formData + formData, ); - + if (value !== undefined) { newValue = value; } return { ...row, [columnField]: newValue }; - }) + }), ); // 계산 필드 업데이트 @@ -1199,14 +1216,14 @@ export function TableSectionRenderer({ } return { ...row, [columnField]: newValue }; - }) + }), ); // 계산 필드 업데이트 const calculatedData = calculateAll(updatedData); handleDataChange(calculatedData); }, - [tableConfig.columns, tableData, formData, calculateAll, handleDataChange] + [tableConfig.columns, tableData, formData, calculateAll, handleDataChange], ); // 소스 테이블 정보 @@ -1216,10 +1233,16 @@ export function TableSectionRenderer({ const sourceSearchFields = source.searchColumns; const columnLabels = source.columnLabels || {}; const modalTitle = uiConfig?.modalTitle || "항목 검색 및 선택"; - const addButtonType = uiConfig?.addButtonType || "search"; - const addButtonText = uiConfig?.addButtonText || (addButtonType === "addRow" ? "항목 추가" : "항목 검색"); const multiSelect = uiConfig?.multiSelect ?? true; + // 버튼 표시 설정 (두 버튼 동시 표시 가능) + // 레거시 호환: 기존 addButtonType 설정이 있으면 그에 맞게 변환 + const legacyAddButtonType = uiConfig?.addButtonType; + const showSearchButton = legacyAddButtonType === "addRow" ? false : (uiConfig?.showSearchButton ?? true); + const showAddRowButton = legacyAddButtonType === "addRow" ? true : (uiConfig?.showAddRowButton ?? false); + const searchButtonText = uiConfig?.searchButtonText || uiConfig?.addButtonText || "품목 검색"; + const addRowButtonText = uiConfig?.addRowButtonText || "직접 입력"; + // 기본 필터 조건 생성 (사전 필터만 - 모달 필터는 ItemSelectionModal에서 처리) const baseFilterCondition: Record = useMemo(() => { const condition: Record = {}; @@ -1233,19 +1256,19 @@ export function TableSectionRenderer({ } return condition; }, [filters?.preFilters]); - + // 조건부 테이블용 필터 조건 생성 (선택된 조건값으로 소스 테이블 필터링) const conditionalFilterCondition = useMemo(() => { const filter = { ...baseFilterCondition }; - + // 조건부 테이블의 소스 필터 설정이 있고, 모달에서 선택된 조건이 있으면 적용 if (conditionalConfig?.sourceFilter?.enabled && modalCondition) { filter[conditionalConfig.sourceFilter.filterColumn] = modalCondition; } - + return filter; }, [baseFilterCondition, conditionalConfig?.sourceFilter, modalCondition]); - + // 모달 필터 설정을 ItemSelectionModal에 전달할 형식으로 변환 const modalFiltersForModal = useMemo(() => { if (!filters?.modalFilters) return []; @@ -1253,7 +1276,7 @@ export function TableSectionRenderer({ column: filter.column, label: filter.label || filter.column, // category 타입을 select로 변환 (ModalFilterConfig 호환) - type: filter.type === "category" ? "select" as const : filter.type as "text" | "select", + type: filter.type === "category" ? ("select" as const) : (filter.type as "text" | "select"), options: filter.options, categoryRef: filter.categoryRef, defaultValue: filter.defaultValue, @@ -1265,138 +1288,156 @@ export function TableSectionRenderer({ // ============================================ // 조건부 테이블: 조건 체크박스 토글 - const handleConditionToggle = useCallback((conditionValue: string, checked: boolean) => { - setSelectedConditions((prev) => { - if (checked) { - const newConditions = [...prev, conditionValue]; - // 첫 번째 조건 선택 시 해당 탭 활성화 - if (prev.length === 0) { - setActiveConditionTab(conditionValue); + const handleConditionToggle = useCallback( + (conditionValue: string, checked: boolean) => { + setSelectedConditions((prev) => { + if (checked) { + const newConditions = [...prev, conditionValue]; + // 첫 번째 조건 선택 시 해당 탭 활성화 + if (prev.length === 0) { + setActiveConditionTab(conditionValue); + } + return newConditions; + } else { + const newConditions = prev.filter((c) => c !== conditionValue); + // 현재 활성 탭이 제거된 경우 다른 탭으로 전환 + if (activeConditionTab === conditionValue && newConditions.length > 0) { + setActiveConditionTab(newConditions[0]); + } + return newConditions; } - return newConditions; - } else { - const newConditions = prev.filter((c) => c !== conditionValue); - // 현재 활성 탭이 제거된 경우 다른 탭으로 전환 - if (activeConditionTab === conditionValue && newConditions.length > 0) { - setActiveConditionTab(newConditions[0]); - } - return newConditions; - } - }); - }, [activeConditionTab]); + }); + }, + [activeConditionTab], + ); // 조건부 테이블: 조건별 데이터 변경 - const handleConditionalDataChange = useCallback((conditionValue: string, newData: any[]) => { - setConditionalTableData((prev) => ({ - ...prev, - [conditionValue]: newData, - })); - - // 부모에게 조건별 데이터 변경 알림 - if (onConditionalTableDataChange) { - onConditionalTableDataChange(conditionValue, newData); - } - - // 전체 데이터를 flat array로 변환하여 onTableDataChange 호출 - // (저장 시 조건 컬럼 값이 자동으로 추가됨) - const conditionColumn = conditionalConfig?.conditionColumn; - const allData: any[] = []; - - // 현재 변경된 조건의 데이터 업데이트 - const updatedConditionalData = { ...conditionalTableData, [conditionValue]: newData }; - - for (const [condition, data] of Object.entries(updatedConditionalData)) { - for (const row of data) { - allData.push({ - ...row, - ...(conditionColumn ? { [conditionColumn]: condition } : {}), - }); + const handleConditionalDataChange = useCallback( + (conditionValue: string, newData: any[]) => { + setConditionalTableData((prev) => ({ + ...prev, + [conditionValue]: newData, + })); + + // 부모에게 조건별 데이터 변경 알림 + if (onConditionalTableDataChange) { + onConditionalTableDataChange(conditionValue, newData); } - } - - onTableDataChange(allData); - }, [conditionalTableData, conditionalConfig?.conditionColumn, onConditionalTableDataChange, onTableDataChange]); + + // 전체 데이터를 flat array로 변환하여 onTableDataChange 호출 + // (저장 시 조건 컬럼 값이 자동으로 추가됨) + const conditionColumn = conditionalConfig?.conditionColumn; + const allData: any[] = []; + + // 현재 변경된 조건의 데이터 업데이트 + const updatedConditionalData = { ...conditionalTableData, [conditionValue]: newData }; + + for (const [condition, data] of Object.entries(updatedConditionalData)) { + for (const row of data) { + allData.push({ + ...row, + ...(conditionColumn ? { [conditionColumn]: condition } : {}), + }); + } + } + + onTableDataChange(allData); + }, + [conditionalTableData, conditionalConfig?.conditionColumn, onConditionalTableDataChange, onTableDataChange], + ); // 조건부 테이블: 조건별 행 변경 - const handleConditionalRowChange = useCallback((conditionValue: string, index: number, newRow: any) => { - const calculatedRow = calculateRow(newRow); - const currentData = conditionalTableData[conditionValue] || []; - const newData = [...currentData]; - newData[index] = calculatedRow; - handleConditionalDataChange(conditionValue, newData); - }, [conditionalTableData, calculateRow, handleConditionalDataChange]); + const handleConditionalRowChange = useCallback( + (conditionValue: string, index: number, newRow: any) => { + const calculatedRow = calculateRow(newRow); + const currentData = conditionalTableData[conditionValue] || []; + const newData = [...currentData]; + newData[index] = calculatedRow; + handleConditionalDataChange(conditionValue, newData); + }, + [conditionalTableData, calculateRow, handleConditionalDataChange], + ); // 조건부 테이블: 조건별 행 삭제 - const handleConditionalRowDelete = useCallback((conditionValue: string, index: number) => { - const currentData = conditionalTableData[conditionValue] || []; - const newData = currentData.filter((_, i) => i !== index); - handleConditionalDataChange(conditionValue, newData); - }, [conditionalTableData, handleConditionalDataChange]); + const handleConditionalRowDelete = useCallback( + (conditionValue: string, index: number) => { + const currentData = conditionalTableData[conditionValue] || []; + const newData = currentData.filter((_, i) => i !== index); + handleConditionalDataChange(conditionValue, newData); + }, + [conditionalTableData, handleConditionalDataChange], + ); // 조건부 테이블: 조건별 선택 행 일괄 삭제 - const handleConditionalBulkDelete = useCallback((conditionValue: string) => { - const selected = conditionalSelectedRows[conditionValue] || new Set(); - if (selected.size === 0) return; - - const currentData = conditionalTableData[conditionValue] || []; - const newData = currentData.filter((_, index) => !selected.has(index)); - handleConditionalDataChange(conditionValue, newData); - - // 선택 상태 초기화 - setConditionalSelectedRows((prev) => ({ - ...prev, - [conditionValue]: new Set(), - })); - }, [conditionalTableData, conditionalSelectedRows, handleConditionalDataChange]); + const handleConditionalBulkDelete = useCallback( + (conditionValue: string) => { + const selected = conditionalSelectedRows[conditionValue] || new Set(); + if (selected.size === 0) return; + + const currentData = conditionalTableData[conditionValue] || []; + const newData = currentData.filter((_, index) => !selected.has(index)); + handleConditionalDataChange(conditionValue, newData); + + // 선택 상태 초기화 + setConditionalSelectedRows((prev) => ({ + ...prev, + [conditionValue]: new Set(), + })); + }, + [conditionalTableData, conditionalSelectedRows, handleConditionalDataChange], + ); // 조건부 테이블: 아이템 추가 (특정 조건에) - const handleConditionalAddItems = useCallback(async (items: any[]) => { - if (!modalCondition) return; - - // 기존 handleAddItems 로직을 재사용하여 매핑된 아이템 생성 - const mappedItems = await Promise.all( - items.map(async (sourceItem) => { - const newItem: any = {}; - - for (const col of tableConfig.columns) { - const mapping = col.valueMapping; - - // 소스 필드에서 값 복사 (기본) - if (!mapping) { - const sourceField = col.sourceField || col.field; - if (sourceItem[sourceField] !== undefined) { - newItem[col.field] = sourceItem[sourceField]; + const handleConditionalAddItems = useCallback( + async (items: any[]) => { + if (!modalCondition) return; + + // 기존 handleAddItems 로직을 재사용하여 매핑된 아이템 생성 + const mappedItems = await Promise.all( + items.map(async (sourceItem) => { + const newItem: any = {}; + + for (const col of tableConfig.columns) { + const mapping = col.valueMapping; + + // 소스 필드에서 값 복사 (기본) + if (!mapping) { + const sourceField = col.sourceField || col.field; + if (sourceItem[sourceField] !== undefined) { + newItem[col.field] = sourceItem[sourceField]; + } + continue; } - continue; - } - - // valueMapping 처리 - if (mapping.type === "source" && mapping.sourceField) { - const value = sourceItem[mapping.sourceField]; - if (value !== undefined) { - newItem[col.field] = value; + + // valueMapping 처리 + if (mapping.type === "source" && mapping.sourceField) { + const value = sourceItem[mapping.sourceField]; + if (value !== undefined) { + newItem[col.field] = value; + } + } else if (mapping.type === "manual") { + newItem[col.field] = col.defaultValue || ""; + } else if (mapping.type === "internal" && mapping.internalField) { + newItem[col.field] = formData[mapping.internalField]; } - } else if (mapping.type === "manual") { - newItem[col.field] = col.defaultValue || ""; - } else if (mapping.type === "internal" && mapping.internalField) { - newItem[col.field] = formData[mapping.internalField]; } - } - - // 원본 소스 데이터 보존 - newItem._sourceData = sourceItem; - - return newItem; - }) - ); - - // 현재 조건의 데이터에 추가 - const currentData = conditionalTableData[modalCondition] || []; - const newData = [...currentData, ...mappedItems]; - handleConditionalDataChange(modalCondition, newData); - - setModalOpen(false); - }, [modalCondition, tableConfig.columns, formData, conditionalTableData, handleConditionalDataChange]); + + // 원본 소스 데이터 보존 + newItem._sourceData = sourceItem; + + return newItem; + }), + ); + + // 현재 조건의 데이터에 추가 + const currentData = conditionalTableData[modalCondition] || []; + const newData = [...currentData, ...mappedItems]; + handleConditionalDataChange(modalCondition, newData); + + setModalOpen(false); + }, + [modalCondition, tableConfig.columns, formData, conditionalTableData, handleConditionalDataChange], + ); // 조건부 테이블: 모달 열기 (특정 조건에 대해) const openConditionalModal = useCallback((conditionValue: string) => { @@ -1405,62 +1446,68 @@ export function TableSectionRenderer({ }, []); // 조건부 테이블: 빈 행 추가 (addRow 모드에서 사용) - const addEmptyRowToCondition = useCallback((conditionValue: string) => { - const newRow: Record = {}; - - // 각 컬럼의 기본값으로 빈 행 생성 - for (const col of tableConfig.columns) { - if (col.defaultValue !== undefined) { - newRow[col.field] = col.defaultValue; - } else if (col.type === "number") { - newRow[col.field] = 0; - } else if (col.type === "checkbox") { - newRow[col.field] = false; - } else { - newRow[col.field] = ""; - } - } - - // 조건 컬럼에 현재 조건 값 설정 - if (conditionalConfig?.conditionColumn) { - newRow[conditionalConfig.conditionColumn] = conditionValue; - } - - // 현재 조건의 데이터에 추가 - const currentData = conditionalTableData[conditionValue] || []; - const newData = [...currentData, newRow]; - handleConditionalDataChange(conditionValue, newData); - }, [tableConfig.columns, conditionalConfig?.conditionColumn, conditionalTableData, handleConditionalDataChange]); + const addEmptyRowToCondition = useCallback( + (conditionValue: string) => { + const newRow: Record = {}; - // 버튼 클릭 핸들러 (addButtonType에 따라 다르게 동작) - const handleAddButtonClick = useCallback((conditionValue: string) => { - const addButtonType = tableConfig.uiConfig?.addButtonType || "search"; - - if (addButtonType === "addRow") { - // 빈 행 직접 추가 - addEmptyRowToCondition(conditionValue); - } else { - // 검색 모달 열기 + // 각 컬럼의 기본값으로 빈 행 생성 + for (const col of tableConfig.columns) { + if (col.defaultValue !== undefined) { + newRow[col.field] = col.defaultValue; + } else if (col.type === "number") { + newRow[col.field] = 0; + } else if (col.type === "checkbox") { + newRow[col.field] = false; + } else { + newRow[col.field] = ""; + } + } + + // 조건 컬럼에 현재 조건 값 설정 + if (conditionalConfig?.conditionColumn) { + newRow[conditionalConfig.conditionColumn] = conditionValue; + } + + // 현재 조건의 데이터에 추가 + const currentData = conditionalTableData[conditionValue] || []; + const newData = [...currentData, newRow]; + handleConditionalDataChange(conditionValue, newData); + }, + [tableConfig.columns, conditionalConfig?.conditionColumn, conditionalTableData, handleConditionalDataChange], + ); + + // 검색 버튼 클릭 핸들러 + const handleSearchButtonClick = useCallback( + (conditionValue: string) => { openConditionalModal(conditionValue); - } - }, [tableConfig.uiConfig?.addButtonType, addEmptyRowToCondition, openConditionalModal]); + }, + [openConditionalModal], + ); + + // 행 추가 버튼 클릭 핸들러 + const handleAddRowButtonClick = useCallback( + (conditionValue: string) => { + addEmptyRowToCondition(conditionValue); + }, + [addEmptyRowToCondition], + ); // 조건부 테이블: 초기 데이터 로드 (수정 모드) useEffect(() => { if (!isConditionalMode) return; if (initialDataLoadedRef.current) return; - + const tableSectionKey = `_tableSection_${sectionId}`; const initialData = formData[tableSectionKey]; - + if (Array.isArray(initialData) && initialData.length > 0) { const conditionColumn = conditionalConfig?.conditionColumn; - + if (conditionColumn) { // 조건별로 데이터 그룹핑 const grouped: ConditionalTableData = {}; const conditions = new Set(); - + for (const row of initialData) { const conditionValue = row[conditionColumn] || ""; if (conditionValue) { @@ -1471,15 +1518,15 @@ export function TableSectionRenderer({ conditions.add(conditionValue); } } - + setConditionalTableData(grouped); setSelectedConditions(Array.from(conditions)); - + // 첫 번째 조건을 활성 탭으로 설정 if (conditions.size > 0) { setActiveConditionTab(Array.from(conditions)[0]); } - + initialDataLoadedRef.current = true; } } @@ -1495,27 +1542,29 @@ export function TableSectionRenderer({ // ============================================ if (isConditionalMode && conditionalConfig) { const { triggerType } = conditionalConfig; - + // 정적 옵션과 동적 옵션 병합 (동적 옵션이 있으면 우선 사용) // 빈 value를 가진 옵션은 제외 (Select.Item은 빈 문자열 value를 허용하지 않음) - const effectiveOptions = (conditionalConfig.optionSource?.enabled && dynamicOptions.length > 0 - ? dynamicOptions - : conditionalConfig.options || []).filter(opt => opt.value && opt.value.trim() !== ""); - + const effectiveOptions = ( + conditionalConfig.optionSource?.enabled && dynamicOptions.length > 0 + ? dynamicOptions + : conditionalConfig.options || [] + ).filter((opt) => opt.value && opt.value.trim() !== ""); + // 로딩 중이면 로딩 표시 if (dynamicOptionsLoading) { return (
-
+
-
+
조건 옵션을 불러오는 중...
); } - + return (
{/* 조건 선택 UI */} @@ -1525,7 +1574,7 @@ export function TableSectionRenderer({ {effectiveOptions.map((option) => (
- + {selectedConditions.length > 0 && ( -
+
{selectedConditions.length}개 유형 선택됨, 총 {totalConditionalItems}개 항목
)}
)} - + {triggerType === "dropdown" && (
유형 선택: @@ -1566,7 +1615,7 @@ export function TableSectionRenderer({ {effectiveOptions.map((option) => ( {option.label} - {conditionalTableData[option.value]?.length > 0 && + {conditionalTableData[option.value]?.length > 0 && ` (${conditionalTableData[option.value].length})`} ))} @@ -1574,7 +1623,7 @@ export function TableSectionRenderer({
)} - + {/* 선택된 조건들의 테이블 (탭 형태) */} {selectedConditions.length > 0 && ( @@ -1594,17 +1643,17 @@ export function TableSectionRenderer({ ); })} - + {selectedConditions.map((conditionValue) => { const data = conditionalTableData[conditionValue] || []; const selected = conditionalSelectedRows[conditionValue] || new Set(); - + return ( {/* 테이블 상단 컨트롤 */}
- + {data.length > 0 && `${data.length}개 항목`} {selected.size > 0 && ` (${selected.size}개 선택됨)`} @@ -1642,20 +1691,25 @@ export function TableSectionRenderer({ 선택 삭제 ({selected.size}) )} - + {searchButtonText} + + )} + {showAddRowButton && ( + + )}
- + {/* 테이블 */} )} - + {/* tabs 모드: 모든 옵션을 탭으로 표시 (선택 UI 없음) */} {triggerType === "tabs" && effectiveOptions.length > 0 && ( - @@ -1702,16 +1756,16 @@ export function TableSectionRenderer({ ); })} - + {effectiveOptions.map((option) => { const data = conditionalTableData[option.value] || []; const selected = conditionalSelectedRows[option.value] || new Set(); - + return (
- + {data.length > 0 && `${data.length}개 항목`} {selected.size > 0 && ` (${selected.size}개 선택됨)`} @@ -1728,20 +1782,25 @@ export function TableSectionRenderer({ 선택 삭제 ({selected.size}) )} - + {searchButtonText} + + )} + {showAddRowButton && ( + + )}
- + )} - + {/* 조건이 선택되지 않은 경우 안내 메시지 (checkbox/dropdown 모드에서만) */} {selectedConditions.length === 0 && triggerType !== "tabs" && (
-

- {triggerType === "checkbox" - ? "위에서 유형을 선택하여 검사항목을 추가하세요." - : "유형을 선택하세요."} +

+ {triggerType === "checkbox" ? "위에서 유형을 선택하여 검사항목을 추가하세요." : "유형을 선택하세요."}

)} - + {/* 옵션이 없는 경우 안내 메시지 */} {effectiveOptions.length === 0 && (
-

- 조건 옵션이 설정되지 않았습니다. -

+

조건 옵션이 설정되지 않았습니다.

)} - + {/* 항목 선택 모달 (조건부 테이블용) */} {/* 추가 버튼 영역 */} -
+
- + {tableData.length > 0 && `${tableData.length}개 항목`} {selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`} @@ -1822,17 +1877,17 @@ export function TableSectionRenderer({ variant="outline" size="sm" onClick={() => setWidthTrigger((prev) => prev + 1)} - className="h-7 text-xs px-2" + className="h-7 px-2 text-xs" title={widthTrigger % 2 === 0 ? "내용에 맞게 자동 조정" : "균등 분배"} > {widthTrigger % 2 === 0 ? ( <> - + 자동 맞춤 ) : ( <> - + 균등 분배 )} @@ -1841,17 +1896,20 @@ export function TableSectionRenderer({
{selectedRows.size > 0 && ( - )} - + )} + {showAddRowButton && ( + + }} + className="h-8 text-xs sm:h-10 sm:text-sm" + > + + {addRowButtonText} + + )}
diff --git a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx index ebd16c44..d82db59b 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx @@ -2928,54 +2928,74 @@ export function TableSectionSettingsModal({ {/* UI 설정 */}

UI 설정

-
-
- - + + {/* 버튼 표시 설정 */} +
+ +

+ 두 버튼을 동시에 표시할 수 있습니다. +

+
+
+ updateUiConfig({ showSearchButton: checked })} + className="scale-75" + /> +
+ 검색 버튼 +

기존 데이터에서 선택

+
+
+
+ updateUiConfig({ showAddRowButton: checked })} + className="scale-75" + /> +
+ 행 추가 버튼 +

빈 행 직접 입력

+
+
+
+ +
+ {/* 검색 버튼 텍스트 */}
- + updateUiConfig({ addButtonText: e.target.value })} - placeholder={tableConfig.uiConfig?.addButtonType === "addRow" ? "항목 추가" : "항목 검색"} + value={tableConfig.uiConfig?.searchButtonText || ""} + onChange={(e) => updateUiConfig({ searchButtonText: e.target.value })} + placeholder="품목 검색" className="h-8 text-xs mt-1" + disabled={!(tableConfig.uiConfig?.showSearchButton ?? true)} />
+ {/* 행 추가 버튼 텍스트 */}
- + + updateUiConfig({ addRowButtonText: e.target.value })} + placeholder="직접 입력" + className="h-8 text-xs mt-1" + disabled={!tableConfig.uiConfig?.showAddRowButton} + /> +
+ {/* 모달 제목 */} +
+ updateUiConfig({ modalTitle: e.target.value })} placeholder="항목 검색 및 선택" className="h-8 text-xs mt-1" - disabled={tableConfig.uiConfig?.addButtonType === "addRow"} + disabled={!(tableConfig.uiConfig?.showSearchButton ?? true)} /> - {tableConfig.uiConfig?.addButtonType === "addRow" && ( -

빈 행 추가 모드에서는 모달이 열리지 않습니다

- )}
+ {/* 테이블 최대 높이 */}
+ {/* 다중 선택 허용 */}
diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index 1f2015eb..a07feed6 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -253,15 +253,19 @@ export interface TableSectionConfig { // 6. UI 설정 uiConfig?: { - addButtonText?: string; // 추가 버튼 텍스트 (기본: "품목 검색") modalTitle?: string; // 모달 제목 (기본: "항목 검색 및 선택") multiSelect?: boolean; // 다중 선택 허용 (기본: true) maxHeight?: string; // 테이블 최대 높이 (기본: "400px") - // 추가 버튼 타입 - // - search: 검색 모달 열기 (기본값) - 기존 데이터에서 선택 - // - addRow: 빈 행 직접 추가 - 새 데이터 직접 입력 + // 버튼 표시 설정 (동시 표시 가능) + showSearchButton?: boolean; // 검색 버튼 표시 (기본: true) + showAddRowButton?: boolean; // 행 추가 버튼 표시 (기본: false) + searchButtonText?: string; // 검색 버튼 텍스트 (기본: "품목 검색") + addRowButtonText?: string; // 행 추가 버튼 텍스트 (기본: "직접 입력") + + // 레거시 호환용 (deprecated) addButtonType?: "search" | "addRow"; + addButtonText?: string; }; // 7. 조건부 테이블 설정 (고급) From b45f4870e88efa75364435fce4fe2a8e60e0d6d6 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 30 Dec 2025 14:03:29 +0900 Subject: [PATCH 4/7] =?UTF-8?q?=EC=8B=A0=EA=B7=9C=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../button-primary/ButtonPrimaryComponent.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 1f067865..ade1c5cc 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -832,7 +832,14 @@ export const ButtonPrimaryComponent: React.FC = ({ } // modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터) - if ((!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) && effectiveTableName) { + // 단, 모달(modal) 액션은 신규 등록이므로 modalDataStore 데이터를 가져오지 않음 + // (다른 화면에서 선택한 데이터가 남아있을 수 있으므로) + const shouldFetchFromModalDataStore = + processedConfig.action.type !== "modal" && + (!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) && + effectiveTableName; + + if (shouldFetchFromModalDataStore) { try { const { useModalDataStore } = await import("@/stores/modalDataStore"); const dataRegistry = useModalDataStore.getState().dataRegistry; @@ -860,9 +867,10 @@ export const ButtonPrimaryComponent: React.FC = ({ return; } - // 모달 액션인데 선택된 데이터가 있으면 경고 메시지 표시하고 중단 + // 모달 액션인데 현재 화면(테이블)에서 직접 선택된 데이터가 있으면 경고 메시지 표시하고 중단 // (신규 등록 모달에서 선택된 데이터가 초기값으로 전달되는 것을 방지) - if (processedConfig.action.type === "modal" && effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) { + // 주의: selectedRowsData만 체크 (modalDataStore의 데이터는 이미 제외했으므로) + if (processedConfig.action.type === "modal" && selectedRowsData && selectedRowsData.length > 0) { toast.warning("신규 등록 시에는 테이블에서 선택된 항목을 해제해주세요."); return; } From c78326bae1bbfe1e08d1a79b600aec08f6835493 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 30 Dec 2025 14:11:42 +0900 Subject: [PATCH 5/7] =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EB=B9=84=ED=99=9C?= =?UTF-8?q?=EC=84=B1=ED=99=94=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config-panels/ButtonConfigPanel.tsx | 73 ++++++++++++++ .../button-primary/ButtonPrimaryComponent.tsx | 99 +++++++++++++++++-- 2 files changed, 165 insertions(+), 7 deletions(-) diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 3a126c29..417ea4ff 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -3081,6 +3081,79 @@ export const ButtonConfigPanel: React.FC = ({ /> )} + {/* 🆕 행 선택 시에만 활성화 설정 */} +
+

행 선택 활성화 조건

+

+ 테이블 리스트나 분할 패널에서 데이터가 선택되었을 때만 버튼을 활성화합니다. +

+ +
+
+ +

+ 체크하면 테이블에서 행을 선택해야만 버튼이 활성화됩니다. +

+
+ { + onUpdateProperty("componentConfig.action.requireRowSelection", checked); + }} + /> +
+ + {component.componentConfig?.action?.requireRowSelection && ( +
+
+ + +

+ 자동 감지: 테이블, 분할 패널, 플로우 위젯 중 선택된 항목이 있으면 활성화 +

+
+ +
+
+ +

+ 여러 행이 선택되어도 활성화 (기본: 1개 이상 선택 시 활성화) +

+
+ { + onUpdateProperty("componentConfig.action.allowMultiRowSelection", checked); + }} + /> +
+ + {!(component.componentConfig?.action?.allowMultiRowSelection ?? true) && ( +
+

+ 정확히 1개의 행만 선택되어야 버튼이 활성화됩니다. +

+
+ )} +
+ )} +
+ {/* 제어 기능 섹션 */}
diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index ade1c5cc..8530d8e1 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -296,6 +296,84 @@ export const ButtonPrimaryComponent: React.FC = ({ return false; }, [component.componentConfig?.action, formData, vehicleStatus, statusLoading, component.label]); + // 🆕 행 선택 기반 비활성화 조건 계산 + const isRowSelectionDisabled = useMemo(() => { + const actionConfig = component.componentConfig?.action; + + // requireRowSelection이 활성화되어 있지 않으면 비활성화하지 않음 + if (!actionConfig?.requireRowSelection) { + return false; + } + + const rowSelectionSource = actionConfig.rowSelectionSource || "auto"; + const allowMultiRowSelection = actionConfig.allowMultiRowSelection ?? true; + + // 선택된 데이터 확인 + let hasSelection = false; + let selectionCount = 0; + + // 1. 자동 감지 모드 또는 특정 소스 모드 + if (rowSelectionSource === "auto" || rowSelectionSource === "tableList") { + // TableList에서 선택된 행 확인 (props로 전달됨) + if (selectedRowsData && selectedRowsData.length > 0) { + hasSelection = true; + selectionCount = selectedRowsData.length; + } + // 또는 selectedRows prop 확인 + else if (selectedRows && selectedRows.length > 0) { + hasSelection = true; + selectionCount = selectedRows.length; + } + } + + if (rowSelectionSource === "auto" || rowSelectionSource === "splitPanelLeft") { + // 분할 패널 좌측 선택 데이터 확인 + if (!hasSelection && splitPanelContext?.selectedLeftData) { + hasSelection = true; + selectionCount = 1; + } + } + + if (rowSelectionSource === "auto" || rowSelectionSource === "flowWidget") { + // 플로우 위젯 선택 데이터 확인 + if (!hasSelection && flowSelectedData && flowSelectedData.length > 0) { + hasSelection = true; + selectionCount = flowSelectedData.length; + } + } + + // 선택된 데이터가 없으면 비활성화 + if (!hasSelection) { + console.log("🚫 [ButtonPrimary] 행 선택 필요 → 비활성화:", component.label, { + rowSelectionSource, + hasSelection, + }); + return true; + } + + // 다중 선택 허용하지 않는 경우, 정확히 1개만 선택되어야 함 + if (!allowMultiRowSelection && selectionCount !== 1) { + console.log("🚫 [ButtonPrimary] 정확히 1개 행 선택 필요 → 비활성화:", component.label, { + selectionCount, + allowMultiRowSelection, + }); + return true; + } + + console.log("✅ [ButtonPrimary] 행 선택 조건 충족:", component.label, { + selectionCount, + rowSelectionSource, + }); + return false; + }, [ + component.componentConfig?.action, + component.label, + selectedRows, + selectedRowsData, + splitPanelContext?.selectedLeftData, + flowSelectedData, + ]); + // 확인 다이얼로그 상태 const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [pendingAction, setPendingAction] = useState<{ @@ -1096,17 +1174,26 @@ export const ButtonPrimaryComponent: React.FC = ({ } } - // 🆕 최종 비활성화 상태 (설정 + 조건부 비활성화) - const finalDisabled = componentConfig.disabled || isOperationButtonDisabled || statusLoading; + // 🆕 최종 비활성화 상태 (설정 + 조건부 비활성화 + 행 선택 필수) + const finalDisabled = componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading; // 공통 버튼 스타일 + // 🔧 component.style에서 background/backgroundColor 충돌 방지 + const userStyle = component.style + ? Object.fromEntries( + Object.entries(component.style).filter( + ([key]) => !["width", "height", "background", "backgroundColor"].includes(key) + ) + ) + : {}; + const buttonElementStyle: React.CSSProperties = { width: "100%", height: "100%", minHeight: "40px", border: "none", borderRadius: "0.5rem", - background: finalDisabled ? "#e5e7eb" : buttonColor, + backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor, // 🔧 background → backgroundColor로 변경 color: finalDisabled ? "#9ca3af" : "white", // 🔧 크기 설정 적용 (sm/md/lg) fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem", @@ -1122,10 +1209,8 @@ export const ButtonPrimaryComponent: React.FC = ({ margin: "0", lineHeight: "1.25", boxShadow: finalDisabled ? "none" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)", - // 디자인 모드와 인터랙티브 모드 모두에서 사용자 스타일 적용 (width/height 제외) - ...(component.style - ? Object.fromEntries(Object.entries(component.style).filter(([key]) => key !== "width" && key !== "height")) - : {}), + // 디자인 모드와 인터랙티브 모드 모두에서 사용자 스타일 적용 (width/height/background 제외) + ...userStyle, }; const buttonContent = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼"; From 84a3956b02f2f7b4a715b803c764ca041b811458 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 30 Dec 2025 14:13:26 +0900 Subject: [PATCH 6/7] =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../button-primary/ButtonPrimaryComponent.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 8530d8e1..d79d926f 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -945,13 +945,10 @@ export const ButtonPrimaryComponent: React.FC = ({ return; } - // 모달 액션인데 현재 화면(테이블)에서 직접 선택된 데이터가 있으면 경고 메시지 표시하고 중단 - // (신규 등록 모달에서 선택된 데이터가 초기값으로 전달되는 것을 방지) - // 주의: selectedRowsData만 체크 (modalDataStore의 데이터는 이미 제외했으므로) - if (processedConfig.action.type === "modal" && selectedRowsData && selectedRowsData.length > 0) { - toast.warning("신규 등록 시에는 테이블에서 선택된 항목을 해제해주세요."); - return; - } + // 🔧 모달 액션 시 선택 데이터 경고 제거 + // 이전에는 "신규 등록 시에는 테이블에서 선택된 항목을 해제해주세요" 경고를 표시했으나, + // 다른 화면에서 선택한 데이터가 남아있는 경우 오탐이 발생하여 제거함. + // 모달 화면 내부에서 필요 시 자체적으로 선택 데이터를 무시하도록 처리하면 됨. // 수정(edit) 액션 검증 if (processedConfig.action.type === "edit") { From fb82d2f5a1d10bc7c0fa0c661c5ee687f564e58f Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 30 Dec 2025 14:19:15 +0900 Subject: [PATCH 7/7] =?UTF-8?q?=EB=B6=84=ED=95=A0=ED=8C=A8=EB=84=90?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B2=84=ED=8A=BC=20=EB=B9=84=ED=99=9C?= =?UTF-8?q?=EC=84=B1=ED=99=94=EA=B8=B0=EB=8A=A5=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../button-primary/ButtonPrimaryComponent.tsx | 81 ++++++++++++++++--- .../SplitPanelLayoutComponent.tsx | 20 ++--- 2 files changed, 81 insertions(+), 20 deletions(-) diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index d79d926f..f311c035 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -296,6 +296,32 @@ export const ButtonPrimaryComponent: React.FC = ({ return false; }, [component.componentConfig?.action, formData, vehicleStatus, statusLoading, component.label]); + // 🆕 modalDataStore에서 선택된 데이터 확인 (분할 패널 등에서 저장됨) + const [modalStoreData, setModalStoreData] = useState>({}); + + // modalDataStore 상태 구독 (실시간 업데이트) + useEffect(() => { + const actionConfig = component.componentConfig?.action; + if (!actionConfig?.requireRowSelection) return; + + // 동적 import로 modalDataStore 구독 + let unsubscribe: (() => void) | undefined; + + import("@/stores/modalDataStore").then(({ useModalDataStore }) => { + // 초기값 설정 + setModalStoreData(useModalDataStore.getState().dataRegistry); + + // 상태 변경 구독 + unsubscribe = useModalDataStore.subscribe((state) => { + setModalStoreData(state.dataRegistry); + }); + }); + + return () => { + unsubscribe?.(); + }; + }, [component.componentConfig?.action?.requireRowSelection]); + // 🆕 행 선택 기반 비활성화 조건 계산 const isRowSelectionDisabled = useMemo(() => { const actionConfig = component.componentConfig?.action; @@ -311,43 +337,76 @@ export const ButtonPrimaryComponent: React.FC = ({ // 선택된 데이터 확인 let hasSelection = false; let selectionCount = 0; + let selectionSource = ""; - // 1. 자동 감지 모드 또는 특정 소스 모드 + // 1. 자동 감지 모드 또는 테이블 리스트 모드 if (rowSelectionSource === "auto" || rowSelectionSource === "tableList") { // TableList에서 선택된 행 확인 (props로 전달됨) if (selectedRowsData && selectedRowsData.length > 0) { hasSelection = true; selectionCount = selectedRowsData.length; + selectionSource = "tableList (selectedRowsData)"; } // 또는 selectedRows prop 확인 else if (selectedRows && selectedRows.length > 0) { hasSelection = true; selectionCount = selectedRows.length; + selectionSource = "tableList (selectedRows)"; } } + // 2. 분할 패널 좌측 선택 데이터 확인 if (rowSelectionSource === "auto" || rowSelectionSource === "splitPanelLeft") { - // 분할 패널 좌측 선택 데이터 확인 - if (!hasSelection && splitPanelContext?.selectedLeftData) { - hasSelection = true; - selectionCount = 1; + // SplitPanelContext에서 확인 + if (splitPanelContext?.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0) { + if (!hasSelection) { + hasSelection = true; + selectionCount = 1; + selectionSource = "splitPanelLeft (context)"; + } + } + + // 🆕 modalDataStore에서도 확인 (SplitPanelLayoutComponent에서 저장) + if (!hasSelection && Object.keys(modalStoreData).length > 0) { + // modalDataStore에서 데이터가 있는지 확인 + for (const [sourceId, items] of Object.entries(modalStoreData)) { + if (items && items.length > 0) { + hasSelection = true; + selectionCount = items.length; + selectionSource = `modalDataStore (${sourceId})`; + break; + } + } } } + // 3. 플로우 위젯 선택 데이터 확인 if (rowSelectionSource === "auto" || rowSelectionSource === "flowWidget") { // 플로우 위젯 선택 데이터 확인 if (!hasSelection && flowSelectedData && flowSelectedData.length > 0) { hasSelection = true; selectionCount = flowSelectedData.length; + selectionSource = "flowWidget"; } } + // 디버깅 로그 + console.log("🔍 [ButtonPrimary] 행 선택 체크:", component.label, { + rowSelectionSource, + hasSelection, + selectionCount, + selectionSource, + hasSplitPanelContext: !!splitPanelContext, + selectedLeftData: splitPanelContext?.selectedLeftData, + selectedRowsData: selectedRowsData?.length, + selectedRows: selectedRows?.length, + flowSelectedData: flowSelectedData?.length, + modalStoreDataKeys: Object.keys(modalStoreData), + }); + // 선택된 데이터가 없으면 비활성화 if (!hasSelection) { - console.log("🚫 [ButtonPrimary] 행 선택 필요 → 비활성화:", component.label, { - rowSelectionSource, - hasSelection, - }); + console.log("🚫 [ButtonPrimary] 행 선택 필요 → 비활성화:", component.label); return true; } @@ -362,7 +421,7 @@ export const ButtonPrimaryComponent: React.FC = ({ console.log("✅ [ButtonPrimary] 행 선택 조건 충족:", component.label, { selectionCount, - rowSelectionSource, + selectionSource, }); return false; }, [ @@ -372,6 +431,8 @@ export const ButtonPrimaryComponent: React.FC = ({ selectedRowsData, splitPanelContext?.selectedLeftData, flowSelectedData, + splitPanelContext, + modalStoreData, ]); // 확인 다이얼로그 상태 diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index ef91a23d..bfb26c90 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -2030,14 +2030,14 @@ export const SplitPanelLayoutComponent: React.FC className="border-border flex flex-shrink-0 flex-col border-r" > -
@@ -2521,14 +2521,14 @@ export const SplitPanelLayoutComponent: React.FC className="flex flex-shrink-0 flex-col" > -