jskim-node #393

Merged
kjs merged 11 commits from jskim-node into main 2026-02-24 15:31:32 +09:00
41 changed files with 5635 additions and 665 deletions

View File

@ -3,10 +3,13 @@
*/ */
import express from "express"; import express from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as ctrl from "../controllers/processWorkStandardController"; import * as ctrl from "../controllers/processWorkStandardController";
const router = express.Router(); const router = express.Router();
router.use(authenticateToken);
// 품목/라우팅/공정 조회 (좌측 트리) // 품목/라우팅/공정 조회 (좌측 트리)
router.get("/items", ctrl.getItemsWithRouting); router.get("/items", ctrl.getItemsWithRouting);
router.get("/items/:itemCode/routings", ctrl.getRoutingsWithProcesses); router.get("/items/:itemCode/routings", ctrl.getRoutingsWithProcesses);

View File

@ -1721,18 +1721,28 @@ export class ScreenManagementService {
throw new Error("이 화면의 레이아웃을 조회할 권한이 없습니다."); throw new Error("이 화면의 레이아웃을 조회할 권한이 없습니다.");
} }
// 🆕 V2 테이블 우선 조회 (회사별 → 공통(*)) // V2 테이블 우선 조회: 기본 레이어(layer_id=1)만 가져옴
// layer_id 필터 없이 queryOne 하면 조건부 레이어가 반환될 수 있음
let v2Layout = await queryOne<{ layout_data: any }>( let v2Layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2 `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], [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 !== "*") { if (!v2Layout && companyCode !== "*") {
v2Layout = await queryOne<{ layout_data: any }>( v2Layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2 `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], [screenId],
); );
} }
@ -5302,7 +5312,22 @@ export class ScreenManagementService {
[screenId, companyCode, layerId], [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 !== "*") { if (!layout && companyCode !== "*") {
layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>( layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>(
`SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2 `SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2

View File

@ -164,6 +164,7 @@ export const componentDefaults: Record<string, any> = {
"v2-date": { type: "v2-date", webType: "date" }, "v2-date": { type: "v2-date", webType: "date" },
"v2-repeater": { type: "v2-repeater", webType: "custom" }, "v2-repeater": { type: "v2-repeater", webType: "custom" },
"v2-repeat-container": { type: "v2-repeat-container", webType: "custom" }, "v2-repeat-container": { type: "v2-repeat-container", webType: "custom" },
"v2-split-line": { type: "v2-split-line", webType: "custom", resizable: true, lineWidth: 4 },
}; };
/** /**

View File

@ -225,7 +225,7 @@ childItemsSection: {
"config": { "config": {
"masterPanel": { "masterPanel": {
"title": "BOM 목록", "title": "BOM 목록",
"entityId": "bom_header", "entityId": "bom",
"columns": [ "columns": [
{ "id": "item_code", "label": "품목코드", "width": 100 }, { "id": "item_code", "label": "품목코드", "width": 100 },
{ "id": "item_name", "label": "품목명", "width": 150 }, { "id": "item_name", "label": "품목명", "width": 150 },

View File

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

View File

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

View File

@ -376,12 +376,26 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
try { try {
setLoading(true); setLoading(true);
// 화면 정보와 레이아웃 데이터 로딩 // 화면 정보와 레이아웃 데이터 로딩 (ScreenModal과 동일하게 V2 API 우선)
const [screenInfo, layoutData] = await Promise.all([ const [screenInfo, v2LayoutData] = await Promise.all([
screenApi.getScreen(screenId), 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) { if (screenInfo && layoutData) {
const components = layoutData.components || []; const components = layoutData.components || [];
@ -1372,6 +1386,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
</div> </div>
) : screenData ? ( ) : screenData ? (
<div <div
data-screen-runtime="true"
className="relative bg-white" className="relative bg-white"
style={{ style={{
width: screenDimensions?.width || 800, width: screenDimensions?.width || 800,

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState, useCallback, useEffect } from "react"; import React, { useState, useCallback, useEffect, useSyncExternalStore } from "react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; 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 { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { evaluateConditional } from "@/lib/utils/conditionalEvaluator"; 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"; import "@/lib/registry/components/ButtonRenderer";
@ -82,9 +88,14 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
parentTabId, parentTabId,
parentTabsComponentId, parentTabsComponentId,
}) => { }) => {
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 const { isPreviewMode } = useScreenPreview();
const { userName: authUserName, user: authUser } = useAuth(); 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 등에서) // 외부에서 전달받은 사용자 정보가 있으면 우선 사용 (ScreenModal 등에서)
const userName = externalUserName || authUserName; const userName = externalUserName || authUserName;
@ -1079,24 +1090,158 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4; const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const labelOffset = hasVisibleLabel ? (labelFontSize + labelMarginBottom + 2) : 0; const labelOffset = hasVisibleLabel ? (labelFontSize + labelMarginBottom + 2) : 0;
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 = { const componentStyle = {
position: "absolute" as const, position: "absolute" as const,
left: position?.x || 0, ...safeStyleWithoutSize,
top: position?.y || 0, // 원래 위치 유지 (음수로 가면 overflow-hidden에 잘림) // left/top은 반드시 마지막에 (styleWithoutSize가 덮어쓰지 못하게)
left: adjustedX,
top: position?.y || 0,
zIndex: position?.z || 1, zIndex: position?.z || 1,
...styleWithoutSize, // width/height 제외한 스타일만 먼저 적용 width: isSplitActive ? adjustedW : (size?.width || 200),
width: size?.width || 200, // size의 픽셀 값이 최종 우선순위
height: isTableSearchWidget ? "auto" : size?.height || 10, height: isTableSearchWidget ? "auto" : size?.height || 10,
minHeight: isTableSearchWidget ? "48px" : undefined, minHeight: isTableSearchWidget ? "48px" : undefined,
// 🆕 라벨이 있으면 overflow visible로 설정하여 라벨이 잘리지 않게 함 overflow: (isSplitActive && adjustedW < origW) ? "hidden" : (labelOffset > 0 ? "visible" : undefined),
overflow: 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 ( return (
<> <>
<div className="absolute" style={componentStyle}> <div ref={elRef} id={`interactive-${component.id}`} className="absolute" style={componentStyle}>
{/* 위젯 렌더링 (라벨은 V2Input 내부에서 absolute로 표시됨) */} {renderInteractiveWidget(splitAdjustedComponent)}
{renderInteractiveWidget(component)}
</div> </div>
{/* 팝업 화면 렌더링 */} {/* 팝업 화면 렌더링 */}

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useMemo } from "react"; import React, { useMemo, useSyncExternalStore } from "react";
import { ComponentData, WebType, WidgetComponent } from "@/types/screen"; import { ComponentData, WebType, WidgetComponent } from "@/types/screen";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { import {
@ -17,6 +17,12 @@ import {
File, File,
} from "lucide-react"; } from "lucide-react";
import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; 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"; import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
// 컴포넌트 렌더러들 자동 등록 // 컴포넌트 렌더러들 자동 등록
@ -388,10 +394,12 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
} }
: component; : component;
// 🆕 분할 패널 리사이즈 Context // 기존 분할 패널 리사이즈 Context (레거시 split-panel-layout용)
const splitPanelContext = useSplitPanel(); const splitPanelContext = useSplitPanel();
// 버튼 컴포넌트인지 확인 (분할 패널 위치 조정 대상) // 캔버스 분할선 글로벌 스토어 (useSyncExternalStore로 직접 구독)
const canvasSplit = useSyncExternalStore(canvasSplitSubscribe, canvasSplitGetSnapshot, canvasSplitGetServerSnapshot);
const componentType = (component as any).componentType || ""; const componentType = (component as any).componentType || "";
const componentId = (component as any).componentId || ""; const componentId = (component as any).componentId || "";
const widgetType = (component as any).widgetType || ""; 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(componentType) ||
["button-primary", "button-secondary"].includes(componentId))); ["button-primary", "button-secondary"].includes(componentId)));
// 🆕 버튼이 처음 렌더링될 때의 분할 패널 정보를 기억 (기준점) // 레거시 분할 패널용 refs
const initialPanelRatioRef = React.useRef<number | null>(null); const initialPanelRatioRef = React.useRef<number | null>(null);
const initialPanelIdRef = React.useRef<string | null>(null); const initialPanelIdRef = React.useRef<string | null>(null);
// 버튼이 좌측 패널에 속하는지 여부 (한번 설정되면 유지)
const isInLeftPanelRef = React.useRef<boolean | 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 = const isSplitPanelComponent =
type === "component" && ["split-panel-layout", "split-panel-layout2"].includes(componentType); type === "component" && ["split-panel-layout", "split-panel-layout2"].includes(componentType);
if (!isButtonComponent || isSplitPanelComponent) { if (isSplitPanelComponent) {
return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false }; return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false };
}
if (!isButtonComponent) {
return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false };
} }
const componentWidth = size?.width || 100; const componentWidth = size?.width || 100;
const componentHeight = size?.height || 40; const componentHeight = size?.height || 40;
// 분할 패널 위에 있는지 확인 (원래 위치 기준)
const overlap = splitPanelContext.getOverlappingSplitPanel(position.x, position.y, componentWidth, componentHeight); const overlap = splitPanelContext.getOverlappingSplitPanel(position.x, position.y, componentWidth, componentHeight);
// 분할 패널 위에 없으면 기준점 초기화
if (!overlap) { if (!overlap) {
if (initialPanelIdRef.current !== null) { if (initialPanelIdRef.current !== null) {
initialPanelRatioRef.current = null; initialPanelRatioRef.current = null;
initialPanelIdRef.current = null; initialPanelIdRef.current = null;
isInLeftPanelRef.current = null; isInLeftPanelRef.current = null;
} }
return { return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false };
adjustedPositionX: position.x,
isOnSplitPanel: false,
isDraggingSplitPanel: false,
};
} }
const { panel } = overlap; const { panel } = overlap;
// 🆕 초기 기준 비율 및 좌측 패널 소속 여부 설정 (처음 한 번만)
if (initialPanelIdRef.current !== overlap.panelId) { if (initialPanelIdRef.current !== overlap.panelId) {
initialPanelRatioRef.current = panel.leftWidthPercent; initialPanelRatioRef.current = panel.initialLeftWidthPercent;
initialPanelIdRef.current = overlap.panelId; initialPanelIdRef.current = overlap.panelId;
const initialDividerX = panel.x + (panel.width * panel.initialLeftWidthPercent) / 100;
// 초기 배치 시 좌측 패널에 있는지 확인 (초기 비율 기준으로 계산)
// 현재 비율이 아닌, 버튼 원래 위치가 초기 좌측 패널 영역 안에 있었는지 판단
const initialLeftPanelWidth = (panel.width * panel.leftWidthPercent) / 100;
const componentCenterX = position.x + componentWidth / 2; const componentCenterX = position.x + componentWidth / 2;
const relativeX = componentCenterX - panel.x; isInLeftPanelRef.current = componentCenterX < initialDividerX;
const wasInLeftPanel = relativeX < initialLeftPanelWidth;
isInLeftPanelRef.current = wasInLeftPanel;
console.log("📌 [버튼 기준점 설정]:", {
componentId: component.id,
panelId: overlap.panelId,
initialRatio: panel.leftWidthPercent,
isInLeftPanel: wasInLeftPanel,
buttonCenterX: componentCenterX,
leftPanelWidth: initialLeftPanelWidth,
});
} }
// 좌측 패널 소속이 아니면 조정하지 않음 (초기 배치 기준) const baseRatio = initialPanelRatioRef.current ?? panel.initialLeftWidthPercent;
if (!isInLeftPanelRef.current) { const initialDividerX = panel.x + (panel.width * baseRatio) / 100;
return { const currentDividerX = panel.x + (panel.width * panel.leftWidthPercent) / 100;
adjustedPositionX: position.x, const dividerDelta = currentDividerX - initialDividerX;
isOnSplitPanel: true,
isDraggingSplitPanel: panel.isDragging,
};
}
// 초기 기준 비율 (버튼이 처음 배치될 때의 비율)
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) { if (Math.abs(dividerDelta) < 1) {
return { return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: true, isDraggingSplitPanel: panel.isDragging };
adjustedPositionX: position.x,
isOnSplitPanel: true,
isDraggingSplitPanel: panel.isDragging,
};
} }
// 🆕 버튼도 분할선과 같은 양만큼 이동 const adjustedX = isInLeftPanelRef.current ? position.x + dividerDelta : position.x;
// 분할선이 왼쪽으로 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,
});
return { return {
adjustedPositionX: adjustedX, adjustedPositionX: adjustedX,
adjustedWidth: null,
isOnSplitPanel: true, isOnSplitPanel: true,
isDraggingSplitPanel: panel.isDragging, 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 displayWidth = resizeSize ? `${resizeSize.width}px` : getWidth();
const displayHeight = resizeSize ? `${resizeSize.height}px` : getHeight(); const displayHeight = resizeSize ? `${resizeSize.height}px` : getHeight();
const isSplitActive = canvasSplit.active && canvasSplit.scopeId && myScopeIdRef.current === canvasSplit.scopeId;
const origWidth = size?.width || 100;
const isSplitShrunk = splitAdjustedWidth !== null && splitAdjustedWidth < origWidth;
const baseStyle = { const baseStyle = {
left: `${adjustedPositionX}px`, // 🆕 조정된 X 좌표 사용 left: `${adjustedPositionX}px`,
top: `${position.y}px`, top: `${position.y}px`,
...componentStyle, // componentStyle 전체 적용 (DynamicComponentRenderer에서 이미 size가 변환됨) ...componentStyle,
width: displayWidth, // 🆕 리사이즈 중이면 resizeSize 사용 width: splitAdjustedWidth !== null ? `${splitAdjustedWidth}px` : displayWidth,
height: displayHeight, // 🆕 리사이즈 중이면 resizeSize 사용 height: displayHeight,
zIndex: component.type === "layout" ? 1 : position.z || 2, zIndex: component.type === "layout" ? 1 : position.z || 2,
right: undefined, right: undefined,
// 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동, 리사이즈 중에도 트랜지션 없음 overflow: isSplitShrunk ? "hidden" as const : undefined,
willChange: canvasSplit.isDragging && isSplitActive ? "left, width" as const : undefined,
transition: transition:
isResizing ? "none" : 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?.(); 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 ( return (
<div <div
ref={outerDivRef} ref={outerDivRef}
@ -602,7 +684,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
style={{ width: "100%", maxWidth: "100%" }} style={{ width: "100%", maxWidth: "100%" }}
> >
<DynamicComponentRenderer <DynamicComponentRenderer
component={enhancedComponent} component={splitAdjustedComp}
isSelected={isSelected} isSelected={isSelected}
isDesignMode={isDesignMode} isDesignMode={isDesignMode}
isInteractive={!isDesignMode} // 편집 모드가 아닐 때만 인터랙티브 isInteractive={!isDesignMode} // 편집 모드가 아닐 때만 인터랙티브

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -3,15 +3,16 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { MenuItem, MenuState } from "@/types/menu"; 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) => { export const useMenu = (user: any, authLoading: boolean) => {
const router = useRouter(); const router = useRouter();
// 상태 관리
const [menuState, setMenuState] = useState<MenuState>({ const [menuState, setMenuState] = useState<MenuState>({
menuList: [], menuList: [],
expandedMenus: new Set(), expandedMenus: new Set(),
@ -36,103 +37,58 @@ export const useMenu = (user: any, authLoading: boolean) => {
* *
*/ */
const buildMenuTree = useCallback((menuItems: MenuItem[]): MenuItem[] => { const buildMenuTree = useCallback((menuItems: MenuItem[]): MenuItem[] => {
console.log("빌드 메뉴 트리 - 원본 메뉴 아이템들:", menuItems);
const menuMap = new Map<string, MenuItem>(); const menuMap = new Map<string, MenuItem>();
const rootMenus: MenuItem[] = []; const rootMenus: MenuItem[] = [];
// 모든 메뉴를 맵에 저장 (ID를 문자열로 변환)
menuItems.forEach((menu) => { menuItems.forEach((menu) => {
const objId = String(menu.OBJID); const objId = String(menu.OBJID);
const parentId = String(menu.PARENT_OBJ_ID); 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: [] }); menuMap.set(objId, { ...menu, OBJID: objId, PARENT_OBJ_ID: parentId, children: [] });
}); });
console.log("메뉴 맵 생성 완료, 총 메뉴 수:", menuMap.size);
// 부모-자식 관계 설정
menuItems.forEach((menu) => { menuItems.forEach((menu) => {
const objId = String(menu.OBJID); const objId = String(menu.OBJID);
const parentId = String(menu.PARENT_OBJ_ID); const parentId = String(menu.PARENT_OBJ_ID);
const menuItem = menuMap.get(objId)!; const menuItem = menuMap.get(objId)!;
// PARENT_OBJ_ID가 특정 값이 아닌 경우 (루트가 아닌 경우)
if (parentId !== "-395553955") { if (parentId !== "-395553955") {
const parent = menuMap.get(parentId); const parent = menuMap.get(parentId);
if (parent) { if (parent) {
parent.children = parent.children || []; parent.children = parent.children || [];
parent.children.push(menuItem); parent.children.push(menuItem);
console.log(`자식 메뉴 추가: ${menu.MENU_NAME_KOR} -> ${parent.MENU_NAME_KOR}`);
} else {
console.log(`부모 메뉴를 찾을 수 없음: ${menu.MENU_NAME_KOR}, 부모 ID: ${parentId}`);
} }
} else { } else {
rootMenus.push(menuItem); 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)); return rootMenus.sort((a, b) => (a.SEQ || 0) - (b.SEQ || 0));
}, []); }, []);
/** /**
* *
* - apiClient /401
* - ( client.ts )
*/ */
const loadMenuData = useCallback(async () => { const loadMenuData = useCallback(async () => {
try { try {
// JWT 토큰 가져오기 const response = await apiClient.get("/admin/user-menus");
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);
if (response.data?.success && response.data?.data) {
const convertedMenuData = convertToUpperCaseKeys(response.data.data || []);
setMenuState((prev: MenuState) => ({ setMenuState((prev: MenuState) => ({
...prev, ...prev,
menuList: buildMenuTree(convertedMenuData), menuList: buildMenuTree(convertedMenuData),
isLoading: false, isLoading: false,
})); }));
} } else {
} else if (menuResponse.status === 401) {
// 인증 실패 시 토큰 제거 및 로그인 페이지로 리다이렉트
localStorage.removeItem("authToken");
router.push("/login");
}
} catch (error) {
console.error("메뉴 데이터 로드 실패:", error);
localStorage.removeItem("authToken");
router.push("/login");
setMenuState((prev: MenuState) => ({ ...prev, isLoading: false })); 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) { if (menu.children && menu.children.length > 0) {
toggleMenu(String(menu.OBJID)); toggleMenu(String(menu.OBJID));
} else { } else {
// 메뉴 이름 저장 (엑셀 다운로드 파일명에 사용)
const menuName = menu.MENU_NAME_KOR || menu.menuNameKor || menu.TRANSLATED_NAME || "메뉴"; const menuName = menu.MENU_NAME_KOR || menu.menuNameKor || menu.TRANSLATED_NAME || "메뉴";
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
localStorage.setItem("currentMenuName", menuName); localStorage.setItem("currentMenuName", menuName);
} }
// 먼저 할당된 화면이 있는지 확인 (URL 유무와 관계없이)
try { try {
const menuObjid = menu.OBJID || menu.objid; const menuObjid = menu.OBJID || menu.objid;
if (menuObjid) { if (menuObjid) {
@ -174,9 +128,7 @@ export const useMenu = (user: any, authLoading: boolean) => {
const assignedScreens = await menuScreenApi.getScreensByMenu(parseInt(menuObjid.toString())); const assignedScreens = await menuScreenApi.getScreensByMenu(parseInt(menuObjid.toString()));
if (assignedScreens.length > 0) { if (assignedScreens.length > 0) {
// 할당된 화면이 있으면 첫 번째 화면으로 이동
const firstScreen = assignedScreens[0]; const firstScreen = assignedScreens[0];
// menuObjid를 쿼리 파라미터로 전달
router.push(`/screens/${firstScreen.screenId}?menuObjid=${menuObjid}`); router.push(`/screens/${firstScreen.screenId}?menuObjid=${menuObjid}`);
return; return;
} }
@ -185,11 +137,9 @@ export const useMenu = (user: any, authLoading: boolean) => {
console.warn("할당된 화면 조회 실패:", error); console.warn("할당된 화면 조회 실패:", error);
} }
// 할당된 화면이 없고 URL이 있으면 기존 URL로 이동
if (menu.MENU_URL) { if (menu.MENU_URL) {
router.push(menu.MENU_URL); router.push(menu.MENU_URL);
} else { } else {
// URL도 없고 할당된 화면도 없으면 경고 메시지
console.warn("메뉴에 URL이나 할당된 화면이 없습니다:", menu); console.warn("메뉴에 URL이나 할당된 화면이 없습니다:", menu);
const { toast } = await import("sonner"); const { toast } = await import("sonner");
toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다."); toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다.");
@ -199,7 +149,6 @@ export const useMenu = (user: any, authLoading: boolean) => {
[toggleMenu, router], [toggleMenu, router],
); );
// 사용자 정보가 있고 로딩이 완료되면 메뉴 데이터 로드
useEffect(() => { useEffect(() => {
if (user && !authLoading) { if (user && !authLoading) {
loadMenuData(); loadMenuData();
@ -212,6 +161,6 @@ export const useMenu = (user: any, authLoading: boolean) => {
isMenuLoading: menuState.isLoading, isMenuLoading: menuState.isLoading,
handleMenuClick, handleMenuClick,
toggleMenu, toggleMenu,
refreshMenus: loadMenuData, // 메뉴 새로고침 함수 추가 refreshMenus: loadMenuData,
}; };
}; };

View File

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

View File

@ -113,6 +113,10 @@ import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트
import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트 import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트
import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트 import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트
import "./v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준 import "./v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준
import "./v2-item-routing/ItemRoutingRenderer"; // 품목별 라우팅
import "./v2-split-line/SplitLineRenderer"; // V2 캔버스 분할선
import "./v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰
import "./v2-bom-item-editor/BomItemEditorRenderer"; // BOM 하위품목 편집기
/** /**
* *

View File

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

View File

@ -0,0 +1,932 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
GripVertical,
Plus,
X,
Search,
ChevronRight,
ChevronDown,
Package,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { apiClient } from "@/lib/api/client";
// ─── 타입 정의 ───
interface BomItemNode {
tempId: string;
id?: string;
parent_detail_id: string | null;
seq_no: number;
level: number;
children: BomItemNode[];
_isNew?: boolean;
_isDeleted?: boolean;
data: Record<string, any>;
}
interface BomColumnConfig {
key: string;
title: string;
width?: string;
visible?: boolean;
editable?: boolean;
isSourceDisplay?: boolean;
inputType?: string;
}
interface ItemInfo {
id: string;
item_number: string;
item_name: string;
type: string;
unit: string;
division: string;
}
interface BomItemEditorProps {
component?: any;
formData?: Record<string, any>;
companyCode?: string;
isDesignMode?: boolean;
selectedRowsData?: any[];
onChange?: (flatData: any[]) => void;
bomId?: string;
}
// 임시 ID 생성
let tempIdCounter = 0;
const generateTempId = () => `temp_${Date.now()}_${++tempIdCounter}`;
// ─── 품목 검색 모달 ───
interface ItemSearchModalProps {
open: boolean;
onClose: () => void;
onSelect: (item: ItemInfo) => void;
companyCode?: string;
}
function ItemSearchModal({
open,
onClose,
onSelect,
companyCode,
}: ItemSearchModalProps) {
const [searchText, setSearchText] = useState("");
const [items, setItems] = useState<ItemInfo[]>([]);
const [loading, setLoading] = useState(false);
const searchItems = useCallback(
async (query: string) => {
setLoading(true);
try {
const result = await entityJoinApi.getTableDataWithJoins("item_info", {
page: 1,
size: 50,
search: query
? { item_number: query, item_name: query }
: undefined,
enableEntityJoin: true,
companyCodeOverride: companyCode,
});
setItems(result.data || []);
} catch (error) {
console.error("[BomItemEditor] 품목 검색 실패:", error);
} finally {
setLoading(false);
}
},
[companyCode],
);
useEffect(() => {
if (open) {
setSearchText("");
searchItems("");
}
}, [open, searchItems]);
const handleSearch = () => {
searchItems(searchText);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleSearch();
}
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Input
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="품목코드 또는 품목명"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<Button
onClick={handleSearch}
size="sm"
className="h-8 sm:h-10"
>
<Search className="mr-1 h-4 w-4" />
</Button>
</div>
<div className="max-h-[300px] overflow-y-auto rounded-md border">
{loading ? (
<div className="flex items-center justify-center py-8">
<span className="text-muted-foreground text-sm"> ...</span>
</div>
) : items.length === 0 ? (
<div className="flex items-center justify-center py-8">
<span className="text-muted-foreground text-sm">
.
</span>
</div>
) : (
<table className="w-full text-xs sm:text-sm">
<thead className="bg-muted/50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr
key={item.id}
onClick={() => {
onSelect(item);
onClose();
}}
className="hover:bg-accent cursor-pointer border-t transition-colors"
>
<td className="px-3 py-2 font-mono">
{item.item_number}
</td>
<td className="px-3 py-2">{item.item_name}</td>
<td className="px-3 py-2">{item.type}</td>
<td className="px-3 py-2">{item.unit}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</DialogContent>
</Dialog>
);
}
// ─── 트리 노드 행 렌더링 (config.columns 기반 동적 셀) ───
interface TreeNodeRowProps {
node: BomItemNode;
depth: number;
expanded: boolean;
hasChildren: boolean;
columns: BomColumnConfig[];
categoryOptionsMap: Record<string, { value: string; label: string }[]>;
mainTableName?: string;
onToggle: () => void;
onFieldChange: (tempId: string, field: string, value: string) => void;
onDelete: (tempId: string) => void;
onAddChild: (parentTempId: string) => void;
}
function TreeNodeRow({
node,
depth,
expanded,
hasChildren,
columns,
categoryOptionsMap,
mainTableName,
onToggle,
onFieldChange,
onDelete,
onAddChild,
}: TreeNodeRowProps) {
const indentPx = depth * 32;
const visibleColumns = columns.filter((c) => c.visible !== false);
const renderCell = (col: BomColumnConfig) => {
const value = node.data[col.key] ?? "";
// 소스 표시 컬럼 (읽기 전용)
if (col.isSourceDisplay) {
return (
<span className="truncate text-xs" title={String(value)}>
{value || "-"}
</span>
);
}
// 카테고리 타입: API에서 로드한 옵션으로 Select 렌더링
if (col.inputType === "category") {
const categoryRef = mainTableName ? `${mainTableName}.${col.key}` : "";
const options = categoryOptionsMap[categoryRef] || [];
return (
<Select
value={String(value || "")}
onValueChange={(val) => onFieldChange(node.tempId, col.key, val)}
>
<SelectTrigger className="h-7 w-full min-w-[70px] text-xs">
<SelectValue placeholder={col.title} />
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
// 편집 불가능 컬럼
if (col.editable === false) {
return (
<span className="text-muted-foreground truncate text-xs">
{value || "-"}
</span>
);
}
// 숫자 입력
if (col.inputType === "number" || col.inputType === "decimal") {
return (
<Input
type="number"
value={String(value)}
onChange={(e) => onFieldChange(node.tempId, col.key, e.target.value)}
className="h-7 w-full min-w-[50px] text-center text-xs"
placeholder={col.title}
/>
);
}
// 기본 텍스트 입력
return (
<Input
value={String(value)}
onChange={(e) => onFieldChange(node.tempId, col.key, e.target.value)}
className="h-7 w-full min-w-[50px] text-xs"
placeholder={col.title}
/>
);
};
return (
<div
className={cn(
"group flex items-center gap-2 rounded-md border px-2 py-1.5",
"transition-colors hover:bg-accent/30",
depth > 0 && "ml-2 border-l-2 border-l-primary/20",
)}
style={{ marginLeft: `${indentPx}px` }}
>
<GripVertical className="text-muted-foreground h-4 w-4 shrink-0 cursor-grab" />
<button
onClick={onToggle}
className={cn(
"flex h-5 w-5 shrink-0 items-center justify-center rounded",
hasChildren
? "hover:bg-accent cursor-pointer"
: "cursor-default opacity-0",
)}
>
{hasChildren &&
(expanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
))}
</button>
<span className="text-muted-foreground w-6 shrink-0 text-center text-xs font-medium">
{node.seq_no}
</span>
{node.level > 0 && (
<span className="bg-primary/10 text-primary shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold">
L{node.level}
</span>
)}
{/* config.columns 기반 동적 셀 렌더링 */}
{visibleColumns.map((col) => (
<div
key={col.key}
className={cn(
"shrink-0",
col.isSourceDisplay ? "min-w-[60px] flex-1" : "min-w-[50px]",
)}
style={{ width: col.width && col.width !== "auto" ? col.width : undefined }}
>
{renderCell(col)}
</div>
))}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => onAddChild(node.tempId)}
title="하위 품목 추가"
>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10 h-7 w-7 shrink-0"
onClick={() => onDelete(node.tempId)}
title="삭제"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
);
}
// ─── 메인 컴포넌트 ───
export function BomItemEditorComponent({
component,
formData,
companyCode,
isDesignMode = false,
selectedRowsData,
onChange,
bomId: propBomId,
}: BomItemEditorProps) {
const [treeData, setTreeData] = useState<BomItemNode[]>([]);
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [itemSearchOpen, setItemSearchOpen] = useState(false);
const [addTargetParentId, setAddTargetParentId] = useState<string | null>(null);
const [categoryOptionsMap, setCategoryOptionsMap] = useState<Record<string, { value: string; label: string }[]>>({});
// 설정값 추출
const cfg = useMemo(() => component?.componentConfig || {}, [component]);
const mainTableName = cfg.mainTableName || "bom_detail";
const parentKeyColumn = cfg.parentKeyColumn || "parent_detail_id";
const columns: BomColumnConfig[] = useMemo(() => cfg.columns || [], [cfg.columns]);
const visibleColumns = useMemo(() => columns.filter((c) => c.visible !== false), [columns]);
const fkColumn = cfg.foreignKeyColumn || "bom_id";
// BOM ID 결정
const bomId = useMemo(() => {
if (propBomId) return propBomId;
if (formData?.id) return formData.id as string;
if (selectedRowsData?.[0]?.id) return selectedRowsData[0].id as string;
return null;
}, [propBomId, formData, selectedRowsData]);
// ─── 카테고리 옵션 로드 (리피터 방식) ───
useEffect(() => {
const loadCategoryOptions = async () => {
const categoryColumns = visibleColumns.filter((col) => col.inputType === "category");
if (categoryColumns.length === 0) return;
for (const col of categoryColumns) {
const categoryRef = `${mainTableName}.${col.key}`;
if (categoryOptionsMap[categoryRef]) continue;
try {
const response = await apiClient.get(`/table-categories/${mainTableName}/${col.key}/values`);
if (response.data?.success && response.data.data) {
const options = response.data.data.map((item: any) => ({
value: item.valueCode || item.value_code,
label: item.valueLabel || item.value_label || item.displayValue || item.display_value || item.label,
}));
setCategoryOptionsMap((prev) => ({ ...prev, [categoryRef]: options }));
}
} catch (error) {
console.error(`카테고리 옵션 로드 실패 (${categoryRef}):`, error);
}
}
};
if (!isDesignMode) {
loadCategoryOptions();
}
}, [visibleColumns, mainTableName, isDesignMode]);
// ─── 데이터 로드 ───
const loadBomDetails = useCallback(
async (id: string) => {
if (!id) return;
setLoading(true);
try {
const result = await entityJoinApi.getTableDataWithJoins(mainTableName, {
page: 1,
size: 500,
search: { [fkColumn]: id },
sortBy: "seq_no",
sortOrder: "asc",
enableEntityJoin: true,
});
const rows = result.data || [];
const tree = buildTree(rows);
setTreeData(tree);
const firstLevelIds = new Set<string>(
tree.map((n) => n.tempId || n.id || ""),
);
setExpandedNodes(firstLevelIds);
} catch (error) {
console.error("[BomItemEditor] 데이터 로드 실패:", error);
} finally {
setLoading(false);
}
},
[mainTableName, fkColumn],
);
useEffect(() => {
if (bomId && !isDesignMode) {
loadBomDetails(bomId);
}
}, [bomId, isDesignMode, loadBomDetails]);
// ─── 트리 빌드 (동적 데이터) ───
const buildTree = (flatData: any[]): BomItemNode[] => {
const nodeMap = new Map<string, BomItemNode>();
const roots: BomItemNode[] = [];
flatData.forEach((item) => {
const tempId = item.id || generateTempId();
nodeMap.set(item.id || tempId, {
tempId,
id: item.id,
parent_detail_id: item[parentKeyColumn] || null,
seq_no: Number(item.seq_no) || 0,
level: Number(item.level) || 0,
children: [],
data: { ...item },
});
});
flatData.forEach((item) => {
const nodeId = item.id || "";
const node = nodeMap.get(nodeId);
if (!node) return;
const parentId = item[parentKeyColumn];
if (parentId && nodeMap.has(parentId)) {
nodeMap.get(parentId)!.children.push(node);
} else {
roots.push(node);
}
});
const sortChildren = (nodes: BomItemNode[]) => {
nodes.sort((a, b) => a.seq_no - b.seq_no);
nodes.forEach((n) => sortChildren(n.children));
};
sortChildren(roots);
return roots;
};
// ─── 트리 -> 평면 변환 (onChange 콜백용) ───
const flattenTree = useCallback((nodes: BomItemNode[]): any[] => {
const result: any[] = [];
const traverse = (
items: BomItemNode[],
parentId: string | null,
level: number,
) => {
items.forEach((node, idx) => {
result.push({
...node.data,
id: node.id,
tempId: node.tempId,
[parentKeyColumn]: parentId,
seq_no: String(idx + 1),
level: String(level),
_isNew: node._isNew,
_targetTable: mainTableName,
});
if (node.children.length > 0) {
traverse(node.children, node.id || node.tempId, level + 1);
}
});
};
traverse(nodes, null, 0);
return result;
}, [parentKeyColumn, mainTableName]);
// 트리 변경 시 부모에게 알림
const notifyChange = useCallback(
(newTree: BomItemNode[]) => {
setTreeData(newTree);
onChange?.(flattenTree(newTree));
},
[onChange, flattenTree],
);
// ─── 노드 조작 함수들 ───
// 트리에서 특정 노드 찾기 (재귀)
const findAndUpdate = (
nodes: BomItemNode[],
targetTempId: string,
updater: (node: BomItemNode) => BomItemNode | null,
): BomItemNode[] => {
const result: BomItemNode[] = [];
for (const node of nodes) {
if (node.tempId === targetTempId) {
const updated = updater(node);
if (updated) result.push(updated);
} else {
result.push({
...node,
children: findAndUpdate(node.children, targetTempId, updater),
});
}
}
return result;
};
// 필드 변경 (data Record 내부 업데이트)
const handleFieldChange = useCallback(
(tempId: string, field: string, value: string) => {
const newTree = findAndUpdate(treeData, tempId, (node) => ({
...node,
data: { ...node.data, [field]: value },
}));
notifyChange(newTree);
},
[treeData, notifyChange],
);
// 노드 삭제
const handleDelete = useCallback(
(tempId: string) => {
const newTree = findAndUpdate(treeData, tempId, () => null);
notifyChange(newTree);
},
[treeData, notifyChange],
);
// 하위 품목 추가 시작 (모달 열기)
const handleAddChild = useCallback((parentTempId: string) => {
setAddTargetParentId(parentTempId);
setItemSearchOpen(true);
}, []);
// 루트 품목 추가 시작
const handleAddRoot = useCallback(() => {
setAddTargetParentId(null);
setItemSearchOpen(true);
}, []);
// 품목 선택 후 추가 (동적 데이터)
const handleItemSelect = useCallback(
(item: ItemInfo) => {
// 소스 테이블 데이터를 _display_ 접두사로 저장 (엔티티 조인 방식)
const sourceData: Record<string, any> = {};
const sourceTable = cfg.dataSource?.sourceTable;
if (sourceTable) {
const sourceFk = cfg.dataSource?.foreignKey || "child_item_id";
sourceData[sourceFk] = item.id;
// 소스 표시 컬럼의 데이터 병합
Object.keys(item).forEach((key) => {
sourceData[`_display_${key}`] = (item as any)[key];
sourceData[key] = (item as any)[key];
});
}
const newNode: BomItemNode = {
tempId: generateTempId(),
parent_detail_id: null,
seq_no: 0,
level: 0,
children: [],
_isNew: true,
data: {
...sourceData,
quantity: "1",
loss_rate: "0",
remark: "",
},
};
let newTree: BomItemNode[];
if (addTargetParentId === null) {
newNode.seq_no = treeData.length + 1;
newNode.level = 0;
newTree = [...treeData, newNode];
} else {
newTree = findAndUpdate(treeData, addTargetParentId, (parent) => {
newNode.parent_detail_id = parent.id || parent.tempId;
newNode.seq_no = parent.children.length + 1;
newNode.level = parent.level + 1;
return {
...parent,
children: [...parent.children, newNode],
};
});
setExpandedNodes((prev) => new Set([...prev, addTargetParentId]));
}
notifyChange(newTree);
},
[addTargetParentId, treeData, notifyChange, cfg],
);
// 펼침/접기 토글
const toggleExpand = useCallback((tempId: string) => {
setExpandedNodes((prev) => {
const next = new Set(prev);
if (next.has(tempId)) next.delete(tempId);
else next.add(tempId);
return next;
});
}, []);
// ─── 재귀 렌더링 ───
const renderNodes = (nodes: BomItemNode[], depth: number) => {
return nodes.map((node) => {
const isExpanded = expandedNodes.has(node.tempId);
return (
<React.Fragment key={node.tempId}>
<TreeNodeRow
node={node}
depth={depth}
expanded={isExpanded}
hasChildren={node.children.length > 0}
columns={visibleColumns}
categoryOptionsMap={categoryOptionsMap}
mainTableName={mainTableName}
onToggle={() => toggleExpand(node.tempId)}
onFieldChange={handleFieldChange}
onDelete={handleDelete}
onAddChild={handleAddChild}
/>
{isExpanded &&
node.children.length > 0 &&
renderNodes(node.children, depth + 1)}
</React.Fragment>
);
});
};
// ─── 디자인 모드 미리보기 ───
if (isDesignMode) {
const cfg = component?.componentConfig || {};
const hasConfig =
cfg.mainTableName || cfg.dataSource?.sourceTable || (cfg.columns && cfg.columns.length > 0);
if (!hasConfig) {
return (
<div className="rounded-md border border-dashed p-6 text-center">
<Package className="text-muted-foreground mx-auto mb-2 h-8 w-8" />
<p className="text-muted-foreground text-sm font-medium">
BOM
</p>
<p className="text-muted-foreground text-xs">
</p>
</div>
);
}
const visibleColumns = (cfg.columns || []).filter((c: any) => c.visible !== false);
const DUMMY_DATA: Record<string, string[]> = {
item_name: ["본체 조립", "프레임", "커버", "전장 조립", "PCB 보드"],
item_number: ["ASM-001", "PRT-010", "PRT-011", "ASM-002", "PRT-020"],
specification: ["100×50", "200mm", "ABS", "50×30", "4-Layer"],
material: ["AL6061", "SUS304", "ABS", "FR-4", "구리"],
stock_unit: ["EA", "EA", "EA", "EA", "EA"],
quantity: ["1", "2", "1", "1", "3"],
loss_rate: ["0", "5", "3", "0", "2"],
unit: ["EA", "EA", "EA", "EA", "EA"],
remark: ["", "외주", "", "", ""],
seq_no: ["1", "2", "3", "4", "5"],
};
const DUMMY_DEPTHS = [0, 1, 1, 0, 1];
const getDummyValue = (col: any, rowIdx: number): string => {
const vals = DUMMY_DATA[col.key];
if (vals) return vals[rowIdx % vals.length];
return "";
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold"> </h4>
<Button size="sm" className="h-7 text-xs" disabled>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{/* 설정 요약 뱃지 */}
<div className="flex flex-wrap gap-1">
{cfg.mainTableName && (
<span className="rounded bg-orange-100 px-1.5 py-0.5 text-[10px] text-orange-700">
: {cfg.mainTableName}
</span>
)}
{cfg.dataSource?.sourceTable && (
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700">
: {cfg.dataSource.sourceTable}
</span>
)}
{cfg.parentKeyColumn && (
<span className="rounded bg-green-100 px-1.5 py-0.5 text-[10px] text-green-700">
: {cfg.parentKeyColumn}
</span>
)}
</div>
{/* 테이블 형태 미리보기 - config.columns 순서 그대로 */}
<div className="overflow-hidden rounded-md border">
{visibleColumns.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6">
<Package className="text-muted-foreground mb-1.5 h-6 w-6" />
<p className="text-muted-foreground text-xs">
</p>
</div>
) : (
<table className="w-full text-[10px]">
<thead className="bg-muted/60">
<tr>
<th className="w-6 px-1 py-1.5 text-center font-medium" />
<th className="w-5 px-0.5 py-1.5 text-center font-medium">#</th>
{visibleColumns.map((col: any) => (
<th
key={col.key}
className={cn(
"px-2 py-1.5 text-left font-medium",
col.isSourceDisplay && "text-blue-600",
)}
style={{ width: col.width && col.width !== "auto" ? col.width : undefined }}
>
{col.title}
</th>
))}
<th className="w-14 px-1 py-1.5 text-center font-medium"></th>
</tr>
</thead>
<tbody>
{DUMMY_DEPTHS.map((depth, rowIdx) => (
<tr
key={rowIdx}
className={cn(
"border-t transition-colors",
rowIdx === 0 && "bg-accent/20",
)}
>
<td className="px-1 py-1 text-center">
<div className="flex items-center justify-center gap-0.5" style={{ paddingLeft: `${depth * 10}px` }}>
{depth === 0 ? (
<ChevronDown className="h-3 w-3 opacity-50" />
) : (
<span className="text-primary/40 text-[10px]"></span>
)}
</div>
</td>
<td className="text-muted-foreground px-0.5 py-1 text-center">
{rowIdx + 1}
</td>
{visibleColumns.map((col: any) => (
<td key={col.key} className="px-1.5 py-0.5">
{col.isSourceDisplay ? (
<span className="truncate text-blue-600">
{getDummyValue(col, rowIdx) || col.title}
</span>
) : col.editable !== false ? (
<div className="h-5 rounded border bg-background px-1.5 text-[10px] leading-5">
{getDummyValue(col, rowIdx)}
</div>
) : (
<span className="text-muted-foreground">
{getDummyValue(col, rowIdx)}
</span>
)}
</td>
))}
<td className="px-1 py-1 text-center">
<div className="flex items-center justify-center gap-0.5">
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center rounded opacity-40">
<Plus className="h-3 w-3" />
</div>
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center rounded opacity-40">
<X className="h-3 w-3" />
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
// ─── 메인 렌더링 ───
return (
<div className="space-y-3">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold"> </h4>
<Button
onClick={handleAddRoot}
size="sm"
className="h-8 text-xs"
>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
{/* 트리 목록 */}
<div className="space-y-1">
{loading ? (
<div className="flex items-center justify-center py-8">
<span className="text-muted-foreground text-sm"> ...</span>
</div>
) : treeData.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-md border border-dashed py-8">
<Package className="text-muted-foreground mb-2 h-8 w-8" />
<p className="text-muted-foreground text-sm">
.
</p>
<p className="text-muted-foreground text-xs">
&quot;&quot; .
</p>
</div>
) : (
renderNodes(treeData, 0)
)}
</div>
{/* 품목 검색 모달 */}
<ItemSearchModal
open={itemSearchOpen}
onClose={() => setItemSearchOpen(false)}
onSelect={handleItemSelect}
companyCode={companyCode}
/>
</div>
);
}
export default BomItemEditorComponent;

View File

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

View File

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

View File

@ -0,0 +1,448 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { ChevronRight, ChevronDown, Package, Layers, Box, AlertCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { entityJoinApi } from "@/lib/api/entityJoin";
/**
* BOM
*/
interface BomTreeNode {
id: string;
bom_id: string;
parent_detail_id: string | null;
seq_no: string;
level: string;
child_item_id: string;
child_item_code: string;
child_item_name: string;
child_item_type: string;
quantity: string;
unit: string;
loss_rate: string;
remark: string;
children: BomTreeNode[];
}
/**
* BOM
*/
interface BomHeaderInfo {
id: string;
bom_number: string;
item_code: string;
item_name: string;
item_type: string;
base_qty: string;
unit: string;
version: string;
revision: string;
status: string;
effective_date: string;
expired_date: string;
remark: string;
}
interface BomTreeComponentProps {
component?: any;
formData?: Record<string, any>;
tableName?: string;
companyCode?: string;
isDesignMode?: boolean;
selectedRowsData?: any[];
[key: string]: any;
}
/**
* BOM
* BOM BOM
*/
export function BomTreeComponent({
component,
formData,
companyCode,
isDesignMode = false,
selectedRowsData,
...props
}: BomTreeComponentProps) {
const [headerInfo, setHeaderInfo] = useState<BomHeaderInfo | null>(null);
const [treeData, setTreeData] = useState<BomTreeNode[]>([]);
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const config = component?.componentConfig || {};
// 선택된 BOM 헤더에서 bom_id 추출
const selectedBomId = useMemo(() => {
// SplitPanel에서 좌측 선택 시 formData나 selectedRowsData로 전달됨
if (selectedRowsData && selectedRowsData.length > 0) {
return selectedRowsData[0]?.id;
}
if (formData?.id) return formData.id;
return null;
}, [formData, selectedRowsData]);
// 선택된 BOM 헤더 정보 추출
const selectedHeaderData = useMemo(() => {
if (selectedRowsData && selectedRowsData.length > 0) {
return selectedRowsData[0] as BomHeaderInfo;
}
if (formData?.id) return formData as unknown as BomHeaderInfo;
return null;
}, [formData, selectedRowsData]);
// BOM 디테일 데이터 로드
const loadBomDetails = useCallback(async (bomId: string) => {
if (!bomId) return;
setLoading(true);
try {
const result = await entityJoinApi.getTableDataWithJoins("bom_detail", {
page: 1,
size: 500,
search: { bom_id: bomId },
sortBy: "seq_no",
sortOrder: "asc",
enableEntityJoin: true,
});
const rows = result.data || [];
const tree = buildTree(rows);
setTreeData(tree);
const firstLevelIds = new Set<string>(tree.map((n: BomTreeNode) => n.id));
setExpandedNodes(firstLevelIds);
} catch (error) {
console.error("[BomTree] 데이터 로드 실패:", error);
} finally {
setLoading(false);
}
}, []);
// 평면 데이터 -> 트리 구조 변환
const buildTree = (flatData: any[]): BomTreeNode[] => {
const nodeMap = new Map<string, BomTreeNode>();
const roots: BomTreeNode[] = [];
// 모든 노드를 맵에 등록
flatData.forEach((item) => {
nodeMap.set(item.id, { ...item, children: [] });
});
// 부모-자식 관계 설정
flatData.forEach((item) => {
const node = nodeMap.get(item.id)!;
if (item.parent_detail_id && nodeMap.has(item.parent_detail_id)) {
nodeMap.get(item.parent_detail_id)!.children.push(node);
} else {
roots.push(node);
}
});
return roots;
};
// 선택된 BOM 변경 시 데이터 로드
useEffect(() => {
if (selectedBomId) {
setHeaderInfo(selectedHeaderData);
loadBomDetails(selectedBomId);
} else {
setHeaderInfo(null);
setTreeData([]);
}
}, [selectedBomId, selectedHeaderData, loadBomDetails]);
// 노드 펼치기/접기 토글
const toggleNode = useCallback((nodeId: string) => {
setExpandedNodes((prev) => {
const next = new Set(prev);
if (next.has(nodeId)) {
next.delete(nodeId);
} else {
next.add(nodeId);
}
return next;
});
}, []);
// 전체 펼치기
const expandAll = useCallback(() => {
const allIds = new Set<string>();
const collectIds = (nodes: BomTreeNode[]) => {
nodes.forEach((n) => {
allIds.add(n.id);
if (n.children.length > 0) collectIds(n.children);
});
};
collectIds(treeData);
setExpandedNodes(allIds);
}, [treeData]);
// 전체 접기
const collapseAll = useCallback(() => {
setExpandedNodes(new Set());
}, []);
// 품목 구분 라벨
const getItemTypeLabel = (type: string) => {
switch (type) {
case "product": return "제품";
case "semi": return "반제품";
case "material": return "원자재";
case "part": return "부품";
default: return type || "-";
}
};
// 품목 구분 아이콘 & 색상
const getItemTypeStyle = (type: string) => {
switch (type) {
case "product":
return { icon: Package, color: "text-blue-600", bg: "bg-blue-50" };
case "semi":
return { icon: Layers, color: "text-amber-600", bg: "bg-amber-50" };
case "material":
return { icon: Box, color: "text-emerald-600", bg: "bg-emerald-50" };
default:
return { icon: Box, color: "text-gray-500", bg: "bg-gray-50" };
}
};
// 디자인 모드 미리보기
if (isDesignMode) {
return (
<div className="flex h-full flex-col rounded-md border bg-white p-4">
<div className="mb-3 flex items-center gap-2">
<Layers className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">BOM </span>
</div>
<div className="flex-1 space-y-1 rounded border border-dashed border-gray-300 p-3">
<div className="flex items-center gap-2 text-xs text-gray-500">
<ChevronDown className="h-3 w-3" />
<Package className="h-3 w-3 text-blue-500" />
<span> A ()</span>
<span className="ml-auto text-gray-400">수량: 1</span>
</div>
<div className="ml-5 flex items-center gap-2 text-xs text-gray-500">
<ChevronRight className="h-3 w-3" />
<Layers className="h-3 w-3 text-amber-500" />
<span> B ()</span>
<span className="ml-auto text-gray-400">수량: 2</span>
</div>
<div className="ml-5 flex items-center gap-2 text-xs text-gray-500">
<span className="ml-3.5" />
<Box className="h-3 w-3 text-emerald-500" />
<span> C ()</span>
<span className="ml-auto text-gray-400">수량: 5</span>
</div>
</div>
</div>
);
}
// 선택 안 된 상태
if (!selectedBomId) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-muted-foreground text-center text-sm">
<p className="mb-2"> BOM을 </p>
<p className="text-xs"> BOM의 </p>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col">
{/* 헤더 정보 */}
{headerInfo && (
<div className="border-b bg-gray-50/80 px-4 py-3">
<div className="mb-2 flex items-center gap-2">
<Package className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold">{headerInfo.item_name || "-"}</h3>
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
{headerInfo.bom_number || "-"}
</span>
<span className={cn(
"ml-1 rounded px-1.5 py-0.5 text-[10px] font-medium",
headerInfo.status === "active" ? "bg-emerald-100 text-emerald-700" : "bg-gray-100 text-gray-500"
)}>
{headerInfo.status === "active" ? "사용" : "미사용"}
</span>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>: <b className="text-foreground">{headerInfo.item_code || "-"}</b></span>
<span>: <b className="text-foreground">{getItemTypeLabel(headerInfo.item_type)}</b></span>
<span>: <b className="text-foreground">{headerInfo.base_qty || "1"} {headerInfo.unit || ""}</b></span>
<span>: <b className="text-foreground">v{headerInfo.version || "1.0"} ( {headerInfo.revision || "1"})</b></span>
</div>
</div>
)}
{/* 트리 툴바 */}
<div className="flex items-center gap-2 border-b px-4 py-2">
<span className="text-xs font-medium text-muted-foreground">BOM </span>
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
{treeData.length}
</span>
<div className="ml-auto flex gap-1">
<button
onClick={expandAll}
className="rounded px-2 py-0.5 text-[10px] text-muted-foreground hover:bg-gray-100"
>
</button>
<button
onClick={collapseAll}
className="rounded px-2 py-0.5 text-[10px] text-muted-foreground hover:bg-gray-100"
>
</button>
</div>
</div>
{/* 트리 컨텐츠 */}
<div className="flex-1 overflow-auto px-2 py-2">
{loading ? (
<div className="flex h-32 items-center justify-center">
<div className="text-muted-foreground text-sm"> ...</div>
</div>
) : treeData.length === 0 ? (
<div className="flex h-32 flex-col items-center justify-center gap-2">
<AlertCircle className="h-5 w-5 text-muted-foreground" />
<p className="text-xs text-muted-foreground"> </p>
</div>
) : (
<div className="space-y-0.5">
{treeData.map((node) => (
<TreeNodeRow
key={node.id}
node={node}
depth={0}
expandedNodes={expandedNodes}
selectedNodeId={selectedNodeId}
onToggle={toggleNode}
onSelect={setSelectedNodeId}
getItemTypeLabel={getItemTypeLabel}
getItemTypeStyle={getItemTypeStyle}
/>
))}
</div>
)}
</div>
</div>
);
}
/**
* ( )
*/
interface TreeNodeRowProps {
node: BomTreeNode;
depth: number;
expandedNodes: Set<string>;
selectedNodeId: string | null;
onToggle: (id: string) => void;
onSelect: (id: string) => void;
getItemTypeLabel: (type: string) => string;
getItemTypeStyle: (type: string) => { icon: any; color: string; bg: string };
}
function TreeNodeRow({
node,
depth,
expandedNodes,
selectedNodeId,
onToggle,
onSelect,
getItemTypeLabel,
getItemTypeStyle,
}: TreeNodeRowProps) {
const isExpanded = expandedNodes.has(node.id);
const hasChildren = node.children.length > 0;
const isSelected = selectedNodeId === node.id;
const style = getItemTypeStyle(node.child_item_type);
const ItemIcon = style.icon;
return (
<>
<div
className={cn(
"group flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1.5 transition-colors",
isSelected ? "bg-primary/10" : "hover:bg-gray-50"
)}
style={{ paddingLeft: `${depth * 20 + 8}px` }}
onClick={() => {
onSelect(node.id);
if (hasChildren) onToggle(node.id);
}}
>
{/* 펼치기/접기 화살표 */}
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center">
{hasChildren ? (
isExpanded ? (
<ChevronDown className="h-3.5 w-3.5 text-gray-400" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-gray-400" />
)
) : (
<span className="h-1 w-1 rounded-full bg-gray-300" />
)}
</span>
{/* 품목 타입 아이콘 */}
<span className={cn("flex h-5 w-5 flex-shrink-0 items-center justify-center rounded", style.bg)}>
<ItemIcon className={cn("h-3 w-3", style.color)} />
</span>
{/* 품목 정보 */}
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate text-xs font-medium text-foreground">
{node.child_item_name || "-"}
</span>
<span className="flex-shrink-0 text-[10px] text-muted-foreground">
{node.child_item_code || ""}
</span>
<span className={cn(
"flex-shrink-0 rounded px-1 py-0.5 text-[10px]",
style.bg, style.color
)}>
{getItemTypeLabel(node.child_item_type)}
</span>
</div>
{/* 수량/단위 */}
<div className="flex flex-shrink-0 items-center gap-2 text-[11px]">
<span className="text-muted-foreground">
: <b className="text-foreground">{node.quantity || "0"}</b> {node.unit || ""}
</span>
{node.loss_rate && node.loss_rate !== "0" && (
<span className="text-amber-600">
: {node.loss_rate}%
</span>
)}
</div>
</div>
{/* 하위 노드 재귀 렌더링 */}
{hasChildren && isExpanded && (
<div>
{node.children.map((child) => (
<TreeNodeRow
key={child.id}
node={child}
depth={depth + 1}
expandedNodes={expandedNodes}
selectedNodeId={selectedNodeId}
onToggle={onToggle}
onSelect={onSelect}
getItemTypeLabel={getItemTypeLabel}
getItemTypeStyle={getItemTypeStyle}
/>
))}
</div>
)}
</>
);
}

View File

@ -0,0 +1,26 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2BomTreeDefinition } from "./index";
import { BomTreeComponent } from "./BomTreeComponent";
export class BomTreeRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = V2BomTreeDefinition;
render(): React.ReactElement {
return <BomTreeComponent {...this.props} />;
}
}
BomTreeRenderer.registerSelf();
if (typeof window !== "undefined") {
setTimeout(() => {
try {
BomTreeRenderer.registerSelf();
} catch (error) {
console.error("BomTree 등록 실패:", error);
}
}, 1000);
}

View File

@ -0,0 +1,27 @@
"use client";
import { ComponentCategory } from "@/types/component";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { BomTreeComponent } from "./BomTreeComponent";
export const V2BomTreeDefinition = createComponentDefinition({
id: "v2-bom-tree",
name: "BOM 트리 뷰",
nameEng: "BOM Tree View",
description: "BOM 구성을 계층 트리 형태로 표시하는 컴포넌트",
category: ComponentCategory.V2,
webType: "text",
component: BomTreeComponent,
defaultConfig: {
detailTable: "bom_detail",
foreignKey: "bom_id",
parentKey: "parent_detail_id",
},
defaultSize: { width: 900, height: 600 },
icon: "GitBranch",
tags: ["BOM", "트리", "계층", "제조", "생산"],
version: "1.0.0",
author: "개발팀",
});
export default V2BomTreeDefinition;

View File

@ -0,0 +1,503 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Search, Plus, Trash2, Edit, ListOrdered, Package } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { cn } from "@/lib/utils";
import { useToast } from "@/hooks/use-toast";
import { ItemRoutingConfig, ItemRoutingComponentProps } from "./types";
import { defaultConfig } from "./config";
import { useItemRouting } from "./hooks/useItemRouting";
export function ItemRoutingComponent({
config: configProp,
isPreview,
}: ItemRoutingComponentProps) {
const { toast } = useToast();
const {
config,
items,
versions,
details,
loading,
selectedItemCode,
selectedItemName,
selectedVersionId,
fetchItems,
selectItem,
selectVersion,
refreshVersions,
refreshDetails,
deleteDetail,
deleteVersion,
} = useItemRouting(configProp || {});
const [searchText, setSearchText] = useState("");
const [deleteTarget, setDeleteTarget] = useState<{
type: "version" | "detail";
id: string;
name: string;
} | null>(null);
// 초기 로딩 (마운트 시 1회만)
const mountedRef = React.useRef(false);
useEffect(() => {
if (!mountedRef.current) {
mountedRef.current = true;
fetchItems();
}
}, [fetchItems]);
// 모달 저장 성공 감지 -> 데이터 새로고침
useEffect(() => {
const handleSaveSuccess = () => {
refreshVersions();
refreshDetails();
};
window.addEventListener("saveSuccessInModal", handleSaveSuccess);
return () => {
window.removeEventListener("saveSuccessInModal", handleSaveSuccess);
};
}, [refreshVersions, refreshDetails]);
// 품목 검색
const handleSearch = useCallback(() => {
fetchItems(searchText || undefined);
}, [fetchItems, searchText]);
const handleSearchKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") handleSearch();
},
[handleSearch]
);
// 버전 추가 모달
const handleAddVersion = useCallback(() => {
if (!selectedItemCode) {
toast({ title: "품목을 먼저 선택해주세요", variant: "destructive" });
return;
}
const screenId = config.modals.versionAddScreenId;
if (!screenId) return;
window.dispatchEvent(
new CustomEvent("openScreenModal", {
detail: {
screenId,
urlParams: { mode: "add", tableName: config.dataSource.routingVersionTable },
splitPanelParentData: {
[config.dataSource.routingVersionFkColumn]: selectedItemCode,
},
},
})
);
}, [selectedItemCode, config, toast]);
// 공정 추가 모달
const handleAddProcess = useCallback(() => {
if (!selectedVersionId) {
toast({ title: "라우팅 버전을 먼저 선택해주세요", variant: "destructive" });
return;
}
const screenId = config.modals.processAddScreenId;
if (!screenId) return;
window.dispatchEvent(
new CustomEvent("openScreenModal", {
detail: {
screenId,
urlParams: { mode: "add", tableName: config.dataSource.routingDetailTable },
splitPanelParentData: {
[config.dataSource.routingDetailFkColumn]: selectedVersionId,
},
},
})
);
}, [selectedVersionId, config, toast]);
// 공정 수정 모달
const handleEditProcess = useCallback(
(detail: Record<string, any>) => {
const screenId = config.modals.processEditScreenId;
if (!screenId) return;
window.dispatchEvent(
new CustomEvent("openScreenModal", {
detail: {
screenId,
urlParams: { mode: "edit", tableName: config.dataSource.routingDetailTable },
editData: detail,
},
})
);
},
[config]
);
// 삭제 확인
const handleConfirmDelete = useCallback(async () => {
if (!deleteTarget) return;
let success = false;
if (deleteTarget.type === "version") {
success = await deleteVersion(deleteTarget.id);
} else {
success = await deleteDetail(deleteTarget.id);
}
if (success) {
toast({ title: `${deleteTarget.name} 삭제 완료` });
} else {
toast({ title: "삭제 실패", variant: "destructive" });
}
setDeleteTarget(null);
}, [deleteTarget, deleteVersion, deleteDetail, toast]);
// entity join으로 가져온 공정명 컬럼 이름 추정
const processNameKey = useMemo(() => {
const ds = config.dataSource;
return `${ds.processTable}_${ds.processNameColumn}`;
}, [config.dataSource]);
const splitRatio = config.splitRatio || 40;
if (isPreview) {
return (
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/20 bg-muted/10 p-4">
<div className="text-center">
<ListOrdered className="mx-auto mb-2 h-8 w-8 text-muted-foreground/50" />
<p className="text-sm font-medium text-muted-foreground">
</p>
<p className="mt-1 text-xs text-muted-foreground/70">
- -
</p>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-background">
<div className="flex flex-1 overflow-hidden">
{/* 좌측 패널: 품목 목록 */}
<div
style={{ width: `${splitRatio}%` }}
className="flex shrink-0 flex-col overflow-hidden border-r"
>
<div className="border-b px-3 py-2">
<h3 className="text-sm font-semibold">
{config.leftPanelTitle || "품목 목록"}
</h3>
</div>
{/* 검색 */}
<div className="flex gap-1.5 border-b px-3 py-2">
<Input
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder="품목명/품번 검색"
className="h-8 text-xs"
/>
<Button variant="outline" size="icon" className="h-8 w-8 shrink-0" onClick={handleSearch}>
<Search className="h-3.5 w-3.5" />
</Button>
</div>
{/* 품목 리스트 */}
<div className="flex-1 overflow-y-auto">
{items.length === 0 ? (
<div className="flex h-full items-center justify-center p-4">
<p className="text-xs text-muted-foreground">
{loading ? "로딩 중..." : "품목이 없습니다"}
</p>
</div>
) : (
<div className="divide-y">
{items.map((item) => {
const itemCode =
item[config.dataSource.itemCodeColumn] || item.item_code || item.item_number;
const itemName =
item[config.dataSource.itemNameColumn] || item.item_name;
const isSelected = selectedItemCode === itemCode;
return (
<button
key={item.id}
className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-muted/50",
isSelected && "bg-primary/10 font-medium"
)}
onClick={() => selectItem(itemCode, itemName)}
>
<Package className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{itemName}</p>
<p className="truncate text-muted-foreground">{itemCode}</p>
</div>
</button>
);
})}
</div>
)}
</div>
</div>
{/* 우측 패널: 버전 + 공정 */}
<div className="flex flex-1 flex-col overflow-hidden">
{selectedItemCode ? (
<>
{/* 헤더: 선택된 품목 + 버전 추가 */}
<div className="flex items-center justify-between border-b px-4 py-2">
<div>
<h3 className="text-sm font-semibold">{selectedItemName}</h3>
<p className="text-xs text-muted-foreground">{selectedItemCode}</p>
</div>
{!config.readonly && (
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={handleAddVersion}
>
<Plus className="h-3 w-3" />
{config.versionAddButtonText || "+ 라우팅 버전 추가"}
</Button>
)}
</div>
{/* 버전 선택 버튼들 */}
{versions.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5 border-b px-4 py-2">
<span className="mr-1 text-xs text-muted-foreground">:</span>
{versions.map((ver) => {
const isActive = selectedVersionId === ver.id;
return (
<div key={ver.id} className="flex items-center gap-0.5">
<Badge
variant={isActive ? "default" : "outline"}
className={cn(
"cursor-pointer px-2.5 py-0.5 text-xs transition-colors",
isActive && "bg-primary text-primary-foreground"
)}
onClick={() => selectVersion(ver.id)}
>
{ver[config.dataSource.routingVersionNameColumn] || ver.version_name || ver.id}
</Badge>
{!config.readonly && (
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
setDeleteTarget({
type: "version",
id: ver.id,
name: ver.version_name || ver.id,
});
}}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
);
})}
</div>
) : (
<div className="border-b px-4 py-3 text-center">
<p className="text-xs text-muted-foreground">
. .
</p>
</div>
)}
{/* 공정 테이블 */}
{selectedVersionId ? (
<div className="flex flex-1 flex-col overflow-hidden">
{/* 공정 테이블 헤더 */}
<div className="flex items-center justify-between px-4 py-2">
<h4 className="text-xs font-medium text-muted-foreground">
{config.rightPanelTitle || "공정 순서"} ({details.length})
</h4>
{!config.readonly && (
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={handleAddProcess}
>
<Plus className="h-3 w-3" />
{config.processAddButtonText || "+ 공정 추가"}
</Button>
)}
</div>
{/* 테이블 */}
<div className="flex-1 overflow-auto px-4 pb-4">
{details.length === 0 ? (
<div className="flex h-32 items-center justify-center">
<p className="text-xs text-muted-foreground">
{loading ? "로딩 중..." : "등록된 공정이 없습니다"}
</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
{config.processColumns.map((col) => (
<TableHead
key={col.name}
style={{ width: col.width ? `${col.width}px` : undefined }}
className={cn(
"text-xs",
col.align === "center" && "text-center",
col.align === "right" && "text-right"
)}
>
{col.label}
</TableHead>
))}
{!config.readonly && (
<TableHead className="w-[80px] text-center text-xs">
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{details.map((detail) => (
<TableRow key={detail.id}>
{config.processColumns.map((col) => {
let cellValue = detail[col.name];
if (
col.name === "process_code" &&
detail[processNameKey]
) {
cellValue = `${detail[col.name]} (${detail[processNameKey]})`;
}
return (
<TableCell
key={col.name}
className={cn(
"text-xs",
col.align === "center" && "text-center",
col.align === "right" && "text-right"
)}
>
{cellValue ?? "-"}
</TableCell>
);
})}
{!config.readonly && (
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleEditProcess(detail)}
>
<Edit className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
onClick={() =>
setDeleteTarget({
type: "detail",
id: detail.id,
name: `공정 ${detail.seq_no || detail.id}`,
})
}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
) : (
versions.length > 0 && (
<div className="flex flex-1 items-center justify-center">
<p className="text-xs text-muted-foreground">
</p>
</div>
)
)}
</>
) : (
<div className="flex flex-1 flex-col items-center justify-center text-center">
<ListOrdered className="mb-3 h-12 w-12 text-muted-foreground/30" />
<p className="text-sm font-medium text-muted-foreground">
</p>
<p className="mt-1 text-xs text-muted-foreground/70">
</p>
</div>
)}
</div>
</div>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="text-base"> </AlertDialogTitle>
<AlertDialogDescription className="text-sm">
{deleteTarget?.name}() ?
{deleteTarget?.type === "version" && (
<>
<br />
.
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@ -0,0 +1,780 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Plus, Trash2, Check, ChevronsUpDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { ItemRoutingConfig, ProcessColumnDef } from "./types";
import { defaultConfig } from "./config";
interface TableInfo {
tableName: string;
displayName?: string;
}
interface ColumnInfo {
columnName: string;
displayName?: string;
dataType?: string;
}
interface ScreenInfo {
screenId: number;
screenName: string;
screenCode: string;
}
// 테이블 셀렉터 Combobox
function TableSelector({
value,
onChange,
tables,
loading,
}: {
value: string;
onChange: (v: string) => void;
tables: TableInfo[];
loading: boolean;
}) {
const [open, setOpen] = useState(false);
const selected = tables.find((t) => t.tableName === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="h-8 w-full justify-between text-xs"
disabled={loading}
>
{loading
? "로딩 중..."
: selected
? selected.displayName || selected.tableName
: "테이블 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-4 text-center text-xs">
.
</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{tables.map((t) => (
<CommandItem
key={t.tableName}
value={`${t.displayName || ""} ${t.tableName}`}
onSelect={() => {
onChange(t.tableName);
setOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
value === t.tableName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">
{t.displayName || t.tableName}
</span>
{t.displayName && (
<span className="text-[10px] text-muted-foreground">
{t.tableName}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// 컬럼 셀렉터 Combobox
function ColumnSelector({
value,
onChange,
tableName,
label,
}: {
value: string;
onChange: (v: string) => void;
tableName: string;
label?: string;
}) {
const [open, setOpen] = useState(false);
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!tableName) {
setColumns([]);
return;
}
const load = async () => {
setLoading(true);
try {
const { tableManagementApi } = await import(
"@/lib/api/tableManagement"
);
const res = await tableManagementApi.getColumnList(tableName);
if (res.success && res.data?.columns) {
setColumns(res.data.columns);
}
} catch {
/* ignore */
} finally {
setLoading(false);
}
};
load();
}, [tableName]);
const selected = columns.find((c) => c.columnName === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="h-8 w-full justify-between text-xs"
disabled={loading || !tableName}
>
{loading
? "로딩..."
: !tableName
? "테이블 먼저 선택"
: selected
? selected.displayName || selected.columnName
: label || "컬럼 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[260px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-4 text-center text-xs">
.
</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{columns.map((c) => (
<CommandItem
key={c.columnName}
value={`${c.displayName || ""} ${c.columnName}`}
onSelect={() => {
onChange(c.columnName);
setOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
value === c.columnName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">
{c.displayName || c.columnName}
</span>
{c.displayName && (
<span className="text-[10px] text-muted-foreground">
{c.columnName}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// 화면 셀렉터 Combobox
function ScreenSelector({
value,
onChange,
}: {
value?: number;
onChange: (v?: number) => void;
}) {
const [open, setOpen] = useState(false);
const [screens, setScreens] = useState<ScreenInfo[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const load = async () => {
setLoading(true);
try {
const { screenApi } = await import("@/lib/api/screen");
const res = await screenApi.getScreens({ page: 1, size: 1000 });
setScreens(
res.data.map((s: any) => ({
screenId: s.screenId,
screenName: s.screenName,
screenCode: s.screenCode,
}))
);
} catch {
/* ignore */
} finally {
setLoading(false);
}
};
load();
}, []);
const selected = screens.find((s) => s.screenId === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="h-8 w-full justify-between text-xs"
disabled={loading}
>
{loading
? "로딩 중..."
: selected
? `${selected.screenName} (${selected.screenId})`
: "화면 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[350px] p-0" align="start">
<Command>
<CommandInput placeholder="화면 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-4 text-center text-xs">
.
</CommandEmpty>
<CommandGroup className="max-h-[300px] overflow-auto">
{screens.map((s) => (
<CommandItem
key={s.screenId}
value={`${s.screenName.toLowerCase()} ${s.screenCode.toLowerCase()} ${s.screenId}`}
onSelect={() => {
onChange(s.screenId === value ? undefined : s.screenId);
setOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
value === s.screenId ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{s.screenName}</span>
<span className="text-[10px] text-muted-foreground">
{s.screenCode} (ID: {s.screenId})
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// 공정 테이블 컬럼 셀렉터 (routingDetailTable의 컬럼 목록에서 선택)
function ProcessColumnSelector({
value,
onChange,
tableName,
processTable,
}: {
value: string;
onChange: (v: string) => void;
tableName: string;
processTable: string;
}) {
const [open, setOpen] = useState(false);
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const loadAll = async () => {
if (!tableName) return;
setLoading(true);
try {
const { tableManagementApi } = await import(
"@/lib/api/tableManagement"
);
const res = await tableManagementApi.getColumnList(tableName);
const cols: ColumnInfo[] = [];
if (res.success && res.data?.columns) {
cols.push(...res.data.columns);
}
if (processTable && processTable !== tableName) {
const res2 = await tableManagementApi.getColumnList(processTable);
if (res2.success && res2.data?.columns) {
cols.push(
...res2.data.columns.map((c: any) => ({
...c,
columnName: c.columnName,
displayName: `[${processTable}] ${c.displayName || c.columnName}`,
}))
);
}
}
setColumns(cols);
} catch {
/* ignore */
} finally {
setLoading(false);
}
};
loadAll();
}, [tableName, processTable]);
const selected = columns.find((c) => c.columnName === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-24 justify-between text-[10px]"
disabled={loading}
>
{selected ? selected.displayName || selected.columnName : value || "선택"}
<ChevronsUpDown className="ml-1 h-2.5 w-2.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-3 text-center text-xs">
</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{columns.map((c) => (
<CommandItem
key={c.columnName}
value={`${c.displayName || ""} ${c.columnName}`}
onSelect={() => {
onChange(c.columnName);
setOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-1 h-3 w-3",
value === c.columnName ? "opacity-100" : "opacity-0"
)}
/>
{c.displayName || c.columnName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
interface ConfigPanelProps {
config: Partial<ItemRoutingConfig>;
onChange: (config: Partial<ItemRoutingConfig>) => void;
}
export function ItemRoutingConfigPanel({
config: configProp,
onChange,
}: ConfigPanelProps) {
const config: ItemRoutingConfig = {
...defaultConfig,
...configProp,
dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource },
modals: { ...defaultConfig.modals, ...configProp?.modals },
processColumns: configProp?.processColumns?.length
? configProp.processColumns
: defaultConfig.processColumns,
};
const [allTables, setAllTables] = useState<TableInfo[]>([]);
const [tablesLoading, setTablesLoading] = useState(false);
useEffect(() => {
const load = async () => {
setTablesLoading(true);
try {
const { tableManagementApi } = await import(
"@/lib/api/tableManagement"
);
const res = await tableManagementApi.getTableList();
if (res.success && res.data) {
setAllTables(res.data);
}
} catch {
/* ignore */
} finally {
setTablesLoading(false);
}
};
load();
}, []);
const update = (partial: Partial<ItemRoutingConfig>) => {
onChange({ ...configProp, ...partial });
};
const updateDataSource = (field: string, value: string) => {
update({ dataSource: { ...config.dataSource, [field]: value } });
};
const updateModals = (field: string, value: number | undefined) => {
update({ modals: { ...config.modals, [field]: value } });
};
// 컬럼 관리
const addColumn = () => {
update({
processColumns: [
...config.processColumns,
{ name: "", label: "새 컬럼", width: 100 },
],
});
};
const removeColumn = (idx: number) => {
update({
processColumns: config.processColumns.filter((_, i) => i !== idx),
});
};
const updateColumn = (
idx: number,
field: keyof ProcessColumnDef,
value: any
) => {
const next = [...config.processColumns];
next[idx] = { ...next[idx], [field]: value };
update({ processColumns: next });
};
return (
<div className="space-y-5 p-4">
<h3 className="text-sm font-semibold"> </h3>
{/* 데이터 소스 설정 */}
<section className="space-y-3">
<p className="text-xs font-medium text-muted-foreground">
</p>
<div>
<Label className="text-xs"> </Label>
<TableSelector
value={config.dataSource.itemTable}
onChange={(v) => updateDataSource("itemTable", v)}
tables={allTables}
loading={tablesLoading}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"> </Label>
<ColumnSelector
value={config.dataSource.itemNameColumn}
onChange={(v) => updateDataSource("itemNameColumn", v)}
tableName={config.dataSource.itemTable}
label="품목명"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<ColumnSelector
value={config.dataSource.itemCodeColumn}
onChange={(v) => updateDataSource("itemCodeColumn", v)}
tableName={config.dataSource.itemTable}
label="품목코드"
/>
</div>
</div>
<div>
<Label className="text-xs"> </Label>
<TableSelector
value={config.dataSource.routingVersionTable}
onChange={(v) => updateDataSource("routingVersionTable", v)}
tables={allTables}
loading={tablesLoading}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"> FK </Label>
<ColumnSelector
value={config.dataSource.routingVersionFkColumn}
onChange={(v) => updateDataSource("routingVersionFkColumn", v)}
tableName={config.dataSource.routingVersionTable}
label="FK 컬럼"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<ColumnSelector
value={config.dataSource.routingVersionNameColumn}
onChange={(v) =>
updateDataSource("routingVersionNameColumn", v)
}
tableName={config.dataSource.routingVersionTable}
label="버전명"
/>
</div>
</div>
<div>
<Label className="text-xs"> </Label>
<TableSelector
value={config.dataSource.routingDetailTable}
onChange={(v) => updateDataSource("routingDetailTable", v)}
tables={allTables}
loading={tablesLoading}
/>
</div>
<div>
<Label className="text-xs"> FK </Label>
<ColumnSelector
value={config.dataSource.routingDetailFkColumn}
onChange={(v) => updateDataSource("routingDetailFkColumn", v)}
tableName={config.dataSource.routingDetailTable}
label="FK 컬럼"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<TableSelector
value={config.dataSource.processTable}
onChange={(v) => updateDataSource("processTable", v)}
tables={allTables}
loading={tablesLoading}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"> </Label>
<ColumnSelector
value={config.dataSource.processNameColumn}
onChange={(v) => updateDataSource("processNameColumn", v)}
tableName={config.dataSource.processTable}
label="공정명"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<ColumnSelector
value={config.dataSource.processCodeColumn}
onChange={(v) => updateDataSource("processCodeColumn", v)}
tableName={config.dataSource.processTable}
label="공정코드"
/>
</div>
</div>
</section>
{/* 모달 설정 */}
<section className="space-y-3">
<p className="text-xs font-medium text-muted-foreground"> </p>
<div>
<Label className="text-xs"> </Label>
<ScreenSelector
value={config.modals.versionAddScreenId}
onChange={(v) => updateModals("versionAddScreenId", v)}
/>
</div>
<div>
<Label className="text-xs"> </Label>
<ScreenSelector
value={config.modals.processAddScreenId}
onChange={(v) => updateModals("processAddScreenId", v)}
/>
</div>
<div>
<Label className="text-xs"> </Label>
<ScreenSelector
value={config.modals.processEditScreenId}
onChange={(v) => updateModals("processEditScreenId", v)}
/>
</div>
</section>
{/* 공정 테이블 컬럼 설정 */}
<section className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-muted-foreground">
</p>
<Button
variant="outline"
size="sm"
className="h-6 gap-1 text-[10px]"
onClick={addColumn}
>
<Plus className="h-3 w-3" />
</Button>
</div>
<div className="space-y-1.5">
{config.processColumns.map((col, idx) => (
<div
key={idx}
className="flex items-center gap-1.5 rounded border bg-muted/30 p-1.5"
>
<ProcessColumnSelector
value={col.name}
onChange={(v) => updateColumn(idx, "name", v)}
tableName={config.dataSource.routingDetailTable}
processTable={config.dataSource.processTable}
/>
<Input
value={col.label}
onChange={(e) => updateColumn(idx, "label", e.target.value)}
className="h-7 flex-1 text-[10px]"
placeholder="표시명"
/>
<Input
type="number"
value={col.width || ""}
onChange={(e) =>
updateColumn(
idx,
"width",
e.target.value ? Number(e.target.value) : undefined
)
}
className="h-7 w-14 text-[10px]"
placeholder="너비"
/>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-destructive hover:text-destructive"
onClick={() => removeColumn(idx)}
disabled={config.processColumns.length <= 1}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
</section>
{/* UI 설정 */}
<section className="space-y-3">
<p className="text-xs font-medium text-muted-foreground">UI </p>
<div>
<Label className="text-xs"> (%)</Label>
<Input
type="number"
value={config.splitRatio || 40}
onChange={(e) => update({ splitRatio: Number(e.target.value) })}
min={20}
max={60}
className="mt-1 h-8 w-20 text-xs"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
value={config.leftPanelTitle || ""}
onChange={(e) => update({ leftPanelTitle: e.target.value })}
className="mt-1 h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
value={config.rightPanelTitle || ""}
onChange={(e) => update({ rightPanelTitle: e.target.value })}
className="mt-1 h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
value={config.versionAddButtonText || ""}
onChange={(e) => update({ versionAddButtonText: e.target.value })}
className="mt-1 h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
value={config.processAddButtonText || ""}
onChange={(e) => update({ processAddButtonText: e.target.value })}
className="mt-1 h-8 text-xs"
/>
</div>
<div className="flex items-center gap-2">
<Switch
checked={config.autoSelectFirstVersion ?? true}
onCheckedChange={(v) => update({ autoSelectFirstVersion: v })}
/>
<Label className="text-xs"> </Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={config.readonly || false}
onCheckedChange={(v) => update({ readonly: v })}
/>
<Label className="text-xs"> </Label>
</div>
</section>
</div>
);
}

View File

@ -0,0 +1,32 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2ItemRoutingDefinition } from "./index";
import { ItemRoutingComponent } from "./ItemRoutingComponent";
export class ItemRoutingRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = V2ItemRoutingDefinition;
render(): React.ReactElement {
const { formData, isPreview, config, tableName } = this.props as Record<
string,
unknown
>;
return (
<ItemRoutingComponent
config={(config as object) || {}}
formData={formData as Record<string, unknown>}
tableName={tableName as string}
isPreview={isPreview as boolean}
/>
);
}
}
ItemRoutingRenderer.registerSelf();
if (process.env.NODE_ENV === "development") {
ItemRoutingRenderer.enableHotReload();
}

View File

@ -0,0 +1,38 @@
import { ItemRoutingConfig } from "./types";
export const defaultConfig: ItemRoutingConfig = {
dataSource: {
itemTable: "item_info",
itemNameColumn: "item_name",
itemCodeColumn: "item_number",
routingVersionTable: "item_routing_version",
routingVersionFkColumn: "item_code",
routingVersionNameColumn: "version_name",
routingDetailTable: "item_routing_detail",
routingDetailFkColumn: "routing_version_id",
processTable: "process_mng",
processNameColumn: "process_name",
processCodeColumn: "process_code",
},
modals: {
versionAddScreenId: 1613,
processAddScreenId: 1614,
processEditScreenId: 1615,
},
processColumns: [
{ name: "seq_no", label: "순서", width: 60, align: "center" },
{ name: "process_code", label: "공정코드", width: 120 },
{ name: "work_type", label: "작업유형", width: 100 },
{ name: "standard_time", label: "표준시간(분)", width: 100, align: "right" },
{ name: "is_required", label: "필수여부", width: 80, align: "center" },
{ name: "is_fixed_order", label: "순서고정", width: 80, align: "center" },
{ name: "outsource_supplier", label: "외주업체", width: 120 },
],
splitRatio: 40,
leftPanelTitle: "품목 목록",
rightPanelTitle: "공정 순서",
readonly: false,
autoSelectFirstVersion: true,
versionAddButtonText: "+ 라우팅 버전 추가",
processAddButtonText: "+ 공정 추가",
};

View File

@ -0,0 +1,239 @@
"use client";
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { apiClient } from "@/lib/api/client";
import { ItemRoutingConfig, ItemData, RoutingVersionData, RoutingDetailData } from "../types";
import { defaultConfig } from "../config";
const API_BASE = "/process-work-standard";
export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
const configKey = useMemo(
() => JSON.stringify(configPartial),
[configPartial]
);
const config: ItemRoutingConfig = useMemo(() => ({
...defaultConfig,
...configPartial,
dataSource: { ...defaultConfig.dataSource, ...configPartial?.dataSource },
modals: { ...defaultConfig.modals, ...configPartial?.modals },
processColumns: configPartial?.processColumns?.length
? configPartial.processColumns
: defaultConfig.processColumns,
}), [configKey]);
const configRef = useRef(config);
configRef.current = config;
const [items, setItems] = useState<ItemData[]>([]);
const [versions, setVersions] = useState<RoutingVersionData[]>([]);
const [details, setDetails] = useState<RoutingDetailData[]>([]);
const [loading, setLoading] = useState(false);
// 선택 상태
const [selectedItemCode, setSelectedItemCode] = useState<string | null>(null);
const [selectedItemName, setSelectedItemName] = useState<string | null>(null);
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);
// 품목 목록 조회
const fetchItems = useCallback(
async (search?: string) => {
try {
setLoading(true);
const ds = configRef.current.dataSource;
const params = new URLSearchParams({
tableName: ds.itemTable,
nameColumn: ds.itemNameColumn,
codeColumn: ds.itemCodeColumn,
routingTable: ds.routingVersionTable,
routingFkColumn: ds.routingVersionFkColumn,
...(search ? { search } : {}),
});
const res = await apiClient.get(`${API_BASE}/items?${params}`);
if (res.data?.success) {
setItems(res.data.data || []);
}
} catch (err) {
console.error("품목 조회 실패", err);
} finally {
setLoading(false);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[configKey]
);
// 라우팅 버전 목록 조회
const fetchVersions = useCallback(
async (itemCode: string) => {
try {
const ds = configRef.current.dataSource;
const params = new URLSearchParams({
routingVersionTable: ds.routingVersionTable,
routingDetailTable: ds.routingDetailTable,
routingFkColumn: ds.routingVersionFkColumn,
processTable: ds.processTable,
processNameColumn: ds.processNameColumn,
processCodeColumn: ds.processCodeColumn,
});
const res = await apiClient.get(
`${API_BASE}/items/${encodeURIComponent(itemCode)}/routings?${params}`
);
if (res.data?.success) {
const routingData = res.data.data || [];
setVersions(routingData);
return routingData;
}
} catch (err) {
console.error("라우팅 버전 조회 실패", err);
}
return [];
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[configKey]
);
// 공정 상세 목록 조회 (특정 버전의 공정들)
const fetchDetails = useCallback(
async (versionId: string) => {
try {
setLoading(true);
const ds = configRef.current.dataSource;
const res = await apiClient.get("/table-data/entity-join-api/data-with-joins", {
params: {
tableName: ds.routingDetailTable,
searchConditions: JSON.stringify({
[ds.routingDetailFkColumn]: {
value: versionId,
operator: "equals",
},
}),
sortColumn: "seq_no",
sortDirection: "ASC",
},
});
if (res.data?.success) {
setDetails(res.data.data || []);
}
} catch (err) {
console.error("공정 상세 조회 실패", err);
} finally {
setLoading(false);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[configKey]
);
// 품목 선택
const selectItem = useCallback(
async (itemCode: string, itemName: string) => {
setSelectedItemCode(itemCode);
setSelectedItemName(itemName);
setSelectedVersionId(null);
setDetails([]);
const versionList = await fetchVersions(itemCode);
// 첫번째 버전 자동 선택
if (config.autoSelectFirstVersion && versionList.length > 0) {
const firstVersion = versionList[0];
setSelectedVersionId(firstVersion.id);
await fetchDetails(firstVersion.id);
}
},
[fetchVersions, fetchDetails, config.autoSelectFirstVersion]
);
// 버전 선택
const selectVersion = useCallback(
async (versionId: string) => {
setSelectedVersionId(versionId);
await fetchDetails(versionId);
},
[fetchDetails]
);
// 모달에서 데이터 변경 후 새로고침
const refreshVersions = useCallback(async () => {
if (selectedItemCode) {
const versionList = await fetchVersions(selectedItemCode);
if (selectedVersionId) {
await fetchDetails(selectedVersionId);
} else if (versionList.length > 0) {
const lastVersion = versionList[versionList.length - 1];
setSelectedVersionId(lastVersion.id);
await fetchDetails(lastVersion.id);
}
}
}, [selectedItemCode, selectedVersionId, fetchVersions, fetchDetails]);
const refreshDetails = useCallback(async () => {
if (selectedVersionId) {
await fetchDetails(selectedVersionId);
}
}, [selectedVersionId, fetchDetails]);
// 공정 삭제
const deleteDetail = useCallback(
async (detailId: string) => {
try {
const ds = configRef.current.dataSource;
const res = await apiClient.delete(
`/table-data/${ds.routingDetailTable}/${detailId}`
);
if (res.data?.success) {
await refreshDetails();
return true;
}
} catch (err) {
console.error("공정 삭제 실패", err);
}
return false;
},
[refreshDetails]
);
// 버전 삭제
const deleteVersion = useCallback(
async (versionId: string) => {
try {
const ds = configRef.current.dataSource;
const res = await apiClient.delete(
`/table-data/${ds.routingVersionTable}/${versionId}`
);
if (res.data?.success) {
if (selectedVersionId === versionId) {
setSelectedVersionId(null);
setDetails([]);
}
await refreshVersions();
return true;
}
} catch (err) {
console.error("버전 삭제 실패", err);
}
return false;
},
[selectedVersionId, refreshVersions]
);
return {
config,
items,
versions,
details,
loading,
selectedItemCode,
selectedItemName,
selectedVersionId,
fetchItems,
selectItem,
selectVersion,
refreshVersions,
refreshDetails,
deleteDetail,
deleteVersion,
};
}

View File

@ -0,0 +1,57 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { ItemRoutingComponent } from "./ItemRoutingComponent";
import { ItemRoutingConfigPanel } from "./ItemRoutingConfigPanel";
import { defaultConfig } from "./config";
export const V2ItemRoutingDefinition = createComponentDefinition({
id: "v2-item-routing",
name: "품목별 라우팅",
nameEng: "Item Routing",
description: "품목별 라우팅 버전과 공정 순서를 관리하는 3단계 계층 컴포넌트",
category: ComponentCategory.INPUT,
webType: "component",
component: ItemRoutingComponent,
defaultConfig: defaultConfig,
defaultSize: {
width: 1400,
height: 800,
gridColumnSpan: "12",
},
configPanel: ItemRoutingConfigPanel,
icon: "ListOrdered",
tags: ["라우팅", "공정", "품목", "버전", "제조", "생산"],
version: "1.0.0",
author: "개발팀",
documentation: `
.
##
- 좌측: 품목
- 상단: 라우팅 (Badge ) /
- 하단: 선택된 (//)
- (1613, 1614, 1615)
##
- /
- ID
- /
- , ,
-
`,
});
export type {
ItemRoutingConfig,
ItemRoutingComponentProps,
ItemRoutingDataSource,
ItemRoutingModals,
ProcessColumnDef,
} from "./types";
export { ItemRoutingComponent } from "./ItemRoutingComponent";
export { ItemRoutingRenderer } from "./ItemRoutingRenderer";
export { ItemRoutingConfigPanel } from "./ItemRoutingConfigPanel";

View File

@ -0,0 +1,77 @@
/**
*
*
* 3 계층: item_info item_routing_version item_routing_detail
*/
// 데이터 소스 설정
export interface ItemRoutingDataSource {
itemTable: string;
itemNameColumn: string;
itemCodeColumn: string;
routingVersionTable: string;
routingVersionFkColumn: string; // item_routing_version에서 item_code를 가리키는 FK
routingVersionNameColumn: string;
routingDetailTable: string;
routingDetailFkColumn: string; // item_routing_detail에서 routing_version_id를 가리키는 FK
processTable: string;
processNameColumn: string;
processCodeColumn: string;
}
// 모달 연동 설정
export interface ItemRoutingModals {
versionAddScreenId?: number;
processAddScreenId?: number;
processEditScreenId?: number;
}
// 공정 테이블 컬럼 정의
export interface ProcessColumnDef {
name: string;
label: string;
width?: number;
align?: "left" | "center" | "right";
}
// 전체 Config
export interface ItemRoutingConfig {
dataSource: ItemRoutingDataSource;
modals: ItemRoutingModals;
processColumns: ProcessColumnDef[];
splitRatio?: number;
leftPanelTitle?: string;
rightPanelTitle?: string;
readonly?: boolean;
autoSelectFirstVersion?: boolean;
versionAddButtonText?: string;
processAddButtonText?: string;
}
// 컴포넌트 Props
export interface ItemRoutingComponentProps {
config: Partial<ItemRoutingConfig>;
formData?: Record<string, any>;
isPreview?: boolean;
tableName?: string;
}
// 데이터 모델
export interface ItemData {
id: string;
[key: string]: any;
}
export interface RoutingVersionData {
id: string;
version_name: string;
[key: string]: any;
}
export interface RoutingDetailData {
id: string;
routing_version_id: string;
seq_no: string;
process_code: string;
[key: string]: any;
}

View File

@ -0,0 +1,285 @@
"use client";
import React, { useState, useCallback, useEffect, useRef } from "react";
import { ComponentRendererProps } from "@/types/component";
import { SplitLineConfig } from "./types";
import { setCanvasSplit, resetCanvasSplit } from "./canvasSplitStore";
export interface SplitLineComponentProps extends ComponentRendererProps {
config?: SplitLineConfig;
}
export const SplitLineComponent: React.FC<SplitLineComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
onClick,
config,
className,
style,
...props
}) => {
const componentConfig = {
...config,
...component.componentConfig,
} as SplitLineConfig;
const resizable = componentConfig.resizable ?? true;
const lineColor = componentConfig.lineColor || "#e2e8f0";
const lineWidth = componentConfig.lineWidth || 4;
const [dragOffset, setDragOffset] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// CSS transform: scale()이 적용된 캔버스에서 정확한 디자인 해상도
const detectCanvasWidth = useCallback((): number => {
if (containerRef.current) {
const canvas =
containerRef.current.closest("[data-screen-runtime]") ||
containerRef.current.closest("[data-screen-canvas]");
if (canvas) {
const w = parseInt((canvas as HTMLElement).style.width);
if (w > 0) return w;
}
}
const canvas = document.querySelector("[data-screen-runtime]");
if (canvas) {
const w = parseInt((canvas as HTMLElement).style.width);
if (w > 0) return w;
}
return 1200;
}, []);
// CSS scale 보정 계수
const getScaleFactor = useCallback((): number => {
if (containerRef.current) {
const canvas = containerRef.current.closest("[data-screen-runtime]");
if (canvas) {
const el = canvas as HTMLElement;
const designWidth = parseInt(el.style.width) || 1200;
const renderedWidth = el.getBoundingClientRect().width;
if (renderedWidth > 0 && designWidth > 0) {
return designWidth / renderedWidth;
}
}
}
return 1;
}, []);
// 스코프 ID (같은 data-screen-runtime 안의 컴포넌트만 영향)
const scopeIdRef = useRef("");
// 글로벌 스토어에 등록 (런타임 모드)
useEffect(() => {
if (isDesignMode) return;
const timer = setTimeout(() => {
const cw = detectCanvasWidth();
const posX = component.position?.x || 0;
// 스코프 ID: 가장 가까운 data-screen-runtime 요소에 고유 ID 부여
let scopeId = "";
if (containerRef.current) {
const runtimeEl = containerRef.current.closest("[data-screen-runtime]");
if (runtimeEl) {
scopeId = runtimeEl.getAttribute("data-split-scope") || "";
if (!scopeId) {
scopeId = `split-scope-${component.id}`;
runtimeEl.setAttribute("data-split-scope", scopeId);
}
}
}
scopeIdRef.current = scopeId;
console.log("[SplitLine] 등록:", { canvasWidth: cw, positionX: posX, scopeId });
setCanvasSplit({
initialDividerX: posX,
currentDividerX: posX,
canvasWidth: cw,
isDragging: false,
active: true,
scopeId,
});
}, 100);
return () => {
clearTimeout(timer);
resetCanvasSplit();
};
}, [isDesignMode, component.id, component.position?.x, detectCanvasWidth]);
// 드래그 중 최종 오프셋 (DOM 직접 조작용)
const latestOffsetRef = useRef(dragOffset);
latestOffsetRef.current = dragOffset;
const rafIdRef = useRef(0);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (!resizable || isDesignMode) return;
e.preventDefault();
e.stopPropagation();
const posX = component.position?.x || 0;
const startX = e.clientX;
const startOffset = latestOffsetRef.current;
const scaleFactor = getScaleFactor();
const cw = detectCanvasWidth();
const MIN_POS = Math.max(50, cw * 0.15);
const MAX_POS = cw - Math.max(50, cw * 0.15);
setIsDragging(true);
setCanvasSplit({ isDragging: true });
const handleMouseMove = (moveEvent: MouseEvent) => {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = requestAnimationFrame(() => {
const rawDelta = moveEvent.clientX - startX;
const delta = rawDelta * scaleFactor;
let newOffset = startOffset + delta;
const newDividerX = posX + newOffset;
if (newDividerX < MIN_POS) newOffset = MIN_POS - posX;
if (newDividerX > MAX_POS) newOffset = MAX_POS - posX;
latestOffsetRef.current = newOffset;
// 스플릿선 자체는 DOM 직접 조작 (React 리렌더 없음)
if (containerRef.current) {
containerRef.current.style.transform = `translateX(${newOffset}px)`;
}
// 스토어 업데이트 → DOM 리스너만 호출 (React 리렌더 없음)
setCanvasSplit({ currentDividerX: posX + newOffset });
});
};
const handleMouseUp = () => {
cancelAnimationFrame(rafIdRef.current);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.userSelect = "";
document.body.style.cursor = "";
// 최종 오프셋을 React 상태에 동기화 (1회만 리렌더)
setDragOffset(latestOffsetRef.current);
setIsDragging(false);
setCanvasSplit({ isDragging: false });
};
document.body.style.userSelect = "none";
document.body.style.cursor = "col-resize";
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
},
[resizable, isDesignMode, component.position?.x, getScaleFactor, detectCanvasWidth],
);
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
};
// props 필터링
const {
selectedScreen: _1, onZoneComponentDrop: _2, onZoneClick: _3,
componentConfig: _4, component: _5, isSelected: _6,
onClick: _7, onDragStart: _8, onDragEnd: _9,
size: _10, position: _11, style: _12,
screenId: _13, tableName: _14, onRefresh: _15, onClose: _16,
webType: _17, autoGeneration: _18, isInteractive: _19,
formData: _20, onFormDataChange: _21,
menuId: _22, menuObjid: _23, onSave: _24,
userId: _25, userName: _26, companyCode: _27,
isInModal: _28, readonly: _29, originalData: _30,
_originalData: _31, _initialData: _32, _groupedData: _33,
allComponents: _34, onUpdateLayout: _35,
selectedRows: _36, selectedRowsData: _37, onSelectedRowsChange: _38,
sortBy: _39, sortOrder: _40, tableDisplayData: _41,
flowSelectedData: _42, flowSelectedStepId: _43, onFlowSelectedDataChange: _44,
onConfigChange: _45, refreshKey: _46, flowRefreshKey: _47, onFlowRefresh: _48,
isPreview: _49, groupedData: _50,
...domProps
} = props as any;
if (isDesignMode) {
return (
<div
ref={containerRef}
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
position: "relative",
...style,
}}
className={className}
onClick={handleClick}
{...domProps}
>
<div
style={{
width: `${lineWidth}px`,
height: "100%",
borderLeft: `${lineWidth}px dashed ${isSelected ? "#3b82f6" : lineColor}`,
}}
/>
<div
style={{
position: "absolute",
top: "4px",
left: "50%",
transform: "translateX(-50%)",
fontSize: "10px",
color: isSelected ? "#3b82f6" : "#9ca3af",
whiteSpace: "nowrap",
backgroundColor: "rgba(255,255,255,0.9)",
padding: "1px 6px",
borderRadius: "4px",
fontWeight: 500,
}}
>
릿
</div>
</div>
);
}
return (
<div
ref={containerRef}
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: resizable ? "col-resize" : "default",
transform: `translateX(${dragOffset}px)`,
transition: isDragging ? "none" : "transform 0.1s ease-out",
zIndex: 50,
...style,
}}
className={className}
onMouseDown={handleMouseDown}
{...domProps}
>
<div
style={{
width: `${lineWidth}px`,
height: "100%",
backgroundColor: isDragging ? "hsl(var(--primary))" : lineColor,
transition: isDragging ? "none" : "background-color 0.15s ease",
}}
className="hover:bg-primary"
/>
</div>
);
};
export const SplitLineWrapper: React.FC<SplitLineComponentProps> = (props) => {
return <SplitLineComponent {...props} />;
};

View File

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

View File

@ -0,0 +1,30 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2SplitLineDefinition } from "./index";
import { SplitLineComponent } from "./SplitLineComponent";
/**
* SplitLine
*
*/
export class SplitLineRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = V2SplitLineDefinition;
render(): React.ReactElement {
return <SplitLineComponent {...this.props} renderer={this} />;
}
protected handleValueChange = (value: any) => {
this.updateComponent({ value });
};
}
// 자동 등록 실행
SplitLineRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
SplitLineRenderer.enableHotReload();
}

View File

@ -0,0 +1,94 @@
/**
*
*
* 최적화: 이중
* - React (subscribe): (active, isDragging /)
* - DOM (subscribeDom): (React , DOM )
*/
export interface CanvasSplitState {
initialDividerX: number;
currentDividerX: number;
canvasWidth: number;
isDragging: boolean;
active: boolean;
scopeId: string;
}
const initialState: CanvasSplitState = {
initialDividerX: 0,
currentDividerX: 0,
canvasWidth: 0,
isDragging: false,
active: false,
scopeId: "",
};
let state: CanvasSplitState = { ...initialState };
// React 리렌더링을 트리거하는 리스너 (구조적 변경 전용)
const reactListeners = new Set<() => void>();
// DOM 직접 조작용 리스너 (드래그 중 위치 업데이트, React 우회)
const domListeners = new Set<(state: CanvasSplitState) => void>();
// React용 스냅샷 (드래그 중 위치 변경에는 갱신 안 함)
let reactSnapshot: CanvasSplitState = { ...initialState };
export function setCanvasSplit(updates: Partial<CanvasSplitState>): void {
state = { ...state, ...updates };
// 드래그 중 위치만 변경 → DOM 리스너만 호출 (React 리렌더 없음)
const isPositionOnlyDuringDrag =
state.isDragging &&
Object.keys(updates).length === 1 &&
"currentDividerX" in updates;
if (isPositionOnlyDuringDrag) {
domListeners.forEach((fn) => fn(state));
return;
}
// 구조적 변경 → React 리스너 + DOM 리스너 모두 호출
reactSnapshot = { ...state };
reactListeners.forEach((fn) => fn());
domListeners.forEach((fn) => fn(state));
}
export function resetCanvasSplit(): void {
state = { ...initialState };
reactSnapshot = { ...initialState };
reactListeners.forEach((fn) => fn());
domListeners.forEach((fn) => fn(state));
}
// React용: useSyncExternalStore에 연결
export function subscribe(callback: () => void): () => void {
reactListeners.add(callback);
return () => {
reactListeners.delete(callback);
};
}
export function getSnapshot(): CanvasSplitState {
return reactSnapshot;
}
export function getServerSnapshot(): CanvasSplitState {
return { ...initialState };
}
// DOM 직접 조작용: 드래그 중 매 프레임 위치 업데이트 수신
export function subscribeDom(
callback: (state: CanvasSplitState) => void,
): () => void {
domListeners.add(callback);
return () => {
domListeners.delete(callback);
};
}
// 현재 상태 직접 참조 (DOM 리스너 콜백 외부에서 최신 상태 필요 시)
export function getCurrentState(): CanvasSplitState {
return state;
}

View File

@ -0,0 +1,41 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { SplitLineWrapper } from "./SplitLineComponent";
import { SplitLineConfigPanel } from "./SplitLineConfigPanel";
import { SplitLineConfig } from "./types";
/**
* SplitLine
*
*/
export const V2SplitLineDefinition = createComponentDefinition({
id: "v2-split-line",
name: "스플릿선",
nameEng: "SplitLine Component",
description: "캔버스를 좌우로 분할하는 드래그 가능한 분할선",
category: ComponentCategory.LAYOUT,
webType: "text",
component: SplitLineWrapper,
defaultConfig: {
resizable: true,
lineColor: "#e2e8f0",
lineWidth: 4,
} as SplitLineConfig,
defaultSize: { width: 8, height: 600 },
configPanel: SplitLineConfigPanel,
icon: "SeparatorVertical",
tags: ["스플릿", "분할", "분할선", "레이아웃"],
version: "1.0.0",
author: "개발팀",
documentation: "",
});
// 타입 내보내기
export type { SplitLineConfig } from "./types";
// 컴포넌트 내보내기
export { SplitLineComponent } from "./SplitLineComponent";
export { SplitLineRenderer } from "./SplitLineRenderer";

View File

@ -0,0 +1,28 @@
"use client";
import { ComponentConfig } from "@/types/component";
/**
* SplitLine
*
*
* X ( splitRatio )
*/
export interface SplitLineConfig extends ComponentConfig {
// 드래그 리사이즈 허용 여부
resizable?: boolean;
// 분할선 스타일
lineColor?: string;
lineWidth?: number;
}
/**
* SplitLine Props
*/
export interface SplitLineProps {
id?: string;
config?: SplitLineConfig;
className?: string;
style?: React.CSSProperties;
}

View File

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

View File

@ -1241,7 +1241,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const searchConditions: Record<string, any> = {}; const searchConditions: Record<string, any> = {};
keys?.forEach((key: any) => { keys?.forEach((key: any) => {
if (key.leftColumn && key.rightColumn && originalItem[key.leftColumn] !== undefined) { if (key.leftColumn && key.rightColumn && originalItem[key.leftColumn] !== undefined) {
searchConditions[key.rightColumn] = originalItem[key.leftColumn]; searchConditions[key.rightColumn] = { value: originalItem[key.leftColumn], operator: "equals" };
} }
}); });
@ -1271,11 +1271,11 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 복합키: 여러 조건으로 필터링 // 복합키: 여러 조건으로 필터링
const { entityJoinApi } = await import("@/lib/api/entityJoin"); const { entityJoinApi } = await import("@/lib/api/entityJoin");
// 복합키 조건 생성 // 복합키 조건 생성 (FK 필터링이므로 equals 연산자 사용)
const searchConditions: Record<string, any> = {}; const searchConditions: Record<string, any> = {};
keys.forEach((key) => { keys.forEach((key) => {
if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) { if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
searchConditions[key.rightColumn] = leftItem[key.leftColumn]; searchConditions[key.rightColumn] = { value: leftItem[key.leftColumn], operator: "equals" };
} }
}); });
@ -2035,20 +2035,47 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.addButton; : (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.addButton;
if (addButtonConfig?.mode === "modal" && addButtonConfig?.modalScreenId) { if (addButtonConfig?.mode === "modal" && addButtonConfig?.modalScreenId) {
// 커스텀 모달 화면 열기 if (!selectedLeftItem) {
toast({
title: "항목을 선택해주세요",
description: "좌측 패널에서 항목을 먼저 선택한 후 추가해주세요.",
variant: "destructive",
});
return;
}
const currentTableName = const currentTableName =
activeTabIndex === 0 activeTabIndex === 0
? componentConfig.rightPanel?.tableName || "" ? componentConfig.rightPanel?.tableName || ""
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.tableName || ""; : (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.tableName || "";
// 좌측 선택 데이터를 modalDataStore에 저장 (추가 화면에서 참조 가능) // 좌측 선택 데이터를 modalDataStore에 저장
if (selectedLeftItem && componentConfig.leftPanel?.tableName) { if (selectedLeftItem && componentConfig.leftPanel?.tableName) {
import("@/stores/modalDataStore").then(({ useModalDataStore }) => { import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
useModalDataStore.getState().setData(componentConfig.leftPanel!.tableName!, [selectedLeftItem]); useModalDataStore.getState().setData(componentConfig.leftPanel!.tableName!, [selectedLeftItem]);
}); });
} }
// ScreenModal 열기 이벤트 발생 // relation.keys에서 FK 데이터 추출
const parentData: Record<string, any> = {};
const relation = activeTabIndex === 0
? componentConfig.rightPanel?.relation
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.relation;
if (relation?.keys && Array.isArray(relation.keys)) {
for (const key of relation.keys) {
if (key.leftColumn && key.rightColumn && selectedLeftItem[key.leftColumn] != null) {
parentData[key.rightColumn] = selectedLeftItem[key.leftColumn];
}
}
} else if (relation) {
const leftColumn = relation.leftColumn;
const rightColumn = relation.foreignKey || relation.rightColumn;
if (leftColumn && rightColumn && selectedLeftItem[leftColumn] != null) {
parentData[rightColumn] = selectedLeftItem[leftColumn];
}
}
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("openScreenModal", { new CustomEvent("openScreenModal", {
detail: { detail: {
@ -2056,19 +2083,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
urlParams: { urlParams: {
mode: "add", mode: "add",
tableName: currentTableName, tableName: currentTableName,
// 좌측 선택 항목의 연결 키 값 전달
...(selectedLeftItem && (() => {
const relation = activeTabIndex === 0
? componentConfig.rightPanel?.relation
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.relation;
const leftColumn = relation?.keys?.[0]?.leftColumn || relation?.leftColumn;
const rightColumn = relation?.keys?.[0]?.rightColumn || relation?.foreignKey;
if (leftColumn && rightColumn && selectedLeftItem[leftColumn] !== undefined) {
return { [rightColumn]: selectedLeftItem[leftColumn] };
}
return {};
})()),
}, },
splitPanelParentData: parentData,
}, },
}), }),
); );
@ -2076,6 +2092,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
console.log("✅ [SplitPanel] 추가 모달 화면 열기:", { console.log("✅ [SplitPanel] 추가 모달 화면 열기:", {
screenId: addButtonConfig.modalScreenId, screenId: addButtonConfig.modalScreenId,
tableName: currentTableName, tableName: currentTableName,
parentData,
}); });
return; return;
} }

View File

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

View File

@ -57,6 +57,7 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
"split-panel-layout2": () => import("@/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel"), "split-panel-layout2": () => import("@/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel"),
"screen-split-panel": () => import("@/lib/registry/components/screen-split-panel/ScreenSplitPanelConfigPanel"), "screen-split-panel": () => import("@/lib/registry/components/screen-split-panel/ScreenSplitPanelConfigPanel"),
"conditional-container": () => import("@/lib/registry/components/conditional-container/ConditionalContainerConfigPanel"), "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"), "table-list": () => import("@/lib/registry/components/table-list/TableListConfigPanel"),

View File

@ -4,36 +4,41 @@ import type { NextRequest } from "next/server";
/** /**
* Next.js * Next.js
* *
*
* 주의: 미들웨어는 (localStorage )
* localStorage에만
* AuthGuard에서
*/ */
export function middleware(request: NextRequest) { export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
// 인증 토큰 확인 const token = request.cookies.get("authToken")?.value;
const token = request.cookies.get("authToken")?.value || request.headers.get("authorization")?.replace("Bearer ", "");
// /login 페이지 접근 시 // /login 페이지 접근 시 - 토큰이 있으면 메인으로
if (pathname === "/login") { if (pathname === "/login") {
// 토큰이 있으면 메인 페이지로 리다이렉트
if (token) { if (token) {
const url = request.nextUrl.clone(); const url = request.nextUrl.clone();
url.pathname = "/main"; url.pathname = "/main";
return NextResponse.redirect(url); return NextResponse.redirect(url);
} }
// 토큰이 없으면 로그인 페이지 표시
return NextResponse.next(); return NextResponse.next();
} }
// 인증이 필요한 페이지들 // 보호 경로 목록 - 쿠키에 토큰이 없으면 로그인으로
const protectedPaths = ["/main", "/admin", "/dashboard", "/settings"]; // 단, 쿠키 없이 localStorage에만 토큰이 있을 수 있으므로
const isProtectedPath = protectedPaths.some((path) => pathname.startsWith(path)); // 클라이언트에서 한 번 더 확인 후 리다이렉트 (AuthGuard)
const strictProtectedPaths = ["/admin"];
if (isProtectedPath && !token) { const isStrictProtected = strictProtectedPaths.some((path) => pathname.startsWith(path));
// 인증되지 않은 사용자는 로그인 페이지로 리다이렉트
if (isStrictProtected && !token) {
const url = request.nextUrl.clone(); const url = request.nextUrl.clone();
url.pathname = "/login"; url.pathname = "/login";
return NextResponse.redirect(url); return NextResponse.redirect(url);
} }
// /main, /screens, /dashboard 등은 쿠키 없어도 통과 허용
// (localStorage 토큰이 있을 수 있으므로 클라이언트 AuthGuard에 위임)
return NextResponse.next(); return NextResponse.next();
} }
@ -42,14 +47,6 @@ export function middleware(request: NextRequest) {
*/ */
export const config = { export const config = {
matcher: [ 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$).*)", "/((?!api|_next/static|_next/image|favicon.ico|.*\\.png$|.*\\.jpg$|.*\\.jpeg$|.*\\.svg$).*)",
], ],
}; };