From f2bee41336e6558647839191774d4df2132f0b0d Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 6 Feb 2026 10:20:45 +0900 Subject: [PATCH] Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feature/v2-unified-renewal --- backend-node/src/routes/commonCodeRoutes.ts | 16 - .../src/services/categoryTreeService.ts | 1 + .../src/services/numberingRuleService.ts | 421 +++++++++++------- .../src/services/tableManagementService.ts | 1 + .../screen/InteractiveScreenViewer.tsx | 16 +- .../components/screen/LayerConditionPanel.tsx | 371 +++++++++++++++ .../components/screen/LayerManagerPanel.tsx | 270 +++++++---- .../components/v2-file-upload/types.ts | 16 +- 8 files changed, 820 insertions(+), 292 deletions(-) create mode 100644 frontend/components/screen/LayerConditionPanel.tsx diff --git a/backend-node/src/routes/commonCodeRoutes.ts b/backend-node/src/routes/commonCodeRoutes.ts index 3885d12a..d1205e51 100644 --- a/backend-node/src/routes/commonCodeRoutes.ts +++ b/backend-node/src/routes/commonCodeRoutes.ts @@ -73,20 +73,4 @@ router.get("/categories/:categoryCode/options", (req, res) => commonCodeController.getCodeOptions(req, res) ); -// 계층 구조 코드 조회 (트리 형태) -router.get("/categories/:categoryCode/hierarchy", (req, res) => - commonCodeController.getCodesHierarchy(req, res) -); - -// 자식 코드 조회 (연쇄 선택용) -router.get("/categories/:categoryCode/children", (req, res) => - commonCodeController.getChildCodes(req, res) -); - -// 카테고리 → 공통코드 호환 API (레거시 지원) -// 기존 카테고리 타입이 공통코드로 마이그레이션된 후에도 동작 -router.get("/category-options/:tableName/:columnName", (req, res) => - commonCodeController.getCategoryOptionsAsCode(req, res) -); - export default router; diff --git a/backend-node/src/services/categoryTreeService.ts b/backend-node/src/services/categoryTreeService.ts index 9296eed9..1550a780 100644 --- a/backend-node/src/services/categoryTreeService.ts +++ b/backend-node/src/services/categoryTreeService.ts @@ -43,6 +43,7 @@ export interface CreateCategoryValueInput { icon?: string; isActive?: boolean; isDefault?: boolean; + targetCompanyCode?: string; // 최고 관리자가 특정 회사를 선택할 때 사용 } // 카테고리 값 수정 입력 diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index 4749bde5..4f5bf1e9 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -47,11 +47,11 @@ class NumberingRuleService { logger.info("채번 규칙 목록 조회 시작", { companyCode }); const pool = getPool(); - + // 멀티테넌시: 최고 관리자만 company_code="*" 데이터를 볼 수 있음 let query: string; let params: any[]; - + if (companyCode === "*") { // 최고 관리자: 모든 회사 데이터 조회 가능 query = ` @@ -107,7 +107,7 @@ class NumberingRuleService { for (const rule of result.rows) { let partsQuery: string; let partsParams: any[]; - + if (companyCode === "*") { // 최고 관리자: 모든 파트 조회 partsQuery = ` @@ -156,7 +156,7 @@ class NumberingRuleService { /** * 현재 메뉴에서 사용 가능한 규칙 목록 조회 (메뉴 스코프) - * + * * 메뉴 스코프 규칙: * - menuObjid가 제공되면 형제 메뉴의 채번 규칙 포함 * - 우선순위: menu (형제 메뉴) > table > global @@ -166,7 +166,7 @@ class NumberingRuleService { menuObjid?: number ): Promise { let menuAndChildObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언 - + try { logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작 (메뉴 스코프)", { companyCode, @@ -178,14 +178,17 @@ class NumberingRuleService { // 1. 선택한 메뉴와 하위 메뉴 OBJID 조회 (형제 메뉴 제외) if (menuObjid) { menuAndChildObjids = await getMenuAndChildObjids(menuObjid); - logger.info("선택한 메뉴 및 하위 메뉴 OBJID 목록", { menuObjid, menuAndChildObjids }); + logger.info("선택한 메뉴 및 하위 메뉴 OBJID 목록", { + menuObjid, + menuAndChildObjids, + }); } // menuObjid가 없으면 global 규칙만 반환 if (!menuObjid || menuAndChildObjids.length === 0) { let query: string; let params: any[]; - + if (companyCode === "*") { // 최고 관리자: 모든 global 규칙 조회 query = ` @@ -239,7 +242,7 @@ class NumberingRuleService { for (const rule of result.rows) { let partsQuery: string; let partsParams: any[]; - + if (companyCode === "*") { partsQuery = ` SELECT @@ -281,7 +284,7 @@ class NumberingRuleService { // 우선순위: menu (형제 메뉴) > table > global let query: string; let params: any[]; - + if (companyCode === "*") { // 최고 관리자: 모든 규칙 조회 query = ` @@ -333,7 +336,7 @@ class NumberingRuleService { logger.info("🔍 채번 규칙 쿼리 실행", { queryPreview: query.substring(0, 200), - paramsTypes: params.map(p => typeof p), + paramsTypes: params.map((p) => typeof p), paramsValues: params, }); @@ -346,7 +349,7 @@ class NumberingRuleService { try { let partsQuery: string; let partsParams: any[]; - + if (companyCode === "*") { partsQuery = ` SELECT @@ -379,7 +382,7 @@ class NumberingRuleService { const partsResult = await pool.query(partsQuery, partsParams); rule.parts = partsResult.rows; - + logger.info("✅ 규칙 파트 조회 성공", { ruleId: rule.ruleId, ruleName: rule.ruleName, @@ -537,11 +540,11 @@ class NumberingRuleService { companyCode: string ): Promise { const pool = getPool(); - + // 멀티테넌시: 최고 관리자만 company_code="*" 데이터를 볼 수 있음 let query: string; let params: any[]; - + if (companyCode === "*") { // 최고 관리자: 모든 규칙 조회 가능 query = ` @@ -598,7 +601,7 @@ class NumberingRuleService { // 파트 정보 조회 let partsQuery: string; let partsParams: any[]; - + if (companyCode === "*") { partsQuery = ` SELECT @@ -836,12 +839,12 @@ class NumberingRuleService { return { ...ruleResult.rows[0], parts }; } catch (error: any) { await client.query("ROLLBACK"); - logger.error("채번 규칙 수정 실패", { + logger.error("채번 규칙 수정 실패", { ruleId, companyCode, error: error.message, stack: error.stack, - updates + updates, }); throw error; } finally { @@ -875,7 +878,7 @@ class NumberingRuleService { * @param formData 폼 데이터 (카테고리 기반 채번 시 사용) */ async previewCode( - ruleId: string, + ruleId: string, companyCode: string, formData?: Record ): Promise { @@ -911,21 +914,26 @@ class NumberingRuleService { case "date": { // 날짜 (다양한 날짜 형식) const dateFormat = autoConfig.dateFormat || "YYYYMMDD"; - + // 컬럼 기준 생성인 경우 폼 데이터에서 날짜 추출 - if (autoConfig.useColumnValue && autoConfig.sourceColumnName && formData) { + if ( + autoConfig.useColumnValue && + autoConfig.sourceColumnName && + formData + ) { const columnValue = formData[autoConfig.sourceColumnName]; if (columnValue) { - const dateValue = columnValue instanceof Date - ? columnValue - : new Date(columnValue); - + const dateValue = + columnValue instanceof Date + ? columnValue + : new Date(columnValue); + if (!isNaN(dateValue.getTime())) { return this.formatDate(dateValue, dateFormat); } } } - + return this.formatDate(new Date(), dateFormat); } @@ -938,63 +946,68 @@ class NumberingRuleService { // 카테고리 기반 코드 생성 const categoryKey = autoConfig.categoryKey; // 예: "item_info.material" const categoryMappings = autoConfig.categoryMappings || []; - + if (!categoryKey || !formData) { - logger.warn("카테고리 키 또는 폼 데이터 없음", { categoryKey, hasFormData: !!formData }); + logger.warn("카테고리 키 또는 폼 데이터 없음", { + categoryKey, + hasFormData: !!formData, + }); return ""; } - + // categoryKey에서 컬럼명 추출 (예: "item_info.material" -> "material") - const columnName = categoryKey.includes(".") - ? categoryKey.split(".")[1] + const columnName = categoryKey.includes(".") + ? categoryKey.split(".")[1] : categoryKey; - + // 폼 데이터에서 해당 컬럼의 값 가져오기 const selectedValue = formData[columnName]; - - logger.info("카테고리 파트 처리", { - categoryKey, - columnName, + + logger.info("카테고리 파트 처리", { + categoryKey, + columnName, selectedValue, formDataKeys: Object.keys(formData), - mappingsCount: categoryMappings.length + mappingsCount: categoryMappings.length, }); - + if (!selectedValue) { - logger.warn("카테고리 값이 선택되지 않음", { columnName, formDataKeys: Object.keys(formData) }); + logger.warn("카테고리 값이 선택되지 않음", { + columnName, + formDataKeys: Object.keys(formData), + }); return ""; } - + // 카테고리 매핑에서 해당 값에 대한 형식 찾기 // selectedValue는 valueCode일 수 있음 (V2Select에서 valueCode를 value로 사용) const selectedValueStr = String(selectedValue); - const mapping = categoryMappings.find( - (m: any) => { - // ID로 매칭 - if (m.categoryValueId?.toString() === selectedValueStr) return true; - // 라벨로 매칭 - if (m.categoryValueLabel === selectedValueStr) return true; - // valueCode로 매칭 (라벨과 동일할 수 있음) - if (m.categoryValueLabel === selectedValueStr) return true; - return false; - } - ); - + const mapping = categoryMappings.find((m: any) => { + // ID로 매칭 + if (m.categoryValueId?.toString() === selectedValueStr) + return true; + // 라벨로 매칭 + if (m.categoryValueLabel === selectedValueStr) return true; + // valueCode로 매칭 (라벨과 동일할 수 있음) + if (m.categoryValueLabel === selectedValueStr) return true; + return false; + }); + if (mapping) { - logger.info("카테고리 매핑 적용", { - selectedValue, + logger.info("카테고리 매핑 적용", { + selectedValue, format: mapping.format, - categoryValueLabel: mapping.categoryValueLabel + categoryValueLabel: mapping.categoryValueLabel, }); return mapping.format || ""; } - - logger.warn("카테고리 매핑을 찾을 수 없음", { - selectedValue, - availableMappings: categoryMappings.map((m: any) => ({ - id: m.categoryValueId, - label: m.categoryValueLabel - })) + + logger.warn("카테고리 매핑을 찾을 수 없음", { + selectedValue, + availableMappings: categoryMappings.map((m: any) => ({ + id: m.categoryValueId, + label: m.categoryValueLabel, + })), }); return ""; } @@ -1006,7 +1019,12 @@ class NumberingRuleService { }); const previewCode = parts.join(rule.separator || ""); - logger.info("코드 미리보기 생성", { ruleId, previewCode, companyCode, hasFormData: !!formData }); + logger.info("코드 미리보기 생성", { + ruleId, + previewCode, + companyCode, + hasFormData: !!formData, + }); return previewCode; } @@ -1018,8 +1036,8 @@ class NumberingRuleService { * @param userInputCode 사용자가 편집한 최종 코드 (수동 입력 부분 추출용) */ async allocateCode( - ruleId: string, - companyCode: string, + ruleId: string, + companyCode: string, formData?: Record, userInputCode?: string ): Promise { @@ -1033,9 +1051,11 @@ class NumberingRuleService { if (!rule) throw new Error("규칙을 찾을 수 없습니다"); // 수동 입력 파트가 있고, 사용자가 입력한 코드가 있으면 수동 입력 부분 추출 - const manualParts = rule.parts.filter((p: any) => p.generationMethod === "manual"); + const manualParts = rule.parts.filter( + (p: any) => p.generationMethod === "manual" + ); let extractedManualValues: string[] = []; - + if (manualParts.length > 0 && userInputCode) { // 프리뷰 코드를 생성해서 ____ 위치 파악 // 🔧 category 파트도 처리하여 올바른 템플릿 생성 @@ -1059,39 +1079,38 @@ class NumberingRuleService { // 카테고리 파트: formData에서 실제 값을 가져와서 매핑된 형식 사용 const categoryKey = autoConfig.categoryKey; const categoryMappings = autoConfig.categoryMappings || []; - + if (!categoryKey || !formData) { return "CATEGORY"; // 폴백 } - - const columnName = categoryKey.includes(".") - ? categoryKey.split(".")[1] + + const columnName = categoryKey.includes(".") + ? categoryKey.split(".")[1] : categoryKey; const selectedValue = formData[columnName]; - + if (!selectedValue) { return "CATEGORY"; // 폴백 } - + const selectedValueStr = String(selectedValue); - const mapping = categoryMappings.find( - (m: any) => { - if (m.categoryValueId?.toString() === selectedValueStr) return true; - if (m.categoryValueLabel === selectedValueStr) return true; - return false; - } - ); - + const mapping = categoryMappings.find((m: any) => { + if (m.categoryValueId?.toString() === selectedValueStr) + return true; + if (m.categoryValueLabel === selectedValueStr) return true; + return false; + }); + return mapping?.format || "CATEGORY"; } default: return ""; } }); - + const separator = rule.separator || ""; const previewTemplate = previewParts.join(separator); - + // 사용자 입력 코드에서 수동 입력 부분 추출 // 예: 템플릿 "R-____-XXX", 사용자입력 "R-MYVALUE-012" → "MYVALUE" 추출 const templateParts = previewTemplate.split("____"); @@ -1100,19 +1119,23 @@ class NumberingRuleService { for (let i = 0; i < templateParts.length - 1; i++) { const prefix = templateParts[i]; const suffix = templateParts[i + 1]; - + // prefix 이후 부분 추출 if (prefix && remainingCode.startsWith(prefix)) { remainingCode = remainingCode.slice(prefix.length); } - + // suffix 이전까지가 수동 입력 값 if (suffix) { // suffix에서 순번(XXX)이나 날짜 부분을 제외한 실제 구분자 찾기 const suffixStart = suffix.replace(/X+|DATEPART/g, ""); - const manualEndIndex = suffixStart ? remainingCode.indexOf(suffixStart) : remainingCode.length; + const manualEndIndex = suffixStart + ? remainingCode.indexOf(suffixStart) + : remainingCode.length; if (manualEndIndex > 0) { - extractedManualValues.push(remainingCode.slice(0, manualEndIndex)); + extractedManualValues.push( + remainingCode.slice(0, manualEndIndex) + ); remainingCode = remainingCode.slice(manualEndIndex); } } else { @@ -1120,8 +1143,10 @@ class NumberingRuleService { } } } - - logger.info(`수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(extractedManualValues)}`); + + logger.info( + `수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(extractedManualValues)}` + ); } let manualPartIndex = 0; @@ -1130,7 +1155,10 @@ class NumberingRuleService { .map((part: any) => { if (part.generationMethod === "manual") { // 추출된 수동 입력 값 사용, 없으면 기본값 사용 - const manualValue = extractedManualValues[manualPartIndex] || part.manualConfig?.value || ""; + const manualValue = + extractedManualValues[manualPartIndex] || + part.manualConfig?.value || + ""; manualPartIndex++; return manualValue; } @@ -1155,16 +1183,21 @@ class NumberingRuleService { case "date": { // 날짜 (다양한 날짜 형식) const dateFormat = autoConfig.dateFormat || "YYYYMMDD"; - + // 컬럼 기준 생성인 경우 폼 데이터에서 날짜 추출 - if (autoConfig.useColumnValue && autoConfig.sourceColumnName && formData) { + if ( + autoConfig.useColumnValue && + autoConfig.sourceColumnName && + formData + ) { const columnValue = formData[autoConfig.sourceColumnName]; if (columnValue) { // 날짜 문자열 또는 Date 객체를 Date로 변환 - const dateValue = columnValue instanceof Date - ? columnValue - : new Date(columnValue); - + const dateValue = + columnValue instanceof Date + ? columnValue + : new Date(columnValue); + if (!isNaN(dateValue.getTime())) { logger.info("컬럼 기준 날짜 생성", { sourceColumn: autoConfig.sourceColumnName, @@ -1185,7 +1218,7 @@ class NumberingRuleService { }); } } - + // 기본: 현재 날짜 사용 return this.formatDate(new Date(), dateFormat); } @@ -1199,60 +1232,65 @@ class NumberingRuleService { // 카테고리 기반 코드 생성 (allocateCode용) const categoryKey = autoConfig.categoryKey; // 예: "item_info.material" const categoryMappings = autoConfig.categoryMappings || []; - + if (!categoryKey || !formData) { - logger.warn("allocateCode: 카테고리 키 또는 폼 데이터 없음", { categoryKey, hasFormData: !!formData }); + logger.warn("allocateCode: 카테고리 키 또는 폼 데이터 없음", { + categoryKey, + hasFormData: !!formData, + }); return ""; } - + // categoryKey에서 컬럼명 추출 (예: "item_info.material" -> "material") - const columnName = categoryKey.includes(".") - ? categoryKey.split(".")[1] + const columnName = categoryKey.includes(".") + ? categoryKey.split(".")[1] : categoryKey; - + // 폼 데이터에서 해당 컬럼의 값 가져오기 const selectedValue = formData[columnName]; - - logger.info("allocateCode: 카테고리 파트 처리", { - categoryKey, - columnName, + + logger.info("allocateCode: 카테고리 파트 처리", { + categoryKey, + columnName, selectedValue, formDataKeys: Object.keys(formData), - mappingsCount: categoryMappings.length + mappingsCount: categoryMappings.length, }); - + if (!selectedValue) { - logger.warn("allocateCode: 카테고리 값이 선택되지 않음", { columnName, formDataKeys: Object.keys(formData) }); + logger.warn("allocateCode: 카테고리 값이 선택되지 않음", { + columnName, + formDataKeys: Object.keys(formData), + }); return ""; } - + // 카테고리 매핑에서 해당 값에 대한 형식 찾기 const selectedValueStr = String(selectedValue); - const mapping = categoryMappings.find( - (m: any) => { - // ID로 매칭 - if (m.categoryValueId?.toString() === selectedValueStr) return true; - // 라벨로 매칭 - if (m.categoryValueLabel === selectedValueStr) return true; - return false; - } - ); - + const mapping = categoryMappings.find((m: any) => { + // ID로 매칭 + if (m.categoryValueId?.toString() === selectedValueStr) + return true; + // 라벨로 매칭 + if (m.categoryValueLabel === selectedValueStr) return true; + return false; + }); + if (mapping) { - logger.info("allocateCode: 카테고리 매핑 적용", { - selectedValue, + logger.info("allocateCode: 카테고리 매핑 적용", { + selectedValue, format: mapping.format, - categoryValueLabel: mapping.categoryValueLabel + categoryValueLabel: mapping.categoryValueLabel, }); return mapping.format || ""; } - - logger.warn("allocateCode: 카테고리 매핑을 찾을 수 없음", { - selectedValue, - availableMappings: categoryMappings.map((m: any) => ({ - id: m.categoryValueId, - label: m.categoryValueLabel - })) + + logger.warn("allocateCode: 카테고리 매핑을 찾을 수 없음", { + selectedValue, + availableMappings: categoryMappings.map((m: any) => ({ + id: m.categoryValueId, + label: m.categoryValueLabel, + })), }); return ""; } @@ -1344,14 +1382,17 @@ class NumberingRuleService { menuObjid?: number ): Promise { try { - logger.info("[테스트] 채번 규칙 목록 조회 시작", { companyCode, menuObjid }); + logger.info("[테스트] 채번 규칙 목록 조회 시작", { + companyCode, + menuObjid, + }); const pool = getPool(); - + // 멀티테넌시: 최고 관리자 vs 일반 회사 let query: string; let params: any[]; - + if (companyCode === "*") { // 최고 관리자: 모든 규칙 조회 query = ` @@ -1508,7 +1549,10 @@ class NumberingRuleService { WHERE rule_id = $1 AND company_code = $2 ORDER BY part_order `; - const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]); + const partsResult = await pool.query(partsQuery, [ + rule.ruleId, + companyCode, + ]); rule.parts = partsResult.rows; logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", { @@ -1556,7 +1600,10 @@ class NumberingRuleService { SELECT rule_id FROM numbering_rules WHERE rule_id = $1 AND company_code = $2 `; - const existingResult = await client.query(existingQuery, [config.ruleId, companyCode]); + const existingResult = await client.query(existingQuery, [ + config.ruleId, + companyCode, + ]); if (existingResult.rows.length > 0) { // 업데이트 @@ -1671,7 +1718,10 @@ class NumberingRuleService { try { await client.query("BEGIN"); - logger.info("테스트 테이블에서 채번 규칙 삭제 시작", { ruleId, companyCode }); + logger.info("테스트 테이블에서 채번 규칙 삭제 시작", { + ruleId, + companyCode, + }); // 파트 먼저 삭제 await client.query( @@ -1779,7 +1829,10 @@ class NumberingRuleService { WHERE rule_id = $1 AND company_code = $2 ORDER BY part_order `; - const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]); + const partsResult = await pool.query(partsQuery, [ + rule.ruleId, + companyCode, + ]); rule.parts = partsResult.rows; logger.info("카테고리 조건 매칭 채번 규칙 찾음", { @@ -1814,7 +1867,11 @@ class NumberingRuleService { AND r.category_value_id IS NULL LIMIT 1 `; - const defaultResult = await pool.query(defaultQuery, [companyCode, tableName, columnName]); + const defaultResult = await pool.query(defaultQuery, [ + companyCode, + tableName, + columnName, + ]); if (defaultResult.rows.length > 0) { const rule = defaultResult.rows[0]; @@ -1831,7 +1888,10 @@ class NumberingRuleService { WHERE rule_id = $1 AND company_code = $2 ORDER BY part_order `; - const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]); + const partsResult = await pool.query(partsQuery, [ + rule.ruleId, + companyCode, + ]); rule.parts = partsResult.rows; logger.info("기본 채번 규칙 찾음 (카테고리 조건 없음)", { @@ -1891,8 +1951,12 @@ class NumberingRuleService { AND r.column_name = $3 ORDER BY r.category_value_id NULLS FIRST, r.created_at `; - const result = await pool.query(query, [companyCode, tableName, columnName]); - + const result = await pool.query(query, [ + companyCode, + tableName, + columnName, + ]); + // 각 규칙의 파트 정보 조회 for (const rule of result.rows) { const partsQuery = ` @@ -1907,7 +1971,10 @@ class NumberingRuleService { WHERE rule_id = $1 AND company_code = $2 ORDER BY part_order `; - const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]); + const partsResult = await pool.query(partsQuery, [ + rule.ruleId, + companyCode, + ]); rule.parts = partsResult.rows; } @@ -1928,11 +1995,21 @@ class NumberingRuleService { async copyRulesForCompany( sourceCompanyCode: string, targetCompanyCode: string - ): Promise<{ copiedCount: number; skippedCount: number; details: string[]; ruleIdMap: Record }> { + ): Promise<{ + copiedCount: number; + skippedCount: number; + details: string[]; + ruleIdMap: Record; + }> { const pool = getPool(); const client = await pool.connect(); - - const result = { copiedCount: 0, skippedCount: 0, details: [] as string[], ruleIdMap: {} as Record }; + + const result = { + copiedCount: 0, + skippedCount: 0, + details: [] as string[], + ruleIdMap: {} as Record, + }; try { await client.query("BEGIN"); @@ -1950,9 +2027,9 @@ class NumberingRuleService { [targetCompanyCode] ); if (deleteResult.rowCount && deleteResult.rowCount > 0) { - logger.info("기존 채번규칙 삭제", { - targetCompanyCode, - deletedCount: deleteResult.rowCount + logger.info("기존 채번규칙 삭제", { + targetCompanyCode, + deletedCount: deleteResult.rowCount, }); } @@ -1962,9 +2039,9 @@ class NumberingRuleService { [sourceCompanyCode] ); - logger.info("원본 채번규칙 조회", { - sourceCompanyCode, - count: sourceRulesResult.rowCount + logger.info("원본 채번규칙 조회", { + sourceCompanyCode, + count: sourceRulesResult.rowCount, }); // 2. 각 채번규칙 복제 @@ -2038,18 +2115,18 @@ class NumberingRuleService { result.ruleIdMap[rule.rule_id] = newRuleId; result.copiedCount++; result.details.push(`복제 완료: ${rule.rule_name}`); - logger.info("채번규칙 복제 완료", { - ruleName: rule.rule_name, + logger.info("채번규칙 복제 완료", { + ruleName: rule.rule_name, oldRuleId: rule.rule_id, - newRuleId + newRuleId, }); } // 3. 화면 레이아웃의 numberingRuleId 참조 업데이트 if (Object.keys(result.ruleIdMap).length > 0) { - logger.info("화면 레이아웃 numberingRuleId 참조 업데이트 시작", { + logger.info("화면 레이아웃 numberingRuleId 참조 업데이트 시작", { targetCompanyCode, - mappingCount: Object.keys(result.ruleIdMap).length + mappingCount: Object.keys(result.ruleIdMap).length, }); // 대상 회사의 모든 화면 레이아웃 조회 @@ -2069,9 +2146,13 @@ class NumberingRuleService { let updated = false; // 각 매핑에 대해 치환 - for (const [oldRuleId, newRuleId] of Object.entries(result.ruleIdMap)) { + for (const [oldRuleId, newRuleId] of Object.entries( + result.ruleIdMap + )) { if (propsStr.includes(`"${oldRuleId}"`)) { - propsStr = propsStr.split(`"${oldRuleId}"`).join(`"${newRuleId}"`); + propsStr = propsStr + .split(`"${oldRuleId}"`) + .join(`"${newRuleId}"`); updated = true; } } @@ -2085,27 +2166,33 @@ class NumberingRuleService { } } - logger.info("화면 레이아웃 numberingRuleId 참조 업데이트 완료", { + logger.info("화면 레이아웃 numberingRuleId 참조 업데이트 완료", { targetCompanyCode, - updatedLayouts + updatedLayouts, }); - result.details.push(`화면 레이아웃 ${updatedLayouts}개의 채번규칙 참조 업데이트`); + result.details.push( + `화면 레이아웃 ${updatedLayouts}개의 채번규칙 참조 업데이트` + ); } await client.query("COMMIT"); - - logger.info("회사별 채번규칙 복제 완료", { - sourceCompanyCode, - targetCompanyCode, + + logger.info("회사별 채번규칙 복제 완료", { + sourceCompanyCode, + targetCompanyCode, copiedCount: result.copiedCount, skippedCount: result.skippedCount, - ruleIdMapCount: Object.keys(result.ruleIdMap).length + ruleIdMapCount: Object.keys(result.ruleIdMap).length, }); return result; } catch (error) { await client.query("ROLLBACK"); - logger.error("회사별 채번규칙 복제 실패", { error, sourceCompanyCode, targetCompanyCode }); + logger.error("회사별 채번규칙 복제 실패", { + error, + sourceCompanyCode, + targetCompanyCode, + }); throw error; } finally { client.release(); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 2d4aa581..5fe2f242 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -3869,6 +3869,7 @@ export class TableManagementService { columnName: string; displayName: string; dataType: string; + inputType?: string; }> > { return await entityJoinService.getReferenceTableColumns(tableName); diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 4a693867..6b9a092b 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -249,8 +249,18 @@ export const InteractiveScreenViewer: React.FC = ( layers.forEach((layer) => { if (layer.type === "conditional" && layer.condition) { const { targetComponentId, operator, value } = layer.condition; - // 컴포넌트 ID를 키로 데이터 조회 - columnName 매핑이 필요할 수 있음 - const targetValue = finalFormData[targetComponentId]; + + // 1. 컴포넌트 ID로 대상 컴포넌트 찾기 + const targetComponent = allComponents.find((c) => c.id === targetComponentId); + + // 2. 컴포넌트의 columnName으로 formData에서 값 조회 + // columnName이 없으면 컴포넌트 ID로 폴백 + const fieldKey = + (targetComponent as any)?.columnName || + (targetComponent as any)?.componentConfig?.columnName || + targetComponentId; + + const targetValue = finalFormData[fieldKey]; let isMatch = false; switch (operator) { @@ -272,7 +282,7 @@ export const InteractiveScreenViewer: React.FC = ( } } }); - }, [finalFormData, layers, handleLayerAction]); + }, [finalFormData, layers, allComponents, handleLayerAction]); // 개선된 검증 시스템 (선택적 활성화) const enhancedValidation = enableEnhancedValidation && screenInfo && tableColumns.length > 0 diff --git a/frontend/components/screen/LayerConditionPanel.tsx b/frontend/components/screen/LayerConditionPanel.tsx new file mode 100644 index 00000000..4304aa55 --- /dev/null +++ b/frontend/components/screen/LayerConditionPanel.tsx @@ -0,0 +1,371 @@ +import React, { useState, useEffect, useMemo, useCallback } from "react"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Loader2, AlertCircle, Check, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { ComponentData, LayerCondition, LayerDefinition } from "@/types/screen-management"; +import { getCodesByCategory, CodeItem } from "@/lib/api/codeManagement"; + +interface LayerConditionPanelProps { + layer: LayerDefinition; + components: ComponentData[]; // 화면의 모든 컴포넌트 + onUpdateCondition: (condition: LayerCondition | undefined) => void; + onClose?: () => void; +} + +// 조건 연산자 옵션 +const OPERATORS = [ + { value: "eq", label: "같음 (=)" }, + { value: "neq", label: "같지 않음 (≠)" }, + { value: "in", label: "포함 (in)" }, +] as const; + +type OperatorType = "eq" | "neq" | "in"; + +export const LayerConditionPanel: React.FC = ({ + layer, + components, + onUpdateCondition, + onClose, +}) => { + // 조건 설정 상태 + const [targetComponentId, setTargetComponentId] = useState( + layer.condition?.targetComponentId || "" + ); + const [operator, setOperator] = useState( + (layer.condition?.operator as OperatorType) || "eq" + ); + const [value, setValue] = useState( + layer.condition?.value?.toString() || "" + ); + const [multiValues, setMultiValues] = useState( + Array.isArray(layer.condition?.value) ? layer.condition.value : [] + ); + + // 코드 목록 로딩 상태 + const [codeOptions, setCodeOptions] = useState([]); + const [isLoadingCodes, setIsLoadingCodes] = useState(false); + const [codeLoadError, setCodeLoadError] = useState(null); + + // 트리거 가능한 컴포넌트 필터링 (셀렉트, 라디오, 코드 타입 등) + const triggerableComponents = useMemo(() => { + return components.filter((comp) => { + const componentType = (comp.componentType || "").toLowerCase(); + const widgetType = ((comp as any).widgetType || "").toLowerCase(); + const webType = ((comp as any).webType || "").toLowerCase(); + const inputType = ((comp as any).componentConfig?.inputType || "").toLowerCase(); + + // 셀렉트, 라디오, 코드 타입 컴포넌트만 허용 + const triggerTypes = ["select", "radio", "code", "checkbox", "toggle"]; + const isTriggerType = triggerTypes.some((type) => + componentType.includes(type) || + widgetType.includes(type) || + webType.includes(type) || + inputType.includes(type) + ); + + return isTriggerType; + }); + }, [components]); + + // 선택된 컴포넌트 정보 + const selectedComponent = useMemo(() => { + return components.find((c) => c.id === targetComponentId); + }, [components, targetComponentId]); + + // 선택된 컴포넌트의 코드 카테고리 + const codeCategory = useMemo(() => { + if (!selectedComponent) return null; + + // codeCategory 확인 (다양한 위치에 있을 수 있음) + const category = + (selectedComponent as any).codeCategory || + (selectedComponent as any).componentConfig?.codeCategory || + (selectedComponent as any).webTypeConfig?.codeCategory; + + return category || null; + }, [selectedComponent]); + + // 컴포넌트 선택 시 코드 목록 로드 + useEffect(() => { + if (!codeCategory) { + setCodeOptions([]); + return; + } + + const loadCodes = async () => { + setIsLoadingCodes(true); + setCodeLoadError(null); + + try { + const codes = await getCodesByCategory(codeCategory); + setCodeOptions(codes); + } catch (error: any) { + console.error("코드 목록 로드 실패:", error); + setCodeLoadError(error.message || "코드 목록을 불러올 수 없습니다."); + setCodeOptions([]); + } finally { + setIsLoadingCodes(false); + } + }; + + loadCodes(); + }, [codeCategory]); + + // 조건 저장 + const handleSave = useCallback(() => { + if (!targetComponentId) { + return; + } + + const condition: LayerCondition = { + targetComponentId, + operator, + value: operator === "in" ? multiValues : value, + }; + + onUpdateCondition(condition); + onClose?.(); + }, [targetComponentId, operator, value, multiValues, onUpdateCondition, onClose]); + + // 조건 삭제 + const handleClear = useCallback(() => { + onUpdateCondition(undefined); + setTargetComponentId(""); + setOperator("eq"); + setValue(""); + setMultiValues([]); + onClose?.(); + }, [onUpdateCondition, onClose]); + + // in 연산자용 다중 값 토글 + const toggleMultiValue = useCallback((val: string) => { + setMultiValues((prev) => + prev.includes(val) + ? prev.filter((v) => v !== val) + : [...prev, val] + ); + }, []); + + // 컴포넌트 라벨 가져오기 + const getComponentLabel = (comp: ComponentData) => { + return comp.label || (comp as any).columnName || comp.id; + }; + + return ( +
+
+

