1299 lines
59 KiB
TypeScript
1299 lines
59 KiB
TypeScript
"use client";
|
||
|
||
import React, { useEffect, useState, useMemo } from "react";
|
||
import { useParams, useSearchParams } from "next/navigation";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Loader2 } from "lucide-react";
|
||
import { screenApi } from "@/lib/api/screen";
|
||
import { ScreenDefinition, LayoutData, ComponentData } from "@/types/screen";
|
||
import { LayerDefinition } from "@/types/screen-management";
|
||
import { useRouter } from "next/navigation";
|
||
import { toast } from "sonner";
|
||
import { initializeComponents } from "@/lib/registry/components";
|
||
import { EditModal } from "@/components/screen/EditModal";
|
||
import { RealtimePreview } from "@/components/screen/RealtimePreviewDynamic";
|
||
import { FlowButtonGroup } from "@/components/screen/widgets/FlowButtonGroup";
|
||
import { FlowVisibilityConfig } from "@/types/control-management";
|
||
import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils";
|
||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
|
||
import { useAuth } from "@/hooks/useAuth"; // 🆕 사용자 정보
|
||
import { useResponsive } from "@/lib/hooks/useResponsive"; // 🆕 반응형 감지
|
||
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 테이블 옵션
|
||
import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 높이 관리
|
||
import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 컴포넌트 간 통신
|
||
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 분할 패널 리사이즈
|
||
import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; // 활성 탭 관리
|
||
import { evaluateConditional } from "@/lib/utils/conditionalEvaluator"; // 조건부 표시 평가
|
||
import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext"; // 화면 다국어
|
||
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; // V2 Zod 기반 변환
|
||
import { useScheduleGenerator, ScheduleConfirmDialog } from "@/lib/v2-core/services/ScheduleGeneratorService"; // 스케줄 자동 생성
|
||
|
||
function ScreenViewPage() {
|
||
// 스케줄 자동 생성 서비스 활성화
|
||
const { showConfirmDialog, previewResult, handleConfirm, closeDialog, isLoading: scheduleLoading } = useScheduleGenerator();
|
||
const params = useParams();
|
||
const searchParams = useSearchParams();
|
||
const router = useRouter();
|
||
const screenId = parseInt(params.screenId as string);
|
||
|
||
// URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프)
|
||
const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined;
|
||
|
||
// URL 쿼리에서 프리뷰용 company_code 가져오기
|
||
const previewCompanyCode = searchParams.get("company_code");
|
||
|
||
// 프리뷰 모드 감지 (iframe에서 로드될 때)
|
||
const isPreviewMode = searchParams.get("preview") === "true";
|
||
|
||
// 🆕 현재 로그인한 사용자 정보
|
||
const { user, userName, companyCode: authCompanyCode } = useAuth();
|
||
|
||
// 프리뷰 모드에서는 URL 파라미터의 company_code 우선 사용
|
||
const companyCode = previewCompanyCode || authCompanyCode;
|
||
|
||
// 🆕 모바일 환경 감지
|
||
const { isMobile } = useResponsive();
|
||
|
||
// 🆕 TableSearchWidget 높이 관리
|
||
const { getHeightDiff } = useTableSearchWidgetHeight();
|
||
|
||
const [screen, setScreen] = useState<ScreenDefinition | null>(null);
|
||
const [layout, setLayout] = useState<LayoutData | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||
|
||
// 테이블에서 선택된 행 데이터 (버튼 액션에 전달)
|
||
const [selectedRowsData, setSelectedRowsData] = useState<any[]>([]);
|
||
|
||
// 테이블 정렬 정보 (엑셀 다운로드용)
|
||
const [tableSortBy, setTableSortBy] = useState<string | undefined>();
|
||
const [tableSortOrder, setTableSortOrder] = useState<"asc" | "desc">("asc");
|
||
const [tableColumnOrder, setTableColumnOrder] = useState<string[] | undefined>();
|
||
const [tableDisplayData, setTableDisplayData] = useState<any[]>([]); // 화면에 표시된 데이터 (컬럼 순서 포함)
|
||
|
||
// 플로우에서 선택된 데이터 (버튼 액션에 전달)
|
||
const [flowSelectedData, setFlowSelectedData] = useState<any[]>([]);
|
||
const [flowSelectedStepId, setFlowSelectedStepId] = useState<number | null>(null);
|
||
|
||
// 테이블 새로고침을 위한 키 (값이 변경되면 테이블이 리렌더링됨)
|
||
const [tableRefreshKey, setTableRefreshKey] = useState(0);
|
||
|
||
// 플로우 새로고침을 위한 키 (값이 변경되면 플로우 데이터가 리렌더링됨)
|
||
const [flowRefreshKey, setFlowRefreshKey] = useState(0);
|
||
|
||
// 🆕 조건부 컨테이너 높이 추적 (컴포넌트 ID → 높이)
|
||
const [conditionalContainerHeights, setConditionalContainerHeights] = useState<Record<string, number>>({});
|
||
|
||
// 🆕 레이어 시스템 지원
|
||
const [conditionalLayers, setConditionalLayers] = useState<LayerDefinition[]>([]);
|
||
// 🆕 조건부 영역(Zone) 목록
|
||
const [zones, setZones] = useState<import("@/types/screen-management").ConditionalZone[]>([]);
|
||
|
||
// 편집 모달 상태
|
||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||
const [editModalConfig, setEditModalConfig] = useState<{
|
||
screenId?: number;
|
||
modalSize?: "sm" | "md" | "lg" | "xl" | "full";
|
||
editData?: Record<string, unknown>;
|
||
onSave?: () => void;
|
||
modalTitle?: string;
|
||
modalDescription?: string;
|
||
}>({});
|
||
|
||
// 레이아웃 준비 완료 상태 (버튼 위치 계산 완료 후 화면 표시)
|
||
const [layoutReady, setLayoutReady] = useState(true);
|
||
|
||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||
const [scale, setScale] = useState(1);
|
||
const [containerWidth, setContainerWidth] = useState(0);
|
||
|
||
useEffect(() => {
|
||
const initComponents = async () => {
|
||
try {
|
||
await initializeComponents();
|
||
} catch (error) {
|
||
console.error("❌ 할당된 화면에서 컴포넌트 시스템 초기화 실패:", error);
|
||
}
|
||
};
|
||
|
||
initComponents();
|
||
}, []);
|
||
|
||
// 편집 모달 이벤트 리스너 등록
|
||
useEffect(() => {
|
||
const handleOpenEditModal = (event: CustomEvent) => {
|
||
// console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail);
|
||
|
||
setEditModalConfig({
|
||
screenId: event.detail.screenId,
|
||
modalSize: event.detail.modalSize,
|
||
editData: event.detail.editData,
|
||
onSave: event.detail.onSave,
|
||
modalTitle: event.detail.modalTitle,
|
||
modalDescription: event.detail.modalDescription,
|
||
});
|
||
setEditModalOpen(true);
|
||
};
|
||
|
||
// @ts-expect-error - CustomEvent type
|
||
window.addEventListener("openEditModal", handleOpenEditModal);
|
||
|
||
return () => {
|
||
// @ts-expect-error - CustomEvent type
|
||
window.removeEventListener("openEditModal", handleOpenEditModal);
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const loadScreen = async () => {
|
||
try {
|
||
setLoading(true);
|
||
setLayoutReady(false); // 화면 로드 시 레이아웃 준비 초기화
|
||
setError(null);
|
||
|
||
// 화면 정보 로드
|
||
const screenData = await screenApi.getScreen(screenId);
|
||
setScreen(screenData);
|
||
|
||
// 레이아웃 로드 (V2 우선, Zod 기반 기본값 병합)
|
||
try {
|
||
// V2 API 먼저 시도
|
||
const v2Response = await screenApi.getLayoutV2(screenId);
|
||
|
||
if (v2Response && isValidV2Layout(v2Response)) {
|
||
// V2 레이아웃: Zod 기반 변환 (기본값 병합)
|
||
const convertedLayout = convertV2ToLegacy(v2Response);
|
||
if (convertedLayout) {
|
||
setLayout({
|
||
...convertedLayout,
|
||
screenResolution: v2Response.screenResolution || convertedLayout.screenResolution,
|
||
} as LayoutData);
|
||
} else {
|
||
throw new Error("V2 레이아웃 변환 실패");
|
||
}
|
||
} else {
|
||
// V1 레이아웃 또는 빈 레이아웃
|
||
const layoutData = await screenApi.getLayout(screenId);
|
||
setLayout(layoutData);
|
||
}
|
||
} catch (layoutError) {
|
||
console.warn("레이아웃 로드 실패, 빈 레이아웃 사용:", layoutError);
|
||
setLayout({
|
||
screenId,
|
||
components: [],
|
||
gridSettings: {
|
||
columns: 12,
|
||
gap: 16,
|
||
padding: 16,
|
||
enabled: true,
|
||
size: 8,
|
||
color: "#e0e0e0",
|
||
opacity: 0.5,
|
||
snapToGrid: true,
|
||
},
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error("화면 로드 실패:", error);
|
||
setError("화면을 불러오는데 실패했습니다.");
|
||
toast.error("화면을 불러오는데 실패했습니다.");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
if (screenId) {
|
||
loadScreen();
|
||
}
|
||
}, [screenId]);
|
||
|
||
// 🆕 조건부 레이어 + Zone 로드
|
||
useEffect(() => {
|
||
const loadConditionalLayersAndZones = async () => {
|
||
if (!screenId || !layout) return;
|
||
|
||
try {
|
||
// 1. Zone 로드
|
||
const loadedZones = await screenApi.getScreenZones(screenId);
|
||
setZones(loadedZones);
|
||
|
||
// 2. 모든 레이어 목록 조회
|
||
const allLayers = await screenApi.getScreenLayers(screenId);
|
||
const nonBaseLayers = allLayers.filter((l: any) => l.layer_id > 1);
|
||
|
||
if (nonBaseLayers.length === 0) {
|
||
setConditionalLayers([]);
|
||
return;
|
||
}
|
||
|
||
// 3. 각 레이어의 레이아웃 데이터 로드
|
||
const layerDefinitions: LayerDefinition[] = [];
|
||
|
||
for (const layerInfo of nonBaseLayers) {
|
||
try {
|
||
const layerData = await screenApi.getLayerLayout(screenId, layerInfo.layer_id);
|
||
const condConfig = layerInfo.condition_config || layerData?.conditionConfig || {};
|
||
|
||
// 레이어 컴포넌트 변환 (V2 → Legacy)
|
||
let layerComponents: any[] = [];
|
||
const rawComponents = layerData?.components;
|
||
if (rawComponents && Array.isArray(rawComponents) && rawComponents.length > 0) {
|
||
const tempV2 = {
|
||
version: "2.0" as const,
|
||
components: rawComponents,
|
||
gridSettings: layerData.gridSettings,
|
||
screenResolution: layerData.screenResolution,
|
||
};
|
||
if (isValidV2Layout(tempV2)) {
|
||
const converted = convertV2ToLegacy(tempV2);
|
||
if (converted) {
|
||
layerComponents = converted.components || [];
|
||
}
|
||
}
|
||
}
|
||
|
||
// Zone 기반 condition_config 처리
|
||
const zoneId = condConfig.zone_id;
|
||
const conditionValue = condConfig.condition_value;
|
||
const zone = zoneId ? loadedZones.find((z: any) => z.zone_id === zoneId) : null;
|
||
|
||
// LayerDefinition 생성
|
||
const layerDef: LayerDefinition = {
|
||
id: String(layerInfo.layer_id),
|
||
name: layerInfo.layer_name || `레이어 ${layerInfo.layer_id}`,
|
||
type: "conditional",
|
||
zIndex: layerInfo.layer_id * 10,
|
||
isVisible: false,
|
||
isLocked: false,
|
||
// Zone 기반 조건 (Zone에서 트리거 정보를 가져옴)
|
||
condition: zone ? {
|
||
targetComponentId: zone.trigger_component_id || "",
|
||
operator: (zone.trigger_operator as "eq" | "neq" | "in") || "eq",
|
||
value: conditionValue,
|
||
} : condConfig.targetComponentId ? {
|
||
targetComponentId: condConfig.targetComponentId,
|
||
operator: condConfig.operator || "eq",
|
||
value: condConfig.value,
|
||
} : undefined,
|
||
// Zone 기반: displayRegion은 Zone에서 가져옴
|
||
zoneId: zoneId || undefined,
|
||
conditionValue: conditionValue || undefined,
|
||
displayRegion: zone ? { x: zone.x, y: zone.y, width: zone.width, height: zone.height } : condConfig.displayRegion || undefined,
|
||
components: layerComponents,
|
||
};
|
||
|
||
layerDefinitions.push(layerDef);
|
||
} catch (layerError) {
|
||
console.warn(`레이어 ${layerInfo.layer_id} 로드 실패:`, layerError);
|
||
}
|
||
}
|
||
|
||
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) {
|
||
console.error("레이어/Zone 로드 실패:", error);
|
||
}
|
||
};
|
||
|
||
loadConditionalLayersAndZones();
|
||
}, [screenId, layout]);
|
||
|
||
// 🆕 조건부 레이어 조건 평가 (formData 변경 시 동기적으로 즉시 계산)
|
||
const activeLayerIds = useMemo(() => {
|
||
if (conditionalLayers.length === 0 || !layout) return [] as string[];
|
||
|
||
const allComponents = layout.components || [];
|
||
const newActiveIds: string[] = [];
|
||
|
||
conditionalLayers.forEach((layer) => {
|
||
if (layer.condition) {
|
||
const { targetComponentId, operator, value } = layer.condition;
|
||
|
||
// 빈 targetComponentId는 무시
|
||
if (!targetComponentId) return;
|
||
|
||
// 트리거 컴포넌트 찾기 (기본 레이어에서)
|
||
const targetComponent = allComponents.find((c) => c.id === targetComponentId);
|
||
|
||
// columnName으로 formData에서 값 조회
|
||
const fieldKey =
|
||
(targetComponent as any)?.columnName ||
|
||
(targetComponent 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;
|
||
}
|
||
|
||
// 디버그 로깅 (값이 존재할 때만)
|
||
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);
|
||
}
|
||
}
|
||
});
|
||
|
||
return newActiveIds;
|
||
}, [formData, conditionalLayers, layout]);
|
||
|
||
// 🆕 메인 테이블 데이터 자동 로드 (단일 레코드 폼)
|
||
// 화면의 메인 테이블에서 사용자 회사 코드로 데이터를 조회하여 폼에 자동 채움
|
||
useEffect(() => {
|
||
const loadMainTableData = async () => {
|
||
if (!screen || !layout || !layout.components || !companyCode) {
|
||
return;
|
||
}
|
||
|
||
const mainTableName = screen.tableName;
|
||
if (!mainTableName) {
|
||
return;
|
||
}
|
||
|
||
// 테이블 위젯이 없는 경우에만 자동 로드 (테이블이 있으면 행 선택으로 데이터 로드)
|
||
const hasTableWidget = layout.components.some(
|
||
(comp: any) =>
|
||
comp.componentType === "table-list" ||
|
||
comp.componentType === "v2-table-list" ||
|
||
comp.widgetType === "table"
|
||
);
|
||
|
||
if (hasTableWidget) {
|
||
return;
|
||
}
|
||
|
||
// 인풋 컴포넌트들 중 메인 테이블의 컬럼을 사용하는 것들 찾기
|
||
const inputComponents = layout.components.filter((comp: any) => {
|
||
const compType = comp.componentType || comp.widgetType;
|
||
const isInputType = compType?.includes("input") ||
|
||
compType?.includes("select") ||
|
||
compType?.includes("textarea") ||
|
||
compType?.includes("v2-input") ||
|
||
compType?.includes("v2-select") ||
|
||
compType?.includes("v2-media") ||
|
||
compType?.includes("file-upload"); // 🆕 레거시 파일 업로드 포함
|
||
const hasColumnName = !!(comp as any).columnName;
|
||
return isInputType && hasColumnName;
|
||
});
|
||
|
||
if (inputComponents.length === 0) {
|
||
return;
|
||
}
|
||
|
||
// 메인 테이블에서 현재 회사의 데이터 조회
|
||
try {
|
||
const { tableTypeApi } = await import("@/lib/api/screen");
|
||
|
||
// company_code로 필터링하여 단일 레코드 조회
|
||
const result = await tableTypeApi.getTableRecord(
|
||
mainTableName,
|
||
"company_code",
|
||
companyCode,
|
||
"*" // 모든 컬럼
|
||
);
|
||
|
||
if (result && result.record) {
|
||
console.log("📦 메인 테이블 데이터 자동 로드:", mainTableName, result.record);
|
||
|
||
// 각 인풋 컴포넌트에 해당하는 데이터 채우기
|
||
const newFormData: Record<string, any> = {};
|
||
inputComponents.forEach((comp: any) => {
|
||
const columnName = comp.columnName;
|
||
if (columnName && result.record[columnName] !== undefined) {
|
||
newFormData[columnName] = result.record[columnName];
|
||
}
|
||
});
|
||
|
||
if (Object.keys(newFormData).length > 0) {
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
...newFormData,
|
||
}));
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.log("메인 테이블 자동 로드 실패 (정상일 수 있음):", error);
|
||
// 에러는 무시 - 데이터가 없거나 권한이 없을 수 있음
|
||
}
|
||
};
|
||
|
||
loadMainTableData();
|
||
}, [screen, layout, companyCode]);
|
||
|
||
// 🆕 개별 autoFill 처리 (메인 테이블과 다른 테이블에서 조회하는 경우)
|
||
useEffect(() => {
|
||
const initAutoFill = async () => {
|
||
if (!layout || !layout.components || !user) {
|
||
return;
|
||
}
|
||
|
||
for (const comp of layout.components) {
|
||
// type: "component" 또는 type: "widget" 모두 처리
|
||
if (comp.type === "widget" || comp.type === "component") {
|
||
const widget = comp as any;
|
||
const fieldName = widget.columnName || widget.id;
|
||
|
||
// autoFill 처리 (명시적으로 설정된 경우만)
|
||
if (widget.autoFill?.enabled || (comp as any).autoFill?.enabled) {
|
||
const autoFillConfig = widget.autoFill || (comp as any).autoFill;
|
||
const currentValue = formData[fieldName];
|
||
|
||
if (currentValue === undefined || currentValue === "") {
|
||
const { sourceTable, filterColumn, userField, displayColumn } = autoFillConfig;
|
||
|
||
// 사용자 정보에서 필터 값 가져오기
|
||
const userValue = user?.[userField as keyof typeof user];
|
||
|
||
if (userValue && sourceTable && filterColumn && displayColumn) {
|
||
try {
|
||
const { tableTypeApi } = await import("@/lib/api/screen");
|
||
const result = await tableTypeApi.getTableRecord(sourceTable, filterColumn, userValue, displayColumn);
|
||
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
[fieldName]: result.value,
|
||
}));
|
||
} catch (error) {
|
||
console.error(`autoFill 조회 실패: ${fieldName}`, error);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
initAutoFill();
|
||
}, [layout, user]);
|
||
|
||
// 🆕 조건부 비활성화/숨김 시 해당 필드 값 초기화
|
||
// 조건 필드들의 값을 추적하여 변경 시에만 실행
|
||
const conditionalFieldValues = useMemo(() => {
|
||
if (!layout?.components) return "";
|
||
|
||
// 조건부 설정에 사용되는 필드들의 현재 값을 JSON 문자열로 만들어 비교
|
||
const conditionFields = new Set<string>();
|
||
layout.components.forEach((component) => {
|
||
const conditional = (component as any).conditional;
|
||
if (conditional?.enabled && conditional.field) {
|
||
conditionFields.add(conditional.field);
|
||
}
|
||
});
|
||
|
||
const values: Record<string, any> = {};
|
||
conditionFields.forEach((field) => {
|
||
values[field] = (formData as Record<string, any>)[field];
|
||
});
|
||
|
||
return JSON.stringify(values);
|
||
}, [layout?.components, formData]);
|
||
|
||
useEffect(() => {
|
||
if (!layout?.components) return;
|
||
|
||
const fieldsToReset: string[] = [];
|
||
|
||
layout.components.forEach((component) => {
|
||
const conditional = (component as any).conditional;
|
||
if (!conditional?.enabled) return;
|
||
|
||
const conditionalResult = evaluateConditional(conditional, formData as Record<string, any>, layout.components);
|
||
|
||
// 숨김 또는 비활성화 상태인 경우
|
||
if (!conditionalResult.visible || conditionalResult.disabled) {
|
||
const fieldName = (component as any).columnName || component.id;
|
||
const currentValue = (formData as Record<string, any>)[fieldName];
|
||
|
||
// 값이 있으면 초기화 대상에 추가
|
||
if (currentValue !== undefined && currentValue !== "" && currentValue !== null) {
|
||
fieldsToReset.push(fieldName);
|
||
}
|
||
}
|
||
});
|
||
|
||
// 초기화할 필드가 있으면 한 번에 처리
|
||
if (fieldsToReset.length > 0) {
|
||
setFormData((prev) => {
|
||
const updated = { ...prev };
|
||
fieldsToReset.forEach((fieldName) => {
|
||
updated[fieldName] = "";
|
||
});
|
||
return updated;
|
||
});
|
||
}
|
||
}, [conditionalFieldValues, layout?.components]);
|
||
|
||
// 캔버스 비율 조정 (사용자 화면에 맞게 자동 스케일) - 초기 로딩 시에만 계산
|
||
// 브라우저 배율 조정 시 메뉴와 화면이 함께 축소/확대되도록 resize 이벤트는 감지하지 않음
|
||
useEffect(() => {
|
||
// 모바일 환경에서는 스케일 조정 비활성화 (반응형만 작동)
|
||
if (isMobile) {
|
||
setScale(1);
|
||
setLayoutReady(true); // 모바일에서도 레이아웃 준비 완료 표시
|
||
return;
|
||
}
|
||
|
||
const updateScale = () => {
|
||
if (containerRef.current && layout) {
|
||
const designWidth = layout?.screenResolution?.width || 1200;
|
||
const designHeight = layout?.screenResolution?.height || 800;
|
||
|
||
// 컨테이너의 실제 크기 (프리뷰 모드에서는 window 크기 사용)
|
||
let containerWidth: number;
|
||
let containerHeight: number;
|
||
|
||
if (isPreviewMode) {
|
||
// iframe에서는 window 크기를 직접 사용
|
||
containerWidth = window.innerWidth;
|
||
containerHeight = window.innerHeight;
|
||
} else {
|
||
containerWidth = containerRef.current.offsetWidth;
|
||
containerHeight = containerRef.current.offsetHeight;
|
||
}
|
||
|
||
let newScale: number;
|
||
|
||
if (isPreviewMode) {
|
||
// 프리뷰 모드: 가로/세로 모두 fit하도록 (여백 없이)
|
||
const scaleX = containerWidth / designWidth;
|
||
const scaleY = containerHeight / designHeight;
|
||
newScale = Math.min(scaleX, scaleY, 1); // 최대 1배율
|
||
} else {
|
||
// 일반 모드: 가로 기준 스케일 (좌우 여백 16px씩 고정)
|
||
const MARGIN_X = 32;
|
||
const availableWidth = containerWidth - MARGIN_X;
|
||
newScale = availableWidth / designWidth;
|
||
}
|
||
|
||
// console.log("📐 스케일 계산:", {
|
||
// containerWidth,
|
||
// containerHeight,
|
||
// designWidth,
|
||
// designHeight,
|
||
// finalScale: newScale,
|
||
// isPreviewMode,
|
||
// });
|
||
|
||
setScale(newScale);
|
||
// 컨테이너 너비 업데이트
|
||
setContainerWidth(containerWidth);
|
||
|
||
// 스케일 계산 완료 후 레이아웃 준비 완료 표시
|
||
setLayoutReady(true);
|
||
}
|
||
};
|
||
|
||
// 초기 측정 (한 번만 실행)
|
||
const timer = setTimeout(updateScale, 100);
|
||
|
||
// resize 이벤트는 감지하지 않음 - 브라우저 배율 조정 시 메뉴와 화면이 함께 변경되도록
|
||
return () => {
|
||
clearTimeout(timer);
|
||
};
|
||
}, [layout, isMobile, isPreviewMode]);
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="from-muted to-muted/50 flex h-full min-h-[400px] w-full items-center justify-center bg-gradient-to-br">
|
||
<div className="border-border bg-background rounded-xl border p-8 text-center shadow-lg">
|
||
<Loader2 className="text-primary mx-auto h-10 w-10 animate-spin" />
|
||
<p className="text-foreground mt-4 font-medium">화면을 불러오는 중...</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error || !screen) {
|
||
return (
|
||
<div className="from-muted to-muted/50 flex h-full min-h-[400px] w-full items-center justify-center bg-gradient-to-br">
|
||
<div className="border-border bg-background max-w-md rounded-xl border p-8 text-center shadow-lg">
|
||
<div className="from-destructive/20 to-warning/20 mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br shadow-sm">
|
||
<span className="text-3xl">⚠️</span>
|
||
</div>
|
||
<h2 className="text-foreground mb-3 text-xl font-bold">화면을 찾을 수 없습니다</h2>
|
||
<p className="text-muted-foreground mb-6 leading-relaxed">{error || "요청하신 화면이 존재하지 않습니다."}</p>
|
||
<Button onClick={() => router.back()} variant="outline" className="rounded-lg">
|
||
이전으로 돌아가기
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 화면 해상도 정보가 있으면 해당 크기로, 없으면 기본 크기 사용
|
||
const screenWidth = layout?.screenResolution?.width || 1200;
|
||
const screenHeight = layout?.screenResolution?.height || 800;
|
||
|
||
return (
|
||
<ScreenPreviewProvider isPreviewMode={false}>
|
||
<ActiveTabProvider>
|
||
<TableOptionsProvider>
|
||
<div
|
||
ref={containerRef}
|
||
className={`bg-background h-full w-full ${isPreviewMode ? "overflow-hidden p-0" : "overflow-auto p-3"}`}
|
||
>
|
||
{/* 레이아웃 준비 중 로딩 표시 */}
|
||
{!layoutReady && (
|
||
<div className="from-muted to-muted/50 flex h-full w-full items-center justify-center bg-gradient-to-br">
|
||
<div className="border-border bg-background rounded-xl border p-8 text-center shadow-lg">
|
||
<Loader2 className="text-primary mx-auto h-8 w-8 animate-spin" />
|
||
<p className="text-foreground mt-4 text-sm font-medium">화면 준비 중...</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 절대 위치 기반 렌더링 (화면관리와 동일한 방식) */}
|
||
{layoutReady && layout && layout.components.length > 0 ? (
|
||
<ScreenMultiLangProvider components={layout.components} companyCode={companyCode}>
|
||
<div
|
||
data-screen-runtime="true"
|
||
className="bg-background relative"
|
||
style={{
|
||
width: `${screenWidth}px`,
|
||
height: `${screenHeight}px`,
|
||
minWidth: `${screenWidth}px`,
|
||
maxWidth: `${screenWidth}px`,
|
||
minHeight: `${screenHeight}px`,
|
||
maxHeight: `${screenHeight}px`,
|
||
flexShrink: 0,
|
||
transform: `scale(${scale})`,
|
||
transformOrigin: "top left",
|
||
overflow: "visible",
|
||
}}
|
||
>
|
||
{/* 최상위 컴포넌트들 렌더링 */}
|
||
{(() => {
|
||
// 🆕 플로우 버튼 그룹 감지 및 처리
|
||
const topLevelComponents = layout.components.filter((component) => !component.parentId);
|
||
|
||
// 화면 관리에서 설정한 해상도를 사용하므로 widthOffset 계산 불필요
|
||
// 모든 컴포넌트는 원본 위치 그대로 사용
|
||
const widthOffset = 0;
|
||
|
||
const buttonGroups: Record<string, any[]> = {};
|
||
const processedButtonIds = new Set<string>();
|
||
// 🔍 전체 버튼 목록 확인
|
||
const allButtons = topLevelComponents.filter((component) => {
|
||
const isButton =
|
||
(component.type === "component" &&
|
||
["button-primary", "button-secondary"].includes((component as any).componentType)) ||
|
||
(component.type === "widget" && (component as any).widgetType === "button");
|
||
return isButton;
|
||
});
|
||
|
||
topLevelComponents.forEach((component) => {
|
||
const isButton =
|
||
(component.type === "component" &&
|
||
["button-primary", "button-secondary"].includes((component as any).componentType)) ||
|
||
(component.type === "widget" && (component as any).widgetType === "button");
|
||
|
||
if (isButton) {
|
||
const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as
|
||
| FlowVisibilityConfig
|
||
| undefined;
|
||
|
||
// 🔧 임시: 버튼 그룹 기능 완전 비활성화
|
||
// TODO: 사용자가 명시적으로 그룹을 원하는 경우에만 활성화하도록 UI 개선 필요
|
||
const DISABLE_BUTTON_GROUPS = false;
|
||
|
||
if (
|
||
!DISABLE_BUTTON_GROUPS &&
|
||
flowConfig?.enabled &&
|
||
flowConfig.layoutBehavior === "auto-compact" &&
|
||
flowConfig.groupId
|
||
) {
|
||
if (!buttonGroups[flowConfig.groupId]) {
|
||
buttonGroups[flowConfig.groupId] = [];
|
||
}
|
||
buttonGroups[flowConfig.groupId].push(component);
|
||
processedButtonIds.add(component.id);
|
||
}
|
||
// else: 모든 버튼을 개별 렌더링
|
||
}
|
||
});
|
||
|
||
const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id));
|
||
|
||
// TableSearchWidget들을 먼저 찾기
|
||
const tableSearchWidgets = regularComponents.filter(
|
||
(c) => (c as any).componentId === "table-search-widget",
|
||
);
|
||
|
||
// 조건부 컨테이너들을 찾기
|
||
const conditionalContainers = regularComponents.filter(
|
||
(c) =>
|
||
(c as any).componentId === "conditional-container" ||
|
||
(c as any).componentType === "conditional-container",
|
||
);
|
||
|
||
// 🆕 같은 X 영역(섹션)에서 컴포넌트들이 겹치지 않도록 자동 수직 정렬
|
||
// ⚠️ V2 레이아웃에서는 사용자가 배치한 위치를 존중하므로 자동 정렬 비활성화
|
||
const autoLayoutComponents = regularComponents;
|
||
|
||
// TableSearchWidget 및 조건부 컨테이너 높이 차이를 계산하여 Y 위치 추가 조정
|
||
const adjustedComponents = autoLayoutComponents.map((component) => {
|
||
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
|
||
const isConditionalContainer = (component as any).componentId === "conditional-container";
|
||
|
||
if (isTableSearchWidget || isConditionalContainer) {
|
||
// 자기 자신은 조정하지 않음
|
||
return component;
|
||
}
|
||
|
||
let totalHeightAdjustment = 0;
|
||
|
||
// TableSearchWidget 높이 조정
|
||
for (const widget of tableSearchWidgets) {
|
||
const isBelow = component.position.y > widget.position.y;
|
||
const heightDiff = getHeightDiff(screenId, widget.id);
|
||
|
||
if (isBelow && heightDiff > 0) {
|
||
totalHeightAdjustment += heightDiff;
|
||
}
|
||
}
|
||
|
||
// 조건부 컨테이너 높이 조정
|
||
for (const container of conditionalContainers) {
|
||
const isBelow = component.position.y > container.position.y;
|
||
const actualHeight = conditionalContainerHeights[container.id];
|
||
const originalHeight = container.size?.height || 200;
|
||
const heightDiff = actualHeight ? actualHeight - originalHeight : 0;
|
||
|
||
if (isBelow && heightDiff > 0) {
|
||
totalHeightAdjustment += heightDiff;
|
||
}
|
||
}
|
||
|
||
// 🆕 Zone 기반 높이 조정
|
||
// Zone 단위로 활성 여부를 판단하여 Y 오프셋 계산
|
||
// Zone은 겹치지 않으므로 merge 로직이 불필요 (단순 boolean 판단)
|
||
for (const zone of zones) {
|
||
const zoneBottom = zone.y + zone.height;
|
||
// 컴포넌트가 Zone 하단보다 아래에 있는 경우
|
||
if (component.position.y >= zoneBottom) {
|
||
// Zone에 매칭되는 활성 레이어가 있는지 확인
|
||
const hasActiveLayer = conditionalLayers.some(
|
||
l => l.zoneId === zone.zone_id && activeLayerIds.includes(l.id)
|
||
);
|
||
if (!hasActiveLayer) {
|
||
// Zone에 활성 레이어 없음: Zone 높이만큼 위로 당김 (빈 공간 제거)
|
||
totalHeightAdjustment -= zone.height;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (totalHeightAdjustment !== 0) {
|
||
return {
|
||
...component,
|
||
position: {
|
||
...component.position,
|
||
y: component.position.y + totalHeightAdjustment,
|
||
},
|
||
};
|
||
}
|
||
|
||
return component;
|
||
});
|
||
|
||
return (
|
||
<>
|
||
{/* 일반 컴포넌트들 */}
|
||
{adjustedComponents.map((component) => {
|
||
// 조건부 표시 설정이 있는 경우에만 평가
|
||
const conditional = (component as any).conditional;
|
||
let conditionalDisabled = false;
|
||
|
||
if (conditional?.enabled) {
|
||
const conditionalResult = evaluateConditional(
|
||
conditional,
|
||
formData as Record<string, any>,
|
||
layout?.components || [],
|
||
);
|
||
|
||
// 조건에 따라 숨김 처리
|
||
if (!conditionalResult.visible) {
|
||
return null;
|
||
}
|
||
|
||
// 조건에 따라 비활성화 처리
|
||
conditionalDisabled = conditionalResult.disabled;
|
||
}
|
||
|
||
// 화면 관리 해상도를 사용하므로 위치 조정 불필요
|
||
return (
|
||
<RealtimePreview
|
||
conditionalDisabled={conditionalDisabled}
|
||
key={component.id}
|
||
component={component}
|
||
isSelected={false}
|
||
isDesignMode={false}
|
||
onClick={() => {}}
|
||
menuObjid={menuObjid}
|
||
screenId={screenId}
|
||
tableName={screen?.tableName}
|
||
userId={user?.userId}
|
||
userName={userName}
|
||
companyCode={companyCode}
|
||
menuObjid={menuObjid}
|
||
selectedRowsData={selectedRowsData}
|
||
sortBy={tableSortBy}
|
||
sortOrder={tableSortOrder}
|
||
columnOrder={tableColumnOrder}
|
||
tableDisplayData={tableDisplayData}
|
||
onSelectedRowsChange={(
|
||
_,
|
||
selectedData,
|
||
sortBy,
|
||
sortOrder,
|
||
columnOrder,
|
||
tableDisplayData,
|
||
) => {
|
||
setSelectedRowsData(selectedData);
|
||
setTableSortBy(sortBy);
|
||
setTableSortOrder(sortOrder || "asc");
|
||
setTableColumnOrder(columnOrder);
|
||
setTableDisplayData(tableDisplayData || []);
|
||
}}
|
||
flowSelectedData={flowSelectedData}
|
||
flowSelectedStepId={flowSelectedStepId}
|
||
onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => {
|
||
setFlowSelectedData(selectedData);
|
||
setFlowSelectedStepId(stepId);
|
||
}}
|
||
refreshKey={tableRefreshKey}
|
||
onRefresh={() => {
|
||
setTableRefreshKey((prev) => prev + 1);
|
||
setSelectedRowsData([]); // 선택 해제
|
||
}}
|
||
flowRefreshKey={flowRefreshKey}
|
||
onFlowRefresh={() => {
|
||
setFlowRefreshKey((prev) => prev + 1);
|
||
setFlowSelectedData([]); // 선택 해제
|
||
setFlowSelectedStepId(null);
|
||
}}
|
||
formData={formData}
|
||
onFormDataChange={(fieldName, value) => {
|
||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||
}}
|
||
onHeightChange={(componentId, newHeight) => {
|
||
setConditionalContainerHeights((prev) => ({
|
||
...prev,
|
||
[componentId]: newHeight,
|
||
}));
|
||
}}
|
||
>
|
||
{/* 자식 컴포넌트들 */}
|
||
{(component.type === "group" ||
|
||
component.type === "container" ||
|
||
component.type === "area") &&
|
||
layout.components
|
||
.filter((child) => child.parentId === component.id)
|
||
.map((child) => {
|
||
// 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정
|
||
const relativeChildComponent = {
|
||
...child,
|
||
position: {
|
||
x: child.position.x - component.position.x,
|
||
y: child.position.y - component.position.y,
|
||
z: child.position.z || 1,
|
||
},
|
||
};
|
||
|
||
return (
|
||
<RealtimePreview
|
||
key={child.id}
|
||
component={relativeChildComponent}
|
||
isSelected={false}
|
||
isDesignMode={false}
|
||
onClick={() => {}}
|
||
menuObjid={menuObjid}
|
||
screenId={screenId}
|
||
tableName={screen?.tableName}
|
||
userId={user?.userId}
|
||
userName={userName}
|
||
companyCode={companyCode}
|
||
menuObjid={menuObjid}
|
||
selectedRowsData={selectedRowsData}
|
||
sortBy={tableSortBy}
|
||
sortOrder={tableSortOrder}
|
||
columnOrder={tableColumnOrder}
|
||
tableDisplayData={tableDisplayData}
|
||
onSelectedRowsChange={(
|
||
_,
|
||
selectedData,
|
||
sortBy,
|
||
sortOrder,
|
||
columnOrder,
|
||
tableDisplayData,
|
||
) => {
|
||
setSelectedRowsData(selectedData);
|
||
setTableSortBy(sortBy);
|
||
setTableSortOrder(sortOrder || "asc");
|
||
setTableColumnOrder(columnOrder);
|
||
setTableDisplayData(tableDisplayData || []);
|
||
}}
|
||
refreshKey={tableRefreshKey}
|
||
onRefresh={() => {
|
||
setTableRefreshKey((prev) => prev + 1);
|
||
setSelectedRowsData([]); // 선택 해제
|
||
}}
|
||
formData={formData}
|
||
onFormDataChange={(fieldName, value) => {
|
||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||
}}
|
||
/>
|
||
);
|
||
})}
|
||
</RealtimePreview>
|
||
);
|
||
})}
|
||
|
||
{/* 🆕 플로우 버튼 그룹들 */}
|
||
{Object.entries(buttonGroups).map(([groupId, buttons]) => {
|
||
if (buttons.length === 0) return null;
|
||
|
||
const firstButton = buttons[0];
|
||
const groupConfig = (firstButton as any).webTypeConfig
|
||
?.flowVisibilityConfig as FlowVisibilityConfig;
|
||
|
||
// 🔍 버튼 그룹 설정 확인
|
||
console.log("🔍 버튼 그룹 설정:", {
|
||
groupId,
|
||
buttonCount: buttons.length,
|
||
buttons: buttons.map((b) => ({
|
||
id: b.id,
|
||
label: b.label,
|
||
x: b.position.x,
|
||
y: b.position.y,
|
||
})),
|
||
groupConfig: {
|
||
layoutBehavior: groupConfig.layoutBehavior,
|
||
groupDirection: groupConfig.groupDirection,
|
||
groupAlign: groupConfig.groupAlign,
|
||
groupGap: groupConfig.groupGap,
|
||
},
|
||
});
|
||
|
||
// 🔧 수정: 그룹 컨테이너는 첫 번째 버튼 위치를 기준으로 하되,
|
||
// 각 버튼의 상대 위치는 원래 위치를 유지
|
||
const firstButtonPosition = {
|
||
x: buttons[0].position.x,
|
||
y: buttons[0].position.y,
|
||
z: buttons[0].position.z || 2,
|
||
};
|
||
|
||
// 버튼 그룹 위치에도 widthOffset 적용
|
||
const adjustedGroupPosition = {
|
||
...firstButtonPosition,
|
||
x: firstButtonPosition.x + widthOffset,
|
||
};
|
||
|
||
// 그룹의 크기 계산: 버튼들의 실제 크기 + 간격을 기준으로 계산
|
||
const direction = groupConfig.groupDirection || "horizontal";
|
||
const gap = groupConfig.groupGap ?? 8;
|
||
|
||
let groupWidth = 0;
|
||
let groupHeight = 0;
|
||
|
||
if (direction === "horizontal") {
|
||
groupWidth = buttons.reduce((total, button, index) => {
|
||
const buttonWidth = button.size?.width || 100;
|
||
const gapWidth = index < buttons.length - 1 ? gap : 0;
|
||
return total + buttonWidth + gapWidth;
|
||
}, 0);
|
||
groupHeight = Math.max(...buttons.map((b) => b.size?.height || 40));
|
||
} else {
|
||
groupWidth = Math.max(...buttons.map((b) => b.size?.width || 100));
|
||
groupHeight = buttons.reduce((total, button, index) => {
|
||
const buttonHeight = button.size?.height || 40;
|
||
const gapHeight = index < buttons.length - 1 ? gap : 0;
|
||
return total + buttonHeight + gapHeight;
|
||
}, 0);
|
||
}
|
||
|
||
return (
|
||
<div
|
||
key={`flow-button-group-${groupId}`}
|
||
style={{
|
||
position: "absolute",
|
||
left: `${adjustedGroupPosition.x}px`,
|
||
top: `${adjustedGroupPosition.y}px`,
|
||
zIndex: adjustedGroupPosition.z,
|
||
width: `${groupWidth}px`,
|
||
height: `${groupHeight}px`,
|
||
}}
|
||
>
|
||
<FlowButtonGroup
|
||
buttons={buttons}
|
||
groupConfig={groupConfig}
|
||
isDesignMode={false}
|
||
renderButton={(button) => {
|
||
// 🔧 각 버튼의 상대 위치 = 버튼의 원래 위치 - 첫 번째 버튼 위치
|
||
const relativeButton = {
|
||
...button,
|
||
position: {
|
||
x: button.position.x - firstButtonPosition.x,
|
||
y: button.position.y - firstButtonPosition.y,
|
||
z: button.position.z || 1,
|
||
},
|
||
};
|
||
|
||
return (
|
||
<div
|
||
key={button.id}
|
||
style={{
|
||
position: "relative",
|
||
display: "inline-block",
|
||
width: button.size?.width || 100,
|
||
minWidth: button.size?.width || 100,
|
||
height: button.size?.height || 40,
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<div style={{ width: "100%", height: "100%" }}>
|
||
<DynamicComponentRenderer
|
||
component={relativeButton}
|
||
isDesignMode={false}
|
||
isInteractive={true}
|
||
formData={formData}
|
||
onDataflowComplete={() => {}}
|
||
screenId={screenId}
|
||
tableName={screen?.tableName}
|
||
userId={user?.userId}
|
||
userName={userName}
|
||
companyCode={companyCode}
|
||
tableDisplayData={tableDisplayData}
|
||
selectedRowsData={selectedRowsData}
|
||
sortBy={tableSortBy}
|
||
sortOrder={tableSortOrder}
|
||
columnOrder={tableColumnOrder}
|
||
onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder) => {
|
||
setSelectedRowsData(selectedData);
|
||
setTableSortBy(sortBy);
|
||
setTableSortOrder(sortOrder || "asc");
|
||
setTableColumnOrder(columnOrder);
|
||
}}
|
||
flowSelectedData={flowSelectedData}
|
||
flowSelectedStepId={flowSelectedStepId}
|
||
onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => {
|
||
setFlowSelectedData(selectedData);
|
||
setFlowSelectedStepId(stepId);
|
||
}}
|
||
refreshKey={tableRefreshKey}
|
||
onRefresh={() => {
|
||
setTableRefreshKey((prev) => prev + 1);
|
||
setSelectedRowsData([]);
|
||
}}
|
||
flowRefreshKey={flowRefreshKey}
|
||
onFlowRefresh={() => {
|
||
setFlowRefreshKey((prev) => prev + 1);
|
||
setFlowSelectedData([]);
|
||
setFlowSelectedStepId(null);
|
||
}}
|
||
onFormDataChange={(fieldName, value) => {
|
||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* 🆕 조건부 레이어 컴포넌트 렌더링 (Zone 기반) */}
|
||
{conditionalLayers.map((layer) => {
|
||
const isActive = activeLayerIds.includes(layer.id);
|
||
if (!isActive || !layer.components || layer.components.length === 0) return null;
|
||
|
||
// Zone 기반: zoneId로 Zone 찾아서 위치/크기 결정
|
||
const zone = layer.zoneId ? zones.find(z => z.zone_id === layer.zoneId) : null;
|
||
const region = zone
|
||
? { x: zone.x, y: zone.y, width: zone.width, height: zone.height }
|
||
: layer.displayRegion;
|
||
|
||
return (
|
||
<div
|
||
key={`conditional-layer-${layer.id}`}
|
||
data-conditional-layer="true"
|
||
style={{
|
||
position: "absolute",
|
||
left: region ? `${region.x}px` : "0px",
|
||
top: region ? `${region.y}px` : "0px",
|
||
width: region ? `${region.width}px` : "100%",
|
||
height: region ? `${region.height}px` : "auto",
|
||
zIndex: layer.zIndex || 20,
|
||
overflow: "hidden",
|
||
transition: "none",
|
||
}}
|
||
>
|
||
{layer.components
|
||
.filter((comp) => !comp.parentId)
|
||
.map((comp) => (
|
||
<RealtimePreview
|
||
key={comp.id}
|
||
component={comp}
|
||
isSelected={false}
|
||
isDesignMode={false}
|
||
onClick={() => {}}
|
||
menuObjid={menuObjid}
|
||
screenId={screenId}
|
||
tableName={screen?.tableName}
|
||
userId={user?.userId}
|
||
userName={userName}
|
||
companyCode={companyCode}
|
||
selectedRowsData={selectedRowsData}
|
||
sortBy={tableSortBy}
|
||
sortOrder={tableSortOrder}
|
||
columnOrder={tableColumnOrder}
|
||
tableDisplayData={tableDisplayData}
|
||
onSelectedRowsChange={(
|
||
_,
|
||
selectedData,
|
||
sortBy,
|
||
sortOrder,
|
||
columnOrder,
|
||
tableDisplayData,
|
||
) => {
|
||
setSelectedRowsData(selectedData);
|
||
setTableSortBy(sortBy);
|
||
setTableSortOrder(sortOrder || "asc");
|
||
setTableColumnOrder(columnOrder);
|
||
setTableDisplayData(tableDisplayData || []);
|
||
}}
|
||
refreshKey={tableRefreshKey}
|
||
onRefresh={() => {
|
||
setTableRefreshKey((prev) => prev + 1);
|
||
setSelectedRowsData([]);
|
||
}}
|
||
formData={formData}
|
||
onFormDataChange={(fieldName, value) => {
|
||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
})}
|
||
</>
|
||
);
|
||
})()}
|
||
</div>
|
||
</ScreenMultiLangProvider>
|
||
) : (
|
||
// 빈 화면일 때
|
||
<div className="bg-background flex items-center justify-center" style={{ minHeight: screenHeight }}>
|
||
<div className="text-center">
|
||
<div className="bg-muted mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full shadow-sm">
|
||
<span className="text-2xl">📄</span>
|
||
</div>
|
||
<h2 className="text-foreground mb-2 text-xl font-semibold">화면이 비어있습니다</h2>
|
||
<p className="text-muted-foreground">이 화면에는 아직 설계된 컴포넌트가 없습니다.</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 편집 모달 */}
|
||
<EditModal
|
||
isOpen={editModalOpen}
|
||
onClose={() => {
|
||
setEditModalOpen(false);
|
||
setEditModalConfig({});
|
||
}}
|
||
screenId={editModalConfig.screenId}
|
||
modalSize={editModalConfig.modalSize}
|
||
editData={editModalConfig.editData}
|
||
onSave={editModalConfig.onSave}
|
||
modalTitle={editModalConfig.modalTitle}
|
||
modalDescription={editModalConfig.modalDescription}
|
||
onDataChange={(changedFormData) => {
|
||
console.log("📝 EditModal에서 데이터 변경 수신:", changedFormData);
|
||
// 변경된 데이터를 메인 폼에 반영
|
||
setFormData((prev) => {
|
||
const updatedFormData = {
|
||
...prev,
|
||
...changedFormData, // 변경된 필드들만 업데이트
|
||
};
|
||
console.log("📊 메인 폼 데이터 업데이트:", updatedFormData);
|
||
return updatedFormData;
|
||
});
|
||
}}
|
||
/>
|
||
|
||
{/* 스케줄 생성 확인 다이얼로그 */}
|
||
<ScheduleConfirmDialog
|
||
open={showConfirmDialog}
|
||
onOpenChange={(open) => !open && closeDialog()}
|
||
preview={previewResult}
|
||
onConfirm={() => handleConfirm(true)}
|
||
onCancel={closeDialog}
|
||
isLoading={scheduleLoading}
|
||
/>
|
||
</div>
|
||
</TableOptionsProvider>
|
||
</ActiveTabProvider>
|
||
</ScreenPreviewProvider>
|
||
);
|
||
}
|
||
|
||
// 실제 컴포넌트를 Provider로 감싸기
|
||
function ScreenViewPageWrapper() {
|
||
return (
|
||
<TableSearchWidgetHeightProvider>
|
||
<ScreenContextProvider>
|
||
<SplitPanelProvider>
|
||
<ScreenViewPage />
|
||
</SplitPanelProvider>
|
||
</ScreenContextProvider>
|
||
</TableSearchWidgetHeightProvider>
|
||
);
|
||
}
|
||
|
||
export default ScreenViewPageWrapper;
|