diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 2c25f7e0..6f412de5 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -1721,18 +1721,28 @@ export class ScreenManagementService { throw new Error("이 화면의 레이아웃을 조회할 권한이 없습니다."); } - // 🆕 V2 테이블 우선 조회 (회사별 → 공통(*)) + // V2 테이블 우선 조회: 기본 레이어(layer_id=1)만 가져옴 + // layer_id 필터 없이 queryOne 하면 조건부 레이어가 반환될 수 있음 let v2Layout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_v2 - WHERE screen_id = $1 AND company_code = $2`, + WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`, [screenId, companyCode], ); - // 회사별 레이아웃 없으면 공통(*) 조회 + // 최고관리자(*): 화면 정의의 company_code로 재조회 + if (!v2Layout && companyCode === "*" && existingScreen.company_code && existingScreen.company_code !== "*") { + v2Layout = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`, + [screenId, existingScreen.company_code], + ); + } + + // 일반 사용자: 회사별 레이아웃 없으면 공통(*) 조회 if (!v2Layout && companyCode !== "*") { v2Layout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_v2 - WHERE screen_id = $1 AND company_code = '*'`, + WHERE screen_id = $1 AND company_code = '*' AND layer_id = 1`, [screenId], ); } @@ -5302,7 +5312,22 @@ export class ScreenManagementService { [screenId, companyCode, layerId], ); - // 회사별 레이어가 없으면 공통(*) 조회 + // 최고관리자(*): 화면 정의의 company_code로 재조회 + if (!layout && companyCode === "*") { + const screenDef = await queryOne<{ company_code: string }>( + `SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, + [screenId], + ); + if (screenDef && screenDef.company_code && screenDef.company_code !== "*") { + layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>( + `SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`, + [screenId, screenDef.company_code, layerId], + ); + } + } + + // 일반 사용자: 회사별 레이어가 없으면 공통(*) 조회 if (!layout && companyCode !== "*") { layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>( `SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2 diff --git a/backend-node/src/utils/componentDefaults.ts b/backend-node/src/utils/componentDefaults.ts index 9a3e7d35..51afbeaf 100644 --- a/backend-node/src/utils/componentDefaults.ts +++ b/backend-node/src/utils/componentDefaults.ts @@ -164,6 +164,7 @@ export const componentDefaults: Record = { "v2-date": { type: "v2-date", webType: "date" }, "v2-repeater": { type: "v2-repeater", webType: "custom" }, "v2-repeat-container": { type: "v2-repeat-container", webType: "custom" }, + "v2-split-line": { type: "v2-split-line", webType: "custom", resizable: true, lineWidth: 4 }, }; /** diff --git a/docs/screen-implementation-guide/01_master-data/bom.md b/docs/screen-implementation-guide/01_master-data/bom.md index 6e626289..14b7c9eb 100644 --- a/docs/screen-implementation-guide/01_master-data/bom.md +++ b/docs/screen-implementation-guide/01_master-data/bom.md @@ -225,7 +225,7 @@ childItemsSection: { "config": { "masterPanel": { "title": "BOM 목록", - "entityId": "bom_header", + "entityId": "bom", "columns": [ { "id": "item_code", "label": "품목코드", "width": 100 }, { "id": "item_name", "label": "품목명", "width": 150 }, diff --git a/frontend/components/auth/AuthGuard.tsx b/frontend/components/auth/AuthGuard.tsx index a02dce73..efb8cd25 100644 --- a/frontend/components/auth/AuthGuard.tsx +++ b/frontend/components/auth/AuthGuard.tsx @@ -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(null); - const [authDebugInfo, setAuthDebugInfo] = useState({}); 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; - // 토큰 확인을 더 정확하게 - 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 (typeof window !== "undefined") { + const token = localStorage.getItem("authToken"); + if (token && !isLoggedIn && !loading) { + 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); - + router.push(redirectTo); 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); - + router.push(redirectTo); 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 ( -
-
-

AuthGuard 로딩 중...

-
{JSON.stringify(authDebugInfo, null, 2)}
+ fallback || ( +
+
+ +

로딩 중...

+
- {fallback ||
로딩 중...
} -
+ ) ); } - // 인증 실패 시 fallback 또는 기본 메시지 표시 if (requireAuth && !isLoggedIn) { - console.log("AuthGuard: 인증 실패 - fallback 표시"); return ( -
-
-

인증 실패

