Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node
This commit is contained in:
commit
0b6c305024
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, ReactNode, useState } from "react";
|
||||
import { useEffect, ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: ReactNode;
|
||||
|
|
@ -15,6 +16,8 @@ interface AuthGuardProps {
|
|||
/**
|
||||
* 인증 보호 컴포넌트
|
||||
* 로그인 상태 및 권한에 따라 접근을 제어
|
||||
* - 토큰 갱신/401 처리는 client.ts 인터셉터가 담당
|
||||
* - 이 컴포넌트는 인증 상태 기반 라우팅 가드 역할만 수행
|
||||
*/
|
||||
export function AuthGuard({
|
||||
children,
|
||||
|
|
@ -23,145 +26,67 @@ export function AuthGuard({
|
|||
redirectTo = "/login",
|
||||
fallback,
|
||||
}: AuthGuardProps) {
|
||||
const { isLoggedIn, isAdmin, loading, error } = useAuth();
|
||||
const { isLoggedIn, isAdmin, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
const [redirectCountdown, setRedirectCountdown] = useState<number | null>(null);
|
||||
const [authDebugInfo, setAuthDebugInfo] = useState<any>({});
|
||||
|
||||
useEffect(() => {
|
||||
console.log("=== AuthGuard 디버깅 ===");
|
||||
console.log("requireAuth:", requireAuth);
|
||||
console.log("requireAdmin:", requireAdmin);
|
||||
console.log("loading:", loading);
|
||||
console.log("isLoggedIn:", isLoggedIn);
|
||||
console.log("isAdmin:", isAdmin);
|
||||
console.log("error:", error);
|
||||
if (loading) return;
|
||||
|
||||
// 토큰 확인을 더 정확하게
|
||||
// 토큰이 있는데 아직 인증 확인 중이면 대기
|
||||
if (typeof window !== "undefined") {
|
||||
const token = localStorage.getItem("authToken");
|
||||
console.log("AuthGuard localStorage 토큰:", token ? "존재" : "없음");
|
||||
console.log("현재 경로:", window.location.pathname);
|
||||
|
||||
// 디버깅 정보 수집
|
||||
setAuthDebugInfo({
|
||||
requireAuth,
|
||||
requireAdmin,
|
||||
loading,
|
||||
isLoggedIn,
|
||||
isAdmin,
|
||||
error,
|
||||
hasToken: !!token,
|
||||
currentPath: window.location.pathname,
|
||||
timestamp: new Date().toISOString(),
|
||||
tokenLength: token ? token.length : 0,
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
console.log("AuthGuard: 로딩 중 - 대기");
|
||||
return;
|
||||
}
|
||||
|
||||
// 토큰이 있는데도 인증이 안 된 경우, 잠시 대기
|
||||
if (token && !isLoggedIn && !loading) {
|
||||
console.log("AuthGuard: 토큰은 있지만 인증이 안됨 - 잠시 대기");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 인증이 필요한데 로그인되지 않은 경우
|
||||
if (requireAuth && !isLoggedIn) {
|
||||
console.log("AuthGuard: 인증 필요하지만 로그인되지 않음 - 5초 후 리다이렉트");
|
||||
console.log("리다이렉트 대상:", redirectTo);
|
||||
|
||||
setRedirectCountdown(5);
|
||||
const countdownInterval = setInterval(() => {
|
||||
setRedirectCountdown((prev) => {
|
||||
if (prev === null || prev <= 1) {
|
||||
clearInterval(countdownInterval);
|
||||
router.push(redirectTo);
|
||||
return null;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 관리자 권한이 필요한데 관리자가 아닌 경우
|
||||
if (requireAdmin && !isAdmin) {
|
||||
console.log("AuthGuard: 관리자 권한 필요하지만 관리자가 아님 - 5초 후 리다이렉트");
|
||||
console.log("리다이렉트 대상:", redirectTo);
|
||||
|
||||
setRedirectCountdown(5);
|
||||
const countdownInterval = setInterval(() => {
|
||||
setRedirectCountdown((prev) => {
|
||||
if (prev === null || prev <= 1) {
|
||||
clearInterval(countdownInterval);
|
||||
router.push(redirectTo);
|
||||
return null;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return;
|
||||
}
|
||||
}, [requireAuth, requireAdmin, loading, isLoggedIn, isAdmin, redirectTo, router]);
|
||||
|
||||
console.log("AuthGuard: 모든 인증 조건 통과 - 컴포넌트 렌더링");
|
||||
}, [requireAuth, requireAdmin, loading, isLoggedIn, isAdmin, error, redirectTo, router]);
|
||||
|
||||
// 로딩 중일 때 fallback 또는 기본 로딩 표시
|
||||
if (loading) {
|
||||
console.log("AuthGuard: 로딩 중 - fallback 표시");
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 rounded bg-primary/20 p-4">
|
||||
<h3 className="font-bold">AuthGuard 로딩 중...</h3>
|
||||
<pre className="text-xs">{JSON.stringify(authDebugInfo, null, 2)}</pre>
|
||||
fallback || (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
{fallback || <div>로딩 중...</div>}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// 인증 실패 시 fallback 또는 기본 메시지 표시
|
||||
if (requireAuth && !isLoggedIn) {
|
||||
console.log("AuthGuard: 인증 실패 - fallback 표시");
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 rounded bg-destructive/20 p-4">
|
||||
<h3 className="font-bold">인증 실패</h3>
|
||||
{redirectCountdown !== null && (
|
||||
<div className="mb-2 text-destructive">
|
||||
<strong>리다이렉트 카운트다운:</strong> {redirectCountdown}초 후 {redirectTo}로 이동
|
||||
fallback || (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">인증 확인 중...</p>
|
||||
</div>
|
||||
)}
|
||||
<pre className="text-xs">{JSON.stringify(authDebugInfo, null, 2)}</pre>
|
||||
</div>
|
||||
{fallback || <div>인증이 필요합니다.</div>}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (requireAdmin && !isAdmin) {
|
||||
console.log("AuthGuard: 관리자 권한 없음 - fallback 표시");
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 rounded bg-orange-100 p-4">
|
||||
<h3 className="font-bold">관리자 권한 없음</h3>
|
||||
{redirectCountdown !== null && (
|
||||
<div className="mb-2 text-destructive">
|
||||
<strong>리다이렉트 카운트다운:</strong> {redirectCountdown}초 후 {redirectTo}로 이동
|
||||
</div>
|
||||
)}
|
||||
<pre className="text-xs">{JSON.stringify(authDebugInfo, null, 2)}</pre>
|
||||
</div>
|
||||
{fallback || <div>관리자 권한이 필요합니다.</div>}
|
||||
fallback || (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">관리자 권한이 필요합니다.</p>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
console.log("AuthGuard: 인증 성공 - 자식 컴포넌트 렌더링");
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
{/* 팝업 화면 렌더링 */}
|
||||
|
|
|
|||
|
|
@ -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} // 편집 모드가 아닐 때만 인터랙티브
|
||||
|
|
|
|||
|
|
@ -81,6 +81,22 @@ export function ComponentsPanel({
|
|||
tags: ["repeater", "table", "modal", "button", "v2", "v2"],
|
||||
defaultSize: { width: 600, height: 300 },
|
||||
},
|
||||
{
|
||||
id: "v2-bom-tree",
|
||||
name: "BOM 트리 뷰",
|
||||
description: "BOM 구성을 계층 트리 형태로 조회",
|
||||
category: "data" as ComponentCategory,
|
||||
tags: ["bom", "tree", "계층", "제조", "v2"],
|
||||
defaultSize: { width: 900, height: 600 },
|
||||
},
|
||||
{
|
||||
id: "v2-bom-item-editor",
|
||||
name: "BOM 하위품목 편집기",
|
||||
description: "BOM 하위 품목을 트리 구조로 추가/편집/삭제",
|
||||
category: "data" as ComponentCategory,
|
||||
tags: ["bom", "tree", "편집", "하위품목", "제조", "v2"],
|
||||
defaultSize: { width: 900, height: 400 },
|
||||
},
|
||||
] as unknown as ComponentDefinition[],
|
||||
[],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -216,6 +216,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
"v2-media": require("@/components/v2/config-panels/V2MediaConfigPanel").V2MediaConfigPanel,
|
||||
"v2-biz": require("@/components/v2/config-panels/V2BizConfigPanel").V2BizConfigPanel,
|
||||
"v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel").V2HierarchyConfigPanel,
|
||||
"v2-bom-item-editor": require("@/components/v2/config-panels/V2BomItemEditorConfigPanel").V2BomItemEditorConfigPanel,
|
||||
};
|
||||
|
||||
const V2ConfigPanel = v2ConfigPanels[componentId];
|
||||
|
|
@ -239,6 +240,10 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
if (componentId === "v2-list") {
|
||||
extraProps.currentTableName = currentTableName;
|
||||
}
|
||||
if (componentId === "v2-bom-item-editor") {
|
||||
extraProps.currentTableName = currentTableName;
|
||||
extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={selectedComponent.id} className="space-y-4">
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,8 +1,7 @@
|
|||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiCall, API_BASE_URL } from "@/lib/api/client";
|
||||
import { apiCall } from "@/lib/api/client";
|
||||
|
||||
// 사용자 정보 타입 정의
|
||||
interface UserInfo {
|
||||
userId: string;
|
||||
userName: string;
|
||||
|
|
@ -23,11 +22,10 @@ interface UserInfo {
|
|||
isAdmin: boolean;
|
||||
sabun?: string;
|
||||
photo?: string | null;
|
||||
companyCode?: string; // 백엔드와 일치하도록 수정
|
||||
company_code?: string; // 하위 호환성을 위해 유지
|
||||
companyCode?: string;
|
||||
company_code?: string;
|
||||
}
|
||||
|
||||
// 인증 상태 타입 정의
|
||||
interface AuthStatus {
|
||||
isLoggedIn: boolean;
|
||||
isAdmin: boolean;
|
||||
|
|
@ -35,14 +33,12 @@ interface AuthStatus {
|
|||
deptCode?: string;
|
||||
}
|
||||
|
||||
// 로그인 결과 타입 정의
|
||||
interface LoginResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
errorCode?: string;
|
||||
}
|
||||
|
||||
// API 응답 타입 정의
|
||||
interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
message: string;
|
||||
|
|
@ -50,9 +46,7 @@ interface ApiResponse<T = any> {
|
|||
errorCode?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 토큰 관리 유틸리티
|
||||
*/
|
||||
// JWT 토큰 관리 유틸리티 (client.ts와 동일한 localStorage 키 사용)
|
||||
const TokenManager = {
|
||||
getToken: (): string | null => {
|
||||
if (typeof window !== "undefined") {
|
||||
|
|
@ -63,7 +57,6 @@ const TokenManager = {
|
|||
|
||||
setToken: (token: string): void => {
|
||||
if (typeof window !== "undefined") {
|
||||
// localStorage에 저장
|
||||
localStorage.setItem("authToken", token);
|
||||
// 쿠키에도 저장 (미들웨어에서 사용)
|
||||
document.cookie = `authToken=${token}; path=/; max-age=86400; SameSite=Lax`;
|
||||
|
|
@ -72,9 +65,7 @@ const TokenManager = {
|
|||
|
||||
removeToken: (): void => {
|
||||
if (typeof window !== "undefined") {
|
||||
// localStorage에서 제거
|
||||
localStorage.removeItem("authToken");
|
||||
// 쿠키에서도 제거
|
||||
document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax";
|
||||
}
|
||||
},
|
||||
|
|
@ -91,12 +82,12 @@ const TokenManager = {
|
|||
|
||||
/**
|
||||
* 인증 상태 관리 훅
|
||||
* 로그인, 로그아웃, 사용자 정보 조회, 권한 확인 등을 담당
|
||||
* - 401 처리는 client.ts의 응답 인터셉터에서 통합 관리
|
||||
* - 이 훅은 상태 관리와 사용자 정보 조회에만 집중
|
||||
*/
|
||||
export const useAuth = () => {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태 관리
|
||||
const [user, setUser] = useState<UserInfo | null>(null);
|
||||
const [authStatus, setAuthStatus] = useState<AuthStatus>({
|
||||
isLoggedIn: false,
|
||||
|
|
@ -106,8 +97,6 @@ export const useAuth = () => {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
// API 기본 URL 설정 (동적으로 결정)
|
||||
|
||||
/**
|
||||
* 현재 사용자 정보 조회
|
||||
*/
|
||||
|
|
@ -116,26 +105,19 @@ export const useAuth = () => {
|
|||
const response = await apiCall<UserInfo>("GET", "/auth/me");
|
||||
|
||||
if (response.success && response.data) {
|
||||
// 사용자 로케일 정보도 함께 조회하여 전역 저장
|
||||
// 사용자 로케일 정보 조회
|
||||
try {
|
||||
const localeResponse = await apiCall<string>("GET", "/admin/user-locale");
|
||||
if (localeResponse.success && localeResponse.data) {
|
||||
const userLocale = localeResponse.data;
|
||||
|
||||
// 전역 상태에 저장 (다른 컴포넌트에서 사용)
|
||||
(window as any).__GLOBAL_USER_LANG = userLocale;
|
||||
(window as any).__GLOBAL_USER_LOCALE_LOADED = true;
|
||||
|
||||
// localStorage에도 저장 (새 창에서 공유)
|
||||
localStorage.setItem("userLocale", userLocale);
|
||||
localStorage.setItem("userLocaleLoaded", "true");
|
||||
}
|
||||
} catch (localeError) {
|
||||
console.warn("⚠️ 사용자 로케일 조회 실패, 기본값 사용:", localeError);
|
||||
} catch {
|
||||
(window as any).__GLOBAL_USER_LANG = "KR";
|
||||
(window as any).__GLOBAL_USER_LOCALE_LOADED = true;
|
||||
|
||||
// localStorage에도 저장
|
||||
localStorage.setItem("userLocale", "KR");
|
||||
localStorage.setItem("userLocaleLoaded", "true");
|
||||
}
|
||||
|
|
@ -144,8 +126,7 @@ export const useAuth = () => {
|
|||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("사용자 정보 조회 실패:", error);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
|
@ -157,95 +138,66 @@ export const useAuth = () => {
|
|||
try {
|
||||
const response = await apiCall<AuthStatus>("GET", "/auth/status");
|
||||
if (response.success && response.data) {
|
||||
// 백엔드에서 isAuthenticated를 반환하므로 isLoggedIn으로 매핑
|
||||
const mappedData = {
|
||||
return {
|
||||
isLoggedIn: (response.data as any).isAuthenticated || response.data.isLoggedIn || false,
|
||||
isAdmin: response.data.isAdmin || false,
|
||||
};
|
||||
return mappedData;
|
||||
}
|
||||
|
||||
return {
|
||||
isLoggedIn: false,
|
||||
isAdmin: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("인증 상태 확인 실패:", error);
|
||||
return {
|
||||
isLoggedIn: false,
|
||||
isAdmin: false,
|
||||
};
|
||||
return { isLoggedIn: false, isAdmin: false };
|
||||
} catch {
|
||||
return { isLoggedIn: false, isAdmin: false };
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 사용자 데이터 새로고침
|
||||
* - API 실패 시에도 토큰이 유효하면 토큰 기반으로 임시 인증 유지
|
||||
* - 토큰 자체가 없거나 만료된 경우에만 비인증 상태로 전환
|
||||
*/
|
||||
const refreshUserData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// JWT 토큰 확인
|
||||
const token = TokenManager.getToken();
|
||||
if (!token) {
|
||||
if (!token || TokenManager.isTokenExpired(token)) {
|
||||
setUser(null);
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
setTimeout(() => {
|
||||
router.push("/login");
|
||||
}, 3000);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 토큰이 있으면 임시로 인증된 상태로 설정
|
||||
// 토큰이 유효하면 우선 인증된 상태로 설정
|
||||
setAuthStatus({
|
||||
isLoggedIn: true,
|
||||
isAdmin: false, // API 호출 후 업데이트될 예정
|
||||
isAdmin: false,
|
||||
});
|
||||
|
||||
try {
|
||||
// 병렬로 사용자 정보와 인증 상태 조회
|
||||
const [userInfo, authStatusData] = await Promise.all([fetchCurrentUser(), checkAuthStatus()]);
|
||||
|
||||
if (userInfo) {
|
||||
setUser(userInfo);
|
||||
|
||||
// 관리자 권한 확인 로직 개선
|
||||
let finalAuthStatus = authStatusData;
|
||||
if (userInfo) {
|
||||
// 사용자 정보를 기반으로 관리자 권한 추가 확인
|
||||
const isAdminFromUser = userInfo.userId === "plm_admin" || userInfo.userType === "ADMIN";
|
||||
finalAuthStatus = {
|
||||
const finalAuthStatus = {
|
||||
isLoggedIn: authStatusData.isLoggedIn,
|
||||
isAdmin: authStatusData.isAdmin || isAdminFromUser,
|
||||
};
|
||||
}
|
||||
|
||||
setAuthStatus(finalAuthStatus);
|
||||
|
||||
// console.log("✅ 최종 사용자 상태:", {
|
||||
// userId: userInfo?.userId,
|
||||
// userName: userInfo?.userName,
|
||||
// companyCode: userInfo?.companyCode || userInfo?.company_code,
|
||||
// });
|
||||
|
||||
// 디버깅용 로그
|
||||
|
||||
// 로그인되지 않은 상태인 경우 토큰 제거 (리다이렉트는 useEffect에서 처리)
|
||||
// API 결과가 비인증이면 상태만 업데이트 (리다이렉트는 client.ts가 처리)
|
||||
if (!finalAuthStatus.isLoggedIn) {
|
||||
TokenManager.removeToken();
|
||||
setUser(null);
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
} else {
|
||||
}
|
||||
} catch (apiError) {
|
||||
console.error("API 호출 실패:", apiError);
|
||||
|
||||
// API 호출 실패 시에도 토큰이 있으면 임시로 인증된 상태로 처리
|
||||
|
||||
// 토큰에서 사용자 정보 추출 시도
|
||||
} else {
|
||||
// userInfo 조회 실패 → 토큰에서 최소 정보 추출하여 유지
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
|
||||
const tempUser = {
|
||||
const tempUser: UserInfo = {
|
||||
userId: payload.userId || payload.id || "unknown",
|
||||
userName: payload.userName || payload.name || "사용자",
|
||||
companyCode: payload.companyCode || payload.company_code || "",
|
||||
|
|
@ -257,32 +209,43 @@ export const useAuth = () => {
|
|||
isLoggedIn: true,
|
||||
isAdmin: tempUser.isAdmin,
|
||||
});
|
||||
} catch (tokenError) {
|
||||
console.error("토큰 파싱 실패:", tokenError);
|
||||
// 토큰 파싱도 실패하면 로그인 페이지로 리다이렉트
|
||||
} catch {
|
||||
// 토큰 파싱도 실패하면 비인증 상태로 전환
|
||||
TokenManager.removeToken();
|
||||
setUser(null);
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
setTimeout(() => {
|
||||
router.push("/login");
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("사용자 데이터 새로고침 실패:", error);
|
||||
setError("사용자 정보를 불러오는데 실패했습니다.");
|
||||
} catch {
|
||||
// API 호출 전체 실패 → 토큰 기반 임시 인증 유지 시도
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
const tempUser: UserInfo = {
|
||||
userId: payload.userId || payload.id || "unknown",
|
||||
userName: payload.userName || payload.name || "사용자",
|
||||
companyCode: payload.companyCode || payload.company_code || "",
|
||||
isAdmin: payload.userId === "plm_admin" || payload.userType === "ADMIN",
|
||||
};
|
||||
|
||||
// 오류 발생 시 토큰 제거 및 로그인 페이지로 리다이렉트
|
||||
setUser(tempUser);
|
||||
setAuthStatus({
|
||||
isLoggedIn: true,
|
||||
isAdmin: tempUser.isAdmin,
|
||||
});
|
||||
} catch {
|
||||
TokenManager.removeToken();
|
||||
setUser(null);
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
setTimeout(() => {
|
||||
router.push("/login");
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setError("사용자 정보를 불러오는데 실패했습니다.");
|
||||
setUser(null);
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fetchCurrentUser, checkAuthStatus, router]);
|
||||
}, [fetchCurrentUser, checkAuthStatus]);
|
||||
|
||||
/**
|
||||
* 로그인 처리
|
||||
|
|
@ -299,10 +262,7 @@ export const useAuth = () => {
|
|||
});
|
||||
|
||||
if (response.success && response.data?.token) {
|
||||
// JWT 토큰 저장
|
||||
TokenManager.setToken(response.data.token);
|
||||
|
||||
// 로그인 성공 시 사용자 정보 및 인증 상태 업데이트
|
||||
await refreshUserData();
|
||||
|
||||
return {
|
||||
|
|
@ -328,7 +288,7 @@ export const useAuth = () => {
|
|||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[apiCall, refreshUserData],
|
||||
[refreshUserData],
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
@ -337,40 +297,27 @@ export const useAuth = () => {
|
|||
const switchCompany = useCallback(
|
||||
async (companyCode: string): Promise<{ success: boolean; message: string }> => {
|
||||
try {
|
||||
// console.log("🔵 useAuth.switchCompany 시작:", companyCode);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// console.log("🔵 API 호출: POST /auth/switch-company");
|
||||
const response = await apiCall<any>("POST", "/auth/switch-company", {
|
||||
companyCode,
|
||||
});
|
||||
// console.log("🔵 API 응답:", response);
|
||||
|
||||
if (response.success && response.data?.token) {
|
||||
// console.log("🔵 새 토큰 받음:", response.data.token.substring(0, 20) + "...");
|
||||
|
||||
// 새로운 JWT 토큰 저장
|
||||
TokenManager.setToken(response.data.token);
|
||||
// console.log("🔵 토큰 저장 완료");
|
||||
|
||||
// refreshUserData 호출하지 않고 바로 성공 반환
|
||||
// (페이지 새로고침 시 자동으로 갱신됨)
|
||||
// console.log("🔵 회사 전환 완료 (페이지 새로고침 필요)");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: response.message || "회사 전환에 성공했습니다.",
|
||||
};
|
||||
} else {
|
||||
// console.error("🔵 API 응답 실패:", response);
|
||||
return {
|
||||
success: false,
|
||||
message: response.message || "회사 전환에 실패했습니다.",
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
// console.error("🔵 switchCompany 에러:", error);
|
||||
const errorMessage = error.message || "회사 전환 중 오류가 발생했습니다.";
|
||||
setError(errorMessage);
|
||||
|
||||
|
|
@ -380,10 +327,9 @@ export const useAuth = () => {
|
|||
};
|
||||
} finally {
|
||||
setLoading(false);
|
||||
// console.log("🔵 switchCompany 완료");
|
||||
}
|
||||
},
|
||||
[apiCall]
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
@ -395,51 +341,37 @@ export const useAuth = () => {
|
|||
|
||||
const response = await apiCall("POST", "/auth/logout");
|
||||
|
||||
// JWT 토큰 제거
|
||||
TokenManager.removeToken();
|
||||
|
||||
// 로케일 정보도 제거
|
||||
localStorage.removeItem("userLocale");
|
||||
localStorage.removeItem("userLocaleLoaded");
|
||||
(window as any).__GLOBAL_USER_LANG = undefined;
|
||||
(window as any).__GLOBAL_USER_LOCALE_LOADED = undefined;
|
||||
|
||||
// 로그아웃 API 호출 성공 여부와 관계없이 클라이언트 상태 초기화
|
||||
setUser(null);
|
||||
setAuthStatus({
|
||||
isLoggedIn: false,
|
||||
isAdmin: false,
|
||||
});
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
setError(null);
|
||||
|
||||
// 로그인 페이지로 리다이렉트
|
||||
router.push("/login");
|
||||
|
||||
return response.success;
|
||||
} catch (error) {
|
||||
console.error("로그아웃 처리 실패:", error);
|
||||
|
||||
// 오류가 발생해도 JWT 토큰 제거 및 클라이언트 상태 초기화
|
||||
} catch {
|
||||
TokenManager.removeToken();
|
||||
|
||||
// 로케일 정보도 제거
|
||||
localStorage.removeItem("userLocale");
|
||||
localStorage.removeItem("userLocaleLoaded");
|
||||
(window as any).__GLOBAL_USER_LANG = undefined;
|
||||
(window as any).__GLOBAL_USER_LOCALE_LOADED = undefined;
|
||||
|
||||
setUser(null);
|
||||
setAuthStatus({
|
||||
isLoggedIn: false,
|
||||
isAdmin: false,
|
||||
});
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
router.push("/login");
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiCall, router]);
|
||||
}, [router]);
|
||||
|
||||
/**
|
||||
* 메뉴 접근 권한 확인
|
||||
|
|
@ -453,8 +385,7 @@ export const useAuth = () => {
|
|||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error("메뉴 권한 확인 실패:", error);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
|
@ -463,96 +394,56 @@ export const useAuth = () => {
|
|||
* 초기 인증 상태 확인
|
||||
*/
|
||||
useEffect(() => {
|
||||
// 이미 초기화되었으면 실행하지 않음
|
||||
if (initializedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// 로그인 페이지에서는 인증 상태 확인하지 않음
|
||||
if (window.location.pathname === "/login") {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 토큰이 있는 경우에만 인증 상태 확인
|
||||
const token = TokenManager.getToken();
|
||||
|
||||
if (token && !TokenManager.isTokenExpired(token)) {
|
||||
// 토큰이 있으면 임시로 인증된 상태로 설정 (API 호출 전에)
|
||||
// 유효한 토큰 → 우선 인증 상태로 설정 후 API 확인
|
||||
setAuthStatus({
|
||||
isLoggedIn: true,
|
||||
isAdmin: false, // API 호출 후 업데이트될 예정
|
||||
});
|
||||
|
||||
refreshUserData();
|
||||
} else if (!token) {
|
||||
// 토큰이 없으면 3초 후 로그인 페이지로 리다이렉트
|
||||
setTimeout(() => {
|
||||
router.push("/login");
|
||||
}, 3000);
|
||||
} else {
|
||||
TokenManager.removeToken();
|
||||
setTimeout(() => {
|
||||
router.push("/login");
|
||||
}, 3000);
|
||||
}
|
||||
}, []); // 초기 마운트 시에만 실행
|
||||
|
||||
/**
|
||||
* 세션 만료 감지 및 처리
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleSessionExpiry = () => {
|
||||
TokenManager.removeToken();
|
||||
setUser(null);
|
||||
setAuthStatus({
|
||||
isLoggedIn: false,
|
||||
isAdmin: false,
|
||||
});
|
||||
setError("세션이 만료되었습니다. 다시 로그인해주세요.");
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
// 전역 에러 핸들러 등록 (401 Unauthorized 응답 처리)
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = async (...args) => {
|
||||
const response = await originalFetch(...args);
|
||||
|
||||
if (response.status === 401 && window.location.pathname !== "/login") {
|
||||
handleSessionExpiry();
|
||||
refreshUserData();
|
||||
} else if (token && TokenManager.isTokenExpired(token)) {
|
||||
// 만료된 토큰 → 정리 (리다이렉트는 AuthGuard에서 처리)
|
||||
TokenManager.removeToken();
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
setLoading(false);
|
||||
} else {
|
||||
// 토큰 없음 → 비인증 상태 (리다이렉트는 AuthGuard에서 처리)
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
return () => {
|
||||
window.fetch = originalFetch;
|
||||
};
|
||||
}, [router]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 상태
|
||||
user,
|
||||
authStatus,
|
||||
loading,
|
||||
error,
|
||||
|
||||
// 계산된 값
|
||||
isLoggedIn: authStatus.isLoggedIn,
|
||||
isAdmin: authStatus.isAdmin,
|
||||
userId: user?.userId,
|
||||
userName: user?.userName,
|
||||
companyCode: user?.companyCode || user?.company_code, // 🆕 회사 코드
|
||||
companyCode: user?.companyCode || user?.company_code,
|
||||
|
||||
// 함수
|
||||
login,
|
||||
logout,
|
||||
switchCompany, // 🆕 회사 전환 함수
|
||||
switchCompany,
|
||||
checkMenuAuth,
|
||||
refreshUserData,
|
||||
|
||||
// 유틸리티
|
||||
clearError: () => setError(null),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
if (!token) {
|
||||
console.error("JWT 토큰이 없습니다.");
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
// 메뉴 목록 조회
|
||||
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);
|
||||
const response = await apiClient.get("/admin/user-menus");
|
||||
|
||||
if (response.data?.success && response.data?.data) {
|
||||
const convertedMenuData = convertToUpperCaseKeys(response.data.data || []);
|
||||
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");
|
||||
} else {
|
||||
setMenuState((prev: MenuState) => ({ ...prev, isLoading: false }));
|
||||
}
|
||||
}, [router, convertToUpperCaseKeys, buildMenuTree]);
|
||||
} catch {
|
||||
// API 실패 시 빈 메뉴로 유지 (401은 client.ts 인터셉터가 리다이렉트 처리)
|
||||
setMenuState((prev: MenuState) => ({ ...prev, isLoading: false }));
|
||||
}
|
||||
}, [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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,19 +116,36 @@ const TokenManager = {
|
|||
},
|
||||
};
|
||||
|
||||
// 토큰 갱신 중복 방지 플래그
|
||||
// ============================================
|
||||
// 토큰 갱신 로직 (중복 요청 방지)
|
||||
// ============================================
|
||||
let isRefreshing = false;
|
||||
let refreshPromise: Promise<string | null> | null = null;
|
||||
let refreshSubscribers: Array<(token: string) => void> = [];
|
||||
let failedRefreshSubscribers: Array<(error: Error) => void> = [];
|
||||
|
||||
// 갱신 대기 중인 요청들에게 새 토큰 전달
|
||||
const onTokenRefreshed = (newToken: string) => {
|
||||
refreshSubscribers.forEach((callback) => callback(newToken));
|
||||
refreshSubscribers = [];
|
||||
failedRefreshSubscribers = [];
|
||||
};
|
||||
|
||||
// 갱신 실패 시 대기 중인 요청들에게 에러 전달
|
||||
const onRefreshFailed = (error: Error) => {
|
||||
failedRefreshSubscribers.forEach((callback) => callback(error));
|
||||
refreshSubscribers = [];
|
||||
failedRefreshSubscribers = [];
|
||||
};
|
||||
|
||||
// 갱신 완료 대기 Promise 등록
|
||||
const waitForTokenRefresh = (): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
refreshSubscribers.push(resolve);
|
||||
failedRefreshSubscribers.push(reject);
|
||||
});
|
||||
};
|
||||
|
||||
// 토큰 갱신 함수
|
||||
const refreshToken = async (): Promise<string | null> => {
|
||||
// 이미 갱신 중이면 기존 Promise 반환
|
||||
if (isRefreshing && refreshPromise) {
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
const currentToken = TokenManager.getToken();
|
||||
if (!currentToken) {
|
||||
|
|
@ -163,45 +165,36 @@ const refreshToken = async (): Promise<string | null> => {
|
|||
if (response.data?.success && response.data?.data?.token) {
|
||||
const newToken = response.data.data.token;
|
||||
TokenManager.setToken(newToken);
|
||||
console.log("[TokenManager] 토큰 갱신 성공");
|
||||
return newToken;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("[TokenManager] 토큰 갱신 실패:", error);
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
refreshPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return refreshPromise;
|
||||
};
|
||||
|
||||
// 자동 토큰 갱신 타이머
|
||||
let tokenRefreshTimer: NodeJS.Timeout | null = null;
|
||||
// ============================================
|
||||
// 자동 토큰 갱신 (백그라운드)
|
||||
// ============================================
|
||||
let tokenRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// 자동 토큰 갱신 시작
|
||||
const startAutoRefresh = (): void => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// 기존 타이머 정리
|
||||
if (tokenRefreshTimer) {
|
||||
clearInterval(tokenRefreshTimer);
|
||||
}
|
||||
|
||||
// 10분마다 토큰 상태 확인
|
||||
// 5분마다 토큰 상태 확인 (기존 10분 → 5분으로 단축)
|
||||
tokenRefreshTimer = setInterval(
|
||||
async () => {
|
||||
const token = TokenManager.getToken();
|
||||
if (token && TokenManager.isTokenExpiringSoon(token)) {
|
||||
console.log("[TokenManager] 토큰 만료 임박, 자동 갱신 시작...");
|
||||
await refreshToken();
|
||||
}
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
); // 10분
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
|
||||
// 페이지 로드 시 즉시 확인
|
||||
const token = TokenManager.getToken();
|
||||
|
|
@ -210,29 +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)) {
|
||||
if (token) {
|
||||
if (!TokenManager.isTokenExpired(token)) {
|
||||
// 유효한 토큰 → 그대로 사용
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
} else if (token && TokenManager.isTokenExpired(token)) {
|
||||
console.warn("❌ 토큰이 만료되었습니다.");
|
||||
// 토큰 제거
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("authToken");
|
||||
}
|
||||
} else {
|
||||
console.warn("⚠️ 토큰이 없습니다.");
|
||||
// 만료된 토큰 → 갱신 시도 후 사용
|
||||
const newToken = await refreshToken();
|
||||
if (newToken) {
|
||||
config.headers.Authorization = `Bearer ${newToken}`;
|
||||
}
|
||||
// 갱신 실패해도 요청은 보냄 (401 응답 인터셉터에서 처리)
|
||||
}
|
||||
}
|
||||
|
||||
// FormData 요청 시 Content-Type 자동 처리
|
||||
|
|
@ -287,18 +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);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 일반 409 에러는 간단한 로그만 출력
|
||||
console.warn("데이터 중복:", {
|
||||
url: url,
|
||||
message: (error.response?.data as { message?: string })?.message || "중복된 데이터입니다.",
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 채번 규칙 미리보기 API 실패는 조용하게 처리 (화면 로드 시 자주 발생)
|
||||
// 채번 규칙 미리보기 API 실패는 조용하게 처리
|
||||
if (url?.includes("/numbering-rules/") && url?.includes("/preview")) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 다른 에러들은 기존처럼 상세 로그 출력
|
||||
console.error("API 응답 오류:", {
|
||||
status: status,
|
||||
statusText: error.response?.statusText,
|
||||
url: url,
|
||||
data: error.response?.data,
|
||||
message: error.message,
|
||||
});
|
||||
|
||||
// 401 에러 처리
|
||||
// 401 에러 처리 (핵심 개선)
|
||||
if (status === 401 && typeof window !== "undefined") {
|
||||
const errorData = error.response?.data as { error?: { code?: string } };
|
||||
const errorCode = errorData?.error?.code;
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
console.warn("[Auth] 401 오류 발생:", {
|
||||
url: url,
|
||||
errorCode: errorCode,
|
||||
token: TokenManager.getToken() ? "존재" : "없음",
|
||||
});
|
||||
// 이미 재시도한 요청이면 로그인으로
|
||||
if (originalRequest?._retry) {
|
||||
redirectToLogin();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 토큰 만료 에러인 경우 갱신 시도
|
||||
const originalRequest = error.config as typeof error.config & { _retry?: boolean };
|
||||
if (errorCode === "TOKEN_EXPIRED" && originalRequest && !originalRequest._retry) {
|
||||
console.log("[Auth] 토큰 만료, 갱신 시도...");
|
||||
// 토큰 만료 에러 → 갱신 후 재시도
|
||||
if (errorCode === "TOKEN_EXPIRED" && originalRequest) {
|
||||
if (!isRefreshing) {
|
||||
isRefreshing = true;
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const newToken = await refreshToken();
|
||||
if (newToken && originalRequest) {
|
||||
if (newToken) {
|
||||
isRefreshing = false;
|
||||
onTokenRefreshed(newToken);
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
return apiClient.request(originalRequest);
|
||||
} else {
|
||||
isRefreshing = false;
|
||||
onRefreshFailed(new Error("토큰 갱신 실패"));
|
||||
redirectToLogin();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
} catch (refreshError) {
|
||||
console.error("[Auth] 토큰 갱신 실패:", refreshError);
|
||||
isRefreshing = false;
|
||||
onRefreshFailed(refreshError as Error);
|
||||
redirectToLogin();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
} else {
|
||||
// 다른 요청이 이미 갱신 중 → 갱신 완료 대기 후 재시도
|
||||
try {
|
||||
const newToken = await waitForTokenRefresh();
|
||||
originalRequest._retry = true;
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
return apiClient.request(originalRequest);
|
||||
} catch {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 토큰 갱신 실패 또는 다른 401 에러인 경우 로그아웃
|
||||
TokenManager.removeToken();
|
||||
|
||||
// 로그인 페이지가 아닌 경우에만 리다이렉트
|
||||
if (window.location.pathname !== "/login") {
|
||||
console.log("[Auth] 로그인 페이지로 리다이렉트");
|
||||
window.location.href = "/login";
|
||||
}
|
||||
// TOKEN_MISSING, INVALID_TOKEN 등 → 로그인으로
|
||||
redirectToLogin();
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
// 공통 응답 타입
|
||||
// ============================================
|
||||
// 공통 타입 및 헬퍼
|
||||
// ============================================
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
|
|
@ -416,7 +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,
|
||||
|
|
|
|||
|
|
@ -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 하위품목 편집기
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ export interface SplitPanelInfo {
|
|||
initialLeftWidthPercent: number;
|
||||
// 드래그 중 여부
|
||||
isDragging: boolean;
|
||||
// 패널 유형: "component" = 분할 패널 레이아웃 (버튼만 조정), "canvas" = 캔버스 분할선 (모든 컴포넌트 조정)
|
||||
panelType?: "component" | "canvas";
|
||||
}
|
||||
|
||||
export interface SplitPanelResizeContextValue {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
"품목추가" 버튼을 눌러 추가하세요.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
renderNodes(treeData, 0)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 품목 검색 모달 */}
|
||||
<ItemSearchModal
|
||||
open={itemSearchOpen}
|
||||
onClose={() => setItemSearchOpen(false)}
|
||||
onSelect={handleItemSelect}
|
||||
companyCode={companyCode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BomItemEditorComponent;
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { BomItemEditorComponent } from "./BomItemEditorComponent";
|
||||
import { V2BomItemEditorDefinition } from "./index";
|
||||
|
||||
export class BomItemEditorRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = V2BomItemEditorDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <BomItemEditorComponent {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
BomItemEditorRenderer.registerSelf();
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
setTimeout(() => {
|
||||
BomItemEditorRenderer.registerSelf();
|
||||
}, 0);
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { ComponentCategory } from "@/types/component";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { BomItemEditorComponent } from "./BomItemEditorComponent";
|
||||
|
||||
export const V2BomItemEditorDefinition = createComponentDefinition({
|
||||
id: "v2-bom-item-editor",
|
||||
name: "BOM 하위품목 편집기",
|
||||
nameEng: "BOM Item Editor",
|
||||
description: "BOM 하위 품목을 트리 구조로 추가/편집/삭제하는 컴포넌트",
|
||||
category: ComponentCategory.V2,
|
||||
webType: "text",
|
||||
component: BomItemEditorComponent,
|
||||
defaultConfig: {
|
||||
detailTable: "bom_detail",
|
||||
sourceTable: "item_info",
|
||||
foreignKey: "bom_id",
|
||||
parentKey: "parent_detail_id",
|
||||
itemCodeField: "item_number",
|
||||
itemNameField: "item_name",
|
||||
itemTypeField: "type",
|
||||
itemUnitField: "unit",
|
||||
},
|
||||
defaultSize: { width: 900, height: 400 },
|
||||
icon: "ListTree",
|
||||
tags: ["BOM", "트리", "편집", "하위품목", "제조"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
});
|
||||
|
||||
export default V2BomItemEditorDefinition;
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -26,6 +26,8 @@ export interface SplitPanelInfo {
|
|||
initialLeftWidthPercent: number;
|
||||
// 드래그 중 여부
|
||||
isDragging: boolean;
|
||||
// 패널 유형: "component" = 분할 패널 레이아웃 (버튼만 조정), "canvas" = 캔버스 분할선 (모든 컴포넌트 조정)
|
||||
panelType?: "component" | "canvas";
|
||||
}
|
||||
|
||||
export interface SplitPanelResizeContextValue {
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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$).*)",
|
||||
],
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue