jskim-node #393

Merged
kjs merged 11 commits from jskim-node into main 2026-02-24 15:31:32 +09:00
13 changed files with 2258 additions and 522 deletions
Showing only changes of commit 27853a9447 - Show all commits

View File

@ -1,8 +1,9 @@
"use client"; "use client";
import { useEffect, ReactNode, useState } from "react"; import { useEffect, ReactNode } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { Loader2 } from "lucide-react";
interface AuthGuardProps { interface AuthGuardProps {
children: ReactNode; children: ReactNode;
@ -15,6 +16,8 @@ interface AuthGuardProps {
/** /**
* *
* *
* - /401 client.ts
* -
*/ */
export function AuthGuard({ export function AuthGuard({
children, children,
@ -23,145 +26,67 @@ export function AuthGuard({
redirectTo = "/login", redirectTo = "/login",
fallback, fallback,
}: AuthGuardProps) { }: AuthGuardProps) {
const { isLoggedIn, isAdmin, loading, error } = useAuth(); const { isLoggedIn, isAdmin, loading } = useAuth();
const router = useRouter(); const router = useRouter();
const [redirectCountdown, setRedirectCountdown] = useState<number | null>(null);
const [authDebugInfo, setAuthDebugInfo] = useState<any>({});
useEffect(() => { useEffect(() => {
console.log("=== AuthGuard 디버깅 ==="); if (loading) return;
console.log("requireAuth:", requireAuth);
console.log("requireAdmin:", requireAdmin);
console.log("loading:", loading);
console.log("isLoggedIn:", isLoggedIn);
console.log("isAdmin:", isAdmin);
console.log("error:", error);
// 토큰 확인을 더 정확하게 // 토큰이 있는데 아직 인증 확인 중이면 대기
if (typeof window !== "undefined") {
const token = localStorage.getItem("authToken"); const token = localStorage.getItem("authToken");
console.log("AuthGuard localStorage 토큰:", token ? "존재" : "없음");
console.log("현재 경로:", window.location.pathname);
// 디버깅 정보 수집
setAuthDebugInfo({
requireAuth,
requireAdmin,
loading,
isLoggedIn,
isAdmin,
error,
hasToken: !!token,
currentPath: window.location.pathname,
timestamp: new Date().toISOString(),
tokenLength: token ? token.length : 0,
});
if (loading) {
console.log("AuthGuard: 로딩 중 - 대기");
return;
}
// 토큰이 있는데도 인증이 안 된 경우, 잠시 대기
if (token && !isLoggedIn && !loading) { if (token && !isLoggedIn && !loading) {
console.log("AuthGuard: 토큰은 있지만 인증이 안됨 - 잠시 대기");
return; return;
} }
}
// 인증이 필요한데 로그인되지 않은 경우
if (requireAuth && !isLoggedIn) { if (requireAuth && !isLoggedIn) {
console.log("AuthGuard: 인증 필요하지만 로그인되지 않음 - 5초 후 리다이렉트");
console.log("리다이렉트 대상:", redirectTo);
setRedirectCountdown(5);
const countdownInterval = setInterval(() => {
setRedirectCountdown((prev) => {
if (prev === null || prev <= 1) {
clearInterval(countdownInterval);
router.push(redirectTo); router.push(redirectTo);
return null;
}
return prev - 1;
});
}, 1000);
return; return;
} }
// 관리자 권한이 필요한데 관리자가 아닌 경우
if (requireAdmin && !isAdmin) { if (requireAdmin && !isAdmin) {
console.log("AuthGuard: 관리자 권한 필요하지만 관리자가 아님 - 5초 후 리다이렉트");
console.log("리다이렉트 대상:", redirectTo);
setRedirectCountdown(5);
const countdownInterval = setInterval(() => {
setRedirectCountdown((prev) => {
if (prev === null || prev <= 1) {
clearInterval(countdownInterval);
router.push(redirectTo); router.push(redirectTo);
return null;
}
return prev - 1;
});
}, 1000);
return; return;
} }
}, [requireAuth, requireAdmin, loading, isLoggedIn, isAdmin, redirectTo, router]);
console.log("AuthGuard: 모든 인증 조건 통과 - 컴포넌트 렌더링");
}, [requireAuth, requireAdmin, loading, isLoggedIn, isAdmin, error, redirectTo, router]);
// 로딩 중일 때 fallback 또는 기본 로딩 표시
if (loading) { if (loading) {
console.log("AuthGuard: 로딩 중 - fallback 표시");
return ( return (
<div> fallback || (
<div className="mb-4 rounded bg-primary/20 p-4"> <div className="flex h-screen items-center justify-center">
<h3 className="font-bold">AuthGuard ...</h3> <div className="flex flex-col items-center gap-3">
<pre className="text-xs">{JSON.stringify(authDebugInfo, null, 2)}</pre> <Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground"> ...</p>
</div> </div>
{fallback || <div> ...</div>}
</div> </div>
)
); );
} }
// 인증 실패 시 fallback 또는 기본 메시지 표시
if (requireAuth && !isLoggedIn) { if (requireAuth && !isLoggedIn) {
console.log("AuthGuard: 인증 실패 - fallback 표시");
return ( return (
<div> fallback || (
<div className="mb-4 rounded bg-destructive/20 p-4"> <div className="flex h-screen items-center justify-center">
<h3 className="font-bold"> </h3> <div className="flex flex-col items-center gap-3">
{redirectCountdown !== null && ( <Loader2 className="h-8 w-8 animate-spin text-primary" />
<div className="mb-2 text-destructive"> <p className="text-sm text-muted-foreground"> ...</p>
<strong> :</strong> {redirectCountdown} {redirectTo}
</div> </div>
)}
<pre className="text-xs">{JSON.stringify(authDebugInfo, null, 2)}</pre>
</div>
{fallback || <div> .</div>}
</div> </div>
)
); );
} }
if (requireAdmin && !isAdmin) { if (requireAdmin && !isAdmin) {
console.log("AuthGuard: 관리자 권한 없음 - fallback 표시");
return ( return (
<div> fallback || (
<div className="mb-4 rounded bg-orange-100 p-4"> <div className="flex h-screen items-center justify-center">
<h3 className="font-bold"> </h3> <p className="text-sm text-muted-foreground"> .</p>
{redirectCountdown !== null && (
<div className="mb-2 text-destructive">
<strong> :</strong> {redirectCountdown} {redirectTo}
</div>
)}
<pre className="text-xs">{JSON.stringify(authDebugInfo, null, 2)}</pre>
</div>
{fallback || <div> .</div>}
</div> </div>
)
); );
} }
console.log("AuthGuard: 인증 성공 - 자식 컴포넌트 렌더링");
return <>{children}</>; return <>{children}</>;
} }

View File

@ -1089,98 +1089,106 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4; const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const labelOffset = hasVisibleLabel ? (labelFontSize + labelMarginBottom + 2) : 0; const labelOffset = hasVisibleLabel ? (labelFontSize + labelMarginBottom + 2) : 0;
// 캔버스 분할선에 따른 X 위치 조정 (너비는 변경하지 않음 - 내부 컴포넌트 깨짐 방지) const calculateCanvasSplitX = (): { x: number; w: number } => {
const calculateCanvasSplitX = (): number => {
const compType = (component as any).componentType || ""; const compType = (component as any).componentType || "";
const isSplitLine = type === "component" && compType === "v2-split-line"; const isSplitLine = type === "component" && compType === "v2-split-line";
const origX = position?.x || 0; const origX = position?.x || 0;
const defaultW = size?.width || 200;
if (isSplitLine) return origX; if (isSplitLine) return { x: origX, w: defaultW };
// DEBUG: 스플릿 스토어 상태 확인 (첫 컴포넌트만)
if (canvasSplit.active && origX > 0 && origX < 50) {
console.log("[SplitDebug]", {
compId: component.id,
compType,
type,
active: canvasSplit.active,
scopeId: canvasSplit.scopeId,
myScopeId: myScopeIdRef.current,
canvasWidth: canvasSplit.canvasWidth,
initialX: canvasSplit.initialDividerX,
currentX: canvasSplit.currentDividerX,
origX,
});
}
if (!canvasSplit.active || canvasSplit.canvasWidth <= 0 || !canvasSplit.scopeId) { if (!canvasSplit.active || canvasSplit.canvasWidth <= 0 || !canvasSplit.scopeId) {
return origX; return { x: origX, w: defaultW };
} }
if (myScopeIdRef.current === null) { if (myScopeIdRef.current === null) {
const el = document.getElementById(`interactive-${component.id}`); const el = document.getElementById(`interactive-${component.id}`);
const container = el?.closest("[data-screen-runtime]"); const container = el?.closest("[data-screen-runtime]");
myScopeIdRef.current = container?.getAttribute("data-split-scope") || "__none__"; myScopeIdRef.current = container?.getAttribute("data-split-scope") || "__none__";
console.log("[SplitDebug] scope resolved:", { compId: component.id, elFound: !!el, containerFound: !!container, myScopeId: myScopeIdRef.current, storeScopeId: canvasSplit.scopeId });
} }
if (myScopeIdRef.current !== canvasSplit.scopeId) { if (myScopeIdRef.current !== canvasSplit.scopeId) {
return origX; return { x: origX, w: defaultW };
} }
const { initialDividerX, currentDividerX, canvasWidth } = canvasSplit; const { initialDividerX, currentDividerX, canvasWidth } = canvasSplit;
const delta = currentDividerX - initialDividerX; const delta = currentDividerX - initialDividerX;
if (Math.abs(delta) < 1) return origX; if (Math.abs(delta) < 1) return { x: origX, w: defaultW };
const origW = size?.width || 200; const origW = defaultW;
if (canvasSplitSideRef.current === null) { if (canvasSplitSideRef.current === null) {
const componentCenterX = origX + (origW / 2); const componentCenterX = origX + (origW / 2);
canvasSplitSideRef.current = componentCenterX < initialDividerX ? "left" : "right"; canvasSplitSideRef.current = componentCenterX < initialDividerX ? "left" : "right";
} }
let newX = origX; // 영역별 비례 스케일링: 스플릿선이 벽 역할 → 절대 넘어가지 않음
let newX: number;
let newW: number;
const GAP = 4; // 스플릿선과의 최소 간격
if (canvasSplitSideRef.current === "left") { if (canvasSplitSideRef.current === "left") {
if (initialDividerX > 0) { // 왼쪽 영역: [0, currentDividerX - GAP]
newX = origX * (currentDividerX / initialDividerX); const initialZoneWidth = initialDividerX;
const currentZoneWidth = Math.max(20, currentDividerX - GAP);
const scale = initialZoneWidth > 0 ? currentZoneWidth / initialZoneWidth : 1;
newX = origX * scale;
newW = origW * scale;
// 안전 클램핑: 왼쪽 영역을 절대 넘지 않음
if (newX + newW > currentDividerX - GAP) {
newW = currentDividerX - GAP - newX;
} }
} else { } else {
// 오른쪽 영역: [currentDividerX + GAP, canvasWidth]
const initialRightWidth = canvasWidth - initialDividerX; const initialRightWidth = canvasWidth - initialDividerX;
const currentRightWidth = canvasWidth - currentDividerX; const currentRightWidth = Math.max(20, canvasWidth - currentDividerX - GAP);
if (initialRightWidth > 0) { const scale = initialRightWidth > 0 ? currentRightWidth / initialRightWidth : 1;
const posRatio = (origX - initialDividerX) / initialRightWidth; const rightOffset = origX - initialDividerX;
newX = currentDividerX + posRatio * currentRightWidth; newX = currentDividerX + GAP + rightOffset * scale;
} newW = origW * scale;
// 안전 클램핑: 오른쪽 영역을 절대 넘지 않음
if (newX < currentDividerX + GAP) newX = currentDividerX + GAP;
if (newX + newW > canvasWidth) newW = canvasWidth - newX;
} }
// 캔버스 범위 내로 클램핑 newX = Math.max(0, newX);
return Math.max(0, Math.min(newX, canvasWidth - 10)); newW = Math.max(20, newW);
return { x: newX, w: newW };
}; };
const adjustedX = calculateCanvasSplitX(); const splitResult = calculateCanvasSplitX();
const adjustedX = splitResult.x;
const adjustedW = splitResult.w;
const origW = size?.width || 200;
const isSplitActive = canvasSplit.active && canvasSplit.scopeId && myScopeIdRef.current === canvasSplit.scopeId; const isSplitActive = canvasSplit.active && canvasSplit.scopeId && myScopeIdRef.current === canvasSplit.scopeId;
// styleWithoutSize에서 left/top 제거 (캔버스 분할 조정값 덮어쓰기 방지)
const { left: _styleLeft, top: _styleTop, ...safeStyleWithoutSize } = styleWithoutSize as any;
const componentStyle = { const componentStyle = {
position: "absolute" as const, position: "absolute" as const,
...safeStyleWithoutSize,
// left/top은 반드시 마지막에 (styleWithoutSize가 덮어쓰지 못하게)
left: adjustedX, left: adjustedX,
top: position?.y || 0, top: position?.y || 0,
zIndex: position?.z || 1, zIndex: position?.z || 1,
...styleWithoutSize, width: isSplitActive ? adjustedW : (size?.width || 200),
width: size?.width || 200,
height: isTableSearchWidget ? "auto" : size?.height || 10, height: isTableSearchWidget ? "auto" : size?.height || 10,
minHeight: isTableSearchWidget ? "48px" : undefined, minHeight: isTableSearchWidget ? "48px" : undefined,
overflow: labelOffset > 0 ? "visible" : undefined, overflow: (isSplitActive && adjustedW < origW) ? "hidden" : (labelOffset > 0 ? "visible" : undefined),
// GPU 가속: 드래그 중 will-change 활성화, 끝나면 해제 willChange: canvasSplit.isDragging && isSplitActive ? "left, width" as const : undefined,
willChange: canvasSplit.isDragging && isSplitActive ? "left" as const : undefined,
transition: isSplitActive transition: isSplitActive
? (canvasSplit.isDragging ? "none" : "left 0.15s ease-out") ? (canvasSplit.isDragging ? "none" : "left 0.15s ease-out, width 0.15s ease-out")
: undefined, : undefined,
}; };
return ( return (
<> <>
<div id={`interactive-${component.id}`} className="absolute" style={componentStyle}> <div id={`interactive-${component.id}`} className="absolute" style={componentStyle}>
{/* 위젯 렌더링 (라벨은 V2Input 내부에서 absolute로 표시됨) */} {renderInteractiveWidget(
{renderInteractiveWidget(component)} isSplitActive && adjustedW !== origW
? { ...component, size: { ...(component as any).size, width: adjustedW } }
: component
)}
</div> </div>
{/* 팝업 화면 렌더링 */} {/* 팝업 화면 렌더링 */}

View File

@ -424,10 +424,10 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
type === "component" && componentType === "v2-split-line"; type === "component" && componentType === "v2-split-line";
if (isSplitLineComponent) { if (isSplitLineComponent) {
return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false }; return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false };
} }
// === 1. 캔버스 분할선 (글로벌 스토어) - 위치만 조정, 너비 미변경 === // === 1. 캔버스 분할선 (글로벌 스토어) ===
if (canvasSplit.active && canvasSplit.canvasWidth > 0 && canvasSplit.scopeId) { if (canvasSplit.active && canvasSplit.canvasWidth > 0 && canvasSplit.scopeId) {
if (myScopeIdRef.current === null) { if (myScopeIdRef.current === null) {
const el = document.getElementById(`component-${id}`); const el = document.getElementById(`component-${id}`);
@ -435,7 +435,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
myScopeIdRef.current = container?.getAttribute("data-split-scope") || "__none__"; myScopeIdRef.current = container?.getAttribute("data-split-scope") || "__none__";
} }
if (myScopeIdRef.current !== canvasSplit.scopeId) { if (myScopeIdRef.current !== canvasSplit.scopeId) {
return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false }; return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false };
} }
const { initialDividerX, currentDividerX, canvasWidth, isDragging: splitDragging } = canvasSplit; const { initialDividerX, currentDividerX, canvasWidth, isDragging: splitDragging } = canvasSplit;
const delta = currentDividerX - initialDividerX; const delta = currentDividerX - initialDividerX;
@ -447,27 +447,39 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
} }
if (Math.abs(delta) < 1) { if (Math.abs(delta) < 1) {
return { adjustedPositionX: position.x, isOnSplitPanel: true, isDraggingSplitPanel: splitDragging }; return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: true, isDraggingSplitPanel: splitDragging };
} }
let adjustedX = position.x; // 영역별 비례 스케일링: 스플릿선이 벽 역할 → 절대 넘어가지 않음
const origW = size?.width || 100;
const GAP = 4;
let adjustedX: number;
let adjustedW: number;
if (canvasSplitSideRef.current === "left") { if (canvasSplitSideRef.current === "left") {
if (initialDividerX > 0) { const initialZoneWidth = initialDividerX;
adjustedX = position.x * (currentDividerX / initialDividerX); const currentZoneWidth = Math.max(20, currentDividerX - GAP);
const scale = initialZoneWidth > 0 ? currentZoneWidth / initialZoneWidth : 1;
adjustedX = position.x * scale;
adjustedW = origW * scale;
if (adjustedX + adjustedW > currentDividerX - GAP) {
adjustedW = currentDividerX - GAP - adjustedX;
} }
} else { } else {
const initialRightWidth = canvasWidth - initialDividerX; const initialRightWidth = canvasWidth - initialDividerX;
const currentRightWidth = canvasWidth - currentDividerX; const currentRightWidth = Math.max(20, canvasWidth - currentDividerX - GAP);
if (initialRightWidth > 0) { const scale = initialRightWidth > 0 ? currentRightWidth / initialRightWidth : 1;
const posRatio = (position.x - initialDividerX) / initialRightWidth; const rightOffset = position.x - initialDividerX;
adjustedX = currentDividerX + posRatio * currentRightWidth; adjustedX = currentDividerX + GAP + rightOffset * scale;
} adjustedW = origW * scale;
if (adjustedX < currentDividerX + GAP) adjustedX = currentDividerX + GAP;
if (adjustedX + adjustedW > canvasWidth) adjustedW = canvasWidth - adjustedX;
} }
adjustedX = Math.max(0, Math.min(adjustedX, canvasWidth - 10)); adjustedX = Math.max(0, adjustedX);
adjustedW = Math.max(20, adjustedW);
return { adjustedPositionX: adjustedX, isOnSplitPanel: true, isDraggingSplitPanel: splitDragging }; return { adjustedPositionX: adjustedX, adjustedWidth: adjustedW, isOnSplitPanel: true, isDraggingSplitPanel: splitDragging };
} }
// === 2. 레거시 분할 패널 (Context) - 버튼 전용 === // === 2. 레거시 분할 패널 (Context) - 버튼 전용 ===
@ -475,11 +487,11 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
type === "component" && ["split-panel-layout", "split-panel-layout2"].includes(componentType); type === "component" && ["split-panel-layout", "split-panel-layout2"].includes(componentType);
if (isSplitPanelComponent) { if (isSplitPanelComponent) {
return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false }; return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false };
} }
if (!isButtonComponent) { if (!isButtonComponent) {
return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false }; return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false };
} }
const componentWidth = size?.width || 100; const componentWidth = size?.width || 100;
@ -493,7 +505,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
initialPanelIdRef.current = null; initialPanelIdRef.current = null;
isInLeftPanelRef.current = null; isInLeftPanelRef.current = null;
} }
return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false }; return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false };
} }
const { panel } = overlap; const { panel } = overlap;
@ -512,37 +524,42 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
const dividerDelta = currentDividerX - initialDividerX; const dividerDelta = currentDividerX - initialDividerX;
if (Math.abs(dividerDelta) < 1) { if (Math.abs(dividerDelta) < 1) {
return { adjustedPositionX: position.x, isOnSplitPanel: true, isDraggingSplitPanel: panel.isDragging }; return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: true, isDraggingSplitPanel: panel.isDragging };
} }
const adjustedX = isInLeftPanelRef.current ? position.x + dividerDelta : position.x; const adjustedX = isInLeftPanelRef.current ? position.x + dividerDelta : position.x;
return { return {
adjustedPositionX: adjustedX, adjustedPositionX: adjustedX,
adjustedWidth: null,
isOnSplitPanel: true, isOnSplitPanel: true,
isDraggingSplitPanel: panel.isDragging, isDraggingSplitPanel: panel.isDragging,
}; };
}; };
const { adjustedPositionX, isOnSplitPanel, isDraggingSplitPanel } = calculateSplitAdjustedPosition(); const { adjustedPositionX, adjustedWidth: splitAdjustedWidth, isOnSplitPanel, isDraggingSplitPanel } = calculateSplitAdjustedPosition();
const displayWidth = resizeSize ? `${resizeSize.width}px` : getWidth(); const displayWidth = resizeSize ? `${resizeSize.width}px` : getWidth();
const displayHeight = resizeSize ? `${resizeSize.height}px` : getHeight(); const displayHeight = resizeSize ? `${resizeSize.height}px` : getHeight();
const isSplitActive = canvasSplit.active && canvasSplit.scopeId && myScopeIdRef.current === canvasSplit.scopeId; const isSplitActive = canvasSplit.active && canvasSplit.scopeId && myScopeIdRef.current === canvasSplit.scopeId;
const origWidth = size?.width || 100;
const isSplitShrunk = splitAdjustedWidth !== null && splitAdjustedWidth < origWidth;
const baseStyle = { const baseStyle = {
left: `${adjustedPositionX}px`, left: `${adjustedPositionX}px`,
top: `${position.y}px`, top: `${position.y}px`,
...componentStyle, ...componentStyle,
width: displayWidth, width: splitAdjustedWidth !== null ? `${splitAdjustedWidth}px` : displayWidth,
height: displayHeight, height: displayHeight,
zIndex: component.type === "layout" ? 1 : position.z || 2, zIndex: component.type === "layout" ? 1 : position.z || 2,
right: undefined, right: undefined,
willChange: canvasSplit.isDragging && isSplitActive ? "left" as const : undefined, overflow: isSplitShrunk ? "hidden" as const : undefined,
willChange: canvasSplit.isDragging && isSplitActive ? "left, width" as const : undefined,
transition: transition:
isResizing ? "none" : isResizing ? "none" :
isOnSplitPanel ? (isDraggingSplitPanel ? "none" : "left 0.15s ease-out") : undefined, isOnSplitPanel ? (isDraggingSplitPanel ? "none" : "left 0.15s ease-out, width 0.15s ease-out") : undefined,
}; };
// 크기 정보는 필요시에만 디버깅 (개발 중 문제 발생 시 주석 해제) // 크기 정보는 필요시에만 디버깅 (개발 중 문제 발생 시 주석 해제)
@ -612,7 +629,10 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
style={{ width: "100%", maxWidth: "100%" }} style={{ width: "100%", maxWidth: "100%" }}
> >
<DynamicComponentRenderer <DynamicComponentRenderer
component={enhancedComponent} component={isSplitShrunk
? { ...enhancedComponent, size: { ...(enhancedComponent as any).size, width: splitAdjustedWidth } }
: enhancedComponent
}
isSelected={isSelected} isSelected={isSelected}
isDesignMode={isDesignMode} isDesignMode={isDesignMode}
isInteractive={!isDesignMode} // 편집 모드가 아닐 때만 인터랙티브 isInteractive={!isDesignMode} // 편집 모드가 아닐 때만 인터랙티브

