feat: 레이어 시스템 추가 및 관리 기능 구현
- InteractiveScreenViewer 컴포넌트에 레이어 시스템을 도입하여, 레이어의 활성화 및 조건부 표시 로직을 추가하였습니다. - ScreenDesigner 컴포넌트에서 레이어 상태 관리 및 레이어 정보 저장 기능을 구현하였습니다. - 레이어 정의 및 조건부 표시 설정을 위한 새로운 타입과 스키마를 추가하여, 레이어 기반의 UI 구성 요소를 보다 유연하게 관리할 수 있도록 하였습니다. - 레이어별 컴포넌트 렌더링 로직을 추가하여, 모달 및 드로어 형태의 레이어를 효과적으로 처리할 수 있도록 개선하였습니다. - 전반적으로 레이어 시스템을 통해 사용자 경험을 향상시키고, UI 구성의 유연성을 높였습니다.
This commit is contained in:
parent
e31bb970a2
commit
4e2209bd5d
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import React, { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -16,7 +16,7 @@ import { useAuth } from "@/hooks/useAuth";
|
|||
import { uploadFilesAndCreateData } from "@/lib/api/file";
|
||||
import { toast } from "sonner";
|
||||
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
|
||||
import { CascadingDropdownConfig } from "@/types/screen-management";
|
||||
import { CascadingDropdownConfig, LayerDefinition } from "@/types/screen-management";
|
||||
import {
|
||||
ComponentData,
|
||||
WidgetComponent,
|
||||
|
|
@ -164,6 +164,8 @@ interface InteractiveScreenViewerProps {
|
|||
enableAutoSave?: boolean;
|
||||
showToastMessages?: boolean;
|
||||
};
|
||||
// 🆕 레이어 시스템 지원
|
||||
layers?: LayerDefinition[];
|
||||
}
|
||||
|
||||
export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = ({
|
||||
|
|
@ -178,6 +180,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
tableColumns = [],
|
||||
showValidationPanel = false,
|
||||
validationOptions = {},
|
||||
layers = [], // 🆕 레이어 목록
|
||||
}) => {
|
||||
// component가 없으면 빈 div 반환
|
||||
if (!component) {
|
||||
|
|
@ -206,9 +209,71 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
// 팝업 전용 formData 상태
|
||||
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 };
|
||||
|
||||
// 🆕 조건부 레이어 로직 (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
|
||||
? useFormValidation(
|
||||
|
|
@ -1395,7 +1460,6 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
>
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
style={{ height: "100%" }}
|
||||
style={{
|
||||
...comp.style,
|
||||
width: "100%",
|
||||
|
|
@ -1413,7 +1477,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>,
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -2124,6 +2188,159 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
}
|
||||
: 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 (
|
||||
<SplitPanelProvider>
|
||||
<ActiveTabProvider>
|
||||
|
|
@ -2147,6 +2364,9 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 🆕 레이어 렌더링 */}
|
||||
{layers.length > 0 && layers.map(renderLayerComponents)}
|
||||
|
||||
{/* 개선된 검증 패널 (선택적 표시) */}
|
||||
{showValidationPanel && enhancedValidation && (
|
||||
<div className="absolute bottom-4 right-4 z-50">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -123,9 +123,12 @@ interface ScreenDesignerProps {
|
|||
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[] = [
|
||||
// 통합 패널 (컴포넌트 + 편집 탭)
|
||||
{
|
||||
id: "v2",
|
||||
title: "패널",
|
||||
|
|
@ -134,12 +137,17 @@ const panelConfigs: PanelConfig[] = [
|
|||
defaultHeight: 700,
|
||||
shortcutKey: "p",
|
||||
},
|
||||
{
|
||||
id: "layer",
|
||||
title: "레이어",
|
||||
defaultPosition: "right",
|
||||
defaultWidth: 240,
|
||||
defaultHeight: 500,
|
||||
shortcutKey: "l",
|
||||
},
|
||||
];
|
||||
|
||||
export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenUpdate }: ScreenDesignerProps) {
|
||||
// 패널 상태 관리
|
||||
const { panelStates, togglePanel, openPanel, closePanel } = usePanelState(panelConfigs);
|
||||
|
||||
const [layout, setLayout] = useState<LayoutData>({
|
||||
components: [],
|
||||
gridSettings: {
|
||||
|
|
@ -171,6 +179,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
SCREEN_RESOLUTIONS[0], // 기본값: Full HD
|
||||
);
|
||||
|
||||
// 🆕 패널 상태 관리 (usePanelState 훅)
|
||||
const { panelStates, togglePanel, openPanel, closePanel, closeAllPanels, updatePanelPosition, updatePanelSize } =
|
||||
usePanelState(panelConfigs);
|
||||
|
||||
const [selectedComponent, setSelectedComponent] = useState<ComponentData | null>(null);
|
||||
|
||||
// 🆕 탭 내부 컴포넌트 선택 상태 (중첩 구조 지원)
|
||||
|
|
@ -438,6 +450,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||
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);
|
||||
|
||||
|
|
@ -462,15 +485,25 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
return lines;
|
||||
}, [layout.gridSettings?.showGrid, screenResolution.width, screenResolution.height]);
|
||||
|
||||
// 필터된 테이블 목록
|
||||
const filteredTables = useMemo(() => {
|
||||
if (!searchTerm) return tables;
|
||||
return tables.filter(
|
||||
(table) =>
|
||||
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
table.columns.some((col) => col.columnName.toLowerCase().includes(searchTerm.toLowerCase())),
|
||||
);
|
||||
}, [tables, searchTerm]);
|
||||
// 🆕 레이어 활성 상태 관리 (LayerProvider 외부에서 관리)
|
||||
const [activeLayerId, setActiveLayerIdLocal] = useState<string | null>("default-layer");
|
||||
|
||||
// 캔버스에 렌더링할 컴포넌트 필터링 (레이어 기반)
|
||||
// 활성 레이어가 있으면 해당 레이어의 컴포넌트만 표시
|
||||
// layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리
|
||||
const visibleComponents = useMemo(() => {
|
||||
// 레이어 시스템이 활성화되지 않았거나 활성 레이어가 없으면 모든 컴포넌트 표시
|
||||
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(() => {
|
||||
|
|
@ -1798,9 +1831,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
// 현재 선택된 테이블을 화면의 기본 테이블로 저장
|
||||
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 = {
|
||||
...layout,
|
||||
components: updatedComponents,
|
||||
layers: updatedLayers, // 🆕 레이어 정보 포함
|
||||
screenResolution: screenResolution,
|
||||
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 = {
|
||||
...layout,
|
||||
components: [...layout.components, ...newComponents],
|
||||
components: [...layout.components, ...componentsWithLayerId],
|
||||
};
|
||||
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
|
||||
// 첫 번째 컴포넌트 선택
|
||||
if (newComponents.length > 0) {
|
||||
setSelectedComponent(newComponents[0]);
|
||||
if (componentsWithLayerId.length > 0) {
|
||||
setSelectedComponent(componentsWithLayerId[0]);
|
||||
}
|
||||
|
||||
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,
|
||||
allowedComponentTypes: layoutData.allowedComponentTypes,
|
||||
dropZoneConfig: layoutData.dropZoneConfig,
|
||||
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
|
||||
} as ComponentData;
|
||||
|
||||
// 레이아웃에 새 컴포넌트 추가
|
||||
|
|
@ -2425,7 +2476,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
|
||||
toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`);
|
||||
},
|
||||
[layout, screenResolution, saveToHistory, zoomLevel],
|
||||
[layout, screenResolution, saveToHistory, zoomLevel, activeLayerId],
|
||||
);
|
||||
|
||||
// handleZoneComponentDrop은 handleComponentDrop으로 대체됨
|
||||
|
|
@ -3016,6 +3067,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
position: snappedPosition,
|
||||
size: componentSize,
|
||||
gridColumns: gridColumns, // 컴포넌트별 그리드 컬럼 수 적용
|
||||
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
|
||||
componentConfig: {
|
||||
type: component.id, // 새 컴포넌트 시스템의 ID 사용
|
||||
webType: component.webType, // 웹타입 정보 추가
|
||||
|
|
@ -3049,7 +3101,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
|
||||
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,
|
||||
position: { x, y, z: 1 } as Position,
|
||||
size: { width: 300, height: 200 },
|
||||
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
|
||||
style: {
|
||||
labelDisplay: true,
|
||||
labelFontSize: "14px",
|
||||
|
|
@ -3671,6 +3724,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
componentType: v2Mapping.componentType, // v2-input, v2-select 등
|
||||
position: { x: relativeX, y: relativeY, z: 1 } as Position,
|
||||
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
||||
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
|
||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
||||
...(column.widgetType === "code" &&
|
||||
column.codeCategory && {
|
||||
|
|
@ -3737,6 +3791,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
componentType: v2Mapping.componentType, // v2-input, v2-select 등
|
||||
position: { x, y, z: 1 } as Position,
|
||||
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
||||
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
|
||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
||||
...(column.widgetType === "code" &&
|
||||
column.codeCategory && {
|
||||
|
|
@ -4388,7 +4443,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
bottom: Math.max(selectionDrag.startPoint.y, currentPoint.y),
|
||||
};
|
||||
|
||||
const selectedIds = layout.components
|
||||
// 🆕 visibleComponents만 선택 대상으로 (현재 활성 레이어의 컴포넌트만)
|
||||
const selectedIds = visibleComponents
|
||||
.filter((comp) => {
|
||||
const compRect = {
|
||||
left: comp.position.x,
|
||||
|
|
@ -4411,7 +4467,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
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,
|
||||
} as Position,
|
||||
parentId: undefined, // 붙여넣기 시 부모 관계 해제
|
||||
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 붙여넣기
|
||||
};
|
||||
newComponents.push(newComponent);
|
||||
});
|
||||
|
|
@ -4578,7 +4635,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
|
||||
// console.log("컴포넌트 붙여넣기 완료:", 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에도 반영
|
||||
// 주의: 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) {
|
||||
return (
|
||||
<div className="bg-background flex h-full items-center justify-center">
|
||||
|
|
@ -5393,6 +5480,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
|
||||
return (
|
||||
<ScreenPreviewProvider isPreviewMode={false}>
|
||||
<LayerProvider
|
||||
initialLayers={initialLayers}
|
||||
onLayersChange={handleLayersChange}
|
||||
onActiveLayerChange={handleActiveLayerChange}
|
||||
>
|
||||
<TableOptionsProvider>
|
||||
<div className="bg-background flex h-full w-full flex-col">
|
||||
{/* 상단 슬림 툴바 */}
|
||||
|
|
@ -5428,10 +5520,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<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>
|
||||
<TabsTrigger value="layers" className="text-xs">
|
||||
레이어
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="properties" className="text-xs">
|
||||
편집
|
||||
</TabsTrigger>
|
||||
|
|
@ -5457,6 +5552,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
/>
|
||||
</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">
|
||||
{/* 탭 내부 컴포넌트 선택 시에도 V2PropertiesPanel 사용 */}
|
||||
{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 모드의 버튼들을 그룹별로 묶기
|
||||
const buttonGroups: Record<string, ComponentData[]> = {};
|
||||
|
|
@ -6740,6 +6841,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
/>
|
||||
</div>
|
||||
</TableOptionsProvider>
|
||||
</LayerProvider>
|
||||
</ScreenPreviewProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -208,17 +208,14 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
if (componentId?.startsWith("v2-")) {
|
||||
const v2ConfigPanels: Record<string, React.FC<{ config: any; onChange: (config: any) => void }>> = {
|
||||
"v2-input": require("@/components/v2/config-panels/V2InputConfigPanel").V2InputConfigPanel,
|
||||
"v2-select": require("@/components/v2/config-panels/V2SelectConfigPanel")
|
||||
.V2SelectConfigPanel,
|
||||
"v2-select": require("@/components/v2/config-panels/V2SelectConfigPanel").V2SelectConfigPanel,
|
||||
"v2-date": require("@/components/v2/config-panels/V2DateConfigPanel").V2DateConfigPanel,
|
||||
"v2-list": require("@/components/v2/config-panels/V2ListConfigPanel").V2ListConfigPanel,
|
||||
"v2-layout": require("@/components/v2/config-panels/V2LayoutConfigPanel")
|
||||
.V2LayoutConfigPanel,
|
||||
"v2-layout": require("@/components/v2/config-panels/V2LayoutConfigPanel").V2LayoutConfigPanel,
|
||||
"v2-group": require("@/components/v2/config-panels/V2GroupConfigPanel").V2GroupConfigPanel,
|
||||
"v2-media": require("@/components/v2/config-panels/V2MediaConfigPanel").V2MediaConfigPanel,
|
||||
"v2-biz": require("@/components/v2/config-panels/V2BizConfigPanel").V2BizConfigPanel,
|
||||
"v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel")
|
||||
.V2HierarchyConfigPanel,
|
||||
"v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel").V2HierarchyConfigPanel,
|
||||
};
|
||||
|
||||
const V2ConfigPanel = v2ConfigPanels[componentId];
|
||||
|
|
@ -823,7 +820,11 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
<div className="space-y-1">
|
||||
<Label className="text-xs">라벨 텍스트</Label>
|
||||
<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) => {
|
||||
handleUpdate("style.labelText", e.target.value);
|
||||
handleUpdate("label", e.target.value); // label도 함께 업데이트
|
||||
|
|
@ -870,10 +871,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
handleUpdate("labelDisplay", boolValue);
|
||||
// labelText도 설정 (처음 켤 때 라벨 텍스트가 없을 수 있음)
|
||||
if (boolValue && !selectedComponent.style?.labelText) {
|
||||
const labelValue =
|
||||
selectedComponent.label ||
|
||||
selectedComponent.componentConfig?.label ||
|
||||
"";
|
||||
const labelValue = selectedComponent.label || selectedComponent.componentConfig?.label || "";
|
||||
if (labelValue) {
|
||||
handleUpdate("style.labelText", labelValue);
|
||||
}
|
||||
|
|
@ -963,8 +961,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
}
|
||||
|
||||
// 🆕 3.5. V2 컴포넌트 - 반드시 다른 체크보다 먼저 처리
|
||||
const v2ComponentType =
|
||||
(selectedComponent as any).componentType || selectedComponent.componentConfig?.type || "";
|
||||
const v2ComponentType = (selectedComponent as any).componentType || selectedComponent.componentConfig?.type || "";
|
||||
if (v2ComponentType.startsWith("v2-")) {
|
||||
const configPanel = renderComponentConfigPanel();
|
||||
if (configPanel) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -10,7 +10,19 @@ import { TableListConfig, ColumnConfig } from "./types";
|
|||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
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 { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -779,7 +791,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
checked={config.toolbar?.showEditMode ?? false}
|
||||
onCheckedChange={(checked) => handleNestedChange("toolbar", "showEditMode", checked)}
|
||||
/>
|
||||
<Label htmlFor="showEditMode" className="text-xs">즉시 저장</Label>
|
||||
<Label htmlFor="showEditMode" className="text-xs">
|
||||
즉시 저장
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
|
|
@ -787,7 +801,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
checked={config.toolbar?.showExcel ?? false}
|
||||
onCheckedChange={(checked) => handleNestedChange("toolbar", "showExcel", checked)}
|
||||
/>
|
||||
<Label htmlFor="showExcel" className="text-xs">Excel</Label>
|
||||
<Label htmlFor="showExcel" className="text-xs">
|
||||
Excel
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
|
|
@ -795,7 +811,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
checked={config.toolbar?.showPdf ?? false}
|
||||
onCheckedChange={(checked) => handleNestedChange("toolbar", "showPdf", checked)}
|
||||
/>
|
||||
<Label htmlFor="showPdf" className="text-xs">PDF</Label>
|
||||
<Label htmlFor="showPdf" className="text-xs">
|
||||
PDF
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
|
|
@ -803,7 +821,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
checked={config.toolbar?.showCopy ?? false}
|
||||
onCheckedChange={(checked) => handleNestedChange("toolbar", "showCopy", checked)}
|
||||
/>
|
||||
<Label htmlFor="showCopy" className="text-xs">복사</Label>
|
||||
<Label htmlFor="showCopy" className="text-xs">
|
||||
복사
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
|
|
@ -811,7 +831,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
checked={config.toolbar?.showSearch ?? false}
|
||||
onCheckedChange={(checked) => handleNestedChange("toolbar", "showSearch", checked)}
|
||||
/>
|
||||
<Label htmlFor="showSearch" className="text-xs">검색</Label>
|
||||
<Label htmlFor="showSearch" className="text-xs">
|
||||
검색
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
|
|
@ -819,7 +841,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
checked={config.toolbar?.showFilter ?? false}
|
||||
onCheckedChange={(checked) => handleNestedChange("toolbar", "showFilter", checked)}
|
||||
/>
|
||||
<Label htmlFor="showFilter" className="text-xs">필터</Label>
|
||||
<Label htmlFor="showFilter" className="text-xs">
|
||||
필터
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
|
|
@ -827,7 +851,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
checked={config.toolbar?.showRefresh ?? false}
|
||||
onCheckedChange={(checked) => handleNestedChange("toolbar", "showRefresh", checked)}
|
||||
/>
|
||||
<Label htmlFor="showRefresh" className="text-xs">새로고침 (상단)</Label>
|
||||
<Label htmlFor="showRefresh" className="text-xs">
|
||||
새로고침 (상단)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
|
|
@ -835,7 +861,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
checked={config.toolbar?.showPaginationRefresh ?? true}
|
||||
onCheckedChange={(checked) => handleNestedChange("toolbar", "showPaginationRefresh", checked)}
|
||||
/>
|
||||
<Label htmlFor="showPaginationRefresh" className="text-xs">새로고침 (하단)</Label>
|
||||
<Label htmlFor="showPaginationRefresh" className="text-xs">
|
||||
새로고침 (하단)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1159,7 +1187,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
<div className="space-y-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">컬럼 선택</h3>
|
||||
<p className="text-[10px] text-muted-foreground">표시할 컬럼을 선택하세요</p>
|
||||
<p className="text-muted-foreground text-[10px]">표시할 컬럼을 선택하세요</p>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
{availableColumns.length > 0 ? (
|
||||
|
|
@ -1176,7 +1204,10 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
onClick={() => {
|
||||
if (isAdded) {
|
||||
// 컬럼 제거
|
||||
handleChange("columns", config.columns?.filter((c) => c.columnName !== column.columnName) || []);
|
||||
handleChange(
|
||||
"columns",
|
||||
config.columns?.filter((c) => c.columnName !== column.columnName) || [],
|
||||
);
|
||||
} else {
|
||||
// 컬럼 추가
|
||||
addColumn(column.columnName);
|
||||
|
|
@ -1187,7 +1218,10 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
checked={isAdded}
|
||||
onCheckedChange={() => {
|
||||
if (isAdded) {
|
||||
handleChange("columns", config.columns?.filter((c) => c.columnName !== column.columnName) || []);
|
||||
handleChange(
|
||||
"columns",
|
||||
config.columns?.filter((c) => c.columnName !== column.columnName) || [],
|
||||
);
|
||||
} else {
|
||||
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" />
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
|
|
@ -1211,13 +1247,13 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
<div className="space-y-2">
|
||||
<div>
|
||||
<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>
|
||||
<hr className="border-border" />
|
||||
<div className="space-y-3">
|
||||
{entityJoinColumns.joinTables.map((joinTable, tableIndex) => (
|
||||
<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" />
|
||||
<span>{joinTable.tableName}</span>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
|
|
@ -1240,13 +1276,16 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
<div
|
||||
key={colIndex}
|
||||
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",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isAlreadyAdded) {
|
||||
// 컬럼 제거
|
||||
handleChange("columns", config.columns?.filter((c) => c.columnName !== matchingJoinColumn.joinAlias) || []);
|
||||
handleChange(
|
||||
"columns",
|
||||
config.columns?.filter((c) => c.columnName !== matchingJoinColumn.joinAlias) || [],
|
||||
);
|
||||
} else {
|
||||
// 컬럼 추가
|
||||
addEntityColumn(matchingJoinColumn);
|
||||
|
|
@ -1257,16 +1296,22 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
checked={isAlreadyAdded}
|
||||
onCheckedChange={() => {
|
||||
if (isAlreadyAdded) {
|
||||
handleChange("columns", config.columns?.filter((c) => c.columnName !== matchingJoinColumn.joinAlias) || []);
|
||||
handleChange(
|
||||
"columns",
|
||||
config.columns?.filter((c) => c.columnName !== matchingJoinColumn.joinAlias) ||
|
||||
[],
|
||||
);
|
||||
} else {
|
||||
addEntityColumn(matchingJoinColumn);
|
||||
}
|
||||
}}
|
||||
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="text-[10px] text-blue-400 ml-auto">{column.inputType || column.dataType}</span>
|
||||
<span className="ml-auto text-[10px] text-blue-400">
|
||||
{column.inputType || column.dataType}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
@ -1301,7 +1346,6 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
onConfigChange={(dataFilter) => handleChange("dataFilter", dataFilter)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -148,9 +148,53 @@ export const componentV2Schema = z.object({
|
|||
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([]),
|
||||
});
|
||||
|
||||
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(),
|
||||
screenResolution: z
|
||||
.object({
|
||||
|
|
@ -952,23 +996,78 @@ export function saveComponentV2(component: ComponentV2 & { config?: Record<strin
|
|||
// ============================================
|
||||
// V2 레이아웃 로드 (전체 컴포넌트 기본값 병합)
|
||||
// ============================================
|
||||
export function loadLayoutV2(
|
||||
layoutData: any,
|
||||
): LayoutV2 & { components: Array<ComponentV2 & { config: Record<string, any> }> } {
|
||||
const parsed = layoutV2Schema.parse(layoutData || { version: "2.0", components: [] });
|
||||
export function loadLayoutV2(layoutData: 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.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 {
|
||||
...parsed,
|
||||
components: parsed.components.map(loadComponentV2),
|
||||
layers: loadedLayers,
|
||||
components: allComponents,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 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.0",
|
||||
components: components.map(saveComponentV2),
|
||||
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 {
|
||||
version: "2.1",
|
||||
layers: [defaultLayer],
|
||||
components: savedComponents,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ export interface BaseComponent {
|
|||
gridColumnStart?: number; // 시작 컬럼 (1-12)
|
||||
gridRowIndex?: number; // 행 인덱스
|
||||
|
||||
// 🆕 레이어 시스템
|
||||
layerId?: string; // 컴포넌트가 속한 레이어 ID
|
||||
|
||||
parentId?: string;
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
|
|
@ -108,7 +111,7 @@ export interface WidgetComponent extends BaseComponent {
|
|||
enabled: boolean; // 자동 입력 활성화
|
||||
sourceTable: string; // 조회할 테이블 (예: company_mng)
|
||||
filterColumn: string; // 필터링할 컬럼 (예: company_code)
|
||||
userField: 'companyCode' | 'userId' | 'deptCode'; // 사용자 정보 필드
|
||||
userField: "companyCode" | "userId" | "deptCode"; // 사용자 정보 필드
|
||||
displayColumn: string; // 표시할 컬럼 (예: company_name)
|
||||
};
|
||||
}
|
||||
|
|
@ -153,7 +156,7 @@ export interface DataTableComponent extends BaseComponent {
|
|||
autoFilter?: {
|
||||
enabled: boolean; // 자동 필터 활성화 여부
|
||||
filterColumn: string; // 필터링할 테이블 컬럼 (예: company_code, dept_code)
|
||||
userField: 'companyCode' | 'userId' | 'deptCode'; // 사용자 정보에서 가져올 필드
|
||||
userField: "companyCode" | "userId" | "deptCode"; // 사용자 정보에서 가져올 필드
|
||||
};
|
||||
|
||||
// 🆕 컬럼 값 기반 데이터 필터링
|
||||
|
|
@ -836,12 +839,71 @@ export interface GroupState {
|
|||
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 {
|
||||
screenId: number;
|
||||
components: ComponentData[];
|
||||
components: ComponentData[]; // @deprecated - use layers instead (kept for backward compatibility)
|
||||
layers?: LayerDefinition[]; // 🆕 레이어 목록
|
||||
gridSettings?: GridSettings;
|
||||
metadata?: LayoutMetadata;
|
||||
screenResolution?: ScreenResolution;
|
||||
|
|
|
|||
Loading…
Reference in New Issue