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

@ -5379,9 +5379,11 @@ export class ScreenManagementService {
[screenId],
);
} else {
// 일반 회사: 자사 Zone만 조회 (company_code = '*' 제외)
// 일반 회사: 자사 Zone + 공통(*) Zone 조회
zones = await query<any>(
`SELECT * FROM screen_conditional_zones WHERE screen_id = $1 AND company_code = $2 ORDER BY zone_id`,
`SELECT * FROM screen_conditional_zones
WHERE screen_id = $1 AND (company_code = $2 OR company_code = '*')
ORDER BY zone_id`,
[screenId, companyCode],
);
}

View File

@ -294,6 +294,16 @@ function ScreenViewPage() {
console.log("🔄 조건부 레이어 로드 완료:", layerDefinitions.length, "개", layerDefinitions.map(l => ({
id: l.id, name: l.name, zoneId: l.zoneId, conditionValue: l.conditionValue,
componentCount: l.components.length,
condition: l.condition ? {
targetComponentId: l.condition.targetComponentId,
operator: l.condition.operator,
value: l.condition.value,
} : "없음",
})));
console.log("🗺️ Zone 정보:", loadedZones.map(z => ({
zone_id: z.zone_id,
trigger_component_id: z.trigger_component_id,
trigger_operator: z.trigger_operator,
})));
setConditionalLayers(layerDefinitions);
} catch (error) {
@ -315,6 +325,9 @@ function ScreenViewPage() {
if (layer.condition) {
const { targetComponentId, operator, value } = layer.condition;
// 빈 targetComponentId는 무시
if (!targetComponentId) return;
// 트리거 컴포넌트 찾기 (기본 레이어에서)
const targetComponent = allComponents.find((c) => c.id === targetComponentId);
@ -329,16 +342,36 @@ function ScreenViewPage() {
let isMatch = false;
switch (operator) {
case "eq":
isMatch = targetValue == value;
// 문자열로 변환하여 비교 (타입 불일치 방지)
isMatch = String(targetValue ?? "") === String(value ?? "");
break;
case "neq":
isMatch = targetValue != value;
isMatch = String(targetValue ?? "") !== String(value ?? "");
break;
case "in":
isMatch = Array.isArray(value) && value.includes(targetValue);
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;
}
// 디버그 로깅 (값이 존재할 때만)
if (targetValue !== undefined && targetValue !== "") {
console.log("🔍 [레이어 조건 평가]", {
layerId: layer.id,
layerName: layer.name,
targetComponentId,
fieldKey,
targetValue: String(targetValue),
conditionValue: String(value),
operator,
isMatch,
});
}
if (isMatch) {
newActiveIds.push(layer.id);
}

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(() => {
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>

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useMemo } from "react";
import {
Dialog,
DialogContent,
@ -15,6 +15,8 @@ import { ComponentData } from "@/types/screen";
import { toast } from "sonner";
import { dynamicFormApi } from "@/lib/api/dynamicForm";
import { useAuth } from "@/hooks/useAuth";
import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
interface EditModalState {
isOpen: boolean;
@ -116,6 +118,10 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
const [groupData, setGroupData] = useState<Record<string, any>[]>([]);
const [originalGroupData, setOriginalGroupData] = useState<Record<string, any>[]>([]);
// 🆕 조건부 레이어 상태 (Zone 기반)
const [zones, setZones] = useState<ConditionalZone[]>([]);
const [conditionalLayers, setConditionalLayers] = useState<LayerDefinition[]>([]);
// 화면의 실제 크기 계산 함수 (ScreenModal과 동일)
const calculateScreenDimensions = (components: ComponentData[]) => {
if (components.length === 0) {
@ -360,16 +366,12 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
try {
setLoading(true);
// console.log("화면 데이터 로딩 시작:", screenId);
// 화면 정보와 레이아웃 데이터 로딩
const [screenInfo, layoutData] = await Promise.all([
screenApi.getScreen(screenId),
screenApi.getLayout(screenId),
]);
// console.log("API 응답:", { screenInfo, layoutData });
if (screenInfo && layoutData) {
const components = layoutData.components || [];
@ -381,11 +383,14 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
components,
screenInfo: screenInfo,
});
// console.log("화면 데이터 설정 완료:", {
// componentsCount: components.length,
// dimensions,
// screenInfo,
// });
// 🆕 조건부 레이어/존 로드 (await으로 에러 포착)
console.log("[EditModal] 화면 데이터 로드 완료, 조건부 레이어 로드 시작:", screenId);
try {
await loadConditionalLayersAndZones(screenId, components);
} catch (layerErr) {
console.error("[EditModal] 조건부 레이어 로드 에러:", layerErr);
}
} else {
throw new Error("화면 데이터가 없습니다");
}
@ -398,6 +403,165 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
}
};
// 🆕 조건부 레이어 & 존 로드 함수
const loadConditionalLayersAndZones = async (screenId: number, baseComponents: ComponentData[]) => {
console.log("[EditModal] loadConditionalLayersAndZones 호출됨:", screenId);
try {
// 레이어 목록 & 존 목록 병렬 로드
console.log("[EditModal] API 호출 시작: getScreenLayers, getScreenZones");
const [layersRes, zonesRes] = await Promise.all([
screenApi.getScreenLayers(screenId),
screenApi.getScreenZones(screenId),
]);
console.log("[EditModal] API 응답:", { layers: layersRes?.length, zones: zonesRes?.length });
const loadedLayers = layersRes || [];
const loadedZones: ConditionalZone[] = zonesRes || [];
setZones(loadedZones);
// 기본 레이어(layer_id=1) 제외한 조건부 레이어 처리
const nonBaseLayers = loadedLayers.filter((l: any) => l.layer_id !== 1);
if (nonBaseLayers.length === 0) {
setConditionalLayers([]);
return;
}
// 각 조건부 레이어의 컴포넌트 로드
const layerDefinitions: LayerDefinition[] = [];
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 conditionConfig = layer.condition_config || {};
const layerZoneId = conditionConfig.zone_id;
const layerConditionValue = conditionConfig.condition_value;
// 이 레이어가 속한 Zone 찾기
const associatedZone = loadedZones.find(
(z) => z.zone_id === layerZoneId
);
layerDefinitions.push({
id: `layer-${layer.layer_id}`,
name: layer.layer_name || `레이어 ${layer.layer_id}`,
type: "conditional",
zIndex: layer.layer_id,
isVisible: false,
isLocked: false,
zoneId: layerZoneId,
conditionValue: layerConditionValue,
condition: associatedZone
? {
targetComponentId: associatedZone.trigger_component_id || "",
operator: (associatedZone.trigger_operator || "eq") as any,
value: layerConditionValue || "",
}
: undefined,
components: layerComponents,
} as LayerDefinition & { components: ComponentData[] });
} catch (layerError) {
console.warn(`[EditModal] 레이어 ${layer.layer_id} 로드 실패:`, layerError);
}
}
console.log("[EditModal] 조건부 레이어 로드 완료:", layerDefinitions.length, "개",
layerDefinitions.map((l) => ({
id: l.id,
name: l.name,
conditionValue: l.conditionValue,
condition: l.condition,
}))
);
setConditionalLayers(layerDefinitions);
} catch (error) {
console.warn("[EditModal] 조건부 레이어 로드 실패:", error);
}
};
// 🆕 조건부 레이어 활성화 평가 (formData 변경 시)
const activeConditionalLayerIds = useMemo(() => {
if (conditionalLayers.length === 0) return [];
const newActiveIds: string[] = [];
const allComponents = screenData?.components || [];
conditionalLayers.forEach((layer) => {
const layerWithComponents = layer as LayerDefinition & { components: ComponentData[] };
if (layerWithComponents.condition) {
const { targetComponentId, operator, value } = layerWithComponents.condition;
if (!targetComponentId) return;
// 트리거 컴포넌트의 columnName 찾기
// V2 레이아웃: overrides.columnName, 레거시: componentConfig.columnName
const targetComponent = allComponents.find((c) => c.id === targetComponentId);
const fieldKey =
(targetComponent as any)?.overrides?.columnName ||
(targetComponent as any)?.columnName ||
(targetComponent as any)?.componentConfig?.columnName ||
targetComponentId;
const currentFormData = groupData.length > 0 ? groupData[0] : formData;
const targetValue = currentFormData[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("[EditModal] 레이어 조건 평가:", {
layerId: layer.id,
layerName: layer.name,
targetComponentId,
fieldKey,
targetValue: targetValue !== undefined ? String(targetValue) : "(없음)",
conditionValue: String(value),
operator,
isMatch,
componentFound: !!targetComponent,
});
if (isMatch) {
newActiveIds.push(layer.id);
}
}
});
return newActiveIds;
}, [formData, groupData, conditionalLayers, screenData?.components]);
// 🆕 활성화된 조건부 레이어의 컴포넌트 가져오기
const activeConditionalComponents = useMemo(() => {
return conditionalLayers
.filter((layer) => activeConditionalLayerIds.includes(layer.id))
.flatMap((layer) => (layer as LayerDefinition & { components: ComponentData[] }).components || []);
}, [conditionalLayers, activeConditionalLayerIds]);
const handleClose = () => {
setModalState({
isOpen: false,
@ -412,6 +576,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
});
setScreenData(null);
setFormData({});
setZones([]);
setConditionalLayers([]);
setOriginalData({});
setGroupData([]); // 🆕
setOriginalGroupData([]); // 🆕
@ -1151,12 +1317,27 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
className="relative bg-white"
style={{
width: screenDimensions?.width || 800,
height: (screenDimensions?.height || 600) + 30, // 라벨 공간 추가
// 🆕 조건부 레이어가 활성화되면 높이 자동 확장
height: (() => {
const baseHeight = (screenDimensions?.height || 600) + 30;
if (activeConditionalComponents.length > 0) {
// 조건부 레이어 컴포넌트 중 가장 아래 위치 계산
const offsetY = screenDimensions?.offsetY || 0;
let maxBottom = 0;
activeConditionalComponents.forEach((comp) => {
const y = parseFloat(comp.position?.y?.toString() || "0") - offsetY + 30;
const h = parseFloat(comp.size?.height?.toString() || "40");
maxBottom = Math.max(maxBottom, y + h);
});
return Math.max(baseHeight, maxBottom + 20); // 20px 여백
}
return baseHeight;
})(),
transformOrigin: "center center",
maxWidth: "100%",
maxHeight: "100%",
}}
>
{/* 기본 레이어 컴포넌트 렌더링 */}
{screenData.components.map((component) => {
// 컴포넌트 위치를 offset만큼 조정
const offsetX = screenDimensions?.offsetX || 0;
@ -1174,49 +1355,37 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
// 🆕 UniversalFormModal이 있는지 확인 (자체 저장 로직 사용)
// 최상위 컴포넌트에 universal-form-modal이 있는지 확인
// ⚠️ 수정: conditional-container는 제외 (groupData가 있으면 EditModal.handleSave 사용)
const hasUniversalFormModal = screenData.components.some(
(c) => {
// 최상위에 universal-form-modal이 있는 경우만 자체 저장 로직 사용
if (c.componentType === "universal-form-modal") return true;
return false;
}
);
// 🆕 _tableSection_ 데이터가 있는지 확인 (TableSectionRenderer 사용 시)
// _tableSection_ 데이터가 있으면 buttonActions.ts의 handleUniversalFormModalTableSectionSave가 처리
const hasTableSectionData = Object.keys(formData).some(k =>
k.startsWith("_tableSection_") || k.startsWith("__tableSection_")
);
// 🆕 그룹 데이터가 있으면 EditModal.handleSave 사용 (일괄 저장)
// 단, _tableSection_ 데이터가 있으면 EditModal.handleSave 사용하지 않음 (buttonActions.ts가 처리)
const shouldUseEditModalSave = !hasTableSectionData && (groupData.length > 0 || !hasUniversalFormModal);
// 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가
const enrichedFormData = {
...(groupData.length > 0 ? groupData[0] : formData),
tableName: screenData.screenInfo?.tableName, // 테이블명 추가
screenId: modalState.screenId, // 화면 ID 추가
tableName: screenData.screenInfo?.tableName,
screenId: modalState.screenId,
};
return (
<InteractiveScreenViewerDynamic
key={component.id}
component={adjustedComponent}
allComponents={screenData.components}
allComponents={[...screenData.components, ...activeConditionalComponents]}
formData={enrichedFormData}
originalData={originalData} // 🆕 원본 데이터 전달 (수정 모드에서 UniversalFormModal 초기화용)
originalData={originalData}
onFormDataChange={(fieldName, value) => {
// 🆕 그룹 데이터가 있으면 처리
if (groupData.length > 0) {
// ModalRepeaterTable의 경우 배열 전체를 받음
if (Array.isArray(value)) {
setGroupData(value);
} else {
// 일반 필드는 모든 항목에 동일하게 적용
setGroupData((prev) =>
prev.map((item) => ({
...item,
@ -1235,19 +1404,74 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName,
}}
// 🆕 메뉴 OBJID 전달 (카테고리 스코프용)
menuObjid={modalState.menuObjid}
// 🆕 그룹 데이터가 있거나 UniversalFormModal이 없으면 EditModal.handleSave 사용
// groupData가 있으면 일괄 저장을 위해 반드시 EditModal.handleSave 사용
onSave={shouldUseEditModalSave ? handleSave : undefined}
isInModal={true}
// 🆕 그룹 데이터를 ModalRepeaterTable에 전달
groupedData={groupedDataProp}
// 🆕 수정 모달에서 읽기 전용 필드 지정 (수주번호, 거래처)
disabledFields={["order_no", "partner_id"]}
/>
);
})}
{/* 🆕 조건부 레이어 컴포넌트 렌더링 */}
{activeConditionalComponents.map((component) => {
const offsetX = screenDimensions?.offsetX || 0;
const offsetY = screenDimensions?.offsetY || 0;
const labelSpace = 30;
const adjustedComponent = {
...component,
position: {
...component.position,
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace,
},
};
const enrichedFormData = {
...(groupData.length > 0 ? groupData[0] : formData),
tableName: screenData.screenInfo?.tableName,
screenId: modalState.screenId,
};
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
return (
<InteractiveScreenViewerDynamic
key={`conditional-${component.id}`}
component={adjustedComponent}
allComponents={[...screenData.components, ...activeConditionalComponents]}
formData={enrichedFormData}
originalData={originalData}
onFormDataChange={(fieldName, value) => {
if (groupData.length > 0) {
if (Array.isArray(value)) {
setGroupData(value);
} else {
setGroupData((prev) =>
prev.map((item) => ({
...item,
[fieldName]: value,
})),
);
}
} else {
setFormData((prev) => ({
...prev,
[fieldName]: value,
}));
}
}}
screenInfo={{
id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName,
}}
menuObjid={modalState.menuObjid}
isInModal={true}
groupedData={groupedDataProp}
/>
);
})}
</div>
) : (
<div className="flex h-full items-center justify-center">

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -19,6 +19,7 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { screenApi } from "@/lib/api/screen";
import { apiClient } from "@/lib/api/client";
import { toast } from "sonner";
import { ComponentData, ConditionalZone } from "@/types/screen-management";
@ -167,6 +168,99 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
}
}, [screenId, loadLayers, onLayerChange, onZonesChange]);
// 동적 소스 옵션 캐시 (trigger_component_id → 옵션 배열)
const [dynamicOptionsCache, setDynamicOptionsCache] = useState<Record<string, { value: string; label: string }[]>>({});
const [loadingDynamicOptions, setLoadingDynamicOptions] = useState<Set<string>>(new Set());
// 이미 로드 시도한 키를 추적 (중복 요청 방지)
const loadedKeysRef = useRef<Set<string>>(new Set());
// 동적 소스 옵션 로드 함수
const loadDynamicOptions = useCallback(async (triggerCompId: string, comp: ComponentData) => {
const cacheKey = triggerCompId;
// 이미 로드 완료 또는 로드 중이면 스킵
if (loadedKeysRef.current.has(cacheKey)) return;
loadedKeysRef.current.add(cacheKey);
setLoadingDynamicOptions(prev => new Set(prev).add(cacheKey));
try {
const config = comp.componentConfig || {};
const isCategory = (comp as any).inputType === "category" || (comp as any).webType === "category";
const source = isCategory ? "category" : config.source;
const compTableName = (comp as any).tableName || config.tableName;
const compColumnName = (comp as any).columnName || config.columnName;
let fetchedOptions: { value: string; label: string }[] = [];
if (source === "category" || isCategory) {
// 카테고리 소스: /table-categories/:tableName/:columnName/values
const catTable = config.categoryTable || compTableName;
const catColumn = config.categoryColumn || compColumnName;
if (catTable && catColumn) {
const response = await apiClient.get(`/table-categories/${catTable}/${catColumn}/values`);
const data = response.data;
if (data.success && data.data) {
// 트리 구조를 평탄화 (valueCode/valueLabel 사용)
const flattenTree = (items: any[]): { value: string; label: string }[] => {
const result: { value: string; label: string }[] = [];
for (const item of items) {
result.push({ value: item.valueCode, label: item.valueLabel });
if (item.children && item.children.length > 0) {
result.push(...flattenTree(item.children));
}
}
return result;
};
fetchedOptions = flattenTree(data.data);
}
}
} else if (source === "code" && config.codeGroup) {
// 공통코드 소스
const response = await apiClient.get(`/common-codes/categories/${config.codeGroup}/options`);
const data = response.data;
if (data.success && data.data) {
fetchedOptions = data.data.map((item: { value: string; label: string }) => ({
value: item.value,
label: item.label,
}));
}
} else if (source === "entity" && config.entityTable) {
// 엔티티 소스
const valueCol = config.entityValueColumn || "id";
const labelCol = config.entityLabelColumn || "name";
const response = await apiClient.get(`/entity/${config.entityTable}/options`, {
params: { value: valueCol, label: labelCol },
});
const data = response.data;
if (data.success && data.data) {
fetchedOptions = data.data;
}
} else if ((source === "distinct" || source === "select") && compTableName && compColumnName) {
// DISTINCT 소스
const isValidCol = compColumnName && !compColumnName.startsWith("comp_");
if (isValidCol) {
const response = await apiClient.get(`/entity/${compTableName}/distinct/${compColumnName}`);
const data = response.data;
if (data.success && data.data) {
fetchedOptions = data.data.map((item: { value: string; label: string }) => ({
value: String(item.value),
label: String(item.label),
}));
}
}
}
setDynamicOptionsCache(prev => ({ ...prev, [cacheKey]: fetchedOptions }));
} catch (error) {
console.error("트리거 옵션 동적 로드 실패:", error);
setDynamicOptionsCache(prev => ({ ...prev, [cacheKey]: [] }));
} finally {
setLoadingDynamicOptions(prev => {
const next = new Set(prev);
next.delete(cacheKey);
return next;
});
}
}, []);
// Zone 트리거 컴포넌트 업데이트
const handleUpdateZoneTrigger = useCallback(async (zoneId: number, triggerComponentId: string, operator: string = "eq") => {
try {
@ -176,12 +270,20 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
});
const loadedZones = await screenApi.getScreenZones(screenId!);
onZonesChange?.(loadedZones);
// 트리거 변경 시 해당 컴포넌트의 동적 옵션 캐시 초기화 → 새로 로드
loadedKeysRef.current.delete(triggerComponentId);
const triggerComp = baseLayerComponents.find(c => c.id === triggerComponentId);
if (triggerComp) {
loadDynamicOptions(triggerComponentId, triggerComp);
}
toast.success("트리거가 설정되었습니다.");
} catch (error) {
console.error("Zone 트리거 업데이트 실패:", error);
toast.error("트리거 설정에 실패했습니다.");
}
}, [screenId, onZonesChange]);
}, [screenId, onZonesChange, baseLayerComponents, loadDynamicOptions]);
// Zone 접힘/펼침 토글
const toggleZone = (zoneId: number) => {
@ -197,21 +299,48 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
["select", "combobox", "radio-group"].some(t => c.componentType?.includes(t))
);
// Zone의 트리거 컴포넌트에서 옵션 목록 가져오기
// Zone 트리거가 변경되면 동적 옵션 로드
useEffect(() => {
for (const zone of zones) {
if (!zone.trigger_component_id) continue;
const triggerComp = baseLayerComponents.find(c => c.id === zone.trigger_component_id);
if (!triggerComp) continue;
const config = triggerComp.componentConfig || {};
const source = config.source;
const isCategory = (triggerComp as any).inputType === "category" || (triggerComp as any).webType === "category";
// 정적 옵션이 아닌 경우에만 동적 로드
const hasStaticOptions = config.options && Array.isArray(config.options) && config.options.length > 0;
if (!hasStaticOptions && (source === "category" || source === "code" || source === "entity" || source === "distinct" || source === "select" || isCategory)) {
loadDynamicOptions(zone.trigger_component_id, triggerComp);
}
}
}, [zones, baseLayerComponents, loadDynamicOptions]);
// Zone의 트리거 컴포넌트에서 옵션 목록 가져오기 (정적 + 동적 지원)
const getTriggerOptions = useCallback((zone: ConditionalZone): { value: string; label: string }[] => {
if (!zone.trigger_component_id) return [];
const triggerComp = baseLayerComponents.find(c => c.id === zone.trigger_component_id);
if (!triggerComp) return [];
const config = triggerComp.componentConfig || {};
// 정적 옵션 (v2-select static source)
if (config.options && Array.isArray(config.options)) {
// 1. 정적 옵션 우선 확인
if (config.options && Array.isArray(config.options) && config.options.length > 0) {
return config.options
.filter((opt: any) => opt.value)
.map((opt: any) => ({ value: opt.value, label: opt.label || opt.value }));
}
// 2. 동적 소스 옵션 (캐시에서 가져오기)
const cached = dynamicOptionsCache[zone.trigger_component_id];
if (cached && cached.length > 0) {
return cached;
}
return [];
}, [baseLayerComponents]);
}, [baseLayerComponents, dynamicOptionsCache]);
return (
<div className="flex h-full flex-col bg-background">
@ -435,6 +564,17 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
{addingToZoneId === zone.zone_id ? (
<div className="flex items-center gap-1 rounded border bg-background p-1.5">
{(() => {
// 동적 옵션 로딩 중 표시
const isLoadingOpts = zone.trigger_component_id ? loadingDynamicOptions.has(zone.trigger_component_id) : false;
if (isLoadingOpts) {
return (
<div className="flex items-center gap-1 flex-1 text-[11px] text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
);
}
const triggerOpts = getTriggerOptions(zone);
// 이미 사용된 조건값 제외
const usedValues = new Set(

View File

@ -2179,7 +2179,12 @@ export default function ScreenDesigner({
if (USE_POP_API) {
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
} else if (USE_V2_API) {
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
// 현재 활성 레이어 ID 포함 (레이어별 저장)
const currentLayerId = activeLayerIdRef.current || 1;
await screenApi.saveLayoutV2(selectedScreen.screenId, {
...v2Layout,
layerId: currentLayerId,
});
} else {
await screenApi.saveLayout(selectedScreen.screenId, updatedLayout);
}
@ -5594,7 +5599,12 @@ export default function ScreenDesigner({
if (USE_POP_API) {
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
} else if (USE_V2_API) {
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
// 현재 활성 레이어 ID 포함 (레이어별 저장)
const currentLayerId = activeLayerIdRef.current || 1;
await screenApi.saveLayoutV2(selectedScreen.screenId, {
...v2Layout,
layerId: currentLayerId,
});
} else {
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
}

View File

@ -199,6 +199,11 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [isLoadingRight, setIsLoadingRight] = useState(false);
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
// 추가 탭 관련 상태
const [activeTabIndex, setActiveTabIndex] = useState(0); // 0 = 기본 탭, 1+ = 추가 탭
const [tabsData, setTabsData] = useState<Record<number, any[]>>({}); // 탭별 데이터
const [tabsLoading, setTabsLoading] = useState<Record<number, boolean>>({}); // 탭별 로딩 상태
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
const [rightColumnLabels, setRightColumnLabels] = useState<Record<string, string>>({}); // 우측 컬럼 라벨
const [leftCategoryMappings, setLeftCategoryMappings] = useState<
@ -1255,14 +1260,109 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
],
);
// 추가 탭 데이터 로딩 함수
const loadTabData = useCallback(
async (tabIndex: number, leftItem: any) => {
const tabConfig = componentConfig.rightPanel?.additionalTabs?.[tabIndex - 1];
if (!tabConfig || !leftItem || isDesignMode) return;
const tabTableName = tabConfig.tableName;
if (!tabTableName) return;
setTabsLoading((prev) => ({ ...prev, [tabIndex]: true }));
try {
const keys = tabConfig.relation?.keys;
const leftColumn = tabConfig.relation?.leftColumn || keys?.[0]?.leftColumn;
const rightColumn = tabConfig.relation?.foreignKey || keys?.[0]?.rightColumn;
let resultData: any[] = [];
if (leftColumn && rightColumn) {
const searchConditions: Record<string, any> = {};
if (keys && keys.length > 0) {
keys.forEach((key: any) => {
if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
searchConditions[key.rightColumn] = {
value: leftItem[key.leftColumn],
operator: "equals",
};
}
});
} else {
const leftValue = leftItem[leftColumn];
if (leftValue !== undefined) {
searchConditions[rightColumn] = {
value: leftValue,
operator: "equals",
};
}
}
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
search: searchConditions,
enableEntityJoin: true,
size: 1000,
});
resultData = result.data || [];
} else {
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
enableEntityJoin: true,
size: 1000,
});
resultData = result.data || [];
}
setTabsData((prev) => ({ ...prev, [tabIndex]: resultData }));
} catch (error) {
console.error(`추가탭 ${tabIndex} 데이터 로드 실패:`, error);
toast({
title: "데이터 로드 실패",
description: `탭 데이터를 불러올 수 없습니다.`,
variant: "destructive",
});
} finally {
setTabsLoading((prev) => ({ ...prev, [tabIndex]: false }));
}
},
[componentConfig.rightPanel?.additionalTabs, isDesignMode, toast],
);
// 탭 변경 핸들러
const handleTabChange = useCallback(
(newTabIndex: number) => {
setActiveTabIndex(newTabIndex);
if (selectedLeftItem) {
if (newTabIndex === 0) {
if (!rightData || (Array.isArray(rightData) && rightData.length === 0)) {
loadRightData(selectedLeftItem);
}
} else {
if (!tabsData[newTabIndex]) {
loadTabData(newTabIndex, selectedLeftItem);
}
}
}
},
[selectedLeftItem, rightData, tabsData, loadRightData, loadTabData],
);
// 좌측 항목 선택 핸들러
const handleLeftItemSelect = useCallback(
(item: any) => {
setSelectedLeftItem(item);
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
loadRightData(item);
setTabsData({}); // 모든 탭 데이터 초기화
// 🆕 modalDataStore에 선택된 좌측 항목 저장 (단일 선택)
// 현재 활성 탭에 따라 데이터 로드
if (activeTabIndex === 0) {
loadRightData(item);
} else {
loadTabData(activeTabIndex, item);
}
// modalDataStore에 선택된 좌측 항목 저장 (단일 선택)
const leftTableName = componentConfig.leftPanel?.tableName;
if (leftTableName && !isDesignMode) {
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
@ -1271,7 +1371,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
});
}
},
[loadRightData, componentConfig.leftPanel?.tableName, isDesignMode],
[loadRightData, loadTabData, activeTabIndex, componentConfig.leftPanel?.tableName, isDesignMode],
);
// 우측 항목 확장/축소 토글
@ -1574,6 +1674,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}
});
// 🆕 추가 탭의 테이블도 카테고리 로드 대상에 포함
const additionalTabs = componentConfig.rightPanel?.additionalTabs || [];
additionalTabs.forEach((tab: any) => {
if (tab.tableName) {
tablesToLoad.add(tab.tableName);
}
// 추가 탭 컬럼에서 조인된 테이블 추출
(tab.columns || []).forEach((col: any) => {
const colName = col.name || col.columnName;
if (colName && colName.includes(".")) {
const joinTableName = colName.split(".")[0];
tablesToLoad.add(joinTableName);
}
});
});
console.log("🔍 우측 패널 카테고리 로드 대상 테이블:", Array.from(tablesToLoad));
// 각 테이블에 대해 카테고리 매핑 로드
@ -1625,7 +1741,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
};
loadRightCategoryMappings();
}, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.columns, isDesignMode]);
}, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.columns, componentConfig.rightPanel?.additionalTabs, isDesignMode]);
// 항목 펼치기/접기 토글
const toggleExpand = useCallback((itemId: any) => {
@ -1668,13 +1784,24 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 수정 버튼 핸들러
const handleEditClick = useCallback(
(panel: "left" | "right", item: any) => {
// 🆕 우측 패널 수정 버튼 설정 확인
if (panel === "right" && componentConfig.rightPanel?.editButton?.mode === "modal") {
const modalScreenId = componentConfig.rightPanel?.editButton?.modalScreenId;
// 우측 패널 수정 버튼 설정 확인 (탭별 설정 지원)
if (panel === "right") {
const editButtonConfig =
activeTabIndex === 0
? componentConfig.rightPanel?.editButton
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.editButton;
const currentTableName =
activeTabIndex === 0
? componentConfig.rightPanel?.tableName || ""
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.tableName || "";
if (editButtonConfig?.mode === "modal") {
const modalScreenId = editButtonConfig?.modalScreenId;
if (modalScreenId) {
// 커스텀 모달 화면 열기
const rightTableName = componentConfig.rightPanel?.tableName || "";
const rightTableName = currentTableName;
// Primary Key 찾기 (우선순위: id > ID > user_id > {table}_id > 첫 번째 필드)
let primaryKeyName = "id";
@ -1753,6 +1880,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return;
}
}
}
// 기존 자동 편집 모드 (인라인 편집 모달)
setEditModalPanel(panel);
@ -1760,7 +1888,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
setEditModalFormData({ ...item });
setShowEditModal(true);
},
[componentConfig],
[componentConfig, activeTabIndex],
);
// 수정 모달 저장
@ -2220,9 +2348,13 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
if (!isDesignMode) {
console.log("🔄 [SplitPanel] refreshTable 이벤트 수신 - 데이터 새로고침");
loadLeftData();
// 선택된 항목이 있으면 우측 패널도 새로고침
// 선택된 항목이 있으면 현재 활성 탭 데이터 새로고침
if (selectedLeftItem) {
if (activeTabIndex === 0) {
loadRightData(selectedLeftItem);
} else {
loadTabData(activeTabIndex, selectedLeftItem);
}
}
}
};
@ -2232,7 +2364,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return () => {
window.removeEventListener("refreshTable", handleRefreshTable);
};
}, [isDesignMode, loadLeftData, loadRightData, selectedLeftItem]);
}, [isDesignMode, loadLeftData, loadRightData, loadTabData, activeTabIndex, selectedLeftItem]);
// 리사이저 드래그 핸들러
const handleMouseDown = (e: React.MouseEvent) => {
@ -3021,24 +3153,63 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
style={{
height: componentConfig.rightPanel?.panelHeaderHeight || 48,
minHeight: componentConfig.rightPanel?.panelHeaderHeight || 48,
padding: "0 1rem",
padding: "0 0.75rem",
display: "flex",
alignItems: "center",
}}
>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-0">
{/* 탭이 없으면 제목만, 있으면 탭으로 전환 */}
{(componentConfig.rightPanel?.additionalTabs?.length || 0) > 0 ? (
<div className="flex items-center gap-0">
<button
onClick={() => handleTabChange(0)}
className={cn(
"px-3 py-1 text-sm font-medium transition-colors",
activeTabIndex === 0
? "text-foreground border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
)}
>
{componentConfig.rightPanel?.title || "기본"}
</button>
{componentConfig.rightPanel?.additionalTabs?.map((tab: any, index: number) => (
<button
key={tab.tabId || `tab-${index}`}
onClick={() => handleTabChange(index + 1)}
className={cn(
"px-3 py-1 text-sm font-medium transition-colors",
activeTabIndex === index + 1
? "text-foreground border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
)}
>
{tab.label || `${index + 1}`}
</button>
))}
</div>
) : (
<CardTitle className="text-base font-semibold">
{componentConfig.rightPanel?.title || "우측 패널"}
</CardTitle>
)}
</div>
{!isDesignMode && (
<div className="flex items-center gap-2">
{componentConfig.rightPanel?.showAdd && (
{activeTabIndex === 0
? componentConfig.rightPanel?.showAdd && (
<Button size="sm" variant="outline" onClick={() => handleAddClick("right")}>
<Plus className="mr-1 h-4 w-4" />
</Button>
)
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.showAdd && (
<Button size="sm" variant="outline" onClick={() => handleAddClick("right")}>
<Plus className="mr-1 h-4 w-4" />
</Button>
)}
{/* 우측 패널 수정/삭제는 각 카드에서 처리 */}
</div>
)}
</div>
@ -3057,8 +3228,139 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div>
)}
<CardContent className="flex-1 overflow-auto p-4">
{/* 우측 데이터/커스텀 */}
{componentConfig.rightPanel?.displayMode === "custom" ? (
{/* 추가 탭 컨텐츠 */}
{activeTabIndex > 0 ? (
(() => {
const currentTabConfig = componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any;
const currentTabData = tabsData[activeTabIndex] || [];
const isTabLoading = tabsLoading[activeTabIndex];
if (isTabLoading) {
return (
<div className="flex h-32 items-center justify-center">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
);
}
if (!selectedLeftItem) {
return (
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-2 py-12 text-sm">
<p> </p>
</div>
);
}
if (currentTabData.length === 0) {
return (
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-2 py-12 text-sm">
<p> .</p>
</div>
);
}
// 탭 컬럼 설정
const tabColumns = currentTabConfig?.columns || [];
// 테이블 모드로 표시
if (currentTabConfig?.displayMode === "table") {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
{tabColumns.map((col: any) => (
<th key={col.name} className="text-muted-foreground px-3 py-2 text-left text-xs font-medium">
{col.label || col.name}
</th>
))}
{(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && (
<th className="text-muted-foreground px-3 py-2 text-right text-xs font-medium"></th>
)}
</tr>
</thead>
<tbody>
{currentTabData.map((item: any, idx: number) => (
<tr key={item.id || idx} className="hover:bg-muted/50 border-b">
{tabColumns.map((col: any) => (
<td key={col.name} className="px-3 py-2 text-xs">
{formatCellValue(
col.name,
getEntityJoinValue(item, col.name),
rightCategoryMappings,
col.format,
)}
</td>
))}
{(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && (
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-1">
{currentTabConfig?.showEdit && (
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs"
onClick={() => handleEditClick("right", item)}
>
<Pencil className="h-3 w-3" />
</Button>
)}
{currentTabConfig?.showDelete && (
<Button size="sm" variant="ghost" className="text-destructive h-7 px-2 text-xs"
onClick={() => handleDeleteClick("right", item)}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
);
}
// 리스트(카드) 모드로 표시
return (
<div className="space-y-2">
{currentTabData.map((item: any, idx: number) => (
<div key={item.id || idx} className="flex items-center justify-between rounded-lg border p-3">
<div className="flex flex-wrap items-center gap-2 text-xs">
{tabColumns.map((col: any) => (
<span key={col.name}>
{formatCellValue(
col.name,
getEntityJoinValue(item, col.name),
rightCategoryMappings,
col.format,
)}
</span>
))}
</div>
{(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && (
<div className="flex items-center gap-1">
{currentTabConfig?.showEdit && (
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs"
onClick={() => handleEditClick("right", item)}
>
<Pencil className="h-3 w-3" />
</Button>
)}
{currentTabConfig?.showDelete && (
<Button size="sm" variant="ghost" className="text-destructive h-7 px-2 text-xs"
onClick={() => handleDeleteClick("right", item)}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
)}
</div>
))}
</div>
);
})()
) : componentConfig.rightPanel?.displayMode === "custom" ? (
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
<div
className="relative h-full w-full"

View File

@ -50,6 +50,14 @@ export interface AdditionalTabConfig {
suffix?: string;
dateFormat?: string;
};
// Entity 조인 컬럼 정보
isEntityJoin?: boolean;
joinInfo?: {
sourceTable: string;
sourceColumn: string;
referenceTable: string;
joinAlias: string;
};
}>;
addModalColumns?: Array<{
@ -145,6 +153,14 @@ export interface SplitPanelLayoutConfig {
suffix?: string; // 접미사 (예: "원", "개")
dateFormat?: string; // 날짜 포맷 (type: "date")
};
// Entity 조인 컬럼 정보
isEntityJoin?: boolean;
joinInfo?: {
sourceTable: string;
sourceColumn: string;
referenceTable: string;
joinAlias: string;
};
}>;
// 추가 모달에서 입력받을 컬럼 설정
addModalColumns?: Array<{
@ -217,6 +233,14 @@ export interface SplitPanelLayoutConfig {
suffix?: string; // 접미사 (예: "원", "개")
dateFormat?: string; // 날짜 포맷 (type: "date")
};
// Entity 조인 컬럼 정보
isEntityJoin?: boolean;
joinInfo?: {
sourceTable: string;
sourceColumn: string;
referenceTable: string;
joinAlias: string;
};
}>;
// 추가 모달에서 입력받을 컬럼 설정
addModalColumns?: Array<{