feat: Enhance screen management with conditional layer and zone handling

- Updated the ScreenManagementService to allow general companies to query both their own zones and common zones.
- Improved the ScreenViewPage to include detailed logging for loaded conditional layers and zones.
- Added functionality to ignore empty targetComponentId in condition evaluations.
- Enhanced the EditModal and LayerManagerPanel to support loading conditional layers and dynamic options based on selected zones.
- Implemented additional tab configurations to manage entity join columns effectively in the SplitPanelLayout components.
This commit is contained in:
DDD1542
2026-02-09 19:36:06 +09:00
parent 30ee36f881
commit 45029bf5f4
9 changed files with 1729 additions and 489 deletions

View File

@@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import {
AlertDialog,
@@ -24,6 +24,7 @@ import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHei
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
interface ScreenModalState {
isOpen: boolean;
@@ -71,6 +72,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 🆕 선택된 데이터 상태 (RepeatScreenModal 등에서 사용)
const [selectedData, setSelectedData] = useState<Record<string, any>[]>([]);
// 🆕 조건부 레이어 상태 (Zone 기반)
const [conditionalLayers, setConditionalLayers] = useState<(LayerDefinition & { components: ComponentData[]; zone?: ConditionalZone })[]>([]);
// 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해)
const [continuousMode, setContinuousMode] = useState(false);
@@ -80,6 +84,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 모달 닫기 확인 다이얼로그 표시 상태
const [showCloseConfirm, setShowCloseConfirm] = useState(false);
// 사용자가 폼 데이터를 실제로 변경했는지 추적 (변경 없으면 경고 없이 바로 닫기)
const formDataChangedRef = useRef(false);
// localStorage에서 연속 모드 상태 복원
useEffect(() => {
const savedMode = localStorage.getItem("screenModal_continuousMode");
@@ -122,9 +129,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const contentWidth = maxX - minX;
const contentHeight = maxY - minY;
// 적절한 여백 추가
const paddingX = 40;
const paddingY = 40;
// 여백 없이 컨텐츠 크기 그대로 사용
const paddingX = 0;
const paddingY = 0;
const finalWidth = Math.max(contentWidth + paddingX, 400);
const finalHeight = Math.max(contentHeight + paddingY, 300);
@@ -132,8 +139,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
return {
width: Math.min(finalWidth, window.innerWidth * 0.95),
height: Math.min(finalHeight, window.innerHeight * 0.9),
offsetX: Math.max(0, minX - paddingX / 2), // 좌측 여백 고려
offsetY: Math.max(0, minY - paddingY / 2), // 상단 여백 고려
offsetX: Math.max(0, minX), // 여백 없이 컨텐츠 시작점 기준
offsetY: Math.max(0, minY), // 여백 없이 컨텐츠 시작점 기준
};
};
@@ -177,6 +184,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 🆕 모달 열린 시간 기록
modalOpenedAtRef.current = Date.now();
// 폼 변경 추적 초기화
formDataChangedRef.current = false;
// 🆕 선택된 데이터 저장 (RepeatScreenModal 등에서 사용)
if (eventSelectedData && Array.isArray(eventSelectedData)) {
setSelectedData(eventSelectedData);
@@ -353,6 +363,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
if (isContinuousMode) {
// 연속 모드: 폼만 초기화하고 모달은 유지
formDataChangedRef.current = false;
setFormData({});
setResetKey((prev) => prev + 1);
@@ -519,6 +530,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
components,
screenInfo: screenInfo,
});
// 🆕 조건부 레이어/존 로드
loadConditionalLayersAndZones(screenId);
} else {
throw new Error("화면 데이터가 없습니다");
}
@@ -531,9 +545,155 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
}
};
// 사용자가 바깥 클릭/ESC/X 버튼으로 닫으려 할 때 확인 다이얼로그 표시
// 🆕 조건부 레이어 & 존 로드 함수
const loadConditionalLayersAndZones = async (screenId: number) => {
try {
const [layersRes, zonesRes] = await Promise.all([
screenApi.getScreenLayers(screenId),
screenApi.getScreenZones(screenId),
]);
const loadedLayers = layersRes || [];
const loadedZones: ConditionalZone[] = zonesRes || [];
// 기본 레이어(layer_id=1) 제외
const nonBaseLayers = loadedLayers.filter((l: any) => l.layer_id !== 1);
if (nonBaseLayers.length === 0) {
setConditionalLayers([]);
return;
}
const layerDefs: (LayerDefinition & { components: ComponentData[] })[] = [];
for (const layer of nonBaseLayers) {
try {
const layerLayout = await screenApi.getLayerLayout(screenId, layer.layer_id);
let layerComponents: ComponentData[] = [];
if (layerLayout && isValidV2Layout(layerLayout)) {
const legacyLayout = convertV2ToLegacy(layerLayout);
layerComponents = (legacyLayout?.components || []) as unknown as ComponentData[];
} else if (layerLayout?.components) {
layerComponents = layerLayout.components;
}
// condition_config에서 zone_id, condition_value 추출
const cc = layer.condition_config || {};
const zone = loadedZones.find((z) => z.zone_id === cc.zone_id);
layerDefs.push({
id: `layer-${layer.layer_id}`,
name: layer.layer_name || `레이어 ${layer.layer_id}`,
type: "conditional",
zIndex: layer.layer_id,
isVisible: false,
isLocked: false,
zoneId: cc.zone_id,
conditionValue: cc.condition_value,
condition: zone
? {
targetComponentId: zone.trigger_component_id || "",
operator: (zone.trigger_operator || "eq") as any,
value: cc.condition_value || "",
}
: undefined,
components: layerComponents,
zone: zone || undefined, // 🆕 Zone 위치 정보 포함 (오프셋 계산용)
} as any);
} catch (err) {
console.warn(`[ScreenModal] 레이어 ${layer.layer_id} 로드 실패:`, err);
}
}
console.log("[ScreenModal] 조건부 레이어 로드 완료:", layerDefs.length, "개",
layerDefs.map((l) => ({
id: l.id, name: l.name, conditionValue: l.conditionValue,
componentCount: l.components.length,
condition: l.condition,
}))
);
setConditionalLayers(layerDefs);
} catch (error) {
console.error("[ScreenModal] 조건부 레이어 로드 실패:", error);
}
};
// 🆕 조건부 레이어 활성화 평가 (formData 변경 시)
const activeConditionalComponents = useMemo(() => {
if (conditionalLayers.length === 0) return [];
const allComponents = screenData?.components || [];
const activeComps: ComponentData[] = [];
conditionalLayers.forEach((layer) => {
if (!layer.condition) return;
const { targetComponentId, operator, value } = layer.condition;
if (!targetComponentId) return;
// V2 레이아웃: overrides.columnName 우선
const comp = allComponents.find((c: any) => c.id === targetComponentId);
const fieldKey =
(comp as any)?.overrides?.columnName ||
(comp as any)?.columnName ||
(comp as any)?.componentConfig?.columnName ||
targetComponentId;
const targetValue = formData[fieldKey];
let isMatch = false;
switch (operator) {
case "eq":
isMatch = String(targetValue ?? "") === String(value ?? "");
break;
case "neq":
isMatch = String(targetValue ?? "") !== String(value ?? "");
break;
case "in":
if (Array.isArray(value)) {
isMatch = value.some((v) => String(v) === String(targetValue ?? ""));
} else if (typeof value === "string" && value.includes(",")) {
isMatch = value.split(",").map((v) => v.trim()).includes(String(targetValue ?? ""));
}
break;
}
console.log("[ScreenModal] 레이어 조건 평가:", {
layerName: layer.name, fieldKey,
targetValue: String(targetValue ?? "(없음)"),
conditionValue: String(value), operator, isMatch,
});
if (isMatch) {
// Zone 오프셋 적용 (레이어 2 컴포넌트는 Zone 상대 좌표로 저장됨)
const zoneX = layer.zone?.x || 0;
const zoneY = layer.zone?.y || 0;
const offsetComponents = layer.components.map((c: any) => ({
...c,
position: {
...c.position,
x: parseFloat(c.position?.x?.toString() || "0") + zoneX,
y: parseFloat(c.position?.y?.toString() || "0") + zoneY,
},
}));
activeComps.push(...offsetComponents);
}
});
return activeComps;
}, [formData, conditionalLayers, screenData?.components]);
// 사용자가 바깥 클릭/ESC/X 버튼으로 닫으려 할 때
// 폼 데이터 변경이 있으면 확인 다이얼로그, 없으면 바로 닫기
const handleCloseAttempt = useCallback(() => {
setShowCloseConfirm(true);
if (formDataChangedRef.current) {
setShowCloseConfirm(true);
} else {
handleCloseInternal();
}
}, []);
// 확인 후 실제로 모달을 닫는 함수
@@ -569,6 +729,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
setFormData({}); // 폼 데이터 초기화
setOriginalData(null); // 원본 데이터 초기화
setSelectedData([]); // 선택된 데이터 초기화
setConditionalLayers([]); // 🆕 조건부 레이어 초기화
setContinuousMode(false);
localStorage.setItem("screenModal_continuousMode", "false");
};
@@ -580,36 +741,21 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const getModalStyle = () => {
if (!screenDimensions) {
return {
className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0",
style: undefined, // undefined로 변경 - defaultWidth/defaultHeight 사용
needsScroll: false,
className: "w-fit min-w-[400px] max-w-4xl overflow-hidden",
style: { padding: 0, gap: 0, maxHeight: "calc(100dvh - 8px)" },
};
}
// 화면관리에서 설정한 크기 = 컨텐츠 영역 크기
// 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스 + gap + 패딩
// 🔧 DialogContent의 gap-4 (16px × 2) + 컨텐츠 pt-6 (24px) 포함
const headerHeight = 48; // DialogHeader (타이틀 + border-b + py-3)
const footerHeight = 44; // 연속 등록 모드 체크박스 영역
const dialogGap = 32; // gap-4 × 2 (header-content, content-footer 사이)
const contentTopPadding = 24; // pt-6 (컨텐츠 영역 상단 패딩)
const horizontalPadding = 16; // 좌우 패딩 최소화
const totalHeight = screenDimensions.height + headerHeight + footerHeight + dialogGap + contentTopPadding;
const maxAvailableHeight = window.innerHeight * 0.95;
// 콘텐츠가 화면 높이를 초과할 때만 스크롤 필요
const needsScroll = totalHeight > maxAvailableHeight;
return {
className: "overflow-hidden p-0",
className: "overflow-hidden",
style: {
width: `${Math.min(screenDimensions.width + horizontalPadding, window.innerWidth * 0.98)}px`,
// 🔧 height 대신 max-height만 설정 - 콘텐츠가 작으면 자동으로 줄어듦
maxHeight: `${maxAvailableHeight}px`,
width: `${Math.min(screenDimensions.width, window.innerWidth * 0.98)}px`,
// CSS가 알아서 처리: 뷰포트 안에 들어가면 auto-height, 넘치면 max-height로 제한
maxHeight: "calc(100dvh - 8px)",
maxWidth: "98vw",
padding: 0,
gap: 0,
},
needsScroll,
};
};
@@ -686,7 +832,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
>
<DialogContent
className={`${modalStyle.className} ${className || ""} max-w-none flex flex-col`}
{...(modalStyle.style && { style: modalStyle.style })}
style={modalStyle.style}
// 바깥 클릭 시 바로 닫히지 않도록 방지
onInteractOutside={(e) => {
e.preventDefault();
@@ -711,7 +857,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
</DialogHeader>
<div
className={`flex-1 min-h-0 flex items-start justify-center pt-6 ${modalStyle.needsScroll ? 'overflow-auto' : 'overflow-visible'}`}
className="flex-1 min-h-0 flex items-start justify-center overflow-auto"
>
{loading ? (
<div className="flex h-full items-center justify-center">
@@ -727,8 +873,22 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
className="relative bg-white"
style={{
width: `${screenDimensions?.width || 800}px`,
height: `${screenDimensions?.height || 600}px`,
// 🆕 라벨이 위로 튀어나갈 수 있도록 overflow visible 설정
// 🆕 조건부 레이어 활성화 시 높이 자동 확장
minHeight: `${screenDimensions?.height || 600}px`,
height: (() => {
const baseHeight = screenDimensions?.height || 600;
if (activeConditionalComponents.length > 0) {
const offsetY = screenDimensions?.offsetY || 0;
let maxBottom = 0;
activeConditionalComponents.forEach((comp: any) => {
const y = parseFloat(comp.position?.y?.toString() || "0") - offsetY;
const h = parseFloat(comp.size?.height?.toString() || "40");
maxBottom = Math.max(maxBottom, y + h);
});
return `${Math.max(baseHeight, maxBottom + 20)}px`;
}
return `${baseHeight}px`;
})(),
overflow: "visible",
}}
>
@@ -864,6 +1024,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
formData={formData}
originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용)
onFormDataChange={(fieldName, value) => {
// 사용자가 실제로 데이터를 변경한 것으로 표시
formDataChangedRef.current = true;
setFormData((prev) => {
const newFormData = {
...prev,
@@ -888,6 +1050,48 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
);
});
})()}
{/* 🆕 조건부 레이어 컴포넌트 렌더링 */}
{activeConditionalComponents.map((component: any) => {
const offsetX = screenDimensions?.offsetX || 0;
const offsetY = screenDimensions?.offsetY || 0;
const adjustedComponent = {
...component,
position: {
...component.position,
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
},
};
return (
<InteractiveScreenViewerDynamic
key={`conditional-${component.id}-${resetKey}`}
component={adjustedComponent}
allComponents={[...(screenData?.components || []), ...activeConditionalComponents]}
formData={formData}
originalData={originalData}
onFormDataChange={(fieldName, value) => {
formDataChangedRef.current = true;
setFormData((prev) => ({
...prev,
[fieldName]: value,
}));
}}
onRefresh={() => {
window.dispatchEvent(new CustomEvent("refreshTable"));
}}
screenInfo={{
id: modalState.screenId!,
tableName: screenData?.screenInfo?.tableName,
}}
userId={userId}
userName={userName}
companyCode={user?.companyCode}
/>
);
})}
</div>
</TableOptionsProvider>
</ActiveTabProvider>