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 = (