엔티티조인 읽기전용 컬럼 추가
This commit is contained in:
parent
71af4dfc6b
commit
19dbe59e3a
|
|
@ -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 && (
|
||||||
|
<div className="space-y-2 mt-4">
|
||||||
|
<Label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Link2 className="h-3 w-3" />
|
||||||
|
엔티티 조인 컬럼
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{entityJoinColumns.joinTables.map((joinTable) => (
|
||||||
|
<div key={joinTable.tableName} className="border rounded-lg p-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{joinTable.tableName}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({joinTable.availableColumns.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{joinTable.availableColumns.map((col) => {
|
||||||
|
// "테이블명.컬럼명" 형식으로 컬럼 이름 생성
|
||||||
|
const fullColumnName = `${joinTable.tableName}.${col.columnName}`;
|
||||||
|
const isSelected = config.columns?.some(
|
||||||
|
(c) => c.columnName === fullColumnName
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={col.columnName}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 p-2 border rounded cursor-pointer",
|
||||||
|
isSelected
|
||||||
|
? "bg-primary/10 border-primary"
|
||||||
|
: "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (isSelected) {
|
||||||
|
removeColumn(fullColumnName);
|
||||||
|
} else {
|
||||||
|
addEntityJoinColumn(joinTable.tableName, col);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox checked={isSelected} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm truncate">{col.columnLabel}</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{col.columnName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 엔티티 조인 컬럼 추가 함수
|
||||||
|
|
||||||
|
```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 (
|
||||||
|
<div>
|
||||||
|
<h2>{displayTitle}</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{translatedColumns?.map((col, idx) => (
|
||||||
|
<th key={idx}>{col.displayLabel}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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<MyComponentConfig>) => void;
|
||||||
|
screenTableName?: string; // 화면에 연결된 테이블명
|
||||||
|
tableColumns?: any[]; // 테이블 컬럼 정보
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MyComponentConfigPanel: React.FC<ConfigPanelProps> = ({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
screenTableName,
|
||||||
|
tableColumns,
|
||||||
|
}) => {
|
||||||
|
// 1. 기본 테이블 컬럼 상태
|
||||||
|
const [availableColumns, setAvailableColumns] = useState<Array<{
|
||||||
|
columnName: string;
|
||||||
|
dataType: string;
|
||||||
|
label?: string;
|
||||||
|
input_type?: string;
|
||||||
|
}>>([]);
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 기본 테이블 컬럼 */}
|
||||||
|
<div>
|
||||||
|
<Label>표시할 컬럼 선택</Label>
|
||||||
|
{/* 기본 컬럼 체크박스들 */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 엔티티 조인 컬럼 (필수!) */}
|
||||||
|
{entityJoinColumns.joinTables.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<Link2 className="h-3 w-3" />
|
||||||
|
엔티티 조인 컬럼
|
||||||
|
</Label>
|
||||||
|
{/* 조인 테이블별 컬럼 선택 UI */}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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` 플래그로 일반 컬럼과 구분
|
||||||
|
|
@ -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;
|
|
||||||
|
|
||||||
// 라벨
|
1. **엔티티 조인 컬럼 활용 (필수)**
|
||||||
label?: string;
|
|
||||||
labelLangKeyId?: number;
|
|
||||||
labelLangKey?: string;
|
|
||||||
|
|
||||||
// 플레이스홀더
|
- 화면을 새로 만들어 임베딩하는 방식 대신 엔티티 관계 활용
|
||||||
placeholder?: string;
|
- `entityJoinApi.getEntityJoinColumns()` 사용법
|
||||||
placeholderLangKeyId?: number;
|
- 설정 패널에서 조인 컬럼 표시 패턴
|
||||||
placeholderLangKey?: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 배열/목록 속성 (컬럼, 탭 등)
|
2. **폼 데이터 관리**
|
||||||
|
|
||||||
```typescript
|
- `useFormCompatibility` 훅 사용법
|
||||||
interface ColumnConfig {
|
- 레거시 `beforeFormSave` 이벤트 호환성
|
||||||
name: string;
|
|
||||||
label: string;
|
|
||||||
// 다국어 키 (필수 추가)
|
|
||||||
langKeyId?: number;
|
|
||||||
langKey?: string;
|
|
||||||
// 기타 속성
|
|
||||||
width?: number;
|
|
||||||
align?: "left" | "center" | "right";
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TabConfig {
|
3. **다국어 지원**
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
// 다국어 키 (필수 추가)
|
|
||||||
langKeyId?: number;
|
|
||||||
langKey?: string;
|
|
||||||
// 탭 제목도 별도로
|
|
||||||
title?: string;
|
|
||||||
titleLangKeyId?: number;
|
|
||||||
titleLangKey?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MyComponentConfig {
|
- 타입 정의 시 `langKeyId`, `langKey` 필드 추가
|
||||||
columns?: ColumnConfig[];
|
- 라벨 추출/매핑 로직
|
||||||
tabs?: TabConfig[];
|
- 번역 표시 로직
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 버튼 컴포넌트
|
4. **컬럼 설정 패널 구현**
|
||||||
|
|
||||||
```typescript
|
- 필수 구조 및 패턴
|
||||||
interface ButtonComponentConfig {
|
|
||||||
text?: string;
|
|
||||||
// 다국어 키 (필수 추가)
|
|
||||||
langKeyId?: number;
|
|
||||||
langKey?: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 실제 예시: 분할 패널
|
5. **체크리스트**
|
||||||
|
- 새 컴포넌트 개발 시 확인 항목
|
||||||
```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 (
|
|
||||||
<div>
|
|
||||||
<h2>{displayTitle}</h2>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
{translatedColumns?.map((col, idx) => (
|
|
||||||
<th key={idx}>{col.displayLabel}</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
</table>
|
|
||||||
<button>{buttonText}</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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<string> => {
|
|
||||||
const keys = new Set<string>();
|
|
||||||
|
|
||||||
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<string> => {
|
|
||||||
const tableNames = new Set<string>();
|
|
||||||
|
|
||||||
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. **테스트**: 다국어 생성 → 다국어 설정 → 언어 변경 순서로 테스트
|
|
||||||
|
|
|
||||||
|
|
@ -484,6 +484,7 @@ export class EntityJoinController {
|
||||||
columnName: col.columnName,
|
columnName: col.columnName,
|
||||||
columnLabel: col.displayName || col.columnName,
|
columnLabel: col.displayName || col.columnName,
|
||||||
dataType: col.dataType,
|
dataType: col.dataType,
|
||||||
|
inputType: col.inputType || "text",
|
||||||
isNullable: true, // 기본값으로 설정
|
isNullable: true, // 기본값으로 설정
|
||||||
maxLength: undefined, // 정보가 없으므로 undefined
|
maxLength: undefined, // 정보가 없으므로 undefined
|
||||||
description: col.displayName,
|
description: col.displayName,
|
||||||
|
|
@ -512,6 +513,7 @@ export class EntityJoinController {
|
||||||
columnName: string;
|
columnName: string;
|
||||||
columnLabel: string;
|
columnLabel: string;
|
||||||
dataType: string;
|
dataType: string;
|
||||||
|
inputType: string;
|
||||||
joinAlias: string;
|
joinAlias: string;
|
||||||
suggestedLabel: string;
|
suggestedLabel: string;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
@ -526,6 +528,7 @@ export class EntityJoinController {
|
||||||
columnName: col.columnName,
|
columnName: col.columnName,
|
||||||
columnLabel: col.columnLabel,
|
columnLabel: col.columnLabel,
|
||||||
dataType: col.dataType,
|
dataType: col.dataType,
|
||||||
|
inputType: col.inputType || "text",
|
||||||
joinAlias,
|
joinAlias,
|
||||||
suggestedLabel,
|
suggestedLabel,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -704,6 +704,7 @@ export class EntityJoinService {
|
||||||
columnName: string;
|
columnName: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
dataType: string;
|
dataType: string;
|
||||||
|
inputType?: string;
|
||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
try {
|
try {
|
||||||
|
|
@ -722,31 +723,39 @@ export class EntityJoinService {
|
||||||
[tableName]
|
[tableName]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 2. column_labels 테이블에서 라벨 정보 조회
|
// 2. column_labels 테이블에서 라벨과 input_type 정보 조회
|
||||||
const columnLabels = await query<{
|
const columnLabels = await query<{
|
||||||
column_name: string;
|
column_name: string;
|
||||||
column_label: string | null;
|
column_label: string | null;
|
||||||
|
input_type: string | null;
|
||||||
}>(
|
}>(
|
||||||
`SELECT column_name, column_label
|
`SELECT column_name, column_label, input_type
|
||||||
FROM column_labels
|
FROM column_labels
|
||||||
WHERE table_name = $1`,
|
WHERE table_name = $1`,
|
||||||
[tableName]
|
[tableName]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 3. 라벨 정보를 맵으로 변환
|
// 3. 라벨 및 inputType 정보를 맵으로 변환
|
||||||
const labelMap = new Map<string, string>();
|
const labelMap = new Map<string, { label: string; inputType: string }>();
|
||||||
columnLabels.forEach((label) => {
|
columnLabels.forEach((col) => {
|
||||||
if (label.column_name && label.column_label) {
|
if (col.column_name) {
|
||||||
labelMap.set(label.column_name, label.column_label);
|
labelMap.set(col.column_name, {
|
||||||
|
label: col.column_label || col.column_name,
|
||||||
|
inputType: col.input_type || "text",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. 컬럼 정보와 라벨 정보 결합
|
// 4. 컬럼 정보와 라벨/inputType 정보 결합
|
||||||
return columns.map((col) => ({
|
return columns.map((col) => {
|
||||||
columnName: col.column_name,
|
const labelInfo = labelMap.get(col.column_name);
|
||||||
displayName: labelMap.get(col.column_name) || col.column_name, // 라벨이 있으면 사용, 없으면 컬럼명
|
return {
|
||||||
dataType: col.data_type,
|
columnName: col.column_name,
|
||||||
}));
|
displayName: labelInfo?.label || col.column_name,
|
||||||
|
dataType: col.data_type,
|
||||||
|
inputType: labelInfo?.inputType || "text",
|
||||||
|
};
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`참조 테이블 컬럼 조회 실패: ${tableName}`, error);
|
logger.error(`참조 테이블 컬럼 조회 실패: ${tableName}`, error);
|
||||||
return [];
|
return [];
|
||||||
|
|
|
||||||
|
|
@ -629,6 +629,10 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
const fieldName = columnName || comp.id;
|
const fieldName = columnName || comp.id;
|
||||||
const currentValue = formData[fieldName] || "";
|
const currentValue = formData[fieldName] || "";
|
||||||
|
|
||||||
|
// 🆕 엔티티 조인 컬럼은 읽기 전용으로 처리
|
||||||
|
const isEntityJoin = (comp as any).isEntityJoin === true;
|
||||||
|
const isReadonly = readonly || isEntityJoin;
|
||||||
|
|
||||||
// 다국어 라벨 적용 (langKey가 있으면 번역 텍스트 사용)
|
// 다국어 라벨 적용 (langKey가 있으면 번역 텍스트 사용)
|
||||||
const compLangKey = (comp as any).langKey;
|
const compLangKey = (comp as any).langKey;
|
||||||
const label = compLangKey && translations[compLangKey] ? translations[compLangKey] : originalLabel;
|
const label = compLangKey && translations[compLangKey] ? translations[compLangKey] : originalLabel;
|
||||||
|
|
@ -745,7 +749,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
placeholder={isAutoInput ? `자동입력: ${config?.autoValueType}` : finalPlaceholder}
|
placeholder={isAutoInput ? `자동입력: ${config?.autoValueType}` : finalPlaceholder}
|
||||||
value={displayValue}
|
value={displayValue}
|
||||||
onChange={isAutoInput ? undefined : handleInputChange}
|
onChange={isAutoInput ? undefined : handleInputChange}
|
||||||
disabled={readonly || isAutoInput}
|
disabled={isReadonly || isAutoInput}
|
||||||
readOnly={isAutoInput}
|
readOnly={isAutoInput}
|
||||||
required={required}
|
required={required}
|
||||||
minLength={config?.minLength}
|
minLength={config?.minLength}
|
||||||
|
|
@ -786,7 +790,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
placeholder={finalPlaceholder}
|
placeholder={finalPlaceholder}
|
||||||
value={currentValue}
|
value={currentValue}
|
||||||
onChange={(e) => updateFormData(fieldName, e.target.valueAsNumber || 0)}
|
onChange={(e) => updateFormData(fieldName, e.target.valueAsNumber || 0)}
|
||||||
disabled={readonly}
|
disabled={isReadonly}
|
||||||
required={required}
|
required={required}
|
||||||
min={config?.min}
|
min={config?.min}
|
||||||
max={config?.max}
|
max={config?.max}
|
||||||
|
|
@ -825,7 +829,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
placeholder={finalPlaceholder}
|
placeholder={finalPlaceholder}
|
||||||
value={currentValue || config?.defaultValue || ""}
|
value={currentValue || config?.defaultValue || ""}
|
||||||
onChange={(e) => updateFormData(fieldName, e.target.value)}
|
onChange={(e) => updateFormData(fieldName, e.target.value)}
|
||||||
disabled={readonly}
|
disabled={isReadonly}
|
||||||
required={required}
|
required={required}
|
||||||
minLength={config?.minLength}
|
minLength={config?.minLength}
|
||||||
maxLength={config?.maxLength}
|
maxLength={config?.maxLength}
|
||||||
|
|
@ -877,7 +881,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
value={currentValue}
|
value={currentValue}
|
||||||
onChange={(value) => updateFormData(fieldName, value)}
|
onChange={(value) => updateFormData(fieldName, value)}
|
||||||
placeholder={finalPlaceholder}
|
placeholder={finalPlaceholder}
|
||||||
disabled={readonly}
|
disabled={isReadonly}
|
||||||
required={required}
|
required={required}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
@ -895,7 +899,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
value={currentValue}
|
value={currentValue}
|
||||||
onChange={(value) => updateFormData(fieldName, value)}
|
onChange={(value) => updateFormData(fieldName, value)}
|
||||||
placeholder={finalPlaceholder}
|
placeholder={finalPlaceholder}
|
||||||
disabled={readonly}
|
disabled={isReadonly}
|
||||||
required={required}
|
required={required}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
@ -912,7 +916,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
<Select
|
<Select
|
||||||
value={currentValue || config?.defaultValue || ""}
|
value={currentValue || config?.defaultValue || ""}
|
||||||
onValueChange={(value) => updateFormData(fieldName, value)}
|
onValueChange={(value) => updateFormData(fieldName, value)}
|
||||||
disabled={readonly}
|
disabled={isReadonly}
|
||||||
required={required}
|
required={required}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-full w-full">
|
<SelectTrigger className="h-full w-full">
|
||||||
|
|
@ -959,7 +963,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
id={fieldName}
|
id={fieldName}
|
||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
onCheckedChange={(checked) => updateFormData(fieldName, checked)}
|
onCheckedChange={(checked) => updateFormData(fieldName, checked)}
|
||||||
disabled={readonly}
|
disabled={isReadonly}
|
||||||
required={required}
|
required={required}
|
||||||
/>
|
/>
|
||||||
<label htmlFor={fieldName} className="text-sm">
|
<label htmlFor={fieldName} className="text-sm">
|
||||||
|
|
@ -1005,7 +1009,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
value=""
|
value=""
|
||||||
checked={selectedValue === ""}
|
checked={selectedValue === ""}
|
||||||
onChange={(e) => updateFormData(fieldName, e.target.value)}
|
onChange={(e) => updateFormData(fieldName, e.target.value)}
|
||||||
disabled={readonly}
|
disabled={isReadonly}
|
||||||
required={required}
|
required={required}
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
|
|
@ -1023,7 +1027,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
value={option.value}
|
value={option.value}
|
||||||
checked={selectedValue === option.value}
|
checked={selectedValue === option.value}
|
||||||
onChange={(e) => updateFormData(fieldName, e.target.value)}
|
onChange={(e) => updateFormData(fieldName, e.target.value)}
|
||||||
disabled={readonly || option.disabled}
|
disabled={isReadonly || option.disabled}
|
||||||
required={required}
|
required={required}
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
|
|
@ -1064,7 +1068,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
placeholder={finalPlaceholder}
|
placeholder={finalPlaceholder}
|
||||||
value={currentValue || config?.defaultValue || ""}
|
value={currentValue || config?.defaultValue || ""}
|
||||||
onChange={(e) => updateFormData(fieldName, e.target.value)}
|
onChange={(e) => updateFormData(fieldName, e.target.value)}
|
||||||
disabled={readonly}
|
disabled={isReadonly}
|
||||||
required={required}
|
required={required}
|
||||||
min={config?.minDate}
|
min={config?.minDate}
|
||||||
max={config?.maxDate}
|
max={config?.maxDate}
|
||||||
|
|
@ -1081,7 +1085,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-full w-full justify-start text-left font-normal"
|
className="h-full w-full justify-start text-left font-normal"
|
||||||
disabled={readonly}
|
disabled={isReadonly}
|
||||||
>
|
>
|
||||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
{dateValue ? format(dateValue, "PPP", { locale: ko }) : config?.defaultValue || finalPlaceholder}
|
{dateValue ? format(dateValue, "PPP", { locale: ko }) : config?.defaultValue || finalPlaceholder}
|
||||||
|
|
@ -1124,7 +1128,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
placeholder={finalPlaceholder}
|
placeholder={finalPlaceholder}
|
||||||
value={currentValue || config?.defaultValue || ""}
|
value={currentValue || config?.defaultValue || ""}
|
||||||
onChange={(e) => updateFormData(fieldName, e.target.value)}
|
onChange={(e) => updateFormData(fieldName, e.target.value)}
|
||||||
disabled={readonly}
|
disabled={isReadonly}
|
||||||
required={required}
|
required={required}
|
||||||
min={config?.minDate}
|
min={config?.minDate}
|
||||||
max={config?.maxDate}
|
max={config?.maxDate}
|
||||||
|
|
@ -1301,7 +1305,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
type="file"
|
type="file"
|
||||||
data-field={fieldName}
|
data-field={fieldName}
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
disabled={readonly}
|
disabled={isReadonly}
|
||||||
required={required}
|
required={required}
|
||||||
multiple={config?.multiple}
|
multiple={config?.multiple}
|
||||||
accept={config?.accept}
|
accept={config?.accept}
|
||||||
|
|
@ -1409,7 +1413,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
<Select
|
<Select
|
||||||
value={currentValue || ""}
|
value={currentValue || ""}
|
||||||
onValueChange={(value) => updateFormData(fieldName, value)}
|
onValueChange={(value) => updateFormData(fieldName, value)}
|
||||||
disabled={readonly}
|
disabled={isReadonly}
|
||||||
required={required}
|
required={required}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-full w-full">
|
<SelectTrigger className="h-full w-full">
|
||||||
|
|
@ -1947,7 +1951,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
return applyStyles(
|
return applyStyles(
|
||||||
<button
|
<button
|
||||||
onClick={handleButtonClick}
|
onClick={handleButtonClick}
|
||||||
disabled={readonly}
|
disabled={isReadonly}
|
||||||
className={`focus:ring-ring w-full rounded-md px-3 py-2 text-sm font-medium transition-colors focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none disabled:opacity-50 ${
|
className={`focus:ring-ring w-full rounded-md px-3 py-2 text-sm font-medium transition-colors focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none disabled:opacity-50 ${
|
||||||
hasCustomColors
|
hasCustomColors
|
||||||
? ""
|
? ""
|
||||||
|
|
@ -1972,7 +1976,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
placeholder={placeholder || "입력하세요..."}
|
placeholder={placeholder || "입력하세요..."}
|
||||||
value={currentValue}
|
value={currentValue}
|
||||||
onChange={(e) => updateFormData(fieldName, e.target.value)}
|
onChange={(e) => updateFormData(fieldName, e.target.value)}
|
||||||
disabled={readonly}
|
disabled={isReadonly}
|
||||||
required={required}
|
required={required}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
style={{ height: "100%" }}
|
style={{ height: "100%" }}
|
||||||
|
|
|
||||||
|
|
@ -2727,14 +2727,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
componentWidth,
|
componentWidth,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 엔티티 조인 컬럼인 경우 읽기 전용으로 설정
|
||||||
|
const isEntityJoinColumn = column.isEntityJoin === true;
|
||||||
|
|
||||||
newComponent = {
|
newComponent = {
|
||||||
id: generateComponentId(),
|
id: generateComponentId(),
|
||||||
type: "component", // ✅ Unified 컴포넌트 시스템 사용
|
type: "component", // ✅ Unified 컴포넌트 시스템 사용
|
||||||
label: column.columnLabel || column.columnName,
|
label: column.columnLabel || column.columnName,
|
||||||
tableName: table.tableName,
|
tableName: table.tableName,
|
||||||
columnName: column.columnName,
|
columnName: column.columnName,
|
||||||
required: column.required,
|
required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님
|
||||||
readonly: false,
|
readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용
|
||||||
parentId: formContainerId, // 폼 컨테이너의 자식으로 설정
|
parentId: formContainerId, // 폼 컨테이너의 자식으로 설정
|
||||||
componentType: unifiedMapping.componentType, // unified-input, unified-select 등
|
componentType: unifiedMapping.componentType, // unified-input, unified-select 등
|
||||||
position: { x: relativeX, y: relativeY, z: 1 } as Position,
|
position: { x: relativeX, y: relativeY, z: 1 } as Position,
|
||||||
|
|
@ -2744,6 +2747,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
column.codeCategory && {
|
column.codeCategory && {
|
||||||
codeCategory: column.codeCategory,
|
codeCategory: column.codeCategory,
|
||||||
}),
|
}),
|
||||||
|
// 엔티티 조인 정보 저장
|
||||||
|
...(isEntityJoinColumn && {
|
||||||
|
isEntityJoin: true,
|
||||||
|
entityJoinTable: column.entityJoinTable,
|
||||||
|
entityJoinColumn: column.entityJoinColumn,
|
||||||
|
}),
|
||||||
style: {
|
style: {
|
||||||
labelDisplay: false, // 라벨 숨김
|
labelDisplay: false, // 라벨 숨김
|
||||||
labelFontSize: "12px",
|
labelFontSize: "12px",
|
||||||
|
|
@ -2754,6 +2763,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
componentConfig: {
|
componentConfig: {
|
||||||
type: unifiedMapping.componentType, // unified-input, unified-select 등
|
type: unifiedMapping.componentType, // unified-input, unified-select 등
|
||||||
...unifiedMapping.componentConfig, // Unified 컴포넌트 기본 설정
|
...unifiedMapping.componentConfig, // Unified 컴포넌트 기본 설정
|
||||||
|
...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2784,14 +2794,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
componentWidth,
|
componentWidth,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 엔티티 조인 컬럼인 경우 읽기 전용으로 설정
|
||||||
|
const isEntityJoinColumn = column.isEntityJoin === true;
|
||||||
|
|
||||||
newComponent = {
|
newComponent = {
|
||||||
id: generateComponentId(),
|
id: generateComponentId(),
|
||||||
type: "component", // ✅ Unified 컴포넌트 시스템 사용
|
type: "component", // ✅ Unified 컴포넌트 시스템 사용
|
||||||
label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명
|
label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명
|
||||||
tableName: table.tableName,
|
tableName: table.tableName,
|
||||||
columnName: column.columnName,
|
columnName: column.columnName,
|
||||||
required: column.required,
|
required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님
|
||||||
readonly: false,
|
readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용
|
||||||
componentType: unifiedMapping.componentType, // unified-input, unified-select 등
|
componentType: unifiedMapping.componentType, // unified-input, unified-select 등
|
||||||
position: { x, y, z: 1 } as Position,
|
position: { x, y, z: 1 } as Position,
|
||||||
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
||||||
|
|
@ -2800,6 +2813,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
column.codeCategory && {
|
column.codeCategory && {
|
||||||
codeCategory: column.codeCategory,
|
codeCategory: column.codeCategory,
|
||||||
}),
|
}),
|
||||||
|
// 엔티티 조인 정보 저장
|
||||||
|
...(isEntityJoinColumn && {
|
||||||
|
isEntityJoin: true,
|
||||||
|
entityJoinTable: column.entityJoinTable,
|
||||||
|
entityJoinColumn: column.entityJoinColumn,
|
||||||
|
}),
|
||||||
style: {
|
style: {
|
||||||
labelDisplay: false, // 라벨 숨김
|
labelDisplay: false, // 라벨 숨김
|
||||||
labelFontSize: "14px",
|
labelFontSize: "14px",
|
||||||
|
|
@ -2810,6 +2829,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
componentConfig: {
|
componentConfig: {
|
||||||
type: unifiedMapping.componentType, // unified-input, unified-select 등
|
type: unifiedMapping.componentType, // unified-input, unified-select 등
|
||||||
...unifiedMapping.componentConfig, // Unified 컴포넌트 기본 설정
|
...unifiedMapping.componentConfig, // Unified 컴포넌트 기본 설정
|
||||||
|
...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,38 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Database, Type, Hash, Calendar, CheckSquare, List, AlignLeft, Code, Building, File } from "lucide-react";
|
import {
|
||||||
|
Database,
|
||||||
|
Type,
|
||||||
|
Hash,
|
||||||
|
Calendar,
|
||||||
|
CheckSquare,
|
||||||
|
List,
|
||||||
|
AlignLeft,
|
||||||
|
Code,
|
||||||
|
Building,
|
||||||
|
File,
|
||||||
|
Link2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
import { TableInfo, WebType } from "@/types/screen";
|
import { TableInfo, WebType } from "@/types/screen";
|
||||||
|
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||||
|
|
||||||
|
interface EntityJoinColumn {
|
||||||
|
columnName: string;
|
||||||
|
columnLabel: string;
|
||||||
|
dataType: string;
|
||||||
|
inputType?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EntityJoinTable {
|
||||||
|
tableName: string;
|
||||||
|
currentDisplayColumn: string;
|
||||||
|
availableColumns: EntityJoinColumn[];
|
||||||
|
}
|
||||||
|
|
||||||
interface TablesPanelProps {
|
interface TablesPanelProps {
|
||||||
tables: TableInfo[];
|
tables: TableInfo[];
|
||||||
|
|
@ -53,15 +82,90 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
|
||||||
onDragStart,
|
onDragStart,
|
||||||
placedColumns = new Set(),
|
placedColumns = new Set(),
|
||||||
}) => {
|
}) => {
|
||||||
|
// 엔티티 조인 컬럼 상태
|
||||||
|
const [entityJoinTables, setEntityJoinTables] = useState<EntityJoinTable[]>([]);
|
||||||
|
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
|
||||||
|
const [expandedJoinTables, setExpandedJoinTables] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// 시스템 컬럼 목록 (숨김 처리)
|
// 시스템 컬럼 목록 (숨김 처리)
|
||||||
const systemColumns = new Set([
|
const systemColumns = new Set([
|
||||||
'id',
|
"id",
|
||||||
'created_date',
|
"created_date",
|
||||||
'updated_date',
|
"updated_date",
|
||||||
'writer',
|
"writer",
|
||||||
'company_code'
|
"company_code",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// 메인 테이블명 추출
|
||||||
|
const mainTableName = tables[0]?.tableName;
|
||||||
|
|
||||||
|
// 엔티티 조인 컬럼 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchEntityJoinColumns = async () => {
|
||||||
|
if (!mainTableName) {
|
||||||
|
setEntityJoinTables([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingEntityJoins(true);
|
||||||
|
try {
|
||||||
|
const result = await entityJoinApi.getEntityJoinColumns(mainTableName);
|
||||||
|
setEntityJoinTables(result.joinTables || []);
|
||||||
|
// 기본적으로 모든 조인 테이블 펼치기
|
||||||
|
setExpandedJoinTables(new Set(result.joinTables?.map((t) => t.tableName) || []));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("엔티티 조인 컬럼 조회 오류:", error);
|
||||||
|
setEntityJoinTables([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingEntityJoins(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchEntityJoinColumns();
|
||||||
|
}, [mainTableName]);
|
||||||
|
|
||||||
|
// 조인 테이블 펼치기/접기 토글
|
||||||
|
const toggleJoinTable = (tableName: string) => {
|
||||||
|
setExpandedJoinTables((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(tableName)) {
|
||||||
|
newSet.delete(tableName);
|
||||||
|
} else {
|
||||||
|
newSet.add(tableName);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 엔티티 조인 컬럼 드래그 핸들러
|
||||||
|
const handleEntityJoinDragStart = (
|
||||||
|
e: React.DragEvent,
|
||||||
|
joinTable: EntityJoinTable,
|
||||||
|
column: EntityJoinColumn,
|
||||||
|
) => {
|
||||||
|
// "테이블명.컬럼명" 형식으로 컬럼 정보 생성
|
||||||
|
const fullColumnName = `${joinTable.tableName}.${column.columnName}`;
|
||||||
|
|
||||||
|
const columnData = {
|
||||||
|
columnName: fullColumnName,
|
||||||
|
columnLabel: column.columnLabel || column.columnName,
|
||||||
|
dataType: column.dataType,
|
||||||
|
widgetType: "text" as WebType,
|
||||||
|
isEntityJoin: true,
|
||||||
|
entityJoinTable: joinTable.tableName,
|
||||||
|
entityJoinColumn: column.columnName,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 기존 테이블 정보를 기반으로 가상의 테이블 정보 생성
|
||||||
|
const virtualTable: TableInfo = {
|
||||||
|
tableName: mainTableName || "",
|
||||||
|
tableLabel: tables[0]?.tableLabel || mainTableName || "",
|
||||||
|
columns: [columnData],
|
||||||
|
};
|
||||||
|
|
||||||
|
onDragStart(e, virtualTable, columnData);
|
||||||
|
};
|
||||||
|
|
||||||
// 이미 배치된 컬럼과 시스템 컬럼을 제외한 테이블 정보 생성
|
// 이미 배치된 컬럼과 시스템 컬럼을 제외한 테이블 정보 생성
|
||||||
const tablesWithAvailableColumns = tables.map((table) => ({
|
const tablesWithAvailableColumns = tables.map((table) => ({
|
||||||
...table,
|
...table,
|
||||||
|
|
@ -126,18 +230,19 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
|
||||||
{table.columns.map((column) => (
|
{table.columns.map((column) => (
|
||||||
<div
|
<div
|
||||||
key={column.columnName}
|
key={column.columnName}
|
||||||
className="hover:bg-accent/50 flex cursor-grab items-center justify-between rounded-md p-2 transition-colors"
|
className="hover:bg-accent/50 flex cursor-grab items-center gap-2 rounded-md p-2 transition-colors"
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => onDragStart(e, table, column)}
|
onDragStart={(e) => onDragStart(e, table, column)}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
{getWidgetIcon(column.widgetType)}
|
||||||
{getWidgetIcon(column.widgetType)}
|
<div className="min-w-0 flex-1">
|
||||||
<div className="min-w-0 flex-1">
|
<div
|
||||||
<div className="truncate text-xs font-medium">{column.columnLabel || column.columnName}</div>
|
className="text-xs font-medium"
|
||||||
<div className="text-muted-foreground truncate text-[10px]">{column.dataType}</div>
|
title={column.columnLabel || column.columnName}
|
||||||
|
>
|
||||||
|
{column.columnLabel || column.columnName}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-shrink-0 items-center gap-1">
|
<div className="flex flex-shrink-0 items-center gap-1">
|
||||||
<Badge variant="secondary" className="h-4 px-1.5 text-[10px]">
|
<Badge variant="secondary" className="h-4 px-1.5 text-[10px]">
|
||||||
{column.widgetType}
|
{column.widgetType}
|
||||||
|
|
@ -153,6 +258,103 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* 엔티티 조인 컬럼 섹션 */}
|
||||||
|
{entityJoinTables.length > 0 && (
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1">
|
||||||
|
<Link2 className="h-3.5 w-3.5 text-cyan-600" />
|
||||||
|
<span className="text-muted-foreground text-xs font-medium">엔티티 조인 컬럼</span>
|
||||||
|
<Badge variant="outline" className="h-4 px-1.5 text-[10px]">
|
||||||
|
{entityJoinTables.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{entityJoinTables.map((joinTable) => {
|
||||||
|
const isExpanded = expandedJoinTables.has(joinTable.tableName);
|
||||||
|
// 검색어로 필터링
|
||||||
|
const filteredColumns = searchTerm
|
||||||
|
? joinTable.availableColumns.filter(
|
||||||
|
(col) =>
|
||||||
|
col.columnName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
col.columnLabel.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
|
)
|
||||||
|
: joinTable.availableColumns;
|
||||||
|
|
||||||
|
// 검색 결과가 없으면 표시하지 않음
|
||||||
|
if (searchTerm && filteredColumns.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={joinTable.tableName} className="space-y-1">
|
||||||
|
{/* 조인 테이블 헤더 */}
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer items-center justify-between rounded-md bg-cyan-50 p-2 hover:bg-cyan-100"
|
||||||
|
onClick={() => toggleJoinTable(joinTable.tableName)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="h-3 w-3 text-cyan-600" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3 w-3 text-cyan-600" />
|
||||||
|
)}
|
||||||
|
<Building className="h-3.5 w-3.5 text-cyan-600" />
|
||||||
|
<span className="text-xs font-semibold text-cyan-800">{joinTable.tableName}</span>
|
||||||
|
<Badge variant="secondary" className="h-4 px-1.5 text-[10px]">
|
||||||
|
{filteredColumns.length}개
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 조인 컬럼 목록 */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="space-y-1 pl-4">
|
||||||
|
{filteredColumns.map((column) => {
|
||||||
|
const fullColumnName = `${joinTable.tableName}.${column.columnName}`;
|
||||||
|
const isPlaced = placedColumns.has(fullColumnName);
|
||||||
|
|
||||||
|
if (isPlaced) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={column.columnName}
|
||||||
|
className="flex cursor-grab items-center gap-2 rounded-md border border-cyan-200 bg-cyan-50/50 p-2 transition-colors hover:bg-cyan-100"
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleEntityJoinDragStart(e, joinTable, column)}
|
||||||
|
title="읽기 전용 - 조인된 테이블에서 참조"
|
||||||
|
>
|
||||||
|
<Link2 className="h-3 w-3 flex-shrink-0 text-cyan-500" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-xs font-medium" title={column.columnLabel || column.columnName}>
|
||||||
|
{column.columnLabel || column.columnName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-1">
|
||||||
|
<Badge variant="secondary" className="h-4 border-gray-300 bg-gray-100 px-1 text-[9px] text-gray-600">
|
||||||
|
읽기
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="h-4 border-cyan-300 px-1.5 text-[10px]">
|
||||||
|
{column.inputType || "text"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 로딩 표시 */}
|
||||||
|
{loadingEntityJoins && (
|
||||||
|
<div className="text-muted-foreground flex items-center justify-center py-4 text-xs">
|
||||||
|
엔티티 조인 컬럼 로드 중...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,7 @@ export const entityJoinApi = {
|
||||||
columnName: string;
|
columnName: string;
|
||||||
columnLabel: string;
|
columnLabel: string;
|
||||||
dataType: string;
|
dataType: string;
|
||||||
|
inputType?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}>;
|
}>;
|
||||||
}>;
|
}>;
|
||||||
|
|
@ -205,6 +206,7 @@ export const entityJoinApi = {
|
||||||
columnName: string;
|
columnName: string;
|
||||||
columnLabel: string;
|
columnLabel: string;
|
||||||
dataType: string;
|
dataType: string;
|
||||||
|
inputType?: string;
|
||||||
joinAlias: string;
|
joinAlias: string;
|
||||||
suggestedLabel: string;
|
suggestedLabel: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue