Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node

This commit is contained in:
kjs 2026-02-24 15:30:07 +09:00
commit 0b6c305024
32 changed files with 3870 additions and 647 deletions

View File

@ -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

View File

@ -164,6 +164,7 @@ export const componentDefaults: Record<string, any> = {
"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 },
};
/**

View File

@ -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 },

View File

@ -1,8 +1,9 @@
"use client";
import { useEffect, ReactNode, useState } from "react";
import { useEffect, ReactNode } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { Loader2 } from "lucide-react";
interface AuthGuardProps {
children: ReactNode;
@ -15,6 +16,8 @@ interface AuthGuardProps {
/**
*
*
* - /401 client.ts
* -
*/
export function AuthGuard({
children,
@ -23,145 +26,67 @@ export function AuthGuard({
redirectTo = "/login",
fallback,
}: AuthGuardProps) {
const { isLoggedIn, isAdmin, loading, error } = useAuth();
const { isLoggedIn, isAdmin, loading } = useAuth();
const router = useRouter();
const [redirectCountdown, setRedirectCountdown] = useState<number | null>(null);
const [authDebugInfo, setAuthDebugInfo] = useState<any>({});
useEffect(() => {
console.log("=== AuthGuard 디버깅 ===");
console.log("requireAuth:", requireAuth);
console.log("requireAdmin:", requireAdmin);
console.log("loading:", loading);
console.log("isLoggedIn:", isLoggedIn);
console.log("isAdmin:", isAdmin);
console.log("error:", error);
if (loading) return;
// 토큰 확인을 더 정확하게
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 (
<div>
<div className="mb-4 rounded bg-primary/20 p-4">
<h3 className="font-bold">AuthGuard ...</h3>
<pre className="text-xs">{JSON.stringify(authDebugInfo, null, 2)}</pre>
fallback || (
<div className="flex h-screen items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground"> ...</p>
</div>
</div>
{fallback || <div> ...</div>}
</div>
)
);
}
// 인증 실패 시 fallback 또는 기본 메시지 표시
if (requireAuth && !isLoggedIn) {
console.log("AuthGuard: 인증 실패 - fallback 표시");
return (
<div>
<div className="mb-4 rounded bg-destructive/20 p-4">
<h3 className="font-bold"> </h3>
{redirectCountdown !== null && (
<div className="mb-2 text-destructive">
<strong> :</strong> {redirectCountdown} {redirectTo}
</div>
)}
<pre className="text-xs">{JSON.stringify(authDebugInfo, null, 2)}</pre>
fallback || (
<div className="flex h-screen items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground"> ...</p>
</div>
</div>
{fallback || <div> .</div>}
</div>
)
);
}
if (requireAdmin && !isAdmin) {
console.log("AuthGuard: 관리자 권한 없음 - fallback 표시");
return (
<div>
<div className="mb-4 rounded bg-orange-100 p-4">
<h3 className="font-bold"> </h3>
{redirectCountdown !== null && (
<div className="mb-2 text-destructive">
<strong> :</strong> {redirectCountdown} {redirectTo}
</div>
)}
<pre className="text-xs">{JSON.stringify(authDebugInfo, null, 2)}</pre>
fallback || (
<div className="flex h-screen items-center justify-center">
<p className="text-sm text-muted-foreground"> .</p>
</div>
{fallback || <div> .</div>}
</div>
)
);
}
console.log("AuthGuard: 인증 성공 - 자식 컴포넌트 렌더링");
return <>{children}</>;
}

View File

@ -1028,6 +1028,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
<ActiveTabProvider>
<TableOptionsProvider>
<div
data-screen-runtime="true"
className="relative bg-white"
style={{
width: `${screenDimensions?.width || 800}px`,

View File

@ -376,12 +376,26 @@ export const EditModal: React.FC<EditModalProps> = ({ 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<EditModalProps> = ({ className }) => {
</div>
) : screenData ? (
<div
data-screen-runtime="true"
className="relative bg-white"
style={{
width: screenDimensions?.width || 800,

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import React, { useState, useCallback, useEffect, useSyncExternalStore } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@ -20,6 +20,12 @@ import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils";
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { evaluateConditional } from "@/lib/utils/conditionalEvaluator";
import {
subscribe as canvasSplitSubscribe,
getSnapshot as canvasSplitGetSnapshot,
getServerSnapshot as canvasSplitGetServerSnapshot,
subscribeDom as canvasSplitSubscribeDom,
} from "@/lib/registry/components/v2-split-line/canvasSplitStore";
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
import "@/lib/registry/components/ButtonRenderer";
@ -82,9 +88,14 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
parentTabId,
parentTabsComponentId,
}) => {
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<string | null>(null);
// 외부에서 전달받은 사용자 정보가 있으면 우선 사용 (ScreenModal 등에서)
const userName = externalUserName || authUserName;
@ -1079,24 +1090,158 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const labelOffset = hasVisibleLabel ? (labelFontSize + labelMarginBottom + 2) : 0;
const calculateCanvasSplitX = (): { x: number; w: number } => {
const compType = (component as any).componentType || "";
const isSplitLine = type === "component" && compType === "v2-split-line";
const origX = position?.x || 0;
const defaultW = size?.width || 200;
if (isSplitLine) return { 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<HTMLDivElement>(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 (
<>
<div className="absolute" style={componentStyle}>
{/* 위젯 렌더링 (라벨은 V2Input 내부에서 absolute로 표시됨) */}
{renderInteractiveWidget(component)}
<div ref={elRef} id={`interactive-${component.id}`} className="absolute" style={componentStyle}>
{renderInteractiveWidget(splitAdjustedComponent)}
</div>
{/* 팝업 화면 렌더링 */}

View File

@ -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<RealtimePreviewProps> = ({
}
: 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<RealtimePreviewProps> = ({
(["button-primary", "button-secondary"].includes(componentType) ||
["button-primary", "button-secondary"].includes(componentId)));
// 🆕 버튼이 처음 렌더링될 때의 분할 패널 정보를 기억 (기준점)
// 레거시 분할 패널용 refs
const initialPanelRatioRef = React.useRef<number | null>(null);
const initialPanelIdRef = React.useRef<string | null>(null);
// 버튼이 좌측 패널에 속하는지 여부 (한번 설정되면 유지)
const isInLeftPanelRef = React.useRef<boolean | null>(null);
// 🆕 분할 패널 위 버튼 위치 자동 조정
const calculateButtonPosition = () => {
// 버튼이 아니거나 분할 패널 컴포넌트 자체인 경우 조정하지 않음
// 캔버스 분할선 좌/우 판정 (한 번만)
const canvasSplitSideRef = React.useRef<"left" | "right" | null>(null);
// 스코프 체크 캐시 (DOM 쿼리 최소화)
const myScopeIdRef = React.useRef<string | null>(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<RealtimePreviewProps> = ({
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 (
<div
ref={outerDivRef}
@ -602,7 +684,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
style={{ width: "100%", maxWidth: "100%" }}
>
<DynamicComponentRenderer
component={enhancedComponent}
component={splitAdjustedComp}
isSelected={isSelected}
isDesignMode={isDesignMode}
isInteractive={!isDesignMode} // 편집 모드가 아닐 때만 인터랙티브

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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<MenuState>({
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<string, MenuItem>();
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,
};
};

View File

@ -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<string | null> | null = null;
let refreshSubscribers: Array<(token: string) => void> = [];
let failedRefreshSubscribers: Array<(error: Error) => void> = [];
// 토큰 갱신 함수
const refreshToken = async (): Promise<string | null> => {
// 이미 갱신 중이면 기존 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<string> => {
return new Promise((resolve, reject) => {
refreshSubscribers.push(resolve);
failedRefreshSubscribers.push(reject);
});
};
const refreshToken = async (): Promise<string | null> => {
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<typeof setInterval> | null = null;
// 자동 토큰 갱신 시작
const startAutoRefresh = (): void => {
if (typeof window === "undefined") return;
// 기존 타이머 정리
if (tokenRefreshTimer) {
clearInterval(tokenRefreshTimer);
}
// 10분마다 토큰 상태 확인
// 5분마다 토큰 상태 확인 (기존 10분 → 5분으로 단축)
tokenRefreshTimer = setInterval(
async () => {
const token = TokenManager.getToken();
if (token && TokenManager.isTokenExpiringSoon(token)) {
console.log("[TokenManager] 토큰 만료 임박, 자동 갱신 시작...");
await refreshToken();
}
},
10 * 60 * 1000,
); // 10분
5 * 60 * 1000,
);
// 페이지 로드 시 즉시 확인
const token = TokenManager.getToken();
@ -210,29 +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<typeof setTimeout> | 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<T = unknown> {
success: boolean;
data?: T;
@ -416,7 +444,6 @@ export interface ApiResponse<T = unknown> {
errorCode?: string;
}
// 사용자 정보 타입
export interface UserInfo {
userId: string;
userName: string;
@ -430,13 +457,11 @@ export interface UserInfo {
isAdmin?: boolean;
}
// 현재 사용자 정보 조회
export const getCurrentUser = async (): Promise<ApiResponse<UserInfo>> => {
try {
const response = await apiClient.get("/auth/me");
return response.data;
} catch (error: any) {
console.error("현재 사용자 정보 조회 실패:", error);
return {
success: false,
message: error.response?.data?.message || error.message || "사용자 정보를 가져올 수 없습니다.",
@ -445,7 +470,6 @@ export const getCurrentUser = async (): Promise<ApiResponse<UserInfo>> => {
}
};
// API 호출 헬퍼 함수
export const apiCall = async <T>(
method: "GET" | "POST" | "PUT" | "DELETE",
url: string,
@ -459,7 +483,6 @@ export const apiCall = async <T>(
});
return response.data;
} catch (error: unknown) {
console.error("API 호출 실패:", error);
const axiosError = error as AxiosError;
return {
success: false,

View File

@ -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 하위품목 편집기
/**
*

View File

@ -26,6 +26,8 @@ export interface SplitPanelInfo {
initialLeftWidthPercent: number;
// 드래그 중 여부
isDragging: boolean;
// 패널 유형: "component" = 분할 패널 레이아웃 (버튼만 조정), "canvas" = 캔버스 분할선 (모든 컴포넌트 조정)
panelType?: "component" | "canvas";
}
export interface SplitPanelResizeContextValue {

View File

@ -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<string, any>;
}
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<string, any>;
companyCode?: string;
isDesignMode?: boolean;
selectedRowsData?: any[];
onChange?: (flatData: any[]) => void;
bomId?: string;
}
// 임시 ID 생성
let tempIdCounter = 0;
const generateTempId = () => `temp_${Date.now()}_${++tempIdCounter}`;
// ─── 품목 검색 모달 ───
interface ItemSearchModalProps {
open: boolean;
onClose: () => void;
onSelect: (item: ItemInfo) => void;
companyCode?: string;
}
function ItemSearchModal({
open,
onClose,
onSelect,
companyCode,
}: ItemSearchModalProps) {
const [searchText, setSearchText] = useState("");
const [items, setItems] = useState<ItemInfo[]>([]);
const [loading, setLoading] = useState(false);
const searchItems = useCallback(
async (query: string) => {
setLoading(true);
try {
const result = await entityJoinApi.getTableDataWithJoins("item_info", {
page: 1,
size: 50,
search: query
? { item_number: query, item_name: query }
: undefined,
enableEntityJoin: true,
companyCodeOverride: companyCode,
});
setItems(result.data || []);
} catch (error) {
console.error("[BomItemEditor] 품목 검색 실패:", error);
} finally {
setLoading(false);
}
},
[companyCode],
);
useEffect(() => {
if (open) {
setSearchText("");
searchItems("");
}
}, [open, searchItems]);
const handleSearch = () => {
searchItems(searchText);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleSearch();
}
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Input
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="품목코드 또는 품목명"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<Button
onClick={handleSearch}
size="sm"
className="h-8 sm:h-10"
>
<Search className="mr-1 h-4 w-4" />
</Button>
</div>
<div className="max-h-[300px] overflow-y-auto rounded-md border">
{loading ? (
<div className="flex items-center justify-center py-8">
<span className="text-muted-foreground text-sm"> ...</span>
</div>
) : items.length === 0 ? (
<div className="flex items-center justify-center py-8">
<span className="text-muted-foreground text-sm">
.
</span>
</div>
) : (
<table className="w-full text-xs sm:text-sm">
<thead className="bg-muted/50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr
key={item.id}
onClick={() => {
onSelect(item);
onClose();
}}
className="hover:bg-accent cursor-pointer border-t transition-colors"
>
<td className="px-3 py-2 font-mono">
{item.item_number}
</td>
<td className="px-3 py-2">{item.item_name}</td>
<td className="px-3 py-2">{item.type}</td>
<td className="px-3 py-2">{item.unit}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</DialogContent>
</Dialog>
);
}
// ─── 트리 노드 행 렌더링 (config.columns 기반 동적 셀) ───
interface TreeNodeRowProps {
node: BomItemNode;
depth: number;
expanded: boolean;
hasChildren: boolean;
columns: BomColumnConfig[];
categoryOptionsMap: Record<string, { value: string; label: string }[]>;
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 (
<span className="truncate text-xs" title={String(value)}>
{value || "-"}
</span>
);
}
// 카테고리 타입: API에서 로드한 옵션으로 Select 렌더링
if (col.inputType === "category") {
const categoryRef = mainTableName ? `${mainTableName}.${col.key}` : "";
const options = categoryOptionsMap[categoryRef] || [];
return (
<Select
value={String(value || "")}
onValueChange={(val) => onFieldChange(node.tempId, col.key, val)}
>
<SelectTrigger className="h-7 w-full min-w-[70px] text-xs">
<SelectValue placeholder={col.title} />
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
// 편집 불가능 컬럼
if (col.editable === false) {
return (
<span className="text-muted-foreground truncate text-xs">
{value || "-"}
</span>
);
}
// 숫자 입력
if (col.inputType === "number" || col.inputType === "decimal") {
return (
<Input
type="number"
value={String(value)}
onChange={(e) => onFieldChange(node.tempId, col.key, e.target.value)}
className="h-7 w-full min-w-[50px] text-center text-xs"
placeholder={col.title}
/>
);
}
// 기본 텍스트 입력
return (
<Input
value={String(value)}
onChange={(e) => onFieldChange(node.tempId, col.key, e.target.value)}
className="h-7 w-full min-w-[50px] text-xs"
placeholder={col.title}
/>
);
};
return (
<div
className={cn(
"group flex items-center gap-2 rounded-md border px-2 py-1.5",
"transition-colors hover:bg-accent/30",
depth > 0 && "ml-2 border-l-2 border-l-primary/20",
)}
style={{ marginLeft: `${indentPx}px` }}
>
<GripVertical className="text-muted-foreground h-4 w-4 shrink-0 cursor-grab" />
<button
onClick={onToggle}
className={cn(
"flex h-5 w-5 shrink-0 items-center justify-center rounded",
hasChildren
? "hover:bg-accent cursor-pointer"
: "cursor-default opacity-0",
)}
>
{hasChildren &&
(expanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
))}
</button>
<span className="text-muted-foreground w-6 shrink-0 text-center text-xs font-medium">
{node.seq_no}
</span>
{node.level > 0 && (
<span className="bg-primary/10 text-primary shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold">
L{node.level}
</span>
)}
{/* config.columns 기반 동적 셀 렌더링 */}
{visibleColumns.map((col) => (
<div
key={col.key}
className={cn(
"shrink-0",
col.isSourceDisplay ? "min-w-[60px] flex-1" : "min-w-[50px]",
)}
style={{ width: col.width && col.width !== "auto" ? col.width : undefined }}
>
{renderCell(col)}
</div>
))}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => onAddChild(node.tempId)}
title="하위 품목 추가"
>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10 h-7 w-7 shrink-0"
onClick={() => onDelete(node.tempId)}
title="삭제"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
);
}
// ─── 메인 컴포넌트 ───
export function BomItemEditorComponent({
component,
formData,
companyCode,
isDesignMode = false,
selectedRowsData,
onChange,
bomId: propBomId,
}: BomItemEditorProps) {
const [treeData, setTreeData] = useState<BomItemNode[]>([]);
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [itemSearchOpen, setItemSearchOpen] = useState(false);
const [addTargetParentId, setAddTargetParentId] = useState<string | null>(null);
const [categoryOptionsMap, setCategoryOptionsMap] = useState<Record<string, { value: string; label: string }[]>>({});
// 설정값 추출
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<string>(
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<string, BomItemNode>();
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<string, any> = {};
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 (
<React.Fragment key={node.tempId}>
<TreeNodeRow
node={node}
depth={depth}
expanded={isExpanded}
hasChildren={node.children.length > 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)}
</React.Fragment>
);
});
};
// ─── 디자인 모드 미리보기 ───
if (isDesignMode) {
const cfg = component?.componentConfig || {};
const hasConfig =
cfg.mainTableName || cfg.dataSource?.sourceTable || (cfg.columns && cfg.columns.length > 0);
if (!hasConfig) {
return (
<div className="rounded-md border border-dashed p-6 text-center">
<Package className="text-muted-foreground mx-auto mb-2 h-8 w-8" />
<p className="text-muted-foreground text-sm font-medium">
BOM
</p>
<p className="text-muted-foreground text-xs">
</p>
</div>
);
}
const visibleColumns = (cfg.columns || []).filter((c: any) => c.visible !== false);
const DUMMY_DATA: Record<string, string[]> = {
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 (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold"> </h4>
<Button size="sm" className="h-7 text-xs" disabled>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{/* 설정 요약 뱃지 */}
<div className="flex flex-wrap gap-1">
{cfg.mainTableName && (
<span className="rounded bg-orange-100 px-1.5 py-0.5 text-[10px] text-orange-700">
: {cfg.mainTableName}
</span>
)}
{cfg.dataSource?.sourceTable && (
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700">
: {cfg.dataSource.sourceTable}
</span>
)}
{cfg.parentKeyColumn && (
<span className="rounded bg-green-100 px-1.5 py-0.5 text-[10px] text-green-700">
: {cfg.parentKeyColumn}
</span>
)}
</div>
{/* 테이블 형태 미리보기 - config.columns 순서 그대로 */}
<div className="overflow-hidden rounded-md border">
{visibleColumns.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6">
<Package className="text-muted-foreground mb-1.5 h-6 w-6" />
<p className="text-muted-foreground text-xs">
</p>
</div>
) : (
<table className="w-full text-[10px]">
<thead className="bg-muted/60">
<tr>
<th className="w-6 px-1 py-1.5 text-center font-medium" />
<th className="w-5 px-0.5 py-1.5 text-center font-medium">#</th>
{visibleColumns.map((col: any) => (
<th
key={col.key}
className={cn(
"px-2 py-1.5 text-left font-medium",
col.isSourceDisplay && "text-blue-600",
)}
style={{ width: col.width && col.width !== "auto" ? col.width : undefined }}
>
{col.title}
</th>
))}
<th className="w-14 px-1 py-1.5 text-center font-medium"></th>
</tr>
</thead>
<tbody>
{DUMMY_DEPTHS.map((depth, rowIdx) => (
<tr
key={rowIdx}
className={cn(
"border-t transition-colors",
rowIdx === 0 && "bg-accent/20",
)}
>
<td className="px-1 py-1 text-center">
<div className="flex items-center justify-center gap-0.5" style={{ paddingLeft: `${depth * 10}px` }}>
{depth === 0 ? (
<ChevronDown className="h-3 w-3 opacity-50" />
) : (
<span className="text-primary/40 text-[10px]"></span>
)}
</div>
</td>
<td className="text-muted-foreground px-0.5 py-1 text-center">
{rowIdx + 1}
</td>
{visibleColumns.map((col: any) => (
<td key={col.key} className="px-1.5 py-0.5">
{col.isSourceDisplay ? (
<span className="truncate text-blue-600">
{getDummyValue(col, rowIdx) || col.title}
</span>
) : col.editable !== false ? (
<div className="h-5 rounded border bg-background px-1.5 text-[10px] leading-5">
{getDummyValue(col, rowIdx)}
</div>
) : (
<span className="text-muted-foreground">
{getDummyValue(col, rowIdx)}
</span>
)}
</td>
))}
<td className="px-1 py-1 text-center">
<div className="flex items-center justify-center gap-0.5">
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center rounded opacity-40">
<Plus className="h-3 w-3" />
</div>
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center rounded opacity-40">
<X className="h-3 w-3" />
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
// ─── 메인 렌더링 ───
return (
<div className="space-y-3">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold"> </h4>
<Button
onClick={handleAddRoot}
size="sm"
className="h-8 text-xs"
>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
{/* 트리 목록 */}
<div className="space-y-1">
{loading ? (
<div className="flex items-center justify-center py-8">
<span className="text-muted-foreground text-sm"> ...</span>
</div>
) : treeData.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-md border border-dashed py-8">
<Package className="text-muted-foreground mb-2 h-8 w-8" />
<p className="text-muted-foreground text-sm">
.
</p>
<p className="text-muted-foreground text-xs">
&quot;&quot; .
</p>
</div>
) : (
renderNodes(treeData, 0)
)}
</div>
{/* 품목 검색 모달 */}
<ItemSearchModal
open={itemSearchOpen}
onClose={() => setItemSearchOpen(false)}
onSelect={handleItemSelect}
companyCode={companyCode}
/>
</div>
);
}
export default BomItemEditorComponent;

View File

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

View File

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

View File

@ -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<string, any>;
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<BomHeaderInfo | null>(null);
const [treeData, setTreeData] = useState<BomTreeNode[]>([]);
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(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<string>(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<string, BomTreeNode>();
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<string>();
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 (
<div className="flex h-full flex-col rounded-md border bg-white p-4">
<div className="mb-3 flex items-center gap-2">
<Layers className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">BOM </span>
</div>
<div className="flex-1 space-y-1 rounded border border-dashed border-gray-300 p-3">
<div className="flex items-center gap-2 text-xs text-gray-500">
<ChevronDown className="h-3 w-3" />
<Package className="h-3 w-3 text-blue-500" />
<span> A ()</span>
<span className="ml-auto text-gray-400">수량: 1</span>
</div>
<div className="ml-5 flex items-center gap-2 text-xs text-gray-500">
<ChevronRight className="h-3 w-3" />
<Layers className="h-3 w-3 text-amber-500" />
<span> B ()</span>
<span className="ml-auto text-gray-400">수량: 2</span>
</div>
<div className="ml-5 flex items-center gap-2 text-xs text-gray-500">
<span className="ml-3.5" />
<Box className="h-3 w-3 text-emerald-500" />
<span> C ()</span>
<span className="ml-auto text-gray-400">수량: 5</span>
</div>
</div>
</div>
);
}
// 선택 안 된 상태
if (!selectedBomId) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-muted-foreground text-center text-sm">
<p className="mb-2"> BOM을 </p>
<p className="text-xs"> BOM의 </p>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col">
{/* 헤더 정보 */}
{headerInfo && (
<div className="border-b bg-gray-50/80 px-4 py-3">
<div className="mb-2 flex items-center gap-2">
<Package className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold">{headerInfo.item_name || "-"}</h3>
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
{headerInfo.bom_number || "-"}
</span>
<span className={cn(
"ml-1 rounded px-1.5 py-0.5 text-[10px] font-medium",
headerInfo.status === "active" ? "bg-emerald-100 text-emerald-700" : "bg-gray-100 text-gray-500"
)}>
{headerInfo.status === "active" ? "사용" : "미사용"}
</span>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>: <b className="text-foreground">{headerInfo.item_code || "-"}</b></span>
<span>: <b className="text-foreground">{getItemTypeLabel(headerInfo.item_type)}</b></span>
<span>: <b className="text-foreground">{headerInfo.base_qty || "1"} {headerInfo.unit || ""}</b></span>
<span>: <b className="text-foreground">v{headerInfo.version || "1.0"} ( {headerInfo.revision || "1"})</b></span>
</div>
</div>
)}
{/* 트리 툴바 */}
<div className="flex items-center gap-2 border-b px-4 py-2">
<span className="text-xs font-medium text-muted-foreground">BOM </span>
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
{treeData.length}
</span>
<div className="ml-auto flex gap-1">
<button
onClick={expandAll}
className="rounded px-2 py-0.5 text-[10px] text-muted-foreground hover:bg-gray-100"
>
</button>
<button
onClick={collapseAll}
className="rounded px-2 py-0.5 text-[10px] text-muted-foreground hover:bg-gray-100"
>
</button>
</div>
</div>
{/* 트리 컨텐츠 */}
<div className="flex-1 overflow-auto px-2 py-2">
{loading ? (
<div className="flex h-32 items-center justify-center">
<div className="text-muted-foreground text-sm"> ...</div>
</div>
) : treeData.length === 0 ? (
<div className="flex h-32 flex-col items-center justify-center gap-2">
<AlertCircle className="h-5 w-5 text-muted-foreground" />
<p className="text-xs text-muted-foreground"> </p>
</div>
) : (
<div className="space-y-0.5">
{treeData.map((node) => (
<TreeNodeRow
key={node.id}
node={node}
depth={0}
expandedNodes={expandedNodes}
selectedNodeId={selectedNodeId}
onToggle={toggleNode}
onSelect={setSelectedNodeId}
getItemTypeLabel={getItemTypeLabel}
getItemTypeStyle={getItemTypeStyle}
/>
))}
</div>
)}
</div>
</div>
);
}
/**
* ( )
*/
interface TreeNodeRowProps {
node: BomTreeNode;
depth: number;
expandedNodes: Set<string>;
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 (
<>
<div
className={cn(
"group flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1.5 transition-colors",
isSelected ? "bg-primary/10" : "hover:bg-gray-50"
)}
style={{ paddingLeft: `${depth * 20 + 8}px` }}
onClick={() => {
onSelect(node.id);
if (hasChildren) onToggle(node.id);
}}
>
{/* 펼치기/접기 화살표 */}
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center">
{hasChildren ? (
isExpanded ? (
<ChevronDown className="h-3.5 w-3.5 text-gray-400" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-gray-400" />
)
) : (
<span className="h-1 w-1 rounded-full bg-gray-300" />
)}
</span>
{/* 품목 타입 아이콘 */}
<span className={cn("flex h-5 w-5 flex-shrink-0 items-center justify-center rounded", style.bg)}>
<ItemIcon className={cn("h-3 w-3", style.color)} />
</span>
{/* 품목 정보 */}
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate text-xs font-medium text-foreground">
{node.child_item_name || "-"}
</span>
<span className="flex-shrink-0 text-[10px] text-muted-foreground">
{node.child_item_code || ""}
</span>
<span className={cn(
"flex-shrink-0 rounded px-1 py-0.5 text-[10px]",
style.bg, style.color
)}>
{getItemTypeLabel(node.child_item_type)}
</span>
</div>
{/* 수량/단위 */}
<div className="flex flex-shrink-0 items-center gap-2 text-[11px]">
<span className="text-muted-foreground">
: <b className="text-foreground">{node.quantity || "0"}</b> {node.unit || ""}
</span>
{node.loss_rate && node.loss_rate !== "0" && (
<span className="text-amber-600">
: {node.loss_rate}%
</span>
)}
</div>
</div>
{/* 하위 노드 재귀 렌더링 */}
{hasChildren && isExpanded && (
<div>
{node.children.map((child) => (
<TreeNodeRow
key={child.id}
node={child}
depth={depth + 1}
expandedNodes={expandedNodes}
selectedNodeId={selectedNodeId}
onToggle={onToggle}
onSelect={onSelect}
getItemTypeLabel={getItemTypeLabel}
getItemTypeStyle={getItemTypeStyle}
/>
))}
</div>
)}
</>
);
}

View File

@ -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 <BomTreeComponent {...this.props} />;
}
}
BomTreeRenderer.registerSelf();
if (typeof window !== "undefined") {
setTimeout(() => {
try {
BomTreeRenderer.registerSelf();
} catch (error) {
console.error("BomTree 등록 실패:", error);
}
}, 1000);
}

View File

@ -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;

View File

@ -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<SplitLineComponentProps> = ({
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<HTMLDivElement>(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 (
<div
ref={containerRef}
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
position: "relative",
...style,
}}
className={className}
onClick={handleClick}
{...domProps}
>
<div
style={{
width: `${lineWidth}px`,
height: "100%",
borderLeft: `${lineWidth}px dashed ${isSelected ? "#3b82f6" : lineColor}`,
}}
/>
<div
style={{
position: "absolute",
top: "4px",
left: "50%",
transform: "translateX(-50%)",
fontSize: "10px",
color: isSelected ? "#3b82f6" : "#9ca3af",
whiteSpace: "nowrap",
backgroundColor: "rgba(255,255,255,0.9)",
padding: "1px 6px",
borderRadius: "4px",
fontWeight: 500,
}}
>
릿
</div>
</div>
);
}
return (
<div
ref={containerRef}
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: resizable ? "col-resize" : "default",
transform: `translateX(${dragOffset}px)`,
transition: isDragging ? "none" : "transform 0.1s ease-out",
zIndex: 50,
...style,
}}
className={className}
onMouseDown={handleMouseDown}
{...domProps}
>
<div
style={{
width: `${lineWidth}px`,
height: "100%",
backgroundColor: isDragging ? "hsl(var(--primary))" : lineColor,
transition: isDragging ? "none" : "background-color 0.15s ease",
}}
className="hover:bg-primary"
/>
</div>
);
};
export const SplitLineWrapper: React.FC<SplitLineComponentProps> = (props) => {
return <SplitLineComponent {...props} />;
};

View File

@ -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<SplitLineConfigPanelProps> = ({ config, onConfigChange }) => {
const currentConfig = config || {};
const handleChange = (key: string, value: any) => {
onConfigChange({
...currentConfig,
[key]: value,
});
};
return (
<div className="space-y-4 p-4">
<h3 className="text-sm font-semibold">릿 </h3>
{/* 드래그 리사이즈 허용 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={currentConfig.resizable ?? true}
onCheckedChange={(checked) => handleChange("resizable", checked)}
/>
</div>
{/* 분할선 스타일 */}
<div className="space-y-2">
<Label className="text-xs"> (px)</Label>
<Input
type="number"
value={currentConfig.lineWidth || 4}
onChange={(e) => handleChange("lineWidth", parseInt(e.target.value) || 4)}
className="h-8 text-xs"
min={1}
max={12}
/>
</div>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<div className="flex items-center gap-2">
<input
type="color"
value={currentConfig.lineColor || "#e2e8f0"}
onChange={(e) => handleChange("lineColor", e.target.value)}
className="h-8 w-8 cursor-pointer rounded border"
/>
<Input
value={currentConfig.lineColor || "#e2e8f0"}
onChange={(e) => handleChange("lineColor", e.target.value)}
className="h-8 flex-1 text-xs"
placeholder="#e2e8f0"
/>
</div>
</div>
<p className="text-[10px] text-muted-foreground">
릿 X .
.
</p>
</div>
);
};
export default SplitLineConfigPanel;

View File

@ -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 <SplitLineComponent {...this.props} renderer={this} />;
}
protected handleValueChange = (value: any) => {
this.updateComponent({ value });
};
}
// 자동 등록 실행
SplitLineRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
SplitLineRenderer.enableHotReload();
}

View File

@ -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<CanvasSplitState>): 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;
}

View File

@ -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";

View File

@ -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;
}

View File

@ -26,6 +26,8 @@ export interface SplitPanelInfo {
initialLeftWidthPercent: number;
// 드래그 중 여부
isDragging: boolean;
// 패널 유형: "component" = 분할 패널 레이아웃 (버튼만 조정), "canvas" = 캔버스 분할선 (모든 컴포넌트 조정)
panelType?: "component" | "canvas";
}
export interface SplitPanelResizeContextValue {

View File

@ -689,6 +689,12 @@ const componentOverridesSchemaRegistry: Record<string, z.ZodType<Record<string,
"v2-repeater": v2V2RepeaterOverridesSchema,
// V2 컴포넌트 (9개)
"v2-split-line": z.object({
resizable: z.boolean().default(true),
lineColor: z.string().default("#e2e8f0"),
lineWidth: z.number().default(4),
}).passthrough(),
"v2-input": v2InputOverridesSchema,
"v2-select": v2SelectOverridesSchema,
"v2-date": v2DateOverridesSchema,
@ -738,6 +744,11 @@ const componentDefaultsRegistry: Record<string, Record<string, any>> = {
autoLoad: true,
syncSelection: true,
},
"v2-split-line": {
resizable: true,
lineColor: "#e2e8f0",
lineWidth: 4,
},
"v2-section-card": {
title: "섹션 제목",
description: "",

View File

@ -57,6 +57,7 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
"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"),

View File

@ -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$).*)",
],
};