From c3a43179e35d6e2bc3cfdc075c0cee7ec8081f19 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 15:15:44 +0900 Subject: [PATCH] refactor: update color schemes and improve component styling - Changed color schemes for various screen types and roles to align with the new design guidelines, enhancing visual consistency across the application. - Updated background colors for components based on their types, such as changing 'bg-slate-400' to 'bg-muted-foreground' and adjusting other color mappings for better clarity. - Improved the styling of the ScreenNode and V2PropertiesPanel components to ensure a more cohesive user experience. - Enhanced the DynamicComponentRenderer to support dynamic loading of column metadata with cache invalidation for better performance. These changes aim to refine the UI and improve the overall aesthetic of the application, ensuring a more modern and user-friendly interface. --- frontend/components/screen/ScreenNode.tsx | 174 +++++++++--------- .../screen/panels/V2PropertiesPanel.tsx | 29 ++- .../v2/config-panels/V2FieldConfigPanel.tsx | 12 +- .../lib/registry/DynamicComponentRenderer.tsx | 155 ++++++++++++---- ...creen-149-field-type-verification-guide.md | 165 +++++++++++++++++ 5 files changed, 404 insertions(+), 131 deletions(-) create mode 100644 test-output/screen-149-field-type-verification-guide.md 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"))` 호출 후 재검증