- {redirectCountdown !== null && ( -
- 리다이렉트 카운트다운: {redirectCountdown}초 후 {redirectTo}로 이동 -
- )} -
{JSON.stringify(authDebugInfo, null, 2)}
+ fallback || ( +
+
+ +

인증 확인 중...

+
- {fallback ||
인증이 필요합니다.
} -
+ ) ); } if (requireAdmin && !isAdmin) { - console.log("AuthGuard: 관리자 권한 없음 - fallback 표시"); return ( -
-
-

관리자 권한 없음

- {redirectCountdown !== null && ( -
- 리다이렉트 카운트다운: {redirectCountdown}초 후 {redirectTo}로 이동 -
- )} -
{JSON.stringify(authDebugInfo, null, 2)}
+ fallback || ( +
+

관리자 권한이 필요합니다.

- {fallback ||
관리자 권한이 필요합니다.
} -
+ ) ); } - console.log("AuthGuard: 인증 성공 - 자식 컴포넌트 렌더링"); return <>{children}; } diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 86348d23..16dd5afc 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -1028,6 +1028,7 @@ export const ScreenModal: React.FC = ({ className }) => {
= ({ className }) => { try { setLoading(true); - // 화면 정보와 레이아웃 데이터 로딩 - const [screenInfo, layoutData] = await Promise.all([ + // 화면 정보와 레이아웃 데이터 로딩 (ScreenModal과 동일하게 V2 API 우선) + const [screenInfo, v2LayoutData] = await Promise.all([ screenApi.getScreen(screenId), - screenApi.getLayout(screenId), + screenApi.getLayoutV2(screenId), ]); + // V2 → Legacy 변환 (ScreenModal과 동일한 패턴) + let layoutData: any = null; + if (v2LayoutData && isValidV2Layout(v2LayoutData)) { + layoutData = convertV2ToLegacy(v2LayoutData); + if (layoutData) { + layoutData.screenResolution = v2LayoutData.screenResolution || layoutData.screenResolution; + } + } + + // V2 없으면 기존 API fallback + if (!layoutData) { + layoutData = await screenApi.getLayout(screenId); + } + if (screenInfo && layoutData) { const components = layoutData.components || []; @@ -1372,6 +1386,7 @@ export const EditModal: React.FC = ({ className }) => {
) : screenData ? (
{ - const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 + const { isPreviewMode } = useScreenPreview(); const { userName: authUserName, user: authUser } = useAuth(); - const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트 + const splitPanelContext = useSplitPanelContext(); + + // 캔버스 분할선 글로벌 스토어 구독 + const canvasSplit = useSyncExternalStore(canvasSplitSubscribe, canvasSplitGetSnapshot, canvasSplitGetServerSnapshot); + const canvasSplitSideRef = React.useRef<"left" | "right" | null>(null); + const myScopeIdRef = React.useRef(null); // 외부에서 전달받은 사용자 정보가 있으면 우선 사용 (ScreenModal 등에서) const userName = externalUserName || authUserName; @@ -1079,24 +1090,158 @@ export const InteractiveScreenViewerDynamic: React.FC { + 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 { x: origX, w: defaultW }; + + if (!canvasSplit.active || canvasSplit.canvasWidth <= 0 || !canvasSplit.scopeId) { + 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__"; + } + if (myScopeIdRef.current !== canvasSplit.scopeId) { + return { x: origX, w: defaultW }; + } + + const { initialDividerX, currentDividerX, canvasWidth } = canvasSplit; + const delta = currentDividerX - initialDividerX; + if (Math.abs(delta) < 1) return { x: origX, w: defaultW }; + + const origW = defaultW; + if (canvasSplitSideRef.current === null) { + const componentCenterX = origX + (origW / 2); + canvasSplitSideRef.current = componentCenterX < initialDividerX ? "left" : "right"; + } + + // 영역별 비례 스케일링: 스플릿선이 벽 역할 → 절대 넘어가지 않음 + let newX: number; + let newW: number; + const GAP = 4; // 스플릿선과의 최소 간격 + + if (canvasSplitSideRef.current === "left") { + // 왼쪽 영역: [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 = 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; + } + + newX = Math.max(0, newX); + newW = Math.max(20, newW); + + return { x: newX, w: newW }; + }; + + 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, - left: position?.x || 0, - top: position?.y || 0, // 원래 위치 유지 (음수로 가면 overflow-hidden에 잘림) + ...safeStyleWithoutSize, + // left/top은 반드시 마지막에 (styleWithoutSize가 덮어쓰지 못하게) + left: adjustedX, + top: position?.y || 0, zIndex: position?.z || 1, - ...styleWithoutSize, // width/height 제외한 스타일만 먼저 적용 - width: size?.width || 200, // size의 픽셀 값이 최종 우선순위 + width: isSplitActive ? adjustedW : (size?.width || 200), height: isTableSearchWidget ? "auto" : size?.height || 10, minHeight: isTableSearchWidget ? "48px" : undefined, - // 🆕 라벨이 있으면 overflow visible로 설정하여 라벨이 잘리지 않게 함 - overflow: labelOffset > 0 ? "visible" : 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, width 0.15s ease-out") + : undefined, }; + // 스플릿 조정된 컴포넌트 객체 캐싱 (드래그 끝난 후 최종 렌더링용) + const splitAdjustedComponent = React.useMemo(() => { + if (isSplitActive && adjustedW !== origW) { + return { ...component, size: { ...(component as any).size, width: Math.round(adjustedW) } }; + } + return component; + }, [component, isSplitActive, adjustedW, origW]); + + // 드래그 중 DOM 직접 조작 (React 리렌더 없이 매 프레임 업데이트) + const elRef = React.useRef(null); + React.useEffect(() => { + const compType = (component as any).componentType || ""; + if (type === "component" && compType === "v2-split-line") return; + + const unsubscribe = canvasSplitSubscribeDom((snap) => { + if (!snap.isDragging || !snap.active || !snap.scopeId) return; + if (myScopeIdRef.current !== snap.scopeId) return; + const el = elRef.current; + if (!el) return; + + const origX = position?.x || 0; + const oW = size?.width || 200; + const { initialDividerX, currentDividerX, canvasWidth } = snap; + const delta = currentDividerX - initialDividerX; + if (Math.abs(delta) < 1) return; + + if (canvasSplitSideRef.current === null) { + canvasSplitSideRef.current = (origX + oW / 2) < initialDividerX ? "left" : "right"; + } + + const GAP = 4; + let nx: number, nw: number; + if (canvasSplitSideRef.current === "left") { + const scale = initialDividerX > 0 ? Math.max(20, currentDividerX - GAP) / initialDividerX : 1; + nx = origX * scale; + nw = oW * scale; + if (nx + nw > currentDividerX - GAP) nw = currentDividerX - GAP - nx; + } else { + const irw = canvasWidth - initialDividerX; + const crw = Math.max(20, canvasWidth - currentDividerX - GAP); + const scale = irw > 0 ? crw / irw : 1; + nx = currentDividerX + GAP + (origX - initialDividerX) * scale; + nw = oW * scale; + if (nx < currentDividerX + GAP) nx = currentDividerX + GAP; + if (nx + nw > canvasWidth) nw = canvasWidth - nx; + } + nx = Math.max(0, nx); + nw = Math.max(20, nw); + + el.style.left = `${nx}px`; + el.style.width = `${Math.round(nw)}px`; + el.style.overflow = nw < oW ? "hidden" : ""; + }); + return unsubscribe; + }, [component.id, position?.x, size?.width, type]); + return ( <> -
- {/* 위젯 렌더링 (라벨은 V2Input 내부에서 absolute로 표시됨) */} - {renderInteractiveWidget(component)} +
+ {renderInteractiveWidget(splitAdjustedComponent)}
{/* 팝업 화면 렌더링 */} diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index 45b97b30..b95506d9 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useMemo } from "react"; +import React, { useMemo, useSyncExternalStore } from "react"; import { ComponentData, WebType, WidgetComponent } from "@/types/screen"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import { @@ -17,6 +17,12 @@ import { File, } from "lucide-react"; import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; +import { + subscribe as canvasSplitSubscribe, + getSnapshot as canvasSplitGetSnapshot, + getServerSnapshot as canvasSplitGetServerSnapshot, + subscribeDom as canvasSplitSubscribeDom, +} from "@/lib/registry/components/v2-split-line/canvasSplitStore"; import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext"; // 컴포넌트 렌더러들 자동 등록 @@ -388,10 +394,12 @@ const RealtimePreviewDynamicComponent: React.FC = ({ } : component; - // 🆕 분할 패널 리사이즈 Context + // 기존 분할 패널 리사이즈 Context (레거시 split-panel-layout용) const splitPanelContext = useSplitPanel(); - // 버튼 컴포넌트인지 확인 (분할 패널 위치 조정 대상) + // 캔버스 분할선 글로벌 스토어 (useSyncExternalStore로 직접 구독) + const canvasSplit = useSyncExternalStore(canvasSplitSubscribe, canvasSplitGetSnapshot, canvasSplitGetServerSnapshot); + const componentType = (component as any).componentType || ""; const componentId = (component as any).componentId || ""; const widgetType = (component as any).widgetType || ""; @@ -402,137 +410,157 @@ const RealtimePreviewDynamicComponent: React.FC = ({ (["button-primary", "button-secondary"].includes(componentType) || ["button-primary", "button-secondary"].includes(componentId))); - // 🆕 버튼이 처음 렌더링될 때의 분할 패널 정보를 기억 (기준점) + // 레거시 분할 패널용 refs const initialPanelRatioRef = React.useRef(null); const initialPanelIdRef = React.useRef(null); - // 버튼이 좌측 패널에 속하는지 여부 (한번 설정되면 유지) const isInLeftPanelRef = React.useRef(null); - // 🆕 분할 패널 위 버튼 위치 자동 조정 - const calculateButtonPosition = () => { - // 버튼이 아니거나 분할 패널 컴포넌트 자체인 경우 조정하지 않음 + // 캔버스 분할선 좌/우 판정 (한 번만) + const canvasSplitSideRef = React.useRef<"left" | "right" | null>(null); + // 스코프 체크 캐시 (DOM 쿼리 최소화) + const myScopeIdRef = React.useRef(null); + + const calculateSplitAdjustedPosition = () => { + const isSplitLineComponent = + type === "component" && componentType === "v2-split-line"; + + if (isSplitLineComponent) { + return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false }; + } + + // === 1. 캔버스 분할선 (글로벌 스토어) === + if (canvasSplit.active && canvasSplit.canvasWidth > 0 && canvasSplit.scopeId) { + if (myScopeIdRef.current === null) { + const el = document.getElementById(`component-${id}`); + const container = el?.closest("[data-screen-runtime]"); + myScopeIdRef.current = container?.getAttribute("data-split-scope") || "__none__"; + } + if (myScopeIdRef.current !== canvasSplit.scopeId) { + return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false }; + } + const { initialDividerX, currentDividerX, canvasWidth, isDragging: splitDragging } = canvasSplit; + const delta = currentDividerX - initialDividerX; + + if (canvasSplitSideRef.current === null) { + const origW = size?.width || 100; + const componentCenterX = position.x + (origW / 2); + canvasSplitSideRef.current = componentCenterX < initialDividerX ? "left" : "right"; + } + + if (Math.abs(delta) < 1) { + return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: true, isDraggingSplitPanel: splitDragging }; + } + + // 영역별 비례 스케일링: 스플릿선이 벽 역할 → 절대 넘어가지 않음 + const origW = size?.width || 100; + const GAP = 4; + let adjustedX: number; + let adjustedW: number; + + if (canvasSplitSideRef.current === "left") { + 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 = 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, adjustedX); + adjustedW = Math.max(20, adjustedW); + + return { adjustedPositionX: adjustedX, adjustedWidth: adjustedW, isOnSplitPanel: true, isDraggingSplitPanel: splitDragging }; + } + + // === 2. 레거시 분할 패널 (Context) - 버튼 전용 === const isSplitPanelComponent = type === "component" && ["split-panel-layout", "split-panel-layout2"].includes(componentType); - if (!isButtonComponent || isSplitPanelComponent) { - return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false }; + if (isSplitPanelComponent) { + return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false }; + } + + if (!isButtonComponent) { + return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false }; } const componentWidth = size?.width || 100; const componentHeight = size?.height || 40; - // 분할 패널 위에 있는지 확인 (원래 위치 기준) const overlap = splitPanelContext.getOverlappingSplitPanel(position.x, position.y, componentWidth, componentHeight); - // 분할 패널 위에 없으면 기준점 초기화 if (!overlap) { if (initialPanelIdRef.current !== null) { initialPanelRatioRef.current = null; 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; - // 🆕 초기 기준 비율 및 좌측 패널 소속 여부 설정 (처음 한 번만) if (initialPanelIdRef.current !== overlap.panelId) { - initialPanelRatioRef.current = panel.leftWidthPercent; + initialPanelRatioRef.current = panel.initialLeftWidthPercent; initialPanelIdRef.current = overlap.panelId; - - // 초기 배치 시 좌측 패널에 있는지 확인 (초기 비율 기준으로 계산) - // 현재 비율이 아닌, 버튼 원래 위치가 초기 좌측 패널 영역 안에 있었는지 판단 - const initialLeftPanelWidth = (panel.width * panel.leftWidthPercent) / 100; + const initialDividerX = panel.x + (panel.width * panel.initialLeftWidthPercent) / 100; const componentCenterX = position.x + componentWidth / 2; - const relativeX = componentCenterX - panel.x; - const wasInLeftPanel = relativeX < initialLeftPanelWidth; - - isInLeftPanelRef.current = wasInLeftPanel; - console.log("📌 [버튼 기준점 설정]:", { - componentId: component.id, - panelId: overlap.panelId, - initialRatio: panel.leftWidthPercent, - isInLeftPanel: wasInLeftPanel, - buttonCenterX: componentCenterX, - leftPanelWidth: initialLeftPanelWidth, - }); + isInLeftPanelRef.current = componentCenterX < initialDividerX; } - // 좌측 패널 소속이 아니면 조정하지 않음 (초기 배치 기준) - if (!isInLeftPanelRef.current) { - return { - adjustedPositionX: position.x, - isOnSplitPanel: true, - isDraggingSplitPanel: panel.isDragging, - }; - } + const baseRatio = initialPanelRatioRef.current ?? panel.initialLeftWidthPercent; + const initialDividerX = panel.x + (panel.width * baseRatio) / 100; + const currentDividerX = panel.x + (panel.width * panel.leftWidthPercent) / 100; + const dividerDelta = currentDividerX - initialDividerX; - // 초기 기준 비율 (버튼이 처음 배치될 때의 비율) - const baseRatio = initialPanelRatioRef.current ?? panel.leftWidthPercent; - - // 기준 비율 대비 현재 비율로 분할선 위치 계산 - const baseDividerX = panel.x + (panel.width * baseRatio) / 100; // 초기 분할선 위치 - const currentDividerX = panel.x + (panel.width * panel.leftWidthPercent) / 100; // 현재 분할선 위치 - - // 분할선 이동량 (px) - const dividerDelta = currentDividerX - baseDividerX; - - // 변화가 없으면 원래 위치 반환 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 }; } - // 🆕 버튼도 분할선과 같은 양만큼 이동 - // 분할선이 왼쪽으로 100px 이동하면, 버튼도 왼쪽으로 100px 이동 - const adjustedX = position.x + dividerDelta; - - console.log("📍 [버튼 위치 조정]:", { - componentId: component.id, - originalX: position.x, - adjustedX, - dividerDelta, - baseRatio, - currentRatio: panel.leftWidthPercent, - baseDividerX, - currentDividerX, - isDragging: 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 } = calculateButtonPosition(); + const { adjustedPositionX, adjustedWidth: splitAdjustedWidth, isOnSplitPanel, isDraggingSplitPanel } = calculateSplitAdjustedPosition(); - // 🆕 리사이즈 크기가 있으면 우선 사용 - // (size가 업데이트되면 위 useEffect에서 resizeSize를 null로 설정) 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`, // 🆕 조정된 X 좌표 사용 + left: `${adjustedPositionX}px`, top: `${position.y}px`, - ...componentStyle, // componentStyle 전체 적용 (DynamicComponentRenderer에서 이미 size가 변환됨) - width: displayWidth, // 🆕 리사이즈 중이면 resizeSize 사용 - height: displayHeight, // 🆕 리사이즈 중이면 resizeSize 사용 + ...componentStyle, + width: splitAdjustedWidth !== null ? `${splitAdjustedWidth}px` : displayWidth, + height: displayHeight, zIndex: component.type === "layout" ? 1 : position.z || 2, right: undefined, - // 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동, 리사이즈 중에도 트랜지션 없음 + overflow: isSplitShrunk ? "hidden" as const : undefined, + willChange: canvasSplit.isDragging && isSplitActive ? "left, width" as const : undefined, transition: isResizing ? "none" : - isOnSplitPanel && isButtonComponent ? (isDraggingSplitPanel ? "none" : "left 0.1s ease-out") : undefined, + isOnSplitPanel ? (isDraggingSplitPanel ? "none" : "left 0.15s ease-out, width 0.15s ease-out") : undefined, }; // 크기 정보는 필요시에만 디버깅 (개발 중 문제 발생 시 주석 해제) @@ -576,6 +604,60 @@ const RealtimePreviewDynamicComponent: React.FC = ({ onDragEnd?.(); }; + const splitAdjustedComp = React.useMemo(() => { + if (isSplitShrunk && splitAdjustedWidth !== null) { + return { ...enhancedComponent, size: { ...(enhancedComponent as any).size, width: Math.round(splitAdjustedWidth) } }; + } + return enhancedComponent; + }, [enhancedComponent, isSplitShrunk, splitAdjustedWidth]); + + // 드래그 중 DOM 직접 조작 (React 리렌더 없이 매 프레임 업데이트) + React.useEffect(() => { + const isSplitLine = type === "component" && componentType === "v2-split-line"; + if (isSplitLine) return; + + const unsubscribe = canvasSplitSubscribeDom((snap) => { + if (!snap.isDragging || !snap.active || !snap.scopeId) return; + if (myScopeIdRef.current !== snap.scopeId) return; + const el = outerDivRef.current; + if (!el) return; + + const origX = position.x; + const oW = size?.width || 100; + const { initialDividerX, currentDividerX, canvasWidth } = snap; + const delta = currentDividerX - initialDividerX; + if (Math.abs(delta) < 1) return; + + if (canvasSplitSideRef.current === null) { + canvasSplitSideRef.current = (origX + oW / 2) < initialDividerX ? "left" : "right"; + } + + const GAP = 4; + let nx: number, nw: number; + if (canvasSplitSideRef.current === "left") { + const scale = initialDividerX > 0 ? Math.max(20, currentDividerX - GAP) / initialDividerX : 1; + nx = origX * scale; + nw = oW * scale; + if (nx + nw > currentDividerX - GAP) nw = currentDividerX - GAP - nx; + } else { + const irw = canvasWidth - initialDividerX; + const crw = Math.max(20, canvasWidth - currentDividerX - GAP); + const scale = irw > 0 ? crw / irw : 1; + nx = currentDividerX + GAP + (origX - initialDividerX) * scale; + nw = oW * scale; + if (nx < currentDividerX + GAP) nx = currentDividerX + GAP; + if (nx + nw > canvasWidth) nw = canvasWidth - nx; + } + nx = Math.max(0, nx); + nw = Math.max(20, nw); + + el.style.left = `${nx}px`; + el.style.width = `${Math.round(nw)}px`; + el.style.overflow = nw < oW ? "hidden" : ""; + }); + return unsubscribe; + }, [id, position.x, size?.width, type, componentType]); + return (
= ({ style={{ width: "100%", maxWidth: "100%" }} > = ({ "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 = ({ if (componentId === "v2-list") { extraProps.currentTableName = currentTableName; } + if (componentId === "v2-bom-item-editor") { + extraProps.currentTableName = currentTableName; + extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName; + } return (
diff --git a/frontend/components/v2/config-panels/V2BomItemEditorConfigPanel.tsx b/frontend/components/v2/config-panels/V2BomItemEditorConfigPanel.tsx new file mode 100644 index 00000000..51c9a4f9 --- /dev/null +++ b/frontend/components/v2/config-panels/V2BomItemEditorConfigPanel.tsx @@ -0,0 +1,1088 @@ +"use client"; + +/** + * BOM 하위품목 편집기 설정 패널 + * + * V2RepeaterConfigPanel 구조를 기반으로 구현: + * - 기본 탭: 저장 테이블 + 엔티티 선택 + 트리 설정 + 기능 옵션 + * - 컬럼 탭: 소스 표시 컬럼 + 저장 입력 컬럼 + 선택된 컬럼 상세 + */ + +import React, { useState, useEffect, useMemo, useCallback } from "react"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Database, + Link2, + Trash2, + GripVertical, + ArrowRight, + ChevronDown, + ChevronRight, + Eye, + EyeOff, + Check, + ChevronsUpDown, + GitBranch, +} from "lucide-react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { tableTypeApi } from "@/lib/api/screen"; +import { tableManagementApi } from "@/lib/api/tableManagement"; +import { cn } from "@/lib/utils"; + +// ─── 타입 (리피터와 동일한 패턴) ─── + +interface TableRelation { + tableName: string; + tableLabel: string; + foreignKeyColumn: string; + referenceColumn: string; +} + +interface ColumnOption { + columnName: string; + displayName: string; + inputType?: string; + detailSettings?: { + codeGroup?: string; + referenceTable?: string; + referenceColumn?: string; + displayColumn?: string; + format?: string; + }; +} + +interface EntityColumnOption { + columnName: string; + displayName: string; + referenceTable?: string; + referenceColumn?: string; + displayColumn?: string; +} + +interface BomColumnConfig { + key: string; + title: string; + width?: string; + visible?: boolean; + editable?: boolean; + hidden?: boolean; + inputType?: string; + isSourceDisplay?: boolean; + detailSettings?: { + codeGroup?: string; + referenceTable?: string; + referenceColumn?: string; + displayColumn?: string; + format?: string; + }; +} + +interface BomItemEditorConfig { + // 저장 테이블 설정 (리피터 패턴) + useCustomTable?: boolean; + mainTableName?: string; + foreignKeyColumn?: string; + foreignKeySourceColumn?: string; + + // 트리 구조 설정 + parentKeyColumn?: string; + + // 엔티티 (품목 참조) 설정 + dataSource?: { + sourceTable?: string; + foreignKey?: string; + referenceKey?: string; + displayColumn?: string; + }; + + // 컬럼 설정 + columns: BomColumnConfig[]; + + // 기능 옵션 + features?: { + showAddButton?: boolean; + showDeleteButton?: boolean; + inlineEdit?: boolean; + showRowNumber?: boolean; + maxDepth?: number; + }; +} + +interface V2BomItemEditorConfigPanelProps { + config: BomItemEditorConfig; + onChange: (config: BomItemEditorConfig) => void; + currentTableName?: string; + screenTableName?: string; +} + +// ─── 메인 패널 ─── + +export function V2BomItemEditorConfigPanel({ + config: propConfig, + onChange, + currentTableName: propCurrentTableName, + screenTableName, +}: V2BomItemEditorConfigPanelProps) { + const currentTableName = screenTableName || propCurrentTableName; + + const config: BomItemEditorConfig = useMemo( + () => ({ + columns: [], + ...propConfig, + dataSource: { ...propConfig?.dataSource }, + features: { + showAddButton: true, + showDeleteButton: true, + inlineEdit: false, + showRowNumber: false, + maxDepth: 3, + ...propConfig?.features, + }, + }), + [propConfig], + ); + + // ─── 상태 ─── + const [currentTableColumns, setCurrentTableColumns] = useState([]); + const [entityColumns, setEntityColumns] = useState([]); + const [sourceTableColumns, setSourceTableColumns] = useState([]); + const [allTables, setAllTables] = useState<{ tableName: string; displayName: string }[]>([]); + const [relatedTables, setRelatedTables] = useState([]); + const [loadingColumns, setLoadingColumns] = useState(false); + const [loadingSourceColumns, setLoadingSourceColumns] = useState(false); + const [loadingTables, setLoadingTables] = useState(false); + const [loadingRelations, setLoadingRelations] = useState(false); + const [tableComboboxOpen, setTableComboboxOpen] = useState(false); + const [expandedColumn, setExpandedColumn] = useState(null); + + // ─── 업데이트 헬퍼 (리피터 패턴) ─── + const updateConfig = useCallback( + (updates: Partial) => { + onChange({ ...config, ...updates }); + }, + [config, onChange], + ); + + const updateFeatures = useCallback( + (field: string, value: any) => { + updateConfig({ features: { ...config.features, [field]: value } }); + }, + [config.features, updateConfig], + ); + + // ─── 전체 테이블 목록 로드 (리피터 동일) ─── + useEffect(() => { + const loadTables = async () => { + setLoadingTables(true); + try { + const response = await tableManagementApi.getTableList(); + if (response.success && response.data) { + setAllTables( + response.data.map((t: any) => ({ + tableName: t.tableName || t.table_name, + displayName: t.displayName || t.table_label || t.tableName || t.table_name, + })), + ); + } + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + } finally { + setLoadingTables(false); + } + }; + loadTables(); + }, []); + + // ─── 연관 테이블 로드 (리피터 동일) ─── + useEffect(() => { + const loadRelatedTables = async () => { + const baseTable = currentTableName || config.mainTableName; + if (!baseTable) { + setRelatedTables([]); + return; + } + setLoadingRelations(true); + try { + const { apiClient } = await import("@/lib/api/client"); + const allRelations: TableRelation[] = []; + + if (currentTableName) { + const response = await apiClient.get( + `/table-management/columns/${currentTableName}/referenced-by`, + ); + if (response.data.success && response.data.data) { + allRelations.push( + ...response.data.data.map((rel: any) => ({ + tableName: rel.tableName || rel.table_name, + tableLabel: rel.tableLabel || rel.table_label || rel.tableName || rel.table_name, + foreignKeyColumn: rel.columnName || rel.column_name, + referenceColumn: rel.referenceColumn || rel.reference_column || "id", + })), + ); + } + } + + if (config.mainTableName && config.mainTableName !== currentTableName) { + const response2 = await apiClient.get( + `/table-management/columns/${config.mainTableName}/referenced-by`, + ); + if (response2.data.success && response2.data.data) { + response2.data.data.forEach((rel: any) => { + const tn = rel.tableName || rel.table_name; + if (!allRelations.some((r) => r.tableName === tn)) { + allRelations.push({ + tableName: tn, + tableLabel: rel.tableLabel || rel.table_label || tn, + foreignKeyColumn: rel.columnName || rel.column_name, + referenceColumn: rel.referenceColumn || rel.reference_column || "id", + }); + } + }); + } + } + + setRelatedTables(allRelations); + } catch (error) { + console.error("연관 테이블 로드 실패:", error); + setRelatedTables([]); + } finally { + setLoadingRelations(false); + } + }; + loadRelatedTables(); + }, [currentTableName, config.mainTableName]); + + // ─── 저장 테이블 선택 (리피터 동일) ─── + const handleSaveTableSelect = useCallback( + (tableName: string) => { + if (!tableName || tableName === currentTableName) { + updateConfig({ + useCustomTable: false, + mainTableName: undefined, + foreignKeyColumn: undefined, + foreignKeySourceColumn: undefined, + }); + return; + } + + const relation = relatedTables.find((r) => r.tableName === tableName); + if (relation) { + updateConfig({ + useCustomTable: true, + mainTableName: tableName, + foreignKeyColumn: relation.foreignKeyColumn, + foreignKeySourceColumn: relation.referenceColumn, + }); + } else { + updateConfig({ + useCustomTable: true, + mainTableName: tableName, + foreignKeyColumn: undefined, + foreignKeySourceColumn: "id", + }); + } + }, + [currentTableName, relatedTables, updateConfig], + ); + + // ─── 저장 테이블 컬럼 로드 (리피터 동일) ─── + const targetTableForColumns = + config.useCustomTable && config.mainTableName ? config.mainTableName : currentTableName; + + useEffect(() => { + const loadColumns = async () => { + if (!targetTableForColumns) { + setCurrentTableColumns([]); + setEntityColumns([]); + return; + } + setLoadingColumns(true); + try { + const columnData = await tableTypeApi.getColumns(targetTableForColumns); + const cols: ColumnOption[] = []; + const entityCols: EntityColumnOption[] = []; + + for (const c of columnData) { + let detailSettings: any = null; + if (c.detailSettings) { + try { + detailSettings = + typeof c.detailSettings === "string" ? JSON.parse(c.detailSettings) : c.detailSettings; + } catch { + // ignore + } + } + + const col: ColumnOption = { + columnName: c.columnName || c.column_name, + displayName: c.displayName || c.columnLabel || c.columnName || c.column_name, + inputType: c.inputType || c.input_type, + detailSettings: detailSettings + ? { + codeGroup: detailSettings.codeGroup, + referenceTable: detailSettings.referenceTable, + referenceColumn: detailSettings.referenceColumn, + displayColumn: detailSettings.displayColumn, + format: detailSettings.format, + } + : undefined, + }; + cols.push(col); + + if (col.inputType === "entity") { + const refTable = detailSettings?.referenceTable || c.referenceTable; + if (refTable) { + entityCols.push({ + columnName: col.columnName, + displayName: col.displayName, + referenceTable: refTable, + referenceColumn: detailSettings?.referenceColumn || c.referenceColumn || "id", + displayColumn: detailSettings?.displayColumn || c.displayColumn, + }); + } + } + } + + setCurrentTableColumns(cols); + setEntityColumns(entityCols); + } catch (error) { + console.error("컬럼 로드 실패:", error); + setCurrentTableColumns([]); + setEntityColumns([]); + } finally { + setLoadingColumns(false); + } + }; + loadColumns(); + }, [targetTableForColumns]); + + // ─── 소스(엔티티) 테이블 컬럼 로드 (리피터 동일) ─── + useEffect(() => { + const loadSourceColumns = async () => { + const sourceTable = config.dataSource?.sourceTable; + if (!sourceTable) { + setSourceTableColumns([]); + return; + } + setLoadingSourceColumns(true); + try { + const columnData = await tableTypeApi.getColumns(sourceTable); + setSourceTableColumns( + columnData.map((c: any) => ({ + columnName: c.columnName || c.column_name, + displayName: c.displayName || c.columnLabel || c.columnName || c.column_name, + inputType: c.inputType || c.input_type, + })), + ); + } catch (error) { + console.error("소스 테이블 컬럼 로드 실패:", error); + setSourceTableColumns([]); + } finally { + setLoadingSourceColumns(false); + } + }; + loadSourceColumns(); + }, [config.dataSource?.sourceTable]); + + // ─── 엔티티 컬럼 선택 시 소스 테이블 자동 설정 (리피터 동일) ─── + const handleEntityColumnSelect = (columnName: string) => { + const selectedEntity = entityColumns.find((c) => c.columnName === columnName); + if (selectedEntity) { + updateConfig({ + dataSource: { + ...config.dataSource, + sourceTable: selectedEntity.referenceTable || "", + foreignKey: selectedEntity.columnName, + referenceKey: selectedEntity.referenceColumn || "id", + displayColumn: selectedEntity.displayColumn, + }, + }); + } + }; + + // ─── 컬럼 토글 (리피터 동일) ─── + const toggleInputColumn = (column: ColumnOption) => { + const exists = config.columns.findIndex((c) => c.key === column.columnName && !c.isSourceDisplay); + if (exists >= 0) { + updateConfig({ columns: config.columns.filter((c) => c.key !== column.columnName || c.isSourceDisplay) }); + } else { + const newCol: BomColumnConfig = { + key: column.columnName, + title: column.displayName, + width: "auto", + visible: true, + editable: true, + inputType: column.inputType || "text", + detailSettings: column.detailSettings, + }; + updateConfig({ columns: [...config.columns, newCol] }); + } + }; + + const toggleSourceDisplayColumn = (column: ColumnOption) => { + const exists = config.columns.some((c) => c.key === column.columnName && c.isSourceDisplay); + if (exists) { + updateConfig({ columns: config.columns.filter((c) => c.key !== column.columnName) }); + } else { + const newCol: BomColumnConfig = { + key: column.columnName, + title: column.displayName, + width: "auto", + visible: true, + editable: false, + isSourceDisplay: true, + }; + updateConfig({ columns: [...config.columns, newCol] }); + } + }; + + const isColumnAdded = (columnName: string) => + config.columns.some((c) => c.key === columnName && !c.isSourceDisplay); + + const isSourceColumnSelected = (columnName: string) => + config.columns.some((c) => c.key === columnName && c.isSourceDisplay); + + const updateColumnProp = (key: string, field: keyof BomColumnConfig, value: any) => { + updateConfig({ + columns: config.columns.map((col) => (col.key === key ? { ...col, [field]: value } : col)), + }); + }; + + // FK 컬럼 제외한 입력 가능 컬럼 + const inputableColumns = useMemo(() => { + const fkColumn = config.dataSource?.foreignKey; + return currentTableColumns.filter( + (col) => col.columnName !== fkColumn && col.inputType !== "entity", + ); + }, [currentTableColumns, config.dataSource?.foreignKey]); + + // ─── 렌더링 ─── + return ( +
+ + + + 기본 + + + 컬럼 + + + + {/* ─── 기본 설정 탭 ─── */} + + {/* 저장 대상 테이블 (리피터 동일) */} +
+ + +
+
+ +
+

+ {config.useCustomTable && config.mainTableName + ? allTables.find((t) => t.tableName === config.mainTableName)?.displayName || + config.mainTableName + : currentTableName || "미설정"} +

+ {config.useCustomTable && config.mainTableName && config.foreignKeyColumn && ( +

+ FK: {config.foreignKeyColumn} → {currentTableName}. + {config.foreignKeySourceColumn || "id"} +

+ )} + {!config.useCustomTable && currentTableName && ( +

화면 메인 테이블

+ )} +
+
+
+ + {/* 테이블 Combobox (리피터 동일) */} + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {currentTableName && ( + + { + handleSaveTableSelect(currentTableName); + setTableComboboxOpen(false); + }} + className="text-xs" + > + + + {currentTableName} + (기본) + + + )} + + {relatedTables.length > 0 && ( + + {relatedTables.map((rel) => ( + { + handleSaveTableSelect(rel.tableName); + setTableComboboxOpen(false); + }} + className="text-xs" + > + + + {rel.tableLabel} + + ({rel.foreignKeyColumn}) + + + ))} + + )} + + + {allTables + .filter( + (t) => + t.tableName !== currentTableName && + !relatedTables.some((r) => r.tableName === t.tableName), + ) + .map((table) => ( + { + handleSaveTableSelect(table.tableName); + setTableComboboxOpen(false); + }} + className="text-xs" + > + + + {table.displayName} + + ))} + + + + + + + {/* FK 직접 입력 (연관 없는 테이블 선택 시) */} + {config.useCustomTable && + config.mainTableName && + currentTableName && + !relatedTables.some((r) => r.tableName === config.mainTableName) && ( +
+

+ 화면 테이블({currentTableName})과의 엔티티 관계가 없습니다. FK 컬럼을 직접 + 입력하세요. +

+
+
+ + updateConfig({ foreignKeyColumn: e.target.value })} + placeholder="예: bom_id" + className="h-7 text-xs" + /> +
+
+ + updateConfig({ foreignKeySourceColumn: e.target.value })} + placeholder="id" + className="h-7 text-xs" + /> +
+
+
+ )} +
+ + + + {/* 트리 구조 설정 (BOM 전용) */} +
+
+ + +
+

+ 계층 구조를 위한 자기 참조 FK 컬럼을 선택하세요 +

+ + {currentTableColumns.length > 0 ? ( + + ) : ( +
+

+ {loadingColumns ? "로딩 중..." : "저장 테이블을 먼저 선택하세요"} +

+
+ )} + + {/* 최대 깊이 */} +
+ + updateFeatures("maxDepth", parseInt(e.target.value) || 3)} + className="h-7 w-20 text-xs" + /> +
+
+ + + + {/* 엔티티 선택 (리피터 모달 모드와 동일) */} +
+ +

+ 품목 검색 시 참조할 엔티티를 선택하세요 (FK만 저장됨) +

+ + {entityColumns.length > 0 ? ( + + ) : ( +
+

+ {loadingColumns + ? "로딩 중..." + : !targetTableForColumns + ? "저장 테이블을 먼저 선택하세요" + : "엔티티 타입 컬럼이 없습니다"} +

+
+ )} + + {config.dataSource?.sourceTable && ( +
+

선택된 엔티티

+
+

검색 테이블: {config.dataSource.sourceTable}

+

저장 컬럼: {config.dataSource.foreignKey} (FK)

+
+
+ )} +
+ + + + {/* 기능 옵션 */} +
+ +
+
+ updateFeatures("showAddButton", !!checked)} + /> + +
+
+ updateFeatures("showDeleteButton", !!checked)} + /> + +
+
+ updateFeatures("inlineEdit", !!checked)} + /> + +
+
+ updateFeatures("showRowNumber", !!checked)} + /> + +
+
+
+ + {/* 메인 화면 테이블 참고 */} + {currentTableName && ( + <> + +
+ +
+

{currentTableName}

+

+ 컬럼 {currentTableColumns.length}개 / 엔티티 {entityColumns.length}개 +

+
+
+ + )} +
+ + {/* ─── 컬럼 설정 탭 (리피터 동일 패턴) ─── */} + +
+ +