View File

@ -81,6 +81,22 @@ export function ComponentsPanel({
tags: ["repeater", "table", "modal", "button", "v2", "v2"], tags: ["repeater", "table", "modal", "button", "v2", "v2"],
defaultSize: { width: 600, height: 300 }, defaultSize: { width: 600, height: 300 },
}, },
{
id: "v2-bom-tree",
name: "BOM 트리 뷰",
description: "BOM 구성을 계층 트리 형태로 조회",
category: "data" as ComponentCategory,
tags: ["bom", "tree", "계층", "제조", "v2"],
defaultSize: { width: 900, height: 600 },
},
{
id: "v2-bom-item-editor",
name: "BOM 하위품목 편집기",
description: "BOM 하위 품목을 트리 구조로 추가/편집/삭제",
category: "data" as ComponentCategory,
tags: ["bom", "tree", "편집", "하위품목", "제조", "v2"],
defaultSize: { width: 900, height: 400 },
},
] as unknown as ComponentDefinition[], ] as unknown as ComponentDefinition[],
[], [],
); );

View File

@ -216,6 +216,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
"v2-media": require("@/components/v2/config-panels/V2MediaConfigPanel").V2MediaConfigPanel, "v2-media": require("@/components/v2/config-panels/V2MediaConfigPanel").V2MediaConfigPanel,
"v2-biz": require("@/components/v2/config-panels/V2BizConfigPanel").V2BizConfigPanel, "v2-biz": require("@/components/v2/config-panels/V2BizConfigPanel").V2BizConfigPanel,
"v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel").V2HierarchyConfigPanel, "v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel").V2HierarchyConfigPanel,
"v2-bom-item-editor": require("@/components/v2/config-panels/V2BomItemEditorConfigPanel").V2BomItemEditorConfigPanel,
}; };
const V2ConfigPanel = v2ConfigPanels[componentId]; const V2ConfigPanel = v2ConfigPanels[componentId];
@ -239,6 +240,10 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
if (componentId === "v2-list") { if (componentId === "v2-list") {
extraProps.currentTableName = currentTableName; extraProps.currentTableName = currentTableName;
} }
if (componentId === "v2-bom-item-editor") {
extraProps.currentTableName = currentTableName;
extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
}
return ( return (
<div key={selectedComponent.id} className="space-y-4"> <div key={selectedComponent.id} className="space-y-4">

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,7 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiCall, API_BASE_URL } from "@/lib/api/client"; import { apiCall } from "@/lib/api/client";
// 사용자 정보 타입 정의
interface UserInfo { interface UserInfo {
userId: string; userId: string;
userName: string; userName: string;
@ -23,11 +22,10 @@ interface UserInfo {
isAdmin: boolean; isAdmin: boolean;
sabun?: string; sabun?: string;
photo?: string | null; photo?: string | null;
companyCode?: string; // 백엔드와 일치하도록 수정 companyCode?: string;
company_code?: string; // 하위 호환성을 위해 유지 company_code?: string;
} }
// 인증 상태 타입 정의
interface AuthStatus { interface AuthStatus {
isLoggedIn: boolean; isLoggedIn: boolean;
isAdmin: boolean; isAdmin: boolean;
@ -35,14 +33,12 @@ interface AuthStatus {
deptCode?: string; deptCode?: string;
} }
// 로그인 결과 타입 정의
interface LoginResult { interface LoginResult {
success: boolean; success: boolean;
message: string; message: string;
errorCode?: string; errorCode?: string;
} }
// API 응답 타입 정의
interface ApiResponse<T = any> { interface ApiResponse<T = any> {
success: boolean; success: boolean;
message: string; message: string;
@ -50,9 +46,7 @@ interface ApiResponse<T = any> {
errorCode?: string; errorCode?: string;
} }
/** // JWT 토큰 관리 유틸리티 (client.ts와 동일한 localStorage 키 사용)
* JWT
*/
const TokenManager = { const TokenManager = {
getToken: (): string | null => { getToken: (): string | null => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@ -63,7 +57,6 @@ const TokenManager = {
setToken: (token: string): void => { setToken: (token: string): void => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
// localStorage에 저장
localStorage.setItem("authToken", token); localStorage.setItem("authToken", token);
// 쿠키에도 저장 (미들웨어에서 사용) // 쿠키에도 저장 (미들웨어에서 사용)
document.cookie = `authToken=${token}; path=/; max-age=86400; SameSite=Lax`; document.cookie = `authToken=${token}; path=/; max-age=86400; SameSite=Lax`;
@ -72,9 +65,7 @@ const TokenManager = {
removeToken: (): void => { removeToken: (): void => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
// localStorage에서 제거
localStorage.removeItem("authToken"); localStorage.removeItem("authToken");
// 쿠키에서도 제거
document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax"; document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax";
} }
}, },
@ -91,12 +82,12 @@ const TokenManager = {
/** /**
* *
* , , , * - 401 client.ts의
* -
*/ */
export const useAuth = () => { export const useAuth = () => {
const router = useRouter(); const router = useRouter();
// 상태 관리
const [user, setUser] = useState<UserInfo | null>(null); const [user, setUser] = useState<UserInfo | null>(null);
const [authStatus, setAuthStatus] = useState<AuthStatus>({ const [authStatus, setAuthStatus] = useState<AuthStatus>({
isLoggedIn: false, isLoggedIn: false,
@ -106,8 +97,6 @@ export const useAuth = () => {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const initializedRef = useRef(false); const initializedRef = useRef(false);
// API 기본 URL 설정 (동적으로 결정)
/** /**
* *
*/ */
@ -116,26 +105,19 @@ export const useAuth = () => {
const response = await apiCall<UserInfo>("GET", "/auth/me"); const response = await apiCall<UserInfo>("GET", "/auth/me");
if (response.success && response.data) { if (response.success && response.data) {
// 사용자 로케일 정보도 함께 조회하여 전역 저장 // 사용자 로케일 정보 조회
try { try {
const localeResponse = await apiCall<string>("GET", "/admin/user-locale"); const localeResponse = await apiCall<string>("GET", "/admin/user-locale");
if (localeResponse.success && localeResponse.data) { if (localeResponse.success && localeResponse.data) {
const userLocale = localeResponse.data; const userLocale = localeResponse.data;
// 전역 상태에 저장 (다른 컴포넌트에서 사용)
(window as any).__GLOBAL_USER_LANG = userLocale; (window as any).__GLOBAL_USER_LANG = userLocale;
(window as any).__GLOBAL_USER_LOCALE_LOADED = true; (window as any).__GLOBAL_USER_LOCALE_LOADED = true;
// localStorage에도 저장 (새 창에서 공유)
localStorage.setItem("userLocale", userLocale); localStorage.setItem("userLocale", userLocale);
localStorage.setItem("userLocaleLoaded", "true"); localStorage.setItem("userLocaleLoaded", "true");
} }
} catch (localeError) { } catch {
console.warn("⚠️ 사용자 로케일 조회 실패, 기본값 사용:", localeError);
(window as any).__GLOBAL_USER_LANG = "KR"; (window as any).__GLOBAL_USER_LANG = "KR";
(window as any).__GLOBAL_USER_LOCALE_LOADED = true; (window as any).__GLOBAL_USER_LOCALE_LOADED = true;
// localStorage에도 저장
localStorage.setItem("userLocale", "KR"); localStorage.setItem("userLocale", "KR");
localStorage.setItem("userLocaleLoaded", "true"); localStorage.setItem("userLocaleLoaded", "true");
} }
@ -144,8 +126,7 @@ export const useAuth = () => {
} }
return null; return null;
} catch (error) { } catch {
console.error("사용자 정보 조회 실패:", error);
return null; return null;
} }
}, []); }, []);
@ -157,95 +138,66 @@ export const useAuth = () => {
try { try {
const response = await apiCall<AuthStatus>("GET", "/auth/status"); const response = await apiCall<AuthStatus>("GET", "/auth/status");
if (response.success && response.data) { if (response.success && response.data) {
// 백엔드에서 isAuthenticated를 반환하므로 isLoggedIn으로 매핑 return {
const mappedData = {
isLoggedIn: (response.data as any).isAuthenticated || response.data.isLoggedIn || false, isLoggedIn: (response.data as any).isAuthenticated || response.data.isLoggedIn || false,
isAdmin: response.data.isAdmin || false, isAdmin: response.data.isAdmin || false,
}; };
return mappedData;
} }
return { return { isLoggedIn: false, isAdmin: false };
isLoggedIn: false, } catch {
isAdmin: false, return { isLoggedIn: false, isAdmin: false };
};
} catch (error) {
console.error("인증 상태 확인 실패:", error);
return {
isLoggedIn: false,
isAdmin: false,
};
} }
}, []); }, []);
/** /**
* *
* - API
* -
*/ */
const refreshUserData = useCallback(async () => { const refreshUserData = useCallback(async () => {
try { try {
setLoading(true); setLoading(true);
// JWT 토큰 확인
const token = TokenManager.getToken(); const token = TokenManager.getToken();
if (!token) { if (!token || TokenManager.isTokenExpired(token)) {
setUser(null); setUser(null);
setAuthStatus({ isLoggedIn: false, isAdmin: false }); setAuthStatus({ isLoggedIn: false, isAdmin: false });
setTimeout(() => { setLoading(false);
router.push("/login");
}, 3000);
return; return;
} }
// 토큰이 있으면 임시로 인증된 상태로 설정 // 토큰이 유효하면 우선 인증된 상태로 설정
setAuthStatus({ setAuthStatus({
isLoggedIn: true, isLoggedIn: true,
isAdmin: false, // API 호출 후 업데이트될 예정 isAdmin: false,
}); });
try { try {
// 병렬로 사용자 정보와 인증 상태 조회
const [userInfo, authStatusData] = await Promise.all([fetchCurrentUser(), checkAuthStatus()]); const [userInfo, authStatusData] = await Promise.all([fetchCurrentUser(), checkAuthStatus()]);
if (userInfo) {
setUser(userInfo); setUser(userInfo);
// 관리자 권한 확인 로직 개선
let finalAuthStatus = authStatusData;
if (userInfo) {
// 사용자 정보를 기반으로 관리자 권한 추가 확인
const isAdminFromUser = userInfo.userId === "plm_admin" || userInfo.userType === "ADMIN"; const isAdminFromUser = userInfo.userId === "plm_admin" || userInfo.userType === "ADMIN";
finalAuthStatus = { const finalAuthStatus = {
isLoggedIn: authStatusData.isLoggedIn, isLoggedIn: authStatusData.isLoggedIn,
isAdmin: authStatusData.isAdmin || isAdminFromUser, isAdmin: authStatusData.isAdmin || isAdminFromUser,
}; };
}
setAuthStatus(finalAuthStatus); setAuthStatus(finalAuthStatus);
// console.log("✅ 최종 사용자 상태:", { // API 결과가 비인증이면 상태만 업데이트 (리다이렉트는 client.ts가 처리)
// userId: userInfo?.userId,
// userName: userInfo?.userName,
// companyCode: userInfo?.companyCode || userInfo?.company_code,
// });
// 디버깅용 로그
// 로그인되지 않은 상태인 경우 토큰 제거 (리다이렉트는 useEffect에서 처리)
if (!finalAuthStatus.isLoggedIn) { if (!finalAuthStatus.isLoggedIn) {
TokenManager.removeToken(); TokenManager.removeToken();
setUser(null); setUser(null);
setAuthStatus({ isLoggedIn: false, isAdmin: false }); setAuthStatus({ isLoggedIn: false, isAdmin: false });
} else {
} }
} catch (apiError) { } else {
console.error("API 호출 실패:", apiError); // userInfo 조회 실패 → 토큰에서 최소 정보 추출하여 유지
// API 호출 실패 시에도 토큰이 있으면 임시로 인증된 상태로 처리
// 토큰에서 사용자 정보 추출 시도
try { try {
const payload = JSON.parse(atob(token.split(".")[1])); const payload = JSON.parse(atob(token.split(".")[1]));
const tempUser: UserInfo = {
const tempUser = {
userId: payload.userId || payload.id || "unknown", userId: payload.userId || payload.id || "unknown",
userName: payload.userName || payload.name || "사용자", userName: payload.userName || payload.name || "사용자",
companyCode: payload.companyCode || payload.company_code || "", companyCode: payload.companyCode || payload.company_code || "",
@ -257,32 +209,43 @@ export const useAuth = () => {
isLoggedIn: true, isLoggedIn: true,
isAdmin: tempUser.isAdmin, isAdmin: tempUser.isAdmin,
}); });
} catch (tokenError) { } catch {
console.error("토큰 파싱 실패:", tokenError); // 토큰 파싱도 실패하면 비인증 상태로 전환
// 토큰 파싱도 실패하면 로그인 페이지로 리다이렉트
TokenManager.removeToken(); TokenManager.removeToken();
setUser(null); setUser(null);
setAuthStatus({ isLoggedIn: false, isAdmin: false }); setAuthStatus({ isLoggedIn: false, isAdmin: false });
setTimeout(() => {
router.push("/login");
}, 3000);
} }
} }
} catch (error) { } catch {
console.error("사용자 데이터 새로고침 실패:", error); // API 호출 전체 실패 → 토큰 기반 임시 인증 유지 시도
setError("사용자 정보를 불러오는데 실패했습니다."); try {
const payload = JSON.parse(atob(token.split(".")[1]));
const tempUser: UserInfo = {
userId: payload.userId || payload.id || "unknown",
userName: payload.userName || payload.name || "사용자",
companyCode: payload.companyCode || payload.company_code || "",
isAdmin: payload.userId === "plm_admin" || payload.userType === "ADMIN",
};
// 오류 발생 시 토큰 제거 및 로그인 페이지로 리다이렉트 setUser(tempUser);
setAuthStatus({
isLoggedIn: true,
isAdmin: tempUser.isAdmin,
});
} catch {
TokenManager.removeToken(); TokenManager.removeToken();
setUser(null); setUser(null);
setAuthStatus({ isLoggedIn: false, isAdmin: false }); setAuthStatus({ isLoggedIn: false, isAdmin: false });
setTimeout(() => { }
router.push("/login"); }
}, 3000); } catch {
setError("사용자 정보를 불러오는데 실패했습니다.");
setUser(null);
setAuthStatus({ isLoggedIn: false, isAdmin: false });
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [fetchCurrentUser, checkAuthStatus, router]); }, [fetchCurrentUser, checkAuthStatus]);
/** /**
* *
@ -299,10 +262,7 @@ export const useAuth = () => {
}); });
if (response.success && response.data?.token) { if (response.success && response.data?.token) {
// JWT 토큰 저장
TokenManager.setToken(response.data.token); TokenManager.setToken(response.data.token);
// 로그인 성공 시 사용자 정보 및 인증 상태 업데이트
await refreshUserData(); await refreshUserData();
return { return {
@ -328,7 +288,7 @@ export const useAuth = () => {
setLoading(false); setLoading(false);
} }
}, },
[apiCall, refreshUserData], [refreshUserData],
); );
/** /**
@ -337,40 +297,27 @@ export const useAuth = () => {
const switchCompany = useCallback( const switchCompany = useCallback(
async (companyCode: string): Promise<{ success: boolean; message: string }> => { async (companyCode: string): Promise<{ success: boolean; message: string }> => {
try { try {
// console.log("🔵 useAuth.switchCompany 시작:", companyCode);
setLoading(true); setLoading(true);
setError(null); setError(null);
// console.log("🔵 API 호출: POST /auth/switch-company");
const response = await apiCall<any>("POST", "/auth/switch-company", { const response = await apiCall<any>("POST", "/auth/switch-company", {
companyCode, companyCode,
}); });
// console.log("🔵 API 응답:", response);
if (response.success && response.data?.token) { if (response.success && response.data?.token) {
// console.log("🔵 새 토큰 받음:", response.data.token.substring(0, 20) + "...");
// 새로운 JWT 토큰 저장
TokenManager.setToken(response.data.token); TokenManager.setToken(response.data.token);
// console.log("🔵 토큰 저장 완료");
// refreshUserData 호출하지 않고 바로 성공 반환
// (페이지 새로고침 시 자동으로 갱신됨)
// console.log("🔵 회사 전환 완료 (페이지 새로고침 필요)");
return { return {
success: true, success: true,
message: response.message || "회사 전환에 성공했습니다.", message: response.message || "회사 전환에 성공했습니다.",
}; };
} else { } else {
// console.error("🔵 API 응답 실패:", response);
return { return {
success: false, success: false,
message: response.message || "회사 전환에 실패했습니다.", message: response.message || "회사 전환에 실패했습니다.",
}; };
} }
} catch (error: any) { } catch (error: any) {
// console.error("🔵 switchCompany 에러:", error);
const errorMessage = error.message || "회사 전환 중 오류가 발생했습니다."; const errorMessage = error.message || "회사 전환 중 오류가 발생했습니다.";
setError(errorMessage); setError(errorMessage);
@ -380,10 +327,9 @@ export const useAuth = () => {
}; };
} finally { } finally {
setLoading(false); setLoading(false);
// console.log("🔵 switchCompany 완료");
} }
}, },
[apiCall] [],
); );
/** /**
@ -395,51 +341,37 @@ export const useAuth = () => {
const response = await apiCall("POST", "/auth/logout"); const response = await apiCall("POST", "/auth/logout");
// JWT 토큰 제거
TokenManager.removeToken(); TokenManager.removeToken();
// 로케일 정보도 제거
localStorage.removeItem("userLocale"); localStorage.removeItem("userLocale");
localStorage.removeItem("userLocaleLoaded"); localStorage.removeItem("userLocaleLoaded");
(window as any).__GLOBAL_USER_LANG = undefined; (window as any).__GLOBAL_USER_LANG = undefined;
(window as any).__GLOBAL_USER_LOCALE_LOADED = undefined; (window as any).__GLOBAL_USER_LOCALE_LOADED = undefined;
// 로그아웃 API 호출 성공 여부와 관계없이 클라이언트 상태 초기화
setUser(null); setUser(null);
setAuthStatus({ setAuthStatus({ isLoggedIn: false, isAdmin: false });
isLoggedIn: false,
isAdmin: false,
});
setError(null); setError(null);
// 로그인 페이지로 리다이렉트
router.push("/login"); router.push("/login");
return response.success; return response.success;
} catch (error) { } catch {
console.error("로그아웃 처리 실패:", error);
// 오류가 발생해도 JWT 토큰 제거 및 클라이언트 상태 초기화
TokenManager.removeToken(); TokenManager.removeToken();
// 로케일 정보도 제거
localStorage.removeItem("userLocale"); localStorage.removeItem("userLocale");
localStorage.removeItem("userLocaleLoaded"); localStorage.removeItem("userLocaleLoaded");
(window as any).__GLOBAL_USER_LANG = undefined; (window as any).__GLOBAL_USER_LANG = undefined;
(window as any).__GLOBAL_USER_LOCALE_LOADED = undefined; (window as any).__GLOBAL_USER_LOCALE_LOADED = undefined;
setUser(null); setUser(null);
setAuthStatus({ setAuthStatus({ isLoggedIn: false, isAdmin: false });
isLoggedIn: false,
isAdmin: false,
});
router.push("/login"); router.push("/login");
return false; return false;
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [apiCall, router]); }, [router]);
/** /**
* *
@ -453,8 +385,7 @@ export const useAuth = () => {
} }
return false; return false;
} catch (error) { } catch {
console.error("메뉴 권한 확인 실패:", error);
return false; return false;
} }
}, []); }, []);
@ -463,96 +394,56 @@ export const useAuth = () => {
* *
*/ */
useEffect(() => { useEffect(() => {
// 이미 초기화되었으면 실행하지 않음 if (initializedRef.current) return;
if (initializedRef.current) {
return;
}
initializedRef.current = true; initializedRef.current = true;
if (typeof window === "undefined") return;
// 로그인 페이지에서는 인증 상태 확인하지 않음 // 로그인 페이지에서는 인증 상태 확인하지 않음
if (window.location.pathname === "/login") { if (window.location.pathname === "/login") {
setLoading(false);
return; return;
} }
// 토큰이 있는 경우에만 인증 상태 확인
const token = TokenManager.getToken(); const token = TokenManager.getToken();
if (token && !TokenManager.isTokenExpired(token)) { if (token && !TokenManager.isTokenExpired(token)) {
// 토큰이 있으면 임시로 인증된 상태로 설정 (API 호출 전에) // 유효한 토큰 → 우선 인증 상태로 설정 후 API 확인
setAuthStatus({ setAuthStatus({
isLoggedIn: true, isLoggedIn: true,
isAdmin: false, // API 호출 후 업데이트될 예정
});
refreshUserData();
} else if (!token) {
// 토큰이 없으면 3초 후 로그인 페이지로 리다이렉트
setTimeout(() => {
router.push("/login");
}, 3000);
} else {
TokenManager.removeToken();
setTimeout(() => {
router.push("/login");
}, 3000);
}
}, []); // 초기 마운트 시에만 실행
/**
*
*/
useEffect(() => {
const handleSessionExpiry = () => {
TokenManager.removeToken();
setUser(null);
setAuthStatus({
isLoggedIn: false,
isAdmin: false, isAdmin: false,
}); });
setError("세션이 만료되었습니다. 다시 로그인해주세요."); refreshUserData();
router.push("/login"); } else if (token && TokenManager.isTokenExpired(token)) {
}; // 만료된 토큰 → 정리 (리다이렉트는 AuthGuard에서 처리)
TokenManager.removeToken();
// 전역 에러 핸들러 등록 (401 Unauthorized 응답 처리) setAuthStatus({ isLoggedIn: false, isAdmin: false });
const originalFetch = window.fetch; setLoading(false);
window.fetch = async (...args) => { } else {
const response = await originalFetch(...args); // 토큰 없음 → 비인증 상태 (리다이렉트는 AuthGuard에서 처리)
setAuthStatus({ isLoggedIn: false, isAdmin: false });
if (response.status === 401 && window.location.pathname !== "/login") { setLoading(false);
handleSessionExpiry();
} }
}, []);
return response;
};
return () => {
window.fetch = originalFetch;
};
}, [router]);
return { return {
// 상태
user, user,
authStatus, authStatus,
loading, loading,
error, error,
// 계산된 값
isLoggedIn: authStatus.isLoggedIn, isLoggedIn: authStatus.isLoggedIn,
isAdmin: authStatus.isAdmin, isAdmin: authStatus.isAdmin,
userId: user?.userId, userId: user?.userId,
userName: user?.userName, userName: user?.userName,
companyCode: user?.companyCode || user?.company_code, // 🆕 회사 코드 companyCode: user?.companyCode || user?.company_code,
// 함수
login, login,
logout, logout,
switchCompany, // 🆕 회사 전환 함수 switchCompany,
checkMenuAuth, checkMenuAuth,
refreshUserData, refreshUserData,
// 유틸리티
clearError: () => setError(null), clearError: () => setError(null),
}; };
}; };

View File

@ -1,24 +1,19 @@
import axios, { AxiosResponse, AxiosError } from "axios"; import axios, { AxiosResponse, AxiosError, InternalAxiosRequestConfig } from "axios";
// API URL 동적 설정 - 환경변수 우선 사용 // API URL 동적 설정 - 환경변수 우선 사용
const getApiBaseUrl = (): string => { const getApiBaseUrl = (): string => {
// 1. 환경변수가 있으면 우선 사용
if (process.env.NEXT_PUBLIC_API_URL) { if (process.env.NEXT_PUBLIC_API_URL) {
return process.env.NEXT_PUBLIC_API_URL; return process.env.NEXT_PUBLIC_API_URL;
} }
// 2. 클라이언트 사이드에서 동적 설정
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const currentHost = window.location.hostname; const currentHost = window.location.hostname;
const currentPort = window.location.port; const currentPort = window.location.port;
const protocol = window.location.protocol;
// 프로덕션 환경: v1.vexplor.com → api.vexplor.com
if (currentHost === "v1.vexplor.com") { if (currentHost === "v1.vexplor.com") {
return "https://api.vexplor.com/api"; return "https://api.vexplor.com/api";
} }
// 로컬 개발환경: localhost:9771 또는 localhost:3000 → localhost:8080
if ( if (
(currentHost === "localhost" || currentHost === "127.0.0.1") && (currentHost === "localhost" || currentHost === "127.0.0.1") &&
(currentPort === "9771" || currentPort === "3000") (currentPort === "9771" || currentPort === "3000")
@ -27,57 +22,46 @@ const getApiBaseUrl = (): string => {
} }
} }
// 3. 기본값
return "http://localhost:8080/api"; return "http://localhost:8080/api";
}; };
export const API_BASE_URL = getApiBaseUrl(); export const API_BASE_URL = getApiBaseUrl();
// 이미지 URL을 완전한 URL로 변환하는 함수 // 이미지 URL을 완전한 URL로 변환하는 함수
// 주의: 모듈 로드 시점이 아닌 런타임에 hostname을 확인해야 SSR 문제 방지
export const getFullImageUrl = (imagePath: string): string => { export const getFullImageUrl = (imagePath: string): string => {
// 빈 값 체크
if (!imagePath) return ""; if (!imagePath) return "";
// 이미 전체 URL인 경우 그대로 반환
if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) { if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
return imagePath; return imagePath;
} }
// /uploads로 시작하는 상대 경로인 경우 API 서버 주소 추가
if (imagePath.startsWith("/uploads")) { if (imagePath.startsWith("/uploads")) {
// 런타임에 현재 hostname 확인 (SSR 시점이 아닌 클라이언트에서 실행될 때)
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const currentHost = window.location.hostname; const currentHost = window.location.hostname;
// 프로덕션 환경: v1.vexplor.com → api.vexplor.com
if (currentHost === "v1.vexplor.com") { if (currentHost === "v1.vexplor.com") {
return `https://api.vexplor.com${imagePath}`; return `https://api.vexplor.com${imagePath}`;
} }
// 로컬 개발환경
if (currentHost === "localhost" || currentHost === "127.0.0.1") { if (currentHost === "localhost" || currentHost === "127.0.0.1") {
return `http://localhost:8080${imagePath}`; return `http://localhost:8080${imagePath}`;
} }
} }
// SSR 또는 알 수 없는 환경에서는 API_BASE_URL 사용 (fallback)
// 주의: 프로덕션 URL이 https://api.vexplor.com/api 이므로
// 단순 .replace("/api", "")는 호스트명의 /api까지 제거하는 버그 발생
// 반드시 문자열 끝의 /api만 제거해야 함
const baseUrl = API_BASE_URL.replace(/\/api$/, ""); const baseUrl = API_BASE_URL.replace(/\/api$/, "");
if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) { if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) {
return `${baseUrl}${imagePath}`; return `${baseUrl}${imagePath}`;
} }
// 최종 fallback
return imagePath; return imagePath;
} }
return imagePath; return imagePath;
}; };
// ============================================
// JWT 토큰 관리 유틸리티 // JWT 토큰 관리 유틸리티
// ============================================
const TokenManager = { const TokenManager = {
getToken: (): string | null => { getToken: (): string | null => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@ -107,20 +91,19 @@ const TokenManager = {
} }
}, },
// 토큰이 곧 만료되는지 확인 (30분 이내) // 만료 30분 전부터 갱신 대상
isTokenExpiringSoon: (token: string): boolean => { isTokenExpiringSoon: (token: string): boolean => {
try { try {
const payload = JSON.parse(atob(token.split(".")[1])); const payload = JSON.parse(atob(token.split(".")[1]));
const expiryTime = payload.exp * 1000; const expiryTime = payload.exp * 1000;
const currentTime = Date.now(); const currentTime = Date.now();
const thirtyMinutes = 30 * 60 * 1000; // 30분 const thirtyMinutes = 30 * 60 * 1000;
return expiryTime - currentTime < thirtyMinutes && expiryTime > currentTime; return expiryTime - currentTime < thirtyMinutes && expiryTime > currentTime;
} catch { } catch {
return false; return false;
} }
}, },
// 토큰 만료까지 남은 시간 (밀리초)
getTimeUntilExpiry: (token: string): number => { getTimeUntilExpiry: (token: string): number => {
try { try {
const payload = JSON.parse(atob(token.split(".")[1])); const payload = JSON.parse(atob(token.split(".")[1]));
@ -131,19 +114,36 @@ const TokenManager = {
}, },
}; };
// 토큰 갱신 중복 방지 플래그 // ============================================
// 토큰 갱신 로직 (중복 요청 방지)
// ============================================
let isRefreshing = false; let isRefreshing = false;
let refreshPromise: Promise<string | null> | null = null; let refreshSubscribers: Array<(token: string) => void> = [];
let failedRefreshSubscribers: Array<(error: Error) => void> = [];
// 갱신 대기 중인 요청들에게 새 토큰 전달
const onTokenRefreshed = (newToken: string) => {
refreshSubscribers.forEach((callback) => callback(newToken));
refreshSubscribers = [];
failedRefreshSubscribers = [];
};
// 갱신 실패 시 대기 중인 요청들에게 에러 전달
const onRefreshFailed = (error: Error) => {
failedRefreshSubscribers.forEach((callback) => callback(error));
refreshSubscribers = [];
failedRefreshSubscribers = [];
};
// 갱신 완료 대기 Promise 등록
const waitForTokenRefresh = (): Promise<string> => {
return new Promise((resolve, reject) => {
refreshSubscribers.push(resolve);
failedRefreshSubscribers.push(reject);
});
};
// 토큰 갱신 함수
const refreshToken = async (): Promise<string | null> => { const refreshToken = async (): Promise<string | null> => {
// 이미 갱신 중이면 기존 Promise 반환
if (isRefreshing && refreshPromise) {
return refreshPromise;
}
isRefreshing = true;
refreshPromise = (async () => {
try { try {
const currentToken = TokenManager.getToken(); const currentToken = TokenManager.getToken();
if (!currentToken) { if (!currentToken) {
@ -163,45 +163,36 @@ const refreshToken = async (): Promise<string | null> => {
if (response.data?.success && response.data?.data?.token) { if (response.data?.success && response.data?.data?.token) {
const newToken = response.data.data.token; const newToken = response.data.data.token;
TokenManager.setToken(newToken); TokenManager.setToken(newToken);
console.log("[TokenManager] 토큰 갱신 성공");
return newToken; return newToken;
} }
return null; return null;
} catch (error) { } catch {
console.error("[TokenManager] 토큰 갱신 실패:", error);
return null; return null;
} finally {
isRefreshing = false;
refreshPromise = null;
} }
})();
return refreshPromise;
}; };
// 자동 토큰 갱신 타이머 // ============================================
let tokenRefreshTimer: NodeJS.Timeout | null = null; // 자동 토큰 갱신 (백그라운드)
// ============================================
let tokenRefreshTimer: ReturnType<typeof setInterval> | null = null;
// 자동 토큰 갱신 시작
const startAutoRefresh = (): void => { const startAutoRefresh = (): void => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
// 기존 타이머 정리
if (tokenRefreshTimer) { if (tokenRefreshTimer) {
clearInterval(tokenRefreshTimer); clearInterval(tokenRefreshTimer);
} }
// 10분마다 토큰 상태 확인 // 5분마다 토큰 상태 확인 (기존 10분 → 5분으로 단축)
tokenRefreshTimer = setInterval( tokenRefreshTimer = setInterval(
async () => { async () => {
const token = TokenManager.getToken(); const token = TokenManager.getToken();
if (token && TokenManager.isTokenExpiringSoon(token)) { if (token && TokenManager.isTokenExpiringSoon(token)) {
console.log("[TokenManager] 토큰 만료 임박, 자동 갱신 시작...");
await refreshToken(); await refreshToken();
} }
}, },
10 * 60 * 1000, 5 * 60 * 1000,
); // 10분 );
// 페이지 로드 시 즉시 확인 // 페이지 로드 시 즉시 확인
const token = TokenManager.getToken(); const token = TokenManager.getToken();
@ -210,29 +201,49 @@ const startAutoRefresh = (): void => {
} }
}; };
// 사용자 활동 감지 및 토큰 갱신 // 페이지 포커스 복귀 시 토큰 갱신 체크 (백그라운드 탭 throttle 대응)
const setupVisibilityRefresh = (): void => {
if (typeof window === "undefined") return;
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
const token = TokenManager.getToken();
if (!token) return;
if (TokenManager.isTokenExpired(token)) {
// 만료됐으면 갱신 시도
refreshToken().then((newToken) => {
if (!newToken) {
redirectToLogin();
}
});
} else if (TokenManager.isTokenExpiringSoon(token)) {
refreshToken();
}
}
});
};
// 사용자 활동 감지 기반 갱신
const setupActivityBasedRefresh = (): void => { const setupActivityBasedRefresh = (): void => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
let lastActivity = Date.now(); let lastActivityCheck = Date.now();
const activityThreshold = 5 * 60 * 1000; // 5분 const activityThreshold = 5 * 60 * 1000; // 5분
const handleActivity = (): void => { const handleActivity = (): void => {
const now = Date.now(); const now = Date.now();
// 마지막 활동으로부터 5분 이상 지났으면 토큰 상태 확인 if (now - lastActivityCheck > activityThreshold) {
if (now - lastActivity > activityThreshold) {
const token = TokenManager.getToken(); const token = TokenManager.getToken();
if (token && TokenManager.isTokenExpiringSoon(token)) { if (token && TokenManager.isTokenExpiringSoon(token)) {
refreshToken(); refreshToken();
} }
lastActivityCheck = now;
} }
lastActivity = now;
}; };
// 사용자 활동 이벤트 감지 ["click", "keydown"].forEach((event) => {
["click", "keydown", "scroll", "mousemove"].forEach((event) => { let throttleTimer: ReturnType<typeof setTimeout> | null = null;
// 너무 잦은 호출 방지를 위해 throttle 적용
let throttleTimer: NodeJS.Timeout | null = null;
window.addEventListener( window.addEventListener(
event, event,
() => { () => {
@ -240,7 +251,7 @@ const setupActivityBasedRefresh = (): void => {
throttleTimer = setTimeout(() => { throttleTimer = setTimeout(() => {
handleActivity(); handleActivity();
throttleTimer = null; throttleTimer = null;
}, 1000); // 1초 throttle }, 2000);
} }
}, },
{ passive: true }, { passive: true },
@ -248,38 +259,56 @@ const setupActivityBasedRefresh = (): void => {
}); });
}; };
// 로그인 페이지 리다이렉트 (중복 방지)
let isRedirecting = false;
const redirectToLogin = (): void => {
if (typeof window === "undefined") return;
if (isRedirecting) return;
if (window.location.pathname === "/login") return;
isRedirecting = true;
TokenManager.removeToken();
window.location.href = "/login";
};
// 클라이언트 사이드에서 자동 갱신 시작 // 클라이언트 사이드에서 자동 갱신 시작
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
startAutoRefresh(); startAutoRefresh();
setupVisibilityRefresh();
setupActivityBasedRefresh(); setupActivityBasedRefresh();
} }
// ============================================
// Axios 인스턴스 생성 // Axios 인스턴스 생성
// ============================================
export const apiClient = axios.create({ export const apiClient = axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,
timeout: 30000, // 30초로 증가 (다중 커넥션 처리 시간 고려) timeout: 30000,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
withCredentials: true, // 쿠키 포함 withCredentials: true,
}); });
// ============================================
// 요청 인터셉터 // 요청 인터셉터
// ============================================
apiClient.interceptors.request.use( apiClient.interceptors.request.use(
(config) => { async (config: InternalAxiosRequestConfig) => {
// JWT 토큰 추가
const token = TokenManager.getToken(); const token = TokenManager.getToken();
if (token && !TokenManager.isTokenExpired(token)) { if (token) {
if (!TokenManager.isTokenExpired(token)) {
// 유효한 토큰 → 그대로 사용
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
} else if (token && TokenManager.isTokenExpired(token)) {
console.warn("❌ 토큰이 만료되었습니다.");
// 토큰 제거
if (typeof window !== "undefined") {
localStorage.removeItem("authToken");
}
} else { } else {
console.warn("⚠️ 토큰이 없습니다."); // 만료된 토큰 → 갱신 시도 후 사용
const newToken = await refreshToken();
if (newToken) {
config.headers.Authorization = `Bearer ${newToken}`;
}
// 갱신 실패해도 요청은 보냄 (401 응답 인터셉터에서 처리)
}
} }
// FormData 요청 시 Content-Type 자동 처리 // FormData 요청 시 Content-Type 자동 처리
@ -287,18 +316,14 @@ apiClient.interceptors.request.use(
delete config.headers["Content-Type"]; delete config.headers["Content-Type"];
} }
// 언어 정보를 쿼리 파라미터에 추가 (GET 요청 시에만) // 언어 정보를 쿼리 파라미터에 추가 (GET 요청)
if (config.method?.toUpperCase() === "GET") { if (config.method?.toUpperCase() === "GET") {
// 우선순위: 전역 변수 > localStorage > 기본값 let currentLang = "KR";
let currentLang = "KR"; // 기본값
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
// 1순위: 전역 변수에서 확인
if ((window as unknown as { __GLOBAL_USER_LANG?: string }).__GLOBAL_USER_LANG) { if ((window as unknown as { __GLOBAL_USER_LANG?: string }).__GLOBAL_USER_LANG) {
currentLang = (window as unknown as { __GLOBAL_USER_LANG: string }).__GLOBAL_USER_LANG; currentLang = (window as unknown as { __GLOBAL_USER_LANG: string }).__GLOBAL_USER_LANG;
} } else {
// 2순위: localStorage에서 확인 (새 창이나 페이지 새로고침 시)
else {
const storedLocale = localStorage.getItem("userLocale"); const storedLocale = localStorage.getItem("userLocale");
if (storedLocale) { if (storedLocale) {
currentLang = storedLocale; currentLang = storedLocale;
@ -316,19 +341,19 @@ apiClient.interceptors.request.use(
return config; return config;
}, },
(error) => { (error) => {
console.error("❌ API 요청 오류:", error);
return Promise.reject(error); return Promise.reject(error);
}, },
); );
// ============================================
// 응답 인터셉터 // 응답 인터셉터
// ============================================
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(response: AxiosResponse) => { (response: AxiosResponse) => {
// 백엔드에서 보내주는 새로운 토큰 처리 // 백엔드에서 보내주는 새로운 토큰 처리
const newToken = response.headers["x-new-token"]; const newToken = response.headers["x-new-token"];
if (newToken) { if (newToken) {
TokenManager.setToken(newToken); TokenManager.setToken(newToken);
console.log("[TokenManager] 서버에서 새 토큰 수신, 저장 완료");
} }
return response; return response;
}, },
@ -336,79 +361,80 @@ apiClient.interceptors.response.use(
const status = error.response?.status; const status = error.response?.status;
const url = error.config?.url; const url = error.config?.url;
// 409 에러 (중복 데이터) 조용하게 처리 // 409 에러 (중복 데이터) - 조용하게 처리
if (status === 409) { if (status === 409) {
// 중복 검사 API와 관계도 저장은 완전히 조용하게 처리
if (url?.includes("/check-duplicate") || url?.includes("/dataflow-diagrams")) { if (url?.includes("/check-duplicate") || url?.includes("/dataflow-diagrams")) {
// 중복 검사와 관계도 중복 이름은 정상적인 비즈니스 로직이므로 콘솔 출력 없음 return Promise.reject(error);
}
return Promise.reject(error); return Promise.reject(error);
} }
// 일반 409 에러는 간단한 로그만 출력 // 채번 규칙 미리보기 API 실패는 조용하게 처리
console.warn("데이터 중복:", {
url: url,
message: (error.response?.data as { message?: string })?.message || "중복된 데이터입니다.",
});
return Promise.reject(error);
}
// 채번 규칙 미리보기 API 실패는 조용하게 처리 (화면 로드 시 자주 발생)
if (url?.includes("/numbering-rules/") && url?.includes("/preview")) { if (url?.includes("/numbering-rules/") && url?.includes("/preview")) {
return Promise.reject(error); return Promise.reject(error);
} }
// 다른 에러들은 기존처럼 상세 로그 출력 // 401 에러 처리 (핵심 개선)
console.error("API 응답 오류:", {
status: status,
statusText: error.response?.statusText,
url: url,
data: error.response?.data,
message: error.message,
});
// 401 에러 처리
if (status === 401 && typeof window !== "undefined") { if (status === 401 && typeof window !== "undefined") {
const errorData = error.response?.data as { error?: { code?: string } }; const errorData = error.response?.data as { error?: { code?: string } };
const errorCode = errorData?.error?.code; const errorCode = errorData?.error?.code;
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
console.warn("[Auth] 401 오류 발생:", { // 이미 재시도한 요청이면 로그인으로
url: url, if (originalRequest?._retry) {
errorCode: errorCode, redirectToLogin();
token: TokenManager.getToken() ? "존재" : "없음", return Promise.reject(error);
}); }
// 토큰 만료 에러인 경우 갱신 시도 // 토큰 만료 에러 → 갱신 후 재시도
const originalRequest = error.config as typeof error.config & { _retry?: boolean }; if (errorCode === "TOKEN_EXPIRED" && originalRequest) {
if (errorCode === "TOKEN_EXPIRED" && originalRequest && !originalRequest._retry) { if (!isRefreshing) {
console.log("[Auth] 토큰 만료, 갱신 시도..."); isRefreshing = true;
originalRequest._retry = true; originalRequest._retry = true;
try { try {
const newToken = await refreshToken(); const newToken = await refreshToken();
if (newToken && originalRequest) { if (newToken) {
isRefreshing = false;
onTokenRefreshed(newToken);
originalRequest.headers.Authorization = `Bearer ${newToken}`; originalRequest.headers.Authorization = `Bearer ${newToken}`;
return apiClient.request(originalRequest); return apiClient.request(originalRequest);
} else {
isRefreshing = false;
onRefreshFailed(new Error("토큰 갱신 실패"));
redirectToLogin();
return Promise.reject(error);
} }
} catch (refreshError) { } catch (refreshError) {
console.error("[Auth] 토큰 갱신 실패:", refreshError); isRefreshing = false;
onRefreshFailed(refreshError as Error);
redirectToLogin();
return Promise.reject(error);
}
} else {
// 다른 요청이 이미 갱신 중 → 갱신 완료 대기 후 재시도
try {
const newToken = await waitForTokenRefresh();
originalRequest._retry = true;
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return apiClient.request(originalRequest);
} catch {
return Promise.reject(error);
}
} }
} }
// 토큰 갱신 실패 또는 다른 401 에러인 경우 로그아웃 // TOKEN_MISSING, INVALID_TOKEN 등 → 로그인으로
TokenManager.removeToken(); redirectToLogin();
// 로그인 페이지가 아닌 경우에만 리다이렉트
if (window.location.pathname !== "/login") {
console.log("[Auth] 로그인 페이지로 리다이렉트");
window.location.href = "/login";
}
} }
return Promise.reject(error); return Promise.reject(error);
}, },
); );
// 공통 응답 타입 // ============================================
// 공통 타입 및 헬퍼
// ============================================
export interface ApiResponse<T = unknown> { export interface ApiResponse<T = unknown> {
success: boolean; success: boolean;
data?: T; data?: T;
@ -416,7 +442,6 @@ export interface ApiResponse<T = unknown> {
errorCode?: string; errorCode?: string;
} }
// 사용자 정보 타입
export interface UserInfo { export interface UserInfo {
userId: string; userId: string;
userName: string; userName: string;
@ -430,13 +455,11 @@ export interface UserInfo {
isAdmin?: boolean; isAdmin?: boolean;
} }
// 현재 사용자 정보 조회
export const getCurrentUser = async (): Promise<ApiResponse<UserInfo>> => { export const getCurrentUser = async (): Promise<ApiResponse<UserInfo>> => {
try { try {
const response = await apiClient.get("/auth/me"); const response = await apiClient.get("/auth/me");
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
console.error("현재 사용자 정보 조회 실패:", error);
return { return {
success: false, success: false,
message: error.response?.data?.message || error.message || "사용자 정보를 가져올 수 없습니다.", message: error.response?.data?.message || error.message || "사용자 정보를 가져올 수 없습니다.",
@ -445,7 +468,6 @@ export const getCurrentUser = async (): Promise<ApiResponse<UserInfo>> => {
} }
}; };
// API 호출 헬퍼 함수
export const apiCall = async <T>( export const apiCall = async <T>(
method: "GET" | "POST" | "PUT" | "DELETE", method: "GET" | "POST" | "PUT" | "DELETE",
url: string, url: string,
@ -459,7 +481,6 @@ export const apiCall = async <T>(
}); });
return response.data; return response.data;
} catch (error: unknown) { } catch (error: unknown) {
console.error("API 호출 실패:", error);
const axiosError = error as AxiosError; const axiosError = error as AxiosError;
return { return {
success: false, success: false,

View File

@ -114,6 +114,7 @@ import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트
import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트 import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트
import "./v2-split-line/SplitLineRenderer"; // V2 캔버스 분할선 import "./v2-split-line/SplitLineRenderer"; // V2 캔버스 분할선
import "./v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰 import "./v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰
import "./v2-bom-item-editor/BomItemEditorRenderer"; // BOM 하위품목 편집기
/** /**
* *

View File

@ -0,0 +1,709 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
GripVertical,
Plus,
X,
Search,
ChevronRight,
ChevronDown,
Package,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { apiClient } from "@/lib/api/client";
// ─── 타입 정의 ───
interface BomItemNode {
tempId: string;
id?: string;
bom_id?: string;
parent_detail_id: string | null;
seq_no: number;
level: number;
child_item_id: string;
child_item_code: string;
child_item_name: string;
child_item_type: string;
quantity: string;
unit: string;
loss_rate: string;
remark: string;
children: BomItemNode[];
_isNew?: boolean;
_isDeleted?: boolean;
}
interface ItemInfo {
id: string;
item_number: string;
item_name: string;
type: string;
unit: string;
division: string;
}
interface BomItemEditorProps {
component?: any;
formData?: Record<string, any>;
companyCode?: string;
isDesignMode?: boolean;
selectedRowsData?: any[];
onChange?: (flatData: any[]) => void;
bomId?: string;
}
// 임시 ID 생성
let tempIdCounter = 0;
const generateTempId = () => `temp_${Date.now()}_${++tempIdCounter}`;
// ─── 품목 검색 모달 ───
interface ItemSearchModalProps {
open: boolean;
onClose: () => void;
onSelect: (item: ItemInfo) => void;
companyCode?: string;
}
function ItemSearchModal({
open,
onClose,
onSelect,
companyCode,
}: ItemSearchModalProps) {
const [searchText, setSearchText] = useState("");
const [items, setItems] = useState<ItemInfo[]>([]);
const [loading, setLoading] = useState(false);
const searchItems = useCallback(
async (query: string) => {
setLoading(true);
try {
const result = await entityJoinApi.getTableDataWithJoins("item_info", {
page: 1,
size: 50,
search: query
? { item_number: query, item_name: query }
: undefined,
enableEntityJoin: true,
companyCodeOverride: companyCode,
});
setItems(result.data || []);
} catch (error) {
console.error("[BomItemEditor] 품목 검색 실패:", error);
} finally {
setLoading(false);
}
},
[companyCode],
);
useEffect(() => {
if (open) {
setSearchText("");
searchItems("");
}
}, [open, searchItems]);
const handleSearch = () => {
searchItems(searchText);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleSearch();
}
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Input
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="품목코드 또는 품목명"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<Button
onClick={handleSearch}
size="sm"
className="h-8 sm:h-10"
>
<Search className="mr-1 h-4 w-4" />
</Button>
</div>
<div className="max-h-[300px] overflow-y-auto rounded-md border">
{loading ? (
<div className="flex items-center justify-center py-8">
<span className="text-muted-foreground text-sm"> ...</span>
</div>
) : items.length === 0 ? (
<div className="flex items-center justify-center py-8">
<span className="text-muted-foreground text-sm">
.
</span>
</div>
) : (
<table className="w-full text-xs sm:text-sm">
<thead className="bg-muted/50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr
key={item.id}
onClick={() => {
onSelect(item);
onClose();
}}
className="hover:bg-accent cursor-pointer border-t transition-colors"
>
<td className="px-3 py-2 font-mono">
{item.item_number}
</td>
<td className="px-3 py-2">{item.item_name}</td>
<td className="px-3 py-2">{item.type}</td>
<td className="px-3 py-2">{item.unit}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</DialogContent>
</Dialog>
);
}
// ─── 트리 노드 행 렌더링 ───
interface TreeNodeRowProps {
node: BomItemNode;
depth: number;
expanded: boolean;
hasChildren: boolean;
onToggle: () => void;
onFieldChange: (tempId: string, field: string, value: string) => void;
onDelete: (tempId: string) => void;
onAddChild: (parentTempId: string) => void;
}
function TreeNodeRow({
node,
depth,
expanded,
hasChildren,
onToggle,
onFieldChange,
onDelete,
onAddChild,
}: TreeNodeRowProps) {
const indentPx = depth * 32;
return (
<div
className={cn(
"group flex items-center gap-2 rounded-md border px-2 py-1.5",
"transition-colors hover:bg-accent/30",
depth > 0 && "ml-2 border-l-2 border-l-primary/20",
)}
style={{ marginLeft: `${indentPx}px` }}
>
{/* 드래그 핸들 */}
<GripVertical className="text-muted-foreground h-4 w-4 shrink-0 cursor-grab" />
{/* 펼침/접기 */}
<button
onClick={onToggle}
className={cn(
"flex h-5 w-5 shrink-0 items-center justify-center rounded",
hasChildren
? "hover:bg-accent cursor-pointer"
: "cursor-default opacity-0",
)}
>
{hasChildren &&
(expanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
))}
</button>
{/* 순번 */}
<span className="text-muted-foreground w-6 shrink-0 text-center text-xs font-medium">
{node.seq_no}
</span>
{/* 품목코드 */}
<span className="w-24 shrink-0 truncate font-mono text-xs font-medium">
{node.child_item_code || "-"}
</span>
{/* 품목명 */}
<span className="min-w-[80px] flex-1 truncate text-xs">
{node.child_item_name || "-"}
</span>
{/* 레벨 뱃지 */}
{node.level > 0 && (
<span className="bg-primary/10 text-primary shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold">
L{node.level}
</span>
)}
{/* 수량 */}
<Input
value={node.quantity}
onChange={(e) =>
onFieldChange(node.tempId, "quantity", e.target.value)
}
className="h-7 w-16 shrink-0 text-center text-xs"
placeholder="수량"
/>
{/* 품목구분 셀렉트 */}
<Select
value={node.child_item_type || ""}
onValueChange={(val) =>
onFieldChange(node.tempId, "child_item_type", val)
}
>
<SelectTrigger className="h-7 w-20 shrink-0 text-xs">
<SelectValue placeholder="구분" />
</SelectTrigger>
<SelectContent>
<SelectItem value="assembly"></SelectItem>
<SelectItem value="process"></SelectItem>
<SelectItem value="purchase"></SelectItem>
<SelectItem value="outsource"></SelectItem>
</SelectContent>
</Select>
{/* 하위 추가 버튼 */}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => onAddChild(node.tempId)}
title="하위 품목 추가"
>
<Plus className="h-3.5 w-3.5" />
</Button>
{/* 삭제 버튼 */}
<Button
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10 h-7 w-7 shrink-0"
onClick={() => onDelete(node.tempId)}
title="삭제"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
);
}
// ─── 메인 컴포넌트 ───
export function BomItemEditorComponent({
component,
formData,
companyCode,
isDesignMode = false,
selectedRowsData,
onChange,
bomId: propBomId,
}: BomItemEditorProps) {
const [treeData, setTreeData] = useState<BomItemNode[]>([]);
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [itemSearchOpen, setItemSearchOpen] = useState(false);
const [addTargetParentId, setAddTargetParentId] = useState<string | null>(
null,
);
// BOM ID 결정
const bomId = useMemo(() => {
if (propBomId) return propBomId;
if (formData?.id) return formData.id as string;
if (selectedRowsData?.[0]?.id) return selectedRowsData[0].id as string;
return null;
}, [propBomId, formData, selectedRowsData]);
// ─── 데이터 로드 ───
const loadBomDetails = useCallback(
async (id: string) => {
if (!id) return;
setLoading(true);
try {
const result = await entityJoinApi.getTableDataWithJoins("bom_detail", {
page: 1,
size: 500,
search: { bom_id: id },
sortBy: "seq_no",
sortOrder: "asc",
enableEntityJoin: true,
});
const rows = result.data || [];
const tree = buildTree(rows);
setTreeData(tree);
// 1레벨 기본 펼침
const firstLevelIds = new Set<string>(
tree.map((n) => n.tempId || n.id || ""),
);
setExpandedNodes(firstLevelIds);
} catch (error) {
console.error("[BomItemEditor] 데이터 로드 실패:", error);
} finally {
setLoading(false);
}
},
[],
);
useEffect(() => {
if (bomId && !isDesignMode) {
loadBomDetails(bomId);
}
}, [bomId, isDesignMode, loadBomDetails]);
// ─── 트리 빌드 ───
const buildTree = (flatData: any[]): BomItemNode[] => {
const nodeMap = new Map<string, BomItemNode>();
const roots: BomItemNode[] = [];
flatData.forEach((item) => {
const tempId = item.id || generateTempId();
nodeMap.set(item.id || tempId, {
tempId,
id: item.id,
bom_id: item.bom_id,
parent_detail_id: item.parent_detail_id || null,
seq_no: Number(item.seq_no) || 0,
level: Number(item.level) || 0,
child_item_id: item.child_item_id || "",
child_item_code: item.child_item_code || "",
child_item_name: item.child_item_name || "",
child_item_type: item.child_item_type || "",
quantity: item.quantity || "1",
unit: item.unit || "EA",
loss_rate: item.loss_rate || "0",
remark: item.remark || "",
children: [],
});
});
flatData.forEach((item) => {
const nodeId = item.id || "";
const node = nodeMap.get(nodeId);
if (!node) return;
if (item.parent_detail_id && nodeMap.has(item.parent_detail_id)) {
nodeMap.get(item.parent_detail_id)!.children.push(node);
} else {
roots.push(node);
}
});
// 순번 정렬
const sortChildren = (nodes: BomItemNode[]) => {
nodes.sort((a, b) => a.seq_no - b.seq_no);
nodes.forEach((n) => sortChildren(n.children));
};
sortChildren(roots);
return roots;
};
// ─── 트리 -> 평면 변환 (onChange 콜백용) ───
const flattenTree = useCallback((nodes: BomItemNode[]): any[] => {
const result: any[] = [];
const traverse = (
items: BomItemNode[],
parentId: string | null,
level: number,
) => {
items.forEach((node, idx) => {
result.push({
id: node.id,
tempId: node.tempId,
bom_id: node.bom_id,
parent_detail_id: parentId,
seq_no: String(idx + 1),
level: String(level),
child_item_id: node.child_item_id,
child_item_code: node.child_item_code,
child_item_name: node.child_item_name,
child_item_type: node.child_item_type,
quantity: node.quantity,
unit: node.unit,
loss_rate: node.loss_rate,
remark: node.remark,
_isNew: node._isNew,
_targetTable: "bom_detail",
});
if (node.children.length > 0) {
traverse(node.children, node.id || node.tempId, level + 1);
}
});
};
traverse(nodes, null, 0);
return result;
}, []);
// 트리 변경 시 부모에게 알림
const notifyChange = useCallback(
(newTree: BomItemNode[]) => {
setTreeData(newTree);
onChange?.(flattenTree(newTree));
},
[onChange, flattenTree],
);
// ─── 노드 조작 함수들 ───
// 트리에서 특정 노드 찾기 (재귀)
const findAndUpdate = (
nodes: BomItemNode[],
targetTempId: string,
updater: (node: BomItemNode) => BomItemNode | null,
): BomItemNode[] => {
const result: BomItemNode[] = [];
for (const node of nodes) {
if (node.tempId === targetTempId) {
const updated = updater(node);
if (updated) result.push(updated);
} else {
result.push({
...node,
children: findAndUpdate(node.children, targetTempId, updater),
});
}
}
return result;
};
// 필드 변경
const handleFieldChange = useCallback(
(tempId: string, field: string, value: string) => {
const newTree = findAndUpdate(treeData, tempId, (node) => ({
...node,
[field]: value,
}));
notifyChange(newTree);
},
[treeData, notifyChange],
);
// 노드 삭제
const handleDelete = useCallback(
(tempId: string) => {
const newTree = findAndUpdate(treeData, tempId, () => null);
notifyChange(newTree);
},
[treeData, notifyChange],
);
// 하위 품목 추가 시작 (모달 열기)
const handleAddChild = useCallback((parentTempId: string) => {
setAddTargetParentId(parentTempId);
setItemSearchOpen(true);
}, []);
// 루트 품목 추가 시작
const handleAddRoot = useCallback(() => {
setAddTargetParentId(null);
setItemSearchOpen(true);
}, []);
// 품목 선택 후 추가
const handleItemSelect = useCallback(
(item: ItemInfo) => {
const newNode: BomItemNode = {
tempId: generateTempId(),
parent_detail_id: null,
seq_no: 0,
level: 0,
child_item_id: item.id,
child_item_code: item.item_number || "",
child_item_name: item.item_name || "",
child_item_type: item.type || "",
quantity: "1",
unit: item.unit || "EA",
loss_rate: "0",
remark: "",
children: [],
_isNew: true,
};
let newTree: BomItemNode[];
if (addTargetParentId === null) {
// 루트에 추가
newNode.seq_no = treeData.length + 1;
newNode.level = 0;
newTree = [...treeData, newNode];
} else {
// 특정 노드 하위에 추가
newTree = findAndUpdate(treeData, addTargetParentId, (parent) => {
newNode.parent_detail_id = parent.id || parent.tempId;
newNode.seq_no = parent.children.length + 1;
newNode.level = parent.level + 1;
return {
...parent,
children: [...parent.children, newNode],
};
});
// 부모 노드 펼침
setExpandedNodes((prev) => new Set([...prev, addTargetParentId]));
}
notifyChange(newTree);
},
[addTargetParentId, treeData, notifyChange],
);
// 펼침/접기 토글
const toggleExpand = useCallback((tempId: string) => {
setExpandedNodes((prev) => {
const next = new Set(prev);
if (next.has(tempId)) next.delete(tempId);
else next.add(tempId);
return next;
});
}, []);
// ─── 재귀 렌더링 ───
const renderNodes = (nodes: BomItemNode[], depth: number) => {
return nodes.map((node) => {
const isExpanded = expandedNodes.has(node.tempId);
return (
<React.Fragment key={node.tempId}>
<TreeNodeRow
node={node}
depth={depth}
expanded={isExpanded}
hasChildren={node.children.length > 0}
onToggle={() => toggleExpand(node.tempId)}
onFieldChange={handleFieldChange}
onDelete={handleDelete}
onAddChild={handleAddChild}
/>
{isExpanded &&
node.children.length > 0 &&
renderNodes(node.children, depth + 1)}
</React.Fragment>
);
});
};
// ─── 디자인 모드 ───
if (isDesignMode) {
return (
<div className="rounded-md border border-dashed p-6 text-center">
<Package className="text-muted-foreground mx-auto mb-2 h-8 w-8" />
<p className="text-muted-foreground text-sm font-medium">
BOM
</p>
<p className="text-muted-foreground text-xs">
</p>
</div>
);
}
// ─── 메인 렌더링 ───
return (
<div className="space-y-3">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold"> </h4>
<Button
onClick={handleAddRoot}
size="sm"
className="h-8 text-xs"
>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
{/* 트리 목록 */}
<div className="space-y-1">
{loading ? (
<div className="flex items-center justify-center py-8">
<span className="text-muted-foreground text-sm"> ...</span>
</div>
) : treeData.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-md border border-dashed py-8">
<Package className="text-muted-foreground mb-2 h-8 w-8" />
<p className="text-muted-foreground text-sm">
.
</p>
<p className="text-muted-foreground text-xs">
&quot;&quot; .
</p>
</div>
) : (
renderNodes(treeData, 0)
)}
</div>
{/* 품목 검색 모달 */}
<ItemSearchModal
open={itemSearchOpen}
onClose={() => setItemSearchOpen(false)}
onSelect={handleItemSelect}
companyCode={companyCode}
/>
</div>
);
}
export default BomItemEditorComponent;

View File

@ -0,0 +1,22 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { BomItemEditorComponent } from "./BomItemEditorComponent";
import { V2BomItemEditorDefinition } from "./index";
export class BomItemEditorRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = V2BomItemEditorDefinition;
render(): React.ReactElement {
return <BomItemEditorComponent {...this.props} />;
}
}
BomItemEditorRenderer.registerSelf();
if (typeof window !== "undefined") {
setTimeout(() => {
BomItemEditorRenderer.registerSelf();
}, 0);
}

View File

@ -0,0 +1,30 @@
import { ComponentCategory } from "@/types/component";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { BomItemEditorComponent } from "./BomItemEditorComponent";
export const V2BomItemEditorDefinition = createComponentDefinition({
id: "v2-bom-item-editor",
name: "BOM 하위품목 편집기",
nameEng: "BOM Item Editor",
description: "BOM 하위 품목을 트리 구조로 추가/편집/삭제하는 컴포넌트",
category: ComponentCategory.V2,
webType: "text",
component: BomItemEditorComponent,
defaultConfig: {
detailTable: "bom_detail",
sourceTable: "item_info",
foreignKey: "bom_id",
parentKey: "parent_detail_id",
itemCodeField: "item_number",
itemNameField: "item_name",
itemTypeField: "type",
itemUnitField: "unit",
},
defaultSize: { width: 900, height: 400 },
icon: "ListTree",
tags: ["BOM", "트리", "편집", "하위품목", "제조"],
version: "1.0.0",
author: "개발팀",
});
export default V2BomItemEditorDefinition;

View File

@ -123,8 +123,8 @@ export const SplitLineComponent: React.FC<SplitLineComponentProps> = ({
const startOffset = dragOffset; const startOffset = dragOffset;
const scaleFactor = getScaleFactor(); const scaleFactor = getScaleFactor();
const cw = detectCanvasWidth(); const cw = detectCanvasWidth();
const MIN_POS = 50; const MIN_POS = Math.max(50, cw * 0.15);
const MAX_POS = cw - 50; const MAX_POS = cw - Math.max(50, cw * 0.15);
setIsDragging(true); setIsDragging(true);
setCanvasSplit({ isDragging: true }); setCanvasSplit({ isDragging: true });