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

508 lines
22 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 } from "react";
import { useParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { screenApi } from "@/lib/api/screen";
import { ScreenDefinition, LayoutData } 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"; // 🆕 사용자 정보
export default function ScreenViewPage() {
const params = useParams();
const router = useRouter();
const screenId = parseInt(params.screenId as string);
// 🆕 현재 로그인한 사용자 정보
const { user, userName, companyCode } = useAuth();
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 [flowSelectedData, setFlowSelectedData] = useState<any[]>([]);
const [flowSelectedStepId, setFlowSelectedStepId] = useState<number | null>(null);
// 테이블 새로고침을 위한 키 (값이 변경되면 테이블이 리렌더링됨)
const [tableRefreshKey, setTableRefreshKey] = useState(0);
// 플로우 새로고침을 위한 키 (값이 변경되면 플로우 데이터가 리렌더링됨)
const [flowRefreshKey, setFlowRefreshKey] = useState(0);
// 편집 모달 상태
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 containerRef = React.useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
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);
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]);
// 캔버스 비율 조정 (사용자 화면에 맞게 자동 스케일)
useEffect(() => {
const updateScale = () => {
if (containerRef.current && layout) {
const designWidth = layout?.screenResolution?.width || 1200;
const designHeight = layout?.screenResolution?.height || 800;
const containerWidth = containerRef.current.offsetWidth;
const containerHeight = containerRef.current.offsetHeight;
// 가로/세로 비율 중 작은 것을 선택 (화면에 맞게)
const scaleX = containerWidth / designWidth;
const scaleY = containerHeight / designHeight;
const newScale = Math.min(scaleX, scaleY);
setScale(newScale);
}
};
// 초기 측정
const timer = setTimeout(updateScale, 100);
window.addEventListener("resize", updateScale);
return () => {
clearTimeout(timer);
window.removeEventListener("resize", updateScale);
};
}, [layout]);
if (loading) {
return (
<div className="flex h-full min-h-[400px] w-full items-center justify-center bg-gradient-to-br from-muted to-muted/50">
<div className="rounded-xl border border-border bg-background p-8 text-center shadow-lg">
<Loader2 className="mx-auto h-10 w-10 animate-spin text-primary" />
<p className="mt-4 font-medium text-foreground"> ...</p>
</div>
</div>
);
}
if (error || !screen) {
return (
<div className="flex h-full min-h-[400px] w-full items-center justify-center bg-gradient-to-br from-muted to-muted/50">
<div className="max-w-md rounded-xl border border-border bg-background p-8 text-center shadow-lg">
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-destructive/20 to-warning/20 shadow-sm">
<span className="text-3xl"></span>
</div>
<h2 className="mb-3 text-xl font-bold text-foreground"> </h2>
<p className="mb-6 leading-relaxed text-muted-foreground">{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}>
<div ref={containerRef} className="bg-background flex h-full w-full items-start justify-start overflow-hidden">
{/* 절대 위치 기반 렌더링 */}
{layout && layout.components.length > 0 ? (
<div
className="bg-background relative origin-top-left"
style={{
width: layout?.screenResolution?.width || 1200,
height: layout?.screenResolution?.height || 800,
transform: `scale(${scale})`,
transformOrigin: "top left",
display: "flex",
flexDirection: "column",
}}
>
{/* 최상위 컴포넌트들 렌더링 */}
{(() => {
// 🆕 플로우 버튼 그룹 감지 및 처리
const topLevelComponents = layout.components.filter((component) => !component.parentId);
const buttonGroups: Record<string, any[]> = {};
const processedButtonIds = new Set<string>();
topLevelComponents.forEach((component) => {
const isButton =
component.type === "button" ||
(component.type === "component" &&
["button-primary", "button-secondary"].includes((component as any).componentType));
if (isButton) {
const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as
| FlowVisibilityConfig
| undefined;
if (flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId) {
if (!buttonGroups[flowConfig.groupId]) {
buttonGroups[flowConfig.groupId] = [];
}
buttonGroups[flowConfig.groupId].push(component);
processedButtonIds.add(component.id);
}
}
});
const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id));
return (
<>
{/* 일반 컴포넌트들 */}
{regularComponents.map((component) => (
<RealtimePreview
key={component.id}
component={component}
isSelected={false}
isDesignMode={false}
onClick={() => {}}
screenId={screenId}
tableName={screen?.tableName}
userId={user?.userId}
userName={userName}
companyCode={companyCode}
selectedRowsData={selectedRowsData}
onSelectedRowsChange={(_, selectedData) => {
console.log("🔍 화면에서 선택된 행 데이터:", selectedData);
setSelectedRowsData(selectedData);
}}
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 }));
}}
>
{/* 자식 컴포넌트들 */}
{(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={() => {}}
screenId={screenId}
tableName={screen?.tableName}
userId={user?.userId}
userName={userName}
companyCode={companyCode}
selectedRowsData={selectedRowsData}
onSelectedRowsChange={(_, selectedData) => {
console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData);
setSelectedRowsData(selectedData);
}}
refreshKey={tableRefreshKey}
onRefresh={() => {
console.log("🔄 테이블 새로고침 요청됨 (자식)");
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;
// 그룹의 위치는 모든 버튼 중 가장 왼쪽/위쪽 버튼의 위치 사용
const groupPosition = buttons.reduce(
(min, button) => ({
x: Math.min(min.x, button.position.x),
y: Math.min(min.y, button.position.y),
z: min.z,
}),
{ x: buttons[0].position.x, y: buttons[0].position.y, z: buttons[0].position.z || 2 },
);
// 그룹의 크기 계산: 버튼들의 실제 크기 + 간격을 기준으로 계산
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: `${groupPosition.x}px`,
top: `${groupPosition.y}px`,
zIndex: groupPosition.z,
width: `${groupWidth}px`,
height: `${groupHeight}px`,
}}
>
<FlowButtonGroup
buttons={buttons}
groupConfig={groupConfig}
isDesignMode={false}
renderButton={(button) => {
const relativeButton = {
...button,
position: { x: 0, y: 0, z: button.position.z || 1 },
};
return (
<div
key={button.id}
style={{
position: "relative",
display: "inline-block",
width: button.size?.width || 100,
height: button.size?.height || 40,
}}
>
<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}
selectedRowsData={selectedRowsData}
onSelectedRowsChange={(_, selectedData) => {
setSelectedRowsData(selectedData);
}}
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>
) : (
// 빈 화면일 때
<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>
</ScreenPreviewProvider>
);
}