+ 표시할 소스 컬럼과 입력 컬럼을 선택하세요 +

+ + {/* 소스 테이블 컬럼 (표시용) */} + {config.dataSource?.sourceTable && ( + <> +
+ + 소스 테이블 ({config.dataSource.sourceTable}) - 표시용 +
+ {loadingSourceColumns ? ( +

로딩 중...

+ ) : sourceTableColumns.length === 0 ? ( +

컬럼 정보가 없습니다

+ ) : ( +
+ {sourceTableColumns.map((column) => ( +
toggleSourceDisplayColumn(column)} + > + toggleSourceDisplayColumn(column)} + className="pointer-events-none h-3.5 w-3.5" + /> + + {column.displayName} + 표시 +
+ ))} +
+ )} + + )} + + {/* 저장 테이블 컬럼 (입력용) */} +
+ + 저장 테이블 ({targetTableForColumns || "미선택"}) - 입력용 +
+ {loadingColumns ? ( +

로딩 중...

+ ) : inputableColumns.length === 0 ? ( +

컬럼 정보가 없습니다

+ ) : ( +
+ {inputableColumns.map((column) => ( +
toggleInputColumn(column)} + > + toggleInputColumn(column)} + className="pointer-events-none h-3.5 w-3.5" + /> + + {column.displayName} + {column.inputType} +
+ ))} +
+ )} +
+ + {/* 선택된 컬럼 상세 (리피터 동일 패턴) */} + {config.columns.length > 0 && ( + <> + +
+ +
+ {config.columns.map((col, index) => ( +
+
e.dataTransfer.setData("columnIndex", String(index))} + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + const fromIndex = parseInt(e.dataTransfer.getData("columnIndex"), 10); + if (fromIndex !== index) { + const newColumns = [...config.columns]; + const [movedCol] = newColumns.splice(fromIndex, 1); + newColumns.splice(index, 0, movedCol); + updateConfig({ columns: newColumns }); + } + }} + > + + + {!col.isSourceDisplay && ( + + )} + + {col.isSourceDisplay ? ( + + ) : ( + + )} + + updateColumnProp(col.key, "title", e.target.value)} + placeholder="제목" + className="h-6 flex-1 text-xs" + /> + + {!col.isSourceDisplay && ( + + )} + + {!col.isSourceDisplay && ( + + updateColumnProp(col.key, "editable", !!checked) + } + title="편집 가능" + /> + )} + + +
+ + {/* 확장 상세 */} + {!col.isSourceDisplay && expandedColumn === col.key && ( +
+
+ + updateColumnProp(col.key, "width", e.target.value)} + placeholder="auto, 100px, 20%" + className="h-6 text-xs" + /> +
+
+ )} +
+ ))} +
+
+ + )} +
+
+
+ ); +} + +V2BomItemEditorConfigPanel.displayName = "V2BomItemEditorConfigPanel"; + +export default V2BomItemEditorConfigPanel; diff --git a/frontend/hooks/useAuth.ts b/frontend/hooks/useAuth.ts index 29752559..737710d3 100644 --- a/frontend/hooks/useAuth.ts +++ b/frontend/hooks/useAuth.ts @@ -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 { success: boolean; message: string; @@ -50,9 +46,7 @@ interface ApiResponse { 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(null); const [authStatus, setAuthStatus] = useState({ isLoggedIn: false, @@ -106,8 +97,6 @@ export const useAuth = () => { const [error, setError] = useState(null); const initializedRef = useRef(false); - // API 기본 URL 설정 (동적으로 결정) - /** * 현재 사용자 정보 조회 */ @@ -116,26 +105,19 @@ export const useAuth = () => { const response = await apiCall("GET", "/auth/me"); if (response.success && response.data) { - // 사용자 로케일 정보도 함께 조회하여 전역 저장 + // 사용자 로케일 정보 조회 try { const localeResponse = await apiCall("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,89 @@ export const useAuth = () => { try { const response = await apiCall("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()]); - setUser(userInfo); - - // 관리자 권한 확인 로직 개선 - let finalAuthStatus = authStatusData; if (userInfo) { - // 사용자 정보를 기반으로 관리자 권한 추가 확인 + setUser(userInfo); + const isAdminFromUser = userInfo.userId === "plm_admin" || userInfo.userType === "ADMIN"; - finalAuthStatus = { + const finalAuthStatus = { isLoggedIn: authStatusData.isLoggedIn, isAdmin: authStatusData.isAdmin || isAdminFromUser, }; - } - setAuthStatus(finalAuthStatus); + setAuthStatus(finalAuthStatus); - // console.log("✅ 최종 사용자 상태:", { - // userId: userInfo?.userId, - // userName: userInfo?.userName, - // companyCode: userInfo?.companyCode || userInfo?.company_code, - // }); - - // 디버깅용 로그 - - // 로그인되지 않은 상태인 경우 토큰 제거 (리다이렉트는 useEffect에서 처리) - if (!finalAuthStatus.isLoggedIn) { - TokenManager.removeToken(); - setUser(null); - setAuthStatus({ isLoggedIn: false, isAdmin: false }); + // API 결과가 비인증이면 상태만 업데이트 (리다이렉트는 client.ts가 처리) + if (!finalAuthStatus.isLoggedIn) { + TokenManager.removeToken(); + setUser(null); + setAuthStatus({ isLoggedIn: false, isAdmin: false }); + } } else { + // userInfo 조회 실패 → 토큰에서 최소 정보 추출하여 유지 + 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 }); + } } - } catch (apiError) { - console.error("API 호출 실패:", apiError); - - // API 호출 실패 시에도 토큰이 있으면 임시로 인증된 상태로 처리 - - // 토큰에서 사용자 정보 추출 시도 + } catch { + // API 호출 전체 실패 → 토큰 기반 임시 인증 유지 시도 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 +232,20 @@ 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); + } catch { setError("사용자 정보를 불러오는데 실패했습니다."); - - // 오류 발생 시 토큰 제거 및 로그인 페이지로 리다이렉트 - TokenManager.removeToken(); setUser(null); setAuthStatus({ isLoggedIn: false, isAdmin: false }); - setTimeout(() => { - router.push("/login"); - }, 3000); } 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("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(); - } - - return response; - }; - - return () => { - window.fetch = originalFetch; - }; - }, [router]); + 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 { - // 상태 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), }; }; diff --git a/frontend/hooks/useMenu.ts b/frontend/hooks/useMenu.ts index 2258f6c4..32fb3d4e 100644 --- a/frontend/hooks/useMenu.ts +++ b/frontend/hooks/useMenu.ts @@ -3,15 +3,16 @@ import { useState, useEffect, useCallback } from "react"; import { useRouter } from "next/navigation"; import { MenuItem, MenuState } from "@/types/menu"; -import { LAYOUT_CONFIG } from "@/constants/layout"; +import { apiClient } from "@/lib/api/client"; /** * 메뉴 관련 비즈니스 로직을 관리하는 커스텀 훅 + * - API 호출은 apiClient를 통해 수행 (토큰 관리/401 처리 자동) + * - 메뉴 로드 실패 시 토큰 삭제/리다이렉트하지 않음 (client.ts가 처리) */ export const useMenu = (user: any, authLoading: boolean) => { const router = useRouter(); - // 상태 관리 const [menuState, setMenuState] = useState({ menuList: [], expandedMenus: new Set(), @@ -36,103 +37,58 @@ export const useMenu = (user: any, authLoading: boolean) => { * 메뉴 트리 구조 생성 */ const buildMenuTree = useCallback((menuItems: MenuItem[]): MenuItem[] => { - console.log("빌드 메뉴 트리 - 원본 메뉴 아이템들:", menuItems); - const menuMap = new Map(); const rootMenus: MenuItem[] = []; - // 모든 메뉴를 맵에 저장 (ID를 문자열로 변환) menuItems.forEach((menu) => { const objId = String(menu.OBJID); const parentId = String(menu.PARENT_OBJ_ID); - console.log(`메뉴 처리: ${menu.MENU_NAME_KOR}, OBJID: ${objId}, PARENT_OBJ_ID: ${parentId}`); menuMap.set(objId, { ...menu, OBJID: objId, PARENT_OBJ_ID: parentId, children: [] }); }); - console.log("메뉴 맵 생성 완료, 총 메뉴 수:", menuMap.size); - - // 부모-자식 관계 설정 menuItems.forEach((menu) => { const objId = String(menu.OBJID); const parentId = String(menu.PARENT_OBJ_ID); const menuItem = menuMap.get(objId)!; - // PARENT_OBJ_ID가 특정 값이 아닌 경우 (루트가 아닌 경우) if (parentId !== "-395553955") { const parent = menuMap.get(parentId); if (parent) { parent.children = parent.children || []; parent.children.push(menuItem); - console.log(`자식 메뉴 추가: ${menu.MENU_NAME_KOR} -> ${parent.MENU_NAME_KOR}`); - } else { - console.log(`부모 메뉴를 찾을 수 없음: ${menu.MENU_NAME_KOR}, 부모 ID: ${parentId}`); } } else { rootMenus.push(menuItem); - console.log(`루트 메뉴 추가: ${menu.MENU_NAME_KOR}`); } }); - console.log("루트 메뉴 개수:", rootMenus.length); - console.log( - "최종 루트 메뉴들:", - rootMenus.map((m) => m.MENU_NAME_KOR), - ); - return rootMenus.sort((a, b) => (a.SEQ || 0) - (b.SEQ || 0)); }, []); /** * 메뉴 데이터 로드 + * - apiClient 사용으로 토큰/401 자동 처리 + * - 실패 시 빈 메뉴 유지 (로그인 리다이렉트는 client.ts 인터셉터가 담당) */ const loadMenuData = useCallback(async () => { try { - // JWT 토큰 가져오기 - const token = localStorage.getItem("authToken"); + const response = await apiClient.get("/admin/user-menus"); - if (!token) { - console.error("JWT 토큰이 없습니다."); - router.push("/login"); - return; + if (response.data?.success && response.data?.data) { + const convertedMenuData = convertToUpperCaseKeys(response.data.data || []); + setMenuState((prev: MenuState) => ({ + ...prev, + menuList: buildMenuTree(convertedMenuData), + isLoading: false, + })); + } else { + setMenuState((prev: MenuState) => ({ ...prev, isLoading: false })); } - - // 메뉴 목록 조회 - const menuResponse = await fetch(`${LAYOUT_CONFIG.API_BASE_URL}${LAYOUT_CONFIG.ENDPOINTS.USER_MENUS}`, { - method: "GET", - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/json", - "Content-Type": "application/json", - }, - }); - - if (menuResponse.ok) { - const menuResult = await menuResponse.json(); - console.log("메뉴 응답 데이터:", menuResult); - - if (menuResult.success && menuResult.data) { - console.log("메뉴 데이터 배열:", menuResult.data); - const convertedMenuData = convertToUpperCaseKeys(menuResult.data || []); - console.log("변환된 메뉴 데이터:", convertedMenuData); - - setMenuState((prev: MenuState) => ({ - ...prev, - menuList: buildMenuTree(convertedMenuData), - isLoading: false, - })); - } - } else if (menuResponse.status === 401) { - // 인증 실패 시 토큰 제거 및 로그인 페이지로 리다이렉트 - localStorage.removeItem("authToken"); - router.push("/login"); - } - } catch (error) { - console.error("메뉴 데이터 로드 실패:", error); - localStorage.removeItem("authToken"); - router.push("/login"); + } catch { + // API 실패 시 빈 메뉴로 유지 (401은 client.ts 인터셉터가 리다이렉트 처리) setMenuState((prev: MenuState) => ({ ...prev, isLoading: false })); } - }, [router, convertToUpperCaseKeys, buildMenuTree]); + }, [convertToUpperCaseKeys, buildMenuTree]); /** * 메뉴 토글 @@ -160,13 +116,11 @@ export const useMenu = (user: any, authLoading: boolean) => { if (menu.children && menu.children.length > 0) { toggleMenu(String(menu.OBJID)); } else { - // 메뉴 이름 저장 (엑셀 다운로드 파일명에 사용) const menuName = menu.MENU_NAME_KOR || menu.menuNameKor || menu.TRANSLATED_NAME || "메뉴"; if (typeof window !== "undefined") { localStorage.setItem("currentMenuName", menuName); } - // 먼저 할당된 화면이 있는지 확인 (URL 유무와 관계없이) try { const menuObjid = menu.OBJID || menu.objid; if (menuObjid) { @@ -174,9 +128,7 @@ export const useMenu = (user: any, authLoading: boolean) => { const assignedScreens = await menuScreenApi.getScreensByMenu(parseInt(menuObjid.toString())); if (assignedScreens.length > 0) { - // 할당된 화면이 있으면 첫 번째 화면으로 이동 const firstScreen = assignedScreens[0]; - // menuObjid를 쿼리 파라미터로 전달 router.push(`/screens/${firstScreen.screenId}?menuObjid=${menuObjid}`); return; } @@ -185,11 +137,9 @@ export const useMenu = (user: any, authLoading: boolean) => { console.warn("할당된 화면 조회 실패:", error); } - // 할당된 화면이 없고 URL이 있으면 기존 URL로 이동 if (menu.MENU_URL) { router.push(menu.MENU_URL); } else { - // URL도 없고 할당된 화면도 없으면 경고 메시지 console.warn("메뉴에 URL이나 할당된 화면이 없습니다:", menu); const { toast } = await import("sonner"); toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다."); @@ -199,7 +149,6 @@ export const useMenu = (user: any, authLoading: boolean) => { [toggleMenu, router], ); - // 사용자 정보가 있고 로딩이 완료되면 메뉴 데이터 로드 useEffect(() => { if (user && !authLoading) { loadMenuData(); @@ -212,6 +161,6 @@ export const useMenu = (user: any, authLoading: boolean) => { isMenuLoading: menuState.isLoading, handleMenuClick, toggleMenu, - refreshMenus: loadMenuData, // 메뉴 새로고침 함수 추가 + refreshMenus: loadMenuData, }; }; diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index 0721fb38..7abe856c 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -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") { @@ -89,12 +73,14 @@ const TokenManager = { setToken: (token: string): void => { if (typeof window !== "undefined") { localStorage.setItem("authToken", token); + document.cookie = `authToken=${token}; path=/; max-age=86400; SameSite=Lax`; } }, removeToken: (): void => { if (typeof window !== "undefined") { localStorage.removeItem("authToken"); + document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax"; } }, @@ -107,20 +93,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,77 +116,85 @@ const TokenManager = { }, }; -// 토큰 갱신 중복 방지 플래그 +// ============================================ +// 토큰 갱신 로직 (중복 요청 방지) +// ============================================ let isRefreshing = false; -let refreshPromise: Promise | null = null; +let refreshSubscribers: Array<(token: string) => void> = []; +let failedRefreshSubscribers: Array<(error: Error) => void> = []; -// 토큰 갱신 함수 -const refreshToken = async (): Promise => { - // 이미 갱신 중이면 기존 Promise 반환 - if (isRefreshing && refreshPromise) { - return refreshPromise; - } - - isRefreshing = true; - refreshPromise = (async () => { - try { - const currentToken = TokenManager.getToken(); - if (!currentToken) { - return null; - } - - const response = await axios.post( - `${API_BASE_URL}/auth/refresh`, - {}, - { - headers: { - Authorization: `Bearer ${currentToken}`, - }, - }, - ); - - 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); - return null; - } finally { - isRefreshing = false; - refreshPromise = null; - } - })(); - - return refreshPromise; +// 갱신 대기 중인 요청들에게 새 토큰 전달 +const onTokenRefreshed = (newToken: string) => { + refreshSubscribers.forEach((callback) => callback(newToken)); + refreshSubscribers = []; + failedRefreshSubscribers = []; }; -// 자동 토큰 갱신 타이머 -let tokenRefreshTimer: NodeJS.Timeout | null = null; +// 갱신 실패 시 대기 중인 요청들에게 에러 전달 +const onRefreshFailed = (error: Error) => { + failedRefreshSubscribers.forEach((callback) => callback(error)); + refreshSubscribers = []; + failedRefreshSubscribers = []; +}; + +// 갱신 완료 대기 Promise 등록 +const waitForTokenRefresh = (): Promise => { + return new Promise((resolve, reject) => { + refreshSubscribers.push(resolve); + failedRefreshSubscribers.push(reject); + }); +}; + +const refreshToken = async (): Promise => { + try { + const currentToken = TokenManager.getToken(); + if (!currentToken) { + return null; + } + + const response = await axios.post( + `${API_BASE_URL}/auth/refresh`, + {}, + { + headers: { + Authorization: `Bearer ${currentToken}`, + }, + }, + ); + + if (response.data?.success && response.data?.data?.token) { + const newToken = response.data.data.token; + TokenManager.setToken(newToken); + return newToken; + } + return null; + } catch { + return null; + } +}; + +// ============================================ +// 자동 토큰 갱신 (백그라운드) +// ============================================ +let tokenRefreshTimer: ReturnType | 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 +203,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 | null = null; window.addEventListener( event, () => { @@ -240,7 +253,7 @@ const setupActivityBasedRefresh = (): void => { throttleTimer = setTimeout(() => { handleActivity(); throttleTimer = null; - }, 1000); // 1초 throttle + }, 2000); } }, { passive: true }, @@ -248,38 +261,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)) { - config.headers.Authorization = `Bearer ${token}`; - } else if (token && TokenManager.isTokenExpired(token)) { - console.warn("❌ 토큰이 만료되었습니다."); - // 토큰 제거 - if (typeof window !== "undefined") { - localStorage.removeItem("authToken"); + if (token) { + if (!TokenManager.isTokenExpired(token)) { + // 유효한 토큰 → 그대로 사용 + config.headers.Authorization = `Bearer ${token}`; + } else { + // 만료된 토큰 → 갱신 시도 후 사용 + const newToken = await refreshToken(); + if (newToken) { + config.headers.Authorization = `Bearer ${newToken}`; + } + // 갱신 실패해도 요청은 보냄 (401 응답 인터셉터에서 처리) } - } else { - console.warn("⚠️ 토큰이 없습니다."); } // FormData 요청 시 Content-Type 자동 처리 @@ -287,18 +318,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 +343,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 +363,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); } - - // 일반 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] 토큰 만료, 갱신 시도..."); - originalRequest._retry = true; + // 토큰 만료 에러 → 갱신 후 재시도 + if (errorCode === "TOKEN_EXPIRED" && originalRequest) { + if (!isRefreshing) { + isRefreshing = true; + originalRequest._retry = true; - try { - const newToken = await refreshToken(); - if (newToken && originalRequest) { + try { + const newToken = await refreshToken(); + 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) { + 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); } - } catch (refreshError) { - console.error("[Auth] 토큰 갱신 실패:", refreshError); } } - // 토큰 갱신 실패 또는 다른 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 { success: boolean; data?: T; @@ -416,7 +444,6 @@ export interface ApiResponse { errorCode?: string; } -// 사용자 정보 타입 export interface UserInfo { userId: string; userName: string; @@ -430,13 +457,11 @@ export interface UserInfo { isAdmin?: boolean; } -// 현재 사용자 정보 조회 export const getCurrentUser = async (): Promise> => { 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 +470,6 @@ export const getCurrentUser = async (): Promise> => { } }; -// API 호출 헬퍼 함수 export const apiCall = async ( method: "GET" | "POST" | "PUT" | "DELETE", url: string, @@ -459,7 +483,6 @@ export const apiCall = async ( }); return response.data; } catch (error: unknown) { - console.error("API 호출 실패:", error); const axiosError = error as AxiosError; return { success: false, diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index bb64b79c..a490f8b0 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -114,6 +114,9 @@ import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트 import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트 import "./v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준 import "./v2-item-routing/ItemRoutingRenderer"; // 품목별 라우팅 +import "./v2-split-line/SplitLineRenderer"; // V2 캔버스 분할선 +import "./v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰 +import "./v2-bom-item-editor/BomItemEditorRenderer"; // BOM 하위품목 편집기 /** * 컴포넌트 초기화 함수 diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelContext.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelContext.tsx index fe3a9327..64fee843 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelContext.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelContext.tsx @@ -26,6 +26,8 @@ export interface SplitPanelInfo { initialLeftWidthPercent: number; // 드래그 중 여부 isDragging: boolean; + // 패널 유형: "component" = 분할 패널 레이아웃 (버튼만 조정), "canvas" = 캔버스 분할선 (모든 컴포넌트 조정) + panelType?: "component" | "canvas"; } export interface SplitPanelResizeContextValue { diff --git a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx new file mode 100644 index 00000000..16aebd59 --- /dev/null +++ b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx @@ -0,0 +1,932 @@ +"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; + parent_detail_id: string | null; + seq_no: number; + level: number; + children: BomItemNode[]; + _isNew?: boolean; + _isDeleted?: boolean; + data: Record; +} + +interface BomColumnConfig { + key: string; + title: string; + width?: string; + visible?: boolean; + editable?: boolean; + isSourceDisplay?: boolean; + inputType?: string; +} + +interface ItemInfo { + id: string; + item_number: string; + item_name: string; + type: string; + unit: string; + division: string; +} + +interface BomItemEditorProps { + component?: any; + formData?: Record; + 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([]); + 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 ( + + + + 품목 검색 + + 하위 품목으로 추가할 품목을 선택하세요. + + + +
+ setSearchText(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="품목코드 또는 품목명" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> + +
+ +
+ {loading ? ( +
+ 검색 중... +
+ ) : items.length === 0 ? ( +
+ + 검색 결과가 없습니다. + +
+ ) : ( + + + + + + + + + + + {items.map((item) => ( + { + onSelect(item); + onClose(); + }} + className="hover:bg-accent cursor-pointer border-t transition-colors" + > + + + + + + ))} + +
품목코드품목명구분단위
+ {item.item_number} + {item.item_name}{item.type}{item.unit}
+ )} +
+
+
+ ); +} + +// ─── 트리 노드 행 렌더링 (config.columns 기반 동적 셀) ─── + +interface TreeNodeRowProps { + node: BomItemNode; + depth: number; + expanded: boolean; + hasChildren: boolean; + columns: BomColumnConfig[]; + categoryOptionsMap: Record; + mainTableName?: string; + onToggle: () => void; + onFieldChange: (tempId: string, field: string, value: string) => void; + onDelete: (tempId: string) => void; + onAddChild: (parentTempId: string) => void; +} + +function TreeNodeRow({ + node, + depth, + expanded, + hasChildren, + columns, + categoryOptionsMap, + mainTableName, + onToggle, + onFieldChange, + onDelete, + onAddChild, +}: TreeNodeRowProps) { + const indentPx = depth * 32; + const visibleColumns = columns.filter((c) => c.visible !== false); + + const renderCell = (col: BomColumnConfig) => { + const value = node.data[col.key] ?? ""; + + // 소스 표시 컬럼 (읽기 전용) + if (col.isSourceDisplay) { + return ( + + {value || "-"} + + ); + } + + // 카테고리 타입: API에서 로드한 옵션으로 Select 렌더링 + if (col.inputType === "category") { + const categoryRef = mainTableName ? `${mainTableName}.${col.key}` : ""; + const options = categoryOptionsMap[categoryRef] || []; + return ( + + ); + } + + // 편집 불가능 컬럼 + if (col.editable === false) { + return ( + + {value || "-"} + + ); + } + + // 숫자 입력 + if (col.inputType === "number" || col.inputType === "decimal") { + return ( + onFieldChange(node.tempId, col.key, e.target.value)} + className="h-7 w-full min-w-[50px] text-center text-xs" + placeholder={col.title} + /> + ); + } + + // 기본 텍스트 입력 + return ( + onFieldChange(node.tempId, col.key, e.target.value)} + className="h-7 w-full min-w-[50px] text-xs" + placeholder={col.title} + /> + ); + }; + + return ( +
0 && "ml-2 border-l-2 border-l-primary/20", + )} + style={{ marginLeft: `${indentPx}px` }} + > + + + + + + {node.seq_no} + + + {node.level > 0 && ( + + L{node.level} + + )} + + {/* config.columns 기반 동적 셀 렌더링 */} + {visibleColumns.map((col) => ( +
+ {renderCell(col)} +
+ ))} + + + + +
+ ); +} + +// ─── 메인 컴포넌트 ─── + +export function BomItemEditorComponent({ + component, + formData, + companyCode, + isDesignMode = false, + selectedRowsData, + onChange, + bomId: propBomId, +}: BomItemEditorProps) { + const [treeData, setTreeData] = useState([]); + const [expandedNodes, setExpandedNodes] = useState>(new Set()); + const [loading, setLoading] = useState(false); + const [itemSearchOpen, setItemSearchOpen] = useState(false); + const [addTargetParentId, setAddTargetParentId] = useState(null); + const [categoryOptionsMap, setCategoryOptionsMap] = useState>({}); + + // 설정값 추출 + const cfg = useMemo(() => component?.componentConfig || {}, [component]); + const mainTableName = cfg.mainTableName || "bom_detail"; + const parentKeyColumn = cfg.parentKeyColumn || "parent_detail_id"; + const columns: BomColumnConfig[] = useMemo(() => cfg.columns || [], [cfg.columns]); + const visibleColumns = useMemo(() => columns.filter((c) => c.visible !== false), [columns]); + const fkColumn = cfg.foreignKeyColumn || "bom_id"; + + // 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]); + + // ─── 카테고리 옵션 로드 (리피터 방식) ─── + + useEffect(() => { + const loadCategoryOptions = async () => { + const categoryColumns = visibleColumns.filter((col) => col.inputType === "category"); + if (categoryColumns.length === 0) return; + + for (const col of categoryColumns) { + const categoryRef = `${mainTableName}.${col.key}`; + if (categoryOptionsMap[categoryRef]) continue; + + try { + const response = await apiClient.get(`/table-categories/${mainTableName}/${col.key}/values`); + if (response.data?.success && response.data.data) { + const options = response.data.data.map((item: any) => ({ + value: item.valueCode || item.value_code, + label: item.valueLabel || item.value_label || item.displayValue || item.display_value || item.label, + })); + setCategoryOptionsMap((prev) => ({ ...prev, [categoryRef]: options })); + } + } catch (error) { + console.error(`카테고리 옵션 로드 실패 (${categoryRef}):`, error); + } + } + }; + + if (!isDesignMode) { + loadCategoryOptions(); + } + }, [visibleColumns, mainTableName, isDesignMode]); + + // ─── 데이터 로드 ─── + + const loadBomDetails = useCallback( + async (id: string) => { + if (!id) return; + setLoading(true); + try { + const result = await entityJoinApi.getTableDataWithJoins(mainTableName, { + page: 1, + size: 500, + search: { [fkColumn]: id }, + sortBy: "seq_no", + sortOrder: "asc", + enableEntityJoin: true, + }); + + const rows = result.data || []; + const tree = buildTree(rows); + setTreeData(tree); + + const firstLevelIds = new Set( + tree.map((n) => n.tempId || n.id || ""), + ); + setExpandedNodes(firstLevelIds); + } catch (error) { + console.error("[BomItemEditor] 데이터 로드 실패:", error); + } finally { + setLoading(false); + } + }, + [mainTableName, fkColumn], + ); + + useEffect(() => { + if (bomId && !isDesignMode) { + loadBomDetails(bomId); + } + }, [bomId, isDesignMode, loadBomDetails]); + + // ─── 트리 빌드 (동적 데이터) ─── + + const buildTree = (flatData: any[]): BomItemNode[] => { + const nodeMap = new Map(); + const roots: BomItemNode[] = []; + + flatData.forEach((item) => { + const tempId = item.id || generateTempId(); + nodeMap.set(item.id || tempId, { + tempId, + id: item.id, + parent_detail_id: item[parentKeyColumn] || null, + seq_no: Number(item.seq_no) || 0, + level: Number(item.level) || 0, + children: [], + data: { ...item }, + }); + }); + + flatData.forEach((item) => { + const nodeId = item.id || ""; + const node = nodeMap.get(nodeId); + if (!node) return; + + const parentId = item[parentKeyColumn]; + if (parentId && nodeMap.has(parentId)) { + nodeMap.get(parentId)!.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({ + ...node.data, + id: node.id, + tempId: node.tempId, + [parentKeyColumn]: parentId, + seq_no: String(idx + 1), + level: String(level), + _isNew: node._isNew, + _targetTable: mainTableName, + }); + if (node.children.length > 0) { + traverse(node.children, node.id || node.tempId, level + 1); + } + }); + }; + traverse(nodes, null, 0); + return result; + }, [parentKeyColumn, mainTableName]); + + // 트리 변경 시 부모에게 알림 + 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; + }; + + // 필드 변경 (data Record 내부 업데이트) + const handleFieldChange = useCallback( + (tempId: string, field: string, value: string) => { + const newTree = findAndUpdate(treeData, tempId, (node) => ({ + ...node, + data: { ...node.data, [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) => { + // 소스 테이블 데이터를 _display_ 접두사로 저장 (엔티티 조인 방식) + const sourceData: Record = {}; + const sourceTable = cfg.dataSource?.sourceTable; + if (sourceTable) { + const sourceFk = cfg.dataSource?.foreignKey || "child_item_id"; + sourceData[sourceFk] = item.id; + // 소스 표시 컬럼의 데이터 병합 + Object.keys(item).forEach((key) => { + sourceData[`_display_${key}`] = (item as any)[key]; + sourceData[key] = (item as any)[key]; + }); + } + + const newNode: BomItemNode = { + tempId: generateTempId(), + parent_detail_id: null, + seq_no: 0, + level: 0, + children: [], + _isNew: true, + data: { + ...sourceData, + quantity: "1", + loss_rate: "0", + remark: "", + }, + }; + + 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, cfg], + ); + + // 펼침/접기 토글 + 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 ( + + 0} + columns={visibleColumns} + categoryOptionsMap={categoryOptionsMap} + mainTableName={mainTableName} + onToggle={() => toggleExpand(node.tempId)} + onFieldChange={handleFieldChange} + onDelete={handleDelete} + onAddChild={handleAddChild} + /> + {isExpanded && + node.children.length > 0 && + renderNodes(node.children, depth + 1)} + + ); + }); + }; + + // ─── 디자인 모드 미리보기 ─── + + if (isDesignMode) { + const cfg = component?.componentConfig || {}; + const hasConfig = + cfg.mainTableName || cfg.dataSource?.sourceTable || (cfg.columns && cfg.columns.length > 0); + + if (!hasConfig) { + return ( +
+ +

