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:
{/* 컴포넌트 수 */}
-
@@ -376,7 +376,7 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
{/* 컴포넌트 수 */}
-
@@ -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) => (
))}
{/* 컴포넌트 수 */}
-
@@ -429,7 +429,7 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
액션 화면
{/* 컴포넌트 수 */}
-
@@ -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"))` 호출 후 재검증