diff --git a/frontend/components/screen/ScreenNode.tsx b/frontend/components/screen/ScreenNode.tsx index ff5ade46..70930e21 100644 --- a/frontend/components/screen/ScreenNode.tsx +++ b/frontend/components/screen/ScreenNode.tsx @@ -109,14 +109,14 @@ const getScreenTypeIcon = (screenType?: string) => { // 화면 타입별 색상 (헤더) const getScreenTypeColor = (screenType?: string, isMain?: boolean) => { - if (!isMain) return "bg-slate-400"; + if (!isMain) return "bg-muted-foreground"; switch (screenType) { case "grid": - return "bg-violet-500"; + return "bg-primary"; case "dashboard": - return "bg-amber-500"; + return "bg-warning"; case "action": - return "bg-rose-500"; + return "bg-destructive"; default: return "bg-primary"; } @@ -124,25 +124,25 @@ const getScreenTypeColor = (screenType?: string, isMain?: boolean) => { // 화면 역할(screenRole)에 따른 색상 const getScreenRoleColor = (screenRole?: string) => { - if (!screenRole) return "bg-slate-400"; + if (!screenRole) return "bg-muted-foreground"; // 역할명에 포함된 키워드로 색상 결정 const role = screenRole.toLowerCase(); if (role.includes("그리드") || role.includes("grid") || role.includes("메인") || role.includes("main") || role.includes("list")) { - return "bg-violet-500"; // 보라색 - 메인 그리드 + return "bg-primary"; // 메인 그리드 } if (role.includes("등록") || role.includes("폼") || role.includes("form") || role.includes("register") || role.includes("input")) { - return "bg-primary"; // 파란색 - 등록 폼 + return "bg-primary"; // 등록 폼 } if (role.includes("액션") || role.includes("action") || role.includes("이벤트") || role.includes("event") || role.includes("클릭")) { - return "bg-rose-500"; // 빨간색 - 액션/이벤트 + return "bg-destructive"; // 액션/이벤트 } if (role.includes("상세") || role.includes("detail") || role.includes("popup") || role.includes("팝업")) { - return "bg-amber-500"; // 주황색 - 상세/팝업 + return "bg-warning"; // 상세/팝업 } - return "bg-slate-400"; // 기본 회색 + return "bg-muted-foreground"; // 기본 회색 }; // 화면 타입별 라벨 @@ -246,17 +246,17 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => { ?.filter(item => item.label && !item.componentKind?.includes('button')) ?.slice(0, 6) ?.map((item, idx) => ( -
+
- {item.label} - {item.componentKind?.split('-')[0] || 'field'} + {item.label} + {item.componentKind?.split('-')[0] || 'field'}
)) || ( -
필드 정보 없음
+
필드 정보 없음
)}
@@ -280,33 +280,33 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => { const getComponentColor = (componentKind: string) => { // 테이블/그리드 관련 if (componentKind === "table-list" || componentKind === "data-grid") { - return "bg-violet-200 border-violet-400"; + return "bg-primary/20 border-primary/40"; } // 검색 필터 if (componentKind === "table-search-widget" || componentKind === "search-filter") { - return "bg-pink-200 border-pink-400"; + return "bg-destructive/20 border-destructive/40"; } // 버튼 관련 if (componentKind?.includes("button")) { - return "bg-blue-300 border-primary"; + return "bg-primary/30 border-primary"; } // 입력 필드 if (componentKind?.includes("input") || componentKind?.includes("text")) { - return "bg-slate-200 border-slate-400"; + return "bg-muted border-border"; } // 셀렉트/드롭다운 if (componentKind?.includes("select") || componentKind?.includes("dropdown")) { - return "bg-amber-200 border-amber-400"; + return "bg-warning/20 border-warning/40"; } // 차트 if (componentKind?.includes("chart")) { - return "bg-emerald-200 border-emerald-400"; + return "bg-success/20 border-success/40"; } // 커스텀 위젯 if (componentKind === "custom") { - return "bg-pink-200 border-pink-400"; + return "bg-destructive/20 border-destructive/40"; } - return "bg-slate-100 border-slate-300"; + return "bg-muted/50 border-border"; }; // ========== 화면 미리보기 컴포넌트 - 화면 타입별 간단한 일러스트 ========== @@ -322,16 +322,16 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
{/* 상단 툴바 */}
-
+
-
+
{/* 테이블 헤더 */} -
+
{[...Array(5)].map((_, i) => ( -
+
))}
{/* 테이블 행들 */} @@ -352,7 +352,7 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
{/* 컴포넌트 수 */} -
+
{totalComponents}개
@@ -376,7 +376,7 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
{/* 컴포넌트 수 */} -
+
{totalComponents}개
@@ -388,13 +388,13 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType: return (
{/* 카드/차트들 */} -
-
-
+
+
+
-
-
-
+
+
+
@@ -402,14 +402,14 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType: {[...Array(10)].map((_, i) => (
))}
{/* 컴포넌트 수 */} -
+
{totalComponents}개
@@ -429,7 +429,7 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
액션 화면
{/* 컴포넌트 수 */} -
+
{totalComponents}개
@@ -438,8 +438,8 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType: // 기본 (알 수 없는 타입) return ( -
-
+
+
{getScreenTypeIcon(screenType)}
{totalComponents}개 컴포넌트 @@ -575,20 +575,20 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { return (
= ({ data }) => { className="absolute -left-1.5 top-1 bottom-1 w-0.5 z-20 rounded-full transition-all duration-500 ease-out" title={hasSaveTarget ? "저장 대상 테이블" : undefined} style={{ - background: 'linear-gradient(to bottom, transparent 0%, #f472b6 15%, #f472b6 85%, transparent 100%)', + background: `linear-gradient(to bottom, transparent 0%, hsl(var(--destructive)) 15%, hsl(var(--destructive)) 85%, transparent 100%)`, opacity: hasSaveTarget ? 1 : 0, transform: hasSaveTarget ? 'scaleY(1)' : 'scaleY(0)', transformOrigin: 'top', @@ -616,7 +616,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { type="target" position={Position.Top} id="top" - className="!h-2 !w-2 !border-2 !border-background !bg-emerald-500 opacity-0 transition-opacity group-hover:opacity-100" + className="!h-2 !w-2 !border-2 !border-background !bg-primary opacity-0 transition-opacity group-hover:opacity-100" /> {/* top source: 메인테이블 → 메인테이블 연결용 (위쪽으로 나가는 선) */} = ({ data }) => { position={Position.Top} id="top_source" style={{ top: -4 }} - className="!h-2 !w-2 !border-2 !border-background !bg-amber-500 opacity-0 transition-opacity group-hover:opacity-100" + className="!h-2 !w-2 !border-2 !border-background !bg-warning opacity-0 transition-opacity group-hover:opacity-100" /> {/* bottom target: 메인테이블 ← 메인테이블 연결용 (아래에서 들어오는 선) */} = ({ data }) => { position={Position.Bottom} id="bottom_target" style={{ bottom: -4 }} - className="!h-2 !w-2 !border-2 !border-background !bg-amber-500 opacity-0 transition-opacity group-hover:opacity-100" + className="!h-2 !w-2 !border-2 !border-background !bg-warning opacity-0 transition-opacity group-hover:opacity-100" /> - {/* 헤더 (필터 관계: 보라색, 필터 소스: 보라색, 메인: 초록색, 기본: 슬레이트) */} + {/* 헤더 (필터 관계: primary, 필터 소스: primary, 메인: primary, 기본: muted-foreground) */}
@@ -679,7 +679,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { {/* 컬럼 목록 - 컴팩트하게 (부드러운 높이 전환 + 스크롤) */} {/* 뱃지도 이 영역 안에 포함되어 높이 계산에 반영됨 */}
= ({ data }) => { {/* 필터 뱃지 */} {filterRefs.length > 0 && ( `${r.fromTable}.${r.fromColumn || 'id'} → ${r.toColumn}`).join('\n')}`} > @@ -707,14 +707,14 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { )} {filterRefs.length > 0 && ( - + {filterRefs.map(r => `${r.fromTableLabel || r.fromTable}.${r.fromColumnLabel || r.fromColumn || 'id'}`).join(', ')} )} {/* 참조 뱃지 */} {lookupRefs.length > 0 && ( `${r.fromTable} → ${r.toColumn}`).join('\n')}`} > {lookupRefs.length}곳 참조 @@ -745,14 +745,14 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { key={col.name} className={`flex items-center gap-1 rounded px-1.5 py-0.5 transition-all duration-300 ${ isJoinColumn - ? "bg-amber-100 border border-orange-300 shadow-sm" + ? "bg-warning/10 border border-warning/30 shadow-sm" : isFilterColumn || isFilterSourceColumn - ? "bg-violet-100 border border-violet-300 shadow-sm" // 필터 컬럼/필터 소스: 보라색 + ? "bg-primary/10 border border-primary/30 shadow-sm" // 필터 컬럼/필터 소스 : isHighlighted ? "bg-primary/10 border border-primary/40 shadow-sm" : hasActiveColumns - ? "bg-slate-100" - : "bg-slate-50 hover:bg-slate-100" + ? "bg-muted" + : "bg-muted/50 hover:bg-muted" }`} style={{ animation: hasActiveColumns ? `fadeIn 0.5s ease-out ${idx * 80}ms forwards` : undefined, @@ -760,18 +760,18 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { }} > {/* PK/FK/조인/필터 아이콘 */} - {isJoinColumn && } - {(isFilterColumn || isFilterSourceColumn) && !isJoinColumn && } - {!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isPrimaryKey && } + {isJoinColumn && } + {(isFilterColumn || isFilterSourceColumn) && !isJoinColumn && } + {!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isPrimaryKey && } {!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isForeignKey && !col.isPrimaryKey && } {!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && !col.isPrimaryKey && !col.isForeignKey &&
} {/* 컬럼명 */} {col.name} @@ -781,51 +781,51 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { <> {/* 조인 참조 테이블 표시 (joinColumnRefs에서) */} {joinRefMap.has(colOriginal) && ( - + ← {joinRefMap.get(colOriginal)?.refTableLabel} )} {/* 필드 매핑 참조 표시 (fieldMappingMap에서, joinRefMap에 없는 경우) */} {!joinRefMap.has(colOriginal) && fieldMappingMap.has(colOriginal) && ( - + ← {fieldMappingMap.get(colOriginal)?.sourceDisplayName} )} - 조인 + 조인 )} {isFilterColumn && !isJoinColumn && ( - 필터 + 필터 )} {/* 메인 테이블에서 필터 소스로 사용되는 컬럼: "필터" + "사용" 둘 다 표시 */} {isFilterSourceColumn && !isJoinColumn && !isFilterColumn && ( <> - 필터 + 필터 {isHighlighted && ( - 사용 + 사용 )} )} {isHighlighted && !isJoinColumn && !isFilterColumn && !isFilterSourceColumn && ( - 사용 + 사용 )} {/* 타입 */} - {col.type} + {col.type}
); })} {/* 더 많은 컬럼이 있을 경우 표시 */} {remainingCount > 0 && ( -
+
+ {remainingCount}개 더
)}
) : (
- - 컬럼 정보 없음 + + 컬럼 정보 없음
)}
@@ -861,10 +861,10 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { export const LegacyScreenNode = ScreenNode; export const AggregateNode: React.FC<{ data: any }> = ({ data }) => { return ( -
- - -
+
+ + +
{data.label || "Aggregate"}
diff --git a/frontend/components/screen/panels/V2PropertiesPanel.tsx b/frontend/components/screen/panels/V2PropertiesPanel.tsx index cf148e6e..ef739b27 100644 --- a/frontend/components/screen/panels/V2PropertiesPanel.tsx +++ b/frontend/components/screen/panels/V2PropertiesPanel.tsx @@ -47,6 +47,7 @@ import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent // ComponentRegistry import (동적 ConfigPanel 가져오기용) import { ComponentRegistry } from "@/lib/registry/ComponentRegistry"; +import { columnMetaCache } from "@/lib/registry/DynamicComponentRenderer"; import { DynamicComponentConfigPanel, hasComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel"; import StyleEditor from "../StyleEditor"; import { Slider } from "@/components/ui/slider"; @@ -207,28 +208,36 @@ export const V2PropertiesPanel: React.FC = ({ onUpdateProperty(selectedComponent.id, "componentConfig", { ...currentConfig, ...newConfig }); }; - // 컬럼의 inputType 가져오기 (entity 타입인지 확인용) - const inputType = currentConfig.inputType || currentConfig.webType || (selectedComponent as any).inputType; - // 현재 화면의 테이블명 가져오기 const currentTableName = tables?.[0]?.tableName; + // DB input_type 가져오기 (columnMetaCache에서 최신값 조회) + const colName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName; + const tblName = selectedComponent.tableName || currentTable?.tableName || currentTableName; + const dbMeta = colName && tblName && !colName.includes(".") ? columnMetaCache[tblName]?.[colName] : undefined; + const dbInputType = dbMeta ? (() => { const raw = dbMeta.input_type || dbMeta.inputType; return raw === "direct" || raw === "auto" ? undefined : raw; })() : undefined; + const inputType = dbInputType || currentConfig.inputType || currentConfig.webType || (selectedComponent as any).inputType; + // 컴포넌트별 추가 props const extraProps: Record = {}; - if (componentId === "v2-select") { + const resolvedTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName; + const resolvedColumnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName; + + if (componentId === "v2-input" || componentId === "v2-select") { extraProps.inputType = inputType; - extraProps.tableName = selectedComponent.tableName || currentTable?.tableName || currentTableName; - extraProps.columnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName; + extraProps.tableName = resolvedTableName; + extraProps.columnName = resolvedColumnName; + extraProps.screenTableName = resolvedTableName; + } + if (componentId === "v2-input") { + extraProps.allComponents = allComponents; } if (componentId === "v2-list") { extraProps.currentTableName = currentTableName; } if (componentId === "v2-bom-item-editor" || componentId === "v2-bom-tree") { extraProps.currentTableName = currentTableName; - extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName; - } - if (componentId === "v2-input") { - extraProps.allComponents = allComponents; + extraProps.screenTableName = resolvedTableName; } return ( diff --git a/frontend/components/v2/config-panels/V2FieldConfigPanel.tsx b/frontend/components/v2/config-panels/V2FieldConfigPanel.tsx index 7dfe8834..2f2b8011 100644 --- a/frontend/components/v2/config-panels/V2FieldConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2FieldConfigPanel.tsx @@ -78,7 +78,15 @@ interface CategoryValueOption { } // ─── 하위 호환: 기존 config에서 fieldType 추론 ─── -function resolveFieldType(config: Record, componentType?: string): FieldType { +function resolveFieldType(config: Record, componentType?: string, metaInputType?: string): FieldType { + // DB input_type이 전달된 경우 (데이터타입관리에서 변경 시) 우선 적용 + if (metaInputType && metaInputType !== "direct" && metaInputType !== "auto") { + const dbType = metaInputType as FieldType; + if (["text", "number", "textarea", "numbering", "select", "category", "entity"].includes(dbType)) { + return dbType; + } + } + if (config.fieldType) return config.fieldType as FieldType; // v2-select 계열 @@ -207,7 +215,7 @@ export const V2FieldConfigPanel: React.FC = ({ inputType: metaInputType, componentType, }) => { - const fieldType = resolveFieldType(config, componentType); + const fieldType = resolveFieldType(config, componentType, metaInputType); const isSelectGroup = ["select", "category", "entity"].includes(fieldType); // ─── 채번 관련 상태 (테이블 기반) ─── diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 873b7408..859d136f 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -13,13 +13,34 @@ import { apiClient } from "@/lib/api/client"; import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor"; // 컬럼 메타데이터 캐시 (테이블명 → 컬럼 설정 맵) -const columnMetaCache: Record> = {}; +export const columnMetaCache: Record> = {}; const columnMetaLoading: Record> = {}; +const columnMetaTimestamp: Record = {}; +const CACHE_TTL_MS = 5000; -async function loadColumnMeta(tableName: string): Promise { - if (columnMetaCache[tableName]) return; +export function invalidateColumnMetaCache(tableName?: string): void { + if (tableName) { + delete columnMetaCache[tableName]; + delete columnMetaLoading[tableName]; + delete columnMetaTimestamp[tableName]; + } else { + for (const key of Object.keys(columnMetaCache)) delete columnMetaCache[key]; + for (const key of Object.keys(columnMetaLoading)) delete columnMetaLoading[key]; + for (const key of Object.keys(columnMetaTimestamp)) delete columnMetaTimestamp[key]; + } +} + +async function loadColumnMeta(tableName: string, forceReload = false): Promise { + const now = Date.now(); + const isStale = columnMetaTimestamp[tableName] && (now - columnMetaTimestamp[tableName] > CACHE_TTL_MS); + + if (!forceReload && !isStale && columnMetaCache[tableName]) return; + + if (forceReload || isStale) { + delete columnMetaCache[tableName]; + delete columnMetaLoading[tableName]; + } - // 이미 로딩 중이면 해당 Promise를 대기 (race condition 방지) if (columnMetaLoading[tableName]) { await columnMetaLoading[tableName]; return; @@ -36,6 +57,7 @@ async function loadColumnMeta(tableName: string): Promise { if (name) map[name] = col; } columnMetaCache[tableName] = map; + columnMetaTimestamp[tableName] = Date.now(); } catch (e) { console.error(`[columnMeta] ${tableName} 로드 실패:`, e); columnMetaCache[tableName] = {}; @@ -56,43 +78,59 @@ export function isColumnRequiredByMeta(tableName?: string, columnName?: string): return nullable === "NO" || nullable === "N"; } -// table_type_columns 기반 componentConfig 병합 (기존 설정이 없을 때만 DB 메타데이터로 보완) +// table_type_columns 기반 componentConfig 병합 (DB input_type 우선 적용) function mergeColumnMeta(tableName: string | undefined, columnName: string | undefined, componentConfig: any): any { if (!tableName || !columnName) return componentConfig; const meta = columnMetaCache[tableName]?.[columnName]; if (!meta) return componentConfig; - const inputType = meta.input_type || meta.inputType; - if (!inputType) return componentConfig; - - // 이미 source가 올바르게 설정된 경우 건드리지 않음 - const existingSource = componentConfig?.source; - if (existingSource && existingSource !== "static" && existingSource !== "distinct" && existingSource !== "select") { - return componentConfig; - } + const rawType = meta.input_type || meta.inputType; + const dbInputType = rawType === "direct" || rawType === "auto" ? undefined : rawType; + if (!dbInputType) return componentConfig; const merged = { ...componentConfig }; + const savedFieldType = merged.fieldType; - // source가 미설정/기본값일 때만 DB 메타데이터로 보완 - if (inputType === "entity") { + // savedFieldType이 있고 DB와 같으면 변경 불필요 + if (savedFieldType && savedFieldType === dbInputType) return merged; + // savedFieldType이 있고 DB와 다르면 — 사용자가 V2FieldConfigPanel에서 설정한 값 존중 + if (savedFieldType) return merged; + + // savedFieldType이 없으면: DB input_type 기준으로 동기화 + // 기존 overrides의 source/inputType이 DB와 불일치하면 덮어씀 + if (dbInputType === "entity") { const refTable = meta.reference_table || meta.referenceTable; const refColumn = meta.reference_column || meta.referenceColumn; const displayCol = meta.display_column || meta.displayColumn; - if (refTable && !merged.entityTable) { + if (refTable) { merged.source = "entity"; merged.entityTable = refTable; merged.entityValueColumn = refColumn || "id"; merged.entityLabelColumn = displayCol || "name"; + merged.fieldType = "entity"; + merged.inputType = "entity"; } - } else if (inputType === "category" && !existingSource) { + } else if (dbInputType === "category") { merged.source = "category"; - } else if (inputType === "select" && !existingSource) { + merged.fieldType = "category"; + merged.inputType = "category"; + } else if (dbInputType === "select") { + if (!merged.source || merged.source === "category" || merged.source === "entity") { + merged.source = "static"; + } const detail = typeof meta.detail_settings === "string" ? JSON.parse(meta.detail_settings || "{}") : meta.detail_settings || {}; if (detail.options && !merged.options?.length) { merged.options = detail.options; } + merged.fieldType = "select"; + merged.inputType = "select"; + } else { + // text, number, textarea 등 input 계열 — 카테고리 잔류 속성 제거 + merged.fieldType = dbInputType; + merged.inputType = dbInputType; + delete merged.source; } return merged; @@ -266,15 +304,27 @@ export const DynamicComponentRenderer: React.FC = children, ...props }) => { - // 컬럼 메타데이터 로드 트리거 (테이블명이 있으면 비동기 로드) + // 컬럼 메타데이터 로드 트리거 (TTL 기반 자동 갱신) const screenTableName = props.tableName || (component as any).tableName; - const [, forceUpdate] = React.useState(0); + const [metaVersion, forceUpdate] = React.useState(0); React.useEffect(() => { if (screenTableName) { loadColumnMeta(screenTableName).then(() => forceUpdate((v) => v + 1)); } }, [screenTableName]); + // table-columns-refresh 이벤트 수신 시 캐시 무효화 후 최신 메타 다시 로드 + React.useEffect(() => { + const handler = () => { + if (screenTableName) { + invalidateColumnMetaCache(screenTableName); + loadColumnMeta(screenTableName, true).then(() => forceUpdate((v) => v + 1)); + } + }; + window.addEventListener("table-columns-refresh", handler); + return () => window.removeEventListener("table-columns-refresh", handler); + }, [screenTableName]); + // 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용 // 🆕 V2 레이아웃의 경우 url에서 컴포넌트 타입 추출 (예: "@/lib/registry/components/v2-input" → "v2-input") const extractTypeFromUrl = (url: string | undefined): string | undefined => { @@ -306,12 +356,40 @@ export const DynamicComponentRenderer: React.FC = const mappedComponentType = mapToV2ComponentType(rawComponentType); - // fieldType 기반 동적 컴포넌트 전환 (통합 필드 설정 패널에서 설정된 값) + // fieldType 기반 동적 컴포넌트 전환 (사용자 설정 > DB input_type > 기본값) const componentType = (() => { - const ft = (component as any).componentConfig?.fieldType; - if (!ft) return mappedComponentType; - if (["text", "number", "password", "textarea", "slider", "color", "numbering"].includes(ft)) return "v2-input"; - if (["select", "category", "entity"].includes(ft)) return "v2-select"; + const configFieldType = (component as any).componentConfig?.fieldType; + const fieldName = (component as any).columnName || (component as any).componentConfig?.fieldKey || (component as any).componentConfig?.columnName; + const isEntityJoin = fieldName?.includes("."); + const baseCol = isEntityJoin ? undefined : fieldName; + const rawDbType = baseCol && screenTableName + ? (columnMetaCache[screenTableName]?.[baseCol]?.input_type || columnMetaCache[screenTableName]?.[baseCol]?.inputType) + : undefined; + const dbInputType = rawDbType === "direct" || rawDbType === "auto" ? undefined : rawDbType; + + // 디버그 (division, unit 필드만) - 문제 확인 후 제거 + if (baseCol && (baseCol === "division" || baseCol === "unit")) { + const result = configFieldType + ? (["text","number","password","textarea","slider","color","numbering"].includes(configFieldType) ? "v2-input" : "v2-select") + : dbInputType + ? (["text","number","password","textarea","slider","color","numbering"].includes(dbInputType) ? "v2-input" : "v2-select") + : mappedComponentType; + const skipCat = dbInputType && !["category", "entity", "select"].includes(dbInputType); + console.log(`[DCR] ${baseCol}: dbInputType=${dbInputType}, RESULT=${result}, skipCat=${skipCat}`); + } + + // 사용자가 V2FieldConfigPanel에서 명시적으로 설정한 fieldType 최우선 + if (configFieldType) { + if (["text", "number", "password", "textarea", "slider", "color", "numbering"].includes(configFieldType)) return "v2-input"; + if (["select", "category", "entity"].includes(configFieldType)) return "v2-select"; + } + + // componentConfig.fieldType 없으면 DB input_type 참조 (초기 로드 시) + if (dbInputType) { + if (["text", "number", "password", "textarea", "slider", "color", "numbering"].includes(dbInputType)) return "v2-input"; + if (["select", "category", "entity"].includes(dbInputType)) return "v2-select"; + } + return mappedComponentType; })(); @@ -376,15 +454,24 @@ export const DynamicComponentRenderer: React.FC = // (v2-input, v2-select, v2-repeat-container 등 모두 동일하게 처리) // 🎯 카테고리 타입 우선 처리 (inputType 또는 webType 확인) - const inputType = (component as any).componentConfig?.inputType || (component as any).inputType; + // DB input_type이 "text" 등 비-카테고리로 변경된 경우 이 분기를 건너뜀 + const savedInputType = (component as any).componentConfig?.inputType || (component as any).inputType; const webType = (component as any).componentConfig?.webType; const tableName = (component as any).tableName; const columnName = (component as any).columnName; - // 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만 - // ⚠️ 단, 다음 경우는 V2SelectRenderer로 직접 처리 (고급 모드 지원): - // 1. componentType이 "select-basic" 또는 "v2-select"인 경우 - // 2. config.mode가 dropdown이 아닌 경우 (radio, check, tagbox 등) + // DB input_type 확인: 데이터타입관리에서 변경한 최신 값이 레이아웃 저장값보다 우선 + const dbMetaForField = columnName && screenTableName && !columnName.includes(".") + ? columnMetaCache[screenTableName]?.[columnName] + : undefined; + const dbFieldInputType = dbMetaForField + ? (() => { const raw = dbMetaForField.input_type || dbMetaForField.inputType; return raw === "direct" || raw === "auto" ? undefined : raw; })() + : undefined; + // DB에서 확인된 타입이 있으면 그걸 사용, 없으면 저장된 값 사용 + const inputType = dbFieldInputType || savedInputType; + // webType도 DB 값으로 대체 (레이아웃에 webType: "category" 하드코딩되어 있을 수 있음) + const effectiveWebType = dbFieldInputType || webType; + const componentMode = (component as any).componentConfig?.mode || (component as any).config?.mode; const isMultipleSelect = (component as any).componentConfig?.multiple; const nonDropdownModes = ["radio", "check", "checkbox", "tag", "tagbox", "toggle", "swap", "combobox"]; @@ -392,7 +479,11 @@ export const DynamicComponentRenderer: React.FC = const shouldUseV2Select = componentType === "select-basic" || componentType === "v2-select" || isNonDropdownMode || isMultipleSelect; - if ((inputType === "category" || webType === "category") && tableName && columnName && shouldUseV2Select) { + // DB input_type이 비-카테고리(text 등)로 확인된 경우, 레이아웃에 category가 남아있어도 카테고리 분기 강제 스킵 + // dbFieldInputType이 있으면(캐시 로드됨) 그 값으로 판단, 없으면 기존 로직 유지 + const isDbConfirmedNonCategory = dbFieldInputType && !["category", "entity", "select"].includes(dbFieldInputType); + + if (!isDbConfirmedNonCategory && (inputType === "category" || effectiveWebType === "category") && tableName && columnName && shouldUseV2Select) { // V2SelectRenderer로 직접 렌더링 (카테고리 + 고급 모드) try { const { V2SelectRenderer } = require("@/lib/registry/components/v2-select/V2SelectRenderer"); @@ -491,7 +582,7 @@ export const DynamicComponentRenderer: React.FC = } catch (error) { console.error("❌ V2SelectRenderer 로드 실패:", error); } - } else if ((inputType === "category" || webType === "category") && tableName && columnName) { + } else if (!isDbConfirmedNonCategory && (inputType === "category" || effectiveWebType === "category") && tableName && columnName) { try { const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent"); const fieldName = columnName || component.id; diff --git a/test-output/screen-149-field-type-verification-guide.md b/test-output/screen-149-field-type-verification-guide.md new file mode 100644 index 00000000..a18bfb11 --- /dev/null +++ b/test-output/screen-149-field-type-verification-guide.md @@ -0,0 +1,165 @@ +# Screen 149 필드 타입 검증 가이드 + +## 배경 +- **화면 149**: 품목정보 (item_info 테이블) 폼 +- **division 컬럼**: DB에서 `input_type = 'text'`로 변경했으나 화면에는 여전히 SELECT 드롭다운으로 표시 +- **unit 컬럼**: `input_type = 'category'` → SELECT 드롭다운으로 표시되어야 함 + +## DB 현황 (vexplor-dev 조회 결과) + +| column_name | company_code | input_type | +|-------------|--------------|------------| +| division | * | category | +| division | COMPANY_7 | **text** | +| division | COMPANY_8, 9, 10, 18, 19, 20, 21 | category | +| unit | * | text | +| unit | COMPANY_18, 19, 20, 21, 7, 8, 9 | **category** | + +**주의:** `wace` 사용자는 `company_code = '*'` (최고 관리자)입니다. +- division: company * → **category** (text 아님) +- unit: company * → **text** (category 아님) + +**회사별로 다릅니다.** 예: COMPANY_7의 division은 text, unit은 category. + +--- + +## 수동 검증 절차 + +### 1. 로그인 +- URL: `http://localhost:9771/login` +- User ID: `wace` +- Password: `wace0909!!` +- 회사: "탑씰" (해당 회사 코드 확인 필요) + +### 2. 화면 149 접속 +- URL: `http://localhost:9771/screens/149` +- 페이지 로드 대기 + +### 3. 필드 확인 + +#### 구분 (division) +- **예상 (DB 기준):** + - company *: SELECT (category) + - COMPANY_7: TEXT INPUT (text) +- **실제:** TEXT INPUT 또는 SELECT 중 어느 쪽인지 확인 + +#### 단위 (unit) +- **예상 (DB 기준):** + - company *: TEXT INPUT (text) + - COMPANY_18~21, 7~9: SELECT (category) +- **실제:** TEXT INPUT 또는 SELECT 중 어느 쪽인지 확인 + +### 4. 스크린샷 +- 구분, 단위 필드가 함께 보이도록 캡처 + +--- + +## 코드 흐름 (input_type → 렌더링) + +### 1. 컬럼 메타 로드 +``` +DynamicComponentRenderer + → loadColumnMeta(screenTableName) + → GET /api/table-management/tables/item_info/columns?size=1000 + → columnMetaCache[tableName][columnName] = { inputType, ... } +``` + +### 2. 렌더 타입 결정 (357~369행) +```javascript +const dbInputType = columnMetaCache[screenTableName]?.[baseCol]?.inputType; +const ft = dbInputType || componentConfig?.fieldType; + +if (["text", "number", ...].includes(ft)) return "v2-input"; // 텍스트 입력 +if (["select", "category", "entity"].includes(ft)) return "v2-select"; // 드롭다운 +``` + +### 3. mergeColumnMeta (81~130행) +- DB `input_type`이 화면 저장값보다 우선 +- `needsSync`이면 DB 값으로 덮어씀 + +--- + +## 캐시 관련 + +### 1. 프론트엔드 (DynamicComponentRenderer) +- `columnMetaCache`: TTL 5초 +- `table-columns-refresh` 이벤트 시 즉시 무효화 및 재로드 + +### 2. 백엔드 (tableManagementService) +- 컬럼 목록: 5분 TTL +- `updateColumnInputType` 호출 시 해당 테이블 캐시 삭제 + +### 3. 캐시 무효화가 필요한 경우 +- 데이터 타입 관리에서 변경 후 화면이 갱신되지 않을 때 +- **대응:** 페이지 새로고침 또는 `?_t=timestamp`로 API 재요청 + +--- + +## 가능한 원인 + +### 1. 회사 코드 불일치 +- 로그인한 사용자 회사와 DB의 `company_code`가 다를 수 있음 +- `wace`는 `company_code = '*'` → division은 category, unit은 text + +### 2. 화면 레이아웃에 저장된 값 +- `componentConfig.fieldType`이 있으면 DB보다 우선될 수 있음 +- 코드상으로는 `dbInputType`이 우선이므로, DB가 제대로 로드되면 덮어씀 + +### 3. 캐시 +- 백엔드 5분, 프론트 5초 +- 데이터 타입 변경 후 곧바로 화면을 열면 이전 캐시가 사용될 수 있음 + +### 4. API 응답 구조 +- `columnMetaCache`에 넣을 때 `col.column_name || col.columnName` 사용 +- `mergeColumnMeta`는 `meta.input_type || meta.inputType` 사용 +- 백엔드는 `inputType`(camelCase) 반환 → `columnMetaCache`에 `inputType` 유지 + +--- + +## 디버깅용 Console 스크립트 + +화면 149 로드 후 브라우저 Console에서 실행: + +```javascript +// 1. columnMetaCache 조회 (DynamicComponentRenderer 내부) +// React DevTools로 DynamicComponentRenderer 선택 후 +// 또는 전역에 노출해 둔 경우: +const meta = window.__COLUMN_META_CACHE__?.item_info; +if (meta) { + console.log("division:", meta.division?.inputType || meta.division?.input_type); + console.log("unit:", meta.unit?.inputType || meta.unit?.input_type); +} + +// 2. API 직접 호출 +fetch("/api/table-management/tables/item_info/columns?size=1000", { + credentials: "include" +}) + .then(r => r.json()) + .then(d => { + const cols = d.data?.columns || d.columns || []; + const div = cols.find(c => (c.columnName || c.column_name) === "division"); + const unit = cols.find(c => (c.columnName || c.column_name) === "unit"); + console.log("API division:", div?.inputType || div?.input_type); + console.log("API unit:", unit?.inputType || unit?.input_type); + }); +``` + +--- + +## 권장 사항 + +1. **회사 코드 확인** + - 로그인한 사용자의 `company_code` 확인 + - `division`/`unit`을 text/category로 바꾼 회사가 맞는지 확인 + +2. **캐시 우회** + - 데이터 타입 변경 후 페이지 새로고침 + - 또는 5초 이상 대기 후 다시 접속 + +3. **데이터 타입 관리에서 변경 시** + - 저장 후 `table-columns-refresh` 이벤트 발생 여부 확인 + - 화면 디자이너의 V2FieldConfigPanel에서 변경 시에는 이벤트가 발생함 + +4. **테이블 관리 UI에서 변경 시** + - `table-columns-refresh` 이벤트가 발생하는지 확인 + - 없으면 해당 화면에서 수동으로 `window.dispatchEvent(new CustomEvent("table-columns-refresh"))` 호출 후 재검증