+ BOM 하위 품목 편집기 +

+

+ 설정 패널에서 테이블과 컬럼을 지정하세요 +

+
+ ); + } + + const visibleColumns = (cfg.columns || []).filter((c: any) => c.visible !== false); + + const DUMMY_DATA: Record = { + item_name: ["본체 조립", "프레임", "커버", "전장 조립", "PCB 보드"], + item_number: ["ASM-001", "PRT-010", "PRT-011", "ASM-002", "PRT-020"], + specification: ["100×50", "200mm", "ABS", "50×30", "4-Layer"], + material: ["AL6061", "SUS304", "ABS", "FR-4", "구리"], + stock_unit: ["EA", "EA", "EA", "EA", "EA"], + quantity: ["1", "2", "1", "1", "3"], + loss_rate: ["0", "5", "3", "0", "2"], + unit: ["EA", "EA", "EA", "EA", "EA"], + remark: ["", "외주", "", "", ""], + seq_no: ["1", "2", "3", "4", "5"], + }; + const DUMMY_DEPTHS = [0, 1, 1, 0, 1]; + + const getDummyValue = (col: any, rowIdx: number): string => { + const vals = DUMMY_DATA[col.key]; + if (vals) return vals[rowIdx % vals.length]; + return ""; + }; + + return ( +
+
+

하위 품목 구성

+ +
+ + {/* 설정 요약 뱃지 */} +
+ {cfg.mainTableName && ( + + 저장: {cfg.mainTableName} + + )} + {cfg.dataSource?.sourceTable && ( + + 소스: {cfg.dataSource.sourceTable} + + )} + {cfg.parentKeyColumn && ( + + 트리: {cfg.parentKeyColumn} + + )} +
+ + {/* 테이블 형태 미리보기 - config.columns 순서 그대로 */} +
+ {visibleColumns.length === 0 ? ( +
+ +

+ 컬럼 탭에서 표시할 컬럼을 선택하세요 +

+
+ ) : ( + + + + + {visibleColumns.map((col: any) => ( + + ))} + + + + + {DUMMY_DEPTHS.map((depth, rowIdx) => ( + + + + {visibleColumns.map((col: any) => ( + + ))} + + + ))} + +
+ # + {col.title} + 액션
+
+ {depth === 0 ? ( + + ) : ( + + )} +
+
+ {rowIdx + 1} + + {col.isSourceDisplay ? ( + + {getDummyValue(col, rowIdx) || col.title} + + ) : col.editable !== false ? ( +
+ {getDummyValue(col, rowIdx)} +
+ ) : ( + + {getDummyValue(col, rowIdx)} + + )} +
+
+
+ +
+
+ +
+
+
+ )} +
+
+ ); + } + + // ─── 메인 렌더링 ─── + + return ( +
+ {/* 헤더 */} +
+

