feat: 레이어 시스템 추가 및 관리 기능 구현

- InteractiveScreenViewer 컴포넌트에 레이어 시스템을 도입하여, 레이어의 활성화 및 조건부 표시 로직을 추가하였습니다.
- ScreenDesigner 컴포넌트에서 레이어 상태 관리 및 레이어 정보 저장 기능을 구현하였습니다.
- 레이어 정의 및 조건부 표시 설정을 위한 새로운 타입과 스키마를 추가하여, 레이어 기반의 UI 구성 요소를 보다 유연하게 관리할 수 있도록 하였습니다.
- 레이어별 컴포넌트 렌더링 로직을 추가하여, 모달 및 드로어 형태의 레이어를 효과적으로 처리할 수 있도록 개선하였습니다.
- 전반적으로 레이어 시스템을 통해 사용자 경험을 향상시키고, UI 구성의 유연성을 높였습니다.
This commit is contained in:
kjs 2026-02-06 09:51:29 +09:00
parent e31bb970a2
commit 4e2209bd5d
8 changed files with 1336 additions and 144 deletions

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState, useCallback } from "react"; import React, { useState, useCallback, useEffect, useMemo } from "react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -16,7 +16,7 @@ import { useAuth } from "@/hooks/useAuth";
import { uploadFilesAndCreateData } from "@/lib/api/file"; import { uploadFilesAndCreateData } from "@/lib/api/file";
import { toast } from "sonner"; import { toast } from "sonner";
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown"; import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
import { CascadingDropdownConfig } from "@/types/screen-management"; import { CascadingDropdownConfig, LayerDefinition } from "@/types/screen-management";
import { import {
ComponentData, ComponentData,
WidgetComponent, WidgetComponent,
@ -164,6 +164,8 @@ interface InteractiveScreenViewerProps {
enableAutoSave?: boolean; enableAutoSave?: boolean;
showToastMessages?: boolean; showToastMessages?: boolean;
}; };
// 🆕 레이어 시스템 지원
layers?: LayerDefinition[];
} }
export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = ({ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = ({
@ -178,6 +180,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
tableColumns = [], tableColumns = [],
showValidationPanel = false, showValidationPanel = false,
validationOptions = {}, validationOptions = {},
layers = [], // 🆕 레이어 목록
}) => { }) => {
// component가 없으면 빈 div 반환 // component가 없으면 빈 div 반환
if (!component) { if (!component) {
@ -206,9 +209,71 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
// 팝업 전용 formData 상태 // 팝업 전용 formData 상태
const [popupFormData, setPopupFormData] = useState<Record<string, any>>({}); const [popupFormData, setPopupFormData] = useState<Record<string, any>>({});
// 🆕 레이어 상태 관리 (런타임용)
const [activeLayerIds, setActiveLayerIds] = useState<string[]>([]);
// 🆕 초기 레이어 설정 (visible인 레이어들)
useEffect(() => {
if (layers.length > 0) {
const initialActiveLayers = layers.filter((l) => l.isVisible).map((l) => l.id);
setActiveLayerIds(initialActiveLayers);
}
}, [layers]);
// 🆕 레이어 제어 액션 핸들러
const handleLayerAction = useCallback((action: string, layerId: string) => {
setActiveLayerIds((prev) => {
switch (action) {
case "show":
return [...new Set([...prev, layerId])];
case "hide":
return prev.filter((id) => id !== layerId);
case "toggle":
return prev.includes(layerId)
? prev.filter((id) => id !== layerId)
: [...prev, layerId];
case "exclusive":
// 해당 레이어만 표시 (모달/드로어 같은 특수 레이어 처리에 활용)
return [...prev, layerId];
default:
return prev;
}
});
}, []);
// 통합된 폼 데이터 // 통합된 폼 데이터
const finalFormData = { ...localFormData, ...externalFormData }; const finalFormData = { ...localFormData, ...externalFormData };
// 🆕 조건부 레이어 로직 (formData 변경 시 자동 평가)
useEffect(() => {
layers.forEach((layer) => {
if (layer.type === "conditional" && layer.condition) {
const { targetComponentId, operator, value } = layer.condition;
// 컴포넌트 ID를 키로 데이터 조회 - columnName 매핑이 필요할 수 있음
const targetValue = finalFormData[targetComponentId];
let isMatch = false;
switch (operator) {
case "eq":
isMatch = targetValue == value;
break;
case "neq":
isMatch = targetValue != value;
break;
case "in":
isMatch = Array.isArray(value) && value.includes(targetValue);
break;
}
if (isMatch) {
handleLayerAction("show", layer.id);
} else {
handleLayerAction("hide", layer.id);
}
}
});
}, [finalFormData, layers, handleLayerAction]);
// 개선된 검증 시스템 (선택적 활성화) // 개선된 검증 시스템 (선택적 활성화)
const enhancedValidation = enableEnhancedValidation && screenInfo && tableColumns.length > 0 const enhancedValidation = enableEnhancedValidation && screenInfo && tableColumns.length > 0
? useFormValidation( ? useFormValidation(
@ -1395,7 +1460,6 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
> >
<SelectTrigger <SelectTrigger
className="w-full" className="w-full"
style={{ height: "100%" }}
style={{ style={{
...comp.style, ...comp.style,
width: "100%", width: "100%",
@ -1413,7 +1477,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select>, </Select>
); );
} }
@ -2124,6 +2188,159 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
} }
: component; : component;
// 🆕 레이어별 컴포넌트 렌더링 함수
const renderLayerComponents = useCallback((layer: LayerDefinition) => {
// 활성화되지 않은 레이어는 렌더링하지 않음
if (!activeLayerIds.includes(layer.id)) return null;
// 모달 레이어 처리
if (layer.type === "modal") {
const modalStyle: React.CSSProperties = {
...(layer.overlayConfig?.backgroundColor && { backgroundColor: layer.overlayConfig.backgroundColor }),
...(layer.overlayConfig?.backdropBlur && { backdropFilter: `blur(${layer.overlayConfig.backdropBlur}px)` }),
};
return (
<Dialog key={layer.id} open={true} onOpenChange={() => handleLayerAction("hide", layer.id)}>
<DialogContent
className="max-w-4xl max-h-[90vh] overflow-hidden"
style={modalStyle}
>
<DialogHeader>
<DialogTitle>{layer.name}</DialogTitle>
</DialogHeader>
<div className="relative h-full w-full min-h-[300px]">
{layer.components.map((comp) => (
<div
key={comp.id}
className="absolute"
style={{
left: `${comp.position.x}px`,
top: `${comp.position.y}px`,
width: comp.style?.width || `${comp.size.width}px`,
height: comp.style?.height || `${comp.size.height}px`,
zIndex: comp.position.z || 1,
}}
>
<InteractiveScreenViewer
component={comp}
allComponents={layer.components}
formData={externalFormData}
onFormDataChange={onFormDataChange}
screenInfo={screenInfo}
/>
</div>
))}
</div>
</DialogContent>
</Dialog>
);
}
// 드로어 레이어 처리
if (layer.type === "drawer") {
const drawerPosition = layer.overlayConfig?.position || "right";
const drawerWidth = layer.overlayConfig?.width || "400px";
const drawerHeight = layer.overlayConfig?.height || "100%";
const drawerPositionStyles: Record<string, React.CSSProperties> = {
right: { right: 0, top: 0, width: drawerWidth, height: "100%" },
left: { left: 0, top: 0, width: drawerWidth, height: "100%" },
bottom: { bottom: 0, left: 0, width: "100%", height: drawerHeight },
top: { top: 0, left: 0, width: "100%", height: drawerHeight },
};
return (
<div
key={layer.id}
className="fixed inset-0 z-50"
onClick={() => handleLayerAction("hide", layer.id)}
>
{/* 백드롭 */}
<div
className="absolute inset-0 bg-black/50"
style={{
...(layer.overlayConfig?.backdropBlur && {
backdropFilter: `blur(${layer.overlayConfig.backdropBlur}px)`
}),
}}
/>
{/* 드로어 패널 */}
<div
className="absolute bg-background shadow-lg"
style={drawerPositionStyles[drawerPosition]}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b p-4">
<h3 className="text-lg font-semibold">{layer.name}</h3>
<Button
variant="ghost"
size="icon"
onClick={() => handleLayerAction("hide", layer.id)}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="relative h-full overflow-auto p-4">
{layer.components.map((comp) => (
<div
key={comp.id}
className="absolute"
style={{
left: `${comp.position.x}px`,
top: `${comp.position.y}px`,
width: comp.style?.width || `${comp.size.width}px`,
height: comp.style?.height || `${comp.size.height}px`,
zIndex: comp.position.z || 1,
}}
>
<InteractiveScreenViewer
component={comp}
allComponents={layer.components}
formData={externalFormData}
onFormDataChange={onFormDataChange}
screenInfo={screenInfo}
/>
</div>
))}
</div>
</div>
</div>
);
}
// 일반/조건부 레이어 (base, conditional)
return (
<div
key={layer.id}
className="pointer-events-none absolute inset-0"
style={{ zIndex: layer.zIndex }}
>
{layer.components.map((comp) => (
<div
key={comp.id}
className="pointer-events-auto absolute"
style={{
left: `${comp.position.x}px`,
top: `${comp.position.y}px`,
width: comp.style?.width || `${comp.size.width}px`,
height: comp.style?.height || `${comp.size.height}px`,
zIndex: comp.position.z || 1,
}}
>
<InteractiveScreenViewer
component={comp}
allComponents={layer.components}
formData={externalFormData}
onFormDataChange={onFormDataChange}
screenInfo={screenInfo}
/>
</div>
))}
</div>
);
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo]);
return ( return (
<SplitPanelProvider> <SplitPanelProvider>
<ActiveTabProvider> <ActiveTabProvider>
@ -2147,6 +2364,9 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
</div> </div>
</div> </div>
{/* 🆕 레이어 렌더링 */}
{layers.length > 0 && layers.map(renderLayerComponents)}
{/* 개선된 검증 패널 (선택적 표시) */} {/* 개선된 검증 패널 (선택적 표시) */}
{showValidationPanel && enhancedValidation && ( {showValidationPanel && enhancedValidation && (
<div className="absolute bottom-4 right-4 z-50"> <div className="absolute bottom-4 right-4 z-50">

View File

@ -0,0 +1,331 @@
import React, { useState, useMemo } from "react";
import { useLayer } from "@/contexts/LayerContext";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import {
Eye,
EyeOff,
Lock,
Unlock,
Plus,
Trash2,
GripVertical,
Layers,
SplitSquareVertical,
PanelRight,
ChevronDown,
Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { LayerType, LayerDefinition, ComponentData } from "@/types/screen-management";
// 레이어 타입별 아이콘
const getLayerTypeIcon = (type: LayerType) => {
switch (type) {
case "base":
return <Layers className="h-3 w-3" />;
case "conditional":
return <SplitSquareVertical className="h-3 w-3" />;
case "modal":
return <Settings2 className="h-3 w-3" />;
case "drawer":
return <PanelRight className="h-3 w-3" />;
default:
return <Layers className="h-3 w-3" />;
}
};
// 레이어 타입별 라벨
function getLayerTypeLabel(type: LayerType): string {
switch (type) {
case "base":
return "기본";
case "conditional":
return "조건부";
case "modal":
return "모달";
case "drawer":
return "드로어";
default:
return type;
}
}
// 레이어 타입별 색상
function getLayerTypeColor(type: LayerType): string {
switch (type) {
case "base":
return "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300";
case "conditional":
return "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300";
case "modal":
return "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300";
case "drawer":
return "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300";
default:
return "bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-300";
}
}
interface LayerItemProps {
layer: LayerDefinition;
isActive: boolean;
componentCount: number; // 🆕 실제 컴포넌트 수 (layout.components 기반)
onSelect: () => void;
onToggleVisibility: () => void;
onToggleLock: () => void;
onRemove: () => void;
onUpdateName: (name: string) => void;
}
const LayerItem: React.FC<LayerItemProps> = ({
layer,
isActive,
componentCount,
onSelect,
onToggleVisibility,
onToggleLock,
onRemove,
onUpdateName,
}) => {
const [isEditing, setIsEditing] = useState(false);
return (
<div
className={cn(
"flex items-center gap-2 rounded-md border p-2 text-sm transition-all cursor-pointer",
isActive
? "border-primary bg-primary/5 shadow-sm"
: "hover:bg-muted border-transparent",
!layer.isVisible && "opacity-50",
)}
onClick={onSelect}
>
{/* 드래그 핸들 */}
<GripVertical className="text-muted-foreground h-4 w-4 cursor-grab flex-shrink-0" />
{/* 레이어 정보 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{/* 레이어 타입 아이콘 */}
<span className={cn("flex-shrink-0", getLayerTypeColor(layer.type), "p-1 rounded")}>
{getLayerTypeIcon(layer.type)}
</span>
{/* 레이어 이름 */}
{isEditing ? (
<input
type="text"
value={layer.name}
onChange={(e) => onUpdateName(e.target.value)}
onBlur={() => setIsEditing(false)}
onKeyDown={(e) => {
if (e.key === "Enter") setIsEditing(false);
}}
className="flex-1 bg-transparent outline-none border-b border-primary text-sm"
autoFocus
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className="flex-1 truncate font-medium"
onDoubleClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
>
{layer.name}
</span>
)}
</div>
{/* 레이어 메타 정보 */}
<div className="flex items-center gap-2 mt-0.5">
<Badge variant="outline" className="text-[10px] px-1 py-0 h-4">
{getLayerTypeLabel(layer.type)}
</Badge>
<span className="text-muted-foreground text-[10px]">
{componentCount}
</span>
</div>
</div>
{/* 액션 버튼들 */}
<div className="flex items-center gap-0.5 flex-shrink-0">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title={layer.isVisible ? "레이어 숨기기" : "레이어 표시"}
onClick={(e) => {
e.stopPropagation();
onToggleVisibility();
}}
>
{layer.isVisible ? (
<Eye className="h-3.5 w-3.5" />
) : (
<EyeOff className="text-muted-foreground h-3.5 w-3.5" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title={layer.isLocked ? "편집 잠금 해제" : "편집 잠금"}
onClick={(e) => {
e.stopPropagation();
onToggleLock();
}}
>
{layer.isLocked ? (
<Lock className="text-destructive h-3.5 w-3.5" />
) : (
<Unlock className="text-muted-foreground h-3.5 w-3.5" />
)}
</Button>
{layer.type !== "base" && (
<Button
variant="ghost"
size="icon"
className="hover:text-destructive h-6 w-6"
title="레이어 삭제"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
);
};
interface LayerManagerPanelProps {
components?: ComponentData[]; // layout.components를 전달받음
}
export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({ components = [] }) => {
const {
layers,
activeLayerId,
setActiveLayerId,
addLayer,
removeLayer,
toggleLayerVisibility,
toggleLayerLock,
updateLayer,
} = useLayer();
// 🆕 각 레이어별 컴포넌트 수 계산 (layout.components 기반)
const componentCountByLayer = useMemo(() => {
const counts: Record<string, number> = {};
// 모든 레이어를 0으로 초기화
layers.forEach(layer => {
counts[layer.id] = 0;
});
// layout.components에서 layerId별로 카운트
components.forEach(comp => {
const layerId = comp.layerId || "default-layer";
if (counts[layerId] !== undefined) {
counts[layerId]++;
} else {
// layerId가 존재하지 않는 레이어인 경우 default-layer로 카운트
if (counts["default-layer"] !== undefined) {
counts["default-layer"]++;
}
}
});
return counts;
}, [components, layers]);
return (
<div className="bg-background flex h-full flex-col">
{/* 헤더 */}
<div className="flex items-center justify-between border-b px-3 py-2">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold"></h3>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
{layers.length}
</Badge>
</div>
{/* 레이어 추가 드롭다운 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 px-2 gap-1">
<Plus className="h-3.5 w-3.5" />
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => addLayer("conditional", "조건부 레이어")}>
<SplitSquareVertical className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => addLayer("modal", "모달 레이어")}>
<Settings2 className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => addLayer("drawer", "드로어 레이어")}>
<PanelRight className="h-4 w-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* 레이어 목록 */}
<ScrollArea className="flex-1">
<div className="space-y-1 p-2">
{layers.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-8">
.
<br />
<span className="text-xs"> + .</span>
</div>
) : (
layers
.slice()
.reverse() // 상위 레이어가 위에 표시
.map((layer) => (
<LayerItem
key={layer.id}
layer={layer}
isActive={activeLayerId === layer.id}
componentCount={componentCountByLayer[layer.id] || 0}
onSelect={() => setActiveLayerId(layer.id)}
onToggleVisibility={() => toggleLayerVisibility(layer.id)}
onToggleLock={() => toggleLayerLock(layer.id)}
onRemove={() => removeLayer(layer.id)}
onUpdateName={(name) => updateLayer(layer.id, { name })}
/>
))
)}
</div>
</ScrollArea>
{/* 도움말 */}
<div className="border-t px-3 py-2 text-[10px] text-muted-foreground">
<p>더블클릭: 이름 | 드래그: 순서 </p>
</div>
</div>
);
};

View File

@ -123,9 +123,12 @@ interface ScreenDesignerProps {
onScreenUpdate?: (updatedScreen: Partial<ScreenDefinition>) => void; onScreenUpdate?: (updatedScreen: Partial<ScreenDefinition>) => void;
} }
// 패널 설정 (통합 패널 1개) import { useLayerOptional, LayerProvider, createDefaultLayer } from "@/contexts/LayerContext";
import { LayerManagerPanel } from "./LayerManagerPanel";
import { LayerType, LayerDefinition } from "@/types/screen-management";
// 패널 설정 업데이트
const panelConfigs: PanelConfig[] = [ const panelConfigs: PanelConfig[] = [
// 통합 패널 (컴포넌트 + 편집 탭)
{ {
id: "v2", id: "v2",
title: "패널", title: "패널",
@ -134,12 +137,17 @@ const panelConfigs: PanelConfig[] = [
defaultHeight: 700, defaultHeight: 700,
shortcutKey: "p", shortcutKey: "p",
}, },
{
id: "layer",
title: "레이어",
defaultPosition: "right",
defaultWidth: 240,
defaultHeight: 500,
shortcutKey: "l",
},
]; ];
export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenUpdate }: ScreenDesignerProps) { export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenUpdate }: ScreenDesignerProps) {
// 패널 상태 관리
const { panelStates, togglePanel, openPanel, closePanel } = usePanelState(panelConfigs);
const [layout, setLayout] = useState<LayoutData>({ const [layout, setLayout] = useState<LayoutData>({
components: [], components: [],
gridSettings: { gridSettings: {
@ -171,6 +179,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
SCREEN_RESOLUTIONS[0], // 기본값: Full HD SCREEN_RESOLUTIONS[0], // 기본값: Full HD
); );
// 🆕 패널 상태 관리 (usePanelState 훅)
const { panelStates, togglePanel, openPanel, closePanel, closeAllPanels, updatePanelPosition, updatePanelSize } =
usePanelState(panelConfigs);
const [selectedComponent, setSelectedComponent] = useState<ComponentData | null>(null); const [selectedComponent, setSelectedComponent] = useState<ComponentData | null>(null);
// 🆕 탭 내부 컴포넌트 선택 상태 (중첩 구조 지원) // 🆕 탭 내부 컴포넌트 선택 상태 (중첩 구조 지원)
@ -438,6 +450,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
const [tables, setTables] = useState<TableInfo[]>([]); const [tables, setTables] = useState<TableInfo[]>([]);
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
// 🆕 검색어로 필터링된 테이블 목록
const filteredTables = useMemo(() => {
if (!searchTerm.trim()) return tables;
const term = searchTerm.toLowerCase();
return tables.filter(
(table) =>
table.tableName.toLowerCase().includes(term) ||
table.columns?.some((col) => col.columnName.toLowerCase().includes(term)),
);
}, [tables, searchTerm]);
// 그룹 생성 다이얼로그 // 그룹 생성 다이얼로그
const [showGroupCreateDialog, setShowGroupCreateDialog] = useState(false); const [showGroupCreateDialog, setShowGroupCreateDialog] = useState(false);
@ -462,15 +485,25 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
return lines; return lines;
}, [layout.gridSettings?.showGrid, screenResolution.width, screenResolution.height]); }, [layout.gridSettings?.showGrid, screenResolution.width, screenResolution.height]);
// 필터된 테이블 목록 // 🆕 레이어 활성 상태 관리 (LayerProvider 외부에서 관리)
const filteredTables = useMemo(() => { const [activeLayerId, setActiveLayerIdLocal] = useState<string | null>("default-layer");
if (!searchTerm) return tables;
return tables.filter( // 캔버스에 렌더링할 컴포넌트 필터링 (레이어 기반)
(table) => // 활성 레이어가 있으면 해당 레이어의 컴포넌트만 표시
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) || // layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리
table.columns.some((col) => col.columnName.toLowerCase().includes(searchTerm.toLowerCase())), const visibleComponents = useMemo(() => {
); // 레이어 시스템이 활성화되지 않았거나 활성 레이어가 없으면 모든 컴포넌트 표시
}, [tables, searchTerm]); if (!activeLayerId) {
return layout.components;
}
// 활성 레이어에 속한 컴포넌트만 필터링
return layout.components.filter((comp) => {
// layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리
const compLayerId = comp.layerId || "default-layer";
return compLayerId === activeLayerId;
});
}, [layout.components, activeLayerId]);
// 이미 배치된 컬럼 목록 계산 // 이미 배치된 컬럼 목록 계산
const placedColumns = useMemo(() => { const placedColumns = useMemo(() => {
@ -1798,9 +1831,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
// 현재 선택된 테이블을 화면의 기본 테이블로 저장 // 현재 선택된 테이블을 화면의 기본 테이블로 저장
const currentMainTableName = tables.length > 0 ? tables[0].tableName : null; const currentMainTableName = tables.length > 0 ? tables[0].tableName : null;
// 🆕 레이어 정보도 함께 저장 (레이어가 있으면 레이어의 컴포넌트로 업데이트)
const updatedLayers = layout.layers?.map((layer) => ({
...layer,
components: layer.components.map((comp) => {
// 분할 패널 업데이트 로직 적용
const updatedComp = updatedComponents.find((uc) => uc.id === comp.id);
return updatedComp || comp;
}),
}));
const layoutWithResolution = { const layoutWithResolution = {
...layout, ...layout,
components: updatedComponents, components: updatedComponents,
layers: updatedLayers, // 🆕 레이어 정보 포함
screenResolution: screenResolution, screenResolution: screenResolution,
mainTableName: currentMainTableName, // 화면의 기본 테이블 mainTableName: currentMainTableName, // 화면의 기본 테이블
}; };
@ -2339,23 +2383,29 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
} }
}); });
// 🆕 현재 활성 레이어에 컴포넌트 추가
const componentsWithLayerId = newComponents.map((comp) => ({
...comp,
layerId: activeLayerId || "default-layer",
}));
// 레이아웃에 새 컴포넌트들 추가 // 레이아웃에 새 컴포넌트들 추가
const newLayout = { const newLayout = {
...layout, ...layout,
components: [...layout.components, ...newComponents], components: [...layout.components, ...componentsWithLayerId],
}; };
setLayout(newLayout); setLayout(newLayout);
saveToHistory(newLayout); saveToHistory(newLayout);
// 첫 번째 컴포넌트 선택 // 첫 번째 컴포넌트 선택
if (newComponents.length > 0) { if (componentsWithLayerId.length > 0) {
setSelectedComponent(newComponents[0]); setSelectedComponent(componentsWithLayerId[0]);
} }
toast.success(`${template.name} 템플릿이 추가되었습니다.`); toast.success(`${template.name} 템플릿이 추가되었습니다.`);
}, },
[layout, selectedScreen, saveToHistory], [layout, selectedScreen, saveToHistory, activeLayerId],
); );
// 레이아웃 드래그 처리 // 레이아웃 드래그 처리
@ -2409,6 +2459,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
label: layoutData.label, label: layoutData.label,
allowedComponentTypes: layoutData.allowedComponentTypes, allowedComponentTypes: layoutData.allowedComponentTypes,
dropZoneConfig: layoutData.dropZoneConfig, dropZoneConfig: layoutData.dropZoneConfig,
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
} as ComponentData; } as ComponentData;
// 레이아웃에 새 컴포넌트 추가 // 레이아웃에 새 컴포넌트 추가
@ -2425,7 +2476,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`); toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`);
}, },
[layout, screenResolution, saveToHistory, zoomLevel], [layout, screenResolution, saveToHistory, zoomLevel, activeLayerId],
); );
// handleZoneComponentDrop은 handleComponentDrop으로 대체됨 // handleZoneComponentDrop은 handleComponentDrop으로 대체됨
@ -3016,6 +3067,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
position: snappedPosition, position: snappedPosition,
size: componentSize, size: componentSize,
gridColumns: gridColumns, // 컴포넌트별 그리드 컬럼 수 적용 gridColumns: gridColumns, // 컴포넌트별 그리드 컬럼 수 적용
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
componentConfig: { componentConfig: {
type: component.id, // 새 컴포넌트 시스템의 ID 사용 type: component.id, // 새 컴포넌트 시스템의 ID 사용
webType: component.webType, // 웹타입 정보 추가 webType: component.webType, // 웹타입 정보 추가
@ -3049,7 +3101,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
toast.success(`${component.name} 컴포넌트가 추가되었습니다.`); toast.success(`${component.name} 컴포넌트가 추가되었습니다.`);
}, },
[layout, selectedScreen, saveToHistory], [layout, selectedScreen, saveToHistory, activeLayerId],
); );
// 드래그 앤 드롭 처리 // 드래그 앤 드롭 처리
@ -3421,6 +3473,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
tableName: table.tableName, tableName: table.tableName,
position: { x, y, z: 1 } as Position, position: { x, y, z: 1 } as Position,
size: { width: 300, height: 200 }, size: { width: 300, height: 200 },
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
style: { style: {
labelDisplay: true, labelDisplay: true,
labelFontSize: "14px", labelFontSize: "14px",
@ -3671,6 +3724,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
componentType: v2Mapping.componentType, // v2-input, v2-select 등 componentType: v2Mapping.componentType, // v2-input, v2-select 등
position: { x: relativeX, y: relativeY, z: 1 } as Position, position: { x: relativeX, y: relativeY, z: 1 } as Position,
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) }, size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
// 코드 타입인 경우 코드 카테고리 정보 추가 // 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" && ...(column.widgetType === "code" &&
column.codeCategory && { column.codeCategory && {
@ -3737,6 +3791,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
componentType: v2Mapping.componentType, // v2-input, v2-select 등 componentType: v2Mapping.componentType, // v2-input, v2-select 등
position: { x, y, z: 1 } as Position, position: { x, y, z: 1 } as Position,
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) }, size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
// 코드 타입인 경우 코드 카테고리 정보 추가 // 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" && ...(column.widgetType === "code" &&
column.codeCategory && { column.codeCategory && {
@ -4388,7 +4443,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
bottom: Math.max(selectionDrag.startPoint.y, currentPoint.y), bottom: Math.max(selectionDrag.startPoint.y, currentPoint.y),
}; };
const selectedIds = layout.components // 🆕 visibleComponents만 선택 대상으로 (현재 활성 레이어의 컴포넌트만)
const selectedIds = visibleComponents
.filter((comp) => { .filter((comp) => {
const compRect = { const compRect = {
left: comp.position.x, left: comp.position.x,
@ -4411,7 +4467,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
selectedComponents: selectedIds, selectedComponents: selectedIds,
})); }));
}, },
[selectionDrag.isSelecting, selectionDrag.startPoint, layout.components, zoomLevel], [selectionDrag.isSelecting, selectionDrag.startPoint, visibleComponents, zoomLevel],
); );
// 드래그 선택 종료 // 드래그 선택 종료
@ -4558,6 +4614,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
z: clipComponent.position.z || 1, z: clipComponent.position.z || 1,
} as Position, } as Position,
parentId: undefined, // 붙여넣기 시 부모 관계 해제 parentId: undefined, // 붙여넣기 시 부모 관계 해제
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 붙여넣기
}; };
newComponents.push(newComponent); newComponents.push(newComponent);
}); });
@ -4578,7 +4635,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
// console.log("컴포넌트 붙여넣기 완료:", newComponents.length, "개"); // console.log("컴포넌트 붙여넣기 완료:", newComponents.length, "개");
toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`); toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`);
}, [clipboard, layout, saveToHistory]); }, [clipboard, layout, saveToHistory, activeLayerId]);
// 🆕 플로우 버튼 그룹 생성 (다중 선택된 버튼들을 한 번에 그룹으로) // 🆕 플로우 버튼 그룹 생성 (다중 선택된 버튼들을 한 번에 그룹으로)
// 🆕 플로우 버튼 그룹 다이얼로그 상태 // 🆕 플로우 버튼 그룹 다이얼로그 상태
@ -5374,6 +5431,36 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
}; };
}, [layout, selectedComponent]); }, [layout, selectedComponent]);
// 🆕 레이어 변경 핸들러 - 레이어 컨텍스트에서 레이어가 변경되면 layout에도 반영
// 주의: layout.components는 layerId 속성으로 레이어를 구분하므로, 여기서 덮어쓰지 않음
const handleLayersChange = useCallback((newLayers: LayerDefinition[]) => {
setLayout((prevLayout) => ({
...prevLayout,
layers: newLayers,
// components는 그대로 유지 - layerId 속성으로 레이어 구분
// components: prevLayout.components (기본값으로 유지됨)
}));
}, []);
// 🆕 활성 레이어 변경 핸들러
const handleActiveLayerChange = useCallback((newActiveLayerId: string | null) => {
setActiveLayerIdLocal(newActiveLayerId);
}, []);
// 🆕 초기 레이어 계산 - layout에서 layers가 있으면 사용, 없으면 기본 레이어 생성
// 주의: components는 layout.components에 layerId 속성으로 저장되므로, layer.components는 비워둠
const initialLayers = useMemo<LayerDefinition[]>(() => {
if (layout.layers && layout.layers.length > 0) {
// 기존 레이어 구조 사용 (layer.components는 무시하고 빈 배열로 설정)
return layout.layers.map(layer => ({
...layer,
components: [], // layout.components + layerId 방식 사용
}));
}
// layers가 없으면 기본 레이어 생성 (components는 빈 배열)
return [createDefaultLayer()];
}, [layout.layers]);
if (!selectedScreen) { if (!selectedScreen) {
return ( return (
<div className="bg-background flex h-full items-center justify-center"> <div className="bg-background flex h-full items-center justify-center">
@ -5393,7 +5480,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
return ( return (
<ScreenPreviewProvider isPreviewMode={false}> <ScreenPreviewProvider isPreviewMode={false}>
<TableOptionsProvider> <LayerProvider
initialLayers={initialLayers}
onLayersChange={handleLayersChange}
onActiveLayerChange={handleActiveLayerChange}
>
<TableOptionsProvider>
<div className="bg-background flex h-full w-full flex-col"> <div className="bg-background flex h-full w-full flex-col">
{/* 상단 슬림 툴바 */} {/* 상단 슬림 툴바 */}
<SlimToolbar <SlimToolbar
@ -5428,10 +5520,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
</div> </div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden"> <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<Tabs defaultValue="components" className="flex min-h-0 flex-1 flex-col"> <Tabs defaultValue="components" className="flex min-h-0 flex-1 flex-col">
<TabsList className="mx-4 mt-2 grid h-8 w-auto grid-cols-2 gap-1"> <TabsList className="mx-4 mt-2 grid h-8 w-auto grid-cols-3 gap-1">
<TabsTrigger value="components" className="text-xs"> <TabsTrigger value="components" className="text-xs">
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="layers" className="text-xs">
</TabsTrigger>
<TabsTrigger value="properties" className="text-xs"> <TabsTrigger value="properties" className="text-xs">
</TabsTrigger> </TabsTrigger>
@ -5457,6 +5552,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
/> />
</TabsContent> </TabsContent>
{/* 🆕 레이어 관리 탭 */}
<TabsContent value="layers" className="mt-0 flex-1 overflow-hidden">
<LayerManagerPanel components={layout.components} />
</TabsContent>
<TabsContent value="properties" className="mt-0 flex-1 overflow-hidden"> <TabsContent value="properties" className="mt-0 flex-1 overflow-hidden">
{/* 탭 내부 컴포넌트 선택 시에도 V2PropertiesPanel 사용 */} {/* 탭 내부 컴포넌트 선택 시에도 V2PropertiesPanel 사용 */}
{selectedTabComponentInfo ? ( {selectedTabComponentInfo ? (
@ -6088,7 +6188,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
{/* 컴포넌트들 */} {/* 컴포넌트들 */}
{(() => { {(() => {
// 🆕 플로우 버튼 그룹 감지 및 처리 // 🆕 플로우 버튼 그룹 감지 및 처리
const topLevelComponents = layout.components.filter((component) => !component.parentId); // visibleComponents를 사용하여 활성 레이어의 컴포넌트만 표시
const topLevelComponents = visibleComponents.filter((component) => !component.parentId);
// auto-compact 모드의 버튼들을 그룹별로 묶기 // auto-compact 모드의 버튼들을 그룹별로 묶기
const buttonGroups: Record<string, ComponentData[]> = {}; const buttonGroups: Record<string, ComponentData[]> = {};
@ -6740,6 +6841,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
/> />
</div> </div>
</TableOptionsProvider> </TableOptionsProvider>
</LayerProvider>
</ScreenPreviewProvider> </ScreenPreviewProvider>
); );
} }

View File

@ -208,17 +208,14 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
if (componentId?.startsWith("v2-")) { if (componentId?.startsWith("v2-")) {
const v2ConfigPanels: Record<string, React.FC<{ config: any; onChange: (config: any) => void }>> = { const v2ConfigPanels: Record<string, React.FC<{ config: any; onChange: (config: any) => void }>> = {
"v2-input": require("@/components/v2/config-panels/V2InputConfigPanel").V2InputConfigPanel, "v2-input": require("@/components/v2/config-panels/V2InputConfigPanel").V2InputConfigPanel,
"v2-select": require("@/components/v2/config-panels/V2SelectConfigPanel") "v2-select": require("@/components/v2/config-panels/V2SelectConfigPanel").V2SelectConfigPanel,
.V2SelectConfigPanel,
"v2-date": require("@/components/v2/config-panels/V2DateConfigPanel").V2DateConfigPanel, "v2-date": require("@/components/v2/config-panels/V2DateConfigPanel").V2DateConfigPanel,
"v2-list": require("@/components/v2/config-panels/V2ListConfigPanel").V2ListConfigPanel, "v2-list": require("@/components/v2/config-panels/V2ListConfigPanel").V2ListConfigPanel,
"v2-layout": require("@/components/v2/config-panels/V2LayoutConfigPanel") "v2-layout": require("@/components/v2/config-panels/V2LayoutConfigPanel").V2LayoutConfigPanel,
.V2LayoutConfigPanel,
"v2-group": require("@/components/v2/config-panels/V2GroupConfigPanel").V2GroupConfigPanel, "v2-group": require("@/components/v2/config-panels/V2GroupConfigPanel").V2GroupConfigPanel,
"v2-media": require("@/components/v2/config-panels/V2MediaConfigPanel").V2MediaConfigPanel, "v2-media": require("@/components/v2/config-panels/V2MediaConfigPanel").V2MediaConfigPanel,
"v2-biz": require("@/components/v2/config-panels/V2BizConfigPanel").V2BizConfigPanel, "v2-biz": require("@/components/v2/config-panels/V2BizConfigPanel").V2BizConfigPanel,
"v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel") "v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel").V2HierarchyConfigPanel,
.V2HierarchyConfigPanel,
}; };
const V2ConfigPanel = v2ConfigPanels[componentId]; const V2ConfigPanel = v2ConfigPanels[componentId];
@ -823,7 +820,11 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Input <Input
value={selectedComponent.style?.labelText !== undefined ? selectedComponent.style.labelText : (selectedComponent.label || selectedComponent.componentConfig?.label || "")} value={
selectedComponent.style?.labelText !== undefined
? selectedComponent.style.labelText
: selectedComponent.label || selectedComponent.componentConfig?.label || ""
}
onChange={(e) => { onChange={(e) => {
handleUpdate("style.labelText", e.target.value); handleUpdate("style.labelText", e.target.value);
handleUpdate("label", e.target.value); // label도 함께 업데이트 handleUpdate("label", e.target.value); // label도 함께 업데이트
@ -870,10 +871,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
handleUpdate("labelDisplay", boolValue); handleUpdate("labelDisplay", boolValue);
// labelText도 설정 (처음 켤 때 라벨 텍스트가 없을 수 있음) // labelText도 설정 (처음 켤 때 라벨 텍스트가 없을 수 있음)
if (boolValue && !selectedComponent.style?.labelText) { if (boolValue && !selectedComponent.style?.labelText) {
const labelValue = const labelValue = selectedComponent.label || selectedComponent.componentConfig?.label || "";
selectedComponent.label ||
selectedComponent.componentConfig?.label ||
"";
if (labelValue) { if (labelValue) {
handleUpdate("style.labelText", labelValue); handleUpdate("style.labelText", labelValue);
} }
@ -963,8 +961,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
} }
// 🆕 3.5. V2 컴포넌트 - 반드시 다른 체크보다 먼저 처리 // 🆕 3.5. V2 컴포넌트 - 반드시 다른 체크보다 먼저 처리
const v2ComponentType = const v2ComponentType = (selectedComponent as any).componentType || selectedComponent.componentConfig?.type || "";
(selectedComponent as any).componentType || selectedComponent.componentConfig?.type || "";
if (v2ComponentType.startsWith("v2-")) { if (v2ComponentType.startsWith("v2-")) {
const configPanel = renderComponentConfigPanel(); const configPanel = renderComponentConfigPanel();
if (configPanel) { if (configPanel) {

View File

@ -0,0 +1,337 @@
import React, { createContext, useContext, useState, useCallback, ReactNode, useMemo } from "react";
import { LayerDefinition, LayerType, ComponentData } from "@/types/screen-management";
import { v4 as uuidv4 } from "uuid";
interface LayerContextType {
// 레이어 상태
layers: LayerDefinition[];
activeLayerId: string | null;
activeLayer: LayerDefinition | null;
// 레이어 관리
setLayers: (layers: LayerDefinition[]) => void;
setActiveLayerId: (id: string | null) => void;
addLayer: (type: LayerType, name?: string) => void;
removeLayer: (id: string) => void;
updateLayer: (id: string, updates: Partial<LayerDefinition>) => void;
moveLayer: (dragIndex: number, hoverIndex: number) => void;
toggleLayerVisibility: (id: string) => void;
toggleLayerLock: (id: string) => void;
getLayerById: (id: string) => LayerDefinition | undefined;
// 컴포넌트 관리 (레이어별)
addComponentToLayer: (layerId: string, component: ComponentData) => void;
removeComponentFromLayer: (layerId: string, componentId: string) => void;
updateComponentInLayer: (layerId: string, componentId: string, updates: Partial<ComponentData>) => void;
moveComponentToLayer: (componentId: string, fromLayerId: string, toLayerId: string) => void;
// 컴포넌트 조회
getAllComponents: () => ComponentData[];
getComponentById: (componentId: string) => { component: ComponentData; layerId: string } | null;
getComponentsInActiveLayer: () => ComponentData[];
// 레이어 가시성 (런타임용)
runtimeVisibleLayers: string[];
setRuntimeVisibleLayers: React.Dispatch<React.SetStateAction<string[]>>;
showLayer: (layerId: string) => void;
hideLayer: (layerId: string) => void;
toggleLayerRuntime: (layerId: string) => void;
}
const LayerContext = createContext<LayerContextType | undefined>(undefined);
export const useLayer = () => {
const context = useContext(LayerContext);
if (!context) {
throw new Error("useLayer must be used within a LayerProvider");
}
return context;
};
// LayerProvider가 없을 때 사용할 기본 컨텍스트 (선택적 사용)
export const useLayerOptional = () => {
return useContext(LayerContext);
};
interface LayerProviderProps {
children: ReactNode;
initialLayers?: LayerDefinition[];
onLayersChange?: (layers: LayerDefinition[]) => void;
onActiveLayerChange?: (activeLayerId: string | null) => void; // 🆕 활성 레이어 변경 콜백
}
// 기본 레이어 생성 헬퍼
export const createDefaultLayer = (components?: ComponentData[]): LayerDefinition => ({
id: "default-layer",
name: "기본 레이어",
type: "base",
zIndex: 0,
isVisible: true,
isLocked: false,
components: components || [],
});
export const LayerProvider: React.FC<LayerProviderProps> = ({
children,
initialLayers = [],
onLayersChange,
onActiveLayerChange,
}) => {
// 초기 레이어가 없으면 기본 레이어 생성
const effectiveInitialLayers = initialLayers.length > 0
? initialLayers
: [createDefaultLayer()];
const [layers, setLayersState] = useState<LayerDefinition[]>(effectiveInitialLayers);
const [activeLayerIdState, setActiveLayerIdState] = useState<string | null>(
effectiveInitialLayers.length > 0 ? effectiveInitialLayers[0].id : null,
);
// 🆕 활성 레이어 변경 시 콜백 호출
const setActiveLayerId = useCallback((id: string | null) => {
setActiveLayerIdState(id);
onActiveLayerChange?.(id);
}, [onActiveLayerChange]);
// 활성 레이어 ID (내부 상태 사용)
const activeLayerId = activeLayerIdState;
// 런타임 가시성 상태 (편집기에서의 isVisible과 별개)
const [runtimeVisibleLayers, setRuntimeVisibleLayers] = useState<string[]>(
effectiveInitialLayers.filter(l => l.isVisible).map(l => l.id)
);
// 레이어 변경 시 콜백 호출
const setLayers = useCallback((newLayers: LayerDefinition[]) => {
setLayersState(newLayers);
onLayersChange?.(newLayers);
}, [onLayersChange]);
// 활성 레이어 계산
const activeLayer = useMemo(() => {
return layers.find(l => l.id === activeLayerId) || null;
}, [layers, activeLayerId]);
const addLayer = useCallback(
(type: LayerType, name?: string) => {
const newLayer: LayerDefinition = {
id: uuidv4(),
name: name || `새 레이어 ${layers.length + 1}`,
type,
zIndex: layers.length,
isVisible: true,
isLocked: false,
components: [],
// 모달/드로어 기본 설정
...(type === "modal" || type === "drawer" ? {
overlayConfig: {
backdrop: true,
closeOnBackdropClick: true,
width: type === "drawer" ? "320px" : "600px",
height: type === "drawer" ? "100%" : "auto",
},
} : {}),
};
setLayers([...layers, newLayer]);
setActiveLayerId(newLayer.id);
// 새 레이어는 런타임에서도 기본적으로 표시
setRuntimeVisibleLayers(prev => [...prev, newLayer.id]);
},
[layers, setLayers],
);
const removeLayer = useCallback(
(id: string) => {
// 기본 레이어는 삭제 불가
const layer = layers.find(l => l.id === id);
if (layer?.type === "base") {
console.warn("기본 레이어는 삭제할 수 없습니다.");
return;
}
const filtered = layers.filter((layer) => layer.id !== id);
setLayers(filtered);
if (activeLayerId === id) {
setActiveLayerId(filtered.length > 0 ? filtered[0].id : null);
}
setRuntimeVisibleLayers(prev => prev.filter(lid => lid !== id));
},
[layers, activeLayerId, setLayers],
);
const updateLayer = useCallback((id: string, updates: Partial<LayerDefinition>) => {
setLayers(layers.map((layer) => (layer.id === id ? { ...layer, ...updates } : layer)));
}, [layers, setLayers]);
const moveLayer = useCallback((dragIndex: number, hoverIndex: number) => {
const newLayers = [...layers];
const [removed] = newLayers.splice(dragIndex, 1);
newLayers.splice(hoverIndex, 0, removed);
// Update zIndex based on new order
setLayers(newLayers.map((layer, index) => ({ ...layer, zIndex: index })));
}, [layers, setLayers]);
const toggleLayerVisibility = useCallback((id: string) => {
setLayers(layers.map((layer) => (layer.id === id ? { ...layer, isVisible: !layer.isVisible } : layer)));
}, [layers, setLayers]);
const toggleLayerLock = useCallback((id: string) => {
setLayers(layers.map((layer) => (layer.id === id ? { ...layer, isLocked: !layer.isLocked } : layer)));
}, [layers, setLayers]);
const getLayerById = useCallback(
(id: string) => {
return layers.find((layer) => layer.id === id);
},
[layers],
);
// ===== 컴포넌트 관리 함수 =====
const addComponentToLayer = useCallback((layerId: string, component: ComponentData) => {
setLayers(layers.map(layer => {
if (layer.id === layerId) {
return {
...layer,
components: [...layer.components, component],
};
}
return layer;
}));
}, [layers, setLayers]);
const removeComponentFromLayer = useCallback((layerId: string, componentId: string) => {
setLayers(layers.map(layer => {
if (layer.id === layerId) {
return {
...layer,
components: layer.components.filter(c => c.id !== componentId),
};
}
return layer;
}));
}, [layers, setLayers]);
const updateComponentInLayer = useCallback((layerId: string, componentId: string, updates: Partial<ComponentData>) => {
setLayers(layers.map(layer => {
if (layer.id === layerId) {
return {
...layer,
components: layer.components.map(c =>
c.id === componentId ? { ...c, ...updates } as ComponentData : c
),
};
}
return layer;
}));
}, [layers, setLayers]);
const moveComponentToLayer = useCallback((componentId: string, fromLayerId: string, toLayerId: string) => {
if (fromLayerId === toLayerId) return;
const fromLayer = layers.find(l => l.id === fromLayerId);
const component = fromLayer?.components.find(c => c.id === componentId);
if (!component) return;
setLayers(layers.map(layer => {
if (layer.id === fromLayerId) {
return {
...layer,
components: layer.components.filter(c => c.id !== componentId),
};
}
if (layer.id === toLayerId) {
return {
...layer,
components: [...layer.components, component],
};
}
return layer;
}));
}, [layers, setLayers]);
// ===== 컴포넌트 조회 함수 =====
const getAllComponents = useCallback((): ComponentData[] => {
return layers.flatMap(layer => layer.components);
}, [layers]);
const getComponentById = useCallback((componentId: string): { component: ComponentData; layerId: string } | null => {
for (const layer of layers) {
const component = layer.components.find(c => c.id === componentId);
if (component) {
return { component, layerId: layer.id };
}
}
return null;
}, [layers]);
const getComponentsInActiveLayer = useCallback((): ComponentData[] => {
const layer = layers.find(l => l.id === activeLayerId);
return layer?.components || [];
}, [layers, activeLayerId]);
// ===== 런타임 레이어 가시성 관리 =====
const showLayer = useCallback((layerId: string) => {
setRuntimeVisibleLayers(prev => [...new Set([...prev, layerId])]);
}, []);
const hideLayer = useCallback((layerId: string) => {
setRuntimeVisibleLayers(prev => prev.filter(id => id !== layerId));
}, []);
const toggleLayerRuntime = useCallback((layerId: string) => {
setRuntimeVisibleLayers(prev =>
prev.includes(layerId)
? prev.filter(id => id !== layerId)
: [...prev, layerId]
);
}, []);
return (
<LayerContext.Provider
value={{
// 레이어 상태
layers,
activeLayerId,
activeLayer,
// 레이어 관리
setLayers,
setActiveLayerId,
addLayer,
removeLayer,
updateLayer,
moveLayer,
toggleLayerVisibility,
toggleLayerLock,
getLayerById,
// 컴포넌트 관리
addComponentToLayer,
removeComponentFromLayer,
updateComponentInLayer,
moveComponentToLayer,
// 컴포넌트 조회
getAllComponents,
getComponentById,
getComponentsInActiveLayer,
// 런타임 가시성
runtimeVisibleLayers,
setRuntimeVisibleLayers,
showLayer,
hideLayer,
toggleLayerRuntime,
}}
>
{children}
</LayerContext.Provider>
);
};

View File

@ -10,7 +10,19 @@ import { TableListConfig, ColumnConfig } from "./types";
import { entityJoinApi } from "@/lib/api/entityJoin"; import { entityJoinApi } from "@/lib/api/entityJoin";
import { tableTypeApi } from "@/lib/api/screen"; import { tableTypeApi } from "@/lib/api/screen";
import { tableManagementApi } from "@/lib/api/tableManagement"; import { tableManagementApi } from "@/lib/api/tableManagement";
import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check, Lock, Unlock, Database, Table2, Link2 } from "lucide-react"; import {
Plus,
Trash2,
ArrowUp,
ArrowDown,
ChevronsUpDown,
Check,
Lock,
Unlock,
Database,
Table2,
Link2,
} from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -35,7 +47,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
}) => { }) => {
// config가 undefined인 경우 빈 객체로 초기화 // config가 undefined인 경우 빈 객체로 초기화
const config = configProp || {}; const config = configProp || {};
// console.log("🔍 TableListConfigPanel props:", { // console.log("🔍 TableListConfigPanel props:", {
// config, // config,
// configType: typeof config, // configType: typeof config,
@ -202,12 +214,12 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
try { try {
const result = await tableManagementApi.getColumnList(targetTableName); const result = await tableManagementApi.getColumnList(targetTableName);
console.log("🔧 tableManagementApi 응답:", result); console.log("🔧 tableManagementApi 응답:", result);
if (result.success && result.data) { if (result.success && result.data) {
// API 응답 구조: { columns: [...], total, page, ... } // API 응답 구조: { columns: [...], total, page, ... }
const columns = Array.isArray(result.data) ? result.data : result.data.columns; const columns = Array.isArray(result.data) ? result.data : result.data.columns;
console.log("🔧 컬럼 배열:", columns); console.log("🔧 컬럼 배열:", columns);
if (columns && Array.isArray(columns)) { if (columns && Array.isArray(columns)) {
setAvailableColumns( setAvailableColumns(
columns.map((col: any) => ({ columns.map((col: any) => ({
@ -779,7 +791,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
checked={config.toolbar?.showEditMode ?? false} checked={config.toolbar?.showEditMode ?? false}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showEditMode", checked)} onCheckedChange={(checked) => handleNestedChange("toolbar", "showEditMode", checked)}
/> />
<Label htmlFor="showEditMode" className="text-xs"> </Label> <Label htmlFor="showEditMode" className="text-xs">
</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
@ -787,7 +801,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
checked={config.toolbar?.showExcel ?? false} checked={config.toolbar?.showExcel ?? false}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showExcel", checked)} onCheckedChange={(checked) => handleNestedChange("toolbar", "showExcel", checked)}
/> />
<Label htmlFor="showExcel" className="text-xs">Excel</Label> <Label htmlFor="showExcel" className="text-xs">
Excel
</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
@ -795,7 +811,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
checked={config.toolbar?.showPdf ?? false} checked={config.toolbar?.showPdf ?? false}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showPdf", checked)} onCheckedChange={(checked) => handleNestedChange("toolbar", "showPdf", checked)}
/> />
<Label htmlFor="showPdf" className="text-xs">PDF</Label> <Label htmlFor="showPdf" className="text-xs">
PDF
</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
@ -803,7 +821,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
checked={config.toolbar?.showCopy ?? false} checked={config.toolbar?.showCopy ?? false}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showCopy", checked)} onCheckedChange={(checked) => handleNestedChange("toolbar", "showCopy", checked)}
/> />
<Label htmlFor="showCopy" className="text-xs"></Label> <Label htmlFor="showCopy" className="text-xs">
</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
@ -811,7 +831,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
checked={config.toolbar?.showSearch ?? false} checked={config.toolbar?.showSearch ?? false}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showSearch", checked)} onCheckedChange={(checked) => handleNestedChange("toolbar", "showSearch", checked)}
/> />
<Label htmlFor="showSearch" className="text-xs"></Label> <Label htmlFor="showSearch" className="text-xs">
</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
@ -819,7 +841,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
checked={config.toolbar?.showFilter ?? false} checked={config.toolbar?.showFilter ?? false}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showFilter", checked)} onCheckedChange={(checked) => handleNestedChange("toolbar", "showFilter", checked)}
/> />
<Label htmlFor="showFilter" className="text-xs"></Label> <Label htmlFor="showFilter" className="text-xs">
</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
@ -827,7 +851,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
checked={config.toolbar?.showRefresh ?? false} checked={config.toolbar?.showRefresh ?? false}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showRefresh", checked)} onCheckedChange={(checked) => handleNestedChange("toolbar", "showRefresh", checked)}
/> />
<Label htmlFor="showRefresh" className="text-xs"> ()</Label> <Label htmlFor="showRefresh" className="text-xs">
()
</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
@ -835,7 +861,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
checked={config.toolbar?.showPaginationRefresh ?? true} checked={config.toolbar?.showPaginationRefresh ?? true}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showPaginationRefresh", checked)} onCheckedChange={(checked) => handleNestedChange("toolbar", "showPaginationRefresh", checked)}
/> />
<Label htmlFor="showPaginationRefresh" className="text-xs"> ()</Label> <Label htmlFor="showPaginationRefresh" className="text-xs">
()
</Label>
</div> </div>
</div> </div>
</div> </div>
@ -1159,7 +1187,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<div className="space-y-2"> <div className="space-y-2">
<div> <div>
<h3 className="text-sm font-semibold"> </h3> <h3 className="text-sm font-semibold"> </h3>
<p className="text-[10px] text-muted-foreground"> </p> <p className="text-muted-foreground text-[10px]"> </p>
</div> </div>
<hr className="border-border" /> <hr className="border-border" />
{availableColumns.length > 0 ? ( {availableColumns.length > 0 ? (
@ -1176,7 +1204,10 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
onClick={() => { onClick={() => {
if (isAdded) { if (isAdded) {
// 컬럼 제거 // 컬럼 제거
handleChange("columns", config.columns?.filter((c) => c.columnName !== column.columnName) || []); handleChange(
"columns",
config.columns?.filter((c) => c.columnName !== column.columnName) || [],
);
} else { } else {
// 컬럼 추가 // 컬럼 추가
addColumn(column.columnName); addColumn(column.columnName);
@ -1187,7 +1218,10 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
checked={isAdded} checked={isAdded}
onCheckedChange={() => { onCheckedChange={() => {
if (isAdded) { if (isAdded) {
handleChange("columns", config.columns?.filter((c) => c.columnName !== column.columnName) || []); handleChange(
"columns",
config.columns?.filter((c) => c.columnName !== column.columnName) || [],
);
} else { } else {
addColumn(column.columnName); addColumn(column.columnName);
} }
@ -1196,7 +1230,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
/> />
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" /> <Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
<span className="truncate text-xs">{column.label || column.columnName}</span> <span className="truncate text-xs">{column.label || column.columnName}</span>
<span className="text-[10px] text-gray-400 ml-auto">{column.input_type || column.dataType}</span> <span className="ml-auto text-[10px] text-gray-400">
{column.input_type || column.dataType}
</span>
</div> </div>
); );
})} })}
@ -1211,13 +1247,13 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<div className="space-y-2"> <div className="space-y-2">
<div> <div>
<h3 className="text-sm font-semibold">Entity </h3> <h3 className="text-sm font-semibold">Entity </h3>
<p className="text-[10px] text-muted-foreground"> </p> <p className="text-muted-foreground text-[10px]"> </p>
</div> </div>
<hr className="border-border" /> <hr className="border-border" />
<div className="space-y-3"> <div className="space-y-3">
{entityJoinColumns.joinTables.map((joinTable, tableIndex) => ( {entityJoinColumns.joinTables.map((joinTable, tableIndex) => (
<div key={tableIndex} className="space-y-1"> <div key={tableIndex} className="space-y-1">
<div className="flex items-center gap-2 text-[10px] font-medium text-blue-600 mb-1"> <div className="mb-1 flex items-center gap-2 text-[10px] font-medium text-blue-600">
<Link2 className="h-3 w-3" /> <Link2 className="h-3 w-3" />
<span>{joinTable.tableName}</span> <span>{joinTable.tableName}</span>
<Badge variant="outline" className="text-[10px]"> <Badge variant="outline" className="text-[10px]">
@ -1225,56 +1261,65 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</Badge> </Badge>
</div> </div>
<div className="max-h-28 space-y-0.5 overflow-y-auto rounded-md border border-blue-200 bg-blue-50/30 p-2"> <div className="max-h-28 space-y-0.5 overflow-y-auto rounded-md border border-blue-200 bg-blue-50/30 p-2">
{joinTable.availableColumns.map((column, colIndex) => { {joinTable.availableColumns.map((column, colIndex) => {
const matchingJoinColumn = entityJoinColumns.availableColumns.find( const matchingJoinColumn = entityJoinColumns.availableColumns.find(
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName, (jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
); );
const isAlreadyAdded = config.columns?.some( const isAlreadyAdded = config.columns?.some(
(col) => col.columnName === matchingJoinColumn?.joinAlias, (col) => col.columnName === matchingJoinColumn?.joinAlias,
); );
if (!matchingJoinColumn) return null; if (!matchingJoinColumn) return null;
return ( return (
<div <div
key={colIndex} key={colIndex}
className={cn( className={cn(
"hover:bg-blue-100/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1", "flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-blue-100/50",
isAlreadyAdded && "bg-blue-100", isAlreadyAdded && "bg-blue-100",
)} )}
onClick={() => { onClick={() => {
if (isAlreadyAdded) { if (isAlreadyAdded) {
// 컬럼 제거 // 컬럼 제거
handleChange("columns", config.columns?.filter((c) => c.columnName !== matchingJoinColumn.joinAlias) || []); handleChange(
"columns",
config.columns?.filter((c) => c.columnName !== matchingJoinColumn.joinAlias) || [],
);
} else { } else {
// 컬럼 추가 // 컬럼 추가
addEntityColumn(matchingJoinColumn); addEntityColumn(matchingJoinColumn);
} }
}} }}
> >
<Checkbox <Checkbox
checked={isAlreadyAdded} checked={isAlreadyAdded}
onCheckedChange={() => { onCheckedChange={() => {
if (isAlreadyAdded) { if (isAlreadyAdded) {
handleChange("columns", config.columns?.filter((c) => c.columnName !== matchingJoinColumn.joinAlias) || []); handleChange(
} else { "columns",
config.columns?.filter((c) => c.columnName !== matchingJoinColumn.joinAlias) ||
[],
);
} else {
addEntityColumn(matchingJoinColumn); addEntityColumn(matchingJoinColumn);
} }
}} }}
className="pointer-events-none h-3.5 w-3.5" className="pointer-events-none h-3.5 w-3.5"
/> />
<Link2 className="text-blue-500 h-3 w-3 flex-shrink-0" /> <Link2 className="h-3 w-3 flex-shrink-0 text-blue-500" />
<span className="truncate text-xs">{column.columnLabel}</span> <span className="truncate text-xs">{column.columnLabel}</span>
<span className="text-[10px] text-blue-400 ml-auto">{column.inputType || column.dataType}</span> <span className="ml-auto text-[10px] text-blue-400">
</div> {column.inputType || column.dataType}
); </span>
})} </div>
</div> );
</div> })}
</div>
</div>
))} ))}
</div> </div>
</div> </div>
)} )}
</> </>
)} )}
@ -1301,7 +1346,6 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
onConfigChange={(dataFilter) => handleChange("dataFilter", dataFilter)} onConfigChange={(dataFilter) => handleChange("dataFilter", dataFilter)}
/> />
</div> </div>
</div> </div>
</div> </div>
); );

View File

@ -148,9 +148,53 @@ export const componentV2Schema = z.object({
overrides: z.record(z.string(), z.any()).default({}), overrides: z.record(z.string(), z.any()).default({}),
}); });
export const layoutV2Schema = z.object({ // ============================================
version: z.string().default("2.0"), // 레이어 스키마 정의
// ============================================
export const layerTypeSchema = z.enum(["base", "conditional", "modal", "drawer"]);
export const layerSchema = z.object({
id: z.string(),
name: z.string(),
type: layerTypeSchema,
zIndex: z.number().default(0),
isVisible: z.boolean().default(true), // 초기 표시 여부
isLocked: z.boolean().default(false), // 편집 잠금 여부
// 조건부 표시 로직
condition: z
.object({
targetComponentId: z.string(),
operator: z.enum(["eq", "neq", "in"]),
value: z.any(),
})
.optional(),
// 모달/드로어 전용 설정
overlayConfig: z
.object({
backdrop: z.boolean().default(true),
closeOnBackdropClick: z.boolean().default(true),
width: z.union([z.string(), z.number()]).optional(),
height: z.union([z.string(), z.number()]).optional(),
// 모달/드로어 스타일링
backgroundColor: z.string().optional(),
backdropBlur: z.number().optional(),
// 드로어 전용
position: z.enum(["left", "right", "top", "bottom"]).optional(),
})
.optional(),
// 해당 레이어에 속한 컴포넌트들
components: z.array(componentV2Schema).default([]), components: z.array(componentV2Schema).default([]),
});
export type Layer = z.infer<typeof layerSchema>;
export const layoutV2Schema = z.object({
version: z.string().default("2.1"),
layers: z.array(layerSchema).default([]), // 신규 필드
components: z.array(componentV2Schema).default([]), // 하위 호환성 유지
updatedAt: z.string().optional(), updatedAt: z.string().optional(),
screenResolution: z screenResolution: z
.object({ .object({
@ -952,23 +996,78 @@ export function saveComponentV2(component: ComponentV2 & { config?: Record<strin
// ============================================ // ============================================
// V2 레이아웃 로드 (전체 컴포넌트 기본값 병합) // V2 레이아웃 로드 (전체 컴포넌트 기본값 병합)
// ============================================ // ============================================
export function loadLayoutV2( export function loadLayoutV2(layoutData: any): LayoutV2 & {
layoutData: any, components: Array<ComponentV2 & { config: Record<string, any> }>;
): LayoutV2 & { components: Array<ComponentV2 & { config: Record<string, any> }> } { layers: Array<Layer & { components: Array<ComponentV2 & { config: Record<string, any> }> }>;
const parsed = layoutV2Schema.parse(layoutData || { version: "2.0", components: [] }); } {
const parsed = layoutV2Schema.parse(layoutData || { version: "2.1", components: [], layers: [] });
// 마이그레이션: components만 있고 layers가 없는 경우 Default Layer 생성
if ((!parsed.layers || parsed.layers.length === 0) && parsed.components && parsed.components.length > 0) {
const defaultLayer: Layer = {
id: "default-layer",
name: "기본 레이어",
type: "base",
zIndex: 0,
isVisible: true,
isLocked: false,
components: parsed.components,
};
parsed.layers = [defaultLayer];
}
// 모든 레이어의 컴포넌트 로드
const loadedLayers = parsed.layers.map((layer) => ({
...layer,
components: layer.components.map(loadComponentV2),
}));
// 하위 호환성을 위한 components 배열 (모든 레이어의 컴포넌트 합침)
const allComponents = loadedLayers.flatMap((layer) => layer.components);
return { return {
...parsed, ...parsed,
components: parsed.components.map(loadComponentV2), layers: loadedLayers,
components: allComponents,
}; };
} }
// ============================================ // ============================================
// V2 레이아웃 저장 (전체 컴포넌트 차이값 추출) // V2 레이아웃 저장 (전체 컴포넌트 차이값 추출)
// ============================================ // ============================================
export function saveLayoutV2(components: Array<ComponentV2 & { config?: Record<string, any> }>): LayoutV2 { export function saveLayoutV2(
components: Array<ComponentV2 & { config?: Record<string, any> }>,
layers?: Array<Layer & { components: Array<ComponentV2 & { config?: Record<string, any> }> }>,
): LayoutV2 {
// 레이어가 있는 경우 레이어 구조 저장
if (layers && layers.length > 0) {
const savedLayers = layers.map((layer) => ({
...layer,
components: layer.components.map(saveComponentV2),
}));
return {
version: "2.1",
layers: savedLayers,
components: savedLayers.flatMap((l) => l.components), // 하위 호환성
};
}
// 레이어가 없는 경우 (기존 방식) - Default Layer로 감싸서 저장
const savedComponents = components.map(saveComponentV2);
const defaultLayer: Layer = {
id: "default-layer",
name: "기본 레이어",
type: "base",
zIndex: 0,
isVisible: true,
isLocked: false,
components: savedComponents,
};
return { return {
version: "2.0", version: "2.1",
components: components.map(saveComponentV2), layers: [defaultLayer],
components: savedComponents,
}; };
} }

View File

@ -38,6 +38,9 @@ export interface BaseComponent {
gridColumnStart?: number; // 시작 컬럼 (1-12) gridColumnStart?: number; // 시작 컬럼 (1-12)
gridRowIndex?: number; // 행 인덱스 gridRowIndex?: number; // 행 인덱스
// 🆕 레이어 시스템
layerId?: string; // 컴포넌트가 속한 레이어 ID
parentId?: string; parentId?: string;
label?: string; label?: string;
required?: boolean; required?: boolean;
@ -102,13 +105,13 @@ export interface WidgetComponent extends BaseComponent {
entityConfig?: EntityTypeConfig; entityConfig?: EntityTypeConfig;
buttonConfig?: ButtonTypeConfig; buttonConfig?: ButtonTypeConfig;
arrayConfig?: ArrayTypeConfig; arrayConfig?: ArrayTypeConfig;
// 🆕 자동 입력 설정 (테이블 조회 기반) // 🆕 자동 입력 설정 (테이블 조회 기반)
autoFill?: { autoFill?: {
enabled: boolean; // 자동 입력 활성화 enabled: boolean; // 자동 입력 활성화
sourceTable: string; // 조회할 테이블 (예: company_mng) sourceTable: string; // 조회할 테이블 (예: company_mng)
filterColumn: string; // 필터링할 컬럼 (예: company_code) filterColumn: string; // 필터링할 컬럼 (예: company_code)
userField: 'companyCode' | 'userId' | 'deptCode'; // 사용자 정보 필드 userField: "companyCode" | "userId" | "deptCode"; // 사용자 정보 필드
displayColumn: string; // 표시할 컬럼 (예: company_name) displayColumn: string; // 표시할 컬럼 (예: company_name)
}; };
} }
@ -148,12 +151,12 @@ export interface DataTableComponent extends BaseComponent {
searchable?: boolean; searchable?: boolean;
sortable?: boolean; sortable?: boolean;
filters?: DataTableFilter[]; filters?: DataTableFilter[];
// 🆕 현재 사용자 정보로 자동 필터링 // 🆕 현재 사용자 정보로 자동 필터링
autoFilter?: { autoFilter?: {
enabled: boolean; // 자동 필터 활성화 여부 enabled: boolean; // 자동 필터 활성화 여부
filterColumn: string; // 필터링할 테이블 컬럼 (예: company_code, dept_code) filterColumn: string; // 필터링할 테이블 컬럼 (예: company_code, dept_code)
userField: 'companyCode' | 'userId' | 'deptCode'; // 사용자 정보에서 가져올 필드 userField: "companyCode" | "userId" | "deptCode"; // 사용자 정보에서 가져올 필드
}; };
// 🆕 컬럼 값 기반 데이터 필터링 // 🆕 컬럼 값 기반 데이터 필터링
@ -307,13 +310,13 @@ export interface SelectTypeConfig {
required?: boolean; required?: boolean;
readonly?: boolean; readonly?: boolean;
emptyMessage?: string; emptyMessage?: string;
/** 🆕 연쇄 드롭다운 관계 코드 (관계 관리에서 정의한 코드) */ /** 🆕 연쇄 드롭다운 관계 코드 (관계 관리에서 정의한 코드) */
cascadingRelationCode?: string; cascadingRelationCode?: string;
/** 🆕 연쇄 드롭다운 부모 필드명 (화면 내 다른 필드의 columnName) */ /** 🆕 연쇄 드롭다운 부모 필드명 (화면 내 다른 필드의 columnName) */
cascadingParentField?: string; cascadingParentField?: string;
/** @deprecated 직접 설정 방식 - cascadingRelationCode 사용 권장 */ /** @deprecated 직접 설정 방식 - cascadingRelationCode 사용 권장 */
cascading?: CascadingDropdownConfig; cascading?: CascadingDropdownConfig;
} }
@ -402,10 +405,10 @@ export interface EntityTypeConfig {
/** /**
* 🆕 (Cascading Dropdown) * 🆕 (Cascading Dropdown)
* *
* . * .
* : 창고 * : 창고
* *
* @example * @example
* // 창고 → 위치 연쇄 드롭다운 * // 창고 → 위치 연쇄 드롭다운
* { * {
@ -420,34 +423,34 @@ export interface EntityTypeConfig {
export interface CascadingDropdownConfig { export interface CascadingDropdownConfig {
/** 연쇄 드롭다운 활성화 여부 */ /** 연쇄 드롭다운 활성화 여부 */
enabled: boolean; enabled: boolean;
/** 부모 필드명 (이 필드의 값에 따라 옵션이 필터링됨) */ /** 부모 필드명 (이 필드의 값에 따라 옵션이 필터링됨) */
parentField: string; parentField: string;
/** 옵션을 조회할 테이블명 */ /** 옵션을 조회할 테이블명 */
sourceTable: string; sourceTable: string;
/** 부모 값과 매칭할 컬럼명 (sourceTable의 컬럼) */ /** 부모 값과 매칭할 컬럼명 (sourceTable의 컬럼) */
parentKeyColumn: string; parentKeyColumn: string;
/** 드롭다운 value로 사용할 컬럼명 */ /** 드롭다운 value로 사용할 컬럼명 */
valueColumn: string; valueColumn: string;
/** 드롭다운 label로 표시할 컬럼명 */ /** 드롭다운 label로 표시할 컬럼명 */
labelColumn: string; labelColumn: string;
/** 추가 필터 조건 (선택사항) */ /** 추가 필터 조건 (선택사항) */
additionalFilters?: Record<string, unknown>; additionalFilters?: Record<string, unknown>;
/** 부모 값이 없을 때 표시할 메시지 */ /** 부모 값이 없을 때 표시할 메시지 */
emptyParentMessage?: string; emptyParentMessage?: string;
/** 옵션이 없을 때 표시할 메시지 */ /** 옵션이 없을 때 표시할 메시지 */
noOptionsMessage?: string; noOptionsMessage?: string;
/** 로딩 중 표시할 메시지 */ /** 로딩 중 표시할 메시지 */
loadingMessage?: string; loadingMessage?: string;
/** 부모 값 변경 시 자동으로 값 초기화 */ /** 부모 값 변경 시 자동으로 값 초기화 */
clearOnParentChange?: boolean; clearOnParentChange?: boolean;
} }
@ -472,23 +475,23 @@ export interface ButtonTypeConfig {
export interface QuickInsertColumnMapping { export interface QuickInsertColumnMapping {
/** 저장할 테이블의 대상 컬럼명 */ /** 저장할 테이블의 대상 컬럼명 */
targetColumn: string; targetColumn: string;
/** 값 소스 타입 */ /** 값 소스 타입 */
sourceType: "component" | "leftPanel" | "fixed" | "currentUser"; sourceType: "component" | "leftPanel" | "fixed" | "currentUser";
// sourceType별 추가 설정 // sourceType별 추가 설정
/** component: 값을 가져올 컴포넌트 ID */ /** component: 값을 가져올 컴포넌트 ID */
sourceComponentId?: string; sourceComponentId?: string;
/** component: 컴포넌트의 columnName (formData 접근용) */ /** component: 컴포넌트의 columnName (formData 접근용) */
sourceColumnName?: string; sourceColumnName?: string;
/** leftPanel: 좌측 선택 데이터의 컬럼명 */ /** leftPanel: 좌측 선택 데이터의 컬럼명 */
sourceColumn?: string; sourceColumn?: string;
/** fixed: 고정값 */ /** fixed: 고정값 */
fixedValue?: any; fixedValue?: any;
/** currentUser: 사용자 정보 필드 */ /** currentUser: 사용자 정보 필드 */
userField?: "userId" | "userName" | "companyCode" | "deptCode"; userField?: "userId" | "userName" | "companyCode" | "deptCode";
} }
@ -499,13 +502,13 @@ export interface QuickInsertColumnMapping {
export interface QuickInsertAfterAction { export interface QuickInsertAfterAction {
/** 데이터 새로고침 (테이블리스트, 카드 디스플레이 컴포넌트) */ /** 데이터 새로고침 (테이블리스트, 카드 디스플레이 컴포넌트) */
refreshData?: boolean; refreshData?: boolean;
/** 초기화할 컴포넌트 ID 목록 */ /** 초기화할 컴포넌트 ID 목록 */
clearComponents?: string[]; clearComponents?: string[];
/** 성공 메시지 표시 여부 */ /** 성공 메시지 표시 여부 */
showSuccessMessage?: boolean; showSuccessMessage?: boolean;
/** 커스텀 성공 메시지 */ /** 커스텀 성공 메시지 */
successMessage?: string; successMessage?: string;
} }
@ -516,20 +519,20 @@ export interface QuickInsertAfterAction {
export interface QuickInsertDuplicateCheck { export interface QuickInsertDuplicateCheck {
/** 중복 체크 활성화 */ /** 중복 체크 활성화 */
enabled: boolean; enabled: boolean;
/** 중복 체크할 컬럼들 */ /** 중복 체크할 컬럼들 */
columns: string[]; columns: string[];
/** 중복 시 에러 메시지 */ /** 중복 시 에러 메시지 */
errorMessage?: string; errorMessage?: string;
} }
/** /**
* (quickInsert) * (quickInsert)
* *
* entity , * entity ,
* INSERT하는 * INSERT하는
* *
* @example * @example
* ```typescript * ```typescript
* const config: QuickInsertConfig = { * const config: QuickInsertConfig = {
@ -557,13 +560,13 @@ export interface QuickInsertDuplicateCheck {
export interface QuickInsertConfig { export interface QuickInsertConfig {
/** 저장할 대상 테이블명 */ /** 저장할 대상 테이블명 */
targetTable: string; targetTable: string;
/** 컬럼 매핑 설정 */ /** 컬럼 매핑 설정 */
columnMappings: QuickInsertColumnMapping[]; columnMappings: QuickInsertColumnMapping[];
/** 저장 후 동작 설정 */ /** 저장 후 동작 설정 */
afterInsert?: QuickInsertAfterAction; afterInsert?: QuickInsertAfterAction;
/** 중복 체크 설정 (선택사항) */ /** 중복 체크 설정 (선택사항) */
duplicateCheck?: QuickInsertDuplicateCheck; duplicateCheck?: QuickInsertDuplicateCheck;
} }
@ -678,15 +681,15 @@ export interface DataTableFilter {
export interface ColumnFilter { export interface ColumnFilter {
id: string; id: string;
columnName: string; // 필터링할 컬럼명 columnName: string; // 필터링할 컬럼명
operator: operator:
| "equals" | "equals"
| "not_equals" | "not_equals"
| "in" | "in"
| "not_in" | "not_in"
| "contains" | "contains"
| "starts_with" | "starts_with"
| "ends_with" | "ends_with"
| "is_null" | "is_null"
| "is_not_null" | "is_not_null"
| "greater_than" | "greater_than"
| "less_than" | "less_than"
@ -836,12 +839,71 @@ export interface GroupState {
groupTitle?: string; groupTitle?: string;
} }
// ============================================
// 레이어 시스템 타입 정의
// ============================================
/**
*
* - base: 기본 ( )
* - conditional: 조건부 ( )
* - modal: 모달 ( )
* - drawer: 드로어 ( )
*/
export type LayerType = "base" | "conditional" | "modal" | "drawer";
/**
*
*/
export interface LayerCondition {
targetComponentId: string; // 트리거가 되는 컴포넌트 ID
operator: "eq" | "neq" | "in"; // 비교 연산자
value: any; // 비교할 값
}
/**
* (/)
*/
export interface LayerOverlayConfig {
backdrop: boolean; // 배경 어둡게 처리 여부
closeOnBackdropClick: boolean; // 배경 클릭 시 닫기 여부
width?: string | number; // 너비
height?: string | number; // 높이
// 모달/드로어 스타일링
backgroundColor?: string; // 컨텐츠 배경색
backdropBlur?: number; // 배경 블러 (px)
// 드로어 전용
position?: "left" | "right" | "top" | "bottom"; // 드로어 위치
}
/**
*
*/
export interface LayerDefinition {
id: string;
name: string;
type: LayerType;
zIndex: number;
isVisible: boolean; // 초기 표시 여부
isLocked: boolean; // 편집 잠금 여부
// 조건부 표시 로직
condition?: LayerCondition;
// 모달/드로어 전용 설정
overlayConfig?: LayerOverlayConfig;
// 해당 레이어에 속한 컴포넌트들
components: ComponentData[];
}
/** /**
* *
*/ */
export interface LayoutData { export interface LayoutData {
screenId: number; screenId: number;
components: ComponentData[]; components: ComponentData[]; // @deprecated - use layers instead (kept for backward compatibility)
layers?: LayerDefinition[]; // 🆕 레이어 목록
gridSettings?: GridSettings; gridSettings?: GridSettings;
metadata?: LayoutMetadata; metadata?: LayoutMetadata;
screenResolution?: ScreenResolution; screenResolution?: ScreenResolution;