diff --git a/.cursor/rules/multilang-component-guide.mdc b/.cursor/rules/multilang-component-guide.mdc new file mode 100644 index 00000000..60bdc0ec --- /dev/null +++ b/.cursor/rules/multilang-component-guide.mdc @@ -0,0 +1,559 @@ +# 다국어 지원 컴포넌트 개발 가이드 + +새로운 화면 컴포넌트를 개발할 때 반드시 다국어 시스템을 고려해야 합니다. +이 가이드는 컴포넌트가 다국어 자동 생성 및 매핑 시스템과 호환되도록 하는 방법을 설명합니다. + +--- + +## 1. 타입 정의 시 다국어 필드 추가 + +### 기본 원칙 + +텍스트가 표시되는 **모든 속성**에 `langKeyId`와 `langKey` 필드를 함께 정의해야 합니다. + +### 단일 텍스트 속성 + +```typescript +interface MyComponentConfig { + // 기본 텍스트 + title?: string; + // 다국어 키 (필수 추가) + titleLangKeyId?: number; + titleLangKey?: string; + + // 라벨 + label?: string; + labelLangKeyId?: number; + labelLangKey?: string; + + // 플레이스홀더 + placeholder?: string; + placeholderLangKeyId?: number; + placeholderLangKey?: string; +} +``` + +### 배열/목록 속성 (컬럼, 탭 등) + +```typescript +interface ColumnConfig { + name: string; + label: string; + // 다국어 키 (필수 추가) + langKeyId?: number; + langKey?: string; + // 기타 속성 + width?: number; + align?: "left" | "center" | "right"; +} + +interface TabConfig { + id: string; + label: string; + // 다국어 키 (필수 추가) + langKeyId?: number; + langKey?: string; + // 탭 제목도 별도로 + title?: string; + titleLangKeyId?: number; + titleLangKey?: string; +} + +interface MyComponentConfig { + columns?: ColumnConfig[]; + tabs?: TabConfig[]; +} +``` + +### 버튼 컴포넌트 + +```typescript +interface ButtonComponentConfig { + text?: string; + // 다국어 키 (필수 추가) + langKeyId?: number; + langKey?: string; +} +``` + +### 실제 예시: 분할 패널 + +```typescript +interface SplitPanelLayoutConfig { + leftPanel?: { + title?: string; + langKeyId?: number; // 좌측 패널 제목 다국어 + langKey?: string; + columns?: Array<{ + name: string; + label: string; + langKeyId?: number; // 각 컬럼 다국어 + langKey?: string; + }>; + }; + rightPanel?: { + title?: string; + langKeyId?: number; // 우측 패널 제목 다국어 + langKey?: string; + columns?: Array<{ + name: string; + label: string; + langKeyId?: number; + langKey?: string; + }>; + additionalTabs?: Array<{ + label: string; + langKeyId?: number; // 탭 라벨 다국어 + langKey?: string; + title?: string; + titleLangKeyId?: number; // 탭 제목 다국어 + titleLangKey?: string; + columns?: Array<{ + name: string; + label: string; + langKeyId?: number; + langKey?: string; + }>; + }>; + }; +} +``` + +--- + +## 2. 라벨 추출 로직 등록 + +### 파일 위치 + +`frontend/lib/utils/multilangLabelExtractor.ts` + +### `extractMultilangLabels` 함수에 추가 + +새 컴포넌트의 라벨을 추출하는 로직을 추가해야 합니다. + +```typescript +// 새 컴포넌트 타입 체크 +if (comp.componentType === "my-new-component") { + const config = comp.componentConfig as MyComponentConfig; + + // 1. 제목 추출 + if (config?.title) { + addLabel({ + id: `${comp.id}_title`, + componentId: `${comp.id}_title`, + label: config.title, + type: "title", + parentType: "my-new-component", + parentLabel: config.title, + langKeyId: config.titleLangKeyId, + langKey: config.titleLangKey, + }); + } + + // 2. 컬럼 추출 + if (config?.columns && Array.isArray(config.columns)) { + config.columns.forEach((col, index) => { + const colLabel = col.label || col.name; + addLabel({ + id: `${comp.id}_col_${index}`, + componentId: `${comp.id}_col_${index}`, + label: colLabel, + type: "column", + parentType: "my-new-component", + parentLabel: config.title || "새 컴포넌트", + langKeyId: col.langKeyId, + langKey: col.langKey, + }); + }); + } + + // 3. 버튼 텍스트 추출 (버튼 컴포넌트인 경우) + if (config?.text) { + addLabel({ + id: `${comp.id}_button`, + componentId: `${comp.id}_button`, + label: config.text, + type: "button", + parentType: "my-new-component", + parentLabel: config.text, + langKeyId: config.langKeyId, + langKey: config.langKey, + }); + } +} +``` + +### 추출해야 할 라벨 타입 + +| 타입 | 설명 | 예시 | +| ------------- | ------------------ | ------------------------ | +| `title` | 컴포넌트/패널 제목 | 분할패널 제목, 카드 제목 | +| `label` | 입력 필드 라벨 | 텍스트 입력 라벨 | +| `button` | 버튼 텍스트 | 저장, 취소, 삭제 | +| `column` | 테이블 컬럼 헤더 | 품목명, 수량, 금액 | +| `tab` | 탭 라벨 | 기본정보, 상세정보 | +| `filter` | 검색 필터 라벨 | 검색어, 기간 | +| `placeholder` | 플레이스홀더 | "검색어를 입력하세요" | +| `action` | 액션 버튼/링크 | 수정, 삭제, 상세보기 | + +--- + +## 3. 매핑 적용 로직 등록 + +### 파일 위치 + +`frontend/lib/utils/multilangLabelExtractor.ts` + +### `applyMultilangMappings` 함수에 추가 + +다국어 키가 선택되면 컴포넌트에 `langKeyId`와 `langKey`를 저장하는 로직을 추가합니다. + +```typescript +// 새 컴포넌트 매핑 적용 +if (comp.componentType === "my-new-component") { + const config = comp.componentConfig as MyComponentConfig; + + // 1. 제목 매핑 + const titleMapping = mappingMap.get(`${comp.id}_title`); + if (titleMapping) { + updated.componentConfig = { + ...updated.componentConfig, + titleLangKeyId: titleMapping.keyId, + titleLangKey: titleMapping.langKey, + }; + } + + // 2. 컬럼 매핑 + if (config?.columns && Array.isArray(config.columns)) { + const updatedColumns = config.columns.map((col, index) => { + const colMapping = mappingMap.get(`${comp.id}_col_${index}`); + if (colMapping) { + return { + ...col, + langKeyId: colMapping.keyId, + langKey: colMapping.langKey, + }; + } + return col; + }); + updated.componentConfig = { + ...updated.componentConfig, + columns: updatedColumns, + }; + } + + // 3. 버튼 매핑 (버튼 컴포넌트인 경우) + const buttonMapping = mappingMap.get(`${comp.id}_button`); + if (buttonMapping) { + updated.componentConfig = { + ...updated.componentConfig, + langKeyId: buttonMapping.keyId, + langKey: buttonMapping.langKey, + }; + } +} +``` + +### 주의사항 + +- **객체 참조 유지**: 매핑 시 기존 `updated.componentConfig`를 기반으로 업데이트해야 합니다. +- **중첩 구조**: 중첩된 객체(예: `leftPanel.columns`)는 상위 객체부터 순서대로 업데이트합니다. + +```typescript +// 잘못된 방법 - 이전 업데이트 덮어쓰기 +updated.componentConfig = { ...config, langKeyId: mapping.keyId }; // ❌ +updated.componentConfig = { ...config, columns: updatedColumns }; // langKeyId 사라짐! + +// 올바른 방법 - 이전 업데이트 유지 +updated.componentConfig = { + ...updated.componentConfig, + langKeyId: mapping.keyId, +}; // ✅ +updated.componentConfig = { + ...updated.componentConfig, + columns: updatedColumns, +}; // ✅ +``` + +--- + +## 4. 번역 표시 로직 구현 + +### 파일 위치 + +새 컴포넌트 파일 (예: `frontend/lib/registry/components/my-component/MyComponent.tsx`) + +### Context 사용 + +```typescript +import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext"; + +const MyComponent = ({ component }: Props) => { + const { getTranslatedText } = useScreenMultiLang(); + const config = component.componentConfig; + + // 제목 번역 + const displayTitle = config?.titleLangKey + ? getTranslatedText(config.titleLangKey, config.title || "") + : config?.title || ""; + + // 컬럼 헤더 번역 + const translatedColumns = config?.columns?.map((col) => ({ + ...col, + displayLabel: col.langKey + ? getTranslatedText(col.langKey, col.label) + : col.label, + })); + + // 버튼 텍스트 번역 + const buttonText = config?.langKey + ? getTranslatedText(config.langKey, config.text || "") + : config?.text || ""; + + return ( +
+

{displayTitle}

+ + + + {translatedColumns?.map((col, idx) => ( + + ))} + + +
{col.displayLabel}
+ +
+ ); +}; +``` + +### getTranslatedText 함수 + +```typescript +// 첫 번째 인자: langKey (다국어 키) +// 두 번째 인자: fallback (키가 없거나 번역이 없을 때 기본값) +const text = getTranslatedText( + "screen.company_1.Sales.OrderList.품목명", + "품목명" +); +``` + +### 주의사항 + +- `langKey`가 없으면 원본 텍스트를 표시합니다. +- `useScreenMultiLang`은 반드시 `ScreenMultiLangProvider` 내부에서 사용해야 합니다. +- 화면 페이지(`/screens/[screenId]/page.tsx`)에서 이미 Provider로 감싸져 있습니다. + +--- + +## 5. ScreenMultiLangContext에 키 수집 로직 추가 + +### 파일 위치 + +`frontend/contexts/ScreenMultiLangContext.tsx` + +### `collectLangKeys` 함수에 추가 + +번역을 미리 로드하기 위해 컴포넌트에서 사용하는 모든 `langKey`를 수집해야 합니다. + +```typescript +const collectLangKeys = (comps: ComponentData[]): Set => { + const keys = new Set(); + + const processComponent = (comp: ComponentData) => { + const config = comp.componentConfig; + + // 새 컴포넌트의 langKey 수집 + if (comp.componentType === "my-new-component") { + // 제목 + if (config?.titleLangKey) { + keys.add(config.titleLangKey); + } + + // 컬럼 + if (config?.columns && Array.isArray(config.columns)) { + config.columns.forEach((col: any) => { + if (col.langKey) { + keys.add(col.langKey); + } + }); + } + + // 버튼 + if (config?.langKey) { + keys.add(config.langKey); + } + } + + // 자식 컴포넌트 재귀 처리 + if (comp.children && Array.isArray(comp.children)) { + comp.children.forEach(processComponent); + } + }; + + comps.forEach(processComponent); + return keys; +}; +``` + +--- + +## 6. MultilangSettingsModal에 표시 로직 추가 + +### 파일 위치 + +`frontend/components/screen/modals/MultilangSettingsModal.tsx` + +### `extractLabelsFromComponents` 함수에 추가 + +다국어 설정 모달에서 새 컴포넌트의 라벨이 표시되도록 합니다. + +```typescript +// 새 컴포넌트 라벨 추출 +if (comp.componentType === "my-new-component") { + const config = comp.componentConfig as MyComponentConfig; + + // 제목 + if (config?.title) { + addLabel({ + id: `${comp.id}_title`, + componentId: `${comp.id}_title`, + label: config.title, + type: "title", + parentType: "my-new-component", + parentLabel: config.title, + langKeyId: config.titleLangKeyId, + langKey: config.titleLangKey, + }); + } + + // 컬럼 + if (config?.columns) { + config.columns.forEach((col, index) => { + // columnLabelMap에서 라벨 가져오기 (테이블 컬럼인 경우) + const tableName = config.tableName; + const displayLabel = + tableName && columnLabelMap[tableName]?.[col.name] + ? columnLabelMap[tableName][col.name] + : col.label || col.name; + + addLabel({ + id: `${comp.id}_col_${index}`, + componentId: `${comp.id}_col_${index}`, + label: displayLabel, + type: "column", + parentType: "my-new-component", + parentLabel: config.title || "새 컴포넌트", + langKeyId: col.langKeyId, + langKey: col.langKey, + }); + }); + } +} +``` + +--- + +## 7. 테이블명 추출 (테이블 사용 컴포넌트인 경우) + +### 파일 위치 + +`frontend/lib/utils/multilangLabelExtractor.ts` + +### `extractTableNames` 함수에 추가 + +컴포넌트가 테이블을 사용하는 경우, 테이블명을 추출해야 컬럼 라벨을 가져올 수 있습니다. + +```typescript +const extractTableNames = (comps: ComponentData[]): Set => { + const tableNames = new Set(); + + const processComponent = (comp: ComponentData) => { + const config = comp.componentConfig; + + // 새 컴포넌트의 테이블명 추출 + if (comp.componentType === "my-new-component") { + if (config?.tableName) { + tableNames.add(config.tableName); + } + if (config?.selectedTable) { + tableNames.add(config.selectedTable); + } + } + + // 자식 컴포넌트 재귀 처리 + if (comp.children && Array.isArray(comp.children)) { + comp.children.forEach(processComponent); + } + }; + + comps.forEach(processComponent); + return tableNames; +}; +``` + +--- + +## 8. 체크리스트 + +새 컴포넌트 개발 시 다음 항목을 확인하세요: + +### 타입 정의 + +- [ ] 모든 텍스트 속성에 `langKeyId`, `langKey` 필드 추가 +- [ ] 배열 속성(columns, tabs 등)의 각 항목에도 다국어 필드 추가 + +### 라벨 추출 (multilangLabelExtractor.ts) + +- [ ] `extractMultilangLabels` 함수에 라벨 추출 로직 추가 +- [ ] `extractTableNames` 함수에 테이블명 추출 로직 추가 (해당되는 경우) + +### 매핑 적용 (multilangLabelExtractor.ts) + +- [ ] `applyMultilangMappings` 함수에 매핑 적용 로직 추가 + +### 번역 표시 (컴포넌트 파일) + +- [ ] `useScreenMultiLang` 훅 사용 +- [ ] `getTranslatedText`로 텍스트 번역 적용 + +### 키 수집 (ScreenMultiLangContext.tsx) + +- [ ] `collectLangKeys` 함수에 langKey 수집 로직 추가 + +### 설정 모달 (MultilangSettingsModal.tsx) + +- [ ] `extractLabelsFromComponents`에 라벨 표시 로직 추가 + +--- + +## 9. 관련 파일 목록 + +| 파일 | 역할 | +| -------------------------------------------------------------- | ----------------------- | +| `frontend/lib/utils/multilangLabelExtractor.ts` | 라벨 추출 및 매핑 적용 | +| `frontend/contexts/ScreenMultiLangContext.tsx` | 번역 Context 및 키 수집 | +| `frontend/components/screen/modals/MultilangSettingsModal.tsx` | 다국어 설정 UI | +| `frontend/components/screen/ScreenDesigner.tsx` | 다국어 생성 버튼 처리 | +| `backend-node/src/services/multilangService.ts` | 다국어 키 생성 서비스 | + +--- + +## 10. 주의사항 + +1. **componentId 형식 일관성**: 라벨 추출과 매핑 적용에서 동일한 ID 형식 사용 + + - 제목: `${comp.id}_title` + - 컬럼: `${comp.id}_col_${index}` + - 버튼: `${comp.id}_button` + +2. **중첩 구조 주의**: 분할패널처럼 중첩된 구조는 경로를 명확히 지정 + + - `${comp.id}_left_title`, `${comp.id}_right_col_${index}` + +3. **기존 값 보존**: 매핑 적용 시 `updated.componentConfig`를 기반으로 업데이트 + +4. **라벨 타입 구분**: 입력 폼의 `label`과 다른 컴포넌트의 `label`을 구분하여 처리 + +5. **테스트**: 다국어 생성 → 다국어 설정 → 언어 변경 순서로 테스트 diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 7e1108c3..43b698d2 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -1044,6 +1044,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2371,6 +2372,7 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", + "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -3474,6 +3476,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3710,6 +3713,7 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3927,6 +3931,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4453,6 +4458,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -5663,6 +5669,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -7425,6 +7432,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -8394,7 +8402,6 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", - "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -9283,6 +9290,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -10133,7 +10141,6 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -10942,6 +10949,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -11047,6 +11055,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 80e406b9..3e8f63f1 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -73,6 +73,7 @@ import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합 import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리 import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색 import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달 +import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리 import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리 @@ -197,6 +198,7 @@ app.use("/api/multilang", multilangRoutes); app.use("/api/table-management", tableManagementRoutes); app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능 app.use("/api/screen-management", screenManagementRoutes); +app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리 app.use("/api/common-codes", commonCodeRoutes); app.use("/api/dynamic-form", dynamicFormRoutes); app.use("/api/files", fileRoutes); diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 231a7cdc..ce7b9c7f 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -553,10 +553,24 @@ export const setUserLocale = async ( const { locale } = req.body; - if (!locale || !["ko", "en", "ja", "zh"].includes(locale)) { + if (!locale) { res.status(400).json({ success: false, - message: "유효하지 않은 로케일입니다. (ko, en, ja, zh 중 선택)", + message: "로케일이 필요합니다.", + }); + return; + } + + // language_master 테이블에서 유효한 언어 코드인지 확인 + const validLang = await queryOne<{ lang_code: string }>( + "SELECT lang_code FROM language_master WHERE lang_code = $1 AND is_active = 'Y'", + [locale] + ); + + if (!validLang) { + res.status(400).json({ + success: false, + message: `유효하지 않은 로케일입니다: ${locale}`, }); return; } @@ -1165,6 +1179,33 @@ export async function saveMenu( logger.info("메뉴 저장 성공", { savedMenu }); + // 다국어 메뉴 카테고리 자동 생성 + try { + const { MultiLangService } = await import("../services/multilangService"); + const multilangService = new MultiLangService(); + + // 회사명 조회 + const companyInfo = await queryOne<{ company_name: string }>( + `SELECT company_name FROM company_mng WHERE company_code = $1`, + [companyCode] + ); + const companyName = companyCode === "*" ? "공통" : (companyInfo?.company_name || companyCode); + + // 메뉴 경로 조회 및 카테고리 생성 + const menuPath = await multilangService.getMenuPath(savedMenu.objid.toString()); + await multilangService.ensureMenuCategory(companyCode, companyName, menuPath); + + logger.info("메뉴 다국어 카테고리 생성 완료", { + menuObjId: savedMenu.objid.toString(), + menuPath, + }); + } catch (categoryError) { + logger.warn("메뉴 다국어 카테고리 생성 실패 (메뉴 저장은 성공)", { + menuObjId: savedMenu.objid.toString(), + error: categoryError, + }); + } + const response: ApiResponse = { success: true, message: "메뉴가 성공적으로 저장되었습니다.", @@ -2649,6 +2690,24 @@ export const createCompany = async ( }); } + // 다국어 카테고리 자동 생성 + try { + const { MultiLangService } = await import("../services/multilangService"); + const multilangService = new MultiLangService(); + await multilangService.ensureCompanyCategory( + createdCompany.company_code, + createdCompany.company_name + ); + logger.info("회사 다국어 카테고리 생성 완료", { + companyCode: createdCompany.company_code, + }); + } catch (categoryError) { + logger.warn("회사 다국어 카테고리 생성 실패 (회사 등록은 성공)", { + companyCode: createdCompany.company_code, + error: categoryError, + }); + } + logger.info("회사 등록 성공", { companyCode: createdCompany.company_code, companyName: createdCompany.company_name, @@ -3058,6 +3117,23 @@ export const updateProfile = async ( } if (locale !== undefined) { + // language_master 테이블에서 유효한 언어 코드인지 확인 + const validLang = await queryOne<{ lang_code: string }>( + "SELECT lang_code FROM language_master WHERE lang_code = $1 AND is_active = 'Y'", + [locale] + ); + + if (!validLang) { + res.status(400).json({ + result: false, + error: { + code: "INVALID_LOCALE", + details: `유효하지 않은 로케일입니다: ${locale}`, + }, + }); + return; + } + updateFields.push(`locale = $${paramIndex}`); updateValues.push(locale); paramIndex++; diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index ed2576cd..ab9bbc46 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -70,11 +70,23 @@ export class EntityJoinController { const userField = parsedAutoFilter.userField || "companyCode"; const userValue = ((req as any).user as any)[userField]; - if (userValue) { - searchConditions[filterColumn] = userValue; + // 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용) + let finalCompanyCode = userValue; + if (parsedAutoFilter.companyCodeOverride && userValue === "*") { + // 최고 관리자만 다른 회사 코드로 오버라이드 가능 + finalCompanyCode = parsedAutoFilter.companyCodeOverride; + logger.info("🔓 최고 관리자 회사 코드 오버라이드:", { + originalCompanyCode: userValue, + overrideCompanyCode: parsedAutoFilter.companyCodeOverride, + tableName, + }); + } + + if (finalCompanyCode) { + searchConditions[filterColumn] = finalCompanyCode; logger.info("🔒 Entity 조인에 멀티테넌시 필터 적용:", { filterColumn, - userValue, + finalCompanyCode, tableName, }); } diff --git a/backend-node/src/controllers/multilangController.ts b/backend-node/src/controllers/multilangController.ts index 14155f86..f14fc3b5 100644 --- a/backend-node/src/controllers/multilangController.ts +++ b/backend-node/src/controllers/multilangController.ts @@ -10,7 +10,10 @@ import { SaveLangTextsRequest, GetUserTextParams, BatchTranslationRequest, + GenerateKeyRequest, + CreateOverrideKeyRequest, ApiResponse, + LangCategory, } from "../types/multilang"; /** @@ -187,7 +190,7 @@ export const getLangKeys = async ( res: Response ): Promise => { try { - const { companyCode, menuCode, keyType, searchText } = req.query; + const { companyCode, menuCode, keyType, searchText, categoryId } = req.query; logger.info("다국어 키 목록 조회 요청", { query: req.query, user: req.user, @@ -199,6 +202,7 @@ export const getLangKeys = async ( menuCode: menuCode as string, keyType: keyType as string, searchText: searchText as string, + categoryId: categoryId ? parseInt(categoryId as string, 10) : undefined, }); const response: ApiResponse = { @@ -630,6 +634,391 @@ export const deleteLanguage = async ( } }; +// ===================================================== +// 카테고리 관련 API +// ===================================================== + +/** + * GET /api/multilang/categories + * 카테고리 목록 조회 API (트리 구조) + */ +export const getCategories = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + logger.info("카테고리 목록 조회 요청", { user: req.user }); + + const multiLangService = new MultiLangService(); + const categories = await multiLangService.getCategories(); + + const response: ApiResponse = { + success: true, + message: "카테고리 목록 조회 성공", + data: categories, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("카테고리 목록 조회 실패:", error); + res.status(500).json({ + success: false, + message: "카테고리 목록 조회 중 오류가 발생했습니다.", + error: { + code: "CATEGORY_LIST_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * GET /api/multilang/categories/:categoryId + * 카테고리 상세 조회 API + */ +export const getCategoryById = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { categoryId } = req.params; + logger.info("카테고리 상세 조회 요청", { categoryId, user: req.user }); + + const multiLangService = new MultiLangService(); + const category = await multiLangService.getCategoryById(parseInt(categoryId)); + + if (!category) { + res.status(404).json({ + success: false, + message: "카테고리를 찾을 수 없습니다.", + error: { + code: "CATEGORY_NOT_FOUND", + details: `Category ID ${categoryId} not found`, + }, + }); + return; + } + + const response: ApiResponse = { + success: true, + message: "카테고리 상세 조회 성공", + data: category, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("카테고리 상세 조회 실패:", error); + res.status(500).json({ + success: false, + message: "카테고리 상세 조회 중 오류가 발생했습니다.", + error: { + code: "CATEGORY_DETAIL_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * GET /api/multilang/categories/:categoryId/path + * 카테고리 경로 조회 API (부모 포함) + */ +export const getCategoryPath = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { categoryId } = req.params; + logger.info("카테고리 경로 조회 요청", { categoryId, user: req.user }); + + const multiLangService = new MultiLangService(); + const path = await multiLangService.getCategoryPath(parseInt(categoryId)); + + const response: ApiResponse = { + success: true, + message: "카테고리 경로 조회 성공", + data: path, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("카테고리 경로 조회 실패:", error); + res.status(500).json({ + success: false, + message: "카테고리 경로 조회 중 오류가 발생했습니다.", + error: { + code: "CATEGORY_PATH_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +// ===================================================== +// 자동 생성 및 오버라이드 관련 API +// ===================================================== + +/** + * POST /api/multilang/keys/generate + * 키 자동 생성 API + */ +export const generateKey = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const generateData: GenerateKeyRequest = req.body; + logger.info("키 자동 생성 요청", { generateData, user: req.user }); + + // 필수 입력값 검증 + if (!generateData.companyCode || !generateData.categoryId || !generateData.keyMeaning) { + res.status(400).json({ + success: false, + message: "회사 코드, 카테고리 ID, 키 의미는 필수입니다.", + error: { + code: "MISSING_REQUIRED_FIELDS", + details: "companyCode, categoryId, and keyMeaning are required", + }, + }); + return; + } + + // 권한 검사: 공통 키(*)는 최고 관리자만 생성 가능 + if (generateData.companyCode === "*" && req.user?.companyCode !== "*") { + res.status(403).json({ + success: false, + message: "공통 키는 최고 관리자만 생성할 수 있습니다.", + error: { + code: "PERMISSION_DENIED", + details: "Only super admin can create common keys", + }, + }); + return; + } + + // 회사 관리자는 자기 회사 키만 생성 가능 + if (generateData.companyCode !== "*" && + req.user?.companyCode !== "*" && + generateData.companyCode !== req.user?.companyCode) { + res.status(403).json({ + success: false, + message: "다른 회사의 키를 생성할 권한이 없습니다.", + error: { + code: "PERMISSION_DENIED", + details: "Cannot create keys for other companies", + }, + }); + return; + } + + const multiLangService = new MultiLangService(); + const keyId = await multiLangService.generateKey({ + ...generateData, + createdBy: req.user?.userId || "system", + }); + + const response: ApiResponse = { + success: true, + message: "키가 성공적으로 생성되었습니다.", + data: keyId, + }; + + res.status(201).json(response); + } catch (error) { + logger.error("키 자동 생성 실패:", error); + res.status(500).json({ + success: false, + message: "키 자동 생성 중 오류가 발생했습니다.", + error: { + code: "KEY_GENERATE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * POST /api/multilang/keys/preview + * 키 미리보기 API + */ +export const previewKey = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { categoryId, keyMeaning, companyCode } = req.body; + logger.info("키 미리보기 요청", { categoryId, keyMeaning, companyCode, user: req.user }); + + if (!categoryId || !keyMeaning || !companyCode) { + res.status(400).json({ + success: false, + message: "카테고리 ID, 키 의미, 회사 코드는 필수입니다.", + error: { + code: "MISSING_REQUIRED_FIELDS", + details: "categoryId, keyMeaning, and companyCode are required", + }, + }); + return; + } + + const multiLangService = new MultiLangService(); + const preview = await multiLangService.previewGeneratedKey( + parseInt(categoryId), + keyMeaning, + companyCode + ); + + const response: ApiResponse<{ + langKey: string; + exists: boolean; + isOverride: boolean; + baseKeyId?: number; + }> = { + success: true, + message: "키 미리보기 성공", + data: preview, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("키 미리보기 실패:", error); + res.status(500).json({ + success: false, + message: "키 미리보기 중 오류가 발생했습니다.", + error: { + code: "KEY_PREVIEW_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * POST /api/multilang/keys/override + * 오버라이드 키 생성 API + */ +export const createOverrideKey = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const overrideData: CreateOverrideKeyRequest = req.body; + logger.info("오버라이드 키 생성 요청", { overrideData, user: req.user }); + + // 필수 입력값 검증 + if (!overrideData.companyCode || !overrideData.baseKeyId) { + res.status(400).json({ + success: false, + message: "회사 코드와 원본 키 ID는 필수입니다.", + error: { + code: "MISSING_REQUIRED_FIELDS", + details: "companyCode and baseKeyId are required", + }, + }); + return; + } + + // 최고 관리자(*)는 오버라이드 키를 만들 수 없음 (이미 공통 키) + if (overrideData.companyCode === "*") { + res.status(400).json({ + success: false, + message: "공통 키에 대한 오버라이드는 생성할 수 없습니다.", + error: { + code: "INVALID_OVERRIDE", + details: "Cannot create override for common keys", + }, + }); + return; + } + + // 회사 관리자는 자기 회사 오버라이드만 생성 가능 + if (req.user?.companyCode !== "*" && + overrideData.companyCode !== req.user?.companyCode) { + res.status(403).json({ + success: false, + message: "다른 회사의 오버라이드 키를 생성할 권한이 없습니다.", + error: { + code: "PERMISSION_DENIED", + details: "Cannot create override keys for other companies", + }, + }); + return; + } + + const multiLangService = new MultiLangService(); + const keyId = await multiLangService.createOverrideKey({ + ...overrideData, + createdBy: req.user?.userId || "system", + }); + + const response: ApiResponse = { + success: true, + message: "오버라이드 키가 성공적으로 생성되었습니다.", + data: keyId, + }; + + res.status(201).json(response); + } catch (error) { + logger.error("오버라이드 키 생성 실패:", error); + res.status(500).json({ + success: false, + message: "오버라이드 키 생성 중 오류가 발생했습니다.", + error: { + code: "OVERRIDE_KEY_CREATE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * GET /api/multilang/keys/overrides/:companyCode + * 회사별 오버라이드 키 목록 조회 API + */ +export const getOverrideKeys = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.params; + logger.info("오버라이드 키 목록 조회 요청", { companyCode, user: req.user }); + + // 권한 검사: 최고 관리자 또는 해당 회사 관리자만 조회 가능 + if (req.user?.companyCode !== "*" && companyCode !== req.user?.companyCode) { + res.status(403).json({ + success: false, + message: "다른 회사의 오버라이드 키를 조회할 권한이 없습니다.", + error: { + code: "PERMISSION_DENIED", + details: "Cannot view override keys for other companies", + }, + }); + return; + } + + const multiLangService = new MultiLangService(); + const keys = await multiLangService.getOverrideKeys(companyCode); + + const response: ApiResponse = { + success: true, + message: "오버라이드 키 목록 조회 성공", + data: keys, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("오버라이드 키 목록 조회 실패:", error); + res.status(500).json({ + success: false, + message: "오버라이드 키 목록 조회 중 오류가 발생했습니다.", + error: { + code: "OVERRIDE_KEYS_LIST_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + /** * POST /api/multilang/batch * 다국어 텍스트 배치 조회 API @@ -710,3 +1099,86 @@ export const getBatchTranslations = async ( }); } }; + +/** + * POST /api/multilang/screen-labels + * 화면 라벨 다국어 키 자동 생성 API + */ +export const generateScreenLabelKeys = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { screenId, menuObjId, labels } = req.body; + + logger.info("화면 라벨 다국어 키 생성 요청", { + screenId, + menuObjId, + labelCount: labels?.length, + user: req.user, + }); + + // 필수 파라미터 검증 + if (!screenId) { + res.status(400).json({ + success: false, + message: "screenId는 필수입니다.", + error: { code: "MISSING_SCREEN_ID" }, + }); + return; + } + + if (!labels || !Array.isArray(labels) || labels.length === 0) { + res.status(400).json({ + success: false, + message: "labels 배열이 필요합니다.", + error: { code: "MISSING_LABELS" }, + }); + return; + } + + // 화면의 회사 정보 조회 (사용자 회사가 아닌 화면 소속 회사 기준) + const { queryOne } = await import("../database/db"); + const screenInfo = await queryOne<{ company_code: string }>( + `SELECT company_code FROM screen_definitions WHERE screen_id = $1`, + [screenId] + ); + const companyCode = screenInfo?.company_code || req.user?.companyCode || "*"; + + // 회사명 조회 + const companyInfo = await queryOne<{ company_name: string }>( + `SELECT company_name FROM company_mng WHERE company_code = $1`, + [companyCode] + ); + const companyName = companyCode === "*" ? "공통" : (companyInfo?.company_name || companyCode); + + logger.info("화면 소속 회사 정보", { screenId, companyCode, companyName }); + + const multiLangService = new MultiLangService(); + const results = await multiLangService.generateScreenLabelKeys({ + screenId: Number(screenId), + companyCode, + companyName, + menuObjId, + labels, + }); + + const response: ApiResponse = { + success: true, + message: `${results.length}개의 다국어 키가 생성되었습니다.`, + data: results, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("화면 라벨 다국어 키 생성 실패:", error); + res.status(500).json({ + success: false, + message: "화면 라벨 다국어 키 생성 중 오류가 발생했습니다.", + error: { + code: "SCREEN_LABEL_KEY_GENERATION_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts new file mode 100644 index 00000000..1c73d88a --- /dev/null +++ b/backend-node/src/controllers/screenGroupController.ts @@ -0,0 +1,2061 @@ +import { Request, Response } from "express"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; +import { MultiLangService } from "../services/multilangService"; + +// pool 인스턴스 가져오기 +const pool = getPool(); + +// 다국어 서비스 인스턴스 +const multiLangService = new MultiLangService(); + +// ============================================================ +// 화면 그룹 (screen_groups) CRUD +// ============================================================ + +// 화면 그룹 목록 조회 +export const getScreenGroups = async (req: Request, res: Response) => { + try { + const companyCode = (req.user as any).companyCode; + const { page = 1, size = 20, searchTerm } = req.query; + const offset = (parseInt(page as string) - 1) * parseInt(size as string); + + let whereClause = "WHERE 1=1"; + const params: any[] = []; + let paramIndex = 1; + + // 회사 코드 필터링 (멀티테넌시) + if (companyCode !== "*") { + whereClause += ` AND company_code = $${paramIndex}`; + params.push(companyCode); + paramIndex++; + } + + // 검색어 필터링 + if (searchTerm) { + whereClause += ` AND (group_name ILIKE $${paramIndex} OR group_code ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`; + params.push(`%${searchTerm}%`); + paramIndex++; + } + + // 전체 개수 조회 + const countQuery = `SELECT COUNT(*) as total FROM screen_groups ${whereClause}`; + const countResult = await pool.query(countQuery, params); + const total = parseInt(countResult.rows[0].total); + + // 데이터 조회 (screens 배열 포함) + const dataQuery = ` + SELECT + sg.*, + (SELECT COUNT(*) FROM screen_group_screens sgs WHERE sgs.group_id = sg.id) as screen_count, + (SELECT json_agg( + json_build_object( + 'id', sgs.id, + 'screen_id', sgs.screen_id, + 'screen_name', sd.screen_name, + 'screen_role', sgs.screen_role, + 'display_order', sgs.display_order, + 'is_default', sgs.is_default, + 'table_name', sd.table_name + ) ORDER BY sgs.display_order + ) FROM screen_group_screens sgs + LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id + WHERE sgs.group_id = sg.id + ) as screens + FROM screen_groups sg + ${whereClause} + ORDER BY sg.display_order ASC, sg.created_date DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + `; + params.push(parseInt(size as string), offset); + + const result = await pool.query(dataQuery, params); + + logger.info("화면 그룹 목록 조회", { companyCode, total, count: result.rows.length }); + + res.json({ + success: true, + data: result.rows, + total, + page: parseInt(page as string), + size: parseInt(size as string), + totalPages: Math.ceil(total / parseInt(size as string)), + }); + } catch (error: any) { + logger.error("화면 그룹 목록 조회 실패:", error); + res.status(500).json({ success: false, message: "화면 그룹 목록 조회에 실패했습니다.", error: error.message }); + } +}; + +// 화면 그룹 상세 조회 +export const getScreenGroup = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const companyCode = (req.user as any).companyCode; + + let query = ` + SELECT sg.*, + (SELECT json_agg( + json_build_object( + 'id', sgs.id, + 'screen_id', sgs.screen_id, + 'screen_name', sd.screen_name, + 'screen_role', sgs.screen_role, + 'display_order', sgs.display_order, + 'is_default', sgs.is_default, + 'table_name', sd.table_name + ) ORDER BY sgs.display_order + ) FROM screen_group_screens sgs + LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id + WHERE sgs.group_id = sg.id + ) as screens + FROM screen_groups sg + WHERE sg.id = $1 + `; + const params: any[] = [id]; + + // 멀티테넌시 필터링 + if (companyCode !== "*") { + query += ` AND sg.company_code = $2`; + params.push(companyCode); + } + + const result = await pool.query(query, params); + + if (result.rows.length === 0) { + return res.status(404).json({ success: false, message: "화면 그룹을 찾을 수 없습니다." }); + } + + res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("화면 그룹 상세 조회 실패:", error); + res.status(500).json({ success: false, message: "화면 그룹 조회에 실패했습니다.", error: error.message }); + } +}; + +// 화면 그룹 생성 +export const createScreenGroup = async (req: Request, res: Response) => { + try { + const userCompanyCode = (req.user as any).companyCode; + const userId = (req.user as any).userId; + const { group_name, group_code, main_table_name, description, icon, display_order, is_active, parent_group_id, target_company_code } = req.body; + + if (!group_name || !group_code) { + return res.status(400).json({ success: false, message: "그룹명과 그룹코드는 필수입니다." }); + } + + // 회사 코드 결정: 최고 관리자가 특정 회사를 선택한 경우 해당 회사로, 아니면 사용자 회사 + let finalCompanyCode = userCompanyCode; + if (userCompanyCode === "*" && target_company_code) { + // 최고 관리자가 특정 회사를 선택한 경우 + finalCompanyCode = target_company_code; + } + + // 부모 그룹이 있으면 group_level과 hierarchy_path 계산 + let groupLevel = 0; + let parentHierarchyPath = ""; + + if (parent_group_id) { + const parentQuery = `SELECT id, group_level, hierarchy_path FROM screen_groups WHERE id = $1`; + const parentResult = await pool.query(parentQuery, [parent_group_id]); + if (parentResult.rows.length > 0) { + groupLevel = (parentResult.rows[0].group_level || 0) + 1; + parentHierarchyPath = parentResult.rows[0].hierarchy_path || `/${parent_group_id}/`; + } + } + + const query = ` + INSERT INTO screen_groups (group_name, group_code, main_table_name, description, icon, display_order, is_active, company_code, writer, parent_group_id, group_level) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING * + `; + const params = [ + group_name, + group_code, + main_table_name || null, + description || null, + icon || null, + display_order || 0, + is_active || 'Y', + finalCompanyCode, + userId, + parent_group_id || null, + groupLevel + ]; + + const result = await pool.query(query, params); + const newGroupId = result.rows[0].id; + + // hierarchy_path 업데이트 + const hierarchyPath = parent_group_id + ? `${parentHierarchyPath}${newGroupId}/`.replace('//', '/') + : `/${newGroupId}/`; + await pool.query(`UPDATE screen_groups SET hierarchy_path = $1 WHERE id = $2`, [hierarchyPath, newGroupId]); + + // 업데이트된 데이터 반환 + const updatedResult = await pool.query(`SELECT * FROM screen_groups WHERE id = $1`, [newGroupId]); + + // 다국어 카테고리 자동 생성 (그룹 경로 기반) + try { + // 그룹 경로 조회 (상위 그룹 → 현재 그룹) + const groupPathResult = await pool.query( + `WITH RECURSIVE group_path AS ( + SELECT id, parent_group_id, group_name, group_level, 1 as depth + FROM screen_groups + WHERE id = $1 + UNION ALL + SELECT g.id, g.parent_group_id, g.group_name, g.group_level, gp.depth + 1 + FROM screen_groups g + INNER JOIN group_path gp ON g.id = gp.parent_group_id + WHERE g.parent_group_id IS NOT NULL + ) + SELECT group_name FROM group_path + ORDER BY depth DESC`, + [newGroupId] + ); + + const groupPath = groupPathResult.rows.map((r: any) => r.group_name); + + // 회사 이름 조회 + let companyName = "공통"; + if (finalCompanyCode !== "*") { + const companyResult = await pool.query( + `SELECT company_name FROM company_mng WHERE company_code = $1`, + [finalCompanyCode] + ); + if (companyResult.rows.length > 0) { + companyName = companyResult.rows[0].company_name; + } + } + + // 다국어 카테고리 생성 + await multiLangService.ensureScreenGroupCategory(finalCompanyCode, companyName, groupPath); + logger.info("화면 그룹 다국어 카테고리 자동 생성 완료", { groupPath, companyCode: finalCompanyCode }); + } catch (multilangError: any) { + // 다국어 카테고리 생성 실패해도 그룹 생성은 성공으로 처리 + logger.warn("화면 그룹 다국어 카테고리 생성 실패 (무시하고 계속):", multilangError.message); + } + + logger.info("화면 그룹 생성", { userCompanyCode, finalCompanyCode, groupId: newGroupId, groupName: group_name, parentGroupId: parent_group_id }); + + res.json({ success: true, data: updatedResult.rows[0], message: "화면 그룹이 생성되었습니다." }); + } catch (error: any) { + logger.error("화면 그룹 생성 실패:", error); + if (error.code === '23505') { + return res.status(400).json({ success: false, message: "이미 존재하는 그룹 코드입니다." }); + } + res.status(500).json({ success: false, message: "화면 그룹 생성에 실패했습니다.", error: error.message }); + } +}; + +// 화면 그룹 수정 +export const updateScreenGroup = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const userCompanyCode = (req.user as any).companyCode; + const { group_name, group_code, main_table_name, description, icon, display_order, is_active, parent_group_id, target_company_code } = req.body; + + // 회사 코드 결정: 최고 관리자가 특정 회사를 선택한 경우 해당 회사로, 아니면 현재 그룹의 회사 유지 + let finalCompanyCode = target_company_code || null; + + // 부모 그룹이 변경되면 group_level과 hierarchy_path 재계산 + let groupLevel = 0; + let hierarchyPath = `/${id}/`; + + if (parent_group_id !== undefined && parent_group_id !== null) { + // 자기 자신을 부모로 지정하는 것 방지 + if (Number(parent_group_id) === Number(id)) { + return res.status(400).json({ success: false, message: "자기 자신을 상위 그룹으로 지정할 수 없습니다." }); + } + + const parentQuery = `SELECT id, group_level, hierarchy_path FROM screen_groups WHERE id = $1`; + const parentResult = await pool.query(parentQuery, [parent_group_id]); + if (parentResult.rows.length > 0) { + // 순환 참조 방지: 부모의 hierarchy_path에 현재 그룹 ID가 포함되어 있으면 오류 + if (parentResult.rows[0].hierarchy_path && parentResult.rows[0].hierarchy_path.includes(`/${id}/`)) { + return res.status(400).json({ success: false, message: "하위 그룹을 상위 그룹으로 지정할 수 없습니다." }); + } + groupLevel = (parentResult.rows[0].group_level || 0) + 1; + const parentPath = parentResult.rows[0].hierarchy_path || `/${parent_group_id}/`; + hierarchyPath = `${parentPath}${id}/`.replace('//', '/'); + } + } + + // 쿼리 구성: 회사 코드 변경 포함 여부 + let query: string; + let params: any[]; + + if (userCompanyCode === "*" && finalCompanyCode) { + // 최고 관리자가 회사를 변경하는 경우 + query = ` + UPDATE screen_groups + SET group_name = $1, group_code = $2, main_table_name = $3, description = $4, + icon = $5, display_order = $6, is_active = $7, updated_date = NOW(), + parent_group_id = $8, group_level = $9, hierarchy_path = $10, company_code = $11 + WHERE id = $12 + `; + params = [ + group_name, group_code, main_table_name, description, icon, display_order, is_active, + parent_group_id || null, groupLevel, hierarchyPath, finalCompanyCode, id + ]; + } else { + // 회사 코드 변경 없음 + query = ` + UPDATE screen_groups + SET group_name = $1, group_code = $2, main_table_name = $3, description = $4, + icon = $5, display_order = $6, is_active = $7, updated_date = NOW(), + parent_group_id = $8, group_level = $9, hierarchy_path = $10 + WHERE id = $11 + `; + params = [ + group_name, group_code, main_table_name, description, icon, display_order, is_active, + parent_group_id || null, groupLevel, hierarchyPath, id + ]; + } + + // 멀티테넌시 필터링 (최고 관리자가 아닌 경우) + if (userCompanyCode !== "*") { + const paramIndex = params.length + 1; + query += ` AND company_code = $${paramIndex}`; + params.push(userCompanyCode); + } + + query += " RETURNING *"; + + const result = await pool.query(query, params); + + if (result.rows.length === 0) { + return res.status(404).json({ success: false, message: "화면 그룹을 찾을 수 없거나 권한이 없습니다." }); + } + + logger.info("화면 그룹 수정", { userCompanyCode, groupId: id, parentGroupId: parent_group_id, targetCompanyCode: finalCompanyCode }); + + res.json({ success: true, data: result.rows[0], message: "화면 그룹이 수정되었습니다." }); + } catch (error: any) { + logger.error("화면 그룹 수정 실패:", error); + res.status(500).json({ success: false, message: "화면 그룹 수정에 실패했습니다.", error: error.message }); + } +}; + +// 화면 그룹 삭제 +export const deleteScreenGroup = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const companyCode = (req.user as any).companyCode; + + let query = `DELETE FROM screen_groups WHERE id = $1`; + const params: any[] = [id]; + + if (companyCode !== "*") { + query += ` AND company_code = $2`; + params.push(companyCode); + } + + query += " RETURNING id"; + + const result = await pool.query(query, params); + + if (result.rows.length === 0) { + return res.status(404).json({ success: false, message: "화면 그룹을 찾을 수 없거나 권한이 없습니다." }); + } + + logger.info("화면 그룹 삭제", { companyCode, groupId: id }); + + res.json({ success: true, message: "화면 그룹이 삭제되었습니다." }); + } catch (error: any) { + logger.error("화면 그룹 삭제 실패:", error); + res.status(500).json({ success: false, message: "화면 그룹 삭제에 실패했습니다.", error: error.message }); + } +}; + + +// ============================================================ +// 화면-그룹 연결 (screen_group_screens) CRUD +// ============================================================ + +// 그룹에 화면 추가 +export const addScreenToGroup = async (req: Request, res: Response) => { + try { + const companyCode = (req.user as any).companyCode; + const userId = (req.user as any).userId; + const { group_id, screen_id, screen_role, display_order, is_default } = req.body; + + if (!group_id || !screen_id) { + return res.status(400).json({ success: false, message: "그룹 ID와 화면 ID는 필수입니다." }); + } + + const query = ` + INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code, writer) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING * + `; + const params = [ + group_id, + screen_id, + screen_role || 'main', + display_order || 0, + is_default || 'N', + companyCode === "*" ? "*" : companyCode, + userId + ]; + + const result = await pool.query(query, params); + + logger.info("화면-그룹 연결 추가", { companyCode, groupId: group_id, screenId: screen_id }); + + res.json({ success: true, data: result.rows[0], message: "화면이 그룹에 추가되었습니다." }); + } catch (error: any) { + logger.error("화면-그룹 연결 추가 실패:", error); + if (error.code === '23505') { + return res.status(400).json({ success: false, message: "이미 그룹에 추가된 화면입니다." }); + } + res.status(500).json({ success: false, message: "화면 추가에 실패했습니다.", error: error.message }); + } +}; + +// 그룹에서 화면 제거 +export const removeScreenFromGroup = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const companyCode = (req.user as any).companyCode; + + let query = `DELETE FROM screen_group_screens WHERE id = $1`; + const params: any[] = [id]; + + if (companyCode !== "*") { + query += ` AND company_code = $2`; + params.push(companyCode); + } + + query += " RETURNING id"; + + const result = await pool.query(query, params); + + if (result.rows.length === 0) { + return res.status(404).json({ success: false, message: "연결을 찾을 수 없거나 권한이 없습니다." }); + } + + logger.info("화면-그룹 연결 제거", { companyCode, id }); + + res.json({ success: true, message: "화면이 그룹에서 제거되었습니다." }); + } catch (error: any) { + logger.error("화면-그룹 연결 제거 실패:", error); + res.status(500).json({ success: false, message: "화면 제거에 실패했습니다.", error: error.message }); + } +}; + +// 그룹 내 화면 순서/역할 수정 +export const updateScreenInGroup = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const companyCode = (req.user as any).companyCode; + const { screen_role, display_order, is_default } = req.body; + + let query = ` + UPDATE screen_group_screens + SET screen_role = $1, display_order = $2, is_default = $3, updated_date = NOW() + WHERE id = $4 + `; + const params: any[] = [screen_role, display_order, is_default, id]; + + if (companyCode !== "*") { + query += ` AND company_code = $5`; + params.push(companyCode); + } + + query += " RETURNING *"; + + const result = await pool.query(query, params); + + if (result.rows.length === 0) { + return res.status(404).json({ success: false, message: "연결을 찾을 수 없거나 권한이 없습니다." }); + } + + res.json({ success: true, data: result.rows[0], message: "화면 정보가 수정되었습니다." }); + } catch (error: any) { + logger.error("화면-그룹 연결 수정 실패:", error); + res.status(500).json({ success: false, message: "화면 정보 수정에 실패했습니다.", error: error.message }); + } +}; + + +// ============================================================ +// 화면 필드 조인 설정 (screen_field_joins) CRUD +// ============================================================ + +// 화면 필드 조인 목록 조회 +export const getFieldJoins = async (req: Request, res: Response) => { + try { + const companyCode = (req.user as any).companyCode; + const { screen_id } = req.query; + + let query = ` + SELECT sfj.*, + tl1.table_label as save_table_label, + tl2.table_label as join_table_label + FROM screen_field_joins sfj + LEFT JOIN table_labels tl1 ON sfj.save_table = tl1.table_name + LEFT JOIN table_labels tl2 ON sfj.join_table = tl2.table_name + WHERE 1=1 + `; + const params: any[] = []; + let paramIndex = 1; + + if (companyCode !== "*") { + query += ` AND sfj.company_code = $${paramIndex}`; + params.push(companyCode); + paramIndex++; + } + + if (screen_id) { + query += ` AND sfj.screen_id = $${paramIndex}`; + params.push(screen_id); + paramIndex++; + } + + query += " ORDER BY sfj.id ASC"; + + const result = await pool.query(query, params); + + res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("필드 조인 목록 조회 실패:", error); + res.status(500).json({ success: false, message: "필드 조인 목록 조회에 실패했습니다.", error: error.message }); + } +}; + +// 화면 필드 조인 생성 +export const createFieldJoin = async (req: Request, res: Response) => { + try { + const companyCode = (req.user as any).companyCode; + const userId = (req.user as any).userId; + const { + screen_id, layout_id, component_id, field_name, + save_table, save_column, join_table, join_column, display_column, + join_type, filter_condition, sort_column, sort_direction, is_active + } = req.body; + + if (!screen_id || !save_table || !save_column || !join_table || !join_column || !display_column) { + return res.status(400).json({ success: false, message: "필수 필드가 누락되었습니다." }); + } + + const query = ` + INSERT INTO screen_field_joins ( + screen_id, layout_id, component_id, field_name, + save_table, save_column, join_table, join_column, display_column, + join_type, filter_condition, sort_column, sort_direction, is_active, company_code, writer + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + RETURNING * + `; + const params = [ + screen_id, layout_id || null, component_id || null, field_name || null, + save_table, save_column, join_table, join_column, display_column, + join_type || 'LEFT', filter_condition || null, sort_column || null, sort_direction || 'ASC', + is_active || 'Y', companyCode === "*" ? "*" : companyCode, userId + ]; + + const result = await pool.query(query, params); + + logger.info("필드 조인 생성", { companyCode, screenId: screen_id, id: result.rows[0].id }); + + res.json({ success: true, data: result.rows[0], message: "필드 조인이 생성되었습니다." }); + } catch (error: any) { + logger.error("필드 조인 생성 실패:", error); + res.status(500).json({ success: false, message: "필드 조인 생성에 실패했습니다.", error: error.message }); + } +}; + +// 화면 필드 조인 수정 +export const updateFieldJoin = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const companyCode = (req.user as any).companyCode; + const { + layout_id, component_id, field_name, + save_table, save_column, join_table, join_column, display_column, + join_type, filter_condition, sort_column, sort_direction, is_active + } = req.body; + + let query = ` + UPDATE screen_field_joins SET + layout_id = $1, component_id = $2, field_name = $3, + save_table = $4, save_column = $5, join_table = $6, join_column = $7, display_column = $8, + join_type = $9, filter_condition = $10, sort_column = $11, sort_direction = $12, + is_active = $13, updated_date = NOW() + WHERE id = $14 + `; + const params: any[] = [ + layout_id, component_id, field_name, + save_table, save_column, join_table, join_column, display_column, + join_type, filter_condition, sort_column, sort_direction, is_active, id + ]; + + if (companyCode !== "*") { + query += ` AND company_code = $15`; + params.push(companyCode); + } + + query += " RETURNING *"; + + const result = await pool.query(query, params); + + if (result.rows.length === 0) { + return res.status(404).json({ success: false, message: "필드 조인을 찾을 수 없거나 권한이 없습니다." }); + } + + res.json({ success: true, data: result.rows[0], message: "필드 조인이 수정되었습니다." }); + } catch (error: any) { + logger.error("필드 조인 수정 실패:", error); + res.status(500).json({ success: false, message: "필드 조인 수정에 실패했습니다.", error: error.message }); + } +}; + +// 화면 필드 조인 삭제 +export const deleteFieldJoin = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const companyCode = (req.user as any).companyCode; + + let query = `DELETE FROM screen_field_joins WHERE id = $1`; + const params: any[] = [id]; + + if (companyCode !== "*") { + query += ` AND company_code = $2`; + params.push(companyCode); + } + + query += " RETURNING id"; + + const result = await pool.query(query, params); + + if (result.rows.length === 0) { + return res.status(404).json({ success: false, message: "필드 조인을 찾을 수 없거나 권한이 없습니다." }); + } + + res.json({ success: true, message: "필드 조인이 삭제되었습니다." }); + } catch (error: any) { + logger.error("필드 조인 삭제 실패:", error); + res.status(500).json({ success: false, message: "필드 조인 삭제에 실패했습니다.", error: error.message }); + } +}; + + +// ============================================================ +// 데이터 흐름 (screen_data_flows) CRUD +// ============================================================ + +// 데이터 흐름 목록 조회 +export const getDataFlows = async (req: Request, res: Response) => { + try { + const companyCode = (req.user as any).companyCode; + const { group_id, source_screen_id } = req.query; + + let query = ` + SELECT sdf.*, + sd1.screen_name as source_screen_name, + sd2.screen_name as target_screen_name, + sg.group_name + FROM screen_data_flows sdf + LEFT JOIN screen_definitions sd1 ON sdf.source_screen_id = sd1.screen_id + LEFT JOIN screen_definitions sd2 ON sdf.target_screen_id = sd2.screen_id + LEFT JOIN screen_groups sg ON sdf.group_id = sg.id + WHERE 1=1 + `; + const params: any[] = []; + let paramIndex = 1; + + if (companyCode !== "*") { + query += ` AND sdf.company_code = $${paramIndex}`; + params.push(companyCode); + paramIndex++; + } + + if (group_id) { + query += ` AND sdf.group_id = $${paramIndex}`; + params.push(group_id); + paramIndex++; + } + + // 특정 화면에서 시작하는 데이터 흐름만 조회 + if (source_screen_id) { + query += ` AND sdf.source_screen_id = $${paramIndex}`; + params.push(source_screen_id); + paramIndex++; + } + + query += " ORDER BY sdf.id ASC"; + + const result = await pool.query(query, params); + + res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("데이터 흐름 목록 조회 실패:", error); + res.status(500).json({ success: false, message: "데이터 흐름 목록 조회에 실패했습니다.", error: error.message }); + } +}; + +// 데이터 흐름 생성 +export const createDataFlow = async (req: Request, res: Response) => { + try { + const companyCode = (req.user as any).companyCode; + const userId = (req.user as any).userId; + const { + group_id, source_screen_id, source_action, target_screen_id, target_action, + data_mapping, flow_type, flow_label, condition_expression, is_active + } = req.body; + + if (!source_screen_id || !target_screen_id) { + return res.status(400).json({ success: false, message: "소스 화면과 타겟 화면은 필수입니다." }); + } + + const query = ` + INSERT INTO screen_data_flows ( + group_id, source_screen_id, source_action, target_screen_id, target_action, + data_mapping, flow_type, flow_label, condition_expression, is_active, company_code, writer + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING * + `; + const params = [ + group_id || null, source_screen_id, source_action || null, target_screen_id, target_action || null, + data_mapping ? JSON.stringify(data_mapping) : null, flow_type || 'unidirectional', + flow_label || null, condition_expression || null, is_active || 'Y', + companyCode === "*" ? "*" : companyCode, userId + ]; + + const result = await pool.query(query, params); + + logger.info("데이터 흐름 생성", { companyCode, id: result.rows[0].id }); + + res.json({ success: true, data: result.rows[0], message: "데이터 흐름이 생성되었습니다." }); + } catch (error: any) { + logger.error("데이터 흐름 생성 실패:", error); + res.status(500).json({ success: false, message: "데이터 흐름 생성에 실패했습니다.", error: error.message }); + } +}; + +// 데이터 흐름 수정 +export const updateDataFlow = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const companyCode = (req.user as any).companyCode; + const { + group_id, source_screen_id, source_action, target_screen_id, target_action, + data_mapping, flow_type, flow_label, condition_expression, is_active + } = req.body; + + let query = ` + UPDATE screen_data_flows SET + group_id = $1, source_screen_id = $2, source_action = $3, + target_screen_id = $4, target_action = $5, data_mapping = $6, + flow_type = $7, flow_label = $8, condition_expression = $9, + is_active = $10, updated_date = NOW() + WHERE id = $11 + `; + const params: any[] = [ + group_id, source_screen_id, source_action, target_screen_id, target_action, + data_mapping ? JSON.stringify(data_mapping) : null, flow_type, flow_label, condition_expression, is_active, id + ]; + + if (companyCode !== "*") { + query += ` AND company_code = $12`; + params.push(companyCode); + } + + query += " RETURNING *"; + + const result = await pool.query(query, params); + + if (result.rows.length === 0) { + return res.status(404).json({ success: false, message: "데이터 흐름을 찾을 수 없거나 권한이 없습니다." }); + } + + res.json({ success: true, data: result.rows[0], message: "데이터 흐름이 수정되었습니다." }); + } catch (error: any) { + logger.error("데이터 흐름 수정 실패:", error); + res.status(500).json({ success: false, message: "데이터 흐름 수정에 실패했습니다.", error: error.message }); + } +}; + +// 데이터 흐름 삭제 +export const deleteDataFlow = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const companyCode = (req.user as any).companyCode; + + let query = `DELETE FROM screen_data_flows WHERE id = $1`; + const params: any[] = [id]; + + if (companyCode !== "*") { + query += ` AND company_code = $2`; + params.push(companyCode); + } + + query += " RETURNING id"; + + const result = await pool.query(query, params); + + if (result.rows.length === 0) { + return res.status(404).json({ success: false, message: "데이터 흐름을 찾을 수 없거나 권한이 없습니다." }); + } + + res.json({ success: true, message: "데이터 흐름이 삭제되었습니다." }); + } catch (error: any) { + logger.error("데이터 흐름 삭제 실패:", error); + res.status(500).json({ success: false, message: "데이터 흐름 삭제에 실패했습니다.", error: error.message }); + } +}; + + +// ============================================================ +// 화면-테이블 관계 (screen_table_relations) CRUD +// ============================================================ + +// 화면-테이블 관계 목록 조회 +export const getTableRelations = async (req: Request, res: Response) => { + try { + const companyCode = (req.user as any).companyCode; + const { screen_id, group_id } = req.query; + + let query = ` + SELECT str.*, + sd.screen_name, + sg.group_name, + tl.table_label + FROM screen_table_relations str + LEFT JOIN screen_definitions sd ON str.screen_id = sd.screen_id + LEFT JOIN screen_groups sg ON str.group_id = sg.id + LEFT JOIN table_labels tl ON str.table_name = tl.table_name + WHERE 1=1 + `; + const params: any[] = []; + let paramIndex = 1; + + if (companyCode !== "*") { + query += ` AND str.company_code = $${paramIndex}`; + params.push(companyCode); + paramIndex++; + } + + if (screen_id) { + query += ` AND str.screen_id = $${paramIndex}`; + params.push(screen_id); + paramIndex++; + } + + if (group_id) { + query += ` AND str.group_id = $${paramIndex}`; + params.push(group_id); + paramIndex++; + } + + query += " ORDER BY str.id ASC"; + + const result = await pool.query(query, params); + + res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("화면-테이블 관계 목록 조회 실패:", error); + res.status(500).json({ success: false, message: "화면-테이블 관계 목록 조회에 실패했습니다.", error: error.message }); + } +}; + +// 화면-테이블 관계 생성 +export const createTableRelation = async (req: Request, res: Response) => { + try { + const companyCode = (req.user as any).companyCode; + const userId = (req.user as any).userId; + const { group_id, screen_id, table_name, relation_type, crud_operations, description, is_active } = req.body; + + if (!screen_id || !table_name) { + return res.status(400).json({ success: false, message: "화면 ID와 테이블명은 필수입니다." }); + } + + const query = ` + INSERT INTO screen_table_relations (group_id, screen_id, table_name, relation_type, crud_operations, description, is_active, company_code, writer) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING * + `; + const params = [ + group_id || null, screen_id, table_name, relation_type || 'main', + crud_operations || 'CRUD', description || null, is_active || 'Y', + companyCode === "*" ? "*" : companyCode, userId + ]; + + const result = await pool.query(query, params); + + logger.info("화면-테이블 관계 생성", { companyCode, screenId: screen_id, tableName: table_name }); + + res.json({ success: true, data: result.rows[0], message: "화면-테이블 관계가 생성되었습니다." }); + } catch (error: any) { + logger.error("화면-테이블 관계 생성 실패:", error); + res.status(500).json({ success: false, message: "화면-테이블 관계 생성에 실패했습니다.", error: error.message }); + } +}; + +// 화면-테이블 관계 수정 +export const updateTableRelation = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const companyCode = (req.user as any).companyCode; + const { group_id, table_name, relation_type, crud_operations, description, is_active } = req.body; + + let query = ` + UPDATE screen_table_relations SET + group_id = $1, table_name = $2, relation_type = $3, crud_operations = $4, + description = $5, is_active = $6, updated_date = NOW() + WHERE id = $7 + `; + const params: any[] = [group_id, table_name, relation_type, crud_operations, description, is_active, id]; + + if (companyCode !== "*") { + query += ` AND company_code = $8`; + params.push(companyCode); + } + + query += " RETURNING *"; + + const result = await pool.query(query, params); + + if (result.rows.length === 0) { + return res.status(404).json({ success: false, message: "화면-테이블 관계를 찾을 수 없거나 권한이 없습니다." }); + } + + res.json({ success: true, data: result.rows[0], message: "화면-테이블 관계가 수정되었습니다." }); + } catch (error: any) { + logger.error("화면-테이블 관계 수정 실패:", error); + res.status(500).json({ success: false, message: "화면-테이블 관계 수정에 실패했습니다.", error: error.message }); + } +}; + +// 화면-테이블 관계 삭제 +export const deleteTableRelation = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const companyCode = (req.user as any).companyCode; + + let query = `DELETE FROM screen_table_relations WHERE id = $1`; + const params: any[] = [id]; + + if (companyCode !== "*") { + query += ` AND company_code = $2`; + params.push(companyCode); + } + + query += " RETURNING id"; + + const result = await pool.query(query, params); + + if (result.rows.length === 0) { + return res.status(404).json({ success: false, message: "화면-테이블 관계를 찾을 수 없거나 권한이 없습니다." }); + } + + res.json({ success: true, message: "화면-테이블 관계가 삭제되었습니다." }); + } catch (error: any) { + logger.error("화면-테이블 관계 삭제 실패:", error); + res.status(500).json({ success: false, message: "화면-테이블 관계 삭제에 실패했습니다.", error: error.message }); + } +}; + +// ============================================================ +// 화면 레이아웃 요약 정보 (미리보기용) +// ============================================================ + +// 화면 레이아웃 요약 조회 (위젯 타입별 개수, 라벨 목록) +export const getScreenLayoutSummary = async (req: Request, res: Response) => { + try { + const { screenId } = req.params; + + // 화면의 컴포넌트 정보 조회 + const query = ` + SELECT + properties->>'widgetType' as widget_type, + properties->>'label' as label, + properties->>'fieldName' as field_name, + properties->>'tableName' as table_name + FROM screen_layouts + WHERE screen_id = $1 + AND component_type = 'component' + ORDER BY display_order ASC + `; + + const result = await pool.query(query, [screenId]); + + // 위젯 타입별 집계 + const widgetCounts: Record = {}; + const labels: string[] = []; + const fields: Array<{ label: string; widgetType: string; fieldName?: string }> = []; + + result.rows.forEach((row: any) => { + const widgetType = row.widget_type || 'text'; + widgetCounts[widgetType] = (widgetCounts[widgetType] || 0) + 1; + + if (row.label && row.label !== '기본 버튼') { + labels.push(row.label); + fields.push({ + label: row.label, + widgetType: widgetType, + fieldName: row.field_name, + }); + } + }); + + // 화면 타입 추론 (가장 많은 컴포넌트 기준) + let screenType = 'form'; // 기본값 + if (widgetCounts['table'] > 0) { + screenType = 'grid'; + } else if (widgetCounts['custom'] > 2) { + screenType = 'dashboard'; + } else if (Object.keys(widgetCounts).length <= 2 && widgetCounts['button'] > 0) { + screenType = 'action'; + } + + logger.info("화면 레이아웃 요약 조회", { screenId, widgetCounts, fieldCount: fields.length }); + + res.json({ + success: true, + data: { + screenId: parseInt(screenId), + screenType, + widgetCounts, + totalComponents: result.rows.length, + fields: fields.slice(0, 10), // 최대 10개 + labels: labels.slice(0, 8), // 최대 8개 + }, + }); + } catch (error: any) { + logger.error("화면 레이아웃 요약 조회 실패:", error); + res.status(500).json({ success: false, message: "화면 레이아웃 요약 조회에 실패했습니다.", error: error.message }); + } +}; + +// 여러 화면의 레이아웃 요약 일괄 조회 (미니어처 렌더링용 좌표 포함) +export const getMultipleScreenLayoutSummary = async (req: Request, res: Response) => { + try { + const { screenIds } = req.body; + + if (!screenIds || !Array.isArray(screenIds) || screenIds.length === 0) { + return res.status(400).json({ success: false, message: "screenIds 배열이 필요합니다." }); + } + + // 여러 화면의 컴포넌트 정보 (좌표 포함) 한번에 조회 + // componentType이 더 정확한 위젯 종류 (table-list, button-primary 등) + // 다양한 컴포넌트 타입에서 사용 컬럼 추출 + const query = ` + SELECT + screen_id, + component_type, + position_x, + position_y, + width, + height, + properties->>'componentType' as component_kind, + properties->>'widgetType' as widget_type, + properties->>'label' as label, + COALESCE( + properties->'componentConfig'->>'bindField', + properties->>'bindField', + properties->'componentConfig'->>'field', + properties->>'field', + properties->>'columnName' + ) as bind_field, + -- componentConfig 전체 (JavaScript에서 다양한 패턴 파싱용) + properties->'componentConfig' as component_config + FROM screen_layouts + WHERE screen_id = ANY($1) + AND component_type = 'component' + ORDER BY screen_id, display_order ASC + `; + + const result = await pool.query(query, [screenIds]); + + // 화면별로 그룹핑 + const summaryMap: Record = {}; + + screenIds.forEach((id: number) => { + summaryMap[id] = { + screenId: id, + screenType: 'form', + widgetCounts: {}, + totalComponents: 0, + // 미니어처 렌더링용 레이아웃 데이터 + layoutItems: [], + canvasWidth: 0, + canvasHeight: 0, + }; + }); + + result.rows.forEach((row: any) => { + const screenId = row.screen_id; + // componentKind가 더 정확한 타입 (table-list, button-primary, table-search-widget 등) + const componentKind = row.component_kind || row.widget_type || 'text'; + const widgetType = row.widget_type || 'text'; + const componentConfig = row.component_config || {}; + + // 다양한 컴포넌트 타입에서 usedColumns, joinColumns 추출 + let usedColumns: string[] = []; + let joinColumns: string[] = []; + + // 1. 기본 columns 배열에서 추출 (table-list 등) + if (Array.isArray(componentConfig.columns)) { + componentConfig.columns.forEach((col: any) => { + const colName = col.columnName || col.field || col.name; + if (colName && !usedColumns.includes(colName)) { + usedColumns.push(colName); + } + if (col.isEntityJoin === true && colName && !joinColumns.includes(colName)) { + joinColumns.push(colName); + } + }); + } + + // 2. split-panel-layout의 leftPanel.columns, rightPanel.columns 추출 + if (componentKind === 'split-panel-layout') { + if (componentConfig.leftPanel?.columns && Array.isArray(componentConfig.leftPanel.columns)) { + componentConfig.leftPanel.columns.forEach((col: any) => { + const colName = col.name || col.columnName || col.field; + if (colName && !usedColumns.includes(colName)) { + usedColumns.push(colName); + } + }); + } + if (componentConfig.rightPanel?.columns && Array.isArray(componentConfig.rightPanel.columns)) { + componentConfig.rightPanel.columns.forEach((col: any) => { + const colName = col.name || col.columnName || col.field; + if (colName) { + // customer_mng.customer_name 같은 경우 조인 컬럼으로 처리 + if (colName.includes('.')) { + if (!joinColumns.includes(colName)) { + joinColumns.push(colName); + } + } else { + if (!usedColumns.includes(colName)) { + usedColumns.push(colName); + } + } + } + }); + } + } + + // 3. selected-items-detail-input의 additionalFields, displayColumns 추출 + if (componentKind === 'selected-items-detail-input') { + if (componentConfig.additionalFields && Array.isArray(componentConfig.additionalFields)) { + componentConfig.additionalFields.forEach((field: any) => { + const fieldName = field.name || field.field; + if (fieldName && !usedColumns.includes(fieldName)) { + usedColumns.push(fieldName); + } + }); + } + // displayColumns는 연관 테이블에서 가져오는 표시용 컬럼이므로 + // 메인 테이블의 joinColumns가 아님 (parentDataMapping에서 별도 추출됨) + // 단, 참조용으로 usedColumns에는 추가 가능 + if (componentConfig.displayColumns && Array.isArray(componentConfig.displayColumns)) { + componentConfig.displayColumns.forEach((col: any) => { + const colName = col.name || col.columnName || col.field; + // displayColumns는 연관 테이블 컬럼이므로 메인 테이블 usedColumns에 추가하지 않음 + // 조인 컬럼은 parentDataMapping.targetField에서 추출됨 + }); + } + } + + // 4. bindField가 있으면 usedColumns에 추가 (인풋 필드, 텍스트 필드 등) + if (row.bind_field && !usedColumns.includes(row.bind_field)) { + usedColumns.push(row.bind_field); + } + + // 5. componentConfig.field 또는 componentConfig.valueField도 추가 + const configField = componentConfig.field || componentConfig.valueField; + if (configField && typeof configField === 'string' && !usedColumns.includes(configField)) { + usedColumns.push(configField); + } + + if (summaryMap[screenId]) { + summaryMap[screenId].widgetCounts[componentKind] = + (summaryMap[screenId].widgetCounts[componentKind] || 0) + 1; + summaryMap[screenId].totalComponents++; + + // 레이아웃 아이템 추가 (미니어처 렌더링용) + summaryMap[screenId].layoutItems.push({ + x: row.position_x || 0, + y: row.position_y || 0, + width: row.width || 100, + height: row.height || 30, + componentKind: componentKind, // 정확한 컴포넌트 종류 + widgetType: widgetType, + label: row.label, + bindField: row.bind_field || null, // 바인딩된 컬럼명 + usedColumns: usedColumns, // 이 컴포넌트에서 사용하는 컬럼 목록 + joinColumns: joinColumns, // 이 컴포넌트에서 조인 컬럼 목록 + }); + + // 캔버스 크기 계산 (최대 좌표 기준) + const rightEdge = (row.position_x || 0) + (row.width || 100); + const bottomEdge = (row.position_y || 0) + (row.height || 30); + if (rightEdge > summaryMap[screenId].canvasWidth) { + summaryMap[screenId].canvasWidth = rightEdge; + } + if (bottomEdge > summaryMap[screenId].canvasHeight) { + summaryMap[screenId].canvasHeight = bottomEdge; + } + } + }); + + // 화면 타입 추론 (componentKind 기준) + Object.values(summaryMap).forEach((summary: any) => { + if (summary.widgetCounts['table-list'] > 0) { + summary.screenType = 'grid'; + } else if (summary.widgetCounts['table-search-widget'] > 1) { + summary.screenType = 'dashboard'; + } else if (summary.totalComponents <= 5 && summary.widgetCounts['button-primary'] > 0) { + summary.screenType = 'action'; + } + }); + + logger.info("여러 화면 레이아웃 요약 조회", { screenIds, count: Object.keys(summaryMap).length }); + + res.json({ + success: true, + data: summaryMap, + }); + } catch (error: any) { + logger.error("여러 화면 레이아웃 요약 조회 실패:", error); + res.status(500).json({ success: false, message: "여러 화면 레이아웃 요약 조회에 실패했습니다.", error: error.message }); + } +}; + +// ============================================================ +// 화면 서브 테이블 관계 조회 (조인/참조 테이블) +// ============================================================ + +// 여러 화면의 서브 테이블 정보 조회 (메인 테이블 → 서브 테이블 관계) +export const getScreenSubTables = async (req: Request, res: Response) => { + try { + const { screenIds } = req.body; + + if (!screenIds || !Array.isArray(screenIds) || screenIds.length === 0) { + return res.status(400).json({ success: false, message: "screenIds 배열이 필요합니다." }); + } + + // 화면별 서브 테이블 그룹화 + const screenSubTables: Record; + }>; + saveTables?: Array<{ + tableName: string; + saveType: 'save' | 'edit' | 'delete' | 'transferData'; + componentType: string; + isMainTable: boolean; + }>; + }> = {}; + + // 1. 기존 방식: componentConfig에서 tableName, sourceTable, fieldMappings 추출 + const componentQuery = ` + SELECT DISTINCT + sd.screen_id, + sd.screen_name, + sd.table_name as main_table, + COALESCE( + sl.properties->'componentConfig'->>'tableName', + sl.properties->'componentConfig'->>'sourceTable' + ) as sub_table, + sl.properties->>'componentType' as component_type, + sl.properties->'componentConfig'->>'targetTable' as target_table, + sl.properties->'componentConfig'->'fieldMappings' as field_mappings, + sl.properties->'componentConfig'->'columns' as columns_config + FROM screen_definitions sd + JOIN screen_layouts sl ON sd.screen_id = sl.screen_id + WHERE sd.screen_id = ANY($1) + AND ( + sl.properties->'componentConfig'->>'tableName' IS NOT NULL + OR sl.properties->'componentConfig'->>'sourceTable' IS NOT NULL + ) + ORDER BY sd.screen_id + `; + + const componentResult = await pool.query(componentQuery, [screenIds]); + + // fieldMappings의 한글 컬럼명을 조회하기 위한 테이블-컬럼 쌍 수집 + const columnLabelLookups: Array<{ table: string; column: string }> = []; + componentResult.rows.forEach((row: any) => { + if (row.field_mappings && Array.isArray(row.field_mappings)) { + row.field_mappings.forEach((fm: any) => { + const mainTable = row.main_table; + const subTable = row.sub_table; + if (fm.sourceField && subTable) { + columnLabelLookups.push({ table: subTable, column: fm.sourceField }); + } + if (fm.targetField && mainTable) { + columnLabelLookups.push({ table: mainTable, column: fm.targetField }); + } + }); + } + }); + + // 한글 컬럼명 조회 + const columnLabelMap = new Map(); // "table.column" -> "한글명" + if (columnLabelLookups.length > 0) { + const uniqueLookups = [...new Set(columnLabelLookups.map(l => `${l.table}|${l.column}`))]; + const conditions = uniqueLookups.map((lookup, i) => { + const [table, column] = lookup.split('|'); + return `(table_name = $${i * 2 + 1} AND column_name = $${i * 2 + 2})`; + }); + const params = uniqueLookups.flatMap(lookup => lookup.split('|')); + + if (conditions.length > 0) { + const labelQuery = ` + SELECT table_name, column_name, column_label + FROM column_labels + WHERE ${conditions.join(' OR ')} + `; + const labelResult = await pool.query(labelQuery, params); + labelResult.rows.forEach((row: any) => { + const key = `${row.table_name}.${row.column_name}`; + columnLabelMap.set(key, row.column_label || row.column_name); + }); + } + } + + componentResult.rows.forEach((row: any) => { + const screenId = row.screen_id; + const mainTable = row.main_table; + const subTable = row.sub_table; + + // 메인 테이블과 동일한 경우 제외 + if (!subTable || subTable === mainTable) { + return; + } + + if (!screenSubTables[screenId]) { + screenSubTables[screenId] = { + screenId, + screenName: row.screen_name, + mainTable: mainTable || '', + subTables: [], + }; + } + + // 중복 체크 + const exists = screenSubTables[screenId].subTables.some( + (st) => st.tableName === subTable + ); + + if (!exists) { + // 관계 타입 추론 + let relationType = 'lookup'; + const componentType = row.component_type || ''; + if (componentType.includes('autocomplete') || componentType.includes('entity-search')) { + relationType = 'lookup'; + } else if (componentType.includes('modal-repeater') || componentType.includes('selected-items')) { + relationType = 'source'; + } else if (componentType.includes('table')) { + relationType = 'join'; + } + + // fieldMappings 파싱 (JSON 배열 또는 null) + let fieldMappings: Array<{ sourceField: string; targetField: string; sourceDisplayName?: string; targetDisplayName?: string }> | undefined; + + if (row.field_mappings && Array.isArray(row.field_mappings)) { + // 1. 직접 fieldMappings가 있는 경우 + fieldMappings = row.field_mappings.map((fm: any) => { + const sourceField = fm.sourceField || fm.source_field || ''; + const targetField = fm.targetField || fm.target_field || ''; + + // 한글 컬럼명 조회 (sourceField는 서브테이블 컬럼, targetField는 메인테이블 컬럼) + const sourceKey = `${subTable}.${sourceField}`; + const targetKey = `${mainTable}.${targetField}`; + + return { + sourceField, + targetField, + // sourceField(서브테이블 컬럼)의 한글명 + sourceDisplayName: columnLabelMap.get(sourceKey) || sourceField, + // targetField(메인테이블 컬럼)의 한글명 + targetDisplayName: columnLabelMap.get(targetKey) || targetField, + }; + }).filter((fm: any) => fm.sourceField || fm.targetField); + } else if (row.columns_config && Array.isArray(row.columns_config)) { + // 2. columns_config.mapping에서 추출 (item_info 같은 경우) + // mapping.type === 'source'인 경우: sourceField(서브테이블) → field(메인테이블) + fieldMappings = []; + row.columns_config.forEach((col: any) => { + if (col.mapping && col.mapping.type === 'source' && col.mapping.sourceField) { + fieldMappings!.push({ + sourceField: col.field || '', // 메인 테이블 컬럼 + targetField: col.mapping.sourceField || '', // 서브 테이블 컬럼 + sourceDisplayName: col.label || col.field || '', // 한글 라벨 + targetDisplayName: col.mapping.sourceField || '', // 서브 테이블은 영문만 + }); + } + }); + if (fieldMappings.length === 0) { + fieldMappings = undefined; + } + } + + screenSubTables[screenId].subTables.push({ + tableName: subTable, + componentType: componentType, + relationType: relationType, + fieldMappings: fieldMappings, + }); + } + }); + + // 2. 추가 방식: 화면에서 사용하는 컬럼 중 column_labels.reference_table이 설정된 경우 + // 화면의 usedColumns/joinColumns에서 reference_table 조회 + const referenceQuery = ` + WITH screen_used_columns AS ( + -- 화면별 사용 컬럼 추출 (componentConfig.columns에서) + SELECT + sd.screen_id, + sd.screen_name, + sd.table_name as main_table, + jsonb_array_elements_text( + COALESCE( + sl.properties->'componentConfig'->'columns', + '[]'::jsonb + ) + )::jsonb->>'columnName' as column_name + FROM screen_definitions sd + JOIN screen_layouts sl ON sd.screen_id = sl.screen_id + WHERE sd.screen_id = ANY($1) + AND sl.properties->'componentConfig'->'columns' IS NOT NULL + AND jsonb_array_length(sl.properties->'componentConfig'->'columns') > 0 + + UNION + + -- bindField도 포함 + SELECT + sd.screen_id, + sd.screen_name, + sd.table_name as main_table, + COALESCE( + sl.properties->'componentConfig'->>'bindField', + sl.properties->>'bindField', + sl.properties->'componentConfig'->>'field', + sl.properties->>'field' + ) as column_name + FROM screen_definitions sd + JOIN screen_layouts sl ON sd.screen_id = sl.screen_id + WHERE sd.screen_id = ANY($1) + AND ( + sl.properties->'componentConfig'->>'bindField' IS NOT NULL + OR sl.properties->>'bindField' IS NOT NULL + OR sl.properties->'componentConfig'->>'field' IS NOT NULL + OR sl.properties->>'field' IS NOT NULL + ) + + UNION + + -- valueField 추출 (entity-search-input, autocomplete-search-input 등에서 사용) + SELECT + sd.screen_id, + sd.screen_name, + sd.table_name as main_table, + sl.properties->'componentConfig'->>'valueField' as column_name + FROM screen_definitions sd + JOIN screen_layouts sl ON sd.screen_id = sl.screen_id + WHERE sd.screen_id = ANY($1) + AND sl.properties->'componentConfig'->>'valueField' IS NOT NULL + + UNION + + -- parentFieldId 추출 (캐스케이딩 관계에서 사용) + SELECT + sd.screen_id, + sd.screen_name, + sd.table_name as main_table, + sl.properties->'componentConfig'->>'parentFieldId' as column_name + FROM screen_definitions sd + JOIN screen_layouts sl ON sd.screen_id = sl.screen_id + WHERE sd.screen_id = ANY($1) + AND sl.properties->'componentConfig'->>'parentFieldId' IS NOT NULL + + UNION + + -- cascadingParentField 추출 (캐스케이딩 부모 필드) + SELECT + sd.screen_id, + sd.screen_name, + sd.table_name as main_table, + sl.properties->'componentConfig'->>'cascadingParentField' as column_name + FROM screen_definitions sd + JOIN screen_layouts sl ON sd.screen_id = sl.screen_id + WHERE sd.screen_id = ANY($1) + AND sl.properties->'componentConfig'->>'cascadingParentField' IS NOT NULL + + UNION + + -- controlField 추출 (conditional-container에서 사용) + SELECT + sd.screen_id, + sd.screen_name, + sd.table_name as main_table, + sl.properties->'componentConfig'->>'controlField' as column_name + FROM screen_definitions sd + JOIN screen_layouts sl ON sd.screen_id = sl.screen_id + WHERE sd.screen_id = ANY($1) + AND sl.properties->'componentConfig'->>'controlField' IS NOT NULL + ) + SELECT DISTINCT + suc.screen_id, + suc.screen_name, + suc.main_table, + suc.column_name, + cl.column_label as source_display_name, + cl.reference_table, + cl.reference_column, + ref_cl.column_label as target_display_name + FROM screen_used_columns suc + JOIN column_labels cl ON cl.table_name = suc.main_table AND cl.column_name = suc.column_name + LEFT JOIN column_labels ref_cl ON ref_cl.table_name = cl.reference_table AND ref_cl.column_name = cl.reference_column + WHERE cl.reference_table IS NOT NULL + AND cl.reference_table != '' + AND cl.reference_table != suc.main_table + AND cl.input_type = 'entity' + ORDER BY suc.screen_id + `; + + const referenceResult = await pool.query(referenceQuery, [screenIds]); + + logger.info("column_labels reference_table 조회 결과", { + screenIds, + referenceCount: referenceResult.rows.length, + references: referenceResult.rows.map((r: any) => ({ + screenId: r.screen_id, + column: r.column_name, + refTable: r.reference_table + })) + }); + + referenceResult.rows.forEach((row: any) => { + const screenId = row.screen_id; + const mainTable = row.main_table; + const referenceTable = row.reference_table; + + if (!referenceTable || referenceTable === mainTable) { + return; + } + + if (!screenSubTables[screenId]) { + screenSubTables[screenId] = { + screenId, + screenName: row.screen_name, + mainTable: mainTable || '', + subTables: [], + }; + } + + // 중복 체크 + const exists = screenSubTables[screenId].subTables.some( + (st) => st.tableName === referenceTable + ); + + if (!exists) { + screenSubTables[screenId].subTables.push({ + tableName: referenceTable, + componentType: 'column_reference', + relationType: 'reference', + fieldMappings: [{ + sourceField: row.column_name, + targetField: row.reference_column || 'id', + sourceDisplayName: row.source_display_name || row.column_name, + targetDisplayName: row.target_display_name || row.reference_column || 'id', + }], + }); + } else { + // 이미 존재하면 fieldMappings에 추가 + const existingSubTable = screenSubTables[screenId].subTables.find( + (st) => st.tableName === referenceTable + ); + if (existingSubTable && existingSubTable.fieldMappings) { + const mappingExists = existingSubTable.fieldMappings.some( + (fm) => fm.sourceField === row.column_name + ); + if (!mappingExists) { + existingSubTable.fieldMappings.push({ + sourceField: row.column_name, + targetField: row.reference_column || 'id', + sourceDisplayName: row.source_display_name || row.column_name, + targetDisplayName: row.target_display_name || row.reference_column || 'id', + }); + } + } + } + }); + + // 3. parentDataMapping 파싱 (selected-items-detail-input 등에서 사용) + const parentMappingQuery = ` + SELECT + sd.screen_id, + sd.screen_name, + sd.table_name as main_table, + sl.properties->>'componentType' as component_type, + sl.properties->'componentConfig'->'parentDataMapping' as parent_data_mapping + FROM screen_definitions sd + JOIN screen_layouts sl ON sd.screen_id = sl.screen_id + WHERE sd.screen_id = ANY($1) + AND sl.properties->'componentConfig'->'parentDataMapping' IS NOT NULL + `; + + const parentMappingResult = await pool.query(parentMappingQuery, [screenIds]); + + parentMappingResult.rows.forEach((row: any) => { + const screenId = row.screen_id; + const mainTable = row.main_table; + const componentType = row.component_type || 'parentDataMapping'; + const parentDataMapping = row.parent_data_mapping; + + if (!Array.isArray(parentDataMapping)) return; + + if (!screenSubTables[screenId]) { + screenSubTables[screenId] = { + screenId, + screenName: row.screen_name, + mainTable: mainTable || '', + subTables: [], + }; + } + + parentDataMapping.forEach((mapping: any) => { + const sourceTable = mapping.sourceTable; + if (!sourceTable || sourceTable === mainTable) return; + + // 중복 체크 + const existingSubTable = screenSubTables[screenId].subTables.find( + (st) => st.tableName === sourceTable + ); + + const newMapping = { + sourceTable: sourceTable, // 연관 테이블 정보 추가 + sourceField: mapping.sourceField || '', + targetField: mapping.targetField || '', + sourceDisplayName: mapping.sourceField || '', + targetDisplayName: mapping.targetField || '', + }; + + if (existingSubTable) { + // 이미 존재하면 fieldMappings에 추가 + if (!existingSubTable.fieldMappings) { + existingSubTable.fieldMappings = []; + } + const mappingExists = existingSubTable.fieldMappings.some( + (fm: any) => fm.sourceField === newMapping.sourceField && fm.targetField === newMapping.targetField + ); + if (!mappingExists) { + existingSubTable.fieldMappings.push(newMapping); + } + } else { + screenSubTables[screenId].subTables.push({ + tableName: sourceTable, + componentType: componentType, + relationType: 'parentMapping', + fieldMappings: [newMapping], + }); + } + }); + }); + + logger.info("parentDataMapping 파싱 완료", { + screenIds, + parentMappingCount: parentMappingResult.rows.length + }); + + // 4. rightPanel.relation 파싱 (split-panel-layout 등에서 사용) + const rightPanelQuery = ` + SELECT + sd.screen_id, + sd.screen_name, + sd.table_name as main_table, + sl.properties->>'componentType' as component_type, + sl.properties->'componentConfig'->'rightPanel'->'relation' as right_panel_relation, + sl.properties->'componentConfig'->'rightPanel'->'tableName' as right_panel_table, + sl.properties->'componentConfig'->'rightPanel'->'columns' as right_panel_columns + FROM screen_definitions sd + JOIN screen_layouts sl ON sd.screen_id = sl.screen_id + WHERE sd.screen_id = ANY($1) + AND sl.properties->'componentConfig'->'rightPanel'->'relation' IS NOT NULL + `; + + const rightPanelResult = await pool.query(rightPanelQuery, [screenIds]); + + // rightPanel.columns에서 참조되는 외부 테이블 수집 (예: customer_mng.customer_name → customer_mng) + const rightPanelJoinedTables: Map> = new Map(); // screenId_tableName → Set<참조테이블> + + rightPanelResult.rows.forEach((row: any) => { + const screenId = row.screen_id; + const rightPanelTable = row.right_panel_table; + const rightPanelColumns = row.right_panel_columns; + + if (rightPanelColumns && Array.isArray(rightPanelColumns)) { + rightPanelColumns.forEach((col: any) => { + const colName = col.name || col.columnName || col.field; + if (colName && colName.includes('.')) { + const refTable = colName.split('.')[0]; + const key = `${screenId}_${rightPanelTable}`; + if (!rightPanelJoinedTables.has(key)) { + rightPanelJoinedTables.set(key, new Set()); + } + rightPanelJoinedTables.get(key)!.add(refTable); + } + }); + } + }); + + rightPanelResult.rows.forEach((row: any) => { + const screenId = row.screen_id; + const mainTable = row.main_table; + const componentType = row.component_type || 'split-panel-layout'; + const relation = row.right_panel_relation; + const rightPanelTable = row.right_panel_table; + + // relation 객체에서 테이블 및 필드 매핑 추출 + const subTable = rightPanelTable || relation?.targetTable || relation?.tableName; + if (!subTable || subTable === mainTable) return; + + // rightPanel.columns에서 참조하는 외부 테이블 목록 + const key = `${screenId}_${subTable}`; + const joinedTables = rightPanelJoinedTables.get(key) ? Array.from(rightPanelJoinedTables.get(key)!) : []; + + if (!screenSubTables[screenId]) { + screenSubTables[screenId] = { + screenId, + screenName: row.screen_name, + mainTable: mainTable || '', + subTables: [], + }; + } + + // 중복 체크 + const existingSubTable = screenSubTables[screenId].subTables.find( + (st) => st.tableName === subTable + ); + + // relation에서 필드 매핑 추출 + const fieldMappings: Array<{ sourceField: string; targetField: string; sourceDisplayName?: string; targetDisplayName?: string }> = []; + + if (relation?.sourceField && relation?.targetField) { + fieldMappings.push({ + sourceField: relation.sourceField, + targetField: relation.targetField, + sourceDisplayName: relation.sourceField, + targetDisplayName: relation.targetField, + }); + } + + // fieldMappings 배열이 있는 경우 + if (relation?.fieldMappings && Array.isArray(relation.fieldMappings)) { + relation.fieldMappings.forEach((fm: any) => { + fieldMappings.push({ + sourceField: fm.sourceField || fm.source_field || '', + targetField: fm.targetField || fm.target_field || '', + sourceDisplayName: fm.sourceField || fm.source_field || '', + targetDisplayName: fm.targetField || fm.target_field || '', + }); + }); + } + + if (existingSubTable) { + // 이미 존재하면 fieldMappings에 추가 + if (!existingSubTable.fieldMappings) { + existingSubTable.fieldMappings = []; + } + fieldMappings.forEach((newMapping) => { + const mappingExists = existingSubTable.fieldMappings!.some( + (fm) => fm.sourceField === newMapping.sourceField && fm.targetField === newMapping.targetField + ); + if (!mappingExists) { + existingSubTable.fieldMappings!.push(newMapping); + } + }); + // 추가 정보도 업데이트 + if (relation?.type) { + (existingSubTable as any).originalRelationType = relation.type; + } + if (relation?.foreignKey) { + (existingSubTable as any).foreignKey = relation.foreignKey; + } + if (relation?.leftColumn) { + (existingSubTable as any).leftColumn = relation.leftColumn; + } + } else { + screenSubTables[screenId].subTables.push({ + tableName: subTable, + componentType: componentType, + relationType: 'rightPanelRelation', + // 관계 유형 추론을 위한 추가 정보 + originalRelationType: relation?.type || 'join', // 원본 relation.type ("join" | "detail") + foreignKey: relation?.foreignKey, // 디테일 테이블의 FK 컬럼 + leftColumn: relation?.leftColumn, // 마스터 테이블의 선택 기준 컬럼 + joinedTables: joinedTables.length > 0 ? joinedTables : undefined, // rightPanel.columns에서 참조하는 외부 테이블들 + fieldMappings: fieldMappings.length > 0 ? fieldMappings : undefined, + } as any); + } + }); + + logger.info("rightPanel.relation 파싱 완료", { + screenIds, + rightPanelCount: rightPanelResult.rows.length + }); + + // 5. joinedTables에 대한 FK 컬럼을 column_labels에서 조회 + // rightPanelRelation에서 joinedTables가 있는 경우, 해당 테이블과 조인하는 FK 컬럼 찾기 + const joinedTableFKLookups: Array<{ subTableName: string; refTable: string }> = []; + Object.values(screenSubTables).forEach((screenData: any) => { + screenData.subTables.forEach((subTable: any) => { + if (subTable.joinedTables && Array.isArray(subTable.joinedTables)) { + subTable.joinedTables.forEach((refTable: string) => { + joinedTableFKLookups.push({ subTableName: subTable.tableName, refTable }); + }); + } + }); + }); + + // column_labels에서 FK 컬럼 조회 (reference_table로 조인하는 컬럼 찾기) + const joinColumnsByTable: { [key: string]: string[] } = {}; // tableName → [FK 컬럼들] + if (joinedTableFKLookups.length > 0) { + const uniqueLookups = joinedTableFKLookups.filter((item, index, self) => + index === self.findIndex((t) => t.subTableName === item.subTableName && t.refTable === item.refTable) + ); + + // 각 subTable에 대해 reference_table이 일치하는 컬럼 조회 + const subTableNames = [...new Set(uniqueLookups.map(l => l.subTableName))]; + const refTableNames = [...new Set(uniqueLookups.map(l => l.refTable))]; + + const fkQuery = ` + SELECT + cl.table_name, + cl.column_name, + cl.column_label, + cl.reference_table, + cl.reference_column, + tl.table_label as reference_table_label + FROM column_labels cl + LEFT JOIN table_labels tl ON cl.reference_table = tl.table_name + WHERE cl.table_name = ANY($1) + AND cl.reference_table = ANY($2) + `; + + const fkResult = await pool.query(fkQuery, [subTableNames, refTableNames]); + + // 참조 정보 포함 객체 배열로 저장 (한글명 포함) + const joinColumnRefsByTable: Record> = {}; + + fkResult.rows.forEach((row: any) => { + if (!joinColumnRefsByTable[row.table_name]) { + joinColumnRefsByTable[row.table_name] = []; + } + // 중복 체크 + const exists = joinColumnRefsByTable[row.table_name].some( + (ref) => ref.column === row.column_name && ref.refTable === row.reference_table + ); + if (!exists) { + joinColumnRefsByTable[row.table_name].push({ + column: row.column_name, + columnLabel: row.column_label || row.column_name, // 컬럼 한글명 (없으면 영문명) + refTable: row.reference_table, + refTableLabel: row.reference_table_label || row.reference_table, // 참조 테이블 한글명 (없으면 영문명) + refColumn: row.reference_column || 'id', + }); + } + }); + + // subTables에 joinColumns (문자열 배열) 및 joinColumnRefs (참조 정보 배열) 추가 + Object.values(screenSubTables).forEach((screenData: any) => { + screenData.subTables.forEach((subTable: any) => { + const refs = joinColumnRefsByTable[subTable.tableName]; + if (refs) { + (subTable as any).joinColumns = refs.map(r => r.column); + (subTable as any).joinColumnRefs = refs; + } + }); + }); + + logger.info("rightPanel joinedTables FK 조회 완료", { + lookupCount: uniqueLookups.length, + resultCount: fkResult.rows.length, + joinColumnsByTable + }); + } + + // 5. 모든 fieldMappings의 한글명을 column_labels에서 가져와서 적용 + // 모든 테이블/컬럼 조합을 수집 + const columnLookups: Array<{ tableName: string; columnName: string }> = []; + Object.values(screenSubTables).forEach((screenData: any) => { + screenData.subTables.forEach((subTable: any) => { + if (subTable.fieldMappings) { + subTable.fieldMappings.forEach((mapping: any) => { + // sourceTable + sourceField (연관 테이블의 컬럼) + if (mapping.sourceTable && mapping.sourceField) { + columnLookups.push({ tableName: mapping.sourceTable, columnName: mapping.sourceField }); + } + // mainTable + targetField (메인 테이블의 컬럼) + if (screenData.mainTable && mapping.targetField) { + columnLookups.push({ tableName: screenData.mainTable, columnName: mapping.targetField }); + } + }); + } + }); + }); + + // 중복 제거 + const uniqueColumnLookups = columnLookups.filter((item, index, self) => + index === self.findIndex((t) => t.tableName === item.tableName && t.columnName === item.columnName) + ); + + // column_labels에서 한글명 조회 + const columnLabelsMap: { [key: string]: string } = {}; + if (uniqueColumnLookups.length > 0) { + const columnLabelsQuery = ` + SELECT + table_name, + column_name, + column_label + FROM column_labels + WHERE (table_name, column_name) IN ( + ${uniqueColumnLookups.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`).join(', ')} + ) + `; + const columnLabelsParams = uniqueColumnLookups.flatMap(item => [item.tableName, item.columnName]); + + try { + const columnLabelsResult = await pool.query(columnLabelsQuery, columnLabelsParams); + columnLabelsResult.rows.forEach((row: any) => { + const key = `${row.table_name}.${row.column_name}`; + columnLabelsMap[key] = row.column_label; + }); + logger.info("column_labels 한글명 조회 완료", { count: columnLabelsResult.rows.length }); + } catch (error: any) { + logger.warn("column_labels 한글명 조회 실패 (무시하고 계속 진행):", error.message); + } + } + + // 각 fieldMappings에 한글명 적용 + Object.values(screenSubTables).forEach((screenData: any) => { + screenData.subTables.forEach((subTable: any) => { + if (subTable.fieldMappings) { + subTable.fieldMappings.forEach((mapping: any) => { + // sourceDisplayName: 연관 테이블의 컬럼 한글명 + if (mapping.sourceTable && mapping.sourceField) { + const sourceKey = `${mapping.sourceTable}.${mapping.sourceField}`; + if (columnLabelsMap[sourceKey]) { + mapping.sourceDisplayName = columnLabelsMap[sourceKey]; + } + } + // targetDisplayName: 메인 테이블의 컬럼 한글명 + if (screenData.mainTable && mapping.targetField) { + const targetKey = `${screenData.mainTable}.${mapping.targetField}`; + if (columnLabelsMap[targetKey]) { + mapping.targetDisplayName = columnLabelsMap[targetKey]; + } + } + }); + } + }); + }); + + // ============================================================ + // 저장 테이블 정보 추출 + // ============================================================ + // 제외 조건: + // 1. table-list + 체크박스 활성화 + openModalWithData 버튼이 있는 화면 + // → 선택 후 다음 화면으로 넘기는 패턴 (실제 DB 저장 아님) + const saveTableQuery = ` + SELECT DISTINCT + sd.screen_id, + sd.screen_name, + sd.table_name as main_table, + sl.properties->'componentConfig'->'action'->>'type' as action_type, + sl.properties->>'componentType' as component_type, + sl.properties->'componentConfig'->>'targetTable' as target_table, + sl.properties->'componentConfig'->'action'->'dataTransfer'->>'targetTable' as transfer_target_table + FROM screen_definitions sd + JOIN screen_layouts sl ON sd.screen_id = sl.screen_id + WHERE sd.screen_id = ANY($1) + AND sl.properties->'componentConfig'->'action'->>'type' = 'save' + AND sl.properties->'componentConfig'->'action'->>'targetScreenId' IS NULL + -- 제외: table-list + 체크박스가 있는 화면 + AND NOT EXISTS ( + SELECT 1 FROM screen_layouts sl_list + WHERE sl_list.screen_id = sd.screen_id + AND sl_list.properties->>'componentType' = 'table-list' + AND (sl_list.properties->'componentConfig'->'checkbox'->>'enabled')::boolean = true + ) + -- 제외: openModalWithData 버튼이 있는 화면 (선택 → 다음 화면 패턴) + AND NOT EXISTS ( + SELECT 1 FROM screen_layouts sl_modal + WHERE sl_modal.screen_id = sd.screen_id + AND sl_modal.properties->'componentConfig'->'action'->>'type' = 'openModalWithData' + ) + ORDER BY sd.screen_id + `; + + const saveTableResult = await pool.query(saveTableQuery, [screenIds]); + + saveTableResult.rows.forEach((row: any) => { + const screenId = row.screen_id; + const mainTable = row.main_table; + const actionType = row.action_type as 'save' | 'edit' | 'delete' | 'transferData'; + const componentType = row.component_type || 'component'; + const targetTable = row.target_table || row.transfer_target_table || mainTable; + + // 화면 정보가 없으면 초기화 + if (!screenSubTables[screenId]) { + screenSubTables[screenId] = { + screenId, + screenName: row.screen_name, + mainTable: mainTable || '', + subTables: [], + saveTables: [], + }; + } + + // saveTables 배열 초기화 + if (!screenSubTables[screenId].saveTables) { + screenSubTables[screenId].saveTables = []; + } + + // 중복 체크 + const existingSaveTable = screenSubTables[screenId].saveTables!.find( + (st) => st.tableName === targetTable && st.saveType === actionType + ); + + if (!existingSaveTable && targetTable) { + screenSubTables[screenId].saveTables!.push({ + tableName: targetTable, + saveType: actionType, + componentType, + isMainTable: targetTable === mainTable, + }); + } + }); + + logger.info("화면 서브 테이블 정보 조회 완료", { + screenIds, + resultCount: Object.keys(screenSubTables).length, + details: Object.values(screenSubTables).map(s => ({ + screenId: s.screenId, + mainTable: s.mainTable, + subTables: s.subTables.map(st => st.tableName), + saveTables: s.saveTables?.map(st => st.tableName) || [] + })) + }); + + res.json({ + success: true, + data: screenSubTables, + }); + } catch (error: any) { + logger.error("화면 서브 테이블 정보 조회 실패:", error); + res.status(500).json({ success: false, message: "화면 서브 테이블 정보 조회에 실패했습니다.", error: error.message }); + } +}; + diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 65cd5f4c..e8c5a1bb 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -775,18 +775,25 @@ export async function getTableData( const userField = autoFilter?.userField || "companyCode"; const userValue = (req.user as any)[userField]; - // 🆕 최고 관리자(company_code = '*')는 모든 회사 데이터 조회 가능 - if (userValue && userValue !== "*") { - enhancedSearch[filterColumn] = userValue; + // 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용) + let finalCompanyCode = userValue; + if (autoFilter?.companyCodeOverride && userValue === "*") { + // 최고 관리자만 다른 회사 코드로 오버라이드 가능 + finalCompanyCode = autoFilter.companyCodeOverride; + logger.info("🔓 최고 관리자 회사 코드 오버라이드:", { + originalCompanyCode: userValue, + overrideCompanyCode: autoFilter.companyCodeOverride, + tableName, + }); + } + + if (finalCompanyCode) { + enhancedSearch[filterColumn] = finalCompanyCode; logger.info("🔍 현재 사용자 필터 적용:", { filterColumn, userField, - userValue, - tableName, - }); - } else if (userValue === "*") { - logger.info("🔓 최고 관리자 - 회사 필터 미적용 (모든 회사 데이터 조회)", { + userValue: finalCompanyCode, tableName, }); } else { @@ -798,7 +805,10 @@ export async function getTableData( } // 🆕 최종 검색 조건 로그 - logger.info(`🔍 최종 검색 조건 (enhancedSearch):`, JSON.stringify(enhancedSearch)); + logger.info( + `🔍 최종 검색 조건 (enhancedSearch):`, + JSON.stringify(enhancedSearch) + ); // 데이터 조회 const result = await tableManagementService.getTableData(tableName, { @@ -883,7 +893,10 @@ export async function addTableData( const companyCode = req.user?.companyCode; if (companyCode && !data.company_code) { // 테이블에 company_code 컬럼이 있는지 확인 - const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code"); + const hasCompanyCodeColumn = await tableManagementService.hasColumn( + tableName, + "company_code" + ); if (hasCompanyCodeColumn) { data.company_code = companyCode; logger.info(`멀티테넌시: company_code 자동 추가 - ${companyCode}`); @@ -893,7 +906,10 @@ export async function addTableData( // 🆕 writer 컬럼 자동 추가 (테이블에 writer 컬럼이 있고 값이 없는 경우) const userId = req.user?.userId; if (userId && !data.writer) { - const hasWriterColumn = await tableManagementService.hasColumn(tableName, "writer"); + const hasWriterColumn = await tableManagementService.hasColumn( + tableName, + "writer" + ); if (hasWriterColumn) { data.writer = userId; logger.info(`writer 자동 추가 - ${userId}`); @@ -911,11 +927,13 @@ export async function addTableData( savedColumns?: string[]; }> = { success: true, - message: result.skippedColumns.length > 0 - ? `테이블 데이터를 추가했습니다. (무시된 컬럼 ${result.skippedColumns.length}개: ${result.skippedColumns.join(", ")})` - : "테이블 데이터를 성공적으로 추가했습니다.", + message: + result.skippedColumns.length > 0 + ? `테이블 데이터를 추가했습니다. (무시된 컬럼 ${result.skippedColumns.length}개: ${result.skippedColumns.join(", ")})` + : "테이블 데이터를 성공적으로 추가했습니다.", data: { - skippedColumns: result.skippedColumns.length > 0 ? result.skippedColumns : undefined, + skippedColumns: + result.skippedColumns.length > 0 ? result.skippedColumns : undefined, savedColumns: result.savedColumns, }, }; @@ -1645,10 +1663,10 @@ export async function toggleLogTable( /** * 메뉴의 상위 메뉴들이 설정한 모든 카테고리 타입 컬럼 조회 (계층 구조 상속) - * + * * @route GET /api/table-management/menu/:menuObjid/category-columns * @description 현재 메뉴와 상위 메뉴들에서 설정한 category_column_mapping의 모든 카테고리 컬럼 조회 - * + * * 예시: * - 2레벨 메뉴 "고객사관리"에서 discount_type, rounding_type 설정 * - 3레벨 메뉴 "고객등록", "고객조회" 등에서도 동일하게 보임 (상속) @@ -1661,7 +1679,10 @@ export async function getCategoryColumnsByMenu( const { menuObjid } = req.params; const companyCode = req.user?.companyCode; - logger.info("📥 메뉴별 카테고리 컬럼 조회 요청", { menuObjid, companyCode }); + logger.info("📥 메뉴별 카테고리 컬럼 조회 요청", { + menuObjid, + companyCode, + }); if (!menuObjid) { res.status(400).json({ @@ -1687,8 +1708,11 @@ export async function getCategoryColumnsByMenu( if (mappingTableExists) { // 🆕 category_column_mapping을 사용한 계층 구조 기반 조회 - logger.info("🔍 category_column_mapping 기반 카테고리 컬럼 조회 (계층 구조 상속)", { menuObjid, companyCode }); - + logger.info( + "🔍 category_column_mapping 기반 카테고리 컬럼 조회 (계층 구조 상속)", + { menuObjid, companyCode } + ); + // 현재 메뉴와 모든 상위 메뉴의 objid 조회 (재귀) const ancestorMenuQuery = ` WITH RECURSIVE menu_hierarchy AS ( @@ -1710,17 +1734,21 @@ export async function getCategoryColumnsByMenu( ARRAY_AGG(menu_name_kor) as menu_names FROM menu_hierarchy `; - - const ancestorMenuResult = await pool.query(ancestorMenuQuery, [parseInt(menuObjid)]); - const ancestorMenuObjids = ancestorMenuResult.rows[0]?.menu_objids || [parseInt(menuObjid)]; + + const ancestorMenuResult = await pool.query(ancestorMenuQuery, [ + parseInt(menuObjid), + ]); + const ancestorMenuObjids = ancestorMenuResult.rows[0]?.menu_objids || [ + parseInt(menuObjid), + ]; const ancestorMenuNames = ancestorMenuResult.rows[0]?.menu_names || []; - - logger.info("✅ 상위 메뉴 계층 조회 완료", { - ancestorMenuObjids, + + logger.info("✅ 상위 메뉴 계층 조회 완료", { + ancestorMenuObjids, ancestorMenuNames, - hierarchyDepth: ancestorMenuObjids.length + hierarchyDepth: ancestorMenuObjids.length, }); - + // 상위 메뉴들에 설정된 모든 카테고리 컬럼 조회 (테이블 필터링 제거) const columnsQuery = ` SELECT DISTINCT @@ -1750,20 +1778,31 @@ export async function getCategoryColumnsByMenu( AND ttc.input_type = 'category' ORDER BY ttc.table_name, ccm.logical_column_name `; - - columnsResult = await pool.query(columnsQuery, [companyCode, ancestorMenuObjids]); - logger.info("✅ category_column_mapping 기반 조회 완료 (계층 구조 상속)", { - rowCount: columnsResult.rows.length, - columns: columnsResult.rows.map((r: any) => `${r.tableName}.${r.columnName}`) - }); + + columnsResult = await pool.query(columnsQuery, [ + companyCode, + ancestorMenuObjids, + ]); + logger.info( + "✅ category_column_mapping 기반 조회 완료 (계층 구조 상속)", + { + rowCount: columnsResult.rows.length, + columns: columnsResult.rows.map( + (r: any) => `${r.tableName}.${r.columnName}` + ), + } + ); } else { // 🔄 레거시 방식: 형제 메뉴들의 테이블에서 모든 카테고리 컬럼 조회 - logger.info("🔍 레거시 방식: 형제 메뉴 테이블 기반 카테고리 컬럼 조회", { menuObjid, companyCode }); - + logger.info("🔍 레거시 방식: 형제 메뉴 테이블 기반 카테고리 컬럼 조회", { + menuObjid, + companyCode, + }); + // 형제 메뉴 조회 const { getSiblingMenuObjids } = await import("../services/menuService"); const siblingObjids = await getSiblingMenuObjids(parseInt(menuObjid)); - + // 형제 메뉴들이 사용하는 테이블 조회 const tablesQuery = ` SELECT DISTINCT sd.table_name @@ -1773,11 +1812,17 @@ export async function getCategoryColumnsByMenu( AND sma.company_code = $2 AND sd.table_name IS NOT NULL `; - - const tablesResult = await pool.query(tablesQuery, [siblingObjids, companyCode]); + + const tablesResult = await pool.query(tablesQuery, [ + siblingObjids, + companyCode, + ]); const tableNames = tablesResult.rows.map((row: any) => row.table_name); - - logger.info("✅ 형제 메뉴 테이블 조회 완료", { tableNames, count: tableNames.length }); + + logger.info("✅ 형제 메뉴 테이블 조회 완료", { + tableNames, + count: tableNames.length, + }); if (tableNames.length === 0) { res.json({ @@ -1787,7 +1832,7 @@ export async function getCategoryColumnsByMenu( }); return; } - + const columnsQuery = ` SELECT ttc.table_name AS "tableName", @@ -1812,13 +1857,15 @@ export async function getCategoryColumnsByMenu( AND ttc.input_type = 'category' ORDER BY ttc.table_name, ttc.column_name `; - + columnsResult = await pool.query(columnsQuery, [tableNames, companyCode]); - logger.info("✅ 레거시 방식 조회 완료", { rowCount: columnsResult.rows.length }); + logger.info("✅ 레거시 방식 조회 완료", { + rowCount: columnsResult.rows.length, + }); } - - logger.info("✅ 카테고리 컬럼 조회 완료", { - columnCount: columnsResult.rows.length + + logger.info("✅ 카테고리 컬럼 조회 완료", { + columnCount: columnsResult.rows.length, }); res.json({ @@ -1843,9 +1890,9 @@ export async function getCategoryColumnsByMenu( /** * 범용 다중 테이블 저장 API - * + * * 메인 테이블과 서브 테이블(들)에 트랜잭션으로 데이터를 저장합니다. - * + * * 요청 본문: * { * mainTable: { tableName: string, primaryKeyColumn: string }, @@ -1915,23 +1962,29 @@ export async function multiTableSave( } let mainResult: any; - + if (isUpdate && pkValue) { // UPDATE const updateColumns = Object.keys(mainData) - .filter(col => col !== pkColumn) + .filter((col) => col !== pkColumn) .map((col, idx) => `"${col}" = $${idx + 1}`) .join(", "); const updateValues = Object.keys(mainData) - .filter(col => col !== pkColumn) - .map(col => mainData[col]); - + .filter((col) => col !== pkColumn) + .map((col) => mainData[col]); + // updated_at 컬럼 존재 여부 확인 - const hasUpdatedAt = await client.query(` + const hasUpdatedAt = await client.query( + ` SELECT 1 FROM information_schema.columns WHERE table_name = $1 AND column_name = 'updated_at' - `, [mainTableName]); - const updatedAtClause = hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 ? ", updated_at = NOW()" : ""; + `, + [mainTableName] + ); + const updatedAtClause = + hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 + ? ", updated_at = NOW()" + : ""; const updateQuery = ` UPDATE "${mainTableName}" @@ -1940,29 +1993,43 @@ export async function multiTableSave( ${companyCode !== "*" ? `AND company_code = $${updateValues.length + 2}` : ""} RETURNING * `; - - const updateParams = companyCode !== "*" - ? [...updateValues, pkValue, companyCode] - : [...updateValues, pkValue]; - - logger.info("메인 테이블 UPDATE:", { query: updateQuery, paramsCount: updateParams.length }); + + const updateParams = + companyCode !== "*" + ? [...updateValues, pkValue, companyCode] + : [...updateValues, pkValue]; + + logger.info("메인 테이블 UPDATE:", { + query: updateQuery, + paramsCount: updateParams.length, + }); mainResult = await client.query(updateQuery, updateParams); } else { // INSERT - const columns = Object.keys(mainData).map(col => `"${col}"`).join(", "); - const placeholders = Object.keys(mainData).map((_, idx) => `$${idx + 1}`).join(", "); + const columns = Object.keys(mainData) + .map((col) => `"${col}"`) + .join(", "); + const placeholders = Object.keys(mainData) + .map((_, idx) => `$${idx + 1}`) + .join(", "); const values = Object.values(mainData); // updated_at 컬럼 존재 여부 확인 - const hasUpdatedAt = await client.query(` + const hasUpdatedAt = await client.query( + ` SELECT 1 FROM information_schema.columns WHERE table_name = $1 AND column_name = 'updated_at' - `, [mainTableName]); - const updatedAtClause = hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 ? ", updated_at = NOW()" : ""; + `, + [mainTableName] + ); + const updatedAtClause = + hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 + ? ", updated_at = NOW()" + : ""; const updateSetClause = Object.keys(mainData) - .filter(col => col !== pkColumn) - .map(col => `"${col}" = EXCLUDED."${col}"`) + .filter((col) => col !== pkColumn) + .map((col) => `"${col}" = EXCLUDED."${col}"`) .join(", "); const insertQuery = ` @@ -1973,7 +2040,10 @@ export async function multiTableSave( RETURNING * `; - logger.info("메인 테이블 INSERT/UPSERT:", { query: insertQuery, paramsCount: values.length }); + logger.info("메인 테이블 INSERT/UPSERT:", { + query: insertQuery, + paramsCount: values.length, + }); mainResult = await client.query(insertQuery, values); } @@ -1992,12 +2062,15 @@ export async function multiTableSave( const { tableName, linkColumn, items, options } = subTableConfig; // saveMainAsFirst가 활성화된 경우, items가 비어있어도 메인 데이터를 서브 테이블에 저장해야 함 - const hasSaveMainAsFirst = options?.saveMainAsFirst && - options?.mainFieldMappings && - options.mainFieldMappings.length > 0; - + const hasSaveMainAsFirst = + options?.saveMainAsFirst && + options?.mainFieldMappings && + options.mainFieldMappings.length > 0; + if (!tableName || (!items?.length && !hasSaveMainAsFirst)) { - logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음 (saveMainAsFirst: ${hasSaveMainAsFirst})`); + logger.info( + `서브 테이블 ${tableName} 스킵: 데이터 없음 (saveMainAsFirst: ${hasSaveMainAsFirst})` + ); continue; } @@ -2010,15 +2083,20 @@ export async function multiTableSave( // 기존 데이터 삭제 옵션 if (options?.deleteExistingBefore && linkColumn?.subColumn) { - const deleteQuery = options?.deleteOnlySubItems && options?.mainMarkerColumn - ? `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1 AND "${options.mainMarkerColumn}" = $2` - : `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1`; - - const deleteParams = options?.deleteOnlySubItems && options?.mainMarkerColumn - ? [savedPkValue, options.subMarkerValue ?? false] - : [savedPkValue]; + const deleteQuery = + options?.deleteOnlySubItems && options?.mainMarkerColumn + ? `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1 AND "${options.mainMarkerColumn}" = $2` + : `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1`; - logger.info(`서브 테이블 ${tableName} 기존 데이터 삭제:`, { deleteQuery, deleteParams }); + const deleteParams = + options?.deleteOnlySubItems && options?.mainMarkerColumn + ? [savedPkValue, options.subMarkerValue ?? false] + : [savedPkValue]; + + logger.info(`서브 테이블 ${tableName} 기존 데이터 삭제:`, { + deleteQuery, + deleteParams, + }); await client.query(deleteQuery, deleteParams); } @@ -2031,7 +2109,12 @@ export async function multiTableSave( linkColumn, mainDataKeys: Object.keys(mainData), }); - if (options?.saveMainAsFirst && options?.mainFieldMappings && options.mainFieldMappings.length > 0 && linkColumn?.subColumn) { + if ( + options?.saveMainAsFirst && + options?.mainFieldMappings && + options.mainFieldMappings.length > 0 && + linkColumn?.subColumn + ) { const mainSubItem: Record = { [linkColumn.subColumn]: savedPkValue, }; @@ -2045,7 +2128,8 @@ export async function multiTableSave( // 메인 마커 설정 if (options.mainMarkerColumn) { - mainSubItem[options.mainMarkerColumn] = options.mainMarkerValue ?? true; + mainSubItem[options.mainMarkerColumn] = + options.mainMarkerValue ?? true; } // company_code 추가 @@ -2068,20 +2152,30 @@ export async function multiTableSave( if (companyCode !== "*") { checkParams.push(companyCode); } - + const existingResult = await client.query(checkQuery, checkParams); - + if (existingResult.rows.length > 0) { // UPDATE const updateColumns = Object.keys(mainSubItem) - .filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn && col !== "company_code") + .filter( + (col) => + col !== linkColumn.subColumn && + col !== options.mainMarkerColumn && + col !== "company_code" + ) .map((col, idx) => `"${col}" = $${idx + 1}`) .join(", "); - + const updateValues = Object.keys(mainSubItem) - .filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn && col !== "company_code") - .map(col => mainSubItem[col]); - + .filter( + (col) => + col !== linkColumn.subColumn && + col !== options.mainMarkerColumn && + col !== "company_code" + ) + .map((col) => mainSubItem[col]); + if (updateColumns) { const updateQuery = ` UPDATE "${tableName}" @@ -2100,14 +2194,26 @@ export async function multiTableSave( } const updateResult = await client.query(updateQuery, updateParams); - subTableResults.push({ tableName, type: "main", data: updateResult.rows[0] }); + subTableResults.push({ + tableName, + type: "main", + data: updateResult.rows[0], + }); } else { - subTableResults.push({ tableName, type: "main", data: existingResult.rows[0] }); + subTableResults.push({ + tableName, + type: "main", + data: existingResult.rows[0], + }); } } else { // INSERT - const mainSubColumns = Object.keys(mainSubItem).map(col => `"${col}"`).join(", "); - const mainSubPlaceholders = Object.keys(mainSubItem).map((_, idx) => `$${idx + 1}`).join(", "); + const mainSubColumns = Object.keys(mainSubItem) + .map((col) => `"${col}"`) + .join(", "); + const mainSubPlaceholders = Object.keys(mainSubItem) + .map((_, idx) => `$${idx + 1}`) + .join(", "); const mainSubValues = Object.values(mainSubItem); const insertQuery = ` @@ -2117,7 +2223,11 @@ export async function multiTableSave( `; const insertResult = await client.query(insertQuery, mainSubValues); - subTableResults.push({ tableName, type: "main", data: insertResult.rows[0] }); + subTableResults.push({ + tableName, + type: "main", + data: insertResult.rows[0], + }); } } @@ -2133,8 +2243,12 @@ export async function multiTableSave( item.company_code = companyCode; } - const subColumns = Object.keys(item).map(col => `"${col}"`).join(", "); - const subPlaceholders = Object.keys(item).map((_, idx) => `$${idx + 1}`).join(", "); + const subColumns = Object.keys(item) + .map((col) => `"${col}"`) + .join(", "); + const subPlaceholders = Object.keys(item) + .map((_, idx) => `$${idx + 1}`) + .join(", "); const subValues = Object.values(item); const subInsertQuery = ` @@ -2143,9 +2257,16 @@ export async function multiTableSave( RETURNING * `; - logger.info(`서브 테이블 ${tableName} 아이템 저장:`, { subInsertQuery, subValuesCount: subValues.length }); + logger.info(`서브 테이블 ${tableName} 아이템 저장:`, { + subInsertQuery, + subValuesCount: subValues.length, + }); const subResult = await client.query(subInsertQuery, subValues); - subTableResults.push({ tableName, type: "sub", data: subResult.rows[0] }); + subTableResults.push({ + tableName, + type: "sub", + data: subResult.rows[0], + }); } logger.info(`서브 테이블 ${tableName} 저장 완료`); @@ -2188,7 +2309,7 @@ export async function multiTableSave( /** * 두 테이블 간의 엔티티 관계 자동 감지 * GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy - * + * * column_labels에서 정의된 엔티티/카테고리 타입 설정을 기반으로 * 두 테이블 간의 외래키 관계를 자동으로 감지합니다. */ @@ -2199,7 +2320,9 @@ export async function getTableEntityRelations( try { const { leftTable, rightTable } = req.query; - logger.info(`=== 테이블 엔티티 관계 조회 시작: ${leftTable} <-> ${rightTable} ===`); + logger.info( + `=== 테이블 엔티티 관계 조회 시작: ${leftTable} <-> ${rightTable} ===` + ); if (!leftTable || !rightTable) { const response: ApiResponse = { @@ -2248,4 +2371,3 @@ export async function getTableEntityRelations( res.status(500).json(response); } } - diff --git a/backend-node/src/routes/cascadingAutoFillRoutes.ts b/backend-node/src/routes/cascadingAutoFillRoutes.ts index c1d69e9f..acb0cbc7 100644 --- a/backend-node/src/routes/cascadingAutoFillRoutes.ts +++ b/backend-node/src/routes/cascadingAutoFillRoutes.ts @@ -56,3 +56,4 @@ export default router; + diff --git a/backend-node/src/routes/cascadingConditionRoutes.ts b/backend-node/src/routes/cascadingConditionRoutes.ts index bbc9384d..96ab25be 100644 --- a/backend-node/src/routes/cascadingConditionRoutes.ts +++ b/backend-node/src/routes/cascadingConditionRoutes.ts @@ -52,3 +52,4 @@ export default router; + diff --git a/backend-node/src/routes/cascadingHierarchyRoutes.ts b/backend-node/src/routes/cascadingHierarchyRoutes.ts index 35ced071..f77019be 100644 --- a/backend-node/src/routes/cascadingHierarchyRoutes.ts +++ b/backend-node/src/routes/cascadingHierarchyRoutes.ts @@ -68,3 +68,4 @@ export default router; + diff --git a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts index 29ac8ee4..6e4094f1 100644 --- a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts +++ b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts @@ -56,3 +56,4 @@ export default router; + diff --git a/backend-node/src/routes/multilangRoutes.ts b/backend-node/src/routes/multilangRoutes.ts index 47137346..00ec04d6 100644 --- a/backend-node/src/routes/multilangRoutes.ts +++ b/backend-node/src/routes/multilangRoutes.ts @@ -21,6 +21,20 @@ import { getUserText, getLangText, getBatchTranslations, + + // 카테고리 관리 API + getCategories, + getCategoryById, + getCategoryPath, + + // 자동 생성 및 오버라이드 API + generateKey, + previewKey, + createOverrideKey, + getOverrideKeys, + + // 화면 라벨 다국어 API + generateScreenLabelKeys, } from "../controllers/multilangController"; const router = express.Router(); @@ -51,4 +65,18 @@ router.post("/keys/:keyId/texts", saveLangTexts); // 다국어 텍스트 저장/ router.get("/user-text/:companyCode/:menuCode/:langKey", getUserText); // 사용자별 다국어 텍스트 조회 router.get("/text/:companyCode/:langKey/:langCode", getLangText); // 특정 키의 다국어 텍스트 조회 +// 카테고리 관리 API +router.get("/categories", getCategories); // 카테고리 트리 조회 +router.get("/categories/:categoryId", getCategoryById); // 카테고리 상세 조회 +router.get("/categories/:categoryId/path", getCategoryPath); // 카테고리 경로 조회 + +// 자동 생성 및 오버라이드 API +router.post("/keys/generate", generateKey); // 키 자동 생성 +router.post("/keys/preview", previewKey); // 키 미리보기 +router.post("/keys/override", createOverrideKey); // 오버라이드 키 생성 +router.get("/keys/overrides/:companyCode", getOverrideKeys); // 오버라이드 키 목록 조회 + +// 화면 라벨 다국어 자동 생성 API +router.post("/screen-labels", generateScreenLabelKeys); // 화면 라벨 다국어 키 자동 생성 + export default router; diff --git a/backend-node/src/routes/screenGroupRoutes.ts b/backend-node/src/routes/screenGroupRoutes.ts new file mode 100644 index 00000000..d4980fe8 --- /dev/null +++ b/backend-node/src/routes/screenGroupRoutes.ts @@ -0,0 +1,94 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { + // 화면 그룹 + getScreenGroups, + getScreenGroup, + createScreenGroup, + updateScreenGroup, + deleteScreenGroup, + // 화면-그룹 연결 + addScreenToGroup, + removeScreenFromGroup, + updateScreenInGroup, + // 필드 조인 + getFieldJoins, + createFieldJoin, + updateFieldJoin, + deleteFieldJoin, + // 데이터 흐름 + getDataFlows, + createDataFlow, + updateDataFlow, + deleteDataFlow, + // 화면-테이블 관계 + getTableRelations, + createTableRelation, + updateTableRelation, + deleteTableRelation, + // 화면 레이아웃 요약 + getScreenLayoutSummary, + getMultipleScreenLayoutSummary, + // 화면 서브 테이블 관계 + getScreenSubTables, +} from "../controllers/screenGroupController"; + +const router = Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +// ============================================================ +// 화면 그룹 (screen_groups) +// ============================================================ +router.get("/groups", getScreenGroups); +router.get("/groups/:id", getScreenGroup); +router.post("/groups", createScreenGroup); +router.put("/groups/:id", updateScreenGroup); +router.delete("/groups/:id", deleteScreenGroup); + +// ============================================================ +// 화면-그룹 연결 (screen_group_screens) +// ============================================================ +router.post("/group-screens", addScreenToGroup); +router.put("/group-screens/:id", updateScreenInGroup); +router.delete("/group-screens/:id", removeScreenFromGroup); + +// ============================================================ +// 필드 조인 설정 (screen_field_joins) +// ============================================================ +router.get("/field-joins", getFieldJoins); +router.post("/field-joins", createFieldJoin); +router.put("/field-joins/:id", updateFieldJoin); +router.delete("/field-joins/:id", deleteFieldJoin); + +// ============================================================ +// 데이터 흐름 (screen_data_flows) +// ============================================================ +router.get("/data-flows", getDataFlows); +router.post("/data-flows", createDataFlow); +router.put("/data-flows/:id", updateDataFlow); +router.delete("/data-flows/:id", deleteDataFlow); + +// ============================================================ +// 화면-테이블 관계 (screen_table_relations) +// ============================================================ +router.get("/table-relations", getTableRelations); +router.post("/table-relations", createTableRelation); +router.put("/table-relations/:id", updateTableRelation); +router.delete("/table-relations/:id", deleteTableRelation); + +// ============================================================ +// 화면 레이아웃 요약 (미리보기용) +// ============================================================ +router.get("/layout-summary/:screenId", getScreenLayoutSummary); +router.post("/layout-summary/batch", getMultipleScreenLayoutSummary); + +// ============================================================ +// 화면 서브 테이블 관계 (조인/참조 테이블) +// ============================================================ +router.post("/sub-tables/batch", getScreenSubTables); + +export default router; + + diff --git a/backend-node/src/services/multilangService.ts b/backend-node/src/services/multilangService.ts index 090065a3..fc765d89 100644 --- a/backend-node/src/services/multilangService.ts +++ b/backend-node/src/services/multilangService.ts @@ -4,6 +4,7 @@ import { Language, LangKey, LangText, + LangCategory, CreateLanguageRequest, UpdateLanguageRequest, CreateLangKeyRequest, @@ -12,12 +13,428 @@ import { GetLangKeysParams, GetUserTextParams, BatchTranslationRequest, + GenerateKeyRequest, + CreateOverrideKeyRequest, ApiResponse, } from "../types/multilang"; export class MultiLangService { constructor() {} + // ===================================================== + // 카테고리 관련 메서드 + // ===================================================== + + /** + * 카테고리 목록 조회 (트리 구조) + */ + async getCategories(): Promise { + try { + logger.info("카테고리 목록 조회 시작"); + + const categories = await query<{ + category_id: number; + category_code: string; + category_name: string; + parent_id: number | null; + level: number; + key_prefix: string; + description: string | null; + sort_order: number; + is_active: string; + }>( + `SELECT category_id, category_code, category_name, parent_id, + level, key_prefix, description, sort_order, is_active + FROM multi_lang_category + WHERE is_active = 'Y' + ORDER BY level ASC, sort_order ASC, category_name ASC` + ); + + // 트리 구조로 변환 + const categoryMap = new Map(); + const rootCategories: LangCategory[] = []; + + // 모든 카테고리를 맵에 저장 + categories.forEach((cat) => { + const category: LangCategory = { + categoryId: cat.category_id, + categoryCode: cat.category_code, + categoryName: cat.category_name, + parentId: cat.parent_id, + level: cat.level, + keyPrefix: cat.key_prefix, + description: cat.description || undefined, + sortOrder: cat.sort_order, + isActive: cat.is_active, + children: [], + }; + categoryMap.set(cat.category_id, category); + }); + + // 부모-자식 관계 설정 + categoryMap.forEach((category) => { + if (category.parentId && categoryMap.has(category.parentId)) { + const parent = categoryMap.get(category.parentId)!; + parent.children = parent.children || []; + parent.children.push(category); + } else if (!category.parentId) { + rootCategories.push(category); + } + }); + + logger.info(`카테고리 목록 조회 완료: ${categories.length}개`); + return rootCategories; + } catch (error) { + logger.error("카테고리 목록 조회 중 오류 발생:", error); + throw new Error( + `카테고리 목록 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 카테고리 단일 조회 + */ + async getCategoryById(categoryId: number): Promise { + try { + const category = await queryOne<{ + category_id: number; + category_code: string; + category_name: string; + parent_id: number | null; + level: number; + key_prefix: string; + description: string | null; + sort_order: number; + is_active: string; + }>( + `SELECT category_id, category_code, category_name, parent_id, + level, key_prefix, description, sort_order, is_active + FROM multi_lang_category + WHERE category_id = $1`, + [categoryId] + ); + + if (!category) { + return null; + } + + return { + categoryId: category.category_id, + categoryCode: category.category_code, + categoryName: category.category_name, + parentId: category.parent_id, + level: category.level, + keyPrefix: category.key_prefix, + description: category.description || undefined, + sortOrder: category.sort_order, + isActive: category.is_active, + }; + } catch (error) { + logger.error("카테고리 조회 중 오류 발생:", error); + throw new Error( + `카테고리 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 카테고리 경로 조회 (부모 포함) + */ + async getCategoryPath(categoryId: number): Promise { + try { + const categories = await query<{ + category_id: number; + category_code: string; + category_name: string; + parent_id: number | null; + level: number; + key_prefix: string; + description: string | null; + sort_order: number; + is_active: string; + }>( + `WITH RECURSIVE category_path AS ( + SELECT category_id, category_code, category_name, parent_id, + level, key_prefix, description, sort_order, is_active + FROM multi_lang_category + WHERE category_id = $1 + UNION ALL + SELECT c.category_id, c.category_code, c.category_name, c.parent_id, + c.level, c.key_prefix, c.description, c.sort_order, c.is_active + FROM multi_lang_category c + INNER JOIN category_path cp ON c.category_id = cp.parent_id + ) + SELECT * FROM category_path ORDER BY level ASC`, + [categoryId] + ); + + return categories.map((cat) => ({ + categoryId: cat.category_id, + categoryCode: cat.category_code, + categoryName: cat.category_name, + parentId: cat.parent_id, + level: cat.level, + keyPrefix: cat.key_prefix, + description: cat.description || undefined, + sortOrder: cat.sort_order, + isActive: cat.is_active, + })); + } catch (error) { + logger.error("카테고리 경로 조회 중 오류 발생:", error); + throw new Error( + `카테고리 경로 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 키 자동 생성 + */ + async generateKey(params: GenerateKeyRequest): Promise { + try { + logger.info("키 자동 생성 시작", { params }); + + // 카테고리 경로 조회 + const categoryPath = await this.getCategoryPath(params.categoryId); + if (categoryPath.length === 0) { + throw new Error("존재하지 않는 카테고리입니다"); + } + + // lang_key 자동 생성 (prefix.meaning 형식) + const prefixes = categoryPath.map((c) => c.keyPrefix); + const langKey = [...prefixes, params.keyMeaning].join("."); + + // 중복 체크 + const existingKey = await queryOne<{ key_id: number }>( + `SELECT key_id FROM multi_lang_key_master + WHERE company_code = $1 AND lang_key = $2`, + [params.companyCode, langKey] + ); + + if (existingKey) { + throw new Error(`이미 존재하는 키입니다: ${langKey}`); + } + + // 트랜잭션으로 키와 텍스트 생성 + let keyId: number = 0; + + await transaction(async (client) => { + // 키 생성 + const keyResult = await client.query( + `INSERT INTO multi_lang_key_master + (company_code, lang_key, category_id, key_meaning, usage_note, description, is_active, created_by, updated_by) + VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7, $7) + RETURNING key_id`, + [ + params.companyCode, + langKey, + params.categoryId, + params.keyMeaning, + params.usageNote || null, + params.usageNote || null, + params.createdBy || "system", + ] + ); + + keyId = keyResult.rows[0].key_id; + + // 텍스트 생성 + for (const text of params.texts) { + await client.query( + `INSERT INTO multi_lang_text + (key_id, lang_code, lang_text, is_active, created_by, updated_by) + VALUES ($1, $2, $3, 'Y', $4, $4)`, + [keyId, text.langCode, text.langText, params.createdBy || "system"] + ); + } + }); + + logger.info("키 자동 생성 완료", { keyId, langKey }); + return keyId; + } catch (error) { + logger.error("키 자동 생성 중 오류 발생:", error); + throw new Error( + `키 자동 생성 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 회사별 오버라이드 키 생성 + */ + async createOverrideKey(params: CreateOverrideKeyRequest): Promise { + try { + logger.info("오버라이드 키 생성 시작", { params }); + + // 원본 키 조회 + const baseKey = await queryOne<{ + key_id: number; + company_code: string; + lang_key: string; + category_id: number | null; + key_meaning: string | null; + }>( + `SELECT key_id, company_code, lang_key, category_id, key_meaning + FROM multi_lang_key_master WHERE key_id = $1`, + [params.baseKeyId] + ); + + if (!baseKey) { + throw new Error("원본 키를 찾을 수 없습니다"); + } + + // 공통 키(*)만 오버라이드 가능 + if (baseKey.company_code !== "*") { + throw new Error("공통 키(*)만 오버라이드 할 수 있습니다"); + } + + // 이미 오버라이드 키가 있는지 확인 + const existingOverride = await queryOne<{ key_id: number }>( + `SELECT key_id FROM multi_lang_key_master + WHERE company_code = $1 AND lang_key = $2`, + [params.companyCode, baseKey.lang_key] + ); + + if (existingOverride) { + throw new Error("이미 해당 회사의 오버라이드 키가 존재합니다"); + } + + let keyId: number = 0; + + await transaction(async (client) => { + // 오버라이드 키 생성 + const keyResult = await client.query( + `INSERT INTO multi_lang_key_master + (company_code, lang_key, category_id, key_meaning, base_key_id, is_active, created_by, updated_by) + VALUES ($1, $2, $3, $4, $5, 'Y', $6, $6) + RETURNING key_id`, + [ + params.companyCode, + baseKey.lang_key, + baseKey.category_id, + baseKey.key_meaning, + params.baseKeyId, + params.createdBy || "system", + ] + ); + + keyId = keyResult.rows[0].key_id; + + // 텍스트 생성 + for (const text of params.texts) { + await client.query( + `INSERT INTO multi_lang_text + (key_id, lang_code, lang_text, is_active, created_by, updated_by) + VALUES ($1, $2, $3, 'Y', $4, $4)`, + [keyId, text.langCode, text.langText, params.createdBy || "system"] + ); + } + }); + + logger.info("오버라이드 키 생성 완료", { keyId, langKey: baseKey.lang_key }); + return keyId; + } catch (error) { + logger.error("오버라이드 키 생성 중 오류 발생:", error); + throw new Error( + `오버라이드 키 생성 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 회사의 오버라이드 키 목록 조회 + */ + async getOverrideKeys(companyCode: string): Promise { + try { + logger.info("오버라이드 키 목록 조회 시작", { companyCode }); + + const keys = await query<{ + key_id: number; + company_code: string; + lang_key: string; + category_id: number | null; + key_meaning: string | null; + usage_note: string | null; + base_key_id: number | null; + is_active: string; + created_date: Date | null; + }>( + `SELECT key_id, company_code, lang_key, category_id, key_meaning, + usage_note, base_key_id, is_active, created_date + FROM multi_lang_key_master + WHERE company_code = $1 AND base_key_id IS NOT NULL + ORDER BY lang_key ASC`, + [companyCode] + ); + + return keys.map((k) => ({ + keyId: k.key_id, + companyCode: k.company_code, + langKey: k.lang_key, + categoryId: k.category_id ?? undefined, + keyMeaning: k.key_meaning ?? undefined, + usageNote: k.usage_note ?? undefined, + baseKeyId: k.base_key_id ?? undefined, + isActive: k.is_active, + createdDate: k.created_date ?? undefined, + })); + } catch (error) { + logger.error("오버라이드 키 목록 조회 중 오류 발생:", error); + throw new Error( + `오버라이드 키 목록 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 키 존재 여부 및 미리보기 확인 + */ + async previewGeneratedKey(categoryId: number, keyMeaning: string, companyCode: string): Promise<{ + langKey: string; + exists: boolean; + isOverride: boolean; + baseKeyId?: number; + }> { + try { + // 카테고리 경로 조회 + const categoryPath = await this.getCategoryPath(categoryId); + if (categoryPath.length === 0) { + throw new Error("존재하지 않는 카테고리입니다"); + } + + // lang_key 생성 + const prefixes = categoryPath.map((c) => c.keyPrefix); + const langKey = [...prefixes, keyMeaning].join("."); + + // 공통 키 확인 + const commonKey = await queryOne<{ key_id: number }>( + `SELECT key_id FROM multi_lang_key_master + WHERE company_code = '*' AND lang_key = $1`, + [langKey] + ); + + // 회사별 키 확인 + const companyKey = await queryOne<{ key_id: number }>( + `SELECT key_id FROM multi_lang_key_master + WHERE company_code = $1 AND lang_key = $2`, + [companyCode, langKey] + ); + + return { + langKey, + exists: !!companyKey, + isOverride: !!commonKey && !companyKey, + baseKeyId: commonKey?.key_id, + }; + } catch (error) { + logger.error("키 미리보기 중 오류 발생:", error); + throw new Error( + `키 미리보기 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + /** * 언어 목록 조회 */ @@ -275,14 +692,29 @@ export class MultiLangService { // 메뉴 코드 필터 if (params.menuCode) { - whereConditions.push(`menu_name = $${paramIndex++}`); + whereConditions.push(`usage_note = $${paramIndex++}`); values.push(params.menuCode); } + // 카테고리 필터 (하위 카테고리 포함) + if (params.categoryId) { + whereConditions.push(`category_id IN ( + WITH RECURSIVE category_tree AS ( + SELECT category_id FROM multi_lang_category WHERE category_id = $${paramIndex} + UNION ALL + SELECT c.category_id FROM multi_lang_category c + INNER JOIN category_tree ct ON c.parent_id = ct.category_id + ) + SELECT category_id FROM category_tree + )`); + values.push(params.categoryId); + paramIndex++; + } + // 검색 조건 (OR) if (params.searchText) { whereConditions.push( - `(lang_key ILIKE $${paramIndex} OR description ILIKE $${paramIndex} OR menu_name ILIKE $${paramIndex})` + `(lang_key ILIKE $${paramIndex} OR description ILIKE $${paramIndex} OR usage_note ILIKE $${paramIndex})` ); values.push(`%${params.searchText}%`); paramIndex++; @@ -296,30 +728,32 @@ export class MultiLangService { const langKeys = await query<{ key_id: number; company_code: string; - menu_name: string | null; + usage_note: string | null; lang_key: string; description: string | null; is_active: string | null; + category_id: number | null; created_date: Date | null; created_by: string | null; updated_date: Date | null; updated_by: string | null; }>( - `SELECT key_id, company_code, menu_name, lang_key, description, is_active, + `SELECT key_id, company_code, usage_note, lang_key, description, is_active, category_id, created_date, created_by, updated_date, updated_by FROM multi_lang_key_master ${whereClause} - ORDER BY company_code ASC, menu_name ASC, lang_key ASC`, + ORDER BY company_code ASC, usage_note ASC, lang_key ASC`, values ); const mappedKeys: LangKey[] = langKeys.map((key) => ({ keyId: key.key_id, companyCode: key.company_code, - menuName: key.menu_name || undefined, + menuName: key.usage_note || undefined, langKey: key.lang_key, description: key.description || undefined, isActive: key.is_active || "Y", + categoryId: key.category_id || undefined, createdDate: key.created_date || undefined, createdBy: key.created_by || undefined, updatedDate: key.updated_date || undefined, @@ -407,7 +841,7 @@ export class MultiLangService { // 다국어 키 생성 const createdKey = await queryOne<{ key_id: number }>( `INSERT INTO multi_lang_key_master - (company_code, menu_name, lang_key, description, is_active, created_by, updated_by) + (company_code, usage_note, lang_key, description, is_active, created_by, updated_by) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING key_id`, [ @@ -480,7 +914,7 @@ export class MultiLangService { values.push(keyData.companyCode); } if (keyData.menuName !== undefined) { - updates.push(`menu_name = $${paramIndex++}`); + updates.push(`usage_note = $${paramIndex++}`); values.push(keyData.menuName); } if (keyData.langKey) { @@ -668,7 +1102,7 @@ export class MultiLangService { WHERE mlt.lang_code = $1 AND mlt.is_active = $2 AND mlkm.company_code = $3 - AND mlkm.menu_name = $4 + AND mlkm.usage_note = $4 AND mlkm.lang_key = $5 AND mlkm.is_active = $6`, [ @@ -753,7 +1187,9 @@ export class MultiLangService { } /** - * 배치 번역 조회 + * 배치 번역 조회 (회사별 우선순위 적용) + * 우선순위: 회사별 키 > 공통 키(*) + * 폴백: 요청 언어 번역이 없으면 KR 번역 사용 */ async getBatchTranslations( params: BatchTranslationRequest @@ -775,12 +1211,17 @@ export class MultiLangService { .map((_, i) => `$${i + 4}`) .join(", "); + // 회사별 우선순위를 적용하기 위해 정렬 수정 + // 회사별 키가 먼저 오도록 DESC 정렬 (company_code가 '*'보다 특정 회사 코드가 알파벳 순으로 앞) + // 또는 CASE WHEN을 사용하여 명시적으로 우선순위 설정 const translations = await query<{ lang_text: string; lang_key: string; company_code: string; + priority: number; }>( - `SELECT mlt.lang_text, mlkm.lang_key, mlkm.company_code + `SELECT mlt.lang_text, mlkm.lang_key, mlkm.company_code, + CASE WHEN mlkm.company_code = $3 THEN 1 ELSE 2 END as priority FROM multi_lang_text mlt INNER JOIN multi_lang_key_master mlkm ON mlt.key_id = mlkm.key_id WHERE mlt.lang_code = $1 @@ -788,28 +1229,76 @@ export class MultiLangService { AND mlkm.lang_key IN (${placeholders}) AND mlkm.company_code IN ($3, '*') AND mlkm.is_active = $2 - ORDER BY mlkm.company_code ASC`, + ORDER BY mlkm.lang_key ASC, priority ASC`, [params.userLang, "Y", params.companyCode, ...params.langKeys] ); const result: Record = {}; + const processedKeys = new Set(); - // 기본값으로 모든 키 설정 - params.langKeys.forEach((key) => { - result[key] = key; - }); - - // 실제 번역으로 덮어쓰기 (회사별 우선) + // 우선순위 기반으로 번역 적용 + // priority가 낮은 것(회사별)이 먼저 오므로, 먼저 처리된 키는 덮어쓰지 않음 translations.forEach((translation) => { const langKey = translation.lang_key; - if (params.langKeys.includes(langKey)) { + if (params.langKeys.includes(langKey) && !processedKeys.has(langKey)) { result[langKey] = translation.lang_text; + processedKeys.add(langKey); + } + }); + + // 번역이 없는 키들에 대해 KR 폴백 조회 (요청 언어가 KR이 아닌 경우) + const missingKeys = params.langKeys.filter((key) => !processedKeys.has(key)); + + if (missingKeys.length > 0 && params.userLang !== "KR") { + logger.info("KR 폴백 번역 조회 시작", { missingCount: missingKeys.length }); + + const fallbackPlaceholders = missingKeys.map((_, i) => `$${i + 3}`).join(", "); + const fallbackTranslations = await query<{ + lang_text: string; + lang_key: string; + company_code: string; + priority: number; + }>( + `SELECT mlt.lang_text, mlkm.lang_key, mlkm.company_code, + CASE WHEN mlkm.company_code = $2 THEN 1 ELSE 2 END as priority + FROM multi_lang_text mlt + INNER JOIN multi_lang_key_master mlkm ON mlt.key_id = mlkm.key_id + WHERE mlt.lang_code = 'KR' + AND mlt.is_active = $1 + AND mlkm.lang_key IN (${fallbackPlaceholders}) + AND mlkm.company_code IN ($2, '*') + AND mlkm.is_active = $1 + ORDER BY mlkm.lang_key ASC, priority ASC`, + ["Y", params.companyCode, ...missingKeys] + ); + + // KR 폴백 적용 + const fallbackProcessed = new Set(); + fallbackTranslations.forEach((translation) => { + const langKey = translation.lang_key; + if (!result[langKey] && !fallbackProcessed.has(langKey)) { + result[langKey] = translation.lang_text; + fallbackProcessed.add(langKey); + } + }); + + logger.info("KR 폴백 번역 조회 완료", { + missingCount: missingKeys.length, + foundFallback: fallbackTranslations.length, + }); + } + + // 여전히 없는 키는 키 자체를 반환 (최후의 폴백) + params.langKeys.forEach((key) => { + if (!result[key]) { + result[key] = key; } }); logger.info("배치 번역 조회 완료", { totalKeys: params.langKeys.length, foundTranslations: translations.length, + companyOverrides: translations.filter(t => t.company_code !== '*').length, resultKeys: Object.keys(result).length, }); @@ -865,4 +1354,391 @@ export class MultiLangService { ); } } + + // ===================================================== + // 회사/메뉴 기반 카테고리 자동 생성 메서드 + // ===================================================== + + /** + * 화면(screen) 루트 카테고리 확인 또는 생성 + */ + async ensureScreenRootCategory(): Promise { + try { + // 기존 screen 카테고리 확인 + const existing = await queryOne<{ category_id: number }>( + `SELECT category_id FROM multi_lang_category + WHERE category_code = 'screen' AND parent_id IS NULL`, + [] + ); + + if (existing) { + return existing.category_id; + } + + // 없으면 생성 + const result = await queryOne<{ category_id: number }>( + `INSERT INTO multi_lang_category + (category_code, category_name, parent_id, level, key_prefix, description, sort_order, is_active, created_date) + VALUES ('screen', '화면', NULL, 1, 'screen', '화면 디자이너에서 자동 생성된 다국어 키', 100, 'Y', NOW()) + RETURNING category_id`, + [] + ); + + logger.info("화면 루트 카테고리 생성", { categoryId: result?.category_id }); + return result!.category_id; + } catch (error) { + logger.error("화면 루트 카테고리 생성 실패:", error); + throw error; + } + } + + /** + * 회사 카테고리 확인 또는 생성 + */ + async ensureCompanyCategory(companyCode: string, companyName: string): Promise { + try { + const screenRootId = await this.ensureScreenRootCategory(); + + // 기존 회사 카테고리 확인 + const existing = await queryOne<{ category_id: number }>( + `SELECT category_id FROM multi_lang_category + WHERE category_code = $1 AND parent_id = $2`, + [companyCode, screenRootId] + ); + + if (existing) { + return existing.category_id; + } + + // 회사 카테고리 생성 + const displayName = companyCode === "*" ? "공통" : companyName; + const keyPrefix = companyCode === "*" ? "common" : companyCode.toLowerCase(); + + const result = await queryOne<{ category_id: number }>( + `INSERT INTO multi_lang_category + (category_code, category_name, parent_id, level, key_prefix, description, sort_order, is_active, created_date) + VALUES ($1, $2, $3, 2, $4, $5, $6, 'Y', NOW()) + RETURNING category_id`, + [ + companyCode, + displayName, + screenRootId, + keyPrefix, + `${displayName} 회사의 화면 다국어`, + companyCode === "*" ? 0 : 10, + ] + ); + + logger.info("회사 카테고리 생성", { companyCode, categoryId: result?.category_id }); + return result!.category_id; + } catch (error) { + logger.error("회사 카테고리 생성 실패:", error); + throw error; + } + } + + /** + * 메뉴 카테고리 확인 또는 생성 (메뉴 경로 전체) + */ + async ensureMenuCategory( + companyCode: string, + companyName: string, + menuPath: string[] // ["영업관리", "수주관리"] + ): Promise { + try { + if (menuPath.length === 0) { + return await this.ensureCompanyCategory(companyCode, companyName); + } + + let parentId = await this.ensureCompanyCategory(companyCode, companyName); + let currentLevel = 3; + + for (const menuName of menuPath) { + // 현재 메뉴 카테고리 확인 + const existing = await queryOne<{ category_id: number }>( + `SELECT category_id FROM multi_lang_category + WHERE category_name = $1 AND parent_id = $2`, + [menuName, parentId] + ); + + if (existing) { + parentId = existing.category_id; + } else { + // 메뉴 카테고리 생성 + const menuCode = `${companyCode}_${menuName}`.replace(/\s+/g, "_"); + const keyPrefix = menuName.toLowerCase().replace(/\s+/g, "_"); + + const result = await queryOne<{ category_id: number }>( + `INSERT INTO multi_lang_category + (category_code, category_name, parent_id, level, key_prefix, description, sort_order, is_active, created_date) + VALUES ($1, $2, $3, $4, $5, $6, 0, 'Y', NOW()) + RETURNING category_id`, + [menuCode, menuName, parentId, currentLevel, keyPrefix, `${menuName} 메뉴의 다국어`] + ); + + logger.info("메뉴 카테고리 생성", { menuName, categoryId: result?.category_id }); + parentId = result!.category_id; + } + + currentLevel++; + } + + return parentId; + } catch (error) { + logger.error("메뉴 카테고리 생성 실패:", error); + throw error; + } + } + + /** + * 메뉴 경로 조회 (menu_info에서 부모 메뉴까지) + */ + async getMenuPath(menuObjId: string): Promise { + try { + const menus = await query<{ menu_name_kor: string; level: number }>( + `WITH RECURSIVE menu_path AS ( + SELECT objid, parent_obj_id, menu_name_kor, 1 as level + FROM menu_info + WHERE objid = $1 + UNION ALL + SELECT m.objid, m.parent_obj_id, m.menu_name_kor, mp.level + 1 + FROM menu_info m + INNER JOIN menu_path mp ON m.objid = mp.parent_obj_id + WHERE m.parent_obj_id IS NOT NULL AND m.parent_obj_id != 0 + ) + SELECT menu_name_kor, level FROM menu_path + WHERE menu_name_kor IS NOT NULL + ORDER BY level DESC`, + [menuObjId] + ); + + return menus.map((m) => m.menu_name_kor); + } catch (error) { + logger.error("메뉴 경로 조회 실패:", error); + return []; + } + } + + /** + * 화면 그룹 경로 조회 (screen_groups에서 계층 구조 조회) + * @param screenId 화면 ID + * @returns 그룹 경로 배열 (최상위 → 현재 그룹 순서) + */ + async getScreenGroupPath(screenId: number): Promise { + try { + // 화면이 속한 그룹 조회 + const screenGroup = await queryOne<{ group_id: number }>( + `SELECT group_id FROM screen_group_screens WHERE screen_id = $1 LIMIT 1`, + [screenId] + ); + + if (!screenGroup) { + logger.info("화면이 그룹에 속하지 않음", { screenId }); + return []; + } + + // 그룹의 계층 구조 경로 조회 (최상위 → 현재 그룹 순서) + const groups = await query<{ group_name: string; group_level: number }>( + `WITH RECURSIVE group_path AS ( + SELECT id, parent_group_id, group_name, group_level, 1 as depth + FROM screen_groups + WHERE id = $1 + UNION ALL + SELECT g.id, g.parent_group_id, g.group_name, g.group_level, gp.depth + 1 + FROM screen_groups g + INNER JOIN group_path gp ON g.id = gp.parent_group_id + WHERE g.parent_group_id IS NOT NULL + ) + SELECT group_name, group_level FROM group_path + ORDER BY depth DESC`, + [screenGroup.group_id] + ); + + return groups.map((g) => g.group_name); + } catch (error) { + logger.error("화면 그룹 경로 조회 실패:", error); + return []; + } + } + + /** + * 화면 그룹 기반 카테고리 확인 또는 생성 + * @param companyCode 회사 코드 + * @param companyName 회사 이름 + * @param groupPath 그룹 경로 (상위 → 하위 순서) + * @returns 최종 카테고리 ID + */ + async ensureScreenGroupCategory( + companyCode: string, + companyName: string, + groupPath: string[] + ): Promise { + try { + if (groupPath.length === 0) { + // 그룹이 없으면 회사 카테고리만 반환 + return await this.ensureCompanyCategory(companyCode, companyName); + } + + let parentId = await this.ensureCompanyCategory(companyCode, companyName); + let currentLevel = 3; // SCREEN(1) > Company(2) > Group(3) + + for (const groupName of groupPath) { + // 현재 그룹 카테고리 확인 + const existing = await queryOne<{ category_id: number }>( + `SELECT category_id FROM multi_lang_category + WHERE category_name = $1 AND parent_id = $2`, + [groupName, parentId] + ); + + if (existing) { + parentId = existing.category_id; + } else { + // 그룹 카테고리 생성 + const groupCode = `${companyCode}_GROUP_${groupName}`.replace(/\s+/g, "_"); + const keyPrefix = groupName.toLowerCase().replace(/\s+/g, "_"); + + const result = await queryOne<{ category_id: number }>( + `INSERT INTO multi_lang_category + (category_code, category_name, parent_id, level, key_prefix, description, sort_order, is_active, created_date) + VALUES ($1, $2, $3, $4, $5, $6, 0, 'Y', NOW()) + RETURNING category_id`, + [groupCode, groupName, parentId, currentLevel, keyPrefix, `${groupName} 화면 그룹의 다국어`] + ); + + logger.info("화면 그룹 카테고리 생성", { groupName, categoryId: result?.category_id }); + parentId = result!.category_id; + } + + currentLevel++; + } + + return parentId; + } catch (error) { + logger.error("화면 그룹 카테고리 생성 실패:", error); + throw error; + } + } + + /** + * 화면 라벨 다국어 키 자동 생성 + * 화면 그룹 기반으로 카테고리 생성 (기존 메뉴 기반에서 변경) + */ + async generateScreenLabelKeys(params: { + screenId: number; + companyCode: string; + companyName: string; + menuObjId?: string; // 하위 호환성 유지 (미사용, 화면 그룹 기반으로 변경) + labels: Array<{ componentId: string; label: string; type?: string }>; + }): Promise> { + try { + logger.info("화면 라벨 다국어 키 자동 생성 시작", { + screenId: params.screenId, + companyCode: params.companyCode, + labelCount: params.labels.length, + }); + + // 화면 그룹 경로 조회 (화면이 속한 그룹의 계층 구조) + const groupPath = await this.getScreenGroupPath(params.screenId); + logger.info("화면 그룹 경로 조회 완료", { screenId: params.screenId, groupPath }); + + // 화면 그룹 기반 카테고리 확보 + const categoryId = await this.ensureScreenGroupCategory( + params.companyCode, + params.companyName, + groupPath + ); + + // 카테고리 경로 조회 (키 생성용) + const categoryPath = await this.getCategoryPath(categoryId); + const keyPrefixParts = categoryPath.map((c) => c.keyPrefix); + + const results: Array<{ componentId: string; keyId: number; langKey: string }> = []; + + for (const labelInfo of params.labels) { + // 라벨을 키 형태로 변환 (한글 → 스네이크케이스) + const keyMeaning = this.labelToKeyMeaning(labelInfo.label); + const langKey = [...keyPrefixParts, keyMeaning].join("."); + + // 기존 키 확인 + const existingKey = await queryOne<{ key_id: number }>( + `SELECT key_id FROM multi_lang_key_master + WHERE lang_key = $1 AND company_code = $2`, + [langKey, params.companyCode] + ); + + let keyId: number; + + if (existingKey) { + keyId = existingKey.key_id; + logger.info("기존 키 사용", { langKey, keyId }); + } else { + // 새 키 생성 + const keyResult = await queryOne<{ key_id: number }>( + `INSERT INTO multi_lang_key_master + (company_code, lang_key, description, is_active, category_id, key_meaning, created_date, created_by) + VALUES ($1, $2, $3, 'Y', $4, $5, NOW(), 'system') + RETURNING key_id`, + [ + params.companyCode, + langKey, + `화면 ${params.screenId}의 ${labelInfo.type || "라벨"}: ${labelInfo.label}`, + categoryId, + keyMeaning, + ] + ); + + keyId = keyResult!.key_id; + + // 한국어 텍스트 저장 (원문) + await query( + `INSERT INTO multi_lang_text (key_id, lang_code, lang_text, is_active, created_date, created_by) + VALUES ($1, 'KR', $2, 'Y', NOW(), 'system') + ON CONFLICT (key_id, lang_code) DO UPDATE SET lang_text = $2, updated_date = NOW()`, + [keyId, labelInfo.label] + ); + + logger.info("새 키 생성", { langKey, keyId }); + } + + results.push({ + componentId: labelInfo.componentId, + keyId, + langKey, + }); + } + + logger.info("화면 라벨 다국어 키 생성 완료", { + screenId: params.screenId, + generatedCount: results.length, + }); + + return results; + } catch (error) { + logger.error("화면 라벨 다국어 키 생성 실패:", error); + throw new Error( + `화면 라벨 다국어 키 생성 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 라벨을 키 의미로 변환 (한글 → 스네이크케이스 또는 영문 유지) + */ + private labelToKeyMeaning(label: string): string { + // 이미 영문 스네이크케이스면 그대로 사용 + if (/^[a-z][a-z0-9_]*$/.test(label)) { + return label; + } + + // 영문 일반이면 스네이크케이스로 변환 + if (/^[A-Za-z][A-Za-z0-9 ]*$/.test(label)) { + return label.toLowerCase().replace(/\s+/g, "_"); + } + + // 한글이면 간단한 변환 (특수문자 제거, 공백을 _로) + return label + .replace(/[^\w가-힣\s]/g, "") + .replace(/\s+/g, "_") + .toLowerCase(); + } } diff --git a/backend-node/src/types/multilang.ts b/backend-node/src/types/multilang.ts index 8ad8adb6..c30fdfaa 100644 --- a/backend-node/src/types/multilang.ts +++ b/backend-node/src/types/multilang.ts @@ -17,12 +17,30 @@ export interface LangKey { langKey: string; description?: string; isActive: string; + categoryId?: number; + keyMeaning?: string; + usageNote?: string; + baseKeyId?: number; createdDate?: Date; createdBy?: string; updatedDate?: Date; updatedBy?: string; } +// 카테고리 인터페이스 +export interface LangCategory { + categoryId: number; + categoryCode: string; + categoryName: string; + parentId?: number | null; + level: number; + keyPrefix: string; + description?: string; + sortOrder: number; + isActive: string; + children?: LangCategory[]; +} + export interface LangText { textId?: number; keyId: number; @@ -63,10 +81,38 @@ export interface CreateLangKeyRequest { langKey: string; description?: string; isActive?: string; + categoryId?: number; + keyMeaning?: string; + usageNote?: string; + baseKeyId?: number; createdBy?: string; updatedBy?: string; } +// 자동 키 생성 요청 +export interface GenerateKeyRequest { + companyCode: string; + categoryId: number; + keyMeaning: string; + usageNote?: string; + texts: Array<{ + langCode: string; + langText: string; + }>; + createdBy?: string; +} + +// 오버라이드 키 생성 요청 +export interface CreateOverrideKeyRequest { + companyCode: string; + baseKeyId: number; + texts: Array<{ + langCode: string; + langText: string; + }>; + createdBy?: string; +} + export interface UpdateLangKeyRequest { companyCode?: string; menuName?: string; @@ -90,6 +136,8 @@ export interface GetLangKeysParams { menuCode?: string; keyType?: string; searchText?: string; + categoryId?: number; + includeOverrides?: boolean; page?: number; limit?: number; } diff --git a/docs/노드플로우_개선사항.md b/docs/노드플로우_개선사항.md index 32757807..a59f4499 100644 --- a/docs/노드플로우_개선사항.md +++ b/docs/노드플로우_개선사항.md @@ -588,3 +588,4 @@ const result = await executeNodeFlow(flowId, { + diff --git a/docs/다국어_관리_시스템_개선_계획서.md b/docs/다국어_관리_시스템_개선_계획서.md new file mode 100644 index 00000000..24ca5850 --- /dev/null +++ b/docs/다국어_관리_시스템_개선_계획서.md @@ -0,0 +1,597 @@ +# 다국어 관리 시스템 개선 계획서 + +## 1. 개요 + +### 1.1 현재 시스템 분석 + +현재 ERP 시스템의 다국어 관리 시스템은 기본적인 기능은 갖추고 있으나 다음과 같은 한계점이 있습니다. + +| 항목 | 현재 상태 | 문제점 | +|------|----------|--------| +| 회사별 다국어 | `company_code` 컬럼 존재하나 `*`(공통)만 사용 | 회사별 커스텀 번역 불가 | +| 언어 키 입력 | 수동 입력 (`button.add` 등) | 명명 규칙 불일치, 오타, 중복 위험 | +| 카테고리 분류 | 없음 (`menu_name` 텍스트만 존재) | 체계적 분류/검색 불가 | +| 권한 관리 | 없음 | 모든 사용자가 모든 키 수정 가능 | +| 조회 우선순위 | 없음 | 회사별 오버라이드 불가 | + +### 1.2 개선 목표 + +1. **회사별 다국어 오버라이드 시스템**: 공통 키를 기본으로 사용하되, 회사별 커스텀 번역 지원 +2. **권한 기반 접근 제어**: 공통 키는 최고 관리자만, 회사 키는 해당 회사만 수정 +3. **카테고리 기반 분류**: 2단계 계층 구조로 체계적 분류 +4. **자동 키 생성**: 카테고리 선택 + 의미 입력으로 규칙화된 키 자동 생성 +5. **실시간 중복 체크**: 키 생성 시 중복 여부 즉시 확인 + +--- + +## 2. 데이터베이스 스키마 설계 + +### 2.1 신규 테이블: multi_lang_category (카테고리 마스터) + +```sql +CREATE TABLE multi_lang_category ( + category_id SERIAL PRIMARY KEY, + category_code VARCHAR(50) NOT NULL, -- BUTTON, FORM, MESSAGE 등 + category_name VARCHAR(100) NOT NULL, -- 버튼, 폼, 메시지 등 + parent_id INT4 REFERENCES multi_lang_category(category_id), + level INT4 DEFAULT 1, -- 1=대분류, 2=세부분류 + key_prefix VARCHAR(50) NOT NULL, -- 키 생성용 prefix + description TEXT, + sort_order INT4 DEFAULT 0, + is_active CHAR(1) DEFAULT 'Y', + created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(50), + updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_by VARCHAR(50), + UNIQUE(category_code, COALESCE(parent_id, 0)) +); + +-- 인덱스 +CREATE INDEX idx_lang_category_parent ON multi_lang_category(parent_id); +CREATE INDEX idx_lang_category_level ON multi_lang_category(level); +``` + +### 2.2 기존 테이블 수정: multi_lang_key_master + +```sql +-- 카테고리 연결 컬럼 추가 +ALTER TABLE multi_lang_key_master +ADD COLUMN category_id INT4 REFERENCES multi_lang_category(category_id); + +-- 키 의미 컬럼 추가 (자동 생성 시 사용자 입력값) +ALTER TABLE multi_lang_key_master +ADD COLUMN key_meaning VARCHAR(100); + +-- 원본 키 참조 (오버라이드 시 원본 추적) +ALTER TABLE multi_lang_key_master +ADD COLUMN base_key_id INT4 REFERENCES multi_lang_key_master(key_id); + +-- menu_name을 usage_note로 변경 (사용 위치 메모) +ALTER TABLE multi_lang_key_master +RENAME COLUMN menu_name TO usage_note; + +-- 인덱스 추가 +CREATE INDEX idx_lang_key_category ON multi_lang_key_master(category_id); +CREATE INDEX idx_lang_key_company_category ON multi_lang_key_master(company_code, category_id); +CREATE INDEX idx_lang_key_base ON multi_lang_key_master(base_key_id); +``` + +### 2.3 테이블 관계도 + +``` +multi_lang_category (1) ◀────────┐ + ├── category_id (PK) │ + ├── category_code │ + ├── parent_id (자기참조) │ + └── key_prefix │ + │ +multi_lang_key_master (N) ────────┘ + ├── key_id (PK) + ├── company_code ('*' = 공통) + ├── category_id (FK) + ├── lang_key (자동 생성) + ├── key_meaning (사용자 입력) + ├── base_key_id (오버라이드 시 원본) + └── usage_note (사용 위치 메모) + │ + ▼ +multi_lang_text (N) + ├── text_id (PK) + ├── key_id (FK) + ├── lang_code (FK → language_master) + └── lang_text +``` + +--- + +## 3. 카테고리 체계 + +### 3.1 대분류 (Level 1) + +| category_code | category_name | key_prefix | 설명 | +|---------------|---------------|------------|------| +| COMMON | 공통 | common | 범용 텍스트 | +| BUTTON | 버튼 | button | 버튼 텍스트 | +| FORM | 폼 | form | 폼 라벨, 플레이스홀더 | +| TABLE | 테이블 | table | 테이블 헤더, 빈 상태 | +| MESSAGE | 메시지 | message | 알림, 경고, 성공 메시지 | +| MENU | 메뉴 | menu | 메뉴명, 네비게이션 | +| MODAL | 모달 | modal | 모달/다이얼로그 | +| VALIDATION | 검증 | validation | 유효성 검사 메시지 | +| STATUS | 상태 | status | 상태 표시 텍스트 | +| TOOLTIP | 툴팁 | tooltip | 툴팁, 도움말 | + +### 3.2 세부분류 (Level 2) + +#### BUTTON 하위 +| category_code | category_name | key_prefix | +|---------------|---------------|------------| +| ACTION | 액션 | action | +| NAVIGATION | 네비게이션 | nav | +| TOGGLE | 토글 | toggle | + +#### FORM 하위 +| category_code | category_name | key_prefix | +|---------------|---------------|------------| +| LABEL | 라벨 | label | +| PLACEHOLDER | 플레이스홀더 | placeholder | +| HELPER | 도움말 | helper | + +#### MESSAGE 하위 +| category_code | category_name | key_prefix | +|---------------|---------------|------------| +| SUCCESS | 성공 | success | +| ERROR | 에러 | error | +| WARNING | 경고 | warning | +| INFO | 안내 | info | +| CONFIRM | 확인 | confirm | + +#### TABLE 하위 +| category_code | category_name | key_prefix | +|---------------|---------------|------------| +| HEADER | 헤더 | header | +| EMPTY | 빈 상태 | empty | +| PAGINATION | 페이지네이션 | pagination | + +#### MENU 하위 +| category_code | category_name | key_prefix | +|---------------|---------------|------------| +| ADMIN | 관리자 | admin | +| USER | 사용자 | user | + +#### MODAL 하위 +| category_code | category_name | key_prefix | +|---------------|---------------|------------| +| TITLE | 제목 | title | +| DESCRIPTION | 설명 | description | + +### 3.3 키 자동 생성 규칙 + +**형식**: `{대분류_prefix}.{세부분류_prefix}.{key_meaning}` + +**예시**: +| 대분류 | 세부분류 | 의미 입력 | 생성 키 | +|--------|----------|----------|---------| +| BUTTON | ACTION | save | `button.action.save` | +| BUTTON | ACTION | delete_selected | `button.action.delete_selected` | +| FORM | LABEL | user_name | `form.label.user_name` | +| FORM | PLACEHOLDER | search | `form.placeholder.search` | +| MESSAGE | SUCCESS | save_complete | `message.success.save_complete` | +| MESSAGE | ERROR | network_fail | `message.error.network_fail` | +| TABLE | HEADER | created_date | `table.header.created_date` | +| MENU | ADMIN | user_management | `menu.admin.user_management` | + +--- + +## 4. 회사별 다국어 시스템 + +### 4.1 조회 우선순위 + +다국어 텍스트 조회 시 다음 우선순위를 적용합니다: + +1. **회사 전용 키** (`company_code = 'COMPANY_A'`) +2. **공통 키** (`company_code = '*'`) + +```sql +-- 조회 쿼리 예시 +WITH ranked_keys AS ( + SELECT + km.lang_key, + mt.lang_text, + km.company_code, + ROW_NUMBER() OVER ( + PARTITION BY km.lang_key + ORDER BY CASE WHEN km.company_code = $1 THEN 1 ELSE 2 END + ) as priority + FROM multi_lang_key_master km + JOIN multi_lang_text mt ON km.key_id = mt.key_id + WHERE km.lang_key = ANY($2) + AND mt.lang_code = $3 + AND km.is_active = 'Y' + AND km.company_code IN ($1, '*') +) +SELECT lang_key, lang_text +FROM ranked_keys +WHERE priority = 1; +``` + +### 4.2 오버라이드 프로세스 + +1. 회사 관리자가 공통 키에서 "이 회사 전용으로 복사" 클릭 +2. 시스템이 `base_key_id`에 원본 키를 참조하는 새 키 생성 +3. 기존 번역 텍스트 복사 +4. 회사 관리자가 번역 수정 +5. 이후 해당 회사 사용자는 회사 전용 번역 사용 + +### 4.3 권한 매트릭스 + +| 작업 | 최고 관리자 (`*`) | 회사 관리자 | 일반 사용자 | +|------|------------------|-------------|-------------| +| 공통 키 조회 | O | O | O | +| 공통 키 생성 | O | X | X | +| 공통 키 수정 | O | X | X | +| 공통 키 삭제 | O | X | X | +| 회사 키 조회 | O | 자사만 | 자사만 | +| 회사 키 생성 (오버라이드) | O | O | X | +| 회사 키 수정 | O | 자사만 | X | +| 회사 키 삭제 | O | 자사만 | X | +| 카테고리 관리 | O | X | X | + +--- + +## 5. API 설계 + +### 5.1 카테고리 API + +| 엔드포인트 | 메서드 | 설명 | 권한 | +|-----------|--------|------|------| +| `/multilang/categories` | GET | 카테고리 목록 조회 | 인증 필요 | +| `/multilang/categories/tree` | GET | 계층 구조로 조회 | 인증 필요 | +| `/multilang/categories` | POST | 카테고리 생성 | 최고 관리자 | +| `/multilang/categories/:id` | PUT | 카테고리 수정 | 최고 관리자 | +| `/multilang/categories/:id` | DELETE | 카테고리 삭제 | 최고 관리자 | + +### 5.2 다국어 키 API (개선) + +| 엔드포인트 | 메서드 | 설명 | 권한 | +|-----------|--------|------|------| +| `/multilang/keys` | GET | 키 목록 조회 (카테고리/회사 필터) | 인증 필요 | +| `/multilang/keys` | POST | 키 생성 | 공통: 최고관리자, 회사: 회사관리자 | +| `/multilang/keys/:keyId` | PUT | 키 수정 | 공통: 최고관리자, 회사: 해당회사 | +| `/multilang/keys/:keyId` | DELETE | 키 삭제 | 공통: 최고관리자, 회사: 해당회사 | +| `/multilang/keys/:keyId/override` | POST | 공통 키를 회사 전용으로 복사 | 회사 관리자 | +| `/multilang/keys/check` | GET | 키 중복 체크 | 인증 필요 | +| `/multilang/keys/generate-preview` | POST | 키 자동 생성 미리보기 | 인증 필요 | + +### 5.3 API 요청/응답 예시 + +#### 키 생성 요청 +```json +POST /multilang/keys +{ + "categoryId": 11, // 세부분류 ID (BUTTON > ACTION) + "keyMeaning": "save_changes", + "description": "변경사항 저장 버튼", + "usageNote": "사용자 관리, 설정 화면", + "texts": [ + { "langCode": "KR", "langText": "저장하기" }, + { "langCode": "US", "langText": "Save Changes" }, + { "langCode": "JP", "langText": "保存する" } + ] +} +``` + +#### 키 생성 응답 +```json +{ + "success": true, + "message": "다국어 키가 생성되었습니다.", + "data": { + "keyId": 175, + "langKey": "button.action.save_changes", + "companyCode": "*", + "categoryId": 11 + } +} +``` + +#### 오버라이드 요청 +```json +POST /multilang/keys/123/override +{ + "texts": [ + { "langCode": "KR", "langText": "등록하기" }, + { "langCode": "US", "langText": "Register" } + ] +} +``` + +--- + +## 6. 프론트엔드 UI 설계 + +### 6.1 다국어 관리 페이지 리뉴얼 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 다국어 관리 │ +│ 다국어 키와 번역 텍스트를 관리합니다 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ [언어 관리] [다국어 키 관리] [카테고리 관리] │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌────────────────────┐ ┌───────────────────────────────────────────────┤ +│ │ 카테고리 필터 │ │ │ +│ │ │ │ 검색: [________________] 회사: [전체 ▼] │ +│ │ ▼ 버튼 (45) │ │ [초기화] [+ 키 등록] │ +│ │ ├ 액션 (30) │ │───────────────────────────────────────────────│ +│ │ ├ 네비게이션 (10)│ │ ☐ │ 키 │ 카테고리 │ 회사 │ 상태 │ +│ │ └ 토글 (5) │ │───────────────────────────────────────────────│ +│ │ ▼ 폼 (60) │ │ ☐ │ button.action.save │ 버튼>액션 │ 공통 │ 활성 │ +│ │ ├ 라벨 (35) │ │ ☐ │ button.action.save │ 버튼>액션 │ A사 │ 활성 │ +│ │ ├ 플레이스홀더(15)│ │ ☐ │ button.action.delete │ 버튼>액션 │ 공통 │ 활성 │ +│ │ └ 도움말 (10) │ │ ☐ │ form.label.user_name │ 폼>라벨 │ 공통 │ 활성 │ +│ │ ▶ 메시지 (40) │ │───────────────────────────────────────────────│ +│ │ ▶ 테이블 (20) │ │ 페이지: [1] [2] [3] ... [10] │ +│ │ ▶ 메뉴 (9) │ │ │ +│ └────────────────────┘ └───────────────────────────────────────────────┤ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 6.2 키 등록 모달 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 다국어 키 등록 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ① 카테고리 선택 │ +│ ┌───────────────────────────────────────────────────────────────┤ +│ │ 대분류 * │ 세부 분류 * │ +│ │ ┌─────────────────────────┐ │ ┌─────────────────────────┐ │ +│ │ │ 공통 │ │ │ (대분류 먼저 선택) │ │ +│ │ │ ● 버튼 │ │ │ ● 액션 │ │ +│ │ │ 폼 │ │ │ 네비게이션 │ │ +│ │ │ 테이블 │ │ │ 토글 │ │ +│ │ │ 메시지 │ │ │ │ │ +│ │ └─────────────────────────┘ │ └─────────────────────────┘ │ +│ └───────────────────────────────────────────────────────────────┤ +│ │ +│ ② 키 정보 입력 │ +│ ┌───────────────────────────────────────────────────────────────┤ +│ │ 키 의미 (영문) * │ +│ │ [ save_changes ] │ +│ │ 영문 소문자, 밑줄(_) 사용. 예: save, add_new, delete_all │ +│ │ │ +│ │ ───────────────────────────────────────────────────────── │ +│ │ 자동 생성 키: │ +│ │ ┌─────────────────────────────────────────────────────────┐ │ +│ │ │ button.action.save_changes │ │ +│ │ └─────────────────────────────────────────────────────────┘ │ +│ │ ✓ 사용 가능한 키입니다 │ +│ └───────────────────────────────────────────────────────────────┤ +│ │ +│ ③ 설명 및 번역 │ +│ ┌───────────────────────────────────────────────────────────────┤ +│ │ 설명 (선택) │ +│ │ [ 변경사항을 저장하는 버튼 ] │ +│ │ │ +│ │ 사용 위치 메모 (선택) │ +│ │ [ 사용자 관리, 설정 화면 ] │ +│ │ │ +│ │ ───────────────────────────────────────────────────────── │ +│ │ 번역 텍스트 │ +│ │ │ +│ │ 한국어 (KR) * [ 저장하기 ] │ +│ │ English (US) [ Save Changes ] │ +│ │ 日本語 (JP) [ 保存する ] │ +│ └───────────────────────────────────────────────────────────────┤ +│ │ +├─────────────────────────────────────────────────────────────────┤ +│ [취소] [등록] │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 6.3 공통 키 편집 모달 (회사 관리자용) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 다국어 키 상세 │ +│ button.action.save (공통) │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 카테고리: 버튼 > 액션 │ +│ 설명: 저장 버튼 │ +│ │ +│ ───────────────────────────────────────────────────────────── │ +│ 번역 텍스트 (읽기 전용) │ +│ │ +│ 한국어 (KR) 저장 │ +│ English (US) Save │ +│ 日本語 (JP) 保存 │ +│ │ +├─────────────────────────────────────────────────────────────────┤ +│ 공통 키는 수정할 수 없습니다. │ +│ 이 회사만의 번역이 필요하시면 아래 버튼을 클릭하세요. │ +│ │ +│ [이 회사 전용으로 복사] │ +├─────────────────────────────────────────────────────────────────┤ +│ [닫기] │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 6.4 회사 전용 키 생성 모달 (오버라이드) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 회사 전용 키 생성 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 원본 키: button.action.save (공통) │ +│ │ +│ 원본 번역: │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ 한국어: 저장 │ │ +│ │ English: Save │ │ +│ │ 日本語: 保存 │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ ───────────────────────────────────────────────────────────── │ +│ │ +│ 이 회사 전용 번역 텍스트: │ +│ │ +│ 한국어 (KR) * [ 등록하기 ] │ +│ English (US) [ Register ] │ +│ 日本語 (JP) [ 登録 ] │ +│ │ +├─────────────────────────────────────────────────────────────────┤ +│ 회사 전용 키를 생성하면 공통 키 대신 사용됩니다. │ +│ 원본 키가 변경되어도 회사 전용 키는 영향받지 않습니다. │ +├─────────────────────────────────────────────────────────────────┤ +│ [취소] [생성] │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 7. 구현 계획 + +### 7.1 Phase 1: 데이터베이스 마이그레이션 + +**예상 소요 시간: 2시간** + +1. 카테고리 테이블 생성 +2. 기본 카테고리 데이터 삽입 (대분류 10개, 세부분류 약 20개) +3. multi_lang_key_master 스키마 변경 +4. 기존 174개 키 카테고리 자동 분류 (패턴 매칭) + +**마이그레이션 파일**: `db/migrations/075_multilang_category_system.sql` + +### 7.2 Phase 2: 백엔드 API 개발 + +**예상 소요 시간: 4시간** + +1. 카테고리 CRUD API +2. 키 조회 로직 수정 (우선순위 적용) +3. 권한 검사 미들웨어 +4. 오버라이드 API +5. 키 중복 체크 API +6. 키 자동 생성 미리보기 API + +**관련 파일**: +- `backend-node/src/controllers/multilangController.ts` +- `backend-node/src/services/multilangService.ts` +- `backend-node/src/routes/multilangRoutes.ts` + +### 7.3 Phase 3: 프론트엔드 UI 개발 + +**예상 소요 시간: 6시간** + +1. 카테고리 트리 컴포넌트 +2. 키 등록 모달 리뉴얼 (단계별 입력) +3. 키 편집 모달 (권한별 UI 분기) +4. 오버라이드 모달 +5. 카테고리 관리 탭 추가 + +**관련 파일**: +- `frontend/app/(main)/admin/systemMng/i18nList/page.tsx` +- `frontend/components/multilang/LangKeyModal.tsx` (리뉴얼) +- `frontend/components/multilang/CategoryTree.tsx` (신규) +- `frontend/components/multilang/OverrideModal.tsx` (신규) + +### 7.4 Phase 4: 테스트 및 마이그레이션 + +**예상 소요 시간: 2시간** + +1. API 테스트 +2. UI 테스트 +3. 기존 데이터 마이그레이션 검증 +4. 권한 테스트 (최고 관리자, 회사 관리자) + +--- + +## 8. 상세 구현 일정 + +| 단계 | 작업 | 예상 시간 | 의존성 | +|------|------|----------|--------| +| 1.1 | 마이그레이션 SQL 작성 | 30분 | - | +| 1.2 | 카테고리 기본 데이터 삽입 | 30분 | 1.1 | +| 1.3 | 기존 키 카테고리 자동 분류 | 30분 | 1.2 | +| 1.4 | 스키마 변경 검증 | 30분 | 1.3 | +| 2.1 | 카테고리 API 개발 | 1시간 | 1.4 | +| 2.2 | 키 조회 로직 수정 (우선순위) | 1시간 | 2.1 | +| 2.3 | 권한 검사 로직 추가 | 30분 | 2.2 | +| 2.4 | 오버라이드 API 개발 | 1시간 | 2.3 | +| 2.5 | 키 생성 API 개선 (자동 생성) | 30분 | 2.4 | +| 3.1 | 카테고리 트리 컴포넌트 | 1시간 | 2.5 | +| 3.2 | 키 등록 모달 리뉴얼 | 2시간 | 3.1 | +| 3.3 | 키 편집/상세 모달 | 1시간 | 3.2 | +| 3.4 | 오버라이드 모달 | 1시간 | 3.3 | +| 3.5 | 카테고리 관리 탭 | 1시간 | 3.4 | +| 4.1 | 통합 테스트 | 1시간 | 3.5 | +| 4.2 | 버그 수정 및 마무리 | 1시간 | 4.1 | + +**총 예상 시간: 약 14시간** + +--- + +## 9. 기대 효과 + +### 9.1 개선 전후 비교 + +| 항목 | 현재 | 개선 후 | +|------|------|---------| +| 키 명명 규칙 | 불규칙 (수동 입력) | 규칙화 (자동 생성) | +| 카테고리 분류 | 없음 | 2단계 계층 구조 | +| 회사별 다국어 | 미활용 | 오버라이드 지원 | +| 조회 우선순위 | 없음 | 회사 전용 > 공통 | +| 권한 관리 | 없음 | 역할별 접근 제어 | +| 중복 체크 | 저장 시에만 | 실시간 검증 | +| 검색/필터 | 키 이름만 | 카테고리 + 회사 + 키 | + +### 9.2 사용자 경험 개선 + +1. **일관된 키 명명**: 자동 생성으로 규칙 준수 +2. **빠른 검색**: 카테고리 기반 필터링 +3. **회사별 커스터마이징**: 브랜드에 맞는 번역 사용 +4. **안전한 수정**: 권한 기반 보호 + +### 9.3 유지보수 개선 + +1. **체계적 분류**: 어떤 텍스트가 어디에 사용되는지 명확 +2. **변경 영향 파악**: 오버라이드 추적으로 영향 범위 확인 +3. **권한 분리**: 공통 키 보호, 회사별 자율성 보장 + +--- + +## 10. 참고 자료 + +### 10.1 관련 파일 + +| 파일 | 설명 | +|------|------| +| `frontend/hooks/useMultiLang.ts` | 다국어 훅 | +| `frontend/lib/utils/multilang.ts` | 다국어 유틸리티 | +| `frontend/app/(main)/admin/systemMng/i18nList/page.tsx` | 다국어 관리 페이지 | +| `backend-node/src/controllers/multilangController.ts` | API 컨트롤러 | +| `backend-node/src/services/multilangService.ts` | 비즈니스 로직 | +| `docs/다국어_시스템_가이드.md` | 기존 시스템 가이드 | + +### 10.2 데이터베이스 테이블 + +| 테이블 | 설명 | +|--------|------| +| `language_master` | 언어 마스터 (KR, US, JP) | +| `multi_lang_key_master` | 다국어 키 마스터 | +| `multi_lang_text` | 다국어 번역 텍스트 | +| `multi_lang_category` | 다국어 카테고리 (신규) | + +--- + +## 11. 변경 이력 + +| 버전 | 날짜 | 작성자 | 변경 내용 | +|------|------|--------|----------| +| 1.0 | 2026-01-13 | AI | 최초 작성 | + + diff --git a/docs/메일발송_기능_사용_가이드.md b/docs/메일발송_기능_사용_가이드.md index 8bfe484e..ef62a60a 100644 --- a/docs/메일발송_기능_사용_가이드.md +++ b/docs/메일발송_기능_사용_가이드.md @@ -361,3 +361,4 @@ + diff --git a/docs/즉시저장_버튼_액션_구현_계획서.md b/docs/즉시저장_버튼_액션_구현_계획서.md index 8d8fb497..806e480d 100644 --- a/docs/즉시저장_버튼_액션_구현_계획서.md +++ b/docs/즉시저장_버튼_액션_구현_계획서.md @@ -347,3 +347,4 @@ const getComponentValue = (componentId: string) => { + diff --git a/docs/화면관계_시각화_개선_보고서.md b/docs/화면관계_시각화_개선_보고서.md new file mode 100644 index 00000000..27946afa --- /dev/null +++ b/docs/화면관계_시각화_개선_보고서.md @@ -0,0 +1,1745 @@ +# 화면 관계 시각화 기능 개선 보고서 + +## 개요 + +화면 그룹 관리에서 ReactFlow를 사용한 화면-테이블 관계 시각화 기능의 범용성 및 정확성 개선 작업을 수행했습니다. + +--- + +## 수정된 파일 목록 + +| 파일 경로 | 역할 | +|----------|------| +| `backend-node/src/controllers/screenGroupController.ts` | 화면 서브테이블 정보 API | +| `frontend/components/screen/ScreenRelationFlow.tsx` | ReactFlow 시각화 컴포넌트 | +| `frontend/lib/api/screenGroup.ts` | API 인터페이스 정의 | + +--- + +## 사용된 데이터베이스 테이블 + +### 화면 정의 관련 + +| 테이블명 | 용도 | 주요 컬럼 | +|----------|------|----------| +| `screen_definitions` | 화면 정의 정보 | `screen_id`, `screen_name`, `table_name`, `company_code` | +| `screen_layouts` | 화면 레이아웃/컴포넌트 정보 | `screen_id`, `properties` (JSONB - componentConfig 포함) | +| `screen_groups` | 화면 그룹 정보 | `group_id`, `group_code`, `group_name`, `parent_group_id` | +| `screen_group_mappings` | 화면-그룹 매핑 | `group_id`, `screen_id`, `display_order` | + +### 메타데이터 관련 + +| 테이블명 | 용도 | 주요 컬럼 | +|----------|------|----------| +| `column_labels` | 컬럼 한글명/참조 정보 | `table_name`, `column_name`, `column_label`, `reference_table`, `reference_column` | +| `table_info` | 테이블 메타 정보 | `table_name`, `table_label`, `column_count` | + +### 조인 정보 추출 소스 + +#### 1. column_labels 테이블 (테이블 관리에서 설정) + +```sql +SELECT + cl.table_name, + cl.column_name, + cl.reference_table, -- 참조 테이블 + cl.reference_column, -- 참조 컬럼 + cl.column_label -- 한글명 +FROM column_labels cl +WHERE cl.reference_table IS NOT NULL +``` + +#### 2. screen_layouts.properties (화면 컴포넌트에서 설정) + +```sql +-- parentDataMapping 추출 +SELECT + sd.screen_id, + sl.properties->'componentConfig'->'parentDataMapping' as parent_data_mapping +FROM screen_definitions sd +JOIN screen_layouts sl ON sd.screen_id = sl.screen_id +WHERE sl.properties->'componentConfig'->'parentDataMapping' IS NOT NULL + +-- rightPanel.relation 추출 +SELECT + sd.screen_id, + sl.properties->'componentConfig'->'rightPanel'->'relation' as right_panel_relation +FROM screen_definitions sd +JOIN screen_layouts sl ON sd.screen_id = sl.screen_id +WHERE sl.properties->'componentConfig'->'rightPanel'->'relation' IS NOT NULL + +-- fieldMappings 추출 +SELECT + sd.screen_id, + sl.properties->'componentConfig'->'fieldMappings' as field_mappings +FROM screen_definitions sd +JOIN screen_layouts sl ON sd.screen_id = sl.screen_id +WHERE sl.properties->'componentConfig'->'fieldMappings' IS NOT NULL +``` + +### 테이블 관계도 + +``` +screen_groups (그룹) + │ + ├─── screen_group_mappings (매핑) + │ │ + │ └─── screen_definitions (화면) + │ │ + │ └─── screen_layouts (레이아웃/컴포넌트) + │ │ + │ └─── properties.componentConfig + │ ├── fieldMappings + │ ├── parentDataMapping + │ ├── columns.mapping + │ └── rightPanel.relation + │ + └─── column_labels (컬럼 메타데이터) + │ + ├── reference_table (참조 테이블) + └── column_label (한글명) +``` + +--- + +## 핵심 문제 및 해결 + +### 1. 조인 컬럼 식별 오류 + +#### 문제 +- `customer_item_mapping` 테이블에서 `customer_id`가 조인 컬럼으로 표시되지 않음 +- `customer_mng`가 `relationType: source`로 분류되어 `sourceField`가 잘못 사용됨 + +#### 원인 +```typescript +// 기존 잘못된 로직 +if (subTable.relationType === 'source') { + // sourceField를 메인테이블 컬럼으로 사용 (잘못됨) + joinColumns.push(mapping.sourceField); +} +``` + +#### 해결 +```typescript +// 수정된 범용 로직 +if (subTable.relationType === 'source' && mapping.sourceTable) { + // sourceTable이 있으면 parentDataMapping과 유사 + // targetField가 메인테이블 컬럼 + joinColumns.push(mapping.targetField); +} else if (subTable.relationType === 'source') { + // 일반 source 타입 + joinColumns.push(mapping.sourceField); +} +``` + +--- + +### 2. displayColumns 잘못된 처리 + +#### 문제 +- `selected-items-detail-input` 컴포넌트의 `displayColumns`가 메인테이블 `joinColumns`에 추가됨 +- `customer_name`, `customer_code` 등이 조인 컬럼으로 잘못 표시됨 + +#### 원인 +```typescript +// 기존 잘못된 로직 +if (componentConfig.displayColumns) { + componentConfig.displayColumns.forEach((col) => { + joinColumns.push(col.name); // 연관 테이블 컬럼을 메인테이블에 추가 (잘못됨) + }); +} +``` + +#### 해결 +```typescript +// 수정된 로직 - displayColumns는 연관 테이블 컬럼이므로 제외 +// 조인 컬럼은 parentDataMapping.targetField에서 별도 추출됨 +if (componentConfig.displayColumns) { + // 메인 테이블 joinColumns에 추가하지 않음 +} +``` + +--- + +### 3. 조인 정보 한글명 미표시 + +#### 문제 +- 조인 컬럼 옆에 `← customer_code` (영문)로 표시됨 +- `← 거래처 코드` (한글)로 표시되어야 함 + +#### 해결 +백엔드에서 `column_labels` 테이블 조회하여 한글명 적용: + +```typescript +// 모든 테이블/컬럼 조합 수집 +const columnLookups = []; +screenSubTables.forEach((screenData) => { + screenData.subTables.forEach((subTable) => { + subTable.fieldMappings?.forEach((mapping) => { + if (mapping.sourceTable && mapping.sourceField) { + columnLookups.push({ tableName: mapping.sourceTable, columnName: mapping.sourceField }); + } + if (screenData.mainTable && mapping.targetField) { + columnLookups.push({ tableName: screenData.mainTable, columnName: mapping.targetField }); + } + }); + }); +}); + +// column_labels에서 한글명 조회 +const columnLabelsQuery = ` + SELECT table_name, column_name, column_label + FROM column_labels + WHERE (table_name, column_name) IN (...) +`; + +// 각 fieldMapping에 한글명 적용 +mapping.sourceDisplayName = columnLabelsMap[`${sourceTable}.${sourceField}`]; +mapping.targetDisplayName = columnLabelsMap[`${mainTable}.${targetField}`]; +``` + +--- + +### 4. 연결 선 라벨 제거 및 단일 로직화 + +#### 문제 +- 테이블 간 연결 선에 `customer_code → customer_id` 라벨이 표시됨 +- 조인 정보가 테이블 노드 내부에 이미 표시되므로 중복 +- 메인-메인 테이블 조인 로직이 2개 존재 (주황색, 초록색) + +#### 해결 +1. **초록색 선 로직 완전 제거**: 중복 로직으로 인한 혼란 방지 +2. **주황색 선 로직 개선**: 모든 메인-메인 조인을 단일 로직으로 처리 + +```typescript +// 메인-메인 조인 엣지 생성 (단일 로직) +const joinEdges: Edge[] = []; + +// 모든 화면의 메인 테이블 목록 +const allMainTables = new Set(Object.values(screenTableMap)); + +focusedSubTablesData.subTables.forEach((subTable) => { + // 1. subTable.tableName이 다른 화면의 메인 테이블인 경우 + const isTargetMainTable = allMainTables.has(subTable.tableName) + && subTable.tableName !== focusedMainTable; + + if (isTargetMainTable) { + joinEdges.push({ + id: `edge-main-join-${focusedScreenId}-${subTable.tableName}-${focusedMainTable}`, + source: `table-${subTable.tableName}`, + target: `table-${focusedMainTable}`, + type: 'smoothstep', + animated: true, + style: { + stroke: '#ea580c', // 주황색 (단일 색상) + strokeWidth: 2, + strokeDasharray: '8,4', + }, + markerEnd: { type: MarkerType.ArrowClosed, color: '#ea580c' }, + }); + } + + // 2. fieldMappings.sourceTable이 메인 테이블인 경우도 처리 + // ... (parentMapping, rightPanelRelation 등) +}); +``` + +--- + +### 5. 메인테이블 fieldMappings 미전달 + +#### 문제 +- 서브테이블에만 `fieldMappings`가 전달되어 조인 정보 표시됨 +- 메인테이블에는 조인 컬럼 옆 연결 정보가 미표시 + +#### 해결 +메인테이블과 연관테이블에 각각 `fieldMappings` 생성: + +```typescript +// 메인테이블용 fieldMappings 생성 +let mainTableFieldMappings = []; +if (isFocusedTable && focusedSubTablesData) { + focusedSubTablesData.subTables.forEach((subTable) => { + subTable.fieldMappings?.forEach((mapping) => { + if (subTable.relationType === 'source' && mapping.sourceTable) { + mainTableFieldMappings.push({ + sourceField: mapping.sourceField, + targetField: mapping.targetField, + sourceDisplayName: mapping.sourceDisplayName, + targetDisplayName: mapping.targetDisplayName, + }); + } + // ... 기타 relationType 처리 + }); + }); +} + +// 연관 테이블용 fieldMappings 생성 +let relatedTableFieldMappings = []; +if (isRelatedTable && relatedTableInfo && focusedSubTablesData) { + // 이 테이블이 sourceTable인 경우의 매핑 추출 +} + +// node.data에 fieldMappings 전달 +return { + ...node, + data: { + ...node.data, + fieldMappings: isFocusedTable ? mainTableFieldMappings + : (isRelatedTable ? relatedTableFieldMappings : []), + }, +}; +``` + +--- + +### 6. 엣지 방향 오류 (참조 방향 반대) + +#### 문제 +- `customer_mng` 화면 포커스 시 `customer_item_mapping`으로 연결선이 표시됨 +- 실제로는 `customer_item_mapping.customer_id` → `customer_mng.customer_code` 참조 관계 +- 참조하는 쪽(A)이 아닌 참조당하는 쪽(B)에서 연결선이 나가는 오류 + +#### 원인 +```typescript +// 기존 잘못된 로직 +newEdges.push({ + id: edgeId, + source: `table-${mainTable}`, // 참조당하는 테이블 (잘못됨) + target: `table-${subTable.tableName}`, // 참조하는 테이블 (잘못됨) + // ... +}); +``` + +#### 해결 +```typescript +// 수정된 올바른 로직 - source/target 교환 +newEdges.push({ + id: edgeId, + source: `table-${subTable.tableName}`, // 참조하는 테이블 (올바름) + target: `table-${mainTable}`, // 참조당하는 테이블 (올바름) + // ... +}); +``` + +#### 동작 예시 +| 관계 | 포커스된 화면 | 예상 동작 | 실제 동작 (수정 후) | +|------|--------------|----------|-------------------| +| A가 B를 참조 | A 포커스 | A→B 연결선 ✅ | A→B 연결선 ✅ | +| A가 B를 참조 | B 포커스 | 연결선 없음 ✅ | 연결선 없음 ✅ | + +--- + +### 7. column_labels 필터링 누락 + +#### 문제 +- `inbound_mng.item_code`가 `item_info`를 참조하는 것으로 조인선 표시 +- 해당 필드는 과거 `entity` 타입이었다가 `text`로 변경됨 +- `reference_table` 데이터가 잔존하여 잘못된 조인 관계 생성 + +#### 원인 +```sql +-- 기존 잘못된 쿼리 +WHERE cl.reference_table IS NOT NULL + AND cl.reference_table != '' + AND cl.reference_table != suc.main_table + -- input_type 체크 없음! +``` + +#### 해결 +```sql +-- 수정된 쿼리 - input_type = 'entity' 조건 추가 +WHERE cl.reference_table IS NOT NULL + AND cl.reference_table != '' + AND cl.reference_table != suc.main_table + AND cl.input_type = 'entity' -- 추가됨 +``` + +--- + +## NTT 설정 소스별 처리 + +시스템에서 조인 정보가 정의되는 모든 소스를 범용적으로 처리합니다: + +| 소스 | 위치 | 처리 상태 | +|------|------|----------| +| `column_labels.reference_table` | 테이블 관리 | ✅ 처리됨 | +| `componentConfig.fieldMappings` | autocomplete, entity-search | ✅ 처리됨 | +| `componentConfig.columns.mapping` | modal-repeater-table | ✅ 처리됨 | +| `componentConfig.parentDataMapping` | selected-items-detail-input | ✅ 처리됨 | +| `componentConfig.rightPanel.relation` | split-panel-layout | ✅ 처리됨 | + +--- + +## API 인터페이스 변경 + +### FieldMappingInfo + +```typescript +export interface FieldMappingInfo { + sourceTable?: string; // 연관 테이블명 (parentDataMapping에서 사용) + sourceField: string; + targetField: string; + sourceDisplayName?: string; // 연관 테이블 컬럼 한글명 + targetDisplayName?: string; // 메인 테이블 컬럼 한글명 +} +``` + +### SubTableInfo + +```typescript +export interface SubTableInfo { + tableName: string; + componentType: string; + relationType: 'lookup' | 'source' | 'join' | 'reference' | 'parentMapping' | 'rightPanelRelation'; + fieldMappings?: FieldMappingInfo[]; +} +``` + +--- + +## 테스트 결과 + +### 판매품목정보 그룹 (3번째 화면 포커스) + +| 테이블 | 컬럼 | 표시 내용 | +|--------|------|----------| +| `customer_item_mapping` | 거래처 ID | ← 거래처 코드 (조인) | +| `customer_item_mapping` | 품목 ID | ← 품번 (조인) | +| `customer_mng` | 거래처 코드 | ← 거래처 ID (조인) | +| `item_info` | 품번 | ← 품목 ID (조인) | + +### 수주관리 그룹 (기존 정상 작동 확인) + +모든 조인 관계 및 컬럼 표시 정상 작동 + +--- + +## 범용성 검증 + +| 항목 | 상태 | +|------|------| +| 특정 테이블명 하드코딩 | ❌ 없음 | +| 특정 컬럼명 하드코딩 | ❌ 없음 | +| 조건 기반 분기 | ✅ `sourceTable` 존재 여부, `relationType`으로 판단 | +| 새로운 화면/그룹 적용 | ✅ 자동 적용 | + +--- + +## 시각화 결과 예시 + +### 포커스 전 (그룹 전체 선택) +``` +[화면1] ─── [화면2] ─── [화면3] + │ │ │ + ▼ ▼ ▼ +[item_info] [customer_mng] [customer_item_mapping] + (메인) (메인) (메인) +``` + +### 3번째 화면 포커스 시 +``` +[화면1] ─── [화면2] ─── [화면3] ← 활성 + │ │ │ + ▼ ▼ ▼ +[item_info] [customer_mng] [customer_item_mapping] + (연관) (연관) (메인/활성) + │ │ │ + │ │ ┌───────────┴───────────┐ + │ │ │ │ + └─────────────┼──────┤ 거래처 ID ← 거래처 코드 │ + │ │ 품목 ID ← 품번 │ + │ │ (+ 13개 사용 컬럼) │ + │ └───────────────────────┘ + ┌─────────────┘ + │ 거래처 코드 ← 거래처 ID + └───────────────────── +``` + +--- + +## 결론 + +1. **범용성 확보**: 모든 NTT 설정 소스에서 조인 관계 자동 추출 +2. **정확성 개선**: `relationType`과 `sourceTable` 기반 정확한 조인 컬럼 식별 +3. **사용자 경험 향상**: 한글명 표시 및 직관적인 연결 정보 제공 +4. **유지보수성**: 새로운 화면/그룹 추가 시 별도 코드 수정 불필요 +5. **메인-메인 조인 로직 단일화**: 기존 중복 로직(초록색/주황색)을 주황색 단일 로직으로 통합 + - `subTable.tableName`이 다른 화면의 메인 테이블인 경우 자동 연결 + - `fieldMappings.sourceTable` 없어도 메인 테이블 간 조인 감지 +6. **연관 테이블 포커싱 개선**: 포커스된 화면의 서브 테이블이 다른 화면의 메인 테이블인 경우 활성화 + - 회색 처리 대신 정상 표시 및 조인 컬럼 강조 + - `relatedMainTables` 생성 로직 확장으로 `subTable.tableName` 기반 감지 +7. **선 연결 규격 정립**: 일관되고 직관적인 연결선 방향 규격화 + - **메인-메인 연결선**: `bottom → bottom_target` 고정 (서브테이블 구간을 통해 연결) + - **서브 테이블 연결선**: `bottom → top` 고정 (아래로 문어발처럼 뻗어나감) + - **절대 규칙**: 선이 테이블이나 화면을 통과하지 않음 +8. **초기 메인-메인 엣지 생성**: 그룹 로딩 시점에 메인 테이블 간 연결선 미리 생성 + - 연한 주황색(`#fdba74`) 점선으로 기본 표시 + - 포커싱 시 진한 주황색(`#ea580c`)으로 강조 및 애니메이션 + - 중복 방지를 위한 `pairKey` 기반 Set 사용 +9. **ReactFlow Handle ID 구분**: 메인-메인 연결을 위한 핸들 추가 + - `TableNode`에 `id="bottom_target"` (type="target") 핸들 추가 + - 메인-메인 엣지는 `sourceHandle="bottom"`, `targetHandle="bottom_target"` 사용 + - 서브 테이블 엣지는 기존대로 `sourceHandle="bottom"`, `targetHandle="top"` 사용 +10. **메인-메인 강조 로직 개선**: 중복 연결선 및 잘못된 연결선 방지 + - 포커스된 메인 테이블이 `source`인 경우에만 해당 엣지 강조 + - 양방향 중복 강조 방지 (A→B와 B→A 동시 강조 안 함) + - 연결되지 않은 테이블에서 조인선이 나타나는 문제 해결 +11. **엣지 방향 수정**: 참조 방향에 맞게 엣지 source/target 교정 + - 기존 잘못된 방향: `mainTable → subTable.tableName` (참조당하는 테이블 → 참조하는 테이블) + - 수정된 올바른 방향: `subTable.tableName → mainTable` (참조하는 테이블 → 참조당하는 테이블) + - A가 B를 참조(entity 설정)하면: A 포커스 시 A→B 연결선 표시 + - B 포커스 시 연결선 없음 (B는 A를 참조하지 않으므로) +12. **column_labels 필터링 강화**: `input_type = 'entity'`인 경우만 참조 관계로 인정 + - `input_type = 'text'`인 경우 `reference_table`이 있어도 조인 관계로 취급하지 않음 + - 과거 entity 설정 후 text로 변경된 경우 잔존 데이터 무시 +13. **범용적 엣지 방향 결정 로직**: `relationType`에 따른 조건부 방향 결정 + - `parentMapping` (sourceTable 있음): `mainTable → sourceTable` 방향 + - `rightPanelRelation` (foreignKey 있음): `subTable → mainTable` 방향 + - `reference` (column_labels): `mainTable → subTable` 방향 + - 기본값: `subTable → mainTable` 방향 + +### 연결선 규격 다이어그램 + +``` +[화면1] [화면2] [화면3] [화면4] + │ │ │ │ + ▼ ▼ ▼ ▼ +[Table1] [Table2] [Table3] [Table4] ← 메인 테이블 + │ │ │ │ + │ bottom │ bottom │ bottom │ bottom + ▼ ▼ ▼ ▼ +┌─────────────────────────────────────────┐ ← 서브테이블 구간 +│ (메인-메인 연결선이 이 구간을 통과) │ +│ ◄── bottom ─ bottom_target ───► │ +│ (서브테이블 연결선도 이 구간에서 연결) │ +└─────────────────────────────────────────┘ + │ │ + ▼ top ▼ top + [서브1] [서브2] [서브3] ← 서브 테이블 +``` + +--- + +## [계획] 연결선 정리 시스템 설계 + +> **상태**: 요건 정의 단계 (미구현) + +### 배경 및 필요성 + +현재 시스템에서 연결선 종류가 증가함에 따라 체계적인 선 관리 시스템이 필요합니다. + +| 현재 상태 | 문제점 | +|-----------|--------| +| 조인선만 존재 | 종속 조회, 저장 테이블 등 추가 선 종류 필요 | +| 경로 규격 미정립 | 선이 테이블을 통과할 가능성 | +| 겹침 방지 미흡 | 같은 경로 사용 시 선 겹침 | + +--- + +### 절대 규칙 (위반 불가) + +1. **선이 테이블 노드를 가로로 통과하면 안됨** +2. **선이 화면 노드를 통과하면 안됨** +3. **같은 종류의 선은 같은 구간에서 이동** +4. **다른 종류의 선은 겹치지 않아야 함** + +--- + +### 연결선 종류 정의 + +#### 현재 구현됨 + +| 번호 | 선 종류 | 의미 | 색상 | 스타일 | 상태 | +|------|---------|------|------|--------|------| +| 1 | 화면 → 메인 테이블 | 화면이 사용하는 테이블 | 파란색 (`#3b82f6`) | 실선 | 구현됨 | +| 2 | 조인선 (엔티티 참조) | 테이블 간 데이터 병합 | 주황색 (`#ea580c`) | 점선 `8,4` | 구현됨 | + +#### 추가 예정 + +| 번호 | 선 종류 | 의미 | 색상 (예정) | 스타일 (예정) | 상태 | +|------|---------|------|-------------|---------------|------| +| 3 | 종속 조회선 (Master-Detail) | 선택 기준 필터 조회 | 시안/보라색 | 점선 `4,4` | 미구현 | +| 4 | 저장 테이블선 | 데이터 저장 대상 | 녹색 | 실선 or 점선 | 미구현 | +| 5+ | 기타 확장 | 데이터 플로우 등 | 미정 | 미정 | 미구현 | + +--- + +### 개념 구분: Join vs 종속 조회 + +| 구분 | Join (조인) | 종속 조회 (Filter) | +|------|------------|-------------------| +| **데이터 처리** | 두 테이블 **병합** | 한 테이블로 다른 테이블 **필터링** | +| **표시 방식** | 같은 그리드에 합쳐서 | 별도 그리드에 각각 | +| **SQL** | `JOIN ON A.key = B.key` | `WHERE B.key = (선택된 A.key)` | +| **예시** | `inbound_mng` + `item_info` 정보 합침 | `customer_mng` 선택 → `customer_item_mapping` 별도 조회 | +| **현재 표현** | 주황색 점선 | 미표현 | + +**더블 그리드 패턴 예시:** +``` +[customer_mng 그리드] | [customer_item_mapping 그리드] + (메인/선택) → (필터링된 결과) +``` +- 이 경우 `customer_mng`가 진짜 메인 +- `customer_item_mapping`은 **종속 조회** (메인이 아님, 선택에 따라 필터링) + +--- + +### 레이아웃 구간 정의 + +``` +Y: 0-200 ┌─────────────────────────────────────────────┐ + │ [화면1] [화면2] [화면3] [화면4] │ ← 화면 구간 + └─────────────────────────────────────────────┘ + │ │ │ │ +Y: 200-300 ════════════════════════════════════════════════ ← 화면-테이블 연결 구간 (파란 실선) + │ │ │ │ +Y: 300-500 ┌─────────────────────────────────────────────┐ + │ [Table1] [Table2] [Table3] [Table4] │ ← 메인 테이블 구간 + └─────────────────────────────────────────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ +Y: 500-700 ┌─────────────────────────────────────────────┐ + │ [서브1] [서브2] [서브3] │ ← 서브 테이블 구간 + └─────────────────────────────────────────────┘ + │ │ │ │ +Y: 700-750 ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ← [레벨1] 조인선 구간 (주황) +Y: 750-800 ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ← [레벨2] 종속 조회선 구간 (시안/보라) +Y: 800-850 ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ← [레벨3] 저장 테이블선 구간 (녹색) +Y: 850+ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ← [레벨4+] 확장 구간 +``` + +--- + +### 선 경로 규격 + +#### 원칙 +1. **수직 이동**: 노드 사이 공간에서만 (노드 통과 안함) +2. **수평 이동**: 각 선 종류별 전용 레벨에서만 +3. **겹침 방지**: 서로 다른 Y 레벨 사용 + +#### 선 종류별 경로 + +| 선 종류 | 출발 핸들 | 도착 핸들 | 수평 이동 레벨 | +|---------|-----------|-----------|----------------| +| 화면 → 메인 | 화면 bottom | 테이블 top | Y: 200-300 (화면-테이블 구간) | +| 메인 → 서브 | 테이블 bottom | 서브 top | Y: 500-700 (서브 테이블 구간 내) | +| 조인선 (메인-메인) | 테이블 bottom | 테이블 bottom_target | **Y: 700-750** (레벨1) | +| 종속 조회선 | 테이블 bottom | 테이블 bottom_target | **Y: 750-800** (레벨2) | +| 저장 테이블선 | 테이블 bottom | 테이블 bottom_target | **Y: 800-850** (레벨3) | + +--- + +### 핸들 설계 + +현재 `TableNode`에 필요한 핸들: + +``` + [top] (target) ← 화면에서 오는 선 + │ + ┌────────┼────────┐ + │ │ │ + │ 테이블 노드 │ + │ │ + └────────┬────────┘ + │ + [bottom] (source) ← 나가는 선 (서브테이블, 조인 등) + │ + [bottom_target] (target) ← 메인-메인 연결용 들어오는 선 +``` + +**추가 필요 핸들 (겹침 방지용):** + +``` + ┌────────────────────────┐ + │ │ + │ 테이블 노드 │ + │ │ + └───┬───────┬───────┬────┘ + │ │ │ + (30%) (50%) (70%) ← X 위치 + │ │ │ + [bottom_join] [bottom] [bottom_filter] + │ │ + 조인선 전용 종속 조회선 전용 +``` + +--- + +### 구현 방안 비교 + +--- + +#### 방안 A: 커스텀 엣지 경로 (완벽 분리) + +**레이어 다이어그램:** + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ [화면1] [화면2] [화면3] [화면4] │ Y: 0-200 +│ │ │ │ │ │ +└────┼─────────────┼──────────────┼──────────────┼────────────────────┘ + │ │ │ │ +═════╧═════════════╧══════════════╧══════════════╧════════════════════ Y: 250 (화면-테이블 구간) + │ │ │ │ +┌────┼─────────────┼──────────────┼──────────────┼────────────────────┐ +│ [Table1] [Table2] [Table3] [Table4] │ Y: 300-500 +│ │ │ │ │ │ +└────┼─────────────┼──────────────┼──────────────┼────────────────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ [서브1] [서브2] [서브3] │ Y: 550-650 +└─────────────────────────────────────────────────────────────────────┘ + │ │ +─────┴───────────── 레벨1: 조인선 (주황) ────────┴───────────────────── Y: 700 + │ │ + └───────────────────────────────────────────┘ + │ │ +─────┴───────────── 레벨2: 종속 조회 (보라) ─────┴───────────────────── Y: 750 + │ │ + └─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ + │ │ +─────┴───────────── 레벨3: 저장 테이블 (녹색) ───┴───────────────────── Y: 800 + │ │ + └─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ +``` + +**특징**: 각 선 종류가 **전용 Y 레벨**에서만 수평 이동. 절대 겹치지 않음. + +**코드 예시:** + +```typescript +// 커스텀 경로 계산 +const getCustomPath = (source, target, lineType) => { + const levels = { + join: 725, // 조인선 Y 레벨 + filter: 775, // 종속 조회선 Y 레벨 + save: 825, // 저장 테이블선 Y 레벨 + }; + + const level = levels[lineType]; + + // 수직 → 수평(레벨) → 수직 경로 + return `M${source.x},${source.y} + L${source.x},${level} + L${target.x},${level} + L${target.x},${target.y}`; +}; +``` + +**장점:** +- 완벽한 분리 보장 +- 절대 규칙 100% 준수 +- 확장성 우수 + +**단점:** +- 구현 복잡도 높음 +- ReactFlow 기본 기능 대신 커스텀 필요 + +--- + +#### 방안 B: 핸들 위치 분리 + smoothstep + +**레이어 다이어그램:** + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ [화면1] [화면2] [화면3] [화면4] │ +└────┬─────────────┬──────────────┬──────────────┬────────────────────┘ + │ │ │ │ +┌────┼─────────────┼──────────────┼──────────────┼────────────────────┐ +│ [Table1] [Table2] [Table3] [Table4] │ +│ 30% 50% 70% 30% 50% 70% │ ← 핸들 X 위치 +│ │ │ │ │ │ │ │ +└───┼───┼───┼──────────────────────────────────┼───┼───┼──────────────┘ + │ │ │ │ │ │ + │ │ └── 종속 조회 (보라) ──────────────┼───┼───┘ ← 70% 위치 + │ │ │ │ + │ └────── 조인선 (주황) ─────────────────┼───┘ ← 50% 위치 + │ │ + └────────── 저장 테이블 (녹색) ────────────┘ ← 30% 위치 + + ※ 시작점은 다르지만 경로가 가까움 (smoothstep 자동 계산) + ※ 선이 가까이 지나가서 겹칠 가능성 있음 +``` + +**특징**: 핸들 X 위치만 다름. **경로가 가까이 지나가서 겹칠 가능성** 있음. + +**코드 예시:** + +```typescript +// 핸들 위치로 분리 + + + +// smoothstep + offset +edge.pathOptions = { offset: lineType === 'filter' ? 50 : 0 }; +``` + +**장점:** +- ReactFlow 기본 기능 활용 +- 구현 상대적 단순 + +**단점:** +- 완벽한 분리 보장 어려움 +- 복잡한 경우 선 겹침 가능 + +--- + +#### 방안 C: 선 대신 마커/뱃지 (종속 조회만) + +**레이어 다이어그램:** + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ [화면1] [화면2] [화면3] [화면4] │ +└────┬─────────────┬──────────────┬──────────────┬────────────────────┘ + │ │ │ │ +┌────┼─────────────┼──────────────┼──────────────┼────────────────────┐ +│ [Table1] [Table2] [Table3] [Table4 🔗] │ +│ │ ↑ │ +│ │ "Table1에서 필터 조회" (툴팁) │ +│ │ │ +│ └────────── 조인선만 표시 (주황) ──────────┘ │ +│ │ +│ ※ 종속 조회, 저장 테이블은 선 없이 뱃지/아이콘으로만 표시 │ +│ ※ 마우스 오버 시 관계 정보 툴팁 표시 │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ [서브1] [서브2] [서브3] │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**특징**: 선 없음 = **겹침/통과 문제 없음**. 하지만 관계 시각화 약함. + +**코드 예시:** + +```typescript +// 테이블 노드에 관계 뱃지 표시 + + + +``` + +**장점:** +- 선 없음 = 겹침/통과 문제 없음 +- 화면 깔끔 + +**단점:** +- 관계 시각화 약함 +- 일관성 부족 (조인은 선, 종속은 뱃지) + +--- + +#### 방안 D: 하이브리드 (커스텀 경로 통일) - 권장 + +**레이어 다이어그램:** + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 레이어 0: 화면 노드 │ +│ [화면1] [화면2] [화면3] [화면4] │ +└─────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────┐ +│ 레이어 1: 화면-테이블 연결 (파란 실선) │ +│ ═══════════════════════════════════════════════════════════════════ │ Y: 250 +└─────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────┐ +│ 레이어 2: 메인 테이블 노드 │ +│ [Table1] [Table2] [Table3] [Table4] │ +└─────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────┐ +│ 레이어 3: 서브 테이블 노드 │ +│ [서브1] [서브2] [서브3] │ +└─────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────┐ +│ 레이어 4: 조인선 구간 (주황색) │ +│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ Y: 700-725 +│ Table1 ──────────────────────────────────► Table4 │ +└─────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────┐ +│ 레이어 5: 종속 조회 구간 (보라색) │ +│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ Y: 750-775 +│ Table1 ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─► Table3 │ +└─────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────┐ +│ 레이어 6: 저장 테이블 구간 (녹색) │ +│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ Y: 800-825 +│ Table2 ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─► Table4 │ +└─────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────┐ +│ 레이어 7+: 확장 가능 │ +│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ Y: 850+ +│ (미래 확장용) │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**특징**: 모든 선이 **전용 레이어**에서 이동. 확장성 최고. 절대 겹치지 않음. + +**구현 방식:** +- **조인선**: 커스텀 경로 (방안 A) - 레벨4 (Y: 700-725) +- **종속 조회선**: 커스텀 경로 (방안 A) - 레벨5 (Y: 750-775) +- **저장 테이블선**: 커스텀 경로 (방안 A) - 레벨6 (Y: 800-825) + +모든 선을 동일한 커스텀 경로 시스템으로 통일. + +**장점:** +- 일관된 시스템 +- 완벽한 분리 +- 확장성 최고 +- 절대 규칙 100% 준수 + +**단점:** +- 초기 구현 비용 높음 + +--- + +### 방안 비교 요약표 + +| 방안 | 겹침 방지 | 절대규칙 준수 | 구현 복잡도 | 확장성 | 시각적 일관성 | +|------|-----------|---------------|-------------|--------|---------------| +| **A** | 완벽 | 100% | 높음 | 좋음 | 좋음 | +| **B** | 불완전 | 90% | 낮음 | 보통 | 보통 | +| **C** | 완벽 (선 없음) | 100% | 낮음 | 좋음 | 약함 | +| **D** | **완벽** | **100%** | 높음 | **최고** | **최고** | + +--- + +### 구현 우선순위 (제안) + +| 순서 | 작업 | 설명 | +|------|------|------| +| 1 | 커스텀 엣지 컴포넌트 개발 | 레벨 기반 경로 계산 | +| 2 | 기존 조인선 마이그레이션 | smoothstep → 커스텀 경로 | +| 3 | 종속 조회선 구현 | 레벨2 경로 + 시안/보라 색상 | +| 4 | 저장 테이블선 구현 | 레벨3 경로 + 녹색 | +| 5 | 테스트 및 최적화 | 다양한 그룹에서 검증 | + +--- + +### 색상 팔레트 (확정 - 2026-01-08) + +| 관계 유형 | 시각적 유형 | 기본 색상 | 강조 색상 | 설명 | +|-----------|-------------|-----------|-----------|------| +| 화면 → 메인 | - | `#3b82f6` (파랑) | `#2563eb` | 화면-테이블 연결 | +| 마스터-디테일 | `filter` | `#8b5cf6` (보라) | `#c4b5fd` | split-panel의 필터링 관계 | +| 계층 구조 | `hierarchy` | `#06b6d4` (시안) | `#a5f3fc` | 부모-자식 자기 참조 | +| 코드 참조 | `lookup` | `#f59e0b` (주황) | `#fcd34d` | autocomplete 등 코드→명칭 | +| 데이터 매핑 | `mapping` | `#10b981` (녹색) | `#6ee7b7` | parentDataMapping | +| 엔티티 조인 | `join` | `#ea580c` (주황) | `#fdba74` | 실제 LEFT/INNER JOIN | + +--- + +## 관계 유형 추론 시스템 (2026-01-08 구현) + +### 구현 개요 + +테이블 간 연결선이 단순 "조인"만이 아니라 다양한 관계 유형을 가지므로, +기존 컴포넌트 설정을 기반으로 관계 유형을 추론하여 시각화에 반영합니다. + +### 관계 유형 분류 + +| 유형 | 기술적 의미 | 컴포넌트 | 식별 조건 | +|------|------------|----------|-----------| +| `filter` | 마스터-디테일 필터링 | split-panel-layout | `relationType='rightPanelRelation'` + `originalRelationType='join'` | +| `hierarchy` | 자기 참조 계층 구조 | split-panel-layout | `relationType='rightPanelRelation'` + `originalRelationType='detail'` | +| `mapping` | 데이터 참조 주입 | selected-items-detail-input | `relationType='parentMapping'` | +| `lookup` | 코드→명칭 변환 | autocomplete, entity-search | `relationType='lookup'` | +| `join` | 실제 엔티티 조인 | column_labels 참조 | `relationType='reference'` | + +### 백엔드 수정 사항 + +`screenGroupController.ts`의 `getScreenSubTables` 함수에서 추가 필드 전달: + +```typescript +// rightPanel.relation 파싱 시 +screenSubTables[screenId].subTables.push({ + tableName: subTable, + componentType: componentType, + relationType: 'rightPanelRelation', + originalRelationType: relation?.type || 'join', // 추가: 원본 relation.type + foreignKey: relation?.foreignKey, // 추가: FK 컬럼 + leftColumn: relation?.leftColumn, // 추가: 마스터 컬럼 + fieldMappings: ..., +}); +``` + +### 프론트엔드 수정 사항 + +1. **타입 확장** (`screenGroup.ts`): + - `SubTableInfo`에 `originalRelationType`, `foreignKey`, `leftColumn` 필드 추가 + - `VisualRelationType` 타입 정의: `'filter' | 'hierarchy' | 'lookup' | 'mapping' | 'join'` + - `inferVisualRelationType()` 함수 추가 + +2. **시각화 적용** (`ScreenRelationFlow.tsx`): + - `RELATION_COLORS` 상수 정의 (관계 유형별 색상) + - 엣지 생성 시 `inferVisualRelationType()` 호출 + - 엣지 스타일에 관계 유형별 색상 적용 + +### 2026-01-09 추가 수정 사항 + +1. **방향 수정**: `rightPanelRelation` 엣지 방향을 `mainTable → subTable`로 변경 + - 이전: `customer_item_mapping → customer_mng` (디테일 → 마스터, 잘못됨) + - 수정: `customer_mng → customer_item_mapping` (마스터 → 디테일, 올바름) + +2. **화면별 엣지 분리**: 같은 테이블 쌍이라도 화면별로 별도 엣지 생성 + - `pairKey`에 `screenId` 포함: `${sourceScreenId}-${[mainTable, subTable].sort().join('-')}` + - `edgeId`에 `screenId` 포함: `edge-main-main-${sourceScreenId}-${referrerTable}-${referencedTable}` + +3. **포커스 필터링 개선**: 해당 화면에서 생성된 연결선만 표시 + - 이전: `sourceTable === focusedMainTable` 조건만 체크 (다른 화면 연결선도 표시됨) + - 수정: `edge.data.sourceScreenId === focusedScreenId` 조건으로 변경 + +4. **parentMapping을 join으로 변경**: `selected-items-detail-input`의 `parentDataMapping`은 FK 관계이므로 `join`으로 분류 + - `customer_item_mapping → customer_mng`: 주황색 (FK: customer_id → customer_code) + - `customer_item_mapping → item_info`: 주황색 (FK: item_id → item_number) + +5. **참조 테이블 시각적 표시**: lookup/reference 관계로 참조되는 테이블에 "X곳 참조" 배지 표시 + - `TableNodeData`에 `referencedBy` 필드 추가 + - `ReferenceInfo` 인터페이스 정의 (fromTable, fromColumn, toColumn, relationType) + - 테이블 노드 헤더에 주황색 배지로 참조 카운트 표시 + - 툴팁에 참조하는 테이블 목록 표시 + +6. **마스터-디테일 필터링 관계 표시**: 디테일 테이블에 "X 필터" 배지 표시 + - 마스터-디테일 관계(rightPanelRelation)도 참조 정보 수집에 추가 + - **보라색 배지**로 "customer_mng 필터" 형태로 표시 + - 툴팁에 FK 컬럼 정보 표시 (예: "customer_mng에서 필터링 (FK: customer_id)") + - lookup 관계는 주황색, filter 관계는 보라색으로 구분 + +7. **FK 컬럼 보라색 강조 + 키값 정보 표시** + - 디테일 테이블에서 필터링에 사용되는 FK 컬럼을 **보라색 배경**으로 강조 + - 컬럼 옆에 참조 정보 표시: "← customer_mng.customer_code" + - 배지에 키값 정보 명확히 표시: "customer_mng.customer_code 필터" + - `TableNodeData`에 `filterColumns` 필드 추가 + - `ReferenceInfo`에서 `toColumn` 정보로 FK 컬럼 식별 + +8. **포커스 상태 기반 필터 표시 개선** + - **문제**: 필터 배지가 모든 화면에서 항상 표시되어 혼란 발생 + - **해결**: 포커스된 화면에서만 해당 관계의 필터 정보 표시 + - 노드 생성 시 `referencedBy`, `filterColumns` 제거 + - `styledNodes` 함수에서 포커스 상태에 따라 동적으로 설정 + - 배지를 헤더 아래 별도 영역으로 이동하여 테이블명 가림 방지 + +**결과:** +| 화면 | customer_item_mapping 표시 | +|------|----------------------------| +| 1번 화면 포커스 | 필터 배지 O + FK 컬럼 보라색 + **상단 정렬** | +| 4번 화면 포커스 | 필터 배지 X, 조인만 표시 | +| 그룹 선택 (포커스 없음) | 필터 배지 X, 테이블명만 표시 | + +9. **필터 컬럼 상단 정렬** + - 필터 컬럼도 파란색/주황색 컬럼처럼 상단에 정렬되어 표시 + - `potentialFilteredColumns`에 `filterSet` 포함 + - 정렬 순서: **조인 컬럼 → 필터 컬럼 → 사용 컬럼** + - 보라색 강조로 필터링 관계 명확히 구분 + +**정렬 우선순위:** +| 순서 | 컬럼 유형 | 색상 | 설명 | +|------|----------|------|------| +| 1 | 조인 컬럼 | 주황색 | FK 조인 관계 | +| 2 | 필터 컬럼 | 보라색 | 마스터-디테일 필터링 | +| 3 | 사용 컬럼 | 파란색 | 화면 필드 매핑 | + +10. **방안 C 적용: 필터선 제거 + 보라색 테두리 애니메이션** + - 필터 관계는 선 없이 뱃지 + 테이블 테두리로만 표시 (겹침 방지) + - 필터링된 테이블에 **보라색 테두리** 적용 (부드러운 색상 전환) + - 조인선(주황)만 표시, 필터선(보라) 제거 + +11. **테이블 높이 부드러운 애니메이션** + - 포커스 시 컬럼 목록이 변경될 때 **부드러운 높이 전환** 적용 + - `transition: height 0.5s cubic-bezier(0.4, 0, 0.2, 1)` 사용 + - **Debounce 로직** (50ms): 듀얼 그리드에서 filterColumns와 joinColumns가 2단계로 업데이트되는 문제 해결 + - 중간 값(늘어났다가 줄어드는 현상) 무시, 최종 값만 적용 + +12. **뱃지 영역 레이아웃 개선** + - 뱃지를 컬럼 목록 영역 **안에 포함** (높이 늘어남 방지) + - `calculatedHeight`에 뱃지 높이(26px) 포함하여 계산 + - 뱃지와 컬럼 동시 변경으로 "늘어났다가 줄어드는" 현상 해결 + +13. **뱃지 스타일 개선** + - 회색 테두리 (`border-slate-300`) + 연한 배경 (`bg-slate-50`) + - 보라색 컬럼과 확실히 구분되는 디자인 + - 필터 태그: 보라색 pill 스타일 (`rounded-full bg-violet-600`) + +**시각적 표현:** +| 관계 유형 | 선 표시 | 테두리 | 배지 | +|----------|---------|--------|------| +| 조인 | ✅ 주황색 점선 | - | "조인" | +| 필터 | ❌ 없음 | 보라색 (부드러운 전환) | "필터 + 키값" | +| 룩업 | ✅ 황색 점선 | - | "N곳 참조" | + +**구현 상세:** +- `ScreenRelationFlow.tsx`: `visualRelationType === 'filter'`인 경우 엣지 생성 건너뛰기 +- `ScreenNode.tsx`: + - `hasFilterRelation` 조건으로 보라색 테두리 + 부드러운 색상 전환 적용 + - `calculatedHeight`에 뱃지 높이 포함 + - `debouncedHeight` 사용으로 중간 값 무시 + - 뱃지를 컬럼 목록 div 안에 배치 + +### 향후 개선 가능 사항 + +1. [ ] 범례(Legend) UI 추가 - 관계 유형별 색상 설명 +2. [ ] 엣지 라벨에 관계 유형 표시 +3. [x] 툴팁에 상세 관계 정보 표시 (FK, 연결 컬럼 등) - 완료 + +--- + +### 다음 단계 + +1. [x] 방안 확정 - 방안 1 (추론 로직) 선택 +2. [x] 색상 팔레트 확정 +3. [x] 관계 유형 추론 함수 구현 +4. [x] 방향 및 포커스 필터링 수정 +5. [x] parentMapping을 join으로 변경 +6. [x] 참조 테이블 시각적 표시 추가 +7. [x] 마스터-디테일 필터링 관계 표시 추가 +8. [x] FK 컬럼 보라색 강조 + 키값 정보 표시 +9. [x] 포커스 상태 기반 필터 표시 개선 +10. [x] 필터 컬럼 상단 정렬 (조인 → 필터 → 사용 순서) +11. [x] 방안 C 적용: 필터선 제거 + 보라색 테두리 (펄스 → 부드러운 전환으로 변경) +12. [x] 테이블 높이 부드러운 애니메이션 + Debounce 적용 +13. [x] 뱃지 영역 레이아웃 개선 (컬럼 목록 안에 포함) +14. [x] 뱃지 스타일 개선 (회색 테두리로 컬럼과 구분) +15. [x] 서브테이블 Y 좌표 조정 (690px → 740px) +16. [x] **저장 테이블 시각화** (구현 완료) +17. [x] 테이블 스크롤 기능 추가 (maxHeight + overflow-y-auto) +18. [x] 테이블/헤더 둥근 모서리 (rounded-xl, rounded-t-xl) +19. [x] 필터 테이블 조인선 + 참조 테이블 활성화 +20. [x] 조인선 색상 상수 통일 (RELATION_COLORS.join.stroke) +21. [x] 필터 연결선 포커싱 제어 (해당 화면 포커싱 시에만 표시) +22. [x] 저장 테이블 제외 조건 추가 (table-list + 체크박스 + openModalWithData) +23. [x] 첫 진입 시 포커싱 없이 시작 (트리에서 화면 클릭 시 그룹만 진입) +24. [ ] **선 교차점 이질감 해결** (계획 중) +22. [ ] 범례 UI 추가 (선택사항) +23. [ ] 엣지 라벨에 관계 유형 표시 (선택사항) + +--- + +## 저장 테이블 시각화 (구현 완료) + +### 개요 +화면에서 데이터가 **어떤 테이블에 저장**되는지 시각화 + +### 저장 테이블 유형 + +| 유형 | 설명 | 예시 | +|------|------|------| +| **메인 저장** | 화면의 메인 테이블에 직접 저장 | 수주등록 → `sales_order_mng` | +| **연계 저장** | 버튼 클릭 → 다른 화면의 테이블에 저장 | 수주관리 → 출하계획 → `shipment_plan` | +| **서브 저장** | 듀얼 그리드에서 서브 테이블에 저장 | 거래처관리 → `customer_item_mapping` | + +### 데이터 수집 방법 (백엔드) + +저장 테이블 정보를 찾을 수 있는 곳: +1. `componentConfig.action.type = 'save'` (edit, delete 제외) +2. `componentConfig.targetTable` (modal-repeater-table 등) +3. `action.dataTransfer.targetTable` (데이터 전송 대상) + +**제외 조건:** +1. `action.targetScreenId IS NOT NULL` (모달 열기 버튼) +2. `table-list` + 체크박스 활성화 + `openModalWithData` 버튼이 있는 화면 + - 예: "거래처별 품목 추가 모달" - 선택 후 다음 화면으로 넘기는 패턴 + - 이 경우 "저장" 버튼은 DB 저장이 아닌 **선택 확인 용도** + +```sql +-- 제외 조건 SQL +AND NOT EXISTS ( + SELECT 1 FROM screen_layouts sl_list + WHERE sl_list.screen_id = sd.screen_id + AND sl_list.properties->>'componentType' = 'table-list' + AND (sl_list.properties->'componentConfig'->'checkbox'->>'enabled')::boolean = true +) +AND NOT EXISTS ( + SELECT 1 FROM screen_layouts sl_modal + WHERE sl_modal.screen_id = sd.screen_id + AND sl_modal.properties->'componentConfig'->'action'->>'type' = 'openModalWithData' +) +``` + +### 시각적 표현 (구현됨) + +**핑크색 막대기 표시** +- 테이블 노드 **왼쪽 바깥**에 핑크색 세로 막대기 표시 +- 위에서 아래로 나타나는 애니메이션 (`scaleY` 트랜지션) +- 포커스 해제 시 사라지는 애니메이션 +- 막대기 양끝 그라데이션 (투명 → 핑크 → 투명) + +**스타일:** +```css +/* 저장 막대기 스타일 */ +position: absolute; +left: -6px; /* -left-1.5 */ +top: 4px; +bottom: 4px; +width: 2px; /* w-0.5 */ +background: linear-gradient( + to bottom, + transparent 0%, + #f472b6 15%, /* pink-400 */ + #f472b6 85%, + transparent 100% +); +transition: all 0.5s ease-out; +transform-origin: top; +``` + +**애니메이션:** +- 포커스 시: `opacity: 1, scaleY: 1` (나타남) +- 포커스 해제 시: `opacity: 0, scaleY: 0` (사라짐) + +### 색상 팔레트 + +| 관계 유형 | 선 색상 | 뱃지/막대 색상 | 컬럼 강조 | +|----------|---------|---------------|----------| +| 조인 | 주황 (#F97316) | 주황 | 주황 | +| 필터 | - | 보라 (#8B5CF6) | 보라 | +| 룩업 | 황색 (#EAB308) | 황색 | - | +| **저장** | - | 핑크 (#F472B6) | - | + +### 구현 단계 (완료) + +1. [x] 백엔드: `getScreenSubTables`에서 저장 테이블 정보 추출 +2. [x] 타입 정의: `SaveTableInfo` 인터페이스 추가 +3. [x] 프론트엔드: 핑크색 막대기 UI 구현 +4. [x] 프론트엔드: 포커싱 시에만 표시 +5. [x] 프론트엔드: 나타나기/사라지기 애니메이션 +6. [ ] 프론트엔드: 뱃지 클릭 시 팝오버 상세정보 (향후) + +--- + +## 필터 테이블 조인선 시각화 (구현 완료) + +### 개요 +마스터-디테일 관계에서 **필터 대상 테이블**이 **다른 테이블과 조인**하는 경우도 시각화 + +### 시나리오 +"거래처관리 화면" (1번 화면) 포커싱 시: +- `customer_mng` (마스터) → `customer_item_mapping` (디테일) 필터 관계 +- `customer_item_mapping` → `item_info` **조인 관계** (품목 ID → 품번) + +### 구현 내용 + +1. **화면 → 필터 대상 테이블 연결선** + - 파란색 점선으로 화면 → `customer_item_mapping` 연결 + - 기존 `customer_mng`로만 가던 연결 외에 추가 + +2. **필터 대상 테이블의 조인선** + - `customer_item_mapping` → `item_info` 주황색 점선 조인선 + - `joinColumnRefs` 기반으로 자동 생성 + +3. **참조 테이블 활성화** + - `item_info` 테이블도 함께 활성화 (회색 처리 안 함) + - 조인 컬럼 주황색 강조 표시 + +### 포커싱 제어 + +**조인선 (주황색 점선)** +- 해당 화면이 포커싱됐을 때만 활성화 +- 다른 화면 포커싱 시 흐리게 처리 (opacity: 0.3) +- 엣지 ID: `edge-filter-join-{screenId}-{sourceTable}-{targetTable}` + +**필터 연결선 (파란색 점선)** +- 화면 → 필터 대상 테이블 연결선 +- 해당 화면이 포커싱됐을 때만 표시 (opacity: 1) +- 포커스 해제 시 완전히 숨김 (opacity: 0) +- 엣지 ID: `edge-screen-filter-{screenId}-{tableName}` + +**styledEdges 처리:** +```typescript +// 필터 조인 엣지 (주황색) +if (edge.id.startsWith("edge-filter-join-")) { + const isActive = focusedScreenId === edgeSourceScreenId; + return { + ...edge, + style: { + stroke: isActive ? RELATION_COLORS.join.stroke : RELATION_COLORS.join.strokeLight, + opacity: isActive ? 1 : 0.3, + }, + }; +} + +// 화면 → 필터 대상 테이블 연결선 (파란색) +if (edge.id.startsWith("edge-screen-filter-")) { + const isActive = focusedScreenId === edgeSourceScreenId; + return { + ...edge, + style: { + opacity: isActive ? 1 : 0, // 포커스 해제 시 완전히 숨김 + }, + }; +} +``` + +### 코드 위치 +- `ScreenRelationFlow.tsx`: 필터 조인 엣지 생성 + styledEdges 처리 +- `styledNodes`: 필터 대상 테이블의 조인 참조 테이블 활성화 로직 + +--- + +## 테이블 노드 UI 개선 (구현 완료) + +### 스크롤 기능 +- 컬럼이 많을 경우 스크롤 가능 (`overflow-y-auto`) +- 최대 높이 제한 (`maxHeight: 300px`) +- 얇은 스크롤바 (`scrollbar-thin`) + +### 둥근 모서리 +- 테이블 전체: `rounded-xl` (12px) +- 헤더: `rounded-t-xl` (상단만 12px) + +### 조인선 색상 통일 +- 모든 조인선이 `RELATION_COLORS.join.stroke` 상수 사용 +- 기본 색상: `#f97316` (orange-500) +- 강조 색상: `#ea580c` (orange-600) + +### 첫 진입 시 포커싱 없이 시작 + +**문제:** +- 트리에서 화면을 클릭하면 해당 화면이 자동 포커싱됨 +- 첫 진입 시 노드 위치가 안정화되기 전에 필터선이 그려져 "망가진" 모습 + +**해결:** +- 트리에서 화면 클릭 시: 그룹만 진입, 포커싱 없음 +- ReactFlow 안에서 화면 클릭 시: 정상 포커싱 + +**코드 변경:** +```typescript +// page.tsx - onScreenSelectInGroup 콜백 +onScreenSelectInGroup={(group, screenId) => { + const isNewGroup = selectedGroup?.id !== group.id; + + if (isNewGroup) { + // 새 그룹 진입: 포커싱 없이 시작 + setSelectedGroup(group); + setFocusedScreenIdInGroup(null); + } else { + // 같은 그룹 내에서 다른 화면 클릭: 포커싱 유지 + setFocusedScreenIdInGroup(screenId); + } + setSelectedScreen(null); +}} +``` + +**사용자 경험:** +1. 트리에서 화면 클릭 (첫 진입) → 깔끔한 초기 상태 (모든 화면/테이블 동일 밝기) +2. 같은 그룹 내에서 다른 화면 클릭 → 포커싱 + 연결선 표시 +3. ReactFlow에서 화면 노드 클릭 → 포커싱 + 연결선 표시 + +--- + +## [계획] 선 교차점 이질감 해결 + +> **상태**: 방안 검토 중 (미구현) + +### 배경 +여러 파란색 연결선이 서로 교차할 때 시각적 이질감 발생 + +### 해결 방안 + +#### 방안 C: 배경색 테두리 (Outline) - 권장 +- 각 선에 **흰색 테두리(outline)** 추가 +- 교차할 때 위에 있는 선이 아래 선을 "덮는" 효과 +- SVG stroke에 흰색 outline 적용 + +**구현 방식:** +```typescript +// 커스텀 엣지 컴포넌트에서 + + +``` + +**장점:** +- 구현 비교적 쉬움 +- 교차점이 깔끔하게 분리되어 보임 +- 핸들 위치/경로 변경 없음 + +**단점:** +- 선이 약간 두꺼워 보일 수 있음 + +--- + +## 화면 관리 시스템 업그레이드 현황 + +### 프로젝트 개요 + +화면 관리 시스템 업그레이드를 통해 다음 3가지 핵심 기능을 구현: + +| 기능 | 설명 | 상태 | +|------|------|------| +| **화면 그룹핑** | 관련 화면들을 그룹으로 묶어 관리 (트리 구조) | 기본 구현 완료 | +| **화면-테이블 관계 시각화** | React Flow를 사용한 노드 기반 시각화 | 기본 구현 완료 | +| **테이블 조인 설정** | 화면 내에서 테이블 간 조인 관계 직접 설정 | 미구현 | + +--- + +### 데이터베이스 테이블 (5개) + +| 테이블명 | 용도 | 상태 | +|----------|------|------| +| `screen_groups` | 화면 그룹 정보 | 생성됨 | +| `screen_group_screens` | 화면-그룹 연결 (N:M) | 생성됨 | +| `screen_field_joins` | 화면 필드 조인 설정 | 생성됨 | +| `screen_data_flows` | 화면 간 데이터 흐름 | 생성됨 | +| `screen_table_relations` | 화면-테이블 관계 | 생성됨 | + +--- + +### 백엔드 API 현황 + +| 파일 | 상태 | 엔드포인트 | +|------|------|-----------| +| `screenGroupController.ts` | 완성됨 | 그룹/화면/조인/흐름/관계 CRUD | +| `screenGroupRoutes.ts` | 완성됨 | `/api/screen-groups/*` | + +--- + +### 프론트엔드 컴포넌트 현황 + +| 컴포넌트 | 경로 | 상태 | +|----------|------|------| +| `ScreenGroupTreeView.tsx` | `components/screen/` | **완료** | +| `ScreenGroupModal.tsx` | `components/screen/` | **완료** (그룹 CRUD 모달) | +| `ScreenRelationFlow.tsx` | `components/screen/` | **완료** | +| `ScreenNode.tsx` | `components/screen/` | **완료** | +| `FieldJoinPanel.tsx` | `components/screen/panels/` | **완료** (조인 설정) | +| `DataFlowPanel.tsx` | `components/screen/panels/` | **완료** (데이터 흐름 설정) | +| API 클라이언트 | `lib/api/screenGroup.ts` | **완료** | + +--- + +### 구현 완료 목록 + +| # | 항목 | 완료일 | +|---|------|--------| +| 1 | DB 테이블 5개 생성 및 메타데이터 등록 | - | +| 2 | 백엔드 API 전체 구현 (CRUD) | - | +| 3 | 프론트엔드 API 클라이언트 구현 | - | +| 4 | 트리 뷰 기본 구현 (그룹/화면 표시) | - | +| 5 | React Flow 시각화 기본 구현 (노드 배치, 연결선) | - | +| 6 | 노드 디자인 1차 개선 (정사각형, 흰색 테마) | - | +| 7 | 화면 레이아웃 요약 API 추가 | 2026-01-01 | +| 8 | 화면 노드 미리보기 구현 (폼/그리드/대시보드) | 2026-01-01 | +| 9 | 테이블 노드 개선 (PK/FK 아이콘, 컬럼 목록) | 2026-01-01 | +| 10 | 연결선 스타일 개선 (CRUD 라벨 제거, 1:N 표시) | 2026-01-01 | + +--- + +### 추가 구현 완료 목록 + +| # | 항목 | 컴포넌트 | 상태 | +|---|------|----------|------| +| 11 | **그룹 관리 UI** | `ScreenGroupModal.tsx` | **완료** | +| 12 | **조인 설정 UI** | `FieldJoinPanel.tsx` (414줄) | **완료** | +| 13 | **데이터 흐름 설정 UI** | `DataFlowPanel.tsx` (462줄) | **완료** | + +--- + +### 미구현 작업 목록 (UI 선택사항) + +| # | 항목 | 설명 | 우선순위 | +|---|------|------|----------| +| 1 | **화면 미리보기 고도화** | 실제 컴포넌트 렌더링, 더 상세한 폼 필드 표시 | 낮음 | +| 2 | 범례(Legend) UI 추가 | 관계 유형별 색상 설명 | 낮음 | +| 3 | 뱃지 클릭 시 팝오버 상세정보 | 저장/필터/조인 뱃지 클릭 시 상세 정보 | 낮음 | +| 4 | 선 교차점 이질감 해결 | 배경색 테두리 방식 | 낮음 | + +--- + +## [다음 단계] 노드 플로워 기반 화면-테이블 설정 시스템 + +### 배경 및 목적 + +**문제**: 화면 디자이너에 너무 많은 기능이 집중되어 있음 +- 조인 설정, 필터 설정, 필드-컬럼 매칭, 저장 테이블 설정 등 + +**해결책**: 화면 관리 노드 플로워에서 이러한 설정을 **직접** 할 수 있게 함 +- 노드 플로워 = 화면-테이블 관계 설정의 **또 다른 UI** +- 시각적으로 설정하고, DB에 저장되면 화면 디자이너/실제 화면에 자동 반영 + +### 핵심 개념 + +``` +노드 플로워에서 화면/테이블 노드 클릭 (우클릭/더블클릭) + ↓ + 모달/팝업 열림 + ↓ + 설정 (조인, 필터, 필드-컬럼 매칭, 저장 테이블 등) + ↓ + DB 저장 (screen_layouts.properties, screen_field_joins 등) + ↓ + 시각화 자동 반영 (데이터 기반으로 그리니까) + 화면 디자이너 자동 반영 (같은 데이터 사용) + 실제 화면 자동 반영 (같은 데이터 사용) +``` + +### 구현 대상 기능 + +| 기능 | 설명 | +|------|------| +| **테이블 연결 설정** | 화면이 어떤 테이블과 연결되는지 | +| **테이블 조인 설정** | 테이블 간 조인 관계 (LEFT, INNER 등) | +| **필터링 설정** | 마스터-디테일 필터링 관계 | +| **필드-컬럼 매칭** | 화면 필드 ↔ 테이블 컬럼 매핑 | +| **저장 테이블 설정** | 어떤 테이블에 데이터가 저장되는지 | + +### 구현 방안 (초안, 미확정) + +#### 방안 A: 통합 설정 모달 + +노드 클릭 시 **하나의 모달**에서 탭으로 모든 설정 + +``` +[화면 노드] 더블클릭 + ↓ +┌─────────────────────────────────┐ +│ 수주관리 화면 설정 │ +│ │ +│ [탭1: 테이블 연결] │ +│ [탭2: 조인 설정] │ +│ [탭3: 필터 설정] │ +│ [탭4: 필드-컬럼 매칭] │ +│ [탭5: 저장 테이블] │ +│ │ +│ [저장] [취소] │ +└─────────────────────────────────┘ +``` + +#### 방안 B: 기능별 분리 모달 + +우클릭 컨텍스트 메뉴로 기능 선택 → 해당 기능 모달 열림 + +``` +[화면 노드] 우클릭 + ↓ +┌─────────────────┐ +│ 테이블 연결 설정 │ +│ 조인 설정 │ +│ 필터 설정 │ +│ 필드-컬럼 매칭 │ +│ 저장 테이블 설정 │ +└─────────────────┘ +``` + +#### 방안 C: 사이드 패널 + +노드 클릭 시 **오른쪽 패널**에 설정 UI 표시 (모달 없이) + +### 현재 상태 + +| 항목 | 상태 | +|------|------| +| 노드 플로워 시각화 | ✅ 완료 (읽기 전용) | +| DB 테이블 | ✅ 있음 (`screen_field_joins`, `screen_data_flows` 등) | +| 백엔드 API | ✅ 있음 (CRUD) | +| 패널 UI | ✅ 있음 (`FieldJoinPanel`, `DataFlowPanel`) | +| **노드에서 직접 설정** | ✅ **구현 완료** (방안 A) | + +--- + +## 노드에서 직접 설정 기능 (방안 A: 통합 설정 모달) + +### 구현 완료 (2026-01-09) + +노드 더블클릭 시 통합 설정 모달이 열리며, 4개 탭으로 다양한 설정을 수행할 수 있습니다. + +#### 사용법 + +1. **화면 노드** 또는 **테이블 노드**를 **더블클릭** +2. 통합 설정 모달이 열림 +3. 탭 선택하여 설정 +4. 저장 후 시각화 자동 새로고침 + +#### 탭 구성 + +| 탭 | 기능 | 설명 | +|----|------|------| +| 테이블 연결 | 화면-테이블 관계 설정 | 메인/서브/조회/저장 테이블 지정, CRUD 권한 설정 | +| 조인 설정 | FK-PK 조인 관계 설정 | 저장 테이블의 FK 컬럼 ↔ 조인 테이블의 PK 컬럼 매핑, 표시 컬럼 지정 | +| 데이터 흐름 | 화면 간 데이터 이동 설정 | 소스 화면 → 타겟 화면, 단방향/양방향 흐름 설정 | +| 필드 매핑 | 테이블 컬럼 정보 조회 | 현재 테이블의 컬럼 목록, 데이터 타입, 웹 타입 확인 | + +#### 구현 파일 + +| 파일 | 역할 | +|------|------| +| `frontend/components/screen/NodeSettingModal.tsx` | **새로 생성** - 통합 설정 모달 컴포넌트 | +| `frontend/components/screen/ScreenRelationFlow.tsx` | 노드 더블클릭 이벤트 핸들러 추가 | + +#### 주요 코드 변경 + +**NodeSettingModal.tsx (신규)** +- 4개 탭 컴포넌트 내장 (TableRelationTab, JoinSettingTab, DataFlowTab, FieldMappingTab) +- 기존 API 활용: `getTableRelations`, `getFieldJoins`, `getDataFlows` +- CRUD 연동: `createFieldJoin`, `updateFieldJoin`, `deleteFieldJoin` 등 +- 저장 후 부모 컴포넌트 새로고침 콜백 (`onRefresh`) + +**ScreenRelationFlow.tsx (수정)** +```typescript +// 노드 더블클릭 이벤트 핸들러 추가 +const handleNodeDoubleClick = useCallback((_event: React.MouseEvent, node: Node) => { + // 화면/테이블 노드 판별 후 모달 오픈 + if (node.id.startsWith("screen-")) { + // 화면 노드 처리 + } else if (node.id.startsWith("table-")) { + // 테이블 노드 처리 + } + setIsSettingModalOpen(true); +}, [screenTableMap, screenSubTableMap]); + +// ReactFlow에 이벤트 연결 + + +// 모달 렌더링 + +``` + +#### 시각화 새로고침 메커니즘 + +```typescript +// 강제 새로고침용 키 +const [refreshKey, setRefreshKey] = useState(0); + +// 새로고침 핸들러 +const handleRefreshVisualization = useCallback(() => { + setRefreshKey(prev => prev + 1); +}, []); + +// useEffect 의존성에 refreshKey 추가 +useEffect(() => { + // 데이터 로드 로직 +}, [screen, selectedGroup, ..., refreshKey]); +``` + +--- + +### 주요 파일 경로 + +``` +backend-node/src/ +├── controllers/screenGroupController.ts # 화면 그룹 API +├── routes/screenGroupRoutes.ts # 라우트 정의 + +frontend/ +├── app/(main)/admin/screenMng/screenMngList/page.tsx # 메인 페이지 +├── components/screen/ +│ ├── ScreenGroupTreeView.tsx # 트리 뷰 (그룹/화면 표시) +│ ├── ScreenGroupModal.tsx # 그룹 추가/수정 모달 +│ ├── ScreenRelationFlow.tsx # React Flow 시각화 + 더블클릭 이벤트 +│ ├── ScreenNode.tsx # 노드 컴포넌트 +│ ├── NodeSettingModal.tsx # **신규** - 통합 설정 모달 +│ └── panels/ +│ ├── FieldJoinPanel.tsx # 필드 조인 설정 UI (개별 패널) +│ └── DataFlowPanel.tsx # 데이터 흐름 설정 UI (개별 패널) +└── lib/api/screenGroup.ts # API 클라이언트 +``` + +--- + +## 향후 개선 사항 + +### 필드 매핑 탭 고도화 + +현재 필드 매핑 탭은 테이블 컬럼 정보를 조회만 가능합니다. 향후 다음 기능 추가 가능: + +1. **컬럼-컴포넌트 바인딩 설정**: 화면 컴포넌트와 DB 컬럼 직접 연결 +2. **드래그 앤 드롭**: 시각적 매핑 UI +3. **자동 매핑 추천**: 컬럼명 기반 자동 매핑 제안 + +### 관계 시각화 연동 + +설정 저장 후 시각화에 즉시 반영되지만, 다음 개선 가능: + +1. **실시간 프리뷰**: 저장 전 미리보기 +2. **관계 유형별 색상 커스터마이징** +3. **관계 라벨 표시 옵션** + +--- + +## 화면 설정 모달 개선 (2026-01-12) + +### 개요 + +화면 노드 우클릭 시 열리는 설정 모달을 대폭 개선했습니다. + +### 주요 변경 사항 + +#### 1. 테이블 정보 시각화 개선 + +| 항목 | 변경 내용 | +|------|----------| +| 메인 테이블 | 아코디언 형식으로 모든 컬럼 표시 | +| 필터 테이블 | 아코디언 형식 + 필터/조인 키 색상 구분 | +| 사용 중 컬럼 | 파란색 배경 + "필드" 배지로 강조 | + +#### 2. 화면 프리뷰 상시 표시 + +- 모달 레이아웃: 좌측 40% (탭) / 우측 60% (프리뷰) +- 탭 전환해도 프리뷰 항상 표시 + +#### 3. 줌/드래그 기능 (react-zoom-pan-pinch 라이브러리) + +```bash +npm install react-zoom-pan-pinch +``` + +| 기능 | 동작 | +|------|------| +| 휠 스크롤 | 마우스 포인터 기준 확대/축소 (20%~300%) | +| 드래그 | 화면 이동 | +| 클릭 | iframe 내부 버튼/목록 상호작용 | + +#### 4. 프리뷰 company_code 전달 문제 해결 + +| 문제 | 해결 | +|------|------| +| 최고 관리자로 다른 회사 프리뷰 불가 | `companyCodeOverride` 파라미터 도입 | +| URL 파라미터 무시됨 | 백엔드에서 admin 전용 오버라이드 처리 | + +### 관련 파일 + +| 파일 | 변경 내용 | +|------|----------| +| `ScreenSettingModal.tsx` | 전체 UI 개선, 줌/드래그 기능 | +| `entityJoin.ts` | `companyCodeOverride` 파라미터 추가 | +| `SplitPanelLayoutComponent.tsx` | `companyCode` prop 추가 | +| `entityJoinController.ts` | `companyCodeOverride` 처리 로직 | + +### 상세 문서 + +- [화면설정모달_개선_완료_보고서.md](./화면설정모달_개선_완료_보고서.md) + +--- + +## 관련 문서 + +- [멀티테넌시 구현 가이드](.cursor/rules/multi-tenancy-guide.mdc) +- [API 클라이언트 사용 규칙](.cursor/rules/api-client-usage.mdc) +- [관리자 페이지 스타일 가이드](.cursor/rules/admin-page-style-guide.mdc) + diff --git a/docs/화면설정모달_개선_완료_보고서.md b/docs/화면설정모달_개선_완료_보고서.md new file mode 100644 index 00000000..c69d82e4 --- /dev/null +++ b/docs/화면설정모달_개선_완료_보고서.md @@ -0,0 +1,1028 @@ +# 화면 설정 모달 개선 완료 보고서 + +## 개요 +화면 관리에서 화면 노드 우클릭 시 열리는 설정 모달을 대폭 개선하여, 테이블 정보 시각화, 필드 매핑 확인, 컬럼 변경/추가/제거 기능, 조인 설정 기능, 실시간 프리뷰 기능을 강화했습니다. + +## 주요 개선 사항 + +### 1. 화면 개요 탭 통합 개선 + +#### 1.1 필드 매핑 탭 → 개요 탭 통합 +- 기존 "필드 매핑" 탭 제거 +- 필드 매핑 정보를 개요 탭의 메인/필터 테이블 아코디언에 통합 표시 +- 더 직관적이고 간결한 UI 제공 + +#### 1.2 메인 테이블 아코디언 +- 메인 테이블(예: `customer_mng`)을 아코디언 형식으로 표시 +- 클릭 시 테이블의 모든 컬럼 정보 표시 +- **1열 레이아웃**: 컬럼 정보를 세로로 배치 +- 화면에서 사용 중인 컬럼은 **파란색 배경 + "필드" 배지**로 강조 +- **컬럼 정렬**: + - 사용중인 필드가 상단에 표시 + - 화면에 표시되는 순서대로 정렬 (y좌표 기준) + - 미사용 컬럼은 하단에 표시 + +#### 1.3 필터 테이블 아코디언 +- 필터 테이블(예: `customer_item_mapping`)을 아코디언 형식으로 표시 +- 클릭 시 테이블의 모든 컬럼 정보 표시 +- 컬럼별 색상 구분: + - **파란색**: 화면에서 사용 중인 컬럼 (필드) + - **보라색**: 필터 키 컬럼 (WHERE 절에 사용) + - **주황색**: 조인 키 컬럼 (JOIN 조건에 사용) +- **다중 배지 표시**: 컬럼이 필드이면서 조인/필터 키인 경우 배지 동시 표시 +- 필터 연결 정보 표시 (예: `→ customer_mng`) + +#### 1.4 컬럼 레이아웃 순서 +- **순서**: `컬럼명 | 배지 | 데이터타입` +- 예: `거래처 코드` `[필드]` `character varying` +- 데이터타입은 오른쪽 정렬 + +#### 1.5 클릭 스타일 개선 +- **테두리 제거**: ring-2, ring-offset 등 제거 +- **강조 색상 연하게**: + - 선택됨: `bg-blue-100 border-blue-300` + - 미선택: `bg-blue-50 border-blue-200` +- 더 부드러운 시각적 피드백 + +#### 1.6 패널 높이 동기화 +- 왼쪽(컬럼 목록)과 오른쪽(설정 패널) 동일한 `max-h-[350px]` 적용 +- `overflow-y-auto`로 스크롤 처리 +- `items-stretch`로 양쪽 패널 높이 동기화 + +### 2. 컬럼 변경 기능 + +#### 2.1 인라인 컬럼 편집 +- 사용중인 필드(파란색 배경)를 클릭하면 우측에 "컬럼 설정" 패널 표시 +- 패널 정보: + - **화면 필드**: 컬럼 한글명 표시 (예: "거래처 코드") + - **현재 컬럼**: 영문 컬럼명 표시 (예: `customer_code`) + - **컬럼 변경**: 드롭다운으로 다른 컬럼 선택 +- 검색 기능으로 컬럼 빠르게 찾기 + +#### 2.2 실시간 반영 +- 컬럼 변경 후 **페이지 새로고침 없이** 실시간 반영 +- `onRefresh` 콜백으로 데이터 리로드 + iframe 새로고침 +- 더 빠른 사용자 경험 + +#### 2.3 변경사항 저장 +- `screenApi.saveLayout()` 사용하여 **화면 디자이너와 동일한 테이블에 저장** +- 저장 위치: + - `componentConfig.leftPanel.columns` (분할 패널) + - `componentConfig.rightPanel.columns` (분할 패널) + - `usedColumns` 배열 + - `bindField` 필드 + - `fieldMapping` 배열 + +### 3. 필드 추가/제거 기능 (신규) + +#### 3.1 필드 추가 +- 비필드 컬럼(회색/흰색 배경) 클릭 +- "컬럼 설정" 패널에 컬럼 정보 표시 +- **"필드로 추가"** 버튼 클릭 → 해당 컬럼이 화면 필드로 추가됨 +- 버튼 스타일: `text-blue-600 border-blue-300 hover:bg-blue-50` (파란색 테두리) + +#### 3.2 필드 제거 +- 기존 필드(파란색 배경) 클릭 +- "컬럼 설정" 패널에 필드 정보 표시 +- **"필드에서 제거"** 버튼 클릭 → 해당 필드가 화면에서 제거됨 +- 버튼 스타일: `text-red-600 border-red-300 hover:bg-red-50` (빨간색 테두리) + +#### 3.3 저장 로직 +```typescript +// 필드 추가: 배열에 새 컬럼 추가 +if (isAddingField) { + return { + ...comp, + componentConfig: { + ...comp.componentConfig, + leftPanel: { + ...comp.componentConfig.leftPanel, + columns: [...leftColumns, { name: newColumn, columnName: newColumn }], + }, + }, + }; +} + +// 필드 제거: 배열에서 해당 컬럼 제거 +if (isRemovingField) { + const filteredColumns = leftColumns.filter((_, i) => i !== columnIdx); + return { + ...comp, + componentConfig: { + ...comp.componentConfig, + leftPanel: { + ...comp.componentConfig.leftPanel, + columns: filteredColumns, + }, + }, + }; +} +``` + +#### 3.4 적용 범위 +- 메인 테이블 아코디언: 필드 추가/제거 가능 +- 필터 테이블 아코디언: 필드 추가/제거 가능 +- `usedColumns`, `componentConfig.usedColumns`, `componentConfig.columns`, `leftPanel.columns`, `rightPanel.columns` 모두 지원 + +### 4. 화면 프리뷰 상시 표시 + +#### 4.1 레이아웃 변경 +- 기존: 탭으로 프리뷰 전환 +- 개선: **모달 우측에 프리뷰 상시 표시** +- 모달 크기 확대 (1600px 최대 너비) +- 좌측 40% (탭 콘텐츠) / 우측 60% (프리뷰) + +#### 4.2 줌/드래그/클릭 기능 (react-zoom-pan-pinch 라이브러리) +- **휠 스크롤**: 마우스 포인터 위치 기준 확대/축소 (20% ~ 300%) +- **드래그**: 마우스 왼쪽 버튼으로 화면 이동 (5px 이상 이동 시) +- **클릭**: iframe 내부 요소 정상 클릭 가능 + - 버튼, 셀렉트박스, 체크박스, 테이블 행 클릭 + - 인풋박스/텍스트박스 포커스 및 입력 + - X버튼(닫기) 등 SVG 아이콘 버튼 클릭 + +#### 4.3 클릭 좌표 보정 시스템 +- 줌 상태에서도 정확한 클릭 위치 계산 +- `designWidth / rect.width` 비율로 좌표 변환 +- 오버레이 방식으로 드래그와 클릭 분리 처리 + +### 5. 필드+조인 컬럼 스타일 개선 + +#### 5.1 다중 역할 컬럼 표시 +- 컬럼이 **필드이면서 조인 키**인 경우: + - **파란색 배경** (필드 기준) + - **왼쪽에 주황색 세로 선** (`border-l-4 border-l-orange-500`) + - 배지: `조인` `필드` 동시 표시 +- 컬럼이 **필드이면서 필터 키**인 경우: + - **파란색 배경** (필드 기준) + - **왼쪽에 보라색 세로 선** (`border-l-4 border-l-purple-400`) + - 배지: `필터` `필드` 동시 표시 + +#### 5.2 조인 컬럼도 필드로 인식 +- `filterTableColumnMappings` 생성 시 조인 컬럼(`ft.joinColumnRefs`)도 포함 +- 조인 테이블 데이터를 화면에서 보여주므로 필드로 간주 + +#### 5.3 컬럼 설정 패널 - 조인 정보 표시 +- 조인 키 클릭 시 패널에 조인 정보 표시: + - **대상 테이블**: item_info (실제 참조 테이블) + - **연결 컬럼**: item_number (참조 컬럼) + +### 6. 조인 관계 설정/수정 기능 + +#### 6.1 기능 설명 +- 컬럼 설정 패널에서 **조인 관계 직접 수정** 가능 +- **모든 컬럼에서 조인 설정 가능** (기존 조인 키가 아닌 컬럼도 포함) +- 테이블 타입 관리(`column_labels` 테이블)와 동일한 저장 위치 사용 + +#### 6.2 저장 테이블 +``` +column_labels 테이블: +├── reference_table (참조 테이블명) +├── reference_column (참조 컬럼 - 보통 PK) +└── display_column (화면에 표시할 컬럼) +``` + +#### 6.3 구현된 UI +1. **컬럼 클릭** → 컬럼 설정 패널 표시 +2. **"조인" 섹션 확인**: + - 조인 설정 있음: "편집" 버튼 + - 조인 설정 없음: "추가" 버튼 +3. **드롭다운으로 설정** (모두 검색 가능): + - 대상 테이블: 전체 테이블 목록에서 검색/선택 + - 연결 컬럼 (PK): 선택한 테이블의 컬럼 중 검색/선택 + - 표시 컬럼: 화면에 표시할 컬럼 검색/선택 +4. **저장 버튼** → `column_labels` 테이블에 저장 +5. **취소 버튼** → 편집 취소 + +#### 6.4 검색 가능한 드롭다운 +- Popover + Command 컴포넌트 사용 +- 실시간 텍스트 검색 지원 +- 대상 테이블, 연결 컬럼, 표시 컬럼 모두 검색 가능 + +#### 6.5 API 연동 +- **테이블 목록 조회**: `tableManagementApi.getTableList()` +- **컬럼 목록 조회**: `tableManagementApi.getColumnList(tableName)` +- **저장**: `tableManagementApi.updateColumnSettings(tableName, columnName, settings)` + +#### 6.6 메인 테이블에도 조인 설정 적용 +- 메인 테이블 아코디언에서도 조인 설정 가능 +- 필터 테이블과 동일한 UI/기능 제공 + +#### 6.7 조인 데이터 소스 수정 +- 기존 조인 키 클릭 시 `joinRef.refTable` 값을 사용 +- 예: `품목 ID` → `item_info` (실제 참조 테이블) +- `mainTable` 대신 `joinRef.refTable` 사용으로 정확한 테이블 표시 + +### 7. 배지 순서 및 스타일 +- **배지 순서**: `필터` → `조인` → `필드` (필드가 맨 뒤) +- **조인 배지**: 주황색 배경 (`bg-orange-200 text-orange-700`) +- **필터 배지**: 보라색 배경 (`bg-purple-200 text-purple-700`) +- **필드 배지**: 파란색 배경 (`bg-blue-500 text-white`) + +### 8. 컴포넌트 통합 리팩토링 + +#### 8.1 `TableColumnAccordion` 통합 컴포넌트 +- 기존 `MainTableAccordion`과 `FilterTableAccordion`을 하나의 컴포넌트로 통합 +- `tableType` prop으로 "main" 또는 "filter" 구분 +- 코드 중복 제거 및 유지보수성 향상 + +#### 8.2 Props 구조 +```typescript +interface TableColumnAccordionProps { + tableName: string; + tableLabel?: string; + tableType: "main" | "filter"; + columnMappings?: ColumnMapping[]; + onColumnChange?: (fieldLabel: string, oldColumn: string, newColumn: string) => void; + onColumnReorder?: (newOrder: string[]) => void; + onJoinSettingSaved?: () => void; + // 필터 테이블 전용 props + mainTable?: string; + filterKeyMapping?: FilterKeyMapping; + joinColumnRefs?: JoinColumnRef[]; +} +``` + +#### 8.3 동적 테마 적용 +- 메인 테이블: 파란색 테마 (`blue`) +- 필터 테이블: 보라색 테마 (`purple`) +- `themeColor`, `themeIcon`, `themeBadge` 변수로 동적 스타일 적용 + +### 9. 제어 관리 탭 (신규) + +#### 9.1 개요 +- 화면 설정 모달에 **"제어 관리"** 탭 추가 +- 화면 디자이너의 버튼 제어 설정을 간편하게 관리 +- 상세 설정은 화면 디자이너 링크 제공 + +#### 9.2 버튼 액션 설정 +- 화면에 배치된 버튼 목록 표시 +- 버튼별 액션 타입, 대상 화면, 플로우 연동 등 수정 가능 +- **편집** 버튼 클릭 시 인라인 편집 모드 활성화 +- **저장** 버튼 클릭 시 `screenApi.saveLayout()` 으로 저장 + +#### 9.3 지원 액션 타입 +| 액션 | 설명 | +|------|------| +| `save` | 저장 | +| `delete` | 삭제 | +| `edit` | 편집 | +| `copy` | 복사 | +| `navigate` | 페이지 이동 | +| `modal` | 모달 열기 | +| `openModalWithData` | 데이터 전달 + 모달 | +| `openRelatedModal` | 연관 데이터 모달 | +| `transferData` | 데이터 전달 | +| `quickInsert` | 즉시 저장 | +| `control` | 제어 흐름 | +| `view_table_history` | 테이블 이력 | +| `excel_download` | 엑셀 다운로드 | +| `excel_upload` | 엑셀 업로드 | + +#### 9.4 모달/네비게이션 화면 선택 +- 액션 타입이 `modal`, `openModalWithData`, `openRelatedModal`인 경우: + - **모달 화면** 선택 가능 + - 검색 가능한 드롭다운 (Combobox) + - **현재 그룹** 화면 우선 표시, **다른 그룹** 화면도 선택 가능 +- 액션 타입이 `navigate`인 경우: + - **이동 화면** 선택 가능 + - 동일하게 검색 가능한 드롭다운 제공 + +#### 9.5 다중 플로우 연동 지원 (신규) +- **한 버튼에 여러 플로우 연동 가능** +- 버튼별 플로우 목록을 세로 리스트 형식으로 표시 +- 각 플로우별 **실행 타이밍** 개별 설정: `before` (버튼 실행 전), `after` (버튼 실행 후) +- 플로우 개별 제거 가능 (X 버튼) +- **플로우 추가** 버튼으로 새 플로우 연동 (검색 가능한 Combobox) +- 화면 디자이너의 `webTypeConfig.dataflowConfig.flowConfigs` 배열로 저장 + +#### 9.6 상시 편집 모드 (개선) +- 기존: "편집" 버튼 클릭 시에만 설정 변경 가능 +- 개선: **상시 편집 가능** (별도 편집 버튼 없음) +- 변경사항이 있으면 "저장" 버튼 활성화 +- 더 빠르고 직관적인 설정 변경 경험 + +#### 9.7 섹션 구분 개선 (UI 개선) +- **버튼 액션 설정** 섹션: + - 파란색 테마 (`border-blue-200 bg-blue-50/30`) + - 헤더: `bg-blue-100/50 text-blue-900` + - 아이콘: MousePointer (파란색) +- **플로우 연동 현황** 섹션: + - 보라색 테마 (`border-purple-200 bg-purple-50/30`) + - 헤더: `bg-purple-100/50 text-purple-900` + - 아이콘: Workflow (보라색) +- 시각적으로 명확한 섹션 구분 + +#### 9.8 플로우 연동 현황 표시 개선 +- 플로우 이름: **일반 텍스트**로 표시 (배지 아님) +- 연동된 버튼: 배지로 표시 +- 미연동 플로우: **보라색 "미연동" 배지**로 표시 +- 플로우 관리 바로가기 버튼 제공 + +#### 9.9 다중 플로우 저장 로직 +```typescript +// 버튼 설정 저장 시 다중 플로우 처리 +if (values.linkedFlows !== undefined) { + if (values.linkedFlows && values.linkedFlows.length > 0) { + comp.webTypeConfig.enableDataflowControl = true; + comp.webTypeConfig.dataflowConfig = { + controlMode: "flow", + // 다중 플로우 저장 + flowConfigs: values.linkedFlows.map((lf: any) => ({ + flowId: lf.id, + flowName: lf.name, + executionTiming: lf.timing || "after", + })), + // 레거시 호환 - 첫 번째 플로우를 단일 flowConfig로도 저장 + flowConfig: { + flowId: values.linkedFlows[0].id, + flowName: values.linkedFlows[0].name, + executionTiming: values.linkedFlows[0].timing || "after", + }, + }; + } else { + // 플로우 연동 해제 (빈 배열) + comp.webTypeConfig.enableDataflowControl = false; + delete comp.webTypeConfig.dataflowConfig; + } +} +``` + +#### 9.10 화면 목록 조회 로직 +```typescript +// 1. 전체 화면 조회 (모든 화면의 ID→이름 맵핑) +const allScreensResponse = await screenApi.getScreens({ size: 1000 }); +const allScreensMap = new Map(); +allScreensResponse.data.forEach((s: any) => { + const sid = Number(s.screenId || s.screen_id || s.id); + const sname = s.screenName || s.screen_name || s.name || `화면 ${sid}`; + if (!isNaN(sid)) allScreensMap.set(sid, sname); +}); + +// 2. 그룹 내 화면 조회 +if (groupId) { + const groupResponse = await getScreenGroup(groupId); + if (groupResponse.success && groupResponse.data?.screens) { + groupScreenIds = groupResponse.data.screens.map((s: any) => + Number(s.screen_id || s.screenId || s.id) + ).filter(id => !isNaN(id)); + } +} + +// 3. 화면 목록 구성 (그룹 내 우선, 전체 포함) +groupScreenIds.forEach(sid => { + screenListResult.push({ id: sid, name: allScreensMap.get(sid), inGroup: true }); +}); +allScreensMap.forEach((name, id) => { + if (!groupScreenIds.includes(id)) { + screenListResult.push({ id, name, inGroup: false }); + } +}); +``` + +### 10. 드래그 앤 드롭 컬럼 순서 변경 + +#### 10.1 기능 설명 +- 사용 중인 컬럼(필드)을 드래그하여 순서 변경 가능 +- 드래그 중에는 시각적으로만 순서 변경, **드롭 시에만 저장** +- 드래그 취소(영역 밖으로 나간 경우) 시 원래 순서로 복원 + +#### 10.2 드래그 상태 관리 +```typescript +// 드래그 상태 +const [draggedIndex, setDraggedIndex] = useState(null); +const [localColumnOrder, setLocalColumnOrder] = useState(null); +``` + +#### 10.3 드래그 핸들러 +```typescript +// 드래그 시작: 현재 순서를 로컬 상태로 저장 +const handleDragStart = (e: React.DragEvent, index: number) => { + setDraggedIndex(index); + const usedColumns = sortedColumns.filter(col => columnMappingMap.has(col.columnName.toLowerCase())); + setLocalColumnOrder(usedColumns.map(col => col.columnName)); +}; + +// 드래그 중: 로컬 순서만 변경 (저장하지 않음) +const handleDragOver = (e: React.DragEvent, hoverIndex: number) => { + if (draggedIndex === null || draggedIndex === hoverIndex || !localColumnOrder) return; + const newOrder = [...localColumnOrder]; + const draggedItem = newOrder[draggedIndex]; + newOrder.splice(draggedIndex, 1); + newOrder.splice(hoverIndex, 0, draggedItem); + setDraggedIndex(hoverIndex); + setLocalColumnOrder(newOrder); +}; + +// 드롭: 최종 순서로 저장 +const handleDrop = (e: React.DragEvent) => { + if (localColumnOrder && onColumnReorder) { + onColumnReorder(localColumnOrder); + } + setDraggedIndex(null); + setLocalColumnOrder(null); +}; + +// 드래그 취소 +const handleDragEnd = () => { + setDraggedIndex(null); + setLocalColumnOrder(null); +}; +``` + +#### 10.4 시각적 피드백 +- 드래그 가능한 컬럼: `cursor-grab active:cursor-grabbing` +- 드래그 중인 컬럼: `opacity-50 scale-95` +- 드래그 중 실시간 순서 변경 표시 + +#### 10.5 저장 로직 (`handleColumnReorder`) +```typescript +const handleColumnReorder = async (tableType: "main" | "filter", newOrder: string[]) => { + const currentLayout = await screenApi.getLayout(screenId); + + const updatedComponents = currentLayout.components.map((comp: any) => { + // leftPanel.columns 순서 변경 + if (comp.componentConfig?.leftPanel?.columns) { + const leftColumns = comp.componentConfig.leftPanel.columns; + const reorderedColumns = newOrder.map(colName => + leftColumns.find((col: any) => col.name?.toLowerCase() === colName.toLowerCase()) + ).filter(Boolean); + const remainingColumns = leftColumns.filter((col: any) => + !newOrder.some(n => n.toLowerCase() === col.name?.toLowerCase()) + ); + + return { + ...comp, + componentConfig: { + ...comp.componentConfig, + leftPanel: { + ...comp.componentConfig.leftPanel, + columns: [...reorderedColumns, ...remainingColumns], + }, + }, + }; + } + return comp; + }); + + await screenApi.saveLayout(screenId, { ...currentLayout, components: updatedComponents }); + onRefresh?.(); +}; +``` + +#### 10.6 지원 범위 +- 메인 테이블: `onColumnReorder={(newOrder) => handleColumnReorder("main", newOrder)}` +- 필터 테이블: `onColumnReorder={(newOrder) => handleColumnReorder("filter", newOrder)}` +- 지원 배열: + - `componentConfig.leftPanel.columns` + - `componentConfig.rightPanel.columns` + - `componentConfig.usedColumns` + - `componentConfig.columns` + +### 11. FlowEditor 임베드 (신규) + +#### 11.1 개요 +- 제어 관리 탭에서 **플로우 빠른 생성** 시 전체 FlowEditor를 모달로 임베드 +- 골격 생성이 아닌 **완전한 플로우 생성** 가능 +- 저장 시 자동으로 버튼에 연동 + +#### 11.2 FlowEditor 컴포넌트 수정 +```typescript +interface FlowEditorProps { + initialFlowId?: number | null; + onSaveComplete?: (flowId: number, flowName: string) => void; // 저장 완료 콜백 + embedded?: boolean; // 임베디드 모드 +} +``` + +#### 11.3 FlowToolbar 수정 +- 저장 완료 시 `onSaveComplete` 콜백 호출 +- 기존 postMessage 로직 대체 + +#### 11.4 사용 방법 +1. "새 플로우" 버튼 클릭 +2. 전체화면 모달에서 FlowEditor 열림 +3. 플로우 완전 구성 (테이블, 필드 매핑, 조건 등) +4. 저장 시 자동으로: + - 플로우 생성 + - 버튼에 연동 (버튼에서 시작한 경우) + - 플로우 목록 새로고침 + +### 12. 화면 캔버스 크기 자동 조절 (신규) + +#### 12.1 문제 +- 기존: iframe 크기가 고정되어 화면 내용이 잘림 +- 특히 폼 화면에서 인풋 필드, 저장 버튼 등이 보이지 않음 + +#### 12.2 해결 +- 백엔드: 컴포넌트 최대 좌표 기준으로 `canvasWidth`, `canvasHeight` 계산 +- 프론트엔드: `PreviewTab`에 캔버스 크기 전달, 여유 마진 추가 + +#### 12.3 구현 +```typescript +// 백엔드 (screenGroupController.ts) +const rightEdge = (row.position_x || 0) + (row.width || 100); +const bottomEdge = (row.position_y || 0) + (row.height || 30); +if (rightEdge > summaryMap[screenId].canvasWidth) { + summaryMap[screenId].canvasWidth = rightEdge; +} +if (bottomEdge > summaryMap[screenId].canvasHeight) { + summaryMap[screenId].canvasHeight = bottomEdge; +} + +// 프론트엔드 (ScreenSettingModal.tsx) +const designWidth = Math.max((canvasWidth || 400) + 120, 500); +const designHeight = Math.max((canvasHeight || 400) + 250, 650); +``` + +### 13. 인풋 필드 인식 개선 (신규) + +#### 13.1 문제 +- 폼 화면의 인풋 필드가 "필드"로 인식되지 않음 +- 필드 매핑 0개로 표시 + +#### 13.2 원인 +- 백엔드 SQL 쿼리에서 `columnName` 속성을 추출하지 않음 +- 프론트엔드에서 `bindField`를 필드 카운트에 포함하지 않음 + +#### 13.3 해결 +```sql +-- 백엔드 SQL 수정 +COALESCE( + properties->'componentConfig'->>'bindField', + properties->>'bindField', + properties->'componentConfig'->>'field', + properties->>'field', + properties->>'columnName' -- 추가됨 +) as bind_field, +``` + +```typescript +// 프론트엔드 필드 카운트 수정 +layoutItems.forEach((item) => { + if (item.usedColumns) { + item.usedColumns.forEach((col) => layoutColumnsSet.add(col)); + } + if (item.bindField) { + layoutColumnsSet.add(item.bindField); // 추가됨 + } +}); +``` + +### 14. 폼 화면 필드 추가/제거 (신규) + +#### 14.1 기존 그리드 vs 폼 화면 +- **그리드 화면**: `leftPanel.columns`, `rightPanel.columns` 배열에서 컬럼 추가/제거 +- **폼 화면**: `text-input` 등 컴포넌트 자체를 추가/제거해야 함 + +#### 14.2 구현 +```typescript +// 필드 추가: 새 text-input 컴포넌트 생성 +if (isAddingField && !columnChanged) { + const newFormComponent: LayoutItem = { + id: `comp-${Date.now()}`, + componentType: "text-input", + label: newColumn, + bindField: newColumn, + position_x: newComponentX, + position_y: newComponentY, + width: 300, + height: 30, + // ... + }; + updatedComponents.push(newFormComponent); +} + +// 필드 제거: bindField가 일치하는 컴포넌트 삭제 +if (isRemovingField && !columnChanged) { + updatedComponents = updatedComponents.filter((comp: any) => + comp.bindField?.toLowerCase() !== oldColumn.toLowerCase() + ); +} +``` + +### 15. 화면 디자이너 모달 통합 (신규) + +#### 15.1 개요 +- 기존: "디자이너" 버튼 클릭 시 새 탭/창에서 열림 +- 변경: 전체화면 Dialog 내부에 ScreenDesigner 임베드 + +#### 15.2 장점 +- 화면 설정 모달을 닫지 않고 디자이너 사용 +- 디자이너 닫을 때 자동 새로고침 + +#### 15.3 구현 +```tsx + + + { + setShowDesignerModal(false); + await loadData(); + setIframeKey(prev => prev + 1); + }} + /> + + +``` + +### 16. 그룹 내 화면 전환 셀렉트박스 (신규) + +#### 16.1 개요 +- 그룹에 여러 화면이 있을 때 모달 내에서 화면 전환 가능 +- 모달을 닫지 않고도 다른 화면 설정 가능 + +#### 16.2 구현 +```typescript +// 그룹 내 화면 목록 로드 +const loadGroupScreens = useCallback(async () => { + if (!groupId) return; + const groupRes = await getScreenGroup(groupId); + if (groupRes.success && groupRes.data) { + const screens = groupRes.data.screens || []; + screens.sort((a, b) => (a.display_order || 0) - (b.display_order || 0)); + setGroupScreens(screens); + } +}, [groupId]); + +// 화면 선택 변경 핸들러 +const handleScreenChange = useCallback(async (newScreenId: number) => { + const selectedScreen = groupScreens.find(s => s.screen_id === newScreenId); + if (!selectedScreen) return; + + setCurrentScreenId(newScreenId); + setCurrentScreenName(selectedScreen.screen_name); + setCurrentMainTable(selectedScreen.table_name); + setIframeKey(prev => prev + 1); +}, [groupScreens]); +``` + +#### 16.3 UI +- 그룹 내 화면이 2개 이상: 제목 옆에 셀렉트박스 표시 +- 그룹 내 화면이 1개: 기존처럼 텍스트 표시 +- 화면 역할(screen_role)도 함께 표시 + +## 기술 스택 + +### 신규 의존성 +```bash +npm install react-zoom-pan-pinch +``` + +### 사용된 컴포넌트 +- `TransformWrapper`, `TransformComponent` - 줌/드래그 기능 +- `Accordion`, `AccordionContent`, `AccordionItem`, `AccordionTrigger` - 아코디언 UI +- `Popover`, `PopoverTrigger`, `PopoverContent` - 드롭다운 컨테이너 +- `Command`, `CommandInput`, `CommandList`, `CommandItem`, `CommandEmpty` - 검색 가능한 선택 UI +- `tableManagementApi.getColumnList()` - 테이블 컬럼 정보 조회 +- `tableManagementApi.getTableList()` - 테이블 목록 조회 +- `tableManagementApi.updateColumnSettings()` - 조인 설정 저장 +- `screenApi.saveLayout()` - 레이아웃 저장 +- `screenApi.getLayout()` - 레이아웃 조회 + +### 핵심 로직 + +#### 컬럼 변경/추가/제거 +```typescript +const handleColumnChange = async (fieldLabel: string, oldColumn: string, newColumn: string) => { + const isAddingField = fieldLabel === "__NEW_FIELD__"; + const isRemovingField = newColumn === "__REMOVE_FIELD__"; + + const currentLayout = await screenApi.getLayout(screenId); + + const updatedComponents = currentLayout.components.map((comp: any) => { + if (comp.componentConfig?.leftPanel?.columns) { + const leftColumns = comp.componentConfig.leftPanel.columns; + + // 필드 추가 + if (isAddingField) { + return { + ...comp, + componentConfig: { + ...comp.componentConfig, + leftPanel: { + ...comp.componentConfig.leftPanel, + columns: [...leftColumns, { name: newColumn, columnName: newColumn }], + }, + }, + }; + } + + // 필드 제거 + const columnIdx = leftColumns.findIndex((col: any) => ...); + if (columnIdx !== -1 && isRemovingField) { + return { + ...comp, + componentConfig: { + ...comp.componentConfig, + leftPanel: { + ...comp.componentConfig.leftPanel, + columns: leftColumns.filter((_, i) => i !== columnIdx), + }, + }, + }; + } + + // 컬럼 변경 + if (columnIdx !== -1) { + return { + ...comp, + componentConfig: { + ...comp.componentConfig, + leftPanel: { + ...comp.componentConfig.leftPanel, + columns: leftColumns.map((col, i) => + i === columnIdx ? { ...col, name: newColumn } : col + ), + }, + }, + }; + } + } + return comp; + }); + + await screenApi.saveLayout(screenId, { ...currentLayout, components: updatedComponents }); + onRefresh?.(); +}; +``` + +#### 조인 설정 편집기 (JoinSettingEditor) +```tsx + +``` + +## 파일 변경 목록 + +| 파일 | 변경 내용 | +|------|----------| +| `frontend/components/screen/ScreenSettingModal.tsx` | 전체 UI 개선, 줌/드래그 기능, 컬럼 변경/추가/제거 기능, 조인 설정 기능, 필드 매핑 통합, 실시간 반영, 제어 관리 탭 추가, 버튼 액션 설정, 플로우 연동, FlowEditor 임베드, ScreenDesigner 모달 임베드, 그룹 내 화면 전환 셀렉트박스 | +| `frontend/components/screen/ScreenRelationFlow.tsx` | `filterKeyMapping`, `joinColumnRefs` 데이터 전달 | +| `frontend/components/dataflow/node-editor/FlowEditor.tsx` | `onSaveComplete` 콜백, `embedded` 모드 props 추가 | +| `frontend/components/dataflow/node-editor/FlowToolbar.tsx` | 저장 완료 시 `onSaveComplete` 콜백 호출 | +| `frontend/lib/api/entityJoin.ts` | `companyCodeOverride` 파라미터 추가 | +| `frontend/lib/api/screen.ts` | `saveLayout`, `getLayout` API 사용 | +| `frontend/lib/api/tableManagement.ts` | `getTableList`, `getColumnList`, `updateColumnSettings` API | +| `frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx` | `companyCode` prop 추가 | +| `frontend/lib/registry/components/button/ButtonPrimaryComponent.tsx` | `webTypeConfig.backgroundColor/textColor` 지원 추가 | +| `backend-node/src/controllers/entityJoinController.ts` | `companyCodeOverride` 처리 로직 추가 | +| `backend-node/src/controllers/screenGroupController.ts` | `bind_field` 쿼리에 `columnName` 추가, `canvasWidth`/`canvasHeight` 계산 | + +## 사용 방법 + +### 화면 설정 모달 열기 +1. 화면 관리 페이지에서 화면 그룹 선택 +2. 화면 노드 우클릭 → 컨텍스트 메뉴 표시 +3. "화면 설정" 선택 → 모달 열림 +4. 좌측 탭에서 정보 확인/수정, 우측에서 실시간 프리뷰 + +### 프리뷰 영역 조작 +- **휠 스크롤**: 확대/축소 (5% 단위) +- **마우스 드래그**: 화면 이동 (5px 이상 움직여야 드래그로 인식) +- **짧은 클릭**: iframe 내부 요소 클릭 + +### 컬럼 변경 +1. 메인/필터 테이블 아코디언 펼치기 +2. 파란색 배경의 "필드" 컬럼 클릭 +3. 우측 "컬럼 설정" 패널 확인 +4. "컬럼 변경" 드롭다운에서 새 컬럼 선택 +5. **실시간 반영** (페이지 새로고침 없음) + +### 필드 추가 +1. 메인/필터 테이블 아코디언 펼치기 +2. 회색/흰색 배경의 비필드 컬럼 클릭 +3. 우측 패널에서 **"필드로 추가"** 버튼 클릭 +4. 해당 컬럼이 화면 필드로 추가됨 + +### 필드 제거 +1. 메인/필터 테이블 아코디언 펼치기 +2. 파란색 배경의 필드 컬럼 클릭 +3. 우측 패널에서 **"필드에서 제거"** 버튼 클릭 +4. 해당 필드가 화면에서 제거됨 + +### 조인 설정 추가/편집 +1. 메인/필터 테이블 아코디언 펼치기 +2. 아무 컬럼 클릭 (조인 키가 아니어도 됨) +3. 우측 패널의 "조인" 섹션에서: + - 조인 없음: **"추가"** 버튼 클릭 + - 조인 있음: **"편집"** 버튼 클릭 +4. 대상 테이블 선택 (검색 가능) +5. 연결 컬럼 (PK) 선택 (검색 가능) +6. 표시 컬럼 선택 (검색 가능) +7. **"저장"** 버튼 클릭 + +### 컬럼 순서 변경 (드래그 앤 드롭) +1. 메인/필터 테이블 아코디언 펼치기 +2. 파란색 배경의 "필드" 컬럼을 드래그 시작 +3. 원하는 위치로 드래그하여 이동 (실시간으로 순서 변경 표시) +4. 마우스를 놓으면 (드롭) 순서가 저장됨 +5. 드래그 취소하려면 컬럼 영역 밖으로 드래그 + +**참고:** +- 사용 중인 필드만 드래그 가능 (파란색 배경) +- 미사용 컬럼은 드래그 불가 +- 드래그 중에는 저장되지 않고, 드롭 시에만 저장됨 + +### 그룹 내 화면 전환 +1. 화면 설정 모달을 열면 제목 옆에 **셀렉트박스** 표시 (그룹 내 화면이 2개 이상일 때) +2. 셀렉트박스 클릭하여 같은 그룹의 다른 화면 선택 +3. 선택 즉시: + - 화면 데이터 자동 리로드 + - 프리뷰 iframe 자동 새로고침 +4. 화면 역할(list, form, modal 등)도 함께 표시 + +**참고:** +- 그룹 내 화면이 1개뿐이면 기존처럼 텍스트로 표시 +- 모달을 닫지 않고도 여러 화면 설정 가능 + +### 플로우 빠른 생성 +1. 제어 관리 탭의 "플로우 연동 현황"에서 **"새 플로우"** 버튼 클릭 +2. 또는 버튼별 플로우 선택에서 **"새 플로우 생성"** 옵션 클릭 +3. 전체화면 모달에서 FlowEditor가 열림 +4. 플로우를 완전하게 구성 (테이블 선택, 필드 매핑, 조건 등) +5. 저장 시 자동으로 해당 버튼에 연동 + +**참고:** +- 버튼에서 생성 시: 해당 버튼에 자동 연동 +- 헤더에서 생성 시: 연동 없이 플로우만 생성 +- 모달을 닫으면 플로우 목록 자동 새로고침 + +### 화면 디자이너 열기 +1. 화면 설정 모달의 제목 옆 **외부 링크 아이콘** 클릭 +2. 전체화면 모달에서 ScreenDesigner가 열림 +3. 컴포넌트 배치, 속성 변경 등 디자인 작업 수행 +4. 모달 닫을 때 자동으로: + - 화면 데이터 리로드 + - 프리뷰 iframe 새로고침 + +--- + +## 향후 개선 계획 (Phase 2) + +### 컨셉: "손쉬운 사용" +> 화면 디자이너에 들어가지 않고도 **자잘한 설정을 빠르게** 처리 +> **화면 테스트 → 설정 수정 → 다시 테스트** 사이클을 최소화 + +### 1순위: 버튼 이름 변경 + 색상 변경 ✅ 완료 +| 항목 | 내용 | +|------|------| +| **필요성** | 오타 수정, 문구 변경, 버튼 색상 변경 시 화면 디자이너 진입 필요 | +| **현재 상태** | ✅ **구현 완료** | +| **구현 내용** | | + +#### 버튼 이름 변경 +- 버튼 이름을 직접 입력 필드에서 수정 가능 +- 실시간 버튼 프리뷰 제공 +- 저장 위치: `componentConfig.text`, `comp.label`, `comp.title` (레거시 호환) + +#### 버튼 색상 변경 +- 배경색 + 글자색 컬러 피커 제공 +- **프리셋 색상 버튼**: 파랑, 초록, 빨강, 회색, 흰색 +- 실시간 버튼 프리뷰에 색상 반영 +- 저장 위치: + - 배경색: `componentConfig.backgroundColor`, `style.backgroundColor` + - 글자색: `componentConfig.textColor`, `style.color`, `style.labelColor` + +### 1순위: 컬럼 라벨(표시명) 변경 +| 항목 | 내용 | +|------|------| +| **필요성** | "거래처코드" → "고객코드" 같은 헤더 변경 | +| **현재 상태** | 컬럼명만 표시, 수정 불가 | +| **구현 방향** | 컬럼 설정 패널에 "표시명" Input 추가 | +| **주의사항** | 라벨만 변경, 실제 DB 컬럼명(`columnName`)은 변경 불가 (company_code 안전) | +| **예상 난이도** | 중간 (1시간) | + +### 2순위: 확인 메시지 설정 ✅ 완료 +| 항목 | 내용 | +|------|------| +| **필요성** | "삭제하시겠습니까?" 같은 확인 다이얼로그 메시지 수정 | +| **현재 상태** | ✅ **구현 완료** | +| **구현 내용** | 저장/삭제 버튼에 확인 메시지 Input 필드 추가, 화면 디자이너와 동일하게 `confirmMessage` 저장 | + +### 추가 요청: 플로우 빠른 생성 ✅ 완료 +| 항목 | 내용 | +|------|------| +| **필요성** | 시스템관리 > 제어관리에 나가지 않고 바로 플로우 생성 | +| **현재 상태** | ✅ **구현 완료** | +| **구현 내용** | FlowEditor를 전체화면 모달로 임베드, 저장 시 자동 버튼 연동 | +| **현재 상태** | 플로우 선택/연동만 가능, 생성 불가 | +| **구현 방안** | | + +#### 방안 A: 모달 내 간이 플로우 생성 +- 장점: 화면 이탈 없음 +- 단점: FlowEditor 축소판 개발 필요 (큰 작업) + +#### 방안 B: iframe으로 FlowEditor 임베드 +- 장점: 기존 FlowEditor 재사용 +- 단점: 화면 공간 부족, 상태 동기화 복잡 + +#### 방안 C: 새 창/탭으로 FlowEditor 열기 + 콜백 +- 장점: 전체 기능 사용 가능, 개발 비용 최소 +- 단점: 화면 전환 필요 + +#### 방안 D: 간이 플로우 템플릿 선택 ⭐ 권장 +- 장점: 빠른 설정, 사용자 친화적 +- 단점: 커스터마이징 제한 + +**템플릿 종류 예시:** +- 데이터 저장 (INSERT) +- 데이터 수정 (UPDATE) +- 이력 저장 (INSERT to 이력 테이블) +- 외부 API 호출 + +### 미포함 항목 (상세 설정 권장) +- 버튼 표시/숨김 +- 버튼 색상 변경 +- 컬럼 너비 조정 +- 필터 라벨 변경 +- 화면 이름/설명 변경 + +--- + +## 완료일 +2026-01-14 + +## 변경 이력 +- 2026-01-12: 최초 작성 (줌/드래그/클릭, company_code 전달) +- 2026-01-12: 컬럼 변경 기능 추가, 필드 매핑 통합, UI 개선 (1열 레이아웃, 배지 변경) +- 2026-01-12: 실시간 반영 구현 (reload 제거), 레이아웃 순서 변경, 스타일 개선 +- 2026-01-12: 필드+조인 컬럼 스타일 개선 (파란배경 + 왼쪽 주황선), 조인 정보 패널 표시 +- 2026-01-12: 조인 관계 설정/수정 기능 구현 완료 (column_labels 테이블 저장) +- 2026-01-13: 필드 추가/제거 기능 구현 +- 2026-01-13: 검색 가능한 조인 설정 드롭다운 (Command 컴포넌트) +- 2026-01-13: 모든 컬럼에서 조인 설정 가능 (범용성 패치) +- 2026-01-13: 메인 테이블에도 조인 설정 기능 추가 +- 2026-01-13: 조인 라인 색상 주황색으로 변경 (`border-l-orange-500`) +- 2026-01-13: 조인 데이터 소스 수정 (`joinRef.refTable` 사용) +- 2026-01-13: 패널 높이 동기화 (`max-h-[350px]`, `items-stretch`) +- 2026-01-13: `MainTableAccordion`과 `FilterTableAccordion`을 `TableColumnAccordion`으로 통합 +- 2026-01-13: 드래그 앤 드롭 컬럼 순서 변경 기능 구현 +- 2026-01-13: 드래그 중에는 로컬 상태만 변경, 드롭 시에만 저장하도록 최적화 +- 2026-01-13: "제어 관리" 탭 신규 추가 (버튼 액션 설정, 플로우 연동) +- 2026-01-13: 버튼별 플로우 연동 및 실행 타이밍 설정 기능 +- 2026-01-13: 모달/네비게이션 화면 선택 시 검색 가능한 Combobox 적용 +- 2026-01-13: 화면 목록 조회 개선 (전체 화면 조회 후 그룹별 분류) +- 2026-01-13: 외부 연동 섹션 제거 (미사용) +- 2026-01-13: 플로우 연동 섹션 추가 (화면에 연동된 전체 플로우 목록) +- 2026-01-13: **다중 플로우 지원** - 한 버튼에 여러 플로우 연동 가능 (`flowConfigs` 배열) +- 2026-01-13: **상시 편집 모드** - "편집" 버튼 제거, 상시 설정 변경 가능 +- 2026-01-13: **섹션 구분 개선** - 버튼 액션(파란색) / 플로우 연동(보라색) 테마 분리 +- 2026-01-13: **플로우 표시 방식 개선** - 플로우명은 텍스트, 미연동은 보라색 배지 +- 2026-01-13: **플로우 타이밍 개별 설정** - 각 플로우별 실행 전/후 설정 가능 +- 2026-01-13: **향후 개선 계획 문서화** - 버튼 이름 변경, 컬럼 라벨 변경, 확인 메시지 설정, 플로우 빠른 생성 +- 2026-01-13: **버튼 이름 변경 기능 구현** - `` → ``, `componentConfig.text` 저장 +- 2026-01-13: **버튼 색상 변경 기능 추가** - 배경색/글자색 컬러 피커, 프리셋 색상 버튼, 실시간 프리뷰 +- 2026-01-13: **버튼 색상 저장 위치 수정** - `webTypeConfig.backgroundColor/textColor`에 저장 (OptimizedButtonComponent와 일치) +- 2026-01-14: **버튼 색상 렌더링 수정** - `ButtonPrimaryComponent.tsx`에서 `webTypeConfig.backgroundColor/textColor` 지원 추가 +- 2026-01-14: **확인 메시지 설정 기능 추가** - 화면 디자이너와 동일하게 `confirmMessage` 필드 사용, Input으로 메시지 설정 +- 2026-01-14: **확인 메시지 save/delete 전용** - `save`/`delete` 액션에서만 확인 메시지 필드 표시, 다른 액션 타입으로 변경 시 confirmMessage 자동 제거 +- 2026-01-14: **플로우 빠른 생성 기능 구현** - 제어 플로우 에디터와 동일한 형식으로 플로우 **골격** 생성 + - 플로우 연동 현황 헤더에 "빠른 생성" 버튼 추가 + - 버튼별 플로우 추가 드롭다운에도 "새 플로우 빠른 생성" 옵션 추가 + - 생성 후 자동 연동 옵션 (선택한 버튼에 자동 연결) + - 테이블 선택 또는 직접 입력, 액션 타입 선택 (INSERT/UPDATE/DELETE) + - **중요**: 빠른 생성은 기본 구조만 생성, 필드 매핑/WHERE 조건은 제어 관리에서 추가 설정 필요 + - "생성만" / "생성 후 편집" 버튼으로 워크플로우 선택 가능 + - 경고 안내 UI 추가 (추가 설정 필요 안내) +- 2026-01-14: **플로우 빠른 생성 → FlowEditor 임베드 방식으로 변경** + - 골격 생성 대신 **전체 FlowEditor를 전체화면 모달로 임베드** + - 플로우 생성 후 자동으로 버튼에 연동 + - `FlowEditor` 컴포넌트에 `onSaveComplete` 콜백과 `embedded` 모드 추가 + - `FlowToolbar`에서 저장 완료 시 콜백 호출 +- 2026-01-14: **인풋 필드 인식 개선** + - 백엔드 `getMultipleScreenLayoutSummary` SQL 쿼리에 `properties->>'columnName'` 추가 + - `bindField`, `componentConfig.field`, `componentConfig.valueField` 를 `usedColumns`에 포함 + - 프론트엔드 `stats` 계산 시 `item.bindField`도 `layoutColumnsSet`에 추가 + - `TableColumnAccordion`에 `usedFields` props 전달하여 필드 배지 정확히 표시 +- 2026-01-14: **화면 캔버스 크기 자동 조절** + - 백엔드에서 `canvasWidth`, `canvasHeight` 계산 (컴포넌트 최대 좌표 기준) + - `PreviewTab`에서 `canvasWidth`, `canvasHeight` props 수신 + - 여유 마진 추가: 가로 +120px, 세로 +250px (패딩, 헤더, 하단 요소 고려) + - 최소 크기 보장 (가로 500px, 세로 650px) +- 2026-01-14: **폼 화면 필드 추가/제거 기능** + - `handleColumnChange`에서 `text-input` 등 폼 컴포넌트 처리 로직 추가 + - 필드 추가 시: 마지막 컴포넌트 아래에 새 `text-input` 컴포넌트 자동 배치 + - 필드 제거 시: `bindField`가 일치하는 컴포넌트 삭제 + - 기존 그리드 컬럼 변경 로직과 통합 +- 2026-01-14: **화면 디자이너 모달 통합** + - 기존: "디자이너" 버튼 클릭 시 새 탭/창에서 열림 + - 변경: **전체화면 Dialog 내부에 ScreenDesigner 임베드** + - `showDesignerModal` 상태로 모달 제어 + - 디자이너 닫을 때 `loadData()` + `setIframeKey()` 호출하여 자동 새로고침 +- 2026-01-14: **그룹 내 화면 전환 기능 (셀렉트박스)** + - `groupId`가 있으면 `getScreenGroup(groupId)`로 그룹 내 화면 목록 조회 + - 그룹 내 화면이 2개 이상일 때 제목 옆에 **셀렉트박스** 표시 + - 화면 선택 시: + - `currentScreenId`, `currentScreenName`, `currentMainTable` 상태 업데이트 + - 레이아웃 데이터 자동 리로드 + - iframe 자동 새로고침 + - 화면 역할(screen_role)도 함께 표시 (예: "거래처 목록 (list)") diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index 0327e122..905d1179 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -1,68 +1,109 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect, useCallback } from "react"; +import { useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; -import { ArrowLeft } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { ArrowLeft, Plus, RefreshCw, Search, LayoutGrid, LayoutList } from "lucide-react"; import ScreenList from "@/components/screen/ScreenList"; import ScreenDesigner from "@/components/screen/ScreenDesigner"; import TemplateManager from "@/components/screen/TemplateManager"; +import { ScreenGroupTreeView } from "@/components/screen/ScreenGroupTreeView"; +import { ScreenRelationFlow } from "@/components/screen/ScreenRelationFlow"; import { ScrollToTop } from "@/components/common/ScrollToTop"; import { ScreenDefinition } from "@/types/screen"; +import { screenApi } from "@/lib/api/screen"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import CreateScreenModal from "@/components/screen/CreateScreenModal"; // 단계별 진행을 위한 타입 정의 type Step = "list" | "design" | "template"; +type ViewMode = "tree" | "table"; export default function ScreenManagementPage() { + const searchParams = useSearchParams(); const [currentStep, setCurrentStep] = useState("list"); const [selectedScreen, setSelectedScreen] = useState(null); + const [selectedGroup, setSelectedGroup] = useState<{ id: number; name: string; company_code?: string } | null>(null); + const [focusedScreenIdInGroup, setFocusedScreenIdInGroup] = useState(null); const [stepHistory, setStepHistory] = useState(["list"]); + const [viewMode, setViewMode] = useState("tree"); + const [screens, setScreens] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + const [isCreateOpen, setIsCreateOpen] = useState(false); + + // 화면 목록 로드 + const loadScreens = useCallback(async () => { + try { + setLoading(true); + const result = await screenApi.getScreens({ page: 1, size: 1000, searchTerm: "" }); + // screenApi.getScreens는 { data: ScreenDefinition[], total, page, size, totalPages } 형태 반환 + if (result.data && result.data.length > 0) { + setScreens(result.data); + } + } catch (error) { + console.error("화면 목록 로드 실패:", error); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadScreens(); + }, [loadScreens]); + + // URL 쿼리 파라미터로 화면 디자이너 자동 열기 + useEffect(() => { + const openDesignerId = searchParams.get("openDesigner"); + if (openDesignerId && screens.length > 0) { + const screenId = parseInt(openDesignerId, 10); + const targetScreen = screens.find((s) => s.screenId === screenId); + if (targetScreen) { + setSelectedScreen(targetScreen); + setCurrentStep("design"); + setStepHistory(["list", "design"]); + } + } + }, [searchParams, screens]); // 화면 설계 모드일 때는 전체 화면 사용 const isDesignMode = currentStep === "design"; - // 단계별 제목과 설명 - const stepConfig = { - list: { - title: "화면 목록 관리", - description: "생성된 화면들을 확인하고 관리하세요", - }, - design: { - title: "화면 설계", - description: "드래그앤드롭으로 화면을 설계하세요", - }, - template: { - title: "템플릿 관리", - description: "화면 템플릿을 관리하고 재사용하세요", - }, - }; - // 다음 단계로 이동 const goToNextStep = (nextStep: Step) => { setStepHistory((prev) => [...prev, nextStep]); setCurrentStep(nextStep); }; - // 이전 단계로 이동 - const goToPreviousStep = () => { - if (stepHistory.length > 1) { - const newHistory = stepHistory.slice(0, -1); - const previousStep = newHistory[newHistory.length - 1]; - setStepHistory(newHistory); - setCurrentStep(previousStep); - } - }; - // 특정 단계로 이동 const goToStep = (step: Step) => { setCurrentStep(step); - // 해당 단계까지의 히스토리만 유지 const stepIndex = stepHistory.findIndex((s) => s === step); if (stepIndex !== -1) { setStepHistory(stepHistory.slice(0, stepIndex + 1)); } }; - // 화면 설계 모드일 때는 레이아웃 없이 전체 화면 사용 (고정 높이) + // 화면 선택 핸들러 (개별 화면 선택 시 그룹 선택 해제) + const handleScreenSelect = (screen: ScreenDefinition) => { + setSelectedScreen(screen); + setSelectedGroup(null); // 그룹 선택 해제 + }; + + // 화면 디자인 핸들러 + const handleDesignScreen = (screen: ScreenDefinition) => { + setSelectedScreen(screen); + goToNextStep("design"); + }; + + // 검색어로 필터링된 화면 + const filteredScreens = screens.filter((screen) => + screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) || + screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + // 화면 설계 모드일 때는 레이아웃 없이 전체 화면 사용 if (isDesignMode) { return (
@@ -72,59 +113,120 @@ export default function ScreenManagementPage() { } return ( -
-
- {/* 페이지 헤더 */} -
-

화면 관리

-

화면을 설계하고 템플릿을 관리합니다

-
- - {/* 단계별 내용 */} -
- {/* 화면 목록 단계 */} - {currentStep === "list" && ( - { - setSelectedScreen(screen); - goToNextStep("design"); - }} - /> - )} - - {/* 템플릿 관리 단계 */} - {currentStep === "template" && ( -
-
-

{stepConfig.template.title}

-
- - -
-
- goToStep("list")} /> -
- )} +
+ {/* 페이지 헤더 */} +
+
+
+

화면 관리

+

화면을 그룹별로 관리하고 데이터 관계를 확인합니다

+
+
+ {/* 뷰 모드 전환 */} + setViewMode(v as ViewMode)}> + + + + 트리 + + + + 테이블 + + + + + +
+ {/* 메인 콘텐츠 */} + {viewMode === "tree" ? ( +
+ {/* 왼쪽: 트리 구조 */} +
+ {/* 검색 */} +
+
+ + setSearchTerm(e.target.value)} + className="pl-9 h-9" + /> +
+
+ {/* 트리 뷰 */} +
+ { + setSelectedGroup(group); + setSelectedScreen(null); // 화면 선택 해제 + setFocusedScreenIdInGroup(null); // 포커스 초기화 + }} + onScreenSelectInGroup={(group, screenId) => { + // 그룹 내 화면 클릭 시 + const isNewGroup = selectedGroup?.id !== group.id; + + if (isNewGroup) { + // 새 그룹 진입: 포커싱 없이 시작 (첫 진입 시 망가지는 문제 방지) + setSelectedGroup(group); + setFocusedScreenIdInGroup(null); + } else { + // 같은 그룹 내에서 다른 화면 클릭: 포커싱 유지 + setFocusedScreenIdInGroup(screenId); + } + setSelectedScreen(null); + }} + /> +
+
+ + {/* 오른쪽: 관계 시각화 (React Flow) */} +
+ +
+
+ ) : ( + // 테이블 뷰 (기존 ScreenList 사용) +
+ +
+ )} + + {/* 화면 생성 모달 */} + setIsCreateOpen(false)} + onSuccess={() => { + setIsCreateOpen(false); + loadScreens(); + }} + /> + {/* Scroll to Top 버튼 */}
); } + diff --git a/frontend/app/(main)/admin/systemMng/i18nList/page.tsx b/frontend/app/(main)/admin/systemMng/i18nList/page.tsx index 3acce6fb..79264d72 100644 --- a/frontend/app/(main)/admin/systemMng/i18nList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/i18nList/page.tsx @@ -7,13 +7,19 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Plus } from "lucide-react"; import { DataTable } from "@/components/common/DataTable"; import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { useAuth } from "@/hooks/useAuth"; import LangKeyModal from "@/components/admin/LangKeyModal"; import LanguageModal from "@/components/admin/LanguageModal"; +import { CategoryTree } from "@/components/admin/multilang/CategoryTree"; +import { KeyGenerateModal } from "@/components/admin/multilang/KeyGenerateModal"; import { apiClient } from "@/lib/api/client"; +import { LangCategory } from "@/lib/api/multilang"; interface Language { langCode: string; @@ -29,6 +35,7 @@ interface LangKey { langKey: string; description: string; isActive: string; + categoryId?: number; } interface LangText { @@ -59,6 +66,10 @@ export default function I18nPage() { const [selectedLanguages, setSelectedLanguages] = useState>(new Set()); const [activeTab, setActiveTab] = useState<"keys" | "languages">("keys"); + // 카테고리 관련 상태 + const [selectedCategory, setSelectedCategory] = useState(null); + const [isGenerateModalOpen, setIsGenerateModalOpen] = useState(false); + const [companies, setCompanies] = useState>([]); // 회사 목록 조회 @@ -92,9 +103,14 @@ export default function I18nPage() { }; // 다국어 키 목록 조회 - const fetchLangKeys = async () => { + const fetchLangKeys = async (categoryId?: number | null) => { try { - const response = await apiClient.get("/multilang/keys"); + const params = new URLSearchParams(); + if (categoryId) { + params.append("categoryId", categoryId.toString()); + } + const url = `/multilang/keys${params.toString() ? `?${params.toString()}` : ""}`; + const response = await apiClient.get(url); const data = response.data; if (data.success) { setLangKeys(data.data); @@ -471,6 +487,13 @@ export default function I18nPage() { initializeData(); }, []); + // 카테고리 변경 시 키 목록 다시 조회 + useEffect(() => { + if (!loading) { + fetchLangKeys(selectedCategory?.categoryId); + } + }, [selectedCategory?.categoryId]); + const columns = [ { id: "select", @@ -678,27 +701,70 @@ export default function I18nPage() { {/* 다국어 키 관리 탭 */} {activeTab === "keys" && ( -
- {/* 좌측: 언어 키 목록 (7/10) */} - - +
+ {/* 좌측: 카테고리 트리 (2/12) */} + +
- 언어 키 목록 + 카테고리 +
+
+ + + setSelectedCategory(cat)} + onDoubleClickCategory={(cat) => { + setSelectedCategory(cat); + setIsGenerateModalOpen(true); + }} + /> + + +
+ + {/* 중앙: 언어 키 목록 (6/12) */} + + +
+ + 언어 키 목록 + {selectedCategory && ( + + {selectedCategory.categoryName} + + )} +
- - + +
- + {/* 검색 필터 영역 */}
- + setSearchText(e.target.value)} + className="h-8 text-xs" />
-
검색 결과: {getFilteredLangKeys().length}건
+
결과: {getFilteredLangKeys().length}건
{/* 테이블 영역 */}
-
전체: {getFilteredLangKeys().length}건
- {/* 우측: 선택된 키의 다국어 관리 (3/10) */} - + {/* 우측: 선택된 키의 다국어 관리 (4/12) */} + {selectedKey ? ( @@ -817,6 +883,18 @@ export default function I18nPage() { onSave={handleSaveLanguage} languageData={editingLanguage} /> + + {/* 키 자동 생성 모달 */} + setIsGenerateModalOpen(false)} + selectedCategory={selectedCategory} + companyCode={user?.companyCode || ""} + isSuperAdmin={user?.companyCode === "*"} + onSuccess={() => { + fetchLangKeys(selectedCategory?.categoryId); + }} + />
diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 9e92bf2b..29288163 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -23,6 +23,7 @@ import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/c import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 컴포넌트 간 통신 import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 분할 패널 리사이즈 import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; // 활성 탭 관리 +import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext"; // 화면 다국어 function ScreenViewPage() { const params = useParams(); @@ -32,9 +33,18 @@ function ScreenViewPage() { // URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프) const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined; + + // URL 쿼리에서 프리뷰용 company_code 가져오기 + const previewCompanyCode = searchParams.get("company_code"); + + // 프리뷰 모드 감지 (iframe에서 로드될 때) + const isPreviewMode = searchParams.get("preview") === "true"; // 🆕 현재 로그인한 사용자 정보 - const { user, userName, companyCode } = useAuth(); + const { user, userName, companyCode: authCompanyCode } = useAuth(); + + // 프리뷰 모드에서는 URL 파라미터의 company_code 우선 사용 + const companyCode = previewCompanyCode || authCompanyCode; // 🆕 모바일 환경 감지 const { isMobile } = useResponsive(); @@ -233,27 +243,40 @@ function ScreenViewPage() { const designWidth = layout?.screenResolution?.width || 1200; const designHeight = layout?.screenResolution?.height || 800; - // 컨테이너의 실제 크기 - const containerWidth = containerRef.current.offsetWidth; - const containerHeight = containerRef.current.offsetHeight; + // 컨테이너의 실제 크기 (프리뷰 모드에서는 window 크기 사용) + let containerWidth: number; + let containerHeight: number; + + if (isPreviewMode) { + // iframe에서는 window 크기를 직접 사용 + containerWidth = window.innerWidth; + containerHeight = window.innerHeight; + } else { + containerWidth = containerRef.current.offsetWidth; + containerHeight = containerRef.current.offsetHeight; + } - // 여백 설정: 좌우 16px씩 (총 32px), 상단 패딩 32px (pt-8) - const MARGIN_X = 32; - const availableWidth = containerWidth - MARGIN_X; - - // 가로 기준 스케일 계산 (좌우 여백 16px씩 고정) - const newScale = availableWidth / designWidth; + let newScale: number; + + if (isPreviewMode) { + // 프리뷰 모드: 가로/세로 모두 fit하도록 (여백 없이) + const scaleX = containerWidth / designWidth; + const scaleY = containerHeight / designHeight; + newScale = Math.min(scaleX, scaleY, 1); // 최대 1배율 + } else { + // 일반 모드: 가로 기준 스케일 (좌우 여백 16px씩 고정) + const MARGIN_X = 32; + const availableWidth = containerWidth - MARGIN_X; + newScale = availableWidth / designWidth; + } // console.log("📐 스케일 계산:", { // containerWidth, // containerHeight, - // MARGIN_X, - // availableWidth, // designWidth, // designHeight, // finalScale: newScale, - // "스케일된 화면 크기": `${designWidth * newScale}px × ${designHeight * newScale}px`, - // "실제 좌우 여백": `${(containerWidth - designWidth * newScale) / 2}px씩`, + // isPreviewMode, // }); setScale(newScale); @@ -272,7 +295,7 @@ function ScreenViewPage() { return () => { clearTimeout(timer); }; - }, [layout, isMobile]); + }, [layout, isMobile, isPreviewMode]); if (loading) { return ( @@ -310,7 +333,7 @@ function ScreenViewPage() { -
+
{/* 레이아웃 준비 중 로딩 표시 */} {!layoutReady && (
@@ -323,9 +346,10 @@ function ScreenViewPage() { {/* 절대 위치 기반 렌더링 (화면관리와 동일한 방식) */} {layoutReady && layout && layout.components.length > 0 ? ( -
+
); })()} -
+
+ ) : ( // 빈 화면일 때
diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 2fbbe7c5..1614c9b8 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -463,7 +463,8 @@ select { left: 0; right: 0; bottom: 0; - background: repeating-linear-gradient(90deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px), + background: + repeating-linear-gradient(90deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px), repeating-linear-gradient(0deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px), radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 212, 255, 0.08) 0%, transparent 60%); pointer-events: none; @@ -471,18 +472,24 @@ select { } .pop-light .pop-bg-pattern::before { - background: repeating-linear-gradient(90deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px), + background: + repeating-linear-gradient(90deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px), repeating-linear-gradient(0deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px), radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 122, 204, 0.05) 0%, transparent 60%); } /* POP 글로우 효과 */ .pop-glow-cyan { - box-shadow: 0 0 20px rgba(0, 212, 255, 0.5), 0 0 40px rgba(0, 212, 255, 0.3); + box-shadow: + 0 0 20px rgba(0, 212, 255, 0.5), + 0 0 40px rgba(0, 212, 255, 0.3); } .pop-glow-cyan-strong { - box-shadow: 0 0 10px rgba(0, 212, 255, 0.8), 0 0 30px rgba(0, 212, 255, 0.5), 0 0 50px rgba(0, 212, 255, 0.3); + box-shadow: + 0 0 10px rgba(0, 212, 255, 0.8), + 0 0 30px rgba(0, 212, 255, 0.5), + 0 0 50px rgba(0, 212, 255, 0.3); } .pop-glow-success { @@ -504,7 +511,9 @@ select { box-shadow: 0 0 5px rgba(0, 212, 255, 0.5); } 50% { - box-shadow: 0 0 20px rgba(0, 212, 255, 0.8), 0 0 30px rgba(0, 212, 255, 0.4); + box-shadow: + 0 0 20px rgba(0, 212, 255, 0.8), + 0 0 30px rgba(0, 212, 255, 0.4); } } @@ -610,4 +619,18 @@ select { animation: marching-ants-v 0.4s linear infinite; } +/* ===== 저장 테이블 막대기 애니메이션 ===== */ +@keyframes saveBarDrop { + 0% { + transform: scaleY(0); + transform-origin: top; + opacity: 0; + } + 100% { + transform: scaleY(1); + transform-origin: top; + opacity: 1; + } +} + /* ===== End of Global Styles ===== */ diff --git a/frontend/components/admin/multilang/CategoryTree.tsx b/frontend/components/admin/multilang/CategoryTree.tsx new file mode 100644 index 00000000..2e1238cf --- /dev/null +++ b/frontend/components/admin/multilang/CategoryTree.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { ChevronRight, ChevronDown, Folder, FolderOpen, Tag } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { LangCategory, getCategories } from "@/lib/api/multilang"; + +interface CategoryTreeProps { + selectedCategoryId: number | null; + onSelectCategory: (category: LangCategory | null) => void; + onDoubleClickCategory?: (category: LangCategory) => void; +} + +interface CategoryNodeProps { + category: LangCategory; + level: number; + selectedCategoryId: number | null; + onSelectCategory: (category: LangCategory) => void; + onDoubleClickCategory?: (category: LangCategory) => void; +} + +function CategoryNode({ + category, + level, + selectedCategoryId, + onSelectCategory, + onDoubleClickCategory, +}: CategoryNodeProps) { + // 기본값: 접힌 상태로 시작 + const [isExpanded, setIsExpanded] = useState(false); + const hasChildren = category.children && category.children.length > 0; + const isSelected = selectedCategoryId === category.categoryId; + + return ( +
+
onSelectCategory(category)} + onDoubleClick={() => onDoubleClickCategory?.(category)} + > + {/* 확장/축소 아이콘 */} + {hasChildren ? ( + + ) : ( + + )} + + {/* 폴더/태그 아이콘 */} + {hasChildren || level === 0 ? ( + isExpanded ? ( + + ) : ( + + ) + ) : ( + + )} + + {/* 카테고리 이름 */} + {category.categoryName} + + {/* prefix 표시 */} + + {category.keyPrefix} + +
+ + {/* 자식 카테고리 */} + {hasChildren && isExpanded && ( +
+ {category.children!.map((child) => ( + + ))} +
+ )} +
+ ); +} + +export function CategoryTree({ + selectedCategoryId, + onSelectCategory, + onDoubleClickCategory, +}: CategoryTreeProps) { + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + loadCategories(); + }, []); + + const loadCategories = async () => { + try { + setLoading(true); + const response = await getCategories(); + if (response.success && response.data) { + setCategories(response.data); + } else { + setError(response.error?.details || "카테고리 로드 실패"); + } + } catch (err) { + setError("카테고리 로드 중 오류 발생"); + } finally { + setLoading(false); + } + }; + + if (loading) { + return ( +
+
+ 카테고리 로딩 중... +
+
+ ); + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + if (categories.length === 0) { + return ( +
+
+ 카테고리가 없습니다 +
+
+ ); + } + + return ( +
+ {/* 전체 선택 옵션 */} +
onSelectCategory(null)} + > + + 전체 +
+ + {/* 카테고리 트리 */} + {categories.map((category) => ( + + ))} +
+ ); +} + +export default CategoryTree; + + diff --git a/frontend/components/admin/multilang/KeyGenerateModal.tsx b/frontend/components/admin/multilang/KeyGenerateModal.tsx new file mode 100644 index 00000000..c595adbc --- /dev/null +++ b/frontend/components/admin/multilang/KeyGenerateModal.tsx @@ -0,0 +1,497 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Loader2, AlertCircle, CheckCircle2, Info, Check, ChevronsUpDown } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + LangCategory, + Language, + generateKey, + previewKey, + createOverrideKey, + getLanguages, + getCategoryPath, + KeyPreview, +} from "@/lib/api/multilang"; +import { apiClient } from "@/lib/api/client"; + +interface Company { + companyCode: string; + companyName: string; +} + +interface KeyGenerateModalProps { + isOpen: boolean; + onClose: () => void; + selectedCategory: LangCategory | null; + companyCode: string; + isSuperAdmin: boolean; + onSuccess: () => void; +} + +export function KeyGenerateModal({ + isOpen, + onClose, + selectedCategory, + companyCode, + isSuperAdmin, + onSuccess, +}: KeyGenerateModalProps) { + // 상태 + const [keyMeaning, setKeyMeaning] = useState(""); + const [usageNote, setUsageNote] = useState(""); + const [targetCompanyCode, setTargetCompanyCode] = useState(companyCode); + const [languages, setLanguages] = useState([]); + const [texts, setTexts] = useState>({}); + const [categoryPath, setCategoryPath] = useState([]); + const [preview, setPreview] = useState(null); + const [loading, setLoading] = useState(false); + const [previewLoading, setPreviewLoading] = useState(false); + const [error, setError] = useState(null); + const [companies, setCompanies] = useState([]); + const [companySearchOpen, setCompanySearchOpen] = useState(false); + + // 초기화 + useEffect(() => { + if (isOpen) { + setKeyMeaning(""); + setUsageNote(""); + setTargetCompanyCode(isSuperAdmin ? "*" : companyCode); + setTexts({}); + setPreview(null); + setError(null); + loadLanguages(); + if (isSuperAdmin) { + loadCompanies(); + } + if (selectedCategory) { + loadCategoryPath(selectedCategory.categoryId); + } else { + setCategoryPath([]); + } + } + }, [isOpen, selectedCategory, companyCode, isSuperAdmin]); + + // 회사 목록 로드 (최고관리자 전용) + const loadCompanies = async () => { + try { + const response = await apiClient.get("/admin/companies"); + if (response.data.success && response.data.data) { + // snake_case를 camelCase로 변환하고 공통(*)은 제외 + const companyList = response.data.data + .filter((c: any) => c.company_code !== "*") + .map((c: any) => ({ + companyCode: c.company_code, + companyName: c.company_name, + })); + setCompanies(companyList); + } + } catch (err) { + console.error("회사 목록 로드 실패:", err); + } + }; + + // 언어 목록 로드 + const loadLanguages = async () => { + const response = await getLanguages(); + if (response.success && response.data) { + const activeLanguages = response.data.filter((l) => l.isActive === "Y"); + setLanguages(activeLanguages); + // 초기 텍스트 상태 설정 + const initialTexts: Record = {}; + activeLanguages.forEach((lang) => { + initialTexts[lang.langCode] = ""; + }); + setTexts(initialTexts); + } + }; + + // 카테고리 경로 로드 + const loadCategoryPath = async (categoryId: number) => { + const response = await getCategoryPath(categoryId); + if (response.success && response.data) { + setCategoryPath(response.data); + } + }; + + // 키 미리보기 (디바운스) + const loadPreview = useCallback(async () => { + if (!selectedCategory || !keyMeaning.trim()) { + setPreview(null); + return; + } + + setPreviewLoading(true); + try { + const response = await previewKey( + selectedCategory.categoryId, + keyMeaning.trim().toLowerCase().replace(/\s+/g, "_"), + targetCompanyCode + ); + if (response.success && response.data) { + setPreview(response.data); + } + } catch (err) { + console.error("키 미리보기 실패:", err); + } finally { + setPreviewLoading(false); + } + }, [selectedCategory, keyMeaning, targetCompanyCode]); + + // keyMeaning 변경 시 디바운스로 미리보기 로드 + useEffect(() => { + const timer = setTimeout(loadPreview, 500); + return () => clearTimeout(timer); + }, [loadPreview]); + + // 텍스트 변경 핸들러 + const handleTextChange = (langCode: string, value: string) => { + setTexts((prev) => ({ ...prev, [langCode]: value })); + }; + + // 저장 핸들러 + const handleSave = async () => { + if (!selectedCategory) { + setError("카테고리를 선택해주세요"); + return; + } + + if (!keyMeaning.trim()) { + setError("키 의미를 입력해주세요"); + return; + } + + // 최소 하나의 텍스트 입력 검증 + const hasText = Object.values(texts).some((t) => t.trim()); + if (!hasText) { + setError("최소 하나의 언어에 대한 텍스트를 입력해주세요"); + return; + } + + setLoading(true); + setError(null); + + try { + // 오버라이드 모드인지 확인 + if (preview?.isOverride && preview.baseKeyId) { + // 오버라이드 키 생성 + const response = await createOverrideKey({ + companyCode: targetCompanyCode, + baseKeyId: preview.baseKeyId, + texts: Object.entries(texts) + .filter(([_, text]) => text.trim()) + .map(([langCode, langText]) => ({ langCode, langText })), + }); + + if (response.success) { + onSuccess(); + onClose(); + } else { + setError(response.error?.details || "오버라이드 키 생성 실패"); + } + } else { + // 새 키 생성 + const response = await generateKey({ + companyCode: targetCompanyCode, + categoryId: selectedCategory.categoryId, + keyMeaning: keyMeaning.trim().toLowerCase().replace(/\s+/g, "_"), + usageNote: usageNote.trim() || undefined, + texts: Object.entries(texts) + .filter(([_, text]) => text.trim()) + .map(([langCode, langText]) => ({ langCode, langText })), + }); + + if (response.success) { + onSuccess(); + onClose(); + } else { + setError(response.error?.details || "키 생성 실패"); + } + } + } catch (err: any) { + setError(err.message || "키 생성 중 오류 발생"); + } finally { + setLoading(false); + } + }; + + // 생성될 키 미리보기 + const generatedKeyPreview = categoryPath.length > 0 && keyMeaning.trim() + ? [...categoryPath.map((c) => c.keyPrefix), keyMeaning.trim().toLowerCase().replace(/\s+/g, "_")].join(".") + : ""; + + return ( + !open && onClose()}> + + + + {preview?.isOverride ? "오버라이드 키 생성" : "다국어 키 생성"} + + + {preview?.isOverride + ? "공통 키에 대한 회사별 오버라이드를 생성합니다" + : "새로운 다국어 키를 자동으로 생성합니다"} + + + +
+ {/* 카테고리 경로 표시 */} +
+ +
+ {categoryPath.length > 0 ? ( + categoryPath.map((cat, idx) => ( + + + {cat.categoryName} + + {idx < categoryPath.length - 1 && ( + / + )} + + )) + ) : ( + + 카테고리를 선택해주세요 + + )} +
+
+ + {/* 키 의미 입력 */} +
+ + setKeyMeaning(e.target.value)} + placeholder="예: add_new_item, search_button, save_success" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 영문 소문자와 밑줄(_)을 사용하세요 +

+
+ + {/* 생성될 키 미리보기 */} + {generatedKeyPreview && ( +
+
+ {previewLoading ? ( + + ) : preview?.exists ? ( + + ) : preview?.isOverride ? ( + + ) : ( + + )} + + {generatedKeyPreview} + +
+ {preview?.exists && ( +

+ 이미 존재하는 키입니다 +

+ )} + {preview?.isOverride && !preview?.exists && ( +

+ 공통 키가 존재합니다. 회사별 오버라이드로 생성됩니다. +

+ )} +
+ )} + + {/* 대상 회사 선택 (최고 관리자만) */} + {isSuperAdmin && ( +
+ +
+ + + + + + + + + + 검색 결과가 없습니다 + + + { + setTargetCompanyCode("*"); + setCompanySearchOpen(false); + }} + className="text-xs sm:text-sm" + > + + 공통 (*) - 모든 회사 적용 + + {companies.map((company) => ( + { + setTargetCompanyCode(company.companyCode); + setCompanySearchOpen(false); + }} + className="text-xs sm:text-sm" + > + + {company.companyName} ({company.companyCode}) + + ))} + + + + + +
+
+ )} + + {/* 사용 메모 */} +
+ +