하위 품목 구성

+ +
+ + {/* 트리 목록 */} +
+ {loading ? ( +
+ 로딩 중... +
+ ) : treeData.length === 0 ? ( +
+ +

+ 하위 품목이 없습니다. +

+

+ "품목추가" 버튼을 눌러 추가하세요. +

+
+ ) : ( + renderNodes(treeData, 0) + )} +
+ + {/* 품목 검색 모달 */} + setItemSearchOpen(false)} + onSelect={handleItemSelect} + companyCode={companyCode} + /> +
+ ); +} + +export default BomItemEditorComponent; diff --git a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorRenderer.tsx b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorRenderer.tsx new file mode 100644 index 00000000..09cf855c --- /dev/null +++ b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorRenderer.tsx @@ -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 ; + } +} + +BomItemEditorRenderer.registerSelf(); + +if (typeof window !== "undefined") { + setTimeout(() => { + BomItemEditorRenderer.registerSelf(); + }, 0); +} diff --git a/frontend/lib/registry/components/v2-bom-item-editor/index.ts b/frontend/lib/registry/components/v2-bom-item-editor/index.ts new file mode 100644 index 00000000..c2d45b7d --- /dev/null +++ b/frontend/lib/registry/components/v2-bom-item-editor/index.ts @@ -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; diff --git a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx new file mode 100644 index 00000000..536c1ddc --- /dev/null +++ b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx @@ -0,0 +1,448 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { ChevronRight, ChevronDown, Package, Layers, Box, AlertCircle } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { entityJoinApi } from "@/lib/api/entityJoin"; + +/** + * BOM 트리 노드 데이터 + */ +interface BomTreeNode { + id: string; + bom_id: string; + parent_detail_id: string | null; + seq_no: string; + level: string; + 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: BomTreeNode[]; +} + +/** + * BOM 헤더 정보 + */ +interface BomHeaderInfo { + id: string; + bom_number: string; + item_code: string; + item_name: string; + item_type: string; + base_qty: string; + unit: string; + version: string; + revision: string; + status: string; + effective_date: string; + expired_date: string; + remark: string; +} + +interface BomTreeComponentProps { + component?: any; + formData?: Record; + tableName?: string; + companyCode?: string; + isDesignMode?: boolean; + selectedRowsData?: any[]; + [key: string]: any; +} + +/** + * BOM 트리 컴포넌트 + * 좌측 패널에서 BOM 헤더 선택 시 계층 구조로 BOM 디테일을 표시 + */ +export function BomTreeComponent({ + component, + formData, + companyCode, + isDesignMode = false, + selectedRowsData, + ...props +}: BomTreeComponentProps) { + const [headerInfo, setHeaderInfo] = useState(null); + const [treeData, setTreeData] = useState([]); + const [expandedNodes, setExpandedNodes] = useState>(new Set()); + const [loading, setLoading] = useState(false); + const [selectedNodeId, setSelectedNodeId] = useState(null); + + const config = component?.componentConfig || {}; + + // 선택된 BOM 헤더에서 bom_id 추출 + const selectedBomId = useMemo(() => { + // SplitPanel에서 좌측 선택 시 formData나 selectedRowsData로 전달됨 + if (selectedRowsData && selectedRowsData.length > 0) { + return selectedRowsData[0]?.id; + } + if (formData?.id) return formData.id; + return null; + }, [formData, selectedRowsData]); + + // 선택된 BOM 헤더 정보 추출 + const selectedHeaderData = useMemo(() => { + if (selectedRowsData && selectedRowsData.length > 0) { + return selectedRowsData[0] as BomHeaderInfo; + } + if (formData?.id) return formData as unknown as BomHeaderInfo; + return null; + }, [formData, selectedRowsData]); + + // BOM 디테일 데이터 로드 + const loadBomDetails = useCallback(async (bomId: string) => { + if (!bomId) return; + setLoading(true); + try { + const result = await entityJoinApi.getTableDataWithJoins("bom_detail", { + page: 1, + size: 500, + search: { bom_id: bomId }, + sortBy: "seq_no", + sortOrder: "asc", + enableEntityJoin: true, + }); + + const rows = result.data || []; + const tree = buildTree(rows); + setTreeData(tree); + const firstLevelIds = new Set(tree.map((n: BomTreeNode) => n.id)); + setExpandedNodes(firstLevelIds); + } catch (error) { + console.error("[BomTree] 데이터 로드 실패:", error); + } finally { + setLoading(false); + } + }, []); + + // 평면 데이터 -> 트리 구조 변환 + const buildTree = (flatData: any[]): BomTreeNode[] => { + const nodeMap = new Map(); + const roots: BomTreeNode[] = []; + + // 모든 노드를 맵에 등록 + flatData.forEach((item) => { + nodeMap.set(item.id, { ...item, children: [] }); + }); + + // 부모-자식 관계 설정 + flatData.forEach((item) => { + const node = nodeMap.get(item.id)!; + if (item.parent_detail_id && nodeMap.has(item.parent_detail_id)) { + nodeMap.get(item.parent_detail_id)!.children.push(node); + } else { + roots.push(node); + } + }); + + return roots; + }; + + // 선택된 BOM 변경 시 데이터 로드 + useEffect(() => { + if (selectedBomId) { + setHeaderInfo(selectedHeaderData); + loadBomDetails(selectedBomId); + } else { + setHeaderInfo(null); + setTreeData([]); + } + }, [selectedBomId, selectedHeaderData, loadBomDetails]); + + // 노드 펼치기/접기 토글 + const toggleNode = useCallback((nodeId: string) => { + setExpandedNodes((prev) => { + const next = new Set(prev); + if (next.has(nodeId)) { + next.delete(nodeId); + } else { + next.add(nodeId); + } + return next; + }); + }, []); + + // 전체 펼치기 + const expandAll = useCallback(() => { + const allIds = new Set(); + const collectIds = (nodes: BomTreeNode[]) => { + nodes.forEach((n) => { + allIds.add(n.id); + if (n.children.length > 0) collectIds(n.children); + }); + }; + collectIds(treeData); + setExpandedNodes(allIds); + }, [treeData]); + + // 전체 접기 + const collapseAll = useCallback(() => { + setExpandedNodes(new Set()); + }, []); + + // 품목 구분 라벨 + const getItemTypeLabel = (type: string) => { + switch (type) { + case "product": return "제품"; + case "semi": return "반제품"; + case "material": return "원자재"; + case "part": return "부품"; + default: return type || "-"; + } + }; + + // 품목 구분 아이콘 & 색상 + const getItemTypeStyle = (type: string) => { + switch (type) { + case "product": + return { icon: Package, color: "text-blue-600", bg: "bg-blue-50" }; + case "semi": + return { icon: Layers, color: "text-amber-600", bg: "bg-amber-50" }; + case "material": + return { icon: Box, color: "text-emerald-600", bg: "bg-emerald-50" }; + default: + return { icon: Box, color: "text-gray-500", bg: "bg-gray-50" }; + } + }; + + // 디자인 모드 미리보기 + if (isDesignMode) { + return ( +
+
+ + BOM 트리 뷰 +
+
+
+ + + 완제품 A (제품) + 수량: 1 +
+
+ + + 반제품 B (반제품) + 수량: 2 +
+
+ + + 원자재 C (원자재) + 수량: 5 +
+
+
+ ); + } + + // 선택 안 된 상태 + if (!selectedBomId) { + return ( +
+
+