조건부 표시 설정

+ {layer.condition && ( + + 설정됨 + + )} +
+ + {/* 트리거 컴포넌트 선택 */} +
+ + + + {/* 코드 카테고리 표시 */} + {codeCategory && ( +
+ 카테고리: + + {codeCategory} + +
+ )} +
+ + {/* 연산자 선택 */} + {targetComponentId && ( +
+ + +
+ )} + + {/* 조건 값 선택 */} + {targetComponentId && ( +
+ + + {isLoadingCodes ? ( +
+ + 코드 목록 로딩 중... +
+ ) : codeLoadError ? ( +
+ + {codeLoadError} +
+ ) : codeOptions.length > 0 ? ( + // 코드 카테고리가 있는 경우 - 선택 UI + operator === "in" ? ( + // 다중 선택 (in 연산자) +
+ {codeOptions.map((code) => ( +
toggleMultiValue(code.codeValue)} + > +
+ {multiValues.includes(code.codeValue) && ( + + )} +
+ {code.codeName} + ({code.codeValue}) +
+ ))} +
+ ) : ( + // 단일 선택 (eq, neq 연산자) + + ) + ) : ( + // 코드 카테고리가 없는 경우 - 직접 입력 + setValue(e.target.value)} + placeholder="조건 값 입력..." + className="h-8 text-xs" + /> + )} + + {/* 선택된 값 표시 (in 연산자) */} + {operator === "in" && multiValues.length > 0 && ( +
+ {multiValues.map((val) => { + const code = codeOptions.find((c) => c.codeValue === val); + return ( + + {code?.codeName || val} + toggleMultiValue(val)} + /> + + ); + })} +
+ )} +
+ )} + + {/* 현재 조건 요약 */} + {targetComponentId && (value || multiValues.length > 0) && ( +
+ 요약: + + "{getComponentLabel(selectedComponent!)}" 값이{" "} + {operator === "eq" && `"${codeOptions.find(c => c.codeValue === value)?.codeName || value}"와 같으면`} + {operator === "neq" && `"${codeOptions.find(c => c.codeValue === value)?.codeName || value}"와 다르면`} + {operator === "in" && `[${multiValues.map(v => codeOptions.find(c => c.codeValue === v)?.codeName || v).join(", ")}] 중 하나이면`} + {" "}이 레이어 표시 + +
+ )} + + {/* 버튼 */} +
+ + +
+
+ ); +}; diff --git a/frontend/components/screen/LayerManagerPanel.tsx b/frontend/components/screen/LayerManagerPanel.tsx index cd482602..05fb36f3 100644 --- a/frontend/components/screen/LayerManagerPanel.tsx +++ b/frontend/components/screen/LayerManagerPanel.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from "react"; +import React, { useState, useMemo, useCallback } from "react"; import { useLayer } from "@/contexts/LayerContext"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -10,6 +10,11 @@ import { DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; import { Badge } from "@/components/ui/badge"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; import { Eye, EyeOff, @@ -22,10 +27,13 @@ import { SplitSquareVertical, PanelRight, ChevronDown, + ChevronRight, Settings2, + Zap, } from "lucide-react"; import { cn } from "@/lib/utils"; -import { LayerType, LayerDefinition, ComponentData } from "@/types/screen-management"; +import { LayerType, LayerDefinition, ComponentData, LayerCondition } from "@/types/screen-management"; +import { LayerConditionPanel } from "./LayerConditionPanel"; // 레이어 타입별 아이콘 const getLayerTypeIcon = (type: LayerType) => { @@ -78,137 +86,196 @@ function getLayerTypeColor(type: LayerType): string { interface LayerItemProps { layer: LayerDefinition; isActive: boolean; - componentCount: number; // 🆕 실제 컴포넌트 수 (layout.components 기반) + componentCount: number; // 실제 컴포넌트 수 (layout.components 기반) + allComponents: ComponentData[]; // 조건 설정에 필요한 전체 컴포넌트 onSelect: () => void; onToggleVisibility: () => void; onToggleLock: () => void; onRemove: () => void; onUpdateName: (name: string) => void; + onUpdateCondition: (condition: LayerCondition | undefined) => void; } const LayerItem: React.FC = ({ layer, isActive, componentCount, + allComponents, onSelect, onToggleVisibility, onToggleLock, onRemove, onUpdateName, + onUpdateCondition, }) => { const [isEditing, setIsEditing] = useState(false); + const [isConditionOpen, setIsConditionOpen] = useState(false); + + // 조건부 레이어인지 확인 + const isConditionalLayer = layer.type === "conditional"; + // 조건 설정 여부 + const hasCondition = !!layer.condition; return ( -
- {/* 드래그 핸들 */} - +
+ {/* 레이어 메인 영역 */} +
+ {/* 드래그 핸들 */} + - {/* 레이어 정보 */} -
-
- {/* 레이어 타입 아이콘 */} - - {getLayerTypeIcon(layer.type)} - + {/* 레이어 정보 */} +
+
+ {/* 레이어 타입 아이콘 */} + + {getLayerTypeIcon(layer.type)} + + + {/* 레이어 이름 */} + {isEditing ? ( + onUpdateName(e.target.value)} + onBlur={() => setIsEditing(false)} + onKeyDown={(e) => { + if (e.key === "Enter") setIsEditing(false); + }} + className="flex-1 bg-transparent outline-none border-b border-primary text-sm" + autoFocus + onClick={(e) => e.stopPropagation()} + /> + ) : ( + { + e.stopPropagation(); + setIsEditing(true); + }} + > + {layer.name} + + )} +
- {/* 레이어 이름 */} - {isEditing ? ( - onUpdateName(e.target.value)} - onBlur={() => setIsEditing(false)} - onKeyDown={(e) => { - if (e.key === "Enter") setIsEditing(false); - }} - className="flex-1 bg-transparent outline-none border-b border-primary text-sm" - autoFocus - onClick={(e) => e.stopPropagation()} - /> - ) : ( - { + {/* 레이어 메타 정보 */} +
+ + {getLayerTypeLabel(layer.type)} + + + {componentCount}개 컴포넌트 + + {/* 조건 설정됨 표시 */} + {hasCondition && ( + + + 조건 + + )} +
+
+ + {/* 액션 버튼들 */} +
+ {/* 조건부 레이어일 때 조건 설정 버튼 */} + {isConditionalLayer && ( + )} -
- - {/* 레이어 메타 정보 */} -
- - {getLayerTypeLabel(layer.type)} - - - {componentCount}개 컴포넌트 - -
-
- - {/* 액션 버튼들 */} -
- - - - - {layer.type !== "base" && ( + - )} + + + + {layer.type !== "base" && ( + + )} +
+ + {/* 조건 설정 패널 (조건부 레이어만) */} + {isConditionalLayer && isConditionOpen && ( +
+ setIsConditionOpen(false)} + /> +
+ )}
); }; @@ -229,6 +296,11 @@ export const LayerManagerPanel: React.FC = ({ components updateLayer, } = useLayer(); + // 레이어 조건 업데이트 핸들러 + const handleUpdateCondition = useCallback((layerId: string, condition: LayerCondition | undefined) => { + updateLayer(layerId, { condition }); + }, [updateLayer]); + // 🆕 각 레이어별 컴포넌트 수 계산 (layout.components 기반) const componentCountByLayer = useMemo(() => { const counts: Record = {}; @@ -311,11 +383,13 @@ export const LayerManagerPanel: React.FC = ({ components layer={layer} isActive={activeLayerId === layer.id} componentCount={componentCountByLayer[layer.id] || 0} + allComponents={components} onSelect={() => setActiveLayerId(layer.id)} onToggleVisibility={() => toggleLayerVisibility(layer.id)} onToggleLock={() => toggleLayerLock(layer.id)} onRemove={() => removeLayer(layer.id)} onUpdateName={(name) => updateLayer(layer.id, { name })} + onUpdateCondition={(condition) => handleUpdateCondition(layer.id, condition)} /> )) )} diff --git a/frontend/lib/registry/components/v2-file-upload/types.ts b/frontend/lib/registry/components/v2-file-upload/types.ts index 9147b88d..4625f7e0 100644 --- a/frontend/lib/registry/components/v2-file-upload/types.ts +++ b/frontend/lib/registry/components/v2-file-upload/types.ts @@ -30,7 +30,7 @@ export interface FileInfo { type?: string; // docType과 동일 uploadedAt?: string; // regdate와 동일 _file?: File; // 로컬 파일 객체 (업로드 전) - + // 대표 이미지 설정 isRepresentative?: boolean; // 대표 이미지로 설정 여부 } @@ -45,24 +45,24 @@ export interface FileUploadConfig extends ComponentConfig { accept?: string; maxSize?: number; // bytes maxFiles?: number; // 최대 파일 수 - + // 공통 설정 disabled?: boolean; required?: boolean; readonly?: boolean; helperText?: string; - + // 스타일 관련 variant?: "default" | "outlined" | "filled"; size?: "sm" | "md" | "lg"; - + // V2 추가 설정 showPreview?: boolean; // 미리보기 표시 여부 showFileList?: boolean; // 파일 목록 표시 여부 showFileSize?: boolean; // 파일 크기 표시 여부 allowDelete?: boolean; // 삭제 허용 여부 allowDownload?: boolean; // 다운로드 허용 여부 - + // 이벤트 관련 onChange?: (value: any) => void; onFocus?: () => void; @@ -83,10 +83,10 @@ export interface FileUploadProps { config?: FileUploadConfig; className?: string; style?: React.CSSProperties; - + // 파일 관련 uploadedFiles?: FileInfo[]; - + // 이벤트 핸들러 onChange?: (value: any) => void; onFocus?: () => void; @@ -100,7 +100,7 @@ export interface FileUploadProps { /** * 파일 업로드 상태 타입 */ -export type FileUploadStatus = 'idle' | 'uploading' | 'success' | 'error'; +export type FileUploadStatus = "idle" | "uploading" | "success" | "error"; /** * 파일 업로드 응답 타입