jskim-node #418
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
// ─── 채번 관련 상태 (테이블 기반) ───
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"))` 호출 후 재검증
|
||||
Loading…
Reference in New Issue