jskim-node #418

Merged
kjs merged 52 commits from jskim-node into main 2026-03-16 14:53:15 +09:00
5 changed files with 404 additions and 131 deletions
Showing only changes of commit c3a43179e3 - Show all commits

View File

@ -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) => (
<div key={idx} className="flex items-center gap-1 rounded bg-slate-50 px-1.5 py-0.5">
<div key={idx} className="flex items-center gap-1 rounded bg-muted/50 px-1.5 py-0.5">
<div className={`h-1.5 w-1.5 rounded-full ${
item.componentKind === 'table-list' ? 'bg-violet-400' :
item.componentKind?.includes('select') ? 'bg-amber-400' :
'bg-slate-400'
item.componentKind === 'table-list' ? 'bg-primary' :
item.componentKind?.includes('select') ? 'bg-warning' :
'bg-muted-foreground'
}`} />
<span className="flex-1 truncate text-[9px] text-slate-600">{item.label}</span>
<span className="text-[8px] text-slate-400">{item.componentKind?.split('-')[0] || 'field'}</span>
<span className="flex-1 truncate text-[9px] text-muted-foreground">{item.label}</span>
<span className="text-[8px] text-muted-foreground/70">{item.componentKind?.split('-')[0] || 'field'}</span>
</div>
)) || (
<div className="text-center text-[9px] text-slate-400 py-2"> </div>
<div className="text-center text-[9px] text-muted-foreground py-2"> </div>
)}
</div>
</div>
@ -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:
<div className="flex h-full flex-col gap-2 rounded-lg border border-border bg-muted/30 p-3">
{/* 상단 툴바 */}
<div className="flex items-center gap-2">
<div className="h-4 w-16 rounded bg-pink-400/80 shadow-sm" />
<div className="h-4 w-16 rounded bg-destructive/80 shadow-sm" />
<div className="flex-1" />
<div className="h-4 w-8 rounded bg-primary shadow-sm" />
<div className="h-4 w-8 rounded bg-primary shadow-sm" />
<div className="h-4 w-8 rounded bg-rose-500 shadow-sm" />
<div className="h-4 w-8 rounded bg-destructive shadow-sm" />
</div>
{/* 테이블 헤더 */}
<div className="flex gap-1 rounded-t-md bg-violet-500 px-2 py-2 shadow-sm">
<div className="flex gap-1 rounded-t-md bg-primary px-2 py-2 shadow-sm">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-2.5 flex-1 rounded bg-white/40" />
<div key={i} className="h-2.5 flex-1 rounded bg-primary-foreground/40" />
))}
</div>
{/* 테이블 행들 */}
@ -352,7 +352,7 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
<div className="h-2.5 w-4 rounded bg-muted-foreground/40" />
</div>
{/* 컴포넌트 수 */}
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
<div className="absolute bottom-2 right-2 rounded-md bg-foreground/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
{totalComponents}
</div>
</div>
@ -376,7 +376,7 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
<div className="h-5 w-14 rounded-md bg-primary shadow-sm" />
</div>
{/* 컴포넌트 수 */}
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
<div className="absolute bottom-2 right-2 rounded-md bg-foreground/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
{totalComponents}
</div>
</div>
@ -388,13 +388,13 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
return (
<div className="grid h-full grid-cols-2 gap-2 rounded-lg border border-border bg-muted/30 p-3">
{/* 카드/차트들 */}
<div className="rounded-lg bg-emerald-100 p-2 shadow-sm">
<div className="mb-2 h-2.5 w-10 rounded bg-emerald-400" />
<div className="h-10 rounded-md bg-emerald-300/80" />
<div className="rounded-lg bg-success/20 p-2 shadow-sm">
<div className="mb-2 h-2.5 w-10 rounded bg-success" />
<div className="h-10 rounded-md bg-success/60" />
</div>
<div className="rounded-lg bg-amber-100 p-2 shadow-sm">
<div className="mb-2 h-2.5 w-10 rounded bg-amber-400" />
<div className="h-10 rounded-md bg-amber-300/80" />
<div className="rounded-lg bg-warning/20 p-2 shadow-sm">
<div className="mb-2 h-2.5 w-10 rounded bg-warning" />
<div className="h-10 rounded-md bg-warning/60" />
</div>
<div className="col-span-2 rounded-lg bg-primary/10 p-2 shadow-sm">
<div className="mb-2 h-2.5 w-12 rounded bg-primary/70" />
@ -402,14 +402,14 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
{[...Array(10)].map((_, i) => (
<div
key={i}
className="flex-1 rounded-t bg-primary/70/80"
className="flex-1 rounded-t bg-primary/70"
style={{ height: `${25 + Math.random() * 75}%` }}
/>
))}
</div>
</div>
{/* 컴포넌트 수 */}
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
<div className="absolute bottom-2 right-2 rounded-md bg-foreground/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
{totalComponents}
</div>
</div>
@ -429,7 +429,7 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
</div>
<div className="text-xs font-medium text-muted-foreground"> </div>
{/* 컴포넌트 수 */}
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
<div className="absolute bottom-2 right-2 rounded-md bg-foreground/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
{totalComponents}
</div>
</div>
@ -438,8 +438,8 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
// 기본 (알 수 없는 타입)
return (
<div className="flex h-full flex-col items-center justify-center gap-3 rounded-lg border border-slate-200 bg-muted/30 text-slate-400">
<div className="rounded-full bg-slate-100 p-4">
<div className="flex h-full flex-col items-center justify-center gap-3 rounded-lg border border-border bg-muted/30 text-muted-foreground">
<div className="rounded-full bg-muted p-4">
{getScreenTypeIcon(screenType)}
</div>
<span className="text-sm font-medium">{totalComponents} </span>
@ -575,20 +575,20 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
return (
<div
className={`group relative flex w-[260px] flex-col overflow-visible rounded-xl border shadow-md ${
// 1. 필터 테이블 (마스터-디테일의 디테일 테이블): 항상 보라색 테두리
// 1. 필터 테이블 (마스터-디테일의 디테일 테이블): 항상 primary 테두리
isFilterTable
? "border-2 border-violet-500 ring-2 ring-violet-500/20 shadow-lg bg-violet-50/50"
// 2. 필터 관련 테이블 (마스터 또는 디테일) 포커스 시: 진한 보라색
? "border-2 border-primary ring-2 ring-primary/20 shadow-lg bg-primary/5"
// 2. 필터 관련 테이블 (마스터 또는 디테일) 포커스 시: primary 강조
: (hasFilterRelation || isFilterSource)
? "border-2 border-violet-500 ring-4 ring-violet-500/30 shadow-xl bg-violet-50"
// 3. 순수 포커스 (필터 관계 없음): 초록색
? "border-2 border-primary ring-4 ring-primary/30 shadow-xl bg-primary/5"
// 3. 순수 포커스 (필터 관계 없음): primary
: isFocused
? "border-2 border-emerald-500 ring-4 ring-emerald-500/30 shadow-xl bg-card"
? "border-2 border-primary ring-4 ring-primary/30 shadow-xl bg-card"
// 4. 흐리게 처리
: isFaded
? "border-border opacity-60 bg-card"
// 5. 기본
: "border-border hover:shadow-lg hover:ring-2 hover:ring-emerald-500/20 bg-card"
: "border-border hover:shadow-lg hover:ring-2 hover:ring-primary/20 bg-card"
}`}
style={{
filter: isFaded ? "grayscale(80%)" : "none",
@ -602,7 +602,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ 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: 메인테이블 → 메인테이블 연결용 (위쪽으로 나가는 선) */}
<Handle
@ -624,25 +624,25 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ 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"
/>
<Handle
type="target"
position={Position.Left}
id="left"
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"
/>
<Handle
type="source"
position={Position.Right}
id="right"
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"
/>
<Handle
type="source"
position={Position.Bottom}
id="bottom"
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: 메인테이블 ← 메인테이블 연결용 (아래에서 들어오는 선) */}
<Handle
@ -650,12 +650,12 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ 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) */}
<div className={`flex items-center gap-2 px-3 py-1.5 text-white rounded-t-xl transition-colors duration-700 ease-in-out ${
isFaded ? "bg-muted-foreground" : (hasFilterRelation || isFilterSource) ? "bg-violet-600" : isMain ? "bg-emerald-600" : "bg-slate-500"
isFaded ? "bg-muted-foreground" : (hasFilterRelation || isFilterSource) ? "bg-primary" : isMain ? "bg-primary" : "bg-muted-foreground"
}`}>
<Database className="h-3.5 w-3.5 shrink-0" />
<div className="flex-1 min-w-0">
@ -679,7 +679,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
{/* 컬럼 목록 - 컴팩트하게 (부드러운 높이 전환 + 스크롤) */}
{/* 뱃지도 이 영역 안에 포함되어 높이 계산에 반영됨 */}
<div
className="p-1.5 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent"
className="p-1.5 overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent"
style={{
height: `${debouncedHeight}px`,
maxHeight: `${MAX_HEIGHT}px`,
@ -699,7 +699,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
{/* 필터 뱃지 */}
{filterRefs.length > 0 && (
<span
className="flex items-center gap-1 rounded-full bg-violet-600 px-2 py-px text-white font-semibold shadow-sm"
className="flex items-center gap-1 rounded-full bg-primary px-2 py-px text-white font-semibold shadow-sm"
title={`마스터-디테일 필터링\n${filterRefs.map(r => `${r.fromTable}.${r.fromColumn || 'id'}${r.toColumn}`).join('\n')}`}
>
<Link2 className="h-3 w-3" />
@ -707,14 +707,14 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
</span>
)}
{filterRefs.length > 0 && (
<span className="text-violet-700 font-medium truncate">
<span className="text-primary font-medium truncate">
{filterRefs.map(r => `${r.fromTableLabel || r.fromTable}.${r.fromColumnLabel || r.fromColumn || 'id'}`).join(', ')}
</span>
)}
{/* 참조 뱃지 */}
{lookupRefs.length > 0 && (
<span
className="flex items-center gap-1 rounded-full bg-amber-500 px-2 py-px text-white font-semibold shadow-sm"
className="flex items-center gap-1 rounded-full bg-warning px-2 py-px text-white font-semibold shadow-sm"
title={`코드 참조 (lookup)\n${lookupRefs.map(r => `${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 && <Link2 className="h-2.5 w-2.5 text-amber-500" />}
{(isFilterColumn || isFilterSourceColumn) && !isJoinColumn && <Link2 className="h-2.5 w-2.5 text-violet-500" />}
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isPrimaryKey && <Key className="h-2.5 w-2.5 text-amber-500" />}
{isJoinColumn && <Link2 className="h-2.5 w-2.5 text-warning" />}
{(isFilterColumn || isFilterSourceColumn) && !isJoinColumn && <Link2 className="h-2.5 w-2.5 text-primary" />}
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isPrimaryKey && <Key className="h-2.5 w-2.5 text-warning" />}
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isForeignKey && !col.isPrimaryKey && <Link2 className="h-2.5 w-2.5 text-primary" />}
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && !col.isPrimaryKey && !col.isForeignKey && <div className="w-2.5" />}
{/* 컬럼명 */}
<span className={`flex-1 truncate font-mono text-[9px] font-medium ${
isJoinColumn ? "text-orange-700"
: (isFilterColumn || isFilterSourceColumn) ? "text-violet-700"
isJoinColumn ? "text-warning"
: (isFilterColumn || isFilterSourceColumn) ? "text-primary"
: isHighlighted ? "text-primary"
: "text-slate-700"
: "text-foreground"
}`}>
{col.name}
</span>
@ -781,51 +781,51 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
<>
{/* 조인 참조 테이블 표시 (joinColumnRefs에서) */}
{joinRefMap.has(colOriginal) && (
<span className="rounded bg-amber-100 px-1 text-[7px] text-amber-600">
<span className="rounded bg-warning/20 px-1 text-[7px] text-warning">
{joinRefMap.get(colOriginal)?.refTableLabel}
</span>
)}
{/* 필드 매핑 참조 표시 (fieldMappingMap에서, joinRefMap에 없는 경우) */}
{!joinRefMap.has(colOriginal) && fieldMappingMap.has(colOriginal) && (
<span className="rounded bg-amber-100 px-1 text-[7px] text-amber-600">
<span className="rounded bg-warning/20 px-1 text-[7px] text-warning">
{fieldMappingMap.get(colOriginal)?.sourceDisplayName}
</span>
)}
<span className="rounded bg-orange-200 px-1 text-[7px] text-orange-700"></span>
<span className="rounded bg-warning/20 px-1 text-[7px] text-warning"></span>
</>
)}
{isFilterColumn && !isJoinColumn && (
<span className="rounded bg-violet-200 px-1 text-[7px] text-violet-700"></span>
<span className="rounded bg-primary/20 px-1 text-[7px] text-primary"></span>
)}
{/* 메인 테이블에서 필터 소스로 사용되는 컬럼: "필터" + "사용" 둘 다 표시 */}
{isFilterSourceColumn && !isJoinColumn && !isFilterColumn && (
<>
<span className="rounded bg-violet-200 px-1 text-[7px] text-violet-700"></span>
<span className="rounded bg-primary/20 px-1 text-[7px] text-primary"></span>
{isHighlighted && (
<span className="rounded bg-blue-200 px-1 text-[7px] text-primary"></span>
<span className="rounded bg-primary/20 px-1 text-[7px] text-primary"></span>
)}
</>
)}
{isHighlighted && !isJoinColumn && !isFilterColumn && !isFilterSourceColumn && (
<span className="rounded bg-blue-200 px-1 text-[7px] text-primary"></span>
<span className="rounded bg-primary/20 px-1 text-[7px] text-primary"></span>
)}
{/* 타입 */}
<span className="text-[8px] text-slate-400">{col.type}</span>
<span className="text-[8px] text-muted-foreground">{col.type}</span>
</div>
);
})}
{/* 더 많은 컬럼이 있을 경우 표시 */}
{remainingCount > 0 && (
<div className="text-center text-[8px] text-slate-400 py-0.5">
<div className="text-center text-[8px] text-muted-foreground py-0.5">
+ {remainingCount}
</div>
)}
</div>
) : (
<div className="flex flex-col items-center justify-center py-2 text-muted-foreground">
<Database className="h-4 w-4 text-slate-300" />
<span className="mt-0.5 text-[8px] text-slate-400"> </span>
<Database className="h-4 w-4 text-muted-foreground" />
<span className="mt-0.5 text-[8px] text-muted-foreground"> </span>
</div>
)}
</div>
@ -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 (
<div className="rounded-lg border-2 border-purple-300 bg-card p-3 shadow-lg">
<Handle type="target" position={Position.Left} id="left" className="!h-3 !w-3 !bg-purple-500" />
<Handle type="source" position={Position.Right} id="right" className="!h-3 !w-3 !bg-purple-500" />
<div className="flex items-center gap-2 text-purple-600">
<div className="rounded-lg border-2 border-primary/40 bg-card p-3 shadow-lg">
<Handle type="target" position={Position.Left} id="left" className="!h-3 !w-3 !bg-primary" />
<Handle type="source" position={Position.Right} id="right" className="!h-3 !w-3 !bg-primary" />
<div className="flex items-center gap-2 text-primary">
<Table2 className="h-4 w-4" />
<span className="text-sm font-semibold">{data.label || "Aggregate"}</span>
</div>

View File

@ -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<V2PropertiesPanelProps> = ({
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<string, any> = {};
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 (

View File

@ -78,7 +78,15 @@ interface CategoryValueOption {
}
// ─── 하위 호환: 기존 config에서 fieldType 추론 ───
function resolveFieldType(config: Record<string, any>, componentType?: string): FieldType {
function resolveFieldType(config: Record<string, any>, 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<V2FieldConfigPanelProps> = ({
inputType: metaInputType,
componentType,
}) => {
const fieldType = resolveFieldType(config, componentType);
const fieldType = resolveFieldType(config, componentType, metaInputType);
const isSelectGroup = ["select", "category", "entity"].includes(fieldType);
// ─── 채번 관련 상태 (테이블 기반) ───

View File

@ -13,13 +13,34 @@ import { apiClient } from "@/lib/api/client";
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
// 컬럼 메타데이터 캐시 (테이블명 → 컬럼 설정 맵)
const columnMetaCache: Record<string, Record<string, any>> = {};
export const columnMetaCache: Record<string, Record<string, any>> = {};
const columnMetaLoading: Record<string, Promise<void>> = {};
const columnMetaTimestamp: Record<string, number> = {};
const CACHE_TTL_MS = 5000;
async function loadColumnMeta(tableName: string): Promise<void> {
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<void> {
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<void> {
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<DynamicComponentRendererProps> =
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<DynamicComponentRendererProps> =
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<DynamicComponentRendererProps> =
// (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<DynamicComponentRendererProps> =
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<DynamicComponentRendererProps> =
} 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;

View File

@ -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"))` 호출 후 재검증