좌측에서 BOM을 선택하세요

+

선택한 BOM의 구성 정보가 트리로 표시됩니다

+
+
+ ); + } + + return ( +
+ {/* 헤더 정보 */} + {headerInfo && ( +
+
+ +

{headerInfo.item_name || "-"}

+ + {headerInfo.bom_number || "-"} + + + {headerInfo.status === "active" ? "사용" : "미사용"} + +
+
+ 품목코드: {headerInfo.item_code || "-"} + 구분: {getItemTypeLabel(headerInfo.item_type)} + 기준수량: {headerInfo.base_qty || "1"} {headerInfo.unit || ""} + 버전: v{headerInfo.version || "1.0"} (차수 {headerInfo.revision || "1"}) +
+
+ )} + + {/* 트리 툴바 */} +
+ BOM 구성 + + {treeData.length}건 + +
+ + +
+
+ + {/* 트리 컨텐츠 */} +
+ {loading ? ( +
+
로딩 중...
+
+ ) : treeData.length === 0 ? ( +
+ +

등록된 하위 품목이 없습니다

+
+ ) : ( +
+ {treeData.map((node) => ( + + ))} +
+ )} +
+
+ ); +} + +/** + * 트리 노드 행 (재귀 렌더링) + */ +interface TreeNodeRowProps { + node: BomTreeNode; + depth: number; + expandedNodes: Set; + selectedNodeId: string | null; + onToggle: (id: string) => void; + onSelect: (id: string) => void; + getItemTypeLabel: (type: string) => string; + getItemTypeStyle: (type: string) => { icon: any; color: string; bg: string }; +} + +function TreeNodeRow({ + node, + depth, + expandedNodes, + selectedNodeId, + onToggle, + onSelect, + getItemTypeLabel, + getItemTypeStyle, +}: TreeNodeRowProps) { + const isExpanded = expandedNodes.has(node.id); + const hasChildren = node.children.length > 0; + const isSelected = selectedNodeId === node.id; + const style = getItemTypeStyle(node.child_item_type); + const ItemIcon = style.icon; + + return ( + <> +
{ + onSelect(node.id); + if (hasChildren) onToggle(node.id); + }} + > + {/* 펼치기/접기 화살표 */} + + {hasChildren ? ( + isExpanded ? ( + + ) : ( + + ) + ) : ( + + )} + + + {/* 품목 타입 아이콘 */} + + + + + {/* 품목 정보 */} +
+ + {node.child_item_name || "-"} + + + {node.child_item_code || ""} + + + {getItemTypeLabel(node.child_item_type)} + +
+ + {/* 수량/단위 */} +
+ + 수량: {node.quantity || "0"} {node.unit || ""} + + {node.loss_rate && node.loss_rate !== "0" && ( + + 로스: {node.loss_rate}% + + )} +
+
+ + {/* 하위 노드 재귀 렌더링 */} + {hasChildren && isExpanded && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} + + ); +} diff --git a/frontend/lib/registry/components/v2-bom-tree/BomTreeRenderer.tsx b/frontend/lib/registry/components/v2-bom-tree/BomTreeRenderer.tsx new file mode 100644 index 00000000..a8219810 --- /dev/null +++ b/frontend/lib/registry/components/v2-bom-tree/BomTreeRenderer.tsx @@ -0,0 +1,26 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { V2BomTreeDefinition } from "./index"; +import { BomTreeComponent } from "./BomTreeComponent"; + +export class BomTreeRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = V2BomTreeDefinition; + + render(): React.ReactElement { + return ; + } +} + +BomTreeRenderer.registerSelf(); + +if (typeof window !== "undefined") { + setTimeout(() => { + try { + BomTreeRenderer.registerSelf(); + } catch (error) { + console.error("BomTree 등록 실패:", error); + } + }, 1000); +} diff --git a/frontend/lib/registry/components/v2-bom-tree/index.ts b/frontend/lib/registry/components/v2-bom-tree/index.ts new file mode 100644 index 00000000..b11f9651 --- /dev/null +++ b/frontend/lib/registry/components/v2-bom-tree/index.ts @@ -0,0 +1,27 @@ +"use client"; + +import { ComponentCategory } from "@/types/component"; +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { BomTreeComponent } from "./BomTreeComponent"; + +export const V2BomTreeDefinition = createComponentDefinition({ + id: "v2-bom-tree", + name: "BOM 트리 뷰", + nameEng: "BOM Tree View", + description: "BOM 구성을 계층 트리 형태로 표시하는 컴포넌트", + category: ComponentCategory.V2, + webType: "text", + component: BomTreeComponent, + defaultConfig: { + detailTable: "bom_detail", + foreignKey: "bom_id", + parentKey: "parent_detail_id", + }, + defaultSize: { width: 900, height: 600 }, + icon: "GitBranch", + tags: ["BOM", "트리", "계층", "제조", "생산"], + version: "1.0.0", + author: "개발팀", +}); + +export default V2BomTreeDefinition; diff --git a/frontend/lib/registry/components/v2-split-line/SplitLineComponent.tsx b/frontend/lib/registry/components/v2-split-line/SplitLineComponent.tsx new file mode 100644 index 00000000..b27f9d9f --- /dev/null +++ b/frontend/lib/registry/components/v2-split-line/SplitLineComponent.tsx @@ -0,0 +1,285 @@ +"use client"; + +import React, { useState, useCallback, useEffect, useRef } from "react"; +import { ComponentRendererProps } from "@/types/component"; +import { SplitLineConfig } from "./types"; +import { setCanvasSplit, resetCanvasSplit } from "./canvasSplitStore"; + +export interface SplitLineComponentProps extends ComponentRendererProps { + config?: SplitLineConfig; +} + +export const SplitLineComponent: React.FC = ({ + component, + isDesignMode = false, + isSelected = false, + onClick, + config, + className, + style, + ...props +}) => { + const componentConfig = { + ...config, + ...component.componentConfig, + } as SplitLineConfig; + + const resizable = componentConfig.resizable ?? true; + const lineColor = componentConfig.lineColor || "#e2e8f0"; + const lineWidth = componentConfig.lineWidth || 4; + + const [dragOffset, setDragOffset] = useState(0); + const [isDragging, setIsDragging] = useState(false); + const containerRef = useRef(null); + + // CSS transform: scale()이 적용된 캔버스에서 정확한 디자인 해상도 + const detectCanvasWidth = useCallback((): number => { + if (containerRef.current) { + const canvas = + containerRef.current.closest("[data-screen-runtime]") || + containerRef.current.closest("[data-screen-canvas]"); + if (canvas) { + const w = parseInt((canvas as HTMLElement).style.width); + if (w > 0) return w; + } + } + const canvas = document.querySelector("[data-screen-runtime]"); + if (canvas) { + const w = parseInt((canvas as HTMLElement).style.width); + if (w > 0) return w; + } + return 1200; + }, []); + + // CSS scale 보정 계수 + const getScaleFactor = useCallback((): number => { + if (containerRef.current) { + const canvas = containerRef.current.closest("[data-screen-runtime]"); + if (canvas) { + const el = canvas as HTMLElement; + const designWidth = parseInt(el.style.width) || 1200; + const renderedWidth = el.getBoundingClientRect().width; + if (renderedWidth > 0 && designWidth > 0) { + return designWidth / renderedWidth; + } + } + } + return 1; + }, []); + + // 스코프 ID (같은 data-screen-runtime 안의 컴포넌트만 영향) + const scopeIdRef = useRef(""); + + // 글로벌 스토어에 등록 (런타임 모드) + useEffect(() => { + if (isDesignMode) return; + + const timer = setTimeout(() => { + const cw = detectCanvasWidth(); + const posX = component.position?.x || 0; + + // 스코프 ID: 가장 가까운 data-screen-runtime 요소에 고유 ID 부여 + let scopeId = ""; + if (containerRef.current) { + const runtimeEl = containerRef.current.closest("[data-screen-runtime]"); + if (runtimeEl) { + scopeId = runtimeEl.getAttribute("data-split-scope") || ""; + if (!scopeId) { + scopeId = `split-scope-${component.id}`; + runtimeEl.setAttribute("data-split-scope", scopeId); + } + } + } + scopeIdRef.current = scopeId; + + console.log("[SplitLine] 등록:", { canvasWidth: cw, positionX: posX, scopeId }); + + setCanvasSplit({ + initialDividerX: posX, + currentDividerX: posX, + canvasWidth: cw, + isDragging: false, + active: true, + scopeId, + }); + }, 100); + + return () => { + clearTimeout(timer); + resetCanvasSplit(); + }; + }, [isDesignMode, component.id, component.position?.x, detectCanvasWidth]); + + // 드래그 중 최종 오프셋 (DOM 직접 조작용) + const latestOffsetRef = useRef(dragOffset); + latestOffsetRef.current = dragOffset; + + const rafIdRef = useRef(0); + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + if (!resizable || isDesignMode) return; + e.preventDefault(); + e.stopPropagation(); + + const posX = component.position?.x || 0; + const startX = e.clientX; + const startOffset = latestOffsetRef.current; + const scaleFactor = getScaleFactor(); + const cw = detectCanvasWidth(); + const MIN_POS = Math.max(50, cw * 0.15); + const MAX_POS = cw - Math.max(50, cw * 0.15); + + setIsDragging(true); + setCanvasSplit({ isDragging: true }); + + const handleMouseMove = (moveEvent: MouseEvent) => { + cancelAnimationFrame(rafIdRef.current); + rafIdRef.current = requestAnimationFrame(() => { + const rawDelta = moveEvent.clientX - startX; + const delta = rawDelta * scaleFactor; + let newOffset = startOffset + delta; + + const newDividerX = posX + newOffset; + if (newDividerX < MIN_POS) newOffset = MIN_POS - posX; + if (newDividerX > MAX_POS) newOffset = MAX_POS - posX; + + latestOffsetRef.current = newOffset; + + // 스플릿선 자체는 DOM 직접 조작 (React 리렌더 없음) + if (containerRef.current) { + containerRef.current.style.transform = `translateX(${newOffset}px)`; + } + // 스토어 업데이트 → DOM 리스너만 호출 (React 리렌더 없음) + setCanvasSplit({ currentDividerX: posX + newOffset }); + }); + }; + + const handleMouseUp = () => { + cancelAnimationFrame(rafIdRef.current); + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.body.style.userSelect = ""; + document.body.style.cursor = ""; + + // 최종 오프셋을 React 상태에 동기화 (1회만 리렌더) + setDragOffset(latestOffsetRef.current); + setIsDragging(false); + setCanvasSplit({ isDragging: false }); + }; + + document.body.style.userSelect = "none"; + document.body.style.cursor = "col-resize"; + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + }, + [resizable, isDesignMode, component.position?.x, getScaleFactor, detectCanvasWidth], + ); + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onClick?.(); + }; + + // props 필터링 + const { + selectedScreen: _1, onZoneComponentDrop: _2, onZoneClick: _3, + componentConfig: _4, component: _5, isSelected: _6, + onClick: _7, onDragStart: _8, onDragEnd: _9, + size: _10, position: _11, style: _12, + screenId: _13, tableName: _14, onRefresh: _15, onClose: _16, + webType: _17, autoGeneration: _18, isInteractive: _19, + formData: _20, onFormDataChange: _21, + menuId: _22, menuObjid: _23, onSave: _24, + userId: _25, userName: _26, companyCode: _27, + isInModal: _28, readonly: _29, originalData: _30, + _originalData: _31, _initialData: _32, _groupedData: _33, + allComponents: _34, onUpdateLayout: _35, + selectedRows: _36, selectedRowsData: _37, onSelectedRowsChange: _38, + sortBy: _39, sortOrder: _40, tableDisplayData: _41, + flowSelectedData: _42, flowSelectedStepId: _43, onFlowSelectedDataChange: _44, + onConfigChange: _45, refreshKey: _46, flowRefreshKey: _47, onFlowRefresh: _48, + isPreview: _49, groupedData: _50, + ...domProps + } = props as any; + + if (isDesignMode) { + return ( +
+
+
+ 스플릿선 +
+
+ ); + } + + return ( +
+
+
+ ); +}; + +export const SplitLineWrapper: React.FC = (props) => { + return ; +}; diff --git a/frontend/lib/registry/components/v2-split-line/SplitLineConfigPanel.tsx b/frontend/lib/registry/components/v2-split-line/SplitLineConfigPanel.tsx new file mode 100644 index 00000000..49f741b8 --- /dev/null +++ b/frontend/lib/registry/components/v2-split-line/SplitLineConfigPanel.tsx @@ -0,0 +1,78 @@ +"use client"; + +import React from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; + +interface SplitLineConfigPanelProps { + config: any; + onConfigChange: (config: any) => void; +} + +/** + * SplitLine 설정 패널 + */ +export const SplitLineConfigPanel: React.FC = ({ config, onConfigChange }) => { + const currentConfig = config || {}; + + const handleChange = (key: string, value: any) => { + onConfigChange({ + ...currentConfig, + [key]: value, + }); + }; + + return ( +
+

스플릿선 설정

+ + {/* 드래그 리사이즈 허용 */} +
+ + handleChange("resizable", checked)} + /> +
+ + {/* 분할선 스타일 */} +
+ + handleChange("lineWidth", parseInt(e.target.value) || 4)} + className="h-8 text-xs" + min={1} + max={12} + /> +
+ +
+ +
+ handleChange("lineColor", e.target.value)} + className="h-8 w-8 cursor-pointer rounded border" + /> + handleChange("lineColor", e.target.value)} + className="h-8 flex-1 text-xs" + placeholder="#e2e8f0" + /> +
+
+ +

