feat/multilang #357
|
|
@ -1607,7 +1607,8 @@ export const getScreenSubTables = async (req: Request, res: Response) => {
|
|||
sd.table_name as main_table,
|
||||
sl.properties->>'componentType' as component_type,
|
||||
sl.properties->'componentConfig'->'rightPanel'->'relation' as right_panel_relation,
|
||||
sl.properties->'componentConfig'->'rightPanel'->'tableName' as right_panel_table
|
||||
sl.properties->'componentConfig'->'rightPanel'->'tableName' as right_panel_table,
|
||||
sl.properties->'componentConfig'->'rightPanel'->'columns' as right_panel_columns
|
||||
FROM screen_definitions sd
|
||||
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
|
||||
WHERE sd.screen_id = ANY($1)
|
||||
|
|
@ -1616,6 +1617,29 @@ export const getScreenSubTables = async (req: Request, res: Response) => {
|
|||
|
||||
const rightPanelResult = await pool.query(rightPanelQuery, [screenIds]);
|
||||
|
||||
// rightPanel.columns에서 참조되는 외부 테이블 수집 (예: customer_mng.customer_name → customer_mng)
|
||||
const rightPanelJoinedTables: Map<string, Set<string>> = new Map(); // screenId_tableName → Set<참조테이블>
|
||||
|
||||
rightPanelResult.rows.forEach((row: any) => {
|
||||
const screenId = row.screen_id;
|
||||
const rightPanelTable = row.right_panel_table;
|
||||
const rightPanelColumns = row.right_panel_columns;
|
||||
|
||||
if (rightPanelColumns && Array.isArray(rightPanelColumns)) {
|
||||
rightPanelColumns.forEach((col: any) => {
|
||||
const colName = col.name || col.columnName || col.field;
|
||||
if (colName && colName.includes('.')) {
|
||||
const refTable = colName.split('.')[0];
|
||||
const key = `${screenId}_${rightPanelTable}`;
|
||||
if (!rightPanelJoinedTables.has(key)) {
|
||||
rightPanelJoinedTables.set(key, new Set());
|
||||
}
|
||||
rightPanelJoinedTables.get(key)!.add(refTable);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
rightPanelResult.rows.forEach((row: any) => {
|
||||
const screenId = row.screen_id;
|
||||
const mainTable = row.main_table;
|
||||
|
|
@ -1626,6 +1650,10 @@ export const getScreenSubTables = async (req: Request, res: Response) => {
|
|||
// relation 객체에서 테이블 및 필드 매핑 추출
|
||||
const subTable = rightPanelTable || relation?.targetTable || relation?.tableName;
|
||||
if (!subTable || subTable === mainTable) return;
|
||||
|
||||
// rightPanel.columns에서 참조하는 외부 테이블 목록
|
||||
const key = `${screenId}_${subTable}`;
|
||||
const joinedTables = rightPanelJoinedTables.get(key) ? Array.from(rightPanelJoinedTables.get(key)!) : [];
|
||||
|
||||
if (!screenSubTables[screenId]) {
|
||||
screenSubTables[screenId] = {
|
||||
|
|
@ -1697,6 +1725,7 @@ export const getScreenSubTables = async (req: Request, res: Response) => {
|
|||
originalRelationType: relation?.type || 'join', // 원본 relation.type ("join" | "detail")
|
||||
foreignKey: relation?.foreignKey, // 디테일 테이블의 FK 컬럼
|
||||
leftColumn: relation?.leftColumn, // 마스터 테이블의 선택 기준 컬럼
|
||||
joinedTables: joinedTables.length > 0 ? joinedTables : undefined, // rightPanel.columns에서 참조하는 외부 테이블들
|
||||
fieldMappings: fieldMappings.length > 0 ? fieldMappings : undefined,
|
||||
} as any);
|
||||
}
|
||||
|
|
@ -1706,6 +1735,86 @@ export const getScreenSubTables = async (req: Request, res: Response) => {
|
|||
screenIds,
|
||||
rightPanelCount: rightPanelResult.rows.length
|
||||
});
|
||||
|
||||
// 5. joinedTables에 대한 FK 컬럼을 column_labels에서 조회
|
||||
// rightPanelRelation에서 joinedTables가 있는 경우, 해당 테이블과 조인하는 FK 컬럼 찾기
|
||||
const joinedTableFKLookups: Array<{ subTableName: string; refTable: string }> = [];
|
||||
Object.values(screenSubTables).forEach((screenData: any) => {
|
||||
screenData.subTables.forEach((subTable: any) => {
|
||||
if (subTable.joinedTables && Array.isArray(subTable.joinedTables)) {
|
||||
subTable.joinedTables.forEach((refTable: string) => {
|
||||
joinedTableFKLookups.push({ subTableName: subTable.tableName, refTable });
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// column_labels에서 FK 컬럼 조회 (reference_table로 조인하는 컬럼 찾기)
|
||||
const joinColumnsByTable: { [key: string]: string[] } = {}; // tableName → [FK 컬럼들]
|
||||
if (joinedTableFKLookups.length > 0) {
|
||||
const uniqueLookups = joinedTableFKLookups.filter((item, index, self) =>
|
||||
index === self.findIndex((t) => t.subTableName === item.subTableName && t.refTable === item.refTable)
|
||||
);
|
||||
|
||||
// 각 subTable에 대해 reference_table이 일치하는 컬럼 조회
|
||||
const subTableNames = [...new Set(uniqueLookups.map(l => l.subTableName))];
|
||||
const refTableNames = [...new Set(uniqueLookups.map(l => l.refTable))];
|
||||
|
||||
const fkQuery = `
|
||||
SELECT
|
||||
cl.table_name,
|
||||
cl.column_name,
|
||||
cl.column_label,
|
||||
cl.reference_table,
|
||||
cl.reference_column,
|
||||
tl.table_label as reference_table_label
|
||||
FROM column_labels cl
|
||||
LEFT JOIN table_labels tl ON cl.reference_table = tl.table_name
|
||||
WHERE cl.table_name = ANY($1)
|
||||
AND cl.reference_table = ANY($2)
|
||||
`;
|
||||
|
||||
const fkResult = await pool.query(fkQuery, [subTableNames, refTableNames]);
|
||||
|
||||
// 참조 정보 포함 객체 배열로 저장 (한글명 포함)
|
||||
const joinColumnRefsByTable: Record<string, Array<{ column: string; columnLabel: string; refTable: string; refTableLabel: string; refColumn: string }>> = {};
|
||||
|
||||
fkResult.rows.forEach((row: any) => {
|
||||
if (!joinColumnRefsByTable[row.table_name]) {
|
||||
joinColumnRefsByTable[row.table_name] = [];
|
||||
}
|
||||
// 중복 체크
|
||||
const exists = joinColumnRefsByTable[row.table_name].some(
|
||||
(ref) => ref.column === row.column_name && ref.refTable === row.reference_table
|
||||
);
|
||||
if (!exists) {
|
||||
joinColumnRefsByTable[row.table_name].push({
|
||||
column: row.column_name,
|
||||
columnLabel: row.column_label || row.column_name, // 컬럼 한글명 (없으면 영문명)
|
||||
refTable: row.reference_table,
|
||||
refTableLabel: row.reference_table_label || row.reference_table, // 참조 테이블 한글명 (없으면 영문명)
|
||||
refColumn: row.reference_column || 'id',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// subTables에 joinColumns (문자열 배열) 및 joinColumnRefs (참조 정보 배열) 추가
|
||||
Object.values(screenSubTables).forEach((screenData: any) => {
|
||||
screenData.subTables.forEach((subTable: any) => {
|
||||
const refs = joinColumnRefsByTable[subTable.tableName];
|
||||
if (refs) {
|
||||
(subTable as any).joinColumns = refs.map(r => r.column);
|
||||
(subTable as any).joinColumnRefs = refs;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
logger.info("rightPanel joinedTables FK 조회 완료", {
|
||||
lookupCount: uniqueLookups.length,
|
||||
resultCount: fkResult.rows.length,
|
||||
joinColumnsByTable
|
||||
});
|
||||
}
|
||||
|
||||
// 5. 모든 fieldMappings의 한글명을 column_labels에서 가져와서 적용
|
||||
// 모든 테이블/컬럼 조합을 수집
|
||||
|
|
|
|||
|
|
@ -1034,10 +1034,39 @@ screenSubTables[screenId].subTables.push({
|
|||
**결과:**
|
||||
| 화면 | customer_item_mapping 표시 |
|
||||
|------|----------------------------|
|
||||
| 1번 화면 포커스 | 필터 배지 O + FK 컬럼 보라색 |
|
||||
| 1번 화면 포커스 | 필터 배지 O + FK 컬럼 보라색 + **상단 정렬** |
|
||||
| 4번 화면 포커스 | 필터 배지 X, 조인만 표시 |
|
||||
| 그룹 선택 (포커스 없음) | 필터 배지 X, 테이블명만 표시 |
|
||||
|
||||
9. **필터 컬럼 상단 정렬**
|
||||
- 필터 컬럼도 파란색/주황색 컬럼처럼 상단에 정렬되어 표시
|
||||
- `potentialFilteredColumns`에 `filterSet` 포함
|
||||
- 정렬 순서: **조인 컬럼 → 필터 컬럼 → 사용 컬럼**
|
||||
- 보라색 강조로 필터링 관계 명확히 구분
|
||||
|
||||
**정렬 우선순위:**
|
||||
| 순서 | 컬럼 유형 | 색상 | 설명 |
|
||||
|------|----------|------|------|
|
||||
| 1 | 조인 컬럼 | 주황색 | FK 조인 관계 |
|
||||
| 2 | 필터 컬럼 | 보라색 | 마스터-디테일 필터링 |
|
||||
| 3 | 사용 컬럼 | 파란색 | 화면 필드 매핑 |
|
||||
|
||||
10. **방안 C 적용: 필터선 제거 + 보라색 테두리 애니메이션**
|
||||
- 필터 관계는 선 없이 뱃지 + 테이블 테두리로만 표시 (겹침 방지)
|
||||
- 필터링된 테이블에 **보라색 테두리 + 펄스 애니메이션** 적용
|
||||
- 조인선(주황)만 표시, 필터선(보라) 제거
|
||||
|
||||
**시각적 표현:**
|
||||
| 관계 유형 | 선 표시 | 테두리 | 배지 |
|
||||
|----------|---------|--------|------|
|
||||
| 조인 | ✅ 주황색 점선 | - | "조인" |
|
||||
| 필터 | ❌ 없음 | 보라색 펄스 | "필터 + 키값" |
|
||||
| 룩업 | ✅ 황색 점선 | - | "N곳 참조" |
|
||||
|
||||
**구현 상세:**
|
||||
- `ScreenRelationFlow.tsx`: `visualRelationType === 'filter'`인 경우 엣지 생성 건너뛰기
|
||||
- `ScreenNode.tsx`: `hasFilterRelation` 조건으로 보라색 테두리 + `animate-pulse` 클래스 적용
|
||||
|
||||
### 향후 개선 가능 사항
|
||||
|
||||
1. [ ] 범례(Legend) UI 추가 - 관계 유형별 색상 설명
|
||||
|
|
@ -1057,8 +1086,10 @@ screenSubTables[screenId].subTables.push({
|
|||
7. [x] 마스터-디테일 필터링 관계 표시 추가
|
||||
8. [x] FK 컬럼 보라색 강조 + 키값 정보 표시
|
||||
9. [x] 포커스 상태 기반 필터 표시 개선
|
||||
10. [ ] 범례 UI 추가 (선택사항)
|
||||
11. [ ] 엣지 라벨에 관계 유형 표시 (선택사항)
|
||||
10. [x] 필터 컬럼 상단 정렬 (조인 → 필터 → 사용 순서)
|
||||
11. [x] 방안 C 적용: 필터선 제거 + 보라색 테두리 애니메이션
|
||||
12. [ ] 범례 UI 추가 (선택사항)
|
||||
13. [ ] 엣지 라벨에 관계 유형 표시 (선택사항)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import {
|
||||
Monitor,
|
||||
|
|
@ -43,9 +43,12 @@ export interface FieldMappingDisplay {
|
|||
|
||||
// 참조 관계 정보 (다른 테이블에서 이 테이블을 참조하는 경우)
|
||||
export interface ReferenceInfo {
|
||||
fromTable: string; // 참조하는 테이블명
|
||||
fromColumn: string; // 참조하는 컬럼명
|
||||
fromTable: string; // 참조하는 테이블명 (영문)
|
||||
fromTableLabel?: string; // 참조하는 테이블 한글명
|
||||
fromColumn: string; // 참조하는 컬럼명 (영문)
|
||||
fromColumnLabel?: string; // 참조하는 컬럼 한글명
|
||||
toColumn: string; // 참조되는 컬럼명 (이 테이블의 컬럼)
|
||||
toColumnLabel?: string; // 참조되는 컬럼 한글명
|
||||
relationType: 'lookup' | 'join' | 'filter'; // 참조 유형
|
||||
}
|
||||
|
||||
|
|
@ -66,6 +69,12 @@ export interface TableNodeData {
|
|||
// 포커스 시 강조할 컬럼 정보
|
||||
highlightedColumns?: string[]; // 화면에서 사용하는 컬럼 (영문명)
|
||||
joinColumns?: string[]; // 조인에 사용되는 컬럼
|
||||
joinColumnRefs?: Array<{ // 조인 컬럼의 참조 정보
|
||||
column: string; // FK 컬럼명 (예: 'customer_id')
|
||||
refTable: string; // 참조 테이블 (예: 'customer_mng')
|
||||
refTableLabel?: string; // 참조 테이블 한글명 (예: '거래처 관리')
|
||||
refColumn: string; // 참조 컬럼 (예: 'customer_code')
|
||||
}>;
|
||||
filterColumns?: string[]; // 필터링에 사용되는 FK 컬럼 (마스터-디테일 관계)
|
||||
// 필드 매핑 정보 (조인 관계 표시용)
|
||||
fieldMappings?: FieldMappingDisplay[]; // 서브 테이블일 때 조인 관계 표시
|
||||
|
|
@ -431,13 +440,25 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
|
|||
|
||||
// ========== 테이블 노드 (하단) - 컬럼 목록 표시 (컴팩트) ==========
|
||||
export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
||||
const { label, subLabel, isMain, isFocused, isFaded, columns, highlightedColumns, joinColumns, filterColumns, fieldMappings, referencedBy } = data;
|
||||
const { label, subLabel, isMain, isFocused, isFaded, columns, highlightedColumns, joinColumns, joinColumnRefs, filterColumns, fieldMappings, referencedBy } = data;
|
||||
|
||||
// 강조할 컬럼 세트 (영문 컬럼명 기준)
|
||||
const highlightSet = new Set(highlightedColumns || []);
|
||||
const filterSet = new Set(filterColumns || []); // 필터링에 사용되는 FK 컬럼
|
||||
const joinSet = new Set(joinColumns || []);
|
||||
|
||||
// 조인 컬럼 참조 정보 맵 생성 (column → { refTable, refTableLabel, refColumn })
|
||||
const joinRefMap = new Map<string, { refTable: string; refTableLabel: string; refColumn: string }>();
|
||||
if (joinColumnRefs) {
|
||||
joinColumnRefs.forEach((ref) => {
|
||||
joinRefMap.set(ref.column, {
|
||||
refTable: ref.refTable,
|
||||
refTableLabel: ref.refTableLabel || ref.refTable, // 한글명 (없으면 영문명)
|
||||
refColumn: ref.refColumn
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 필드 매핑 맵 생성 (targetField → { sourceField, sourceDisplayName })
|
||||
// 서브 테이블에서 targetField가 어떤 메인 테이블 컬럼(sourceField)과 연결되는지
|
||||
const fieldMappingMap = new Map<string, { sourceField: string; sourceDisplayName: string }>();
|
||||
|
|
@ -451,39 +472,93 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
|||
});
|
||||
}
|
||||
|
||||
// 필터 소스 컬럼 세트 (메인 테이블에서 필터에 사용되는 컬럼)
|
||||
const filterSourceSet = new Set(
|
||||
referencedBy?.filter(r => r.relationType === 'filter').map(r => r.fromColumn) || []
|
||||
);
|
||||
|
||||
// 포커스 모드: 사용 컬럼만 필터링하여 표시
|
||||
// originalName (영문) 또는 name으로 매칭 시도
|
||||
// 필터 컬럼(filterSet) 및 필터 소스 컬럼(filterSourceSet)도 포함하여 보라색으로 표시
|
||||
const potentialFilteredColumns = columns?.filter(col => {
|
||||
const colOriginal = col.originalName || col.name;
|
||||
return highlightSet.has(colOriginal) || joinSet.has(colOriginal);
|
||||
return highlightSet.has(colOriginal) || joinSet.has(colOriginal) || filterSet.has(colOriginal) || filterSourceSet.has(colOriginal);
|
||||
}) || [];
|
||||
const hasActiveColumns = potentialFilteredColumns.length > 0;
|
||||
|
||||
// 정렬: 조인 컬럼 → 필터 컬럼/필터 소스 컬럼 → 사용 컬럼 순서
|
||||
const sortedFilteredColumns = [...potentialFilteredColumns].sort((a, b) => {
|
||||
const aOriginal = a.originalName || a.name;
|
||||
const bOriginal = b.originalName || b.name;
|
||||
|
||||
const aIsJoin = joinSet.has(aOriginal);
|
||||
const bIsJoin = joinSet.has(bOriginal);
|
||||
const aIsFilter = filterSet.has(aOriginal) || filterSourceSet.has(aOriginal);
|
||||
const bIsFilter = filterSet.has(bOriginal) || filterSourceSet.has(bOriginal);
|
||||
|
||||
// 조인 컬럼 우선
|
||||
if (aIsJoin && !bIsJoin) return -1;
|
||||
if (!aIsJoin && bIsJoin) return 1;
|
||||
// 필터 컬럼/필터 소스 다음
|
||||
if (aIsFilter && !bIsFilter) return -1;
|
||||
if (!aIsFilter && bIsFilter) return 1;
|
||||
// 나머지는 원래 순서 유지
|
||||
return 0;
|
||||
});
|
||||
|
||||
const hasActiveColumns = sortedFilteredColumns.length > 0;
|
||||
|
||||
// 필터 관계가 있는 테이블인지 확인 (마스터-디테일 필터링)
|
||||
// - hasFilterRelation: 디테일 테이블 (WHERE 조건 대상) - filterColumns에 FK 컬럼이 있음
|
||||
// - isFilterSource: 마스터 테이블 (필터 소스, WHERE 조건 제공) - 포커스된 화면의 메인 테이블이고 filterSourceSet에 컬럼이 있음
|
||||
// 디테일 테이블: filterColumns(filterSet)에 FK 컬럼이 있고, 포커스된 화면의 메인이 아님
|
||||
const hasFilterRelation = filterSet.size > 0 && !isFocused;
|
||||
// 마스터 테이블: 포커스된 화면의 메인 테이블(isFocused)이고 filterSourceSet에 컬럼이 있음
|
||||
const isFilterSource = isFocused && filterSourceSet.size > 0;
|
||||
|
||||
// 표시할 컬럼:
|
||||
// - 포커스 시 (활성 컬럼 있음): 필터된 컬럼만 표시
|
||||
// - 포커스 시 (활성 컬럼 있음): 정렬된 컬럼만 표시
|
||||
// - 비포커스 시: 최대 8개만 표시
|
||||
const MAX_DEFAULT_COLUMNS = 8;
|
||||
const allColumns = columns || [];
|
||||
const displayColumns = hasActiveColumns
|
||||
? potentialFilteredColumns
|
||||
? sortedFilteredColumns
|
||||
: allColumns.slice(0, MAX_DEFAULT_COLUMNS);
|
||||
const remainingCount = hasActiveColumns
|
||||
? 0
|
||||
: Math.max(0, allColumns.length - MAX_DEFAULT_COLUMNS);
|
||||
const totalCount = allColumns.length;
|
||||
|
||||
// 컬럼 수 기반 높이 계산 (DOM 측정 없이)
|
||||
// - 각 컬럼 행 높이: 약 22px (py-0.5 + text + gap-px)
|
||||
// - 컨테이너 패딩: p-1.5 = 12px (상하 합계)
|
||||
const COLUMN_ROW_HEIGHT = 22;
|
||||
const CONTAINER_PADDING = 12;
|
||||
const MAX_HEIGHT = 180;
|
||||
|
||||
const calculatedHeight = useMemo(() => {
|
||||
const rawHeight = CONTAINER_PADDING + (displayColumns.length * COLUMN_ROW_HEIGHT);
|
||||
return Math.min(rawHeight, MAX_HEIGHT);
|
||||
}, [displayColumns.length]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex w-[260px] flex-col overflow-hidden rounded-lg border bg-card shadow-md transition-all duration-300 ${
|
||||
isFocused
|
||||
? "border-2 border-emerald-500 ring-4 ring-emerald-500/30 shadow-xl"
|
||||
className={`group relative flex w-[260px] flex-col overflow-hidden rounded-lg border shadow-md ${
|
||||
// 필터 관련 테이블 (마스터 또는 디테일): 보라색
|
||||
(hasFilterRelation || isFilterSource)
|
||||
? "border-2 border-violet-500 ring-4 ring-violet-500/30 shadow-xl bg-violet-50"
|
||||
// 순수 포커스 (필터 관계 없음): 초록색
|
||||
: isFocused
|
||||
? "border-2 border-emerald-500 ring-4 ring-emerald-500/30 shadow-xl bg-card"
|
||||
// 흐리게 처리
|
||||
: isFaded
|
||||
? "border-gray-200 opacity-60"
|
||||
: "border-border hover:shadow-lg hover:ring-2 hover:ring-emerald-500/20"
|
||||
? "border-gray-200 opacity-60 bg-card"
|
||||
// 기본
|
||||
: "border-border hover:shadow-lg hover:ring-2 hover:ring-emerald-500/20 bg-card"
|
||||
}`}
|
||||
style={{
|
||||
filter: isFaded ? "grayscale(80%)" : "none",
|
||||
transition: "all 0.3s ease",
|
||||
// 색상/테두리/그림자만 transition (높이 제외)
|
||||
transition: "background-color 0.7s ease, border-color 0.7s ease, box-shadow 0.7s ease, filter 0.3s ease, opacity 0.3s ease",
|
||||
}}
|
||||
>
|
||||
{/* Handles */}
|
||||
|
|
@ -529,14 +604,21 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
|||
className="!h-2 !w-2 !border-2 !border-background !bg-orange-500 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
|
||||
{/* 헤더 (초록색, 컴팩트) */}
|
||||
<div className={`flex items-center gap-2 px-3 py-1.5 text-white transition-colors duration-300 ${
|
||||
isFaded ? "bg-gray-400" : isMain ? "bg-emerald-600" : "bg-slate-500"
|
||||
{/* 헤더 (필터 관계: 보라색, 필터 소스: 보라색, 메인: 초록색, 기본: 슬레이트) */}
|
||||
<div className={`flex items-center gap-2 px-3 py-1.5 text-white transition-colors duration-700 ease-in-out ${
|
||||
isFaded ? "bg-gray-400" : (hasFilterRelation || isFilterSource) ? "bg-violet-600" : isMain ? "bg-emerald-600" : "bg-slate-500"
|
||||
}`}>
|
||||
<Database className="h-3.5 w-3.5 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-[11px] font-semibold">{label}</div>
|
||||
{subLabel && <div className="truncate text-[9px] opacity-80">{subLabel}</div>}
|
||||
{/* 필터 관계에 따른 문구 변경 */}
|
||||
<div className="truncate text-[9px] opacity-80">
|
||||
{isFilterSource
|
||||
? "마스터 테이블 (필터 소스)"
|
||||
: hasFilterRelation
|
||||
? "디테일 테이블 (WHERE 조건)"
|
||||
: subLabel}
|
||||
</div>
|
||||
</div>
|
||||
{hasActiveColumns && (
|
||||
<span className="rounded-full bg-white/20 px-1.5 py-0.5 text-[8px] shrink-0">
|
||||
|
|
@ -565,7 +647,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
|||
)}
|
||||
{filterRefs.length > 0 && (
|
||||
<span className="text-violet-600 truncate">
|
||||
{filterRefs.map(r => `${r.fromTable}.${r.fromColumn || 'id'}`).join(', ')}
|
||||
{filterRefs.map(r => `${r.fromTableLabel || r.fromTable}.${r.fromColumnLabel || r.fromColumn || 'id'}`).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
{lookupRefs.length > 0 && (
|
||||
|
|
@ -580,29 +662,38 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
|||
);
|
||||
})()}
|
||||
|
||||
{/* 컬럼 목록 - 컴팩트하게 (스크롤 가능) */}
|
||||
<div className="p-1.5 transition-all duration-300 max-h-[150px] overflow-y-auto">
|
||||
{/* 컬럼 목록 - 컴팩트하게 (부드러운 높이 전환) */}
|
||||
<div
|
||||
className="p-1.5 overflow-hidden"
|
||||
style={{
|
||||
height: `${calculatedHeight}px`,
|
||||
transition: 'height 0.7s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
}}
|
||||
>
|
||||
{displayColumns.length > 0 ? (
|
||||
<div className="flex flex-col gap-px">
|
||||
<div className="flex flex-col gap-px transition-all duration-700 ease-in-out">
|
||||
{displayColumns.map((col, idx) => {
|
||||
const colOriginal = col.originalName || col.name;
|
||||
const isJoinColumn = joinSet.has(colOriginal);
|
||||
const isFilterColumn = filterSet.has(colOriginal); // 필터링 FK 컬럼
|
||||
const isFilterColumn = filterSet.has(colOriginal); // 서브 테이블의 필터링 FK 컬럼
|
||||
const isHighlighted = highlightSet.has(colOriginal);
|
||||
|
||||
// 필터링 참조 정보 (어떤 테이블의 어떤 컬럼에서 필터링되는지)
|
||||
// 필터링 참조 정보 (어떤 테이블의 어떤 컬럼에서 필터링되는지) - 서브 테이블용
|
||||
const filterRefInfo = referencedBy?.find(
|
||||
r => r.relationType === 'filter' && r.toColumn === colOriginal
|
||||
);
|
||||
|
||||
// 메인 테이블에서 필터 소스로 사용되는 컬럼인지 (fromColumn과 일치)
|
||||
const isFilterSourceColumn = filterSourceSet.has(colOriginal);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={col.name}
|
||||
className={`flex items-center gap-1 rounded px-1.5 py-0.5 transition-all duration-300 ${
|
||||
isJoinColumn
|
||||
? "bg-orange-100 border border-orange-300 shadow-sm"
|
||||
: isFilterColumn
|
||||
? "bg-violet-100 border border-violet-300 shadow-sm" // 필터 컬럼: 보라색
|
||||
: isFilterColumn || isFilterSourceColumn
|
||||
? "bg-violet-100 border border-violet-300 shadow-sm" // 필터 컬럼/필터 소스: 보라색
|
||||
: isHighlighted
|
||||
? "bg-blue-100 border border-blue-300 shadow-sm"
|
||||
: hasActiveColumns
|
||||
|
|
@ -610,21 +701,21 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
|||
: "bg-slate-50 hover:bg-slate-100"
|
||||
}`}
|
||||
style={{
|
||||
animation: hasActiveColumns ? `fadeIn 0.3s ease ${idx * 50}ms forwards` : undefined,
|
||||
animation: hasActiveColumns ? `fadeIn 0.5s ease-out ${idx * 80}ms forwards` : undefined,
|
||||
opacity: hasActiveColumns ? 0 : 1,
|
||||
}}
|
||||
>
|
||||
{/* PK/FK/조인/필터 아이콘 */}
|
||||
{isJoinColumn && <Link2 className="h-2.5 w-2.5 text-orange-500" />}
|
||||
{isFilterColumn && !isJoinColumn && <Link2 className="h-2.5 w-2.5 text-violet-500" />}
|
||||
{!isJoinColumn && !isFilterColumn && col.isPrimaryKey && <Key className="h-2.5 w-2.5 text-amber-500" />}
|
||||
{!isJoinColumn && !isFilterColumn && col.isForeignKey && !col.isPrimaryKey && <Link2 className="h-2.5 w-2.5 text-blue-500" />}
|
||||
{!isJoinColumn && !isFilterColumn && !col.isPrimaryKey && !col.isForeignKey && <div className="w-2.5" />}
|
||||
{(isFilterColumn || isFilterSourceColumn) && !isJoinColumn && <Link2 className="h-2.5 w-2.5 text-violet-500" />}
|
||||
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isPrimaryKey && <Key className="h-2.5 w-2.5 text-amber-500" />}
|
||||
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isForeignKey && !col.isPrimaryKey && <Link2 className="h-2.5 w-2.5 text-blue-500" />}
|
||||
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && !col.isPrimaryKey && !col.isForeignKey && <div className="w-2.5" />}
|
||||
|
||||
{/* 컬럼명 */}
|
||||
<span className={`flex-1 truncate font-mono text-[9px] font-medium ${
|
||||
isJoinColumn ? "text-orange-700"
|
||||
: isFilterColumn ? "text-violet-700"
|
||||
: (isFilterColumn || isFilterSourceColumn) ? "text-violet-700"
|
||||
: isHighlighted ? "text-blue-700"
|
||||
: "text-slate-700"
|
||||
}`}>
|
||||
|
|
@ -634,8 +725,14 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
|||
{/* 역할 태그 + 참조 관계 표시 */}
|
||||
{isJoinColumn && (
|
||||
<>
|
||||
{/* 참조 관계 표시: ← 한글 컬럼명 (또는 영문) */}
|
||||
{fieldMappingMap.has(colOriginal) && (
|
||||
{/* 조인 참조 테이블 표시 (joinColumnRefs에서) */}
|
||||
{joinRefMap.has(colOriginal) && (
|
||||
<span className="rounded bg-orange-100 px-1 text-[7px] text-orange-600">
|
||||
← {joinRefMap.get(colOriginal)?.refTableLabel}
|
||||
</span>
|
||||
)}
|
||||
{/* 필드 매핑 참조 표시 (fieldMappingMap에서, joinRefMap에 없는 경우) */}
|
||||
{!joinRefMap.has(colOriginal) && fieldMappingMap.has(colOriginal) && (
|
||||
<span className="rounded bg-orange-100 px-1 text-[7px] text-orange-600">
|
||||
← {fieldMappingMap.get(colOriginal)?.sourceDisplayName}
|
||||
</span>
|
||||
|
|
@ -643,12 +740,19 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
|||
<span className="rounded bg-orange-200 px-1 text-[7px] text-orange-700">조인</span>
|
||||
</>
|
||||
)}
|
||||
{isFilterColumn && !isJoinColumn && filterRefInfo && (
|
||||
<span className="rounded bg-violet-200 px-1 text-[7px] text-violet-700">
|
||||
← {filterRefInfo.fromTable}.{filterRefInfo.fromColumn || 'id'}
|
||||
</span>
|
||||
{isFilterColumn && !isJoinColumn && (
|
||||
<span className="rounded bg-violet-200 px-1 text-[7px] text-violet-700">필터</span>
|
||||
)}
|
||||
{isHighlighted && !isJoinColumn && !isFilterColumn && (
|
||||
{/* 메인 테이블에서 필터 소스로 사용되는 컬럼: "필터" + "사용" 둘 다 표시 */}
|
||||
{isFilterSourceColumn && !isJoinColumn && !isFilterColumn && (
|
||||
<>
|
||||
<span className="rounded bg-violet-200 px-1 text-[7px] text-violet-700">필터</span>
|
||||
{isHighlighted && (
|
||||
<span className="rounded bg-blue-200 px-1 text-[7px] text-blue-700">사용</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isHighlighted && !isJoinColumn && !isFilterColumn && !isFilterSourceColumn && (
|
||||
<span className="rounded bg-blue-200 px-1 text-[7px] text-blue-700">사용</span>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -668,6 +668,12 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
|
||||
// 관계 유형 추론 및 색상 결정
|
||||
const visualRelationType = inferVisualRelationType(subTable as SubTableInfo);
|
||||
|
||||
// 방안 C: 필터 관계는 선 없이 뱃지로만 표시 (겹침 방지)
|
||||
if (visualRelationType === 'filter') {
|
||||
return; // 필터선 생성 건너뛰기
|
||||
}
|
||||
|
||||
const relationColor = RELATION_COLORS[visualRelationType];
|
||||
|
||||
// 화면별로 고유한 엣지 ID
|
||||
|
|
@ -1172,23 +1178,79 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
let focusedFilterColumns: string[] = [];
|
||||
let focusedReferencedBy: ReferenceInfo[] = [];
|
||||
|
||||
// 조인 컬럼 참조 정보 수집
|
||||
let focusedJoinColumnRefs: Array<{ column: string; refTable: string; refColumn: string }> = [];
|
||||
|
||||
if (focusedScreenId !== null && focusedSubTablesData) {
|
||||
// 포커스된 화면에서 이 테이블이 rightPanelRelation의 서브테이블인 경우
|
||||
focusedSubTablesData.subTables.forEach((subTable) => {
|
||||
if (subTable.tableName === tableName && subTable.relationType === 'rightPanelRelation') {
|
||||
// FK 컬럼 추출
|
||||
// FK 컬럼 추출 (필터링 기준)
|
||||
if (subTable.foreignKey) {
|
||||
focusedFilterColumns.push(subTable.foreignKey);
|
||||
}
|
||||
// 참조 정보 생성
|
||||
// 조인 컬럼 추가 (rightPanel.columns에서 외부 테이블 참조하는 FK)
|
||||
// 예: customer_mng.customer_name 표시를 위해 customer_id 사용
|
||||
// 조인 컬럼은 주황색으로 표시되어야 하므로 joinColumns에 추가
|
||||
if (subTable.joinColumns && Array.isArray(subTable.joinColumns)) {
|
||||
subTable.joinColumns.forEach((col) => {
|
||||
if (!joinColumns.includes(col)) {
|
||||
joinColumns.push(col);
|
||||
}
|
||||
});
|
||||
}
|
||||
// 조인 컬럼 참조 정보 추가
|
||||
if (subTable.joinColumnRefs && Array.isArray(subTable.joinColumnRefs)) {
|
||||
focusedJoinColumnRefs = [...focusedJoinColumnRefs, ...subTable.joinColumnRefs];
|
||||
}
|
||||
// 참조 정보 생성 (한글명 포함) - 서브 테이블용
|
||||
const mainTableCols = tableColumns[focusedSubTablesData.mainTable] || [];
|
||||
const fromColInfo = mainTableCols.find(c => c.columnName === (subTable.leftColumn || 'id'));
|
||||
const fromColumnLabel = fromColInfo?.displayName || subTable.leftColumn || 'id';
|
||||
|
||||
const subTableCols = tableColumns[subTable.tableName] || [];
|
||||
const toColInfo = subTableCols.find(c => c.columnName === subTable.foreignKey);
|
||||
const toColumnLabel = toColInfo?.displayName || subTable.foreignKey || '';
|
||||
|
||||
focusedReferencedBy.push({
|
||||
fromTable: focusedSubTablesData.mainTable,
|
||||
fromTableLabel: focusedSubTablesData.mainTable,
|
||||
fromColumn: subTable.leftColumn || 'id',
|
||||
fromColumnLabel: fromColumnLabel,
|
||||
toColumn: subTable.foreignKey || '',
|
||||
toColumnLabel: toColumnLabel,
|
||||
relationType: 'filter',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 메인 테이블인 경우: 이 테이블이 필터 소스로 사용되는 정보 추가
|
||||
if (isFocusedTable) {
|
||||
focusedSubTablesData.subTables.forEach((subTable) => {
|
||||
if (subTable.relationType === 'rightPanelRelation' && subTable.leftColumn) {
|
||||
// 메인 테이블의 컬럼 한글명 조회
|
||||
const mainTableCols = tableColumns[focusedSubTablesData.mainTable] || [];
|
||||
const fromColInfo = mainTableCols.find(c => c.columnName === subTable.leftColumn);
|
||||
const fromColumnLabel = fromColInfo?.displayName || subTable.leftColumn || 'id';
|
||||
|
||||
// 서브 테이블의 컬럼 한글명 조회
|
||||
const subTableCols = tableColumns[subTable.tableName] || [];
|
||||
const toColInfo = subTableCols.find(c => c.columnName === subTable.foreignKey);
|
||||
const toColumnLabel = toColInfo?.displayName || subTable.foreignKey || '';
|
||||
|
||||
// 메인 테이블 입장: "내 컬럼이 서브 테이블의 필터 소스로 사용됨"
|
||||
focusedReferencedBy.push({
|
||||
fromTable: focusedSubTablesData.mainTable,
|
||||
fromTableLabel: focusedSubTablesData.mainTable,
|
||||
fromColumn: subTable.leftColumn,
|
||||
fromColumnLabel: fromColumnLabel,
|
||||
toColumn: subTable.foreignKey || '',
|
||||
toColumnLabel: toColumnLabel,
|
||||
relationType: 'filter',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -1200,6 +1262,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
isFaded: focusedScreenId !== null && !isActiveTable,
|
||||
highlightedColumns: isActiveTable ? highlightedColumns : [],
|
||||
joinColumns: isActiveTable ? joinColumns : [],
|
||||
joinColumnRefs: focusedJoinColumnRefs.length > 0 ? focusedJoinColumnRefs : undefined, // 조인 컬럼 참조 정보
|
||||
filterColumns: focusedFilterColumns, // 포커스 상태에서만 표시
|
||||
referencedBy: focusedReferencedBy.length > 0 ? focusedReferencedBy : undefined, // 포커스 상태에서만 표시
|
||||
fieldMappings: isFocusedTable ? mainTableFieldMappings : (isRelatedTable ? relatedTableFieldMappings : []),
|
||||
|
|
@ -1304,7 +1367,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
|
||||
return node;
|
||||
});
|
||||
}, [nodes, selectedGroup, focusedScreenId, screenSubTableMap, subTablesDataMap, screenUsedColumnsMap, screenTableMap]);
|
||||
}, [nodes, selectedGroup, focusedScreenId, screenSubTableMap, subTablesDataMap, screenUsedColumnsMap, screenTableMap, tableColumns]);
|
||||
|
||||
// 포커스에 따른 엣지 스타일링 (그룹 모드 & 개별 화면 모드)
|
||||
const styledEdges = React.useMemo(() => {
|
||||
|
|
@ -1405,6 +1468,12 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
|
||||
// 관계 유형 추론 및 색상 결정
|
||||
const visualRelationType = inferVisualRelationType(subTable as SubTableInfo);
|
||||
|
||||
// 방안 C: 필터 관계는 선 없이 뱃지로만 표시 (겹침 방지)
|
||||
if (visualRelationType === 'filter') {
|
||||
return; // 필터선 생성 건너뛰기
|
||||
}
|
||||
|
||||
const relationColor = RELATION_COLORS[visualRelationType];
|
||||
|
||||
joinEdges.push({
|
||||
|
|
|
|||
|
|
@ -410,6 +410,16 @@ export interface SubTableInfo {
|
|||
originalRelationType?: 'join' | 'detail'; // 원본 relation.type
|
||||
foreignKey?: string; // 디테일 테이블의 FK 컬럼
|
||||
leftColumn?: string; // 마스터 테이블의 선택 기준 컬럼
|
||||
// rightPanel.columns에서 외부 테이블 참조 정보
|
||||
joinedTables?: string[]; // 참조하는 외부 테이블들 (예: ['customer_mng'])
|
||||
joinColumns?: string[]; // 외부 테이블과 조인하는 FK 컬럼들 (예: ['customer_id'])
|
||||
joinColumnRefs?: Array<{ // FK 컬럼 참조 정보 (어떤 테이블.컬럼에서 오는지)
|
||||
column: string; // FK 컬럼명 (예: 'customer_id')
|
||||
columnLabel: string; // FK 컬럼 한글명 (예: '거래처 ID')
|
||||
refTable: string; // 참조 테이블 (예: 'customer_mng')
|
||||
refTableLabel: string; // 참조 테이블 한글명 (예: '거래처 관리')
|
||||
refColumn: string; // 참조 컬럼 (예: 'customer_code')
|
||||
}>;
|
||||
}
|
||||
|
||||
// 시각적 관계 유형 (시각화에서 사용)
|
||||
|
|
|
|||
Loading…
Reference in New Issue