feat: Add BOM tree view and BOM item editor components
- Introduced new components for BOM tree view and BOM item editor, enhancing the data management capabilities within the application. - Updated the ComponentsPanel to include these new components with appropriate descriptions and default sizes. - Integrated the BOM item editor into the V2PropertiesPanel for seamless editing of BOM items. - Adjusted the SplitLineComponent to improve the handling of canvas split positions, ensuring better user experience during component interactions.
This commit is contained in:
parent
5ec689101e
commit
27853a9447
|
|
@ -1,8 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, ReactNode, useState } from "react";
|
||||
import { useEffect, ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: ReactNode;
|
||||
|
|
@ -15,6 +16,8 @@ interface AuthGuardProps {
|
|||
/**
|
||||
* 인증 보호 컴포넌트
|
||||
* 로그인 상태 및 권한에 따라 접근을 제어
|
||||
* - 토큰 갱신/401 처리는 client.ts 인터셉터가 담당
|
||||
* - 이 컴포넌트는 인증 상태 기반 라우팅 가드 역할만 수행
|
||||
*/
|
||||
export function AuthGuard({
|
||||
children,
|
||||
|
|
@ -23,145 +26,67 @@ export function AuthGuard({
|
|||
redirectTo = "/login",
|
||||
fallback,
|
||||
}: AuthGuardProps) {
|
||||
const { isLoggedIn, isAdmin, loading, error } = useAuth();
|
||||
const { isLoggedIn, isAdmin, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
const [redirectCountdown, setRedirectCountdown] = useState<number | null>(null);
|
||||
const [authDebugInfo, setAuthDebugInfo] = useState<any>({});
|
||||
|
||||
useEffect(() => {
|
||||
console.log("=== AuthGuard 디버깅 ===");
|
||||
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 (loading) return;
|
||||
|
||||
// 토큰 확인을 더 정확하게
|
||||
// 토큰이 있는데 아직 인증 확인 중이면 대기
|
||||
if (typeof window !== "undefined") {
|
||||
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) {
|
||||
console.log("AuthGuard: 토큰은 있지만 인증이 안됨 - 잠시 대기");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 인증이 필요한데 로그인되지 않은 경우
|
||||
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);
|
||||
return null;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 관리자 권한이 필요한데 관리자가 아닌 경우
|
||||
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);
|
||||
return null;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return;
|
||||
}
|
||||
}, [requireAuth, requireAdmin, loading, isLoggedIn, isAdmin, redirectTo, router]);
|
||||
|
||||
console.log("AuthGuard: 모든 인증 조건 통과 - 컴포넌트 렌더링");
|
||||
}, [requireAuth, requireAdmin, loading, isLoggedIn, isAdmin, error, redirectTo, router]);
|
||||
|
||||
// 로딩 중일 때 fallback 또는 기본 로딩 표시
|
||||
if (loading) {
|
||||
console.log("AuthGuard: 로딩 중 - fallback 표시");
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 rounded bg-primary/20 p-4">
|
||||
<h3 className="font-bold">AuthGuard 로딩 중...</h3>
|
||||
<pre className="text-xs">{JSON.stringify(authDebugInfo, null, 2)}</pre>
|
||||
fallback || (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
{fallback || <div>로딩 중...</div>}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// 인증 실패 시 fallback 또는 기본 메시지 표시
|
||||
if (requireAuth && !isLoggedIn) {
|
||||
console.log("AuthGuard: 인증 실패 - fallback 표시");
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 rounded bg-destructive/20 p-4">
|
||||
<h3 className="font-bold">인증 실패</h3>
|
||||
{redirectCountdown !== null && (
|
||||
<div className="mb-2 text-destructive">
|
||||
<strong>리다이렉트 카운트다운:</strong> {redirectCountdown}초 후 {redirectTo}로 이동
|
||||
fallback || (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">인증 확인 중...</p>
|
||||
</div>
|
||||
)}
|
||||
<pre className="text-xs">{JSON.stringify(authDebugInfo, null, 2)}</pre>
|
||||
</div>
|
||||
{fallback || <div>인증이 필요합니다.</div>}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (requireAdmin && !isAdmin) {
|
||||
console.log("AuthGuard: 관리자 권한 없음 - fallback 표시");
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 rounded bg-orange-100 p-4">
|
||||
<h3 className="font-bold">관리자 권한 없음</h3>
|
||||
{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>}
|
||||
fallback || (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">관리자 권한이 필요합니다.</p>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
console.log("AuthGuard: 인증 성공 - 자식 컴포넌트 렌더링");
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1089,98 +1089,106 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
||||
const labelOffset = hasVisibleLabel ? (labelFontSize + labelMarginBottom + 2) : 0;
|
||||
|
||||
// 캔버스 분할선에 따른 X 위치 조정 (너비는 변경하지 않음 - 내부 컴포넌트 깨짐 방지)
|
||||
const calculateCanvasSplitX = (): number => {
|
||||
const calculateCanvasSplitX = (): { x: number; w: number } => {
|
||||
const compType = (component as any).componentType || "";
|
||||
const isSplitLine = type === "component" && compType === "v2-split-line";
|
||||
const origX = position?.x || 0;
|
||||
const defaultW = size?.width || 200;
|
||||
|
||||
if (isSplitLine) return origX;
|
||||
|
||||
// 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 (isSplitLine) return { x: origX, w: defaultW };
|
||||
|
||||
if (!canvasSplit.active || canvasSplit.canvasWidth <= 0 || !canvasSplit.scopeId) {
|
||||
return origX;
|
||||
return { x: origX, w: defaultW };
|
||||
}
|
||||
|
||||
if (myScopeIdRef.current === null) {
|
||||
const el = document.getElementById(`interactive-${component.id}`);
|
||||
const container = el?.closest("[data-screen-runtime]");
|
||||
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) {
|
||||
return origX;
|
||||
return { x: origX, w: defaultW };
|
||||
}
|
||||
|
||||
const { initialDividerX, currentDividerX, canvasWidth } = canvasSplit;
|
||||
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) {
|
||||
const componentCenterX = origX + (origW / 2);
|
||||
canvasSplitSideRef.current = componentCenterX < initialDividerX ? "left" : "right";
|
||||
}
|
||||
|
||||
let newX = origX;
|
||||
// 영역별 비례 스케일링: 스플릿선이 벽 역할 → 절대 넘어가지 않음
|
||||
let newX: number;
|
||||
let newW: number;
|
||||
const GAP = 4; // 스플릿선과의 최소 간격
|
||||
|
||||
if (canvasSplitSideRef.current === "left") {
|
||||
if (initialDividerX > 0) {
|
||||
newX = origX * (currentDividerX / initialDividerX);
|
||||
// 왼쪽 영역: [0, currentDividerX - GAP]
|
||||
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 {
|
||||
// 오른쪽 영역: [currentDividerX + GAP, canvasWidth]
|
||||
const initialRightWidth = canvasWidth - initialDividerX;
|
||||
const currentRightWidth = canvasWidth - currentDividerX;
|
||||
if (initialRightWidth > 0) {
|
||||
const posRatio = (origX - initialDividerX) / initialRightWidth;
|
||||
newX = currentDividerX + posRatio * currentRightWidth;
|
||||
}
|
||||
const currentRightWidth = Math.max(20, canvasWidth - currentDividerX - GAP);
|
||||
const scale = initialRightWidth > 0 ? currentRightWidth / initialRightWidth : 1;
|
||||
const rightOffset = origX - initialDividerX;
|
||||
newX = currentDividerX + GAP + rightOffset * scale;
|
||||
newW = origW * scale;
|
||||
// 안전 클램핑: 오른쪽 영역을 절대 넘지 않음
|
||||
if (newX < currentDividerX + GAP) newX = currentDividerX + GAP;
|
||||
if (newX + newW > canvasWidth) newW = canvasWidth - newX;
|
||||
}
|
||||
|
||||
// 캔버스 범위 내로 클램핑
|
||||
return Math.max(0, Math.min(newX, canvasWidth - 10));
|
||||
newX = Math.max(0, newX);
|
||||
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;
|
||||
|
||||
// styleWithoutSize에서 left/top 제거 (캔버스 분할 조정값 덮어쓰기 방지)
|
||||
const { left: _styleLeft, top: _styleTop, ...safeStyleWithoutSize } = styleWithoutSize as any;
|
||||
|
||||
const componentStyle = {
|
||||
position: "absolute" as const,
|
||||
...safeStyleWithoutSize,
|
||||
// left/top은 반드시 마지막에 (styleWithoutSize가 덮어쓰지 못하게)
|
||||
left: adjustedX,
|
||||
top: position?.y || 0,
|
||||
zIndex: position?.z || 1,
|
||||
...styleWithoutSize,
|
||||
width: size?.width || 200,
|
||||
width: isSplitActive ? adjustedW : (size?.width || 200),
|
||||
height: isTableSearchWidget ? "auto" : size?.height || 10,
|
||||
minHeight: isTableSearchWidget ? "48px" : undefined,
|
||||
overflow: labelOffset > 0 ? "visible" : undefined,
|
||||
// GPU 가속: 드래그 중 will-change 활성화, 끝나면 해제
|
||||
willChange: canvasSplit.isDragging && isSplitActive ? "left" as const : undefined,
|
||||
overflow: (isSplitActive && adjustedW < origW) ? "hidden" : (labelOffset > 0 ? "visible" : undefined),
|
||||
willChange: canvasSplit.isDragging && isSplitActive ? "left, width" as const : undefined,
|
||||
transition: isSplitActive
|
||||
? (canvasSplit.isDragging ? "none" : "left 0.15s ease-out")
|
||||
? (canvasSplit.isDragging ? "none" : "left 0.15s ease-out, width 0.15s ease-out")
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id={`interactive-${component.id}`} className="absolute" style={componentStyle}>
|
||||
{/* 위젯 렌더링 (라벨은 V2Input 내부에서 absolute로 표시됨) */}
|
||||
{renderInteractiveWidget(component)}
|
||||
{renderInteractiveWidget(
|
||||
isSplitActive && adjustedW !== origW
|
||||
? { ...component, size: { ...(component as any).size, width: adjustedW } }
|
||||
: component
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 팝업 화면 렌더링 */}
|
||||
|
|
|
|||
|
|
@ -424,10 +424,10 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
type === "component" && componentType === "v2-split-line";
|
||||
|
||||
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 (myScopeIdRef.current === null) {
|
||||
const el = document.getElementById(`component-${id}`);
|
||||
|
|
@ -435,7 +435,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
myScopeIdRef.current = container?.getAttribute("data-split-scope") || "__none__";
|
||||
}
|
||||
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 delta = currentDividerX - initialDividerX;
|
||||
|
|
@ -447,27 +447,39 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
}
|
||||
|
||||
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 (initialDividerX > 0) {
|
||||
adjustedX = position.x * (currentDividerX / initialDividerX);
|
||||
const initialZoneWidth = 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 {
|
||||
const initialRightWidth = canvasWidth - initialDividerX;
|
||||
const currentRightWidth = canvasWidth - currentDividerX;
|
||||
if (initialRightWidth > 0) {
|
||||
const posRatio = (position.x - initialDividerX) / initialRightWidth;
|
||||
adjustedX = currentDividerX + posRatio * currentRightWidth;
|
||||
}
|
||||
const currentRightWidth = Math.max(20, canvasWidth - currentDividerX - GAP);
|
||||
const scale = initialRightWidth > 0 ? currentRightWidth / initialRightWidth : 1;
|
||||
const rightOffset = position.x - initialDividerX;
|
||||
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) - 버튼 전용 ===
|
||||
|
|
@ -475,11 +487,11 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
type === "component" && ["split-panel-layout", "split-panel-layout2"].includes(componentType);
|
||||
|
||||
if (isSplitPanelComponent) {
|
||||
return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false };
|
||||
return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false };
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
@ -493,7 +505,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
initialPanelIdRef.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;
|
||||
|
|
@ -512,37 +524,42 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
const dividerDelta = currentDividerX - initialDividerX;
|
||||
|
||||
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;
|
||||
|
||||
return {
|
||||
adjustedPositionX: adjustedX,
|
||||
adjustedWidth: null,
|
||||
isOnSplitPanel: true,
|
||||
isDraggingSplitPanel: panel.isDragging,
|
||||
};
|
||||
};
|
||||
|
||||
const { adjustedPositionX, isOnSplitPanel, isDraggingSplitPanel } = calculateSplitAdjustedPosition();
|
||||
const { adjustedPositionX, adjustedWidth: splitAdjustedWidth, isOnSplitPanel, isDraggingSplitPanel } = calculateSplitAdjustedPosition();
|
||||
|
||||
const displayWidth = resizeSize ? `${resizeSize.width}px` : getWidth();
|
||||
const displayHeight = resizeSize ? `${resizeSize.height}px` : getHeight();
|
||||
|
||||
const isSplitActive = canvasSplit.active && canvasSplit.scopeId && myScopeIdRef.current === canvasSplit.scopeId;
|
||||
|
||||
const origWidth = size?.width || 100;
|
||||
const isSplitShrunk = splitAdjustedWidth !== null && splitAdjustedWidth < origWidth;
|
||||
|
||||
const baseStyle = {
|
||||
left: `${adjustedPositionX}px`,
|
||||
top: `${position.y}px`,
|
||||
...componentStyle,
|
||||
width: displayWidth,
|
||||
width: splitAdjustedWidth !== null ? `${splitAdjustedWidth}px` : displayWidth,
|
||||
height: displayHeight,
|
||||
zIndex: component.type === "layout" ? 1 : position.z || 2,
|
||||
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:
|
||||
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%" }}
|
||||
>
|
||||
<DynamicComponentRenderer
|
||||
component={enhancedComponent}
|
||||
component={isSplitShrunk
|
||||
? { ...enhancedComponent, size: { ...(enhancedComponent as any).size, width: splitAdjustedWidth } }
|
||||
: enhancedComponent
|
||||
}
|
||||
isSelected={isSelected}
|
||||
isDesignMode={isDesignMode}
|
||||
isInteractive={!isDesignMode} // 편집 모드가 아닐 때만 인터랙티브
|
||||
|
|
|
|||
|
|
@ -81,6 +81,22 @@ export function ComponentsPanel({
|
|||
tags: ["repeater", "table", "modal", "button", "v2", "v2"],
|
||||
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[],
|
||||
[],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -216,6 +216,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
"v2-media": require("@/components/v2/config-panels/V2MediaConfigPanel").V2MediaConfigPanel,
|
||||
"v2-biz": require("@/components/v2/config-panels/V2BizConfigPanel").V2BizConfigPanel,
|
||||
"v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel").V2HierarchyConfigPanel,
|
||||
"v2-bom-item-editor": require("@/components/v2/config-panels/V2BomItemEditorConfigPanel").V2BomItemEditorConfigPanel,
|
||||
};
|
||||
|
||||
const V2ConfigPanel = v2ConfigPanels[componentId];
|
||||
|
|
@ -239,6 +240,10 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
if (componentId === "v2-list") {
|
||||
extraProps.currentTableName = currentTableName;
|
||||
}
|
||||
if (componentId === "v2-bom-item-editor") {
|
||||
extraProps.currentTableName = currentTableName;
|
||||
extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={selectedComponent.id} className="space-y-4">
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,8 +1,7 @@
|
|||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiCall, API_BASE_URL } from "@/lib/api/client";
|
||||
import { apiCall } from "@/lib/api/client";
|
||||
|
||||
// 사용자 정보 타입 정의
|
||||
interface UserInfo {
|
||||
userId: string;
|
||||
userName: string;
|
||||
|
|
@ -23,11 +22,10 @@ interface UserInfo {
|
|||
isAdmin: boolean;
|
||||
sabun?: string;
|
||||
photo?: string | null;
|
||||
companyCode?: string; // 백엔드와 일치하도록 수정
|
||||
company_code?: string; // 하위 호환성을 위해 유지
|
||||
companyCode?: string;
|
||||
company_code?: string;
|
||||
}
|
||||
|
||||
// 인증 상태 타입 정의
|
||||
interface AuthStatus {
|
||||
isLoggedIn: boolean;
|
||||
isAdmin: boolean;
|
||||
|
|
@ -35,14 +33,12 @@ interface AuthStatus {
|
|||
deptCode?: string;
|
||||
}
|
||||
|
||||
// 로그인 결과 타입 정의
|
||||
interface LoginResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
errorCode?: string;
|
||||
}
|
||||
|
||||
// API 응답 타입 정의
|
||||
interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
message: string;
|
||||
|
|
@ -50,9 +46,7 @@ interface ApiResponse<T = any> {
|
|||
errorCode?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 토큰 관리 유틸리티
|
||||
*/
|
||||
// JWT 토큰 관리 유틸리티 (client.ts와 동일한 localStorage 키 사용)
|
||||
const TokenManager = {
|
||||
getToken: (): string | null => {
|
||||
if (typeof window !== "undefined") {
|
||||
|
|
@ -63,7 +57,6 @@ const TokenManager = {
|
|||
|
||||
setToken: (token: string): void => {
|
||||
if (typeof window !== "undefined") {
|
||||
// localStorage에 저장
|
||||
localStorage.setItem("authToken", token);
|
||||
// 쿠키에도 저장 (미들웨어에서 사용)
|
||||
document.cookie = `authToken=${token}; path=/; max-age=86400; SameSite=Lax`;
|
||||
|
|
@ -72,9 +65,7 @@ const TokenManager = {
|
|||
|
||||
removeToken: (): void => {
|
||||
if (typeof window !== "undefined") {
|
||||
// localStorage에서 제거
|
||||
localStorage.removeItem("authToken");
|
||||
// 쿠키에서도 제거
|
||||
document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax";
|
||||
}
|
||||
},
|
||||
|
|
@ -91,12 +82,12 @@ const TokenManager = {
|
|||
|
||||
/**
|
||||
* 인증 상태 관리 훅
|
||||
* 로그인, 로그아웃, 사용자 정보 조회, 권한 확인 등을 담당
|
||||
* - 401 처리는 client.ts의 응답 인터셉터에서 통합 관리
|
||||
* - 이 훅은 상태 관리와 사용자 정보 조회에만 집중
|
||||
*/
|
||||
export const useAuth = () => {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태 관리
|
||||
const [user, setUser] = useState<UserInfo | null>(null);
|
||||
const [authStatus, setAuthStatus] = useState<AuthStatus>({
|
||||
isLoggedIn: false,
|
||||
|
|
@ -106,8 +97,6 @@ export const useAuth = () => {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
// API 기본 URL 설정 (동적으로 결정)
|
||||
|
||||
/**
|
||||
* 현재 사용자 정보 조회
|
||||
*/
|
||||
|
|
@ -116,26 +105,19 @@ export const useAuth = () => {
|
|||
const response = await apiCall<UserInfo>("GET", "/auth/me");
|
||||
|
||||
if (response.success && response.data) {
|
||||
// 사용자 로케일 정보도 함께 조회하여 전역 저장
|
||||
// 사용자 로케일 정보 조회
|
||||
try {
|
||||
const localeResponse = await apiCall<string>("GET", "/admin/user-locale");
|
||||
if (localeResponse.success && localeResponse.data) {
|
||||
const userLocale = localeResponse.data;
|
||||
|
||||
// 전역 상태에 저장 (다른 컴포넌트에서 사용)
|
||||
(window as any).__GLOBAL_USER_LANG = userLocale;
|
||||
(window as any).__GLOBAL_USER_LOCALE_LOADED = true;
|
||||
|
||||
// localStorage에도 저장 (새 창에서 공유)
|
||||
localStorage.setItem("userLocale", userLocale);
|
||||
localStorage.setItem("userLocaleLoaded", "true");
|
||||
}
|
||||
} catch (localeError) {
|
||||
console.warn("⚠️ 사용자 로케일 조회 실패, 기본값 사용:", localeError);
|
||||
} catch {
|
||||
(window as any).__GLOBAL_USER_LANG = "KR";
|
||||
(window as any).__GLOBAL_USER_LOCALE_LOADED = true;
|
||||
|
||||
// localStorage에도 저장
|
||||
localStorage.setItem("userLocale", "KR");
|
||||
localStorage.setItem("userLocaleLoaded", "true");
|
||||
}
|
||||
|
|
@ -144,8 +126,7 @@ export const useAuth = () => {
|
|||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("사용자 정보 조회 실패:", error);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
|
@ -157,95 +138,66 @@ export const useAuth = () => {
|
|||
try {
|
||||
const response = await apiCall<AuthStatus>("GET", "/auth/status");
|
||||
if (response.success && response.data) {
|
||||
// 백엔드에서 isAuthenticated를 반환하므로 isLoggedIn으로 매핑
|
||||
const mappedData = {
|
||||
return {
|
||||
isLoggedIn: (response.data as any).isAuthenticated || response.data.isLoggedIn || false,
|
||||
isAdmin: response.data.isAdmin || false,
|
||||
};
|
||||
return mappedData;
|
||||
}
|
||||
|
||||
return {
|
||||
isLoggedIn: false,
|
||||
isAdmin: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("인증 상태 확인 실패:", error);
|
||||
return {
|
||||
isLoggedIn: false,
|
||||
isAdmin: false,
|
||||
};
|
||||
return { isLoggedIn: false, isAdmin: false };
|
||||
} catch {
|
||||
return { isLoggedIn: false, isAdmin: false };
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 사용자 데이터 새로고침
|
||||
* - API 실패 시에도 토큰이 유효하면 토큰 기반으로 임시 인증 유지
|
||||
* - 토큰 자체가 없거나 만료된 경우에만 비인증 상태로 전환
|
||||
*/
|
||||
const refreshUserData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// JWT 토큰 확인
|
||||
const token = TokenManager.getToken();
|
||||
if (!token) {
|
||||
if (!token || TokenManager.isTokenExpired(token)) {
|
||||
setUser(null);
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
setTimeout(() => {
|
||||
router.push("/login");
|
||||
}, 3000);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 토큰이 있으면 임시로 인증된 상태로 설정
|
||||
// 토큰이 유효하면 우선 인증된 상태로 설정
|
||||
setAuthStatus({
|
||||
isLoggedIn: true,
|
||||
isAdmin: false, // API 호출 후 업데이트될 예정
|
||||
isAdmin: false,
|
||||
});
|
||||
|
||||
try {
|
||||
// 병렬로 사용자 정보와 인증 상태 조회
|
||||
const [userInfo, authStatusData] = await Promise.all([fetchCurrentUser(), checkAuthStatus()]);
|
||||
|
||||
if (userInfo) {
|
||||
setUser(userInfo);
|
||||
|
||||
// 관리자 권한 확인 로직 개선
|
||||
let finalAuthStatus = authStatusData;
|
||||
if (userInfo) {
|
||||
// 사용자 정보를 기반으로 관리자 권한 추가 확인
|
||||
const isAdminFromUser = userInfo.userId === "plm_admin" || userInfo.userType === "ADMIN";
|
||||
finalAuthStatus = {
|
||||
const finalAuthStatus = {
|
||||
isLoggedIn: authStatusData.isLoggedIn,
|
||||
isAdmin: authStatusData.isAdmin || isAdminFromUser,
|
||||
};
|
||||
}
|
||||
|
||||
setAuthStatus(finalAuthStatus);
|
||||
|
||||
// console.log("✅ 최종 사용자 상태:", {
|
||||
// userId: userInfo?.userId,
|
||||
// userName: userInfo?.userName,
|
||||
// companyCode: userInfo?.companyCode || userInfo?.company_code,
|
||||
// });
|
||||
|
||||
// 디버깅용 로그
|
||||
|
||||
// 로그인되지 않은 상태인 경우 토큰 제거 (리다이렉트는 useEffect에서 처리)
|
||||
// API 결과가 비인증이면 상태만 업데이트 (리다이렉트는 client.ts가 처리)
|
||||
if (!finalAuthStatus.isLoggedIn) {
|
||||
TokenManager.removeToken();
|
||||
setUser(null);
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
} else {
|
||||
}
|
||||
} catch (apiError) {
|
||||
console.error("API 호출 실패:", apiError);
|
||||
|
||||
// API 호출 실패 시에도 토큰이 있으면 임시로 인증된 상태로 처리
|
||||
|
||||
// 토큰에서 사용자 정보 추출 시도
|
||||
} else {
|
||||
// userInfo 조회 실패 → 토큰에서 최소 정보 추출하여 유지
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
|
||||
const tempUser = {
|
||||
const tempUser: UserInfo = {
|
||||
userId: payload.userId || payload.id || "unknown",
|
||||
userName: payload.userName || payload.name || "사용자",
|
||||
companyCode: payload.companyCode || payload.company_code || "",
|
||||
|
|
@ -257,32 +209,43 @@ export const useAuth = () => {
|
|||
isLoggedIn: true,
|
||||
isAdmin: tempUser.isAdmin,
|
||||
});
|
||||
} catch (tokenError) {
|
||||
console.error("토큰 파싱 실패:", tokenError);
|
||||
// 토큰 파싱도 실패하면 로그인 페이지로 리다이렉트
|
||||
} catch {
|
||||
// 토큰 파싱도 실패하면 비인증 상태로 전환
|
||||
TokenManager.removeToken();
|
||||
setUser(null);
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
setTimeout(() => {
|
||||
router.push("/login");
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("사용자 데이터 새로고침 실패:", error);
|
||||
setError("사용자 정보를 불러오는데 실패했습니다.");
|
||||
} catch {
|
||||
// API 호출 전체 실패 → 토큰 기반 임시 인증 유지 시도
|
||||
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();
|
||||
setUser(null);
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
setTimeout(() => {
|
||||
router.push("/login");
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setError("사용자 정보를 불러오는데 실패했습니다.");
|
||||
setUser(null);
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fetchCurrentUser, checkAuthStatus, router]);
|
||||
}, [fetchCurrentUser, checkAuthStatus]);
|
||||
|
||||
/**
|
||||
* 로그인 처리
|
||||
|
|
@ -299,10 +262,7 @@ export const useAuth = () => {
|
|||
});
|
||||
|
||||
if (response.success && response.data?.token) {
|
||||
// JWT 토큰 저장
|
||||
TokenManager.setToken(response.data.token);
|
||||
|
||||
// 로그인 성공 시 사용자 정보 및 인증 상태 업데이트
|
||||
await refreshUserData();
|
||||
|
||||
return {
|
||||
|
|
@ -328,7 +288,7 @@ export const useAuth = () => {
|
|||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[apiCall, refreshUserData],
|
||||
[refreshUserData],
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
@ -337,40 +297,27 @@ export const useAuth = () => {
|
|||
const switchCompany = useCallback(
|
||||
async (companyCode: string): Promise<{ success: boolean; message: string }> => {
|
||||
try {
|
||||
// console.log("🔵 useAuth.switchCompany 시작:", companyCode);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// console.log("🔵 API 호출: POST /auth/switch-company");
|
||||
const response = await apiCall<any>("POST", "/auth/switch-company", {
|
||||
companyCode,
|
||||
});
|
||||
// console.log("🔵 API 응답:", response);
|
||||
|
||||
if (response.success && response.data?.token) {
|
||||
// console.log("🔵 새 토큰 받음:", response.data.token.substring(0, 20) + "...");
|
||||
|
||||
// 새로운 JWT 토큰 저장
|
||||
TokenManager.setToken(response.data.token);
|
||||
// console.log("🔵 토큰 저장 완료");
|
||||
|
||||
// refreshUserData 호출하지 않고 바로 성공 반환
|
||||
// (페이지 새로고침 시 자동으로 갱신됨)
|
||||
// console.log("🔵 회사 전환 완료 (페이지 새로고침 필요)");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: response.message || "회사 전환에 성공했습니다.",
|
||||
};
|
||||
} else {
|
||||
// console.error("🔵 API 응답 실패:", response);
|
||||
return {
|
||||
success: false,
|
||||
message: response.message || "회사 전환에 실패했습니다.",
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
// console.error("🔵 switchCompany 에러:", error);
|
||||
const errorMessage = error.message || "회사 전환 중 오류가 발생했습니다.";
|
||||
setError(errorMessage);
|
||||
|
||||
|
|
@ -380,10 +327,9 @@ export const useAuth = () => {
|
|||
};
|
||||
} finally {
|
||||
setLoading(false);
|
||||
// console.log("🔵 switchCompany 완료");
|
||||
}
|
||||
},
|
||||
[apiCall]
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
@ -395,51 +341,37 @@ export const useAuth = () => {
|
|||
|
||||
const response = await apiCall("POST", "/auth/logout");
|
||||
|
||||
// JWT 토큰 제거
|
||||
TokenManager.removeToken();
|
||||
|
||||
// 로케일 정보도 제거
|
||||
localStorage.removeItem("userLocale");
|
||||
localStorage.removeItem("userLocaleLoaded");
|
||||
(window as any).__GLOBAL_USER_LANG = undefined;
|
||||
(window as any).__GLOBAL_USER_LOCALE_LOADED = undefined;
|
||||
|
||||
// 로그아웃 API 호출 성공 여부와 관계없이 클라이언트 상태 초기화
|
||||
setUser(null);
|
||||
setAuthStatus({
|
||||
isLoggedIn: false,
|
||||
isAdmin: false,
|
||||
});
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
setError(null);
|
||||
|
||||
// 로그인 페이지로 리다이렉트
|
||||
router.push("/login");
|
||||
|
||||
return response.success;
|
||||
} catch (error) {
|
||||
console.error("로그아웃 처리 실패:", error);
|
||||
|
||||
// 오류가 발생해도 JWT 토큰 제거 및 클라이언트 상태 초기화
|
||||
} catch {
|
||||
TokenManager.removeToken();
|
||||
|
||||
// 로케일 정보도 제거
|
||||
localStorage.removeItem("userLocale");
|
||||
localStorage.removeItem("userLocaleLoaded");
|
||||
(window as any).__GLOBAL_USER_LANG = undefined;
|
||||
(window as any).__GLOBAL_USER_LOCALE_LOADED = undefined;
|
||||
|
||||
setUser(null);
|
||||
setAuthStatus({
|
||||
isLoggedIn: false,
|
||||
isAdmin: false,
|
||||
});
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
router.push("/login");
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiCall, router]);
|
||||
}, [router]);
|
||||
|
||||
/**
|
||||
* 메뉴 접근 권한 확인
|
||||
|
|
@ -453,8 +385,7 @@ export const useAuth = () => {
|
|||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error("메뉴 권한 확인 실패:", error);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
|
@ -463,96 +394,56 @@ export const useAuth = () => {
|
|||
* 초기 인증 상태 확인
|
||||
*/
|
||||
useEffect(() => {
|
||||
// 이미 초기화되었으면 실행하지 않음
|
||||
if (initializedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// 로그인 페이지에서는 인증 상태 확인하지 않음
|
||||
if (window.location.pathname === "/login") {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 토큰이 있는 경우에만 인증 상태 확인
|
||||
const token = TokenManager.getToken();
|
||||
|
||||
if (token && !TokenManager.isTokenExpired(token)) {
|
||||
// 토큰이 있으면 임시로 인증된 상태로 설정 (API 호출 전에)
|
||||
// 유효한 토큰 → 우선 인증 상태로 설정 후 API 확인
|
||||
setAuthStatus({
|
||||
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,
|
||||
});
|
||||
setError("세션이 만료되었습니다. 다시 로그인해주세요.");
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
// 전역 에러 핸들러 등록 (401 Unauthorized 응답 처리)
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = async (...args) => {
|
||||
const response = await originalFetch(...args);
|
||||
|
||||
if (response.status === 401 && window.location.pathname !== "/login") {
|
||||
handleSessionExpiry();
|
||||
refreshUserData();
|
||||
} else if (token && TokenManager.isTokenExpired(token)) {
|
||||
// 만료된 토큰 → 정리 (리다이렉트는 AuthGuard에서 처리)
|
||||
TokenManager.removeToken();
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
setLoading(false);
|
||||
} else {
|
||||
// 토큰 없음 → 비인증 상태 (리다이렉트는 AuthGuard에서 처리)
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
return () => {
|
||||
window.fetch = originalFetch;
|
||||
};
|
||||
}, [router]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 상태
|
||||
user,
|
||||
authStatus,
|
||||
loading,
|
||||
error,
|
||||
|
||||
// 계산된 값
|
||||
isLoggedIn: authStatus.isLoggedIn,
|
||||
isAdmin: authStatus.isAdmin,
|
||||
userId: user?.userId,
|
||||
userName: user?.userName,
|
||||
companyCode: user?.companyCode || user?.company_code, // 🆕 회사 코드
|
||||
companyCode: user?.companyCode || user?.company_code,
|
||||
|
||||
// 함수
|
||||
login,
|
||||
logout,
|
||||
switchCompany, // 🆕 회사 전환 함수
|
||||
switchCompany,
|
||||
checkMenuAuth,
|
||||
refreshUserData,
|
||||
|
||||
// 유틸리티
|
||||
clearError: () => setError(null),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,24 +1,19 @@
|
|||
import axios, { AxiosResponse, AxiosError } from "axios";
|
||||
import axios, { AxiosResponse, AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||
|
||||
// API URL 동적 설정 - 환경변수 우선 사용
|
||||
const getApiBaseUrl = (): string => {
|
||||
// 1. 환경변수가 있으면 우선 사용
|
||||
if (process.env.NEXT_PUBLIC_API_URL) {
|
||||
return process.env.NEXT_PUBLIC_API_URL;
|
||||
}
|
||||
|
||||
// 2. 클라이언트 사이드에서 동적 설정
|
||||
if (typeof window !== "undefined") {
|
||||
const currentHost = window.location.hostname;
|
||||
const currentPort = window.location.port;
|
||||
const protocol = window.location.protocol;
|
||||
|
||||
// 프로덕션 환경: v1.vexplor.com → api.vexplor.com
|
||||
if (currentHost === "v1.vexplor.com") {
|
||||
return "https://api.vexplor.com/api";
|
||||
}
|
||||
|
||||
// 로컬 개발환경: localhost:9771 또는 localhost:3000 → localhost:8080
|
||||
if (
|
||||
(currentHost === "localhost" || currentHost === "127.0.0.1") &&
|
||||
(currentPort === "9771" || currentPort === "3000")
|
||||
|
|
@ -27,57 +22,46 @@ const getApiBaseUrl = (): string => {
|
|||
}
|
||||
}
|
||||
|
||||
// 3. 기본값
|
||||
return "http://localhost:8080/api";
|
||||
};
|
||||
|
||||
export const API_BASE_URL = getApiBaseUrl();
|
||||
|
||||
// 이미지 URL을 완전한 URL로 변환하는 함수
|
||||
// 주의: 모듈 로드 시점이 아닌 런타임에 hostname을 확인해야 SSR 문제 방지
|
||||
export const getFullImageUrl = (imagePath: string): string => {
|
||||
// 빈 값 체크
|
||||
if (!imagePath) return "";
|
||||
|
||||
// 이미 전체 URL인 경우 그대로 반환
|
||||
if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
|
||||
return imagePath;
|
||||
}
|
||||
|
||||
// /uploads로 시작하는 상대 경로인 경우 API 서버 주소 추가
|
||||
if (imagePath.startsWith("/uploads")) {
|
||||
// 런타임에 현재 hostname 확인 (SSR 시점이 아닌 클라이언트에서 실행될 때)
|
||||
if (typeof window !== "undefined") {
|
||||
const currentHost = window.location.hostname;
|
||||
|
||||
// 프로덕션 환경: v1.vexplor.com → api.vexplor.com
|
||||
if (currentHost === "v1.vexplor.com") {
|
||||
return `https://api.vexplor.com${imagePath}`;
|
||||
}
|
||||
|
||||
// 로컬 개발환경
|
||||
if (currentHost === "localhost" || currentHost === "127.0.0.1") {
|
||||
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$/, "");
|
||||
if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) {
|
||||
return `${baseUrl}${imagePath}`;
|
||||
}
|
||||
|
||||
// 최종 fallback
|
||||
return imagePath;
|
||||
}
|
||||
|
||||
return imagePath;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// JWT 토큰 관리 유틸리티
|
||||
// ============================================
|
||||
const TokenManager = {
|
||||
getToken: (): string | null => {
|
||||
if (typeof window !== "undefined") {
|
||||
|
|
@ -107,20 +91,19 @@ const TokenManager = {
|
|||
}
|
||||
},
|
||||
|
||||
// 토큰이 곧 만료되는지 확인 (30분 이내)
|
||||
// 만료 30분 전부터 갱신 대상
|
||||
isTokenExpiringSoon: (token: string): boolean => {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
const expiryTime = payload.exp * 1000;
|
||||
const currentTime = Date.now();
|
||||
const thirtyMinutes = 30 * 60 * 1000; // 30분
|
||||
const thirtyMinutes = 30 * 60 * 1000;
|
||||
return expiryTime - currentTime < thirtyMinutes && expiryTime > currentTime;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// 토큰 만료까지 남은 시간 (밀리초)
|
||||
getTimeUntilExpiry: (token: string): number => {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
|
|
@ -131,19 +114,36 @@ const TokenManager = {
|
|||
},
|
||||
};
|
||||
|
||||
// 토큰 갱신 중복 방지 플래그
|
||||
// ============================================
|
||||
// 토큰 갱신 로직 (중복 요청 방지)
|
||||
// ============================================
|
||||
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> => {
|
||||
// 이미 갱신 중이면 기존 Promise 반환
|
||||
if (isRefreshing && refreshPromise) {
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
const currentToken = TokenManager.getToken();
|
||||
if (!currentToken) {
|
||||
|
|
@ -163,45 +163,36 @@ const refreshToken = async (): Promise<string | null> => {
|
|||
if (response.data?.success && response.data?.data?.token) {
|
||||
const newToken = response.data.data.token;
|
||||
TokenManager.setToken(newToken);
|
||||
console.log("[TokenManager] 토큰 갱신 성공");
|
||||
return newToken;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("[TokenManager] 토큰 갱신 실패:", error);
|
||||
} catch {
|
||||
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 => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// 기존 타이머 정리
|
||||
if (tokenRefreshTimer) {
|
||||
clearInterval(tokenRefreshTimer);
|
||||
}
|
||||
|
||||
// 10분마다 토큰 상태 확인
|
||||
// 5분마다 토큰 상태 확인 (기존 10분 → 5분으로 단축)
|
||||
tokenRefreshTimer = setInterval(
|
||||
async () => {
|
||||
const token = TokenManager.getToken();
|
||||
if (token && TokenManager.isTokenExpiringSoon(token)) {
|
||||
console.log("[TokenManager] 토큰 만료 임박, 자동 갱신 시작...");
|
||||
await refreshToken();
|
||||
}
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
); // 10분
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
|
||||
// 페이지 로드 시 즉시 확인
|
||||
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 => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
let lastActivity = Date.now();
|
||||
let lastActivityCheck = Date.now();
|
||||
const activityThreshold = 5 * 60 * 1000; // 5분
|
||||
|
||||
const handleActivity = (): void => {
|
||||
const now = Date.now();
|
||||
// 마지막 활동으로부터 5분 이상 지났으면 토큰 상태 확인
|
||||
if (now - lastActivity > activityThreshold) {
|
||||
if (now - lastActivityCheck > activityThreshold) {
|
||||
const token = TokenManager.getToken();
|
||||
if (token && TokenManager.isTokenExpiringSoon(token)) {
|
||||
refreshToken();
|
||||
}
|
||||
lastActivityCheck = now;
|
||||
}
|
||||
lastActivity = now;
|
||||
};
|
||||
|
||||
// 사용자 활동 이벤트 감지
|
||||
["click", "keydown", "scroll", "mousemove"].forEach((event) => {
|
||||
// 너무 잦은 호출 방지를 위해 throttle 적용
|
||||
let throttleTimer: NodeJS.Timeout | null = null;
|
||||
["click", "keydown"].forEach((event) => {
|
||||
let throttleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
window.addEventListener(
|
||||
event,
|
||||
() => {
|
||||
|
|
@ -240,7 +251,7 @@ const setupActivityBasedRefresh = (): void => {
|
|||
throttleTimer = setTimeout(() => {
|
||||
handleActivity();
|
||||
throttleTimer = null;
|
||||
}, 1000); // 1초 throttle
|
||||
}, 2000);
|
||||
}
|
||||
},
|
||||
{ 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") {
|
||||
startAutoRefresh();
|
||||
setupVisibilityRefresh();
|
||||
setupActivityBasedRefresh();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Axios 인스턴스 생성
|
||||
// ============================================
|
||||
export const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 30000, // 30초로 증가 (다중 커넥션 처리 시간 고려)
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
withCredentials: true, // 쿠키 포함
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 요청 인터셉터
|
||||
// ============================================
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
// JWT 토큰 추가
|
||||
async (config: InternalAxiosRequestConfig) => {
|
||||
const token = TokenManager.getToken();
|
||||
|
||||
if (token && !TokenManager.isTokenExpired(token)) {
|
||||
if (token) {
|
||||
if (!TokenManager.isTokenExpired(token)) {
|
||||
// 유효한 토큰 → 그대로 사용
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
} else if (token && TokenManager.isTokenExpired(token)) {
|
||||
console.warn("❌ 토큰이 만료되었습니다.");
|
||||
// 토큰 제거
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("authToken");
|
||||
}
|
||||
} else {
|
||||
console.warn("⚠️ 토큰이 없습니다.");
|
||||
// 만료된 토큰 → 갱신 시도 후 사용
|
||||
const newToken = await refreshToken();
|
||||
if (newToken) {
|
||||
config.headers.Authorization = `Bearer ${newToken}`;
|
||||
}
|
||||
// 갱신 실패해도 요청은 보냄 (401 응답 인터셉터에서 처리)
|
||||
}
|
||||
}
|
||||
|
||||
// FormData 요청 시 Content-Type 자동 처리
|
||||
|
|
@ -287,18 +316,14 @@ apiClient.interceptors.request.use(
|
|||
delete config.headers["Content-Type"];
|
||||
}
|
||||
|
||||
// 언어 정보를 쿼리 파라미터에 추가 (GET 요청 시에만)
|
||||
// 언어 정보를 쿼리 파라미터에 추가 (GET 요청)
|
||||
if (config.method?.toUpperCase() === "GET") {
|
||||
// 우선순위: 전역 변수 > localStorage > 기본값
|
||||
let currentLang = "KR"; // 기본값
|
||||
let currentLang = "KR";
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
// 1순위: 전역 변수에서 확인
|
||||
if ((window as unknown as { __GLOBAL_USER_LANG?: string }).__GLOBAL_USER_LANG) {
|
||||
currentLang = (window as unknown as { __GLOBAL_USER_LANG: string }).__GLOBAL_USER_LANG;
|
||||
}
|
||||
// 2순위: localStorage에서 확인 (새 창이나 페이지 새로고침 시)
|
||||
else {
|
||||
} else {
|
||||
const storedLocale = localStorage.getItem("userLocale");
|
||||
if (storedLocale) {
|
||||
currentLang = storedLocale;
|
||||
|
|
@ -316,19 +341,19 @@ apiClient.interceptors.request.use(
|
|||
return config;
|
||||
},
|
||||
(error) => {
|
||||
console.error("❌ API 요청 오류:", error);
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// 응답 인터셉터
|
||||
// ============================================
|
||||
apiClient.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
// 백엔드에서 보내주는 새로운 토큰 처리
|
||||
const newToken = response.headers["x-new-token"];
|
||||
if (newToken) {
|
||||
TokenManager.setToken(newToken);
|
||||
console.log("[TokenManager] 서버에서 새 토큰 수신, 저장 완료");
|
||||
}
|
||||
return response;
|
||||
},
|
||||
|
|
@ -336,79 +361,80 @@ apiClient.interceptors.response.use(
|
|||
const status = error.response?.status;
|
||||
const url = error.config?.url;
|
||||
|
||||
// 409 에러 (중복 데이터)는 조용하게 처리
|
||||
// 409 에러 (중복 데이터) - 조용하게 처리
|
||||
if (status === 409) {
|
||||
// 중복 검사 API와 관계도 저장은 완전히 조용하게 처리
|
||||
if (url?.includes("/check-duplicate") || url?.includes("/dataflow-diagrams")) {
|
||||
// 중복 검사와 관계도 중복 이름은 정상적인 비즈니스 로직이므로 콘솔 출력 없음
|
||||
return Promise.reject(error);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 일반 409 에러는 간단한 로그만 출력
|
||||
console.warn("데이터 중복:", {
|
||||
url: url,
|
||||
message: (error.response?.data as { message?: string })?.message || "중복된 데이터입니다.",
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 채번 규칙 미리보기 API 실패는 조용하게 처리 (화면 로드 시 자주 발생)
|
||||
// 채번 규칙 미리보기 API 실패는 조용하게 처리
|
||||
if (url?.includes("/numbering-rules/") && url?.includes("/preview")) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 다른 에러들은 기존처럼 상세 로그 출력
|
||||
console.error("API 응답 오류:", {
|
||||
status: status,
|
||||
statusText: error.response?.statusText,
|
||||
url: url,
|
||||
data: error.response?.data,
|
||||
message: error.message,
|
||||
});
|
||||
|
||||
// 401 에러 처리
|
||||
// 401 에러 처리 (핵심 개선)
|
||||
if (status === 401 && typeof window !== "undefined") {
|
||||
const errorData = error.response?.data as { error?: { code?: string } };
|
||||
const errorCode = errorData?.error?.code;
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
console.warn("[Auth] 401 오류 발생:", {
|
||||
url: url,
|
||||
errorCode: errorCode,
|
||||
token: TokenManager.getToken() ? "존재" : "없음",
|
||||
});
|
||||
// 이미 재시도한 요청이면 로그인으로
|
||||
if (originalRequest?._retry) {
|
||||
redirectToLogin();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 토큰 만료 에러인 경우 갱신 시도
|
||||
const originalRequest = error.config as typeof error.config & { _retry?: boolean };
|
||||
if (errorCode === "TOKEN_EXPIRED" && originalRequest && !originalRequest._retry) {
|
||||
console.log("[Auth] 토큰 만료, 갱신 시도...");
|
||||
// 토큰 만료 에러 → 갱신 후 재시도
|
||||
if (errorCode === "TOKEN_EXPIRED" && originalRequest) {
|
||||
if (!isRefreshing) {
|
||||
isRefreshing = true;
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const newToken = await refreshToken();
|
||||
if (newToken && originalRequest) {
|
||||
if (newToken) {
|
||||
isRefreshing = false;
|
||||
onTokenRefreshed(newToken);
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
return apiClient.request(originalRequest);
|
||||
} else {
|
||||
isRefreshing = false;
|
||||
onRefreshFailed(new Error("토큰 갱신 실패"));
|
||||
redirectToLogin();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
} 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 에러인 경우 로그아웃
|
||||
TokenManager.removeToken();
|
||||
|
||||
// 로그인 페이지가 아닌 경우에만 리다이렉트
|
||||
if (window.location.pathname !== "/login") {
|
||||
console.log("[Auth] 로그인 페이지로 리다이렉트");
|
||||
window.location.href = "/login";
|
||||
}
|
||||
// TOKEN_MISSING, INVALID_TOKEN 등 → 로그인으로
|
||||
redirectToLogin();
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
// 공통 응답 타입
|
||||
// ============================================
|
||||
// 공통 타입 및 헬퍼
|
||||
// ============================================
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
|
|
@ -416,7 +442,6 @@ export interface ApiResponse<T = unknown> {
|
|||
errorCode?: string;
|
||||
}
|
||||
|
||||
// 사용자 정보 타입
|
||||
export interface UserInfo {
|
||||
userId: string;
|
||||
userName: string;
|
||||
|
|
@ -430,13 +455,11 @@ export interface UserInfo {
|
|||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
// 현재 사용자 정보 조회
|
||||
export const getCurrentUser = async (): Promise<ApiResponse<UserInfo>> => {
|
||||
try {
|
||||
const response = await apiClient.get("/auth/me");
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("현재 사용자 정보 조회 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || error.message || "사용자 정보를 가져올 수 없습니다.",
|
||||
|
|
@ -445,7 +468,6 @@ export const getCurrentUser = async (): Promise<ApiResponse<UserInfo>> => {
|
|||
}
|
||||
};
|
||||
|
||||
// API 호출 헬퍼 함수
|
||||
export const apiCall = async <T>(
|
||||
method: "GET" | "POST" | "PUT" | "DELETE",
|
||||
url: string,
|
||||
|
|
@ -459,7 +481,6 @@ export const apiCall = async <T>(
|
|||
});
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
console.error("API 호출 실패:", error);
|
||||
const axiosError = error as AxiosError;
|
||||
return {
|
||||
success: false,
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트
|
|||
import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트
|
||||
import "./v2-split-line/SplitLineRenderer"; // V2 캔버스 분할선
|
||||
import "./v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰
|
||||
import "./v2-bom-item-editor/BomItemEditorRenderer"; // BOM 하위품목 편집기
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
"품목추가" 버튼을 눌러 추가하세요.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
renderNodes(treeData, 0)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 품목 검색 모달 */}
|
||||
<ItemSearchModal
|
||||
open={itemSearchOpen}
|
||||
onClose={() => setItemSearchOpen(false)}
|
||||
onSelect={handleItemSelect}
|
||||
companyCode={companyCode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BomItemEditorComponent;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -123,8 +123,8 @@ export const SplitLineComponent: React.FC<SplitLineComponentProps> = ({
|
|||
const startOffset = dragOffset;
|
||||
const scaleFactor = getScaleFactor();
|
||||
const cw = detectCanvasWidth();
|
||||
const MIN_POS = 50;
|
||||
const MAX_POS = cw - 50;
|
||||
const MIN_POS = Math.max(50, cw * 0.15);
|
||||
const MAX_POS = cw - Math.max(50, cw * 0.15);
|
||||
|
||||
setIsDragging(true);
|
||||
setCanvasSplit({ isDragging: true });
|
||||
|
|
|
|||
Loading…
Reference in New Issue