diff --git a/.cursor/rules/component-development-guide.mdc b/.cursor/rules/component-development-guide.mdc new file mode 100644 index 00000000..f39e70ca --- /dev/null +++ b/.cursor/rules/component-development-guide.mdc @@ -0,0 +1,699 @@ +--- +description: 화면 컴포넌트 개발 시 필수 가이드 - 엔티티 조인, 폼 데이터, 다국어 지원 +alwaysApply: false +--- + +# 화면 컴포넌트 개발 가이드 + +새로운 화면 컴포넌트를 개발할 때 반드시 따라야 하는 핵심 원칙과 패턴을 설명합니다. +이 가이드는 컴포넌트가 시스템의 핵심 기능(엔티티 조인, 다국어, 폼 데이터 관리 등)과 +올바르게 통합되도록 하는 방법을 설명합니다. + +--- + +## 목차 + +1. [엔티티 조인 컬럼 활용 (필수)](#1-엔티티-조인-컬럼-활용-필수) +2. [폼 데이터 관리](#2-폼-데이터-관리) +3. [다국어 지원](#3-다국어-지원) +4. [컬럼 설정 패널 구현](#4-컬럼-설정-패널-구현) +5. [체크리스트](#5-체크리스트) + +--- + +## 1. 엔티티 조인 컬럼 활용 (필수) + +### 핵심 원칙 + +**화면을 새로 만들어서 화면 안에 넣는 방식을 사용하지 않습니다.** + +대신, 현재 화면의 메인 테이블을 기준으로 테이블 타입관리의 엔티티 관계를 불러와서 +조인되어 있는 컬럼들을 모두 사용 가능하게 해야 합니다. + +### API 사용법 + +```typescript +import { entityJoinApi } from "@/lib/api/entityJoin"; + +// 테이블의 엔티티 조인 컬럼 정보 가져오기 +const result = await entityJoinApi.getEntityJoinColumns(tableName); + +// 응답 구조 +{ + tableName: string; + joinTables: Array<{ + tableName: string; // 조인 테이블명 (예: item_info) + currentDisplayColumn: string; // 현재 표시 컬럼 + availableColumns: Array<{ + // 사용 가능한 컬럼들 + columnName: string; + columnLabel: string; + dataType: string; + description?: string; + }>; + }>; + availableColumns: Array<{ + // 플랫한 구조의 전체 사용 가능 컬럼 + tableName: string; + columnName: string; + columnLabel: string; + dataType: string; + joinAlias: string; // 예: item_code_item_name + suggestedLabel: string; // 예: 품목명 + }>; + summary: { + totalJoinTables: number; + totalAvailableColumns: number; + } +} +``` + +### 컬럼 선택 UI 구현 + +ConfigPanel에서 엔티티 조인 컬럼을 표시하는 표준 패턴입니다. + +```typescript +// 상태 정의 +const [entityJoinColumns, setEntityJoinColumns] = useState<{ + availableColumns: Array<{ + tableName: string; + columnName: string; + columnLabel: string; + dataType: string; + joinAlias: string; + suggestedLabel: string; + }>; + joinTables: Array<{ + tableName: string; + currentDisplayColumn: string; + availableColumns: Array<{ + columnName: string; + columnLabel: string; + dataType: string; + description?: string; + }>; + }>; +}>({ availableColumns: [], joinTables: [] }); + +const [loadingEntityJoins, setLoadingEntityJoins] = useState(false); + +// 엔티티 조인 컬럼 로드 +useEffect(() => { + const fetchEntityJoinColumns = async () => { + const tableName = config.selectedTable || screenTableName; + if (!tableName) { + setEntityJoinColumns({ availableColumns: [], joinTables: [] }); + return; + } + + setLoadingEntityJoins(true); + try { + const result = await entityJoinApi.getEntityJoinColumns(tableName); + setEntityJoinColumns({ + availableColumns: result.availableColumns || [], + joinTables: result.joinTables || [], + }); + } catch (error) { + console.error("엔티티 조인 컬럼 조회 오류:", error); + setEntityJoinColumns({ availableColumns: [], joinTables: [] }); + } finally { + setLoadingEntityJoins(false); + } + }; + + fetchEntityJoinColumns(); +}, [config.selectedTable, screenTableName]); +``` + +### 컬럼 선택 UI 렌더링 + +```tsx +{ + /* 엔티티 조인 컬럼 섹션 */ +} +{ + entityJoinColumns.joinTables.length > 0 && ( +
+ + + {entityJoinColumns.joinTables.map((joinTable) => ( +
+
+ + {joinTable.tableName} + + + ({joinTable.availableColumns.length}) + +
+ +
+ {joinTable.availableColumns.map((col) => { + // "테이블명.컬럼명" 형식으로 컬럼 이름 생성 + const fullColumnName = `${joinTable.tableName}.${col.columnName}`; + const isSelected = config.columns?.some( + (c) => c.columnName === fullColumnName + ); + + return ( +
{ + if (isSelected) { + removeColumn(fullColumnName); + } else { + addEntityJoinColumn(joinTable.tableName, col); + } + }} + > + +
+
{col.columnLabel}
+
+ {col.columnName} +
+
+
+ ); + })} +
+
+ ))} +
+ ); +} +``` + +### 엔티티 조인 컬럼 추가 함수 + +```typescript +const addEntityJoinColumn = (tableName: string, column: any) => { + const fullColumnName = `${tableName}.${column.columnName}`; + + const newColumn: ColumnConfig = { + columnName: fullColumnName, + displayName: column.columnLabel || column.columnName, + visible: true, + sortable: true, + searchable: true, + align: "left", + format: "text", + order: config.columns?.length || 0, + isEntityJoin: true, // 엔티티 조인 컬럼 표시 + entityJoinTable: tableName, + entityJoinColumn: column.columnName, + }; + + onChange({ + ...config, + columns: [...(config.columns || []), newColumn], + }); +}; +``` + +### 데이터 조회 시 엔티티 조인 활용 + +```typescript +// 엔티티 조인이 포함된 데이터 조회 +const response = await entityJoinApi.getTableDataWithJoins(tableName, { + page: 1, + size: 10, + enableEntityJoin: true, + // 추가 조인 컬럼 지정 (화면 설정에서 선택한 컬럼들) + additionalJoinColumns: config.columns + ?.filter((col) => col.isEntityJoin) + ?.map((col) => ({ + sourceTable: col.entityJoinTable!, + sourceColumn: col.entityJoinColumn!, + joinAlias: col.columnName, + })), +}); +``` + +### 셀 값 추출 헬퍼 + +엔티티 조인 컬럼의 값을 데이터에서 추출하는 헬퍼 함수입니다. + +```typescript +const getEntityJoinValue = (item: any, columnName: string): any => { + // 직접 매칭 시도 + if (item[columnName] !== undefined) { + return item[columnName]; + } + + // "테이블명.컬럼명" 형식인 경우 + if (columnName.includes(".")) { + const [tableName, fieldName] = columnName.split("."); + + // 1. 소스 컬럼 추론 (item_info → item_code) + const inferredSourceColumn = tableName + .replace("_info", "_code") + .replace("_mng", "_id"); + + // 2. 정확한 키 매핑: 소스컬럼_필드명 + const exactKey = `${inferredSourceColumn}_${fieldName}`; + if (item[exactKey] !== undefined) { + return item[exactKey]; + } + + // 3. item_id 패턴 시도 + const idPatternKey = `${tableName.replace("_info", "_id")}_${fieldName}`; + if (item[idPatternKey] !== undefined) { + return item[idPatternKey]; + } + + // 4. 단순 필드명으로 시도 + if (item[fieldName] !== undefined) { + return item[fieldName]; + } + } + + return undefined; +}; +``` + +--- + +## 2. 폼 데이터 관리 + +### 통합 폼 시스템 (UnifiedFormContext) + +새 컴포넌트는 통합 폼 시스템을 사용해야 합니다. + +```typescript +import { useFormCompatibility } from "@/hooks/useFormCompatibility"; + +const MyComponent = ({ onFormDataChange, formData, ...props }) => { + // 호환성 브릿지 사용 + const { getValue, setValue, submit } = useFormCompatibility({ + legacyOnFormDataChange: onFormDataChange, + }); + + // 값 읽기 + const currentValue = getValue("fieldName"); + + // 값 설정 (모든 시스템에 전파됨) + const handleChange = (value: any) => { + setValue("fieldName", value); + }; + + // 저장 + const handleSave = async () => { + const result = await submit({ + tableName: "my_table", + mode: "insert", + }); + }; +}; +``` + +### 레거시 컴포넌트와의 호환성 + +기존 `beforeFormSave` 이벤트를 사용하는 컴포넌트(리피터 등)와 호환됩니다. + +```typescript +import { useBeforeFormSave } from "@/hooks/useFormCompatibility"; + +const MyRepeaterComponent = ({ value, columnName }) => { + // beforeFormSave 이벤트에서 데이터 수집 + useEffect(() => { + const handleSaveRequest = (event: CustomEvent) => { + if (event.detail && columnName) { + event.detail.formData[columnName] = value; + } + }; + + window.addEventListener("beforeFormSave", handleSaveRequest); + return () => + window.removeEventListener("beforeFormSave", handleSaveRequest); + }, [value, columnName]); +}; +``` + +### onChange 핸들러 패턴 + +컴포넌트에서 값이 변경될 때 사용하는 표준 패턴입니다. + +```typescript +// 기본 패턴 (권장) +const handleChange = useCallback( + (value: any) => { + // 1. UnifiedFormContext가 있으면 사용 + if (unifiedContext) { + unifiedContext.setValue(fieldName, value); + } + + // 2. ScreenContext가 있으면 사용 + if (screenContext?.updateFormData) { + screenContext.updateFormData(fieldName, value); + } + + // 3. 레거시 콜백이 있으면 호출 + if (onFormDataChange) { + onFormDataChange(fieldName, value); + } + }, + [fieldName, unifiedContext, screenContext, onFormDataChange] +); +``` + +--- + +## 3. 다국어 지원 + +### 타입 정의 시 다국어 필드 추가 + +텍스트가 표시되는 **모든 속성**에 `langKeyId`와 `langKey` 필드를 추가합니다. + +```typescript +interface MyComponentConfig { + // 기본 텍스트 + title?: string; + titleLangKeyId?: number; + titleLangKey?: string; + + // 컬럼 배열 + columns?: Array<{ + name: string; + label: string; + langKeyId?: number; + langKey?: string; + }>; +} +``` + +### 라벨 추출 로직 등록 + +파일: `frontend/lib/utils/multilangLabelExtractor.ts` + +```typescript +// extractMultilangLabels 함수에 추가 +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 && Array.isArray(config.columns)) { + config.columns.forEach((col, index) => { + addLabel({ + id: `${comp.id}_col_${index}`, + componentId: `${comp.id}_col_${index}`, + label: col.label || col.name, + type: "column", + parentType: "my-new-component", + parentLabel: config.title || "컴포넌트", + langKeyId: col.langKeyId, + langKey: col.langKey, + }); + }); + } +} +``` + +### 매핑 적용 로직 등록 + +```typescript +// applyMultilangMappings 함수에 추가 +if (comp.componentType === "my-new-component") { + const config = comp.componentConfig as MyComponentConfig; + + // 제목 매핑 + const titleMapping = mappingMap.get(`${comp.id}_title`); + if (titleMapping) { + updated.componentConfig = { + ...updated.componentConfig, + titleLangKeyId: titleMapping.keyId, + titleLangKey: titleMapping.langKey, + }; + } + + // 컬럼 매핑 + 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, + }; + } +} +``` + +### 번역 표시 로직 + +```typescript +import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext"; + +const MyComponent = ({ component }) => { + 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, + })); + + return ( +
+

{displayTitle}

+ + + + {translatedColumns?.map((col, idx) => ( + + ))} + + +
{col.displayLabel}
+
+ ); +}; +``` + +### ScreenMultiLangContext에 키 수집 로직 추가 + +파일: `frontend/contexts/ScreenMultiLangContext.tsx` + +```typescript +// collectLangKeys 함수에 추가 +if (comp.componentType === "my-new-component") { + const config = comp.componentConfig; + + 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); + } + }); + } +} +``` + +--- + +## 4. 컬럼 설정 패널 구현 + +### 필수 구조 + +모든 테이블/목록 기반 컴포넌트의 설정 패널은 다음 구조를 따릅니다: + +```typescript +interface ConfigPanelProps { + config: MyComponentConfig; + onChange: (config: Partial) => void; + screenTableName?: string; // 화면에 연결된 테이블명 + tableColumns?: any[]; // 테이블 컬럼 정보 +} + +export const MyComponentConfigPanel: React.FC = ({ + config, + onChange, + screenTableName, + tableColumns, +}) => { + // 1. 기본 테이블 컬럼 상태 + const [availableColumns, setAvailableColumns] = useState>([]); + + // 2. 엔티티 조인 컬럼 상태 (필수!) + const [entityJoinColumns, setEntityJoinColumns] = useState<{ + availableColumns: Array<{...}>; + joinTables: Array<{...}>; + }>({ availableColumns: [], joinTables: [] }); + + // 3. 로딩 상태 + const [loadingEntityJoins, setLoadingEntityJoins] = useState(false); + + // 4. 화면 테이블명이 있으면 자동 설정 + useEffect(() => { + if (screenTableName && !config.selectedTable) { + onChange({ + ...config, + selectedTable: screenTableName, + columns: config.columns || [], + }); + } + }, [screenTableName]); + + // 5. 기본 컬럼 로드 + useEffect(() => { + // tableColumns prop 또는 API에서 로드 + }, [config.selectedTable, screenTableName, tableColumns]); + + // 6. 엔티티 조인 컬럼 로드 (필수!) + useEffect(() => { + const fetchEntityJoinColumns = async () => { + const tableName = config.selectedTable || screenTableName; + if (!tableName) { + setEntityJoinColumns({ availableColumns: [], joinTables: [] }); + return; + } + + setLoadingEntityJoins(true); + try { + const result = await entityJoinApi.getEntityJoinColumns(tableName); + setEntityJoinColumns({ + availableColumns: result.availableColumns || [], + joinTables: result.joinTables || [], + }); + } catch (error) { + setEntityJoinColumns({ availableColumns: [], joinTables: [] }); + } finally { + setLoadingEntityJoins(false); + } + }; + + fetchEntityJoinColumns(); + }, [config.selectedTable, screenTableName]); + + // 7. UI 렌더링 + return ( +
+ {/* 기본 테이블 컬럼 */} +
+ + {/* 기본 컬럼 체크박스들 */} +
+ + {/* 엔티티 조인 컬럼 (필수!) */} + {entityJoinColumns.joinTables.length > 0 && ( +
+ + {/* 조인 테이블별 컬럼 선택 UI */} +
+ )} +
+ ); +}; +``` + +--- + +## 5. 체크리스트 + +새 컴포넌트 개발 시 다음 항목을 확인하세요: + +### 엔티티 조인 (필수) + +- [ ] `entityJoinApi.getEntityJoinColumns()` 호출하여 조인 컬럼 로드 +- [ ] 설정 패널에 "엔티티 조인 컬럼" 섹션 추가 +- [ ] 조인 컬럼 선택 시 `tableName.columnName` 형식으로 저장 +- [ ] 데이터 조회 시 `getTableDataWithJoins()` 사용 +- [ ] 셀 값 추출 시 `getEntityJoinValue()` 헬퍼 사용 + +### 폼 데이터 관리 + +- [ ] `useFormCompatibility` 훅 사용 +- [ ] 값 변경 시 `setValue()` 호출 +- [ ] 리피터 컴포넌트는 `beforeFormSave` 이벤트 처리 + +### 다국어 지원 + +- [ ] 타입 정의에 `langKeyId`, `langKey` 필드 추가 +- [ ] `extractMultilangLabels` 함수에 라벨 추출 로직 추가 +- [ ] `applyMultilangMappings` 함수에 매핑 적용 로직 추가 +- [ ] `collectLangKeys` 함수에 키 수집 로직 추가 +- [ ] 컴포넌트에서 `useScreenMultiLang` 훅으로 번역 표시 + +### 설정 패널 + +- [ ] `screenTableName` prop 처리 +- [ ] `tableColumns` prop 처리 +- [ ] 엔티티 조인 컬럼 로드 및 표시 +- [ ] 컬럼 추가/제거/순서변경 기능 + +--- + +## 관련 파일 목록 + +| 파일 | 역할 | +| ---------------------------------------------------- | --------------------- | +| `frontend/lib/api/entityJoin.ts` | 엔티티 조인 API | +| `frontend/hooks/useFormCompatibility.ts` | 폼 호환성 브릿지 | +| `frontend/components/unified/UnifiedFormContext.tsx` | 통합 폼 Context | +| `frontend/lib/utils/multilangLabelExtractor.ts` | 다국어 라벨 추출/매핑 | +| `frontend/contexts/ScreenMultiLangContext.tsx` | 다국어 번역 Context | + +--- + +## 참고: TableListConfigPanel 예시 + +`frontend/lib/registry/components/table-list/TableListConfigPanel.tsx` 파일에서 +엔티티 조인 컬럼을 어떻게 표시하는지 참고하세요. + +주요 패턴: + +1. `entityJoinApi.getEntityJoinColumns(tableName)` 호출 +2. `joinTables` 배열을 순회하며 각 조인 테이블의 컬럼 표시 +3. `tableName.columnName` 형식으로 컬럼명 생성 +4. `isEntityJoin: true` 플래그로 일반 컬럼과 구분 diff --git a/.cursor/rules/multilang-component-guide.mdc b/.cursor/rules/multilang-component-guide.mdc index 60bdc0ec..25a0b5c5 100644 --- a/.cursor/rules/multilang-component-guide.mdc +++ b/.cursor/rules/multilang-component-guide.mdc @@ -1,559 +1,40 @@ -# 다국어 지원 컴포넌트 개발 가이드 - -새로운 화면 컴포넌트를 개발할 때 반드시 다국어 시스템을 고려해야 합니다. -이 가이드는 컴포넌트가 다국어 자동 생성 및 매핑 시스템과 호환되도록 하는 방법을 설명합니다. - +--- +description: (Deprecated) 이 파일은 component-development-guide.mdc로 통합되었습니다. +alwaysApply: false --- -## 1. 타입 정의 시 다국어 필드 추가 +# 다국어 지원 컴포넌트 개발 가이드 (Deprecated) -### 기본 원칙 +> **이 문서는 더 이상 사용되지 않습니다.** +> +> 새로운 통합 가이드를 참조하세요: `component-development-guide.mdc` -텍스트가 표시되는 **모든 속성**에 `langKeyId`와 `langKey` 필드를 함께 정의해야 합니다. +다국어 지원을 포함한 모든 컴포넌트 개발 가이드가 다음 파일로 통합되었습니다: -### 단일 텍스트 속성 +**[component-development-guide.mdc](.cursor/rules/component-development-guide.mdc)** -```typescript -interface MyComponentConfig { - // 기본 텍스트 - title?: string; - // 다국어 키 (필수 추가) - titleLangKeyId?: number; - titleLangKey?: string; +통합된 가이드에는 다음 내용이 포함되어 있습니다: - // 라벨 - label?: string; - labelLangKeyId?: number; - labelLangKey?: string; +1. **엔티티 조인 컬럼 활용 (필수)** - // 플레이스홀더 - placeholder?: string; - placeholderLangKeyId?: number; - placeholderLangKey?: string; -} -``` + - 화면을 새로 만들어 임베딩하는 방식 대신 엔티티 관계 활용 + - `entityJoinApi.getEntityJoinColumns()` 사용법 + - 설정 패널에서 조인 컬럼 표시 패턴 -### 배열/목록 속성 (컬럼, 탭 등) +2. **폼 데이터 관리** -```typescript -interface ColumnConfig { - name: string; - label: string; - // 다국어 키 (필수 추가) - langKeyId?: number; - langKey?: string; - // 기타 속성 - width?: number; - align?: "left" | "center" | "right"; -} + - `useFormCompatibility` 훅 사용법 + - 레거시 `beforeFormSave` 이벤트 호환성 -interface TabConfig { - id: string; - label: string; - // 다국어 키 (필수 추가) - langKeyId?: number; - langKey?: string; - // 탭 제목도 별도로 - title?: string; - titleLangKeyId?: number; - titleLangKey?: string; -} +3. **다국어 지원** -interface MyComponentConfig { - columns?: ColumnConfig[]; - tabs?: TabConfig[]; -} -``` + - 타입 정의 시 `langKeyId`, `langKey` 필드 추가 + - 라벨 추출/매핑 로직 + - 번역 표시 로직 -### 버튼 컴포넌트 +4. **컬럼 설정 패널 구현** -```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. **테스트**: 다국어 생성 → 다국어 설정 → 언어 변경 순서로 테스트 +5. **체크리스트** + - 새 컴포넌트 개발 시 확인 항목 diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index ab9bbc46..f722d469 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -484,6 +484,7 @@ export class EntityJoinController { columnName: col.columnName, columnLabel: col.displayName || col.columnName, dataType: col.dataType, + inputType: col.inputType || "text", isNullable: true, // 기본값으로 설정 maxLength: undefined, // 정보가 없으므로 undefined description: col.displayName, @@ -512,6 +513,7 @@ export class EntityJoinController { columnName: string; columnLabel: string; dataType: string; + inputType: string; joinAlias: string; suggestedLabel: string; }> = []; @@ -526,6 +528,7 @@ export class EntityJoinController { columnName: col.columnName, columnLabel: col.columnLabel, dataType: col.dataType, + inputType: col.inputType || "text", joinAlias, suggestedLabel, }); diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index 25d96927..574f7190 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -704,6 +704,7 @@ export class EntityJoinService { columnName: string; displayName: string; dataType: string; + inputType?: string; }> > { try { @@ -722,31 +723,39 @@ export class EntityJoinService { [tableName] ); - // 2. column_labels 테이블에서 라벨 정보 조회 + // 2. column_labels 테이블에서 라벨과 input_type 정보 조회 const columnLabels = await query<{ column_name: string; column_label: string | null; + input_type: string | null; }>( - `SELECT column_name, column_label + `SELECT column_name, column_label, input_type FROM column_labels WHERE table_name = $1`, [tableName] ); - // 3. 라벨 정보를 맵으로 변환 - const labelMap = new Map(); - columnLabels.forEach((label) => { - if (label.column_name && label.column_label) { - labelMap.set(label.column_name, label.column_label); + // 3. 라벨 및 inputType 정보를 맵으로 변환 + const labelMap = new Map(); + columnLabels.forEach((col) => { + if (col.column_name) { + labelMap.set(col.column_name, { + label: col.column_label || col.column_name, + inputType: col.input_type || "text", + }); } }); - // 4. 컬럼 정보와 라벨 정보 결합 - return columns.map((col) => ({ - columnName: col.column_name, - displayName: labelMap.get(col.column_name) || col.column_name, // 라벨이 있으면 사용, 없으면 컬럼명 - dataType: col.data_type, - })); + // 4. 컬럼 정보와 라벨/inputType 정보 결합 + return columns.map((col) => { + const labelInfo = labelMap.get(col.column_name); + return { + columnName: col.column_name, + displayName: labelInfo?.label || col.column_name, + dataType: col.data_type, + inputType: labelInfo?.inputType || "text", + }; + }); } catch (error) { logger.error(`참조 테이블 컬럼 조회 실패: ${tableName}`, error); return []; diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 8445f5e1..bd06363f 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -629,6 +629,10 @@ export const InteractiveScreenViewer: React.FC = ( const fieldName = columnName || comp.id; const currentValue = formData[fieldName] || ""; + // 🆕 엔티티 조인 컬럼은 읽기 전용으로 처리 + const isEntityJoin = (comp as any).isEntityJoin === true; + const isReadonly = readonly || isEntityJoin; + // 다국어 라벨 적용 (langKey가 있으면 번역 텍스트 사용) const compLangKey = (comp as any).langKey; const label = compLangKey && translations[compLangKey] ? translations[compLangKey] : originalLabel; @@ -745,7 +749,7 @@ export const InteractiveScreenViewer: React.FC = ( placeholder={isAutoInput ? `자동입력: ${config?.autoValueType}` : finalPlaceholder} value={displayValue} onChange={isAutoInput ? undefined : handleInputChange} - disabled={readonly || isAutoInput} + disabled={isReadonly || isAutoInput} readOnly={isAutoInput} required={required} minLength={config?.minLength} @@ -786,7 +790,7 @@ export const InteractiveScreenViewer: React.FC = ( placeholder={finalPlaceholder} value={currentValue} onChange={(e) => updateFormData(fieldName, e.target.valueAsNumber || 0)} - disabled={readonly} + disabled={isReadonly} required={required} min={config?.min} max={config?.max} @@ -825,7 +829,7 @@ export const InteractiveScreenViewer: React.FC = ( placeholder={finalPlaceholder} value={currentValue || config?.defaultValue || ""} onChange={(e) => updateFormData(fieldName, e.target.value)} - disabled={readonly} + disabled={isReadonly} required={required} minLength={config?.minLength} maxLength={config?.maxLength} @@ -877,7 +881,7 @@ export const InteractiveScreenViewer: React.FC = ( value={currentValue} onChange={(value) => updateFormData(fieldName, value)} placeholder={finalPlaceholder} - disabled={readonly} + disabled={isReadonly} required={required} />, ); @@ -895,7 +899,7 @@ export const InteractiveScreenViewer: React.FC = ( value={currentValue} onChange={(value) => updateFormData(fieldName, value)} placeholder={finalPlaceholder} - disabled={readonly} + disabled={isReadonly} required={required} />, ); @@ -912,7 +916,7 @@ export const InteractiveScreenViewer: React.FC = ( updateFormData(fieldName, value)} - disabled={readonly} + disabled={isReadonly} required={required} > @@ -1947,7 +1951,7 @@ export const InteractiveScreenViewer: React.FC = ( return applyStyles(