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

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

View File

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