835 lines
31 KiB
TypeScript
835 lines
31 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, FileQuestion, AlertTriangle } 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 { showErrorToast } from "@/lib/utils/toastUtils";
|
|
import { initializeComponents } from "@/lib/registry/components";
|
|
import { EditModal } from "@/components/screen/EditModal";
|
|
import { RealtimePreview } from "@/components/screen/RealtimePreviewDynamic";
|
|
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
|
import { TableSearchWidgetHeightProvider } 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";
|
|
import { useScheduleGenerator, ScheduleConfirmDialog } from "@/lib/v2-core/services/ScheduleGeneratorService";
|
|
import { ResponsiveGridRenderer } from "@/components/screen/ResponsiveGridRenderer";
|
|
import { useTabId } from "@/contexts/TabIdContext";
|
|
import { useTabStore } from "@/stores/tabStore";
|
|
|
|
export interface ScreenViewPageProps {
|
|
screenIdProp?: number;
|
|
menuObjidProp?: number;
|
|
}
|
|
|
|
function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {}) {
|
|
// 스케줄 자동 생성 서비스 활성화
|
|
const {
|
|
showConfirmDialog,
|
|
previewResult,
|
|
handleConfirm,
|
|
closeDialog,
|
|
isLoading: scheduleLoading,
|
|
} = useScheduleGenerator();
|
|
const params = useParams();
|
|
const searchParams = useSearchParams();
|
|
const router = useRouter();
|
|
const screenId = screenIdProp ?? parseInt(params.screenId as string);
|
|
|
|
// props 우선, 없으면 URL 쿼리에서 menuObjid 가져오기
|
|
const menuObjid =
|
|
menuObjidProp ?? (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 [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 [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[]>([]);
|
|
// 데이터 전달에 의해 강제 활성화된 레이어 ID 목록
|
|
const [forceActivatedLayerIds, setForceActivatedLayerIds] = useState<string[]>([]);
|
|
|
|
// 편집 모달 상태
|
|
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(false);
|
|
|
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const initComponents = async () => {
|
|
try {
|
|
await initializeComponents();
|
|
} catch (error) {
|
|
console.error("❌ 할당된 화면에서 컴포넌트 시스템 초기화 실패:", error);
|
|
}
|
|
};
|
|
|
|
initComponents();
|
|
}, []);
|
|
|
|
// 편집 모달 이벤트 리스너 등록 (활성 탭에서만 처리)
|
|
const tabId = useTabId();
|
|
useEffect(() => {
|
|
const handleOpenEditModal = (event: CustomEvent) => {
|
|
const state = useTabStore.getState();
|
|
const currentActiveTabId = state[state.mode].activeTabId;
|
|
if (tabId && tabId !== currentActiveTabId) return;
|
|
|
|
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);
|
|
};
|
|
}, [tabId]);
|
|
|
|
useEffect(() => {
|
|
const loadScreen = async () => {
|
|
try {
|
|
setLoading(true);
|
|
setLayoutReady(false);
|
|
setError(null);
|
|
|
|
const screenData = await screenApi.getScreen(screenId);
|
|
setScreen(screenData);
|
|
|
|
// 레이아웃 로드 (V2 우선, Zod 기반 기본값 병합)
|
|
try {
|
|
const v2Response = await screenApi.getLayoutV2(screenId);
|
|
|
|
if (v2Response && isValidV2Layout(v2Response)) {
|
|
const convertedLayout = convertV2ToLegacy(v2Response);
|
|
if (convertedLayout) {
|
|
setLayout({
|
|
...convertedLayout,
|
|
screenResolution: v2Response.screenResolution || convertedLayout.screenResolution,
|
|
} as LayoutData);
|
|
} else {
|
|
throw new Error("V2 레이아웃 변환 실패");
|
|
}
|
|
} else {
|
|
const layoutData = await screenApi.getLayout(screenId);
|
|
if (layoutData?.components?.length > 0) {
|
|
setLayout(layoutData);
|
|
} else {
|
|
console.warn("[ScreenViewPage] getLayout 실패, getLayerLayout(1) fallback:", screenId);
|
|
const baseLayerData = await screenApi.getLayerLayout(screenId, 1);
|
|
if (baseLayerData && isValidV2Layout(baseLayerData)) {
|
|
const converted = convertV2ToLegacy(baseLayerData);
|
|
if (converted) {
|
|
setLayout({
|
|
...converted,
|
|
screenResolution: baseLayerData.screenResolution || converted.screenResolution,
|
|
} as LayoutData);
|
|
} else {
|
|
setLayout(layoutData);
|
|
}
|
|
} else {
|
|
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("화면을 불러오는데 실패했습니다.");
|
|
showErrorToast("화면을 불러오는 데 실패했습니다", error, {
|
|
guidance: "화면 설정을 확인하거나 잠시 후 다시 시도해 주세요.",
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
setLayoutReady(true);
|
|
}
|
|
};
|
|
|
|
if (screenId) {
|
|
loadScreen();
|
|
}
|
|
}, [screenId]);
|
|
|
|
// 조건부 레이어 + Zone 로드
|
|
useEffect(() => {
|
|
const loadConditionalLayersAndZones = async () => {
|
|
if (!screenId || !layout) return;
|
|
|
|
try {
|
|
// Zone 로드
|
|
const loadedZones = await screenApi.getScreenZones(screenId);
|
|
setZones(loadedZones);
|
|
|
|
// 모든 레이어 목록 조회
|
|
const allLayers = await screenApi.getScreenLayers(screenId);
|
|
const nonBaseLayers = allLayers.filter((l: any) => l.layer_id > 1);
|
|
|
|
if (nonBaseLayers.length === 0) {
|
|
setConditionalLayers([]);
|
|
return;
|
|
}
|
|
|
|
// 각 레이어의 레이아웃 데이터 로드
|
|
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 || {};
|
|
|
|
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 || [];
|
|
}
|
|
}
|
|
}
|
|
|
|
const zoneId = condConfig.zone_id;
|
|
const conditionValue = condConfig.condition_value;
|
|
const zone = zoneId ? loadedZones.find((z: any) => z.zone_id === zoneId) : null;
|
|
|
|
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,
|
|
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,
|
|
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);
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
if (!targetComponentId) return;
|
|
|
|
const targetComponent = allComponents.find((c) => c.id === targetComponentId);
|
|
|
|
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 (isMatch) {
|
|
newActiveIds.push(layer.id);
|
|
}
|
|
}
|
|
});
|
|
|
|
for (const forcedId of forceActivatedLayerIds) {
|
|
if (!newActiveIds.includes(forcedId)) {
|
|
newActiveIds.push(forcedId);
|
|
}
|
|
}
|
|
|
|
return newActiveIds;
|
|
}, [formData, conditionalLayers, layout, forceActivatedLayerIds]);
|
|
|
|
// 데이터 전달에 의한 레이어 강제 활성화 이벤트 리스너
|
|
useEffect(() => {
|
|
const handleActivateLayer = (e: Event) => {
|
|
const { componentId, targetLayerId } = (e as CustomEvent).detail || {};
|
|
if (!componentId && !targetLayerId) return;
|
|
|
|
if (targetLayerId) {
|
|
setForceActivatedLayerIds((prev) => (prev.includes(targetLayerId) ? prev : [...prev, targetLayerId]));
|
|
return;
|
|
}
|
|
|
|
for (const layer of conditionalLayers) {
|
|
const found = layer.components.some((comp) => comp.id === componentId);
|
|
if (found) {
|
|
setForceActivatedLayerIds((prev) => (prev.includes(layer.id) ? prev : [...prev, layer.id]));
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
|
|
window.addEventListener("activateLayerForComponent", handleActivateLayer);
|
|
return () => {
|
|
window.removeEventListener("activateLayerForComponent", handleActivateLayer);
|
|
};
|
|
}, [conditionalLayers]);
|
|
|
|
// 메인 테이블 데이터 자동 로드 (단일 레코드 폼)
|
|
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");
|
|
|
|
const result = await tableTypeApi.getTableRecord(mainTableName, "company_code", companyCode, "*");
|
|
|
|
if (result && 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) {
|
|
if (comp.type === "widget" || comp.type === "component") {
|
|
const widget = comp as any;
|
|
const fieldName = widget.columnName || widget.id;
|
|
|
|
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 "";
|
|
|
|
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]);
|
|
|
|
// 화면 해상도 정보
|
|
const screenWidth = layout?.screenResolution?.width || 1200;
|
|
const screenHeight = layout?.screenResolution?.height || 800;
|
|
|
|
// RealtimePreview에 전달할 공통 props 빌더
|
|
const buildRealtimePreviewProps = (component: ComponentData, extraProps?: Record<string, any>) => ({
|
|
component,
|
|
isSelected: false,
|
|
isDesignMode: false,
|
|
onClick: () => {},
|
|
menuObjid,
|
|
screenId,
|
|
tableName: screen?.tableName,
|
|
userId: user?.userId,
|
|
userName,
|
|
companyCode,
|
|
selectedRowsData,
|
|
sortBy: tableSortBy,
|
|
sortOrder: tableSortOrder,
|
|
columnOrder: tableColumnOrder,
|
|
flowSelectedData,
|
|
flowSelectedStepId,
|
|
onFlowSelectedDataChange: (selectedData: any[], stepId: number | null) => {
|
|
setFlowSelectedData(selectedData);
|
|
setFlowSelectedStepId(stepId);
|
|
},
|
|
refreshKey: tableRefreshKey,
|
|
onRefresh: () => {
|
|
setTableRefreshKey((prev) => prev + 1);
|
|
setSelectedRowsData([]);
|
|
},
|
|
flowRefreshKey,
|
|
onFlowRefresh: () => {
|
|
setFlowRefreshKey((prev) => prev + 1);
|
|
setFlowSelectedData([]);
|
|
setFlowSelectedStepId(null);
|
|
},
|
|
formData,
|
|
onFormDataChange: (fieldName: string, value: any) => {
|
|
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
|
},
|
|
onSelectedRowsChange: (_: any[], selectedData: any[]) => {
|
|
setSelectedRowsData(selectedData);
|
|
},
|
|
...extraProps,
|
|
});
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="bg-muted/30 flex h-full min-h-[400px] w-full items-center justify-center">
|
|
<div className="border-border bg-background rounded-lg border p-8 text-center">
|
|
<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="bg-muted/30 flex h-full min-h-[400px] w-full items-center justify-center">
|
|
<div className="border-border bg-background max-w-md rounded-lg border p-8 text-center">
|
|
<div className="bg-destructive/10 mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full">
|
|
<AlertTriangle className="text-destructive h-10 w-10" />
|
|
</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>
|
|
);
|
|
}
|
|
|
|
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="bg-muted/30 flex h-full w-full items-center justify-center">
|
|
<div className="border-border bg-background rounded-lg border p-8 text-center">
|
|
<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}>
|
|
{/* 기본 레이어: ResponsiveGridRenderer로 렌더링 */}
|
|
<ResponsiveGridRenderer
|
|
components={layout.components}
|
|
canvasWidth={screenWidth}
|
|
canvasHeight={screenHeight}
|
|
renderComponent={(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
|
|
{...buildRealtimePreviewProps(component, {
|
|
conditionalDisabled,
|
|
onHeightChange: (componentId: string, newHeight: number) => {
|
|
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}
|
|
{...buildRealtimePreviewProps(relativeChildComponent)}
|
|
/>
|
|
);
|
|
})}
|
|
</RealtimePreview>
|
|
);
|
|
}}
|
|
/>
|
|
|
|
{/* 조건부 레이어 (Zone 기반) */}
|
|
{conditionalLayers.map((layer) => {
|
|
const isActive = activeLayerIds.includes(layer.id);
|
|
if (!isActive || !layer.components || layer.components.length === 0) return null;
|
|
|
|
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",
|
|
}}
|
|
>
|
|
<ResponsiveGridRenderer
|
|
components={layer.components.filter((comp) => !comp.parentId)}
|
|
canvasWidth={region?.width || screenWidth}
|
|
canvasHeight={region?.height || screenHeight}
|
|
renderComponent={(comp) => (
|
|
<RealtimePreview key={comp.id} {...buildRealtimePreviewProps(comp)} />
|
|
)}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</ScreenMultiLangProvider>
|
|
) : (
|
|
// 빈 화면일 때
|
|
layoutReady && (
|
|
<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">
|
|
<FileQuestion className="text-muted-foreground h-8 w-8" />
|
|
</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) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
...changedFormData,
|
|
}));
|
|
}}
|
|
/>
|
|
|
|
{/* 스케줄 생성 확인 다이얼로그 */}
|
|
<ScheduleConfirmDialog
|
|
open={showConfirmDialog}
|
|
onOpenChange={(open) => !open && closeDialog()}
|
|
preview={previewResult}
|
|
onConfirm={() => handleConfirm(true)}
|
|
onCancel={closeDialog}
|
|
isLoading={scheduleLoading}
|
|
/>
|
|
</div>
|
|
</TableOptionsProvider>
|
|
</ActiveTabProvider>
|
|
</ScreenPreviewProvider>
|
|
);
|
|
}
|
|
|
|
// 실제 컴포넌트를 Provider로 감싸기
|
|
function ScreenViewPageWrapper({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {}) {
|
|
return (
|
|
<TableSearchWidgetHeightProvider>
|
|
<ScreenContextProvider>
|
|
<SplitPanelProvider>
|
|
<ScreenViewPage screenIdProp={screenIdProp} menuObjidProp={menuObjidProp} />
|
|
</SplitPanelProvider>
|
|
</ScreenContextProvider>
|
|
</TableSearchWidgetHeightProvider>
|
|
);
|
|
}
|
|
|
|
export { ScreenViewPageWrapper };
|
|
export default ScreenViewPageWrapper;
|