refactor: update color schemes and improve component styling

- Changed color schemes for various screen types and roles to align with the new design guidelines, enhancing visual consistency across the application.
- Updated background colors for components based on their types, such as changing 'bg-slate-400' to 'bg-muted-foreground' and adjusting other color mappings for better clarity.
- Improved the styling of the ScreenNode and V2PropertiesPanel components to ensure a more cohesive user experience.
- Enhanced the DynamicComponentRenderer to support dynamic loading of column metadata with cache invalidation for better performance.

These changes aim to refine the UI and improve the overall aesthetic of the application, ensuring a more modern and user-friendly interface.
This commit is contained in:
DDD1542
2026-03-15 15:15:44 +09:00
parent b8f5d4be4c
commit c3a43179e3
5 changed files with 404 additions and 131 deletions

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;