+ 캔버스에서 스플릿선의 X 위치가 초기 분할 지점이 됩니다. + 런타임에서 드래그하면 좌우 컴포넌트가 함께 이동합니다. +

+
+ ); +}; + +export default SplitLineConfigPanel; diff --git a/frontend/lib/registry/components/v2-split-line/SplitLineRenderer.tsx b/frontend/lib/registry/components/v2-split-line/SplitLineRenderer.tsx new file mode 100644 index 00000000..81888266 --- /dev/null +++ b/frontend/lib/registry/components/v2-split-line/SplitLineRenderer.tsx @@ -0,0 +1,30 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { V2SplitLineDefinition } from "./index"; +import { SplitLineComponent } from "./SplitLineComponent"; + +/** + * SplitLine 렌더러 + * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 + */ +export class SplitLineRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = V2SplitLineDefinition; + + render(): React.ReactElement { + return ; + } + + protected handleValueChange = (value: any) => { + this.updateComponent({ value }); + }; +} + +// 자동 등록 실행 +SplitLineRenderer.registerSelf(); + +// Hot Reload 지원 (개발 모드) +if (process.env.NODE_ENV === "development") { + SplitLineRenderer.enableHotReload(); +} diff --git a/frontend/lib/registry/components/v2-split-line/canvasSplitStore.ts b/frontend/lib/registry/components/v2-split-line/canvasSplitStore.ts new file mode 100644 index 00000000..85cdbdbd --- /dev/null +++ b/frontend/lib/registry/components/v2-split-line/canvasSplitStore.ts @@ -0,0 +1,94 @@ +/** + * 캔버스 분할선 글로벌 스토어 + * + * 성능 최적화: 이중 리스너 구조 + * - React 리스너 (subscribe): 구조적 변경만 알림 (active, isDragging 시작/종료) + * - DOM 리스너 (subscribeDom): 드래그 중 위치 변경 알림 (React 우회, 직접 DOM 조작) + */ + +export interface CanvasSplitState { + initialDividerX: number; + currentDividerX: number; + canvasWidth: number; + isDragging: boolean; + active: boolean; + scopeId: string; +} + +const initialState: CanvasSplitState = { + initialDividerX: 0, + currentDividerX: 0, + canvasWidth: 0, + isDragging: false, + active: false, + scopeId: "", +}; + +let state: CanvasSplitState = { ...initialState }; + +// React 리렌더링을 트리거하는 리스너 (구조적 변경 전용) +const reactListeners = new Set<() => void>(); + +// DOM 직접 조작용 리스너 (드래그 중 위치 업데이트, React 우회) +const domListeners = new Set<(state: CanvasSplitState) => void>(); + +// React용 스냅샷 (드래그 중 위치 변경에는 갱신 안 함) +let reactSnapshot: CanvasSplitState = { ...initialState }; + +export function setCanvasSplit(updates: Partial): void { + state = { ...state, ...updates }; + + // 드래그 중 위치만 변경 → DOM 리스너만 호출 (React 리렌더 없음) + const isPositionOnlyDuringDrag = + state.isDragging && + Object.keys(updates).length === 1 && + "currentDividerX" in updates; + + if (isPositionOnlyDuringDrag) { + domListeners.forEach((fn) => fn(state)); + return; + } + + // 구조적 변경 → React 리스너 + DOM 리스너 모두 호출 + reactSnapshot = { ...state }; + reactListeners.forEach((fn) => fn()); + domListeners.forEach((fn) => fn(state)); +} + +export function resetCanvasSplit(): void { + state = { ...initialState }; + reactSnapshot = { ...initialState }; + reactListeners.forEach((fn) => fn()); + domListeners.forEach((fn) => fn(state)); +} + +// React용: useSyncExternalStore에 연결 +export function subscribe(callback: () => void): () => void { + reactListeners.add(callback); + return () => { + reactListeners.delete(callback); + }; +} + +export function getSnapshot(): CanvasSplitState { + return reactSnapshot; +} + +export function getServerSnapshot(): CanvasSplitState { + return { ...initialState }; +} + +// DOM 직접 조작용: 드래그 중 매 프레임 위치 업데이트 수신 +export function subscribeDom( + callback: (state: CanvasSplitState) => void, +): () => void { + domListeners.add(callback); + return () => { + domListeners.delete(callback); + }; +} + +// 현재 상태 직접 참조 (DOM 리스너 콜백 외부에서 최신 상태 필요 시) +export function getCurrentState(): CanvasSplitState { + return state; +} diff --git a/frontend/lib/registry/components/v2-split-line/index.ts b/frontend/lib/registry/components/v2-split-line/index.ts new file mode 100644 index 00000000..55a3a0d1 --- /dev/null +++ b/frontend/lib/registry/components/v2-split-line/index.ts @@ -0,0 +1,41 @@ +"use client"; + +import React from "react"; +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { ComponentCategory } from "@/types/component"; +import { SplitLineWrapper } from "./SplitLineComponent"; +import { SplitLineConfigPanel } from "./SplitLineConfigPanel"; +import { SplitLineConfig } from "./types"; + +/** + * SplitLine 컴포넌트 정의 + * 캔버스를 좌우로 분할하는 드래그 가능한 세로 분할선 + */ +export const V2SplitLineDefinition = createComponentDefinition({ + id: "v2-split-line", + name: "스플릿선", + nameEng: "SplitLine Component", + description: "캔버스를 좌우로 분할하는 드래그 가능한 분할선", + category: ComponentCategory.LAYOUT, + webType: "text", + component: SplitLineWrapper, + defaultConfig: { + resizable: true, + lineColor: "#e2e8f0", + lineWidth: 4, + } as SplitLineConfig, + defaultSize: { width: 8, height: 600 }, + configPanel: SplitLineConfigPanel, + icon: "SeparatorVertical", + tags: ["스플릿", "분할", "분할선", "레이아웃"], + version: "1.0.0", + author: "개발팀", + documentation: "", +}); + +// 타입 내보내기 +export type { SplitLineConfig } from "./types"; + +// 컴포넌트 내보내기 +export { SplitLineComponent } from "./SplitLineComponent"; +export { SplitLineRenderer } from "./SplitLineRenderer"; diff --git a/frontend/lib/registry/components/v2-split-line/types.ts b/frontend/lib/registry/components/v2-split-line/types.ts new file mode 100644 index 00000000..2e3109b3 --- /dev/null +++ b/frontend/lib/registry/components/v2-split-line/types.ts @@ -0,0 +1,28 @@ +"use client"; + +import { ComponentConfig } from "@/types/component"; + +/** + * SplitLine 컴포넌트 설정 타입 + * 캔버스를 좌우로 분할하는 드래그 가능한 분할선 + * + * 초기 분할 지점은 캔버스 위 X 위치로 결정됨 (별도 splitRatio 불필요) + */ +export interface SplitLineConfig extends ComponentConfig { + // 드래그 리사이즈 허용 여부 + resizable?: boolean; + + // 분할선 스타일 + lineColor?: string; + lineWidth?: number; +} + +/** + * SplitLine 컴포넌트 Props 타입 + */ +export interface SplitLineProps { + id?: string; + config?: SplitLineConfig; + className?: string; + style?: React.CSSProperties; +} diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelContext.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelContext.tsx index fe3a9327..64fee843 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelContext.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelContext.tsx @@ -26,6 +26,8 @@ export interface SplitPanelInfo { initialLeftWidthPercent: number; // 드래그 중 여부 isDragging: boolean; + // 패널 유형: "component" = 분할 패널 레이아웃 (버튼만 조정), "canvas" = 캔버스 분할선 (모든 컴포넌트 조정) + panelType?: "component" | "canvas"; } export interface SplitPanelResizeContextValue { diff --git a/frontend/lib/schemas/componentConfig.ts b/frontend/lib/schemas/componentConfig.ts index 5908e5f3..2fd03bd4 100644 --- a/frontend/lib/schemas/componentConfig.ts +++ b/frontend/lib/schemas/componentConfig.ts @@ -689,6 +689,12 @@ const componentOverridesSchemaRegistry: Record> = { autoLoad: true, syncSelection: true, }, + "v2-split-line": { + resizable: true, + lineColor: "#e2e8f0", + lineWidth: 4, + }, "v2-section-card": { title: "섹션 제목", description: "", diff --git a/frontend/lib/utils/getComponentConfigPanel.tsx b/frontend/lib/utils/getComponentConfigPanel.tsx index 9c347b2e..10e06d83 100644 --- a/frontend/lib/utils/getComponentConfigPanel.tsx +++ b/frontend/lib/utils/getComponentConfigPanel.tsx @@ -57,6 +57,7 @@ const CONFIG_PANEL_MAP: Record Promise> = { "split-panel-layout2": () => import("@/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel"), "screen-split-panel": () => import("@/lib/registry/components/screen-split-panel/ScreenSplitPanelConfigPanel"), "conditional-container": () => import("@/lib/registry/components/conditional-container/ConditionalContainerConfigPanel"), + "v2-split-line": () => import("@/lib/registry/components/v2-split-line/SplitLineConfigPanel"), // ========== 테이블/리스트 ========== "table-list": () => import("@/lib/registry/components/table-list/TableListConfigPanel"), diff --git a/frontend/middleware.ts b/frontend/middleware.ts index eb42b4c2..d603adc7 100644 --- a/frontend/middleware.ts +++ b/frontend/middleware.ts @@ -4,36 +4,41 @@ import type { NextRequest } from "next/server"; /** * Next.js 미들웨어 * 페이지 렌더링 전에 실행되어 인증 상태를 확인하고 리다이렉트 처리 + * + * 주의: 미들웨어는 쿠키만 접근 가능 (localStorage 접근 불가) + * 따라서 토큰이 localStorage에만 있고 쿠키에 없는 경우는 + * 클라이언트 사이드의 AuthGuard에서 처리 */ export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; - // 인증 토큰 확인 - const token = request.cookies.get("authToken")?.value || request.headers.get("authorization")?.replace("Bearer ", ""); + const token = request.cookies.get("authToken")?.value; - // /login 페이지 접근 시 + // /login 페이지 접근 시 - 토큰이 있으면 메인으로 if (pathname === "/login") { - // 토큰이 있으면 메인 페이지로 리다이렉트 if (token) { const url = request.nextUrl.clone(); url.pathname = "/main"; return NextResponse.redirect(url); } - // 토큰이 없으면 로그인 페이지 표시 return NextResponse.next(); } - // 인증이 필요한 페이지들 - const protectedPaths = ["/main", "/admin", "/dashboard", "/settings"]; - const isProtectedPath = protectedPaths.some((path) => pathname.startsWith(path)); + // 보호 경로 목록 - 쿠키에 토큰이 없으면 로그인으로 + // 단, 쿠키 없이 localStorage에만 토큰이 있을 수 있으므로 + // 클라이언트에서 한 번 더 확인 후 리다이렉트 (AuthGuard) + const strictProtectedPaths = ["/admin"]; - if (isProtectedPath && !token) { - // 인증되지 않은 사용자는 로그인 페이지로 리다이렉트 + const isStrictProtected = strictProtectedPaths.some((path) => pathname.startsWith(path)); + + if (isStrictProtected && !token) { const url = request.nextUrl.clone(); url.pathname = "/login"; return NextResponse.redirect(url); } + // /main, /screens, /dashboard 등은 쿠키 없어도 통과 허용 + // (localStorage 토큰이 있을 수 있으므로 클라이언트 AuthGuard에 위임) return NextResponse.next(); } @@ -42,14 +47,6 @@ export function middleware(request: NextRequest) { */ export const config = { matcher: [ - /* - * 다음 경로를 제외한 모든 요청에 대해 실행: - * - api (API routes) - * - _next/static (static files) - * - _next/image (image optimization files) - * - favicon.ico (favicon file) - * - public 폴더의 파일들 - */ "/((?!api|_next/static|_next/image|favicon.ico|.*\\.png$|.*\\.jpg$|.*\\.jpeg$|.*\\.svg$).*)", ], };