ERP-node/frontend/app/(main)/screens/[screenId]/page.tsx

969 lines
43 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 { 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"; // 화면 다국어
function ScreenViewPage() {
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 [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);
// 레이아웃 로드
try {
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]);
// 🆕 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
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 영역(섹션)에서 컴포넌트들이 겹치지 않도록 자동 수직 정렬
const autoLayoutComponents = (() => {
// X 위치 기준으로 섹션 그룹화 (50px 오차 범위)
const X_THRESHOLD = 50;
const GAP = 16; // 컴포넌트 간 간격
// 컴포넌트를 X 섹션별로 그룹화
const sections: Map<number, typeof regularComponents> = new Map();
regularComponents.forEach((comp) => {
const x = comp.position.x;
let foundSection = false;
for (const [sectionX, components] of sections.entries()) {
if (Math.abs(x - sectionX) < X_THRESHOLD) {
components.push(comp);
foundSection = true;
break;
}
}
if (!foundSection) {
sections.set(x, [comp]);
}
});
// 각 섹션 내에서 Y 위치 순으로 정렬 후 자동 배치
const adjustedMap = new Map<string, typeof regularComponents[0]>();
for (const [sectionX, components] of sections.entries()) {
// 섹션 내 2개 이상 컴포넌트가 있을 때만 자동 배치
if (components.length >= 2) {
// Y 위치 순으로 정렬
const sorted = [...components].sort((a, b) => a.position.y - b.position.y);
let currentY = sorted[0].position.y;
sorted.forEach((comp, index) => {
if (index === 0) {
adjustedMap.set(comp.id, comp);
} else {
// 이전 컴포넌트 아래로 배치
const prevComp = sorted[index - 1];
const prevAdjusted = adjustedMap.get(prevComp.id) || prevComp;
const prevBottom = prevAdjusted.position.y + (prevAdjusted.size?.height || 100);
const newY = prevBottom + GAP;
adjustedMap.set(comp.id, {
...comp,
position: {
...comp.position,
y: newY,
},
});
}
});
} else {
// 단일 컴포넌트는 그대로
components.forEach((comp) => adjustedMap.set(comp.id, comp));
}
}
return regularComponents.map((comp) => adjustedMap.get(comp.id) || comp);
})();
// 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;
}
}
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>
);
})}
</>
);
})()}
</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;
});
}}
/>
</div>
</TableOptionsProvider>
</ActiveTabProvider>
</ScreenPreviewProvider>
);
}
// 실제 컴포넌트를 Provider로 감싸기
function ScreenViewPageWrapper() {
return (
<TableSearchWidgetHeightProvider>
<ScreenContextProvider>
<SplitPanelProvider>
<ScreenViewPage />
</SplitPanelProvider>
</ScreenContextProvider>
</TableSearchWidgetHeightProvider>
);
}
export default ScreenViewPageWrapper;