feat: 레이어 시스템 추가 및 관리 기능 구현
- InteractiveScreenViewer 컴포넌트에 레이어 시스템을 도입하여, 레이어의 활성화 및 조건부 표시 로직을 추가하였습니다. - ScreenDesigner 컴포넌트에서 레이어 상태 관리 및 레이어 정보 저장 기능을 구현하였습니다. - 레이어 정의 및 조건부 표시 설정을 위한 새로운 타입과 스키마를 추가하여, 레이어 기반의 UI 구성 요소를 보다 유연하게 관리할 수 있도록 하였습니다. - 레이어별 컴포넌트 렌더링 로직을 추가하여, 모달 및 드로어 형태의 레이어를 효과적으로 처리할 수 있도록 개선하였습니다. - 전반적으로 레이어 시스템을 통해 사용자 경험을 향상시키고, UI 구성의 유연성을 높였습니다.
This commit is contained in:
parent
e31bb970a2
commit
4e2209bd5d
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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;
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 { 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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue