From 2b175a21f4ff73332ffe724741b9b8a2d122bf90 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 25 Feb 2026 11:45:28 +0900 Subject: [PATCH] feat: Enhance entity options retrieval with additional fields support - Updated the `getEntityOptions` function to accept an optional `fields` parameter, allowing clients to specify additional columns to be retrieved. - Implemented logic to dynamically include extra columns in the SQL query based on the provided `fields`, improving flexibility in data retrieval. - Enhanced the response to indicate whether extra fields were included, facilitating better client-side handling of the data. - Added logging for authentication failures in the `AuthGuard` component to improve debugging and user experience. - Integrated auto-fill functionality in the `V2Select` component to automatically populate fields based on selected entity references, enhancing user interaction. - Updated the `ItemSearchModal` to support multi-selection of items, improving usability in item management scenarios. --- .../src/controllers/entitySearchController.ts | 17 +- frontend/components/auth/AuthGuard.tsx | 3 + frontend/components/screen/ScreenDesigner.tsx | 22 +- frontend/components/v2/V2Select.tsx | 93 +++++- .../v2/config-panels/V2SelectConfigPanel.tsx | 9 + frontend/hooks/useAuth.ts | 21 +- frontend/hooks/useMenu.ts | 5 +- frontend/lib/api/client.ts | 43 ++- frontend/lib/authLogger.ts | 225 +++++++++++++ .../lib/registry/DynamicComponentRenderer.tsx | 106 +++++- .../BomItemEditorComponent.tsx | 310 ++++++++++++++---- .../v2-bom-tree/BomTreeComponent.tsx | 35 +- .../components/v2-select/V2SelectRenderer.tsx | 9 +- frontend/lib/utils/buttonActions.ts | 57 +++- frontend/types/v2-components.ts | 2 + 15 files changed, 828 insertions(+), 129 deletions(-) create mode 100644 frontend/lib/authLogger.ts diff --git a/backend-node/src/controllers/entitySearchController.ts b/backend-node/src/controllers/entitySearchController.ts index bbc42568..3ece2ce7 100644 --- a/backend-node/src/controllers/entitySearchController.ts +++ b/backend-node/src/controllers/entitySearchController.ts @@ -115,7 +115,7 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re export async function getEntityOptions(req: AuthenticatedRequest, res: Response) { try { const { tableName } = req.params; - const { value = "id", label = "name" } = req.query; + const { value = "id", label = "name", fields } = req.query; // tableName 유효성 검증 if (!tableName || tableName === "undefined" || tableName === "null") { @@ -167,9 +167,21 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response) ? `WHERE ${whereConditions.join(" AND ")}` : ""; + // autoFill용 추가 컬럼 처리 + let extraColumns = ""; + if (fields && typeof fields === "string") { + const requestedFields = fields.split(",").map((f) => f.trim()).filter(Boolean); + const validExtra = requestedFields.filter( + (f) => existingColumns.has(f) && f !== valueColumn && f !== effectiveLabelColumn + ); + if (validExtra.length > 0) { + extraColumns = ", " + validExtra.map((f) => `"${f}"`).join(", "); + } + } + // 쿼리 실행 (최대 500개) const query = ` - SELECT ${valueColumn} as value, ${effectiveLabelColumn} as label + SELECT ${valueColumn} as value, ${effectiveLabelColumn} as label${extraColumns} FROM ${tableName} ${whereClause} ORDER BY ${effectiveLabelColumn} ASC @@ -184,6 +196,7 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response) labelColumn: effectiveLabelColumn, companyCode, rowCount: result.rowCount, + extraFields: extraColumns ? true : false, }); res.json({ diff --git a/frontend/components/auth/AuthGuard.tsx b/frontend/components/auth/AuthGuard.tsx index efb8cd25..3b3eb182 100644 --- a/frontend/components/auth/AuthGuard.tsx +++ b/frontend/components/auth/AuthGuard.tsx @@ -3,6 +3,7 @@ import { useEffect, ReactNode } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/hooks/useAuth"; +import { AuthLogger } from "@/lib/authLogger"; import { Loader2 } from "lucide-react"; interface AuthGuardProps { @@ -41,11 +42,13 @@ export function AuthGuard({ } if (requireAuth && !isLoggedIn) { + AuthLogger.log("AUTH_GUARD_BLOCK", `인증 필요하지만 비로그인 상태 → ${redirectTo} 리다이렉트`); router.push(redirectTo); return; } if (requireAdmin && !isAdmin) { + AuthLogger.log("AUTH_GUARD_BLOCK", `관리자 권한 필요하지만 일반 사용자 → ${redirectTo} 리다이렉트`); router.push(redirectTo); return; } diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 8dfe9ae4..fcb5b100 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -3968,10 +3968,10 @@ export default function ScreenDesigner({ label: column.columnLabel || column.columnName, tableName: table.tableName, columnName: column.columnName, - required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님 - readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용 - parentId: formContainerId, // 폼 컨테이너의 자식으로 설정 - componentType: v2Mapping.componentType, // v2-input, v2-select 등 + required: isEntityJoinColumn ? false : column.required, + readonly: false, + parentId: formContainerId, + componentType: v2Mapping.componentType, position: { x: relativeX, y: relativeY, z: 1 } as Position, size: { width: componentWidth, height: getDefaultHeight(column.widgetType) }, layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) @@ -3995,12 +3995,11 @@ export default function ScreenDesigner({ }, componentConfig: { type: v2Mapping.componentType, // v2-input, v2-select 등 - ...v2Mapping.componentConfig, // V2 컴포넌트 기본 설정 - ...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화 + ...v2Mapping.componentConfig, }, }; } else { - return; // 폼 컨테이너를 찾을 수 없으면 드롭 취소 + return; } } else { // 일반 캔버스에 드롭한 경우 - 🆕 V2 컴포넌트 시스템 사용 @@ -4036,9 +4035,9 @@ export default function ScreenDesigner({ label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명 tableName: table.tableName, columnName: column.columnName, - required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님 - readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용 - componentType: v2Mapping.componentType, // v2-input, v2-select 등 + required: isEntityJoinColumn ? false : column.required, + readonly: false, + componentType: v2Mapping.componentType, position: { x, y, z: 1 } as Position, size: { width: componentWidth, height: getDefaultHeight(column.widgetType) }, layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) @@ -4062,8 +4061,7 @@ export default function ScreenDesigner({ }, componentConfig: { type: v2Mapping.componentType, // v2-input, v2-select 등 - ...v2Mapping.componentConfig, // V2 컴포넌트 기본 설정 - ...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화 + ...v2Mapping.componentConfig, }, }; } diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index 4fd27cb0..fe21b790 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -622,6 +622,7 @@ export const V2Select = forwardRef( config: configProp, value, onChange, + onFormDataChange, tableName, columnName, isDesignMode, // 🔧 디자인 모드 (클릭 방지) @@ -630,6 +631,9 @@ export const V2Select = forwardRef( // config가 없으면 기본값 사용 const config = configProp || { mode: "dropdown" as const, source: "static" as const, options: [] }; + // 엔티티 자동 채움: 같은 폼의 다른 컴포넌트 중 참조 테이블 컬럼을 자동 감지 + const allComponents = (props as any).allComponents as any[] | undefined; + const [options, setOptions] = useState(config.options || []); const [loading, setLoading] = useState(false); const [optionsLoaded, setOptionsLoaded] = useState(false); @@ -742,10 +746,7 @@ export const V2Select = forwardRef( const valueCol = entityValueColumn || "id"; const labelCol = entityLabelColumn || "name"; const response = await apiClient.get(`/entity/${entityTable}/options`, { - params: { - value: valueCol, - label: labelCol, - }, + params: { value: valueCol, label: labelCol }, }); const data = response.data; if (data.success && data.data) { @@ -819,6 +820,70 @@ export const V2Select = forwardRef( loadOptions(); }, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue]); + // 같은 폼에서 참조 테이블(entityTable) 컬럼을 사용하는 다른 컴포넌트 자동 감지 + const autoFillTargets = useMemo(() => { + if (source !== "entity" || !entityTable || !allComponents) return []; + + const targets: Array<{ sourceField: string; targetColumnName: string }> = []; + for (const comp of allComponents) { + if (comp.id === id) continue; + + // overrides 구조 지원 (DB에서 로드 시 overrides 안에 데이터가 있음) + const ov = (comp as any).overrides || {}; + const compColumnName = comp.columnName || ov.columnName || comp.componentConfig?.columnName || ""; + + // 방법1: entityJoinTable 속성이 있는 경우 + const joinTable = comp.entityJoinTable || ov.entityJoinTable || comp.componentConfig?.entityJoinTable; + const joinColumn = comp.entityJoinColumn || ov.entityJoinColumn || comp.componentConfig?.entityJoinColumn; + if (joinTable === entityTable && joinColumn) { + targets.push({ sourceField: joinColumn, targetColumnName: compColumnName }); + continue; + } + + // 방법2: columnName이 "테이블명.컬럼명" 형식인 경우 (예: item_info.unit) + if (compColumnName.includes(".")) { + const [prefix, actualColumn] = compColumnName.split("."); + if (prefix === entityTable && actualColumn) { + targets.push({ sourceField: actualColumn, targetColumnName: compColumnName }); + } + } + } + return targets; + }, [source, entityTable, allComponents, id]); + + // 엔티티 autoFill 적용 래퍼 + const handleChangeWithAutoFill = useCallback((newValue: string | string[]) => { + onChange?.(newValue); + + if (autoFillTargets.length === 0 || !onFormDataChange || !entityTable) return; + + const selectedKey = typeof newValue === "string" ? newValue : newValue[0]; + if (!selectedKey) return; + + const valueCol = entityValueColumn || "id"; + + apiClient.get(`/table-management/tables/${entityTable}/data-with-joins`, { + params: { + page: 1, + size: 1, + search: JSON.stringify({ [valueCol]: selectedKey }), + autoFilter: JSON.stringify({ enabled: true, filterColumn: "company_code", userField: "companyCode" }), + }, + }).then((res) => { + const responseData = res.data?.data; + const rows = responseData?.data || responseData?.rows || []; + if (rows.length > 0) { + const fullData = rows[0]; + for (const target of autoFillTargets) { + const sourceValue = fullData[target.sourceField]; + if (sourceValue !== undefined) { + onFormDataChange(target.targetColumnName, sourceValue); + } + } + } + }).catch((err) => console.error("autoFill 조회 실패:", err)); + }, [onChange, autoFillTargets, onFormDataChange, entityTable, entityValueColumn]); + // 모드별 컴포넌트 렌더링 const renderSelect = () => { if (loading) { @@ -876,12 +941,12 @@ export const V2Select = forwardRef( switch (config.mode) { case "dropdown": - case "combobox": // 🔧 콤보박스는 검색 가능한 드롭다운 + case "combobox": return ( ( onChange?.(v)} + onChange={(v) => handleChangeWithAutoFill(v)} disabled={isDisabled} /> ); case "check": - case "checkbox": // 🔧 기존 저장된 값 호환 + case "checkbox": return ( @@ -919,7 +984,7 @@ export const V2Select = forwardRef( @@ -930,7 +995,7 @@ export const V2Select = forwardRef( ( onChange?.(v)} + onChange={(v) => handleChangeWithAutoFill(v)} disabled={isDisabled} /> ); @@ -953,7 +1018,7 @@ export const V2Select = forwardRef( @@ -964,7 +1029,7 @@ export const V2Select = forwardRef( diff --git a/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx b/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx index ce3b3dbd..f808ecf1 100644 --- a/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx @@ -302,6 +302,15 @@ export const V2SelectConfigPanel: React.FC = ({ config 테이블 컬럼을 조회할 수 없습니다. 테이블 타입 관리에서 참조 테이블을 설정해주세요.

)} + + {/* 자동 채움 안내 */} + {config.entityTable && entityColumns.length > 0 && ( +
+

+ 같은 폼에 참조 테이블({config.entityTable})의 컬럼이 배치되어 있으면, 엔티티 선택 시 해당 필드가 자동으로 채워집니다. +

+
+ )} )} diff --git a/frontend/hooks/useAuth.ts b/frontend/hooks/useAuth.ts index 737710d3..d03aab29 100644 --- a/frontend/hooks/useAuth.ts +++ b/frontend/hooks/useAuth.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useRouter } from "next/navigation"; import { apiCall } from "@/lib/api/client"; +import { AuthLogger } from "@/lib/authLogger"; interface UserInfo { userId: string; @@ -161,13 +162,15 @@ export const useAuth = () => { const token = TokenManager.getToken(); if (!token || TokenManager.isTokenExpired(token)) { + AuthLogger.log("AUTH_CHECK_FAIL", `refreshUserData: 토큰 ${!token ? "없음" : "만료됨"}`); setUser(null); setAuthStatus({ isLoggedIn: false, isAdmin: false }); setLoading(false); return; } - // 토큰이 유효하면 우선 인증된 상태로 설정 + AuthLogger.log("AUTH_CHECK_START", "refreshUserData: API로 인증 상태 확인 시작"); + setAuthStatus({ isLoggedIn: true, isAdmin: false, @@ -186,15 +189,16 @@ export const useAuth = () => { }; setAuthStatus(finalAuthStatus); + AuthLogger.log("AUTH_CHECK_SUCCESS", `사용자: ${userInfo.userId}, 인증: ${finalAuthStatus.isLoggedIn}`); - // API 결과가 비인증이면 상태만 업데이트 (리다이렉트는 client.ts가 처리) if (!finalAuthStatus.isLoggedIn) { + AuthLogger.log("AUTH_CHECK_FAIL", "API 응답에서 비인증 상태 반환 → 토큰 제거"); TokenManager.removeToken(); setUser(null); setAuthStatus({ isLoggedIn: false, isAdmin: false }); } } else { - // userInfo 조회 실패 → 토큰에서 최소 정보 추출하여 유지 + AuthLogger.log("AUTH_CHECK_FAIL", "userInfo 조회 실패 → 토큰 기반 임시 인증 유지 시도"); try { const payload = JSON.parse(atob(token.split(".")[1])); const tempUser: UserInfo = { @@ -210,14 +214,14 @@ export const useAuth = () => { isAdmin: tempUser.isAdmin, }); } catch { - // 토큰 파싱도 실패하면 비인증 상태로 전환 + AuthLogger.log("AUTH_CHECK_FAIL", "토큰 파싱 실패 → 비인증 전환"); TokenManager.removeToken(); setUser(null); setAuthStatus({ isLoggedIn: false, isAdmin: false }); } } } catch { - // API 호출 전체 실패 → 토큰 기반 임시 인증 유지 시도 + AuthLogger.log("AUTH_CHECK_FAIL", "API 호출 전체 실패 → 토큰 기반 임시 인증 유지 시도"); try { const payload = JSON.parse(atob(token.split(".")[1])); const tempUser: UserInfo = { @@ -233,6 +237,7 @@ export const useAuth = () => { isAdmin: tempUser.isAdmin, }); } catch { + AuthLogger.log("AUTH_CHECK_FAIL", "최종 fallback 실패 → 비인증 전환"); TokenManager.removeToken(); setUser(null); setAuthStatus({ isLoggedIn: false, isAdmin: false }); @@ -408,19 +413,19 @@ export const useAuth = () => { const token = TokenManager.getToken(); if (token && !TokenManager.isTokenExpired(token)) { - // 유효한 토큰 → 우선 인증 상태로 설정 후 API 확인 + AuthLogger.log("AUTH_CHECK_START", `초기 인증 확인: 유효한 토큰 존재 (경로: ${window.location.pathname})`); setAuthStatus({ isLoggedIn: true, isAdmin: false, }); refreshUserData(); } else if (token && TokenManager.isTokenExpired(token)) { - // 만료된 토큰 → 정리 (리다이렉트는 AuthGuard에서 처리) + AuthLogger.log("TOKEN_EXPIRED_DETECTED", `초기 확인 시 만료된 토큰 발견 → 정리 (경로: ${window.location.pathname})`); TokenManager.removeToken(); setAuthStatus({ isLoggedIn: false, isAdmin: false }); setLoading(false); } else { - // 토큰 없음 → 비인증 상태 (리다이렉트는 AuthGuard에서 처리) + AuthLogger.log("AUTH_CHECK_FAIL", `초기 확인: 토큰 없음 (경로: ${window.location.pathname})`); setAuthStatus({ isLoggedIn: false, isAdmin: false }); setLoading(false); } diff --git a/frontend/hooks/useMenu.ts b/frontend/hooks/useMenu.ts index 32fb3d4e..59ddab02 100644 --- a/frontend/hooks/useMenu.ts +++ b/frontend/hooks/useMenu.ts @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react"; import { useRouter } from "next/navigation"; import { MenuItem, MenuState } from "@/types/menu"; import { apiClient } from "@/lib/api/client"; +import { AuthLogger } from "@/lib/authLogger"; /** * 메뉴 관련 비즈니스 로직을 관리하는 커스텀 훅 @@ -84,8 +85,8 @@ export const useMenu = (user: any, authLoading: boolean) => { } else { setMenuState((prev: MenuState) => ({ ...prev, isLoading: false })); } - } catch { - // API 실패 시 빈 메뉴로 유지 (401은 client.ts 인터셉터가 리다이렉트 처리) + } catch (err: any) { + AuthLogger.log("MENU_LOAD_FAIL", `메뉴 로드 실패: ${err?.response?.status || err?.message || "unknown"}`); setMenuState((prev: MenuState) => ({ ...prev, isLoading: false })); } }, [convertToUpperCaseKeys, buildMenuTree]); diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index 7abe856c..2338ad63 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -1,4 +1,14 @@ import axios, { AxiosResponse, AxiosError, InternalAxiosRequestConfig } from "axios"; +import { AuthLogger } from "@/lib/authLogger"; + +const authLog = (event: string, detail: string) => { + if (typeof window === "undefined") return; + try { + AuthLogger.log(event as any, detail); + } catch { + // 로거 실패해도 앱 동작에 영향 없음 + } +}; // API URL 동적 설정 - 환경변수 우선 사용 const getApiBaseUrl = (): string => { @@ -149,9 +159,12 @@ const refreshToken = async (): Promise => { try { const currentToken = TokenManager.getToken(); if (!currentToken) { + authLog("TOKEN_REFRESH_FAIL", "갱신 시도했으나 토큰 자체가 없음"); return null; } + authLog("TOKEN_REFRESH_START", `남은시간: ${Math.round(TokenManager.getTimeUntilExpiry(currentToken) / 60000)}분`); + const response = await axios.post( `${API_BASE_URL}/auth/refresh`, {}, @@ -165,10 +178,13 @@ const refreshToken = async (): Promise => { if (response.data?.success && response.data?.data?.token) { const newToken = response.data.data.token; TokenManager.setToken(newToken); + authLog("TOKEN_REFRESH_SUCCESS", "토큰 갱신 완료"); return newToken; } + authLog("TOKEN_REFRESH_FAIL", `API 응답 실패: success=${response.data?.success}`); return null; - } catch { + } catch (err: any) { + authLog("TOKEN_REFRESH_FAIL", `API 호출 에러: ${err?.response?.status || err?.message || "unknown"}`); return null; } }; @@ -210,16 +226,21 @@ const setupVisibilityRefresh = (): void => { document.addEventListener("visibilitychange", () => { if (!document.hidden) { const token = TokenManager.getToken(); - if (!token) return; + if (!token) { + authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 없음"); + return; + } if (TokenManager.isTokenExpired(token)) { - // 만료됐으면 갱신 시도 + authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 만료 감지 → 갱신 시도"); refreshToken().then((newToken) => { if (!newToken) { + authLog("REDIRECT_TO_LOGIN", "탭 복귀 후 토큰 갱신 실패로 리다이렉트"); redirectToLogin(); } }); } else if (TokenManager.isTokenExpiringSoon(token)) { + authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 만료 임박 → 갱신 시도"); refreshToken(); } } @@ -268,6 +289,7 @@ const redirectToLogin = (): void => { if (isRedirecting) return; if (window.location.pathname === "/login") return; + authLog("REDIRECT_TO_LOGIN", `리다이렉트 실행 (from: ${window.location.pathname})`); isRedirecting = true; TokenManager.removeToken(); window.location.href = "/login"; @@ -301,15 +323,13 @@ apiClient.interceptors.request.use( if (token) { if (!TokenManager.isTokenExpired(token)) { - // 유효한 토큰 → 그대로 사용 config.headers.Authorization = `Bearer ${token}`; } else { - // 만료된 토큰 → 갱신 시도 후 사용 + authLog("TOKEN_EXPIRED_DETECTED", `요청 전 토큰 만료 감지 (${config.url}) → 갱신 시도`); const newToken = await refreshToken(); if (newToken) { config.headers.Authorization = `Bearer ${newToken}`; } - // 갱신 실패해도 요청은 보냄 (401 응답 인터셉터에서 처리) } } @@ -378,12 +398,16 @@ apiClient.interceptors.response.use( // 401 에러 처리 (핵심 개선) if (status === 401 && typeof window !== "undefined") { - const errorData = error.response?.data as { error?: { code?: string } }; + const errorData = error.response?.data as { error?: { code?: string; details?: string } }; const errorCode = errorData?.error?.code; + const errorDetails = errorData?.error?.details; const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; + authLog("API_401_RECEIVED", `URL: ${url} | 코드: ${errorCode || "없음"} | 상세: ${errorDetails || "없음"}`); + // 이미 재시도한 요청이면 로그인으로 if (originalRequest?._retry) { + authLog("REDIRECT_TO_LOGIN", `재시도 후에도 401 (${url}) → 로그인 리다이렉트`); redirectToLogin(); return Promise.reject(error); } @@ -395,6 +419,7 @@ apiClient.interceptors.response.use( originalRequest._retry = true; try { + authLog("API_401_RETRY", `토큰 만료로 갱신 후 재시도 (${url})`); const newToken = await refreshToken(); if (newToken) { isRefreshing = false; @@ -404,17 +429,18 @@ apiClient.interceptors.response.use( } else { isRefreshing = false; onRefreshFailed(new Error("토큰 갱신 실패")); + authLog("REDIRECT_TO_LOGIN", `토큰 갱신 실패 (${url}) → 로그인 리다이렉트`); redirectToLogin(); return Promise.reject(error); } } catch (refreshError) { isRefreshing = false; onRefreshFailed(refreshError as Error); + authLog("REDIRECT_TO_LOGIN", `토큰 갱신 예외 (${url}) → 로그인 리다이렉트`); redirectToLogin(); return Promise.reject(error); } } else { - // 다른 요청이 이미 갱신 중 → 갱신 완료 대기 후 재시도 try { const newToken = await waitForTokenRefresh(); originalRequest._retry = true; @@ -427,6 +453,7 @@ apiClient.interceptors.response.use( } // TOKEN_MISSING, INVALID_TOKEN 등 → 로그인으로 + authLog("REDIRECT_TO_LOGIN", `복구 불가능한 인증 에러 (${errorCode || "UNKNOWN"}, ${url}) → 로그인 리다이렉트`); redirectToLogin(); } diff --git a/frontend/lib/authLogger.ts b/frontend/lib/authLogger.ts new file mode 100644 index 00000000..f30284ab --- /dev/null +++ b/frontend/lib/authLogger.ts @@ -0,0 +1,225 @@ +/** + * 인증 이벤트 로거 + * - 토큰 갱신/삭제/리다이렉트 발생 시 원인을 기록 + * - localStorage에 저장하여 브라우저에서 확인 가능 + * - 콘솔에서 window.__AUTH_LOG.show() 로 조회 + */ + +const STORAGE_KEY = "auth_debug_log"; +const MAX_ENTRIES = 200; + +export type AuthEventType = + | "TOKEN_SET" + | "TOKEN_REMOVED" + | "TOKEN_EXPIRED_DETECTED" + | "TOKEN_REFRESH_START" + | "TOKEN_REFRESH_SUCCESS" + | "TOKEN_REFRESH_FAIL" + | "REDIRECT_TO_LOGIN" + | "API_401_RECEIVED" + | "API_401_RETRY" + | "AUTH_CHECK_START" + | "AUTH_CHECK_SUCCESS" + | "AUTH_CHECK_FAIL" + | "AUTH_GUARD_BLOCK" + | "AUTH_GUARD_PASS" + | "MENU_LOAD_FAIL" + | "VISIBILITY_CHANGE" + | "MIDDLEWARE_REDIRECT"; + +interface AuthLogEntry { + timestamp: string; + event: AuthEventType; + detail: string; + tokenStatus: string; + url: string; + stack?: string; +} + +function getTokenSummary(): string { + if (typeof window === "undefined") return "SSR"; + + const token = localStorage.getItem("authToken"); + if (!token) return "없음"; + + try { + const payload = JSON.parse(atob(token.split(".")[1])); + const exp = payload.exp * 1000; + const now = Date.now(); + const remainMs = exp - now; + + if (remainMs <= 0) { + return `만료됨(${Math.abs(Math.round(remainMs / 60000))}분 전)`; + } + + const remainMin = Math.round(remainMs / 60000); + const remainHour = Math.floor(remainMin / 60); + const min = remainMin % 60; + + return `유효(${remainHour}h${min}m 남음, user:${payload.userId})`; + } catch { + return "파싱실패"; + } +} + +function getCallStack(): string { + try { + const stack = new Error().stack || ""; + const lines = stack.split("\n").slice(3, 7); + return lines.map((l) => l.trim()).join(" <- "); + } catch { + return ""; + } +} + +function writeLog(event: AuthEventType, detail: string) { + if (typeof window === "undefined") return; + + const entry: AuthLogEntry = { + timestamp: new Date().toISOString(), + event, + detail, + tokenStatus: getTokenSummary(), + url: window.location.pathname + window.location.search, + stack: getCallStack(), + }; + + // 콘솔 출력 (그룹) + const isError = ["TOKEN_REMOVED", "REDIRECT_TO_LOGIN", "API_401_RECEIVED", "TOKEN_REFRESH_FAIL", "AUTH_GUARD_BLOCK"].includes(event); + const logFn = isError ? console.warn : console.debug; + logFn(`[AuthLog] ${event}: ${detail} | 토큰: ${entry.tokenStatus} | ${entry.url}`); + + // localStorage에 저장 + try { + const stored = localStorage.getItem(STORAGE_KEY); + const logs: AuthLogEntry[] = stored ? JSON.parse(stored) : []; + logs.push(entry); + + // 최대 개수 초과 시 오래된 것 제거 + while (logs.length > MAX_ENTRIES) { + logs.shift(); + } + + localStorage.setItem(STORAGE_KEY, JSON.stringify(logs)); + } catch { + // localStorage 공간 부족 등의 경우 무시 + } +} + +/** + * 저장된 로그 조회 + */ +function getLogs(): AuthLogEntry[] { + if (typeof window === "undefined") return []; + try { + const stored = localStorage.getItem(STORAGE_KEY); + return stored ? JSON.parse(stored) : []; + } catch { + return []; + } +} + +/** + * 로그 초기화 + */ +function clearLogs() { + if (typeof window === "undefined") return; + localStorage.removeItem(STORAGE_KEY); +} + +/** + * 로그를 테이블 형태로 콘솔에 출력 + */ +function showLogs(filter?: AuthEventType | "ERROR") { + const logs = getLogs(); + + if (logs.length === 0) { + console.log("[AuthLog] 저장된 로그가 없습니다."); + return; + } + + let filtered = logs; + if (filter === "ERROR") { + filtered = logs.filter((l) => + ["TOKEN_REMOVED", "REDIRECT_TO_LOGIN", "API_401_RECEIVED", "TOKEN_REFRESH_FAIL", "AUTH_GUARD_BLOCK", "AUTH_CHECK_FAIL", "TOKEN_EXPIRED_DETECTED"].includes(l.event) + ); + } else if (filter) { + filtered = logs.filter((l) => l.event === filter); + } + + console.log(`\n[AuthLog] 총 ${filtered.length}건 (전체 ${logs.length}건)`); + console.log("─".repeat(120)); + + filtered.forEach((entry, i) => { + const time = entry.timestamp.replace("T", " ").split(".")[0]; + console.log( + `${i + 1}. [${time}] ${entry.event}\n 상세: ${entry.detail}\n 토큰: ${entry.tokenStatus}\n 경로: ${entry.url}${entry.stack ? `\n 호출: ${entry.stack}` : ""}\n` + ); + }); +} + +/** + * 마지막 리다이렉트 원인 조회 + */ +function getLastRedirectReason(): AuthLogEntry | null { + const logs = getLogs(); + for (let i = logs.length - 1; i >= 0; i--) { + if (logs[i].event === "REDIRECT_TO_LOGIN") { + return logs[i]; + } + } + return null; +} + +/** + * 로그를 텍스트 파일로 다운로드 + */ +function downloadLogs() { + if (typeof window === "undefined") return; + + const logs = getLogs(); + if (logs.length === 0) { + console.log("[AuthLog] 저장된 로그가 없습니다."); + return; + } + + const text = logs + .map((entry, i) => { + const time = entry.timestamp.replace("T", " ").split(".")[0]; + return `[${i + 1}] ${time} | ${entry.event}\n 상세: ${entry.detail}\n 토큰: ${entry.tokenStatus}\n 경로: ${entry.url}${entry.stack ? `\n 호출: ${entry.stack}` : ""}`; + }) + .join("\n\n"); + + const blob = new Blob([text], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `auth-debug-log_${new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-")}.txt`; + a.click(); + URL.revokeObjectURL(url); + + console.log("[AuthLog] 로그 파일 다운로드 완료"); +} + +// 전역 접근 가능하게 등록 +if (typeof window !== "undefined") { + (window as any).__AUTH_LOG = { + show: showLogs, + errors: () => showLogs("ERROR"), + clear: clearLogs, + download: downloadLogs, + lastRedirect: getLastRedirectReason, + raw: getLogs, + }; +} + +export const AuthLogger = { + log: writeLog, + getLogs, + clearLogs, + showLogs, + downloadLogs, + getLastRedirectReason, +}; + +export default AuthLogger; diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index e6b13067..e79d9f83 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -8,6 +8,76 @@ import { filterDOMProps } from "@/lib/utils/domPropsFilter"; // 통합 폼 시스템 import import { useV2FormOptional } from "@/components/v2/V2FormContext"; +import { apiClient } from "@/lib/api/client"; + +// 컬럼 메타데이터 캐시 (테이블명 → 컬럼 설정 맵) +const columnMetaCache: Record> = {}; +const columnMetaLoading: Record> = {}; + +async function loadColumnMeta(tableName: string): Promise { + if (columnMetaCache[tableName] || columnMetaLoading[tableName]) return; + + columnMetaLoading[tableName] = (async () => { + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=1000`); + const data = response.data.data || response.data; + const columns = data.columns || data || []; + const map: Record = {}; + for (const col of columns) { + const name = col.column_name || col.columnName; + if (name) map[name] = col; + } + columnMetaCache[tableName] = map; + } catch { + columnMetaCache[tableName] = {}; + } finally { + delete columnMetaLoading[tableName]; + } + })(); + + await columnMetaLoading[tableName]; +} + +// table_type_columns 기반 componentConfig 병합 (기존 설정이 없을 때만 DB 메타데이터로 보완) +function mergeColumnMeta(tableName: string | undefined, columnName: string | undefined, componentConfig: any): any { + if (!tableName || !columnName) return componentConfig; + + const meta = columnMetaCache[tableName]?.[columnName]; + if (!meta) return componentConfig; + + const inputType = meta.input_type || meta.inputType; + if (!inputType) return componentConfig; + + // 이미 source가 올바르게 설정된 경우 건드리지 않음 + const existingSource = componentConfig?.source; + if (existingSource && existingSource !== "static" && existingSource !== "distinct" && existingSource !== "select") { + return componentConfig; + } + + const merged = { ...componentConfig }; + + // source가 미설정/기본값일 때만 DB 메타데이터로 보완 + if (inputType === "entity") { + const refTable = meta.reference_table || meta.referenceTable; + const refColumn = meta.reference_column || meta.referenceColumn; + const displayCol = meta.display_column || meta.displayColumn; + if (refTable && !merged.entityTable) { + merged.source = "entity"; + merged.entityTable = refTable; + merged.entityValueColumn = refColumn || "id"; + merged.entityLabelColumn = displayCol || "name"; + } + } else if (inputType === "category" && !existingSource) { + merged.source = "category"; + } else if (inputType === "select" && !existingSource) { + const detail = typeof meta.detail_settings === "string" ? JSON.parse(meta.detail_settings || "{}") : (meta.detail_settings || {}); + if (detail.options && !merged.options?.length) { + merged.options = detail.options; + } + } + + return merged; +} // 컴포넌트 렌더러 인터페이스 export interface ComponentRenderer { @@ -175,6 +245,15 @@ export const DynamicComponentRenderer: React.FC = children, ...props }) => { + // 컬럼 메타데이터 로드 트리거 (테이블명이 있으면 비동기 로드) + const screenTableName = props.tableName || (component as any).tableName; + const [, forceUpdate] = React.useState(0); + React.useEffect(() => { + if (screenTableName && !columnMetaCache[screenTableName]) { + loadColumnMeta(screenTableName).then(() => forceUpdate((v) => v + 1)); + } + }, [screenTableName]); + // 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용 // 🆕 V2 레이아웃의 경우 url에서 컴포넌트 타입 추출 (예: "@/lib/registry/components/v2-input" → "v2-input") const extractTypeFromUrl = (url: string | undefined): string | undefined => { @@ -550,24 +629,34 @@ export const DynamicComponentRenderer: React.FC = height: finalStyle.height, }; + // 컬럼 메타데이터 기반 componentConfig 병합 (DB 최신 설정 우선) + const isEntityJoinColumn = fieldName?.includes("."); + const baseColumnName = isEntityJoinColumn ? undefined : fieldName; + const mergedComponentConfig = mergeColumnMeta(screenTableName, baseColumnName, component.componentConfig || {}); + + // 엔티티 조인 컬럼은 런타임에서 readonly/disabled 강제 해제 + const effectiveComponent = isEntityJoinColumn + ? { ...component, componentConfig: mergedComponentConfig, readonly: false } + : { ...component, componentConfig: mergedComponentConfig }; + const rendererProps = { - component, + component: effectiveComponent, isSelected, onClick, onDragStart, onDragEnd, size: component.size || newComponent.defaultSize, position: component.position, - config: component.componentConfig, - componentConfig: component.componentConfig, + config: mergedComponentConfig, + componentConfig: mergedComponentConfig, // componentConfig의 모든 속성을 props로 spread (tableName, displayField 등) - ...(component.componentConfig || {}), + ...(mergedComponentConfig || {}), // 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선) style: mergedStyle, // 🆕 라벨 표시 (labelDisplay가 true일 때만) label: effectiveLabel, - // 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달 - inputType: (component as any).inputType || component.componentConfig?.inputType, + // 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달 (DB 메타데이터 우선) + inputType: (baseColumnName && columnMetaCache[screenTableName || ""]?.[baseColumnName]?.input_type) || (component as any).inputType || mergedComponentConfig?.inputType, columnName: (component as any).columnName || component.componentConfig?.columnName, value: currentValue, // formData에서 추출한 현재 값 전달 // 새로운 기능들 전달 @@ -607,9 +696,8 @@ export const DynamicComponentRenderer: React.FC = // componentConfig.mode가 있으면 유지 (entity-search-input의 UI 모드) mode: component.componentConfig?.mode || mode, isInModal, - readonly: component.readonly, - // 🆕 disabledFields 체크 또는 기존 readonly - disabled: disabledFields?.includes(fieldName) || component.readonly, + readonly: isEntityJoinColumn ? false : component.readonly, + disabled: isEntityJoinColumn ? false : (disabledFields?.includes(fieldName) || component.readonly), originalData, allComponents, onUpdateLayout, diff --git a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx index 16aebd59..95f9987e 100644 --- a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx @@ -13,6 +13,7 @@ import { import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, @@ -26,6 +27,7 @@ import { DialogHeader, DialogTitle, DialogDescription, + DialogFooter, } from "@/components/ui/dialog"; import { entityJoinApi } from "@/lib/api/entityJoin"; import { apiClient } from "@/lib/api/client"; @@ -82,7 +84,7 @@ const generateTempId = () => `temp_${Date.now()}_${++tempIdCounter}`; interface ItemSearchModalProps { open: boolean; onClose: () => void; - onSelect: (item: ItemInfo) => void; + onSelect: (items: ItemInfo[]) => void; companyCode?: string; } @@ -94,6 +96,7 @@ function ItemSearchModal({ }: ItemSearchModalProps) { const [searchText, setSearchText] = useState(""); const [items, setItems] = useState([]); + const [selectedItems, setSelectedItems] = useState>(new Set()); const [loading, setLoading] = useState(false); const searchItems = useCallback( @@ -109,7 +112,7 @@ function ItemSearchModal({ enableEntityJoin: true, companyCodeOverride: companyCode, }); - setItems(result.data || []); + setItems((result.data || []) as ItemInfo[]); } catch (error) { console.error("[BomItemEditor] 품목 검색 실패:", error); } finally { @@ -122,6 +125,7 @@ function ItemSearchModal({ useEffect(() => { if (open) { setSearchText(""); + setSelectedItems(new Set()); searchItems(""); } }, [open, searchItems]); @@ -180,6 +184,15 @@ function ItemSearchModal({ + @@ -191,11 +204,31 @@ function ItemSearchModal({ { - onSelect(item); - onClose(); + setSelectedItems((prev) => { + const next = new Set(prev); + if (next.has(item.id)) next.delete(item.id); + else next.add(item.id); + return next; + }); }} - className="hover:bg-accent cursor-pointer border-t transition-colors" + className={cn( + "cursor-pointer border-t transition-colors", + selectedItems.has(item.id) ? "bg-primary/10" : "hover:bg-accent", + )} > + @@ -208,6 +241,25 @@ function ItemSearchModal({
+ 0 && selectedItems.size === items.length} + onCheckedChange={(checked) => { + if (checked) setSelectedItems(new Set(items.map((i) => i.id))); + else setSelectedItems(new Set()); + }} + /> + 품목코드 품목명 구분
e.stopPropagation()}> + { + setSelectedItems((prev) => { + const next = new Set(prev); + if (checked) next.add(item.id); + else next.delete(item.id); + return next; + }); + }} + /> + {item.item_number}
)} + + {selectedItems.size > 0 && ( + + + {selectedItems.size}개 선택됨 + + + + )} ); @@ -227,6 +279,10 @@ interface TreeNodeRowProps { onFieldChange: (tempId: string, field: string, value: string) => void; onDelete: (tempId: string) => void; onAddChild: (parentTempId: string) => void; + onDragStart: (e: React.DragEvent, tempId: string) => void; + onDragOver: (e: React.DragEvent, tempId: string) => void; + onDrop: (e: React.DragEvent, tempId: string) => void; + isDragOver?: boolean; } function TreeNodeRow({ @@ -241,6 +297,10 @@ function TreeNodeRow({ onFieldChange, onDelete, onAddChild, + onDragStart, + onDragOver, + onDrop, + isDragOver, }: TreeNodeRowProps) { const indentPx = depth * 32; const visibleColumns = columns.filter((c) => c.visible !== false); @@ -319,8 +379,13 @@ function TreeNodeRow({ "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", + isDragOver && "border-primary bg-primary/5 border-dashed", )} style={{ marginLeft: `${indentPx}px` }} + draggable + onDragStart={(e) => onDragStart(e, node.tempId)} + onDragOver={(e) => onDragOver(e, node.tempId)} + onDrop={(e) => onDrop(e, node.tempId)} > @@ -409,7 +474,7 @@ export function BomItemEditorComponent({ // 설정값 추출 const cfg = useMemo(() => component?.componentConfig || {}, [component]); const mainTableName = cfg.mainTableName || "bom_detail"; - const parentKeyColumn = cfg.parentKeyColumn || "parent_detail_id"; + const parentKeyColumn = (cfg.parentKeyColumn && cfg.parentKeyColumn !== "id") ? 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"; @@ -431,7 +496,14 @@ export function BomItemEditorComponent({ for (const col of categoryColumns) { const categoryRef = `${mainTableName}.${col.key}`; - if (categoryOptionsMap[categoryRef]) continue; + + const alreadyLoaded = await new Promise((resolve) => { + setCategoryOptionsMap((prev) => { + resolve(!!prev[categoryRef]); + return prev; + }); + }); + if (alreadyLoaded) continue; try { const response = await apiClient.get(`/table-categories/${mainTableName}/${col.key}/values`); @@ -455,11 +527,23 @@ export function BomItemEditorComponent({ // ─── 데이터 로드 ─── + const sourceFk = cfg.dataSource?.foreignKey || "child_item_id"; + const sourceTable = cfg.dataSource?.sourceTable || "item_info"; + const loadBomDetails = useCallback( async (id: string) => { if (!id) return; setLoading(true); try { + // isSourceDisplay 컬럼을 추가 조인 컬럼으로 요청 + const displayCols = columns.filter((c) => c.isSourceDisplay); + const additionalJoinColumns = displayCols.map((col) => ({ + sourceTable, + sourceColumn: sourceFk, + joinAlias: `${sourceFk}_${col.key}`, + referenceTable: sourceTable, + })); + const result = await entityJoinApi.getTableDataWithJoins(mainTableName, { page: 1, size: 500, @@ -467,9 +551,20 @@ export function BomItemEditorComponent({ sortBy: "seq_no", sortOrder: "asc", enableEntityJoin: true, + additionalJoinColumns: additionalJoinColumns.length > 0 ? additionalJoinColumns : undefined, + }); + + const rows = (result.data || []).map((row: Record) => { + const mapped = { ...row }; + for (const key of Object.keys(row)) { + if (key.startsWith(`${sourceFk}_`)) { + const shortKey = key.replace(`${sourceFk}_`, ""); + if (!mapped[shortKey]) mapped[shortKey] = row[key]; + } + } + return mapped; }); - const rows = result.data || []; const tree = buildTree(rows); setTreeData(tree); @@ -483,7 +578,7 @@ export function BomItemEditorComponent({ setLoading(false); } }, - [mainTableName, fkColumn], + [mainTableName, fkColumn, sourceFk, sourceTable, columns], ); useEffect(() => { @@ -548,10 +643,13 @@ export function BomItemEditorComponent({ id: node.id, tempId: node.tempId, [parentKeyColumn]: parentId, + [fkColumn]: bomId, seq_no: String(idx + 1), level: String(level), _isNew: node._isNew, _targetTable: mainTableName, + _fkColumn: fkColumn, + _deferSave: true, }); if (node.children.length > 0) { traverse(node.children, node.id || node.tempId, level + 1); @@ -560,7 +658,7 @@ export function BomItemEditorComponent({ }; traverse(nodes, null, 0); return result; - }, [parentKeyColumn, mainTableName]); + }, [parentKeyColumn, mainTableName, fkColumn, bomId]); // 트리 변경 시 부모에게 알림 const notifyChange = useCallback( @@ -627,53 +725,56 @@ export function BomItemEditorComponent({ setItemSearchOpen(true); }, []); - // 품목 선택 후 추가 (동적 데이터) + // 품목 선택 후 추가 (다중 선택 지원) const handleItemSelect = useCallback( - (item: ItemInfo) => { - // 소스 테이블 데이터를 _display_ 접두사로 저장 (엔티티 조인 방식) - const sourceData: Record = {}; - const sourceTable = cfg.dataSource?.sourceTable; - if (sourceTable) { - const sourceFk = cfg.dataSource?.foreignKey || "child_item_id"; - sourceData[sourceFk] = item.id; - // 소스 표시 컬럼의 데이터 병합 - Object.keys(item).forEach((key) => { - sourceData[`_display_${key}`] = (item as any)[key]; - sourceData[key] = (item as any)[key]; - }); + (selectedItemsList: ItemInfo[]) => { + let newTree = [...treeData]; + + for (const item of selectedItemsList) { + const sourceData: Record = {}; + const sourceTable = cfg.dataSource?.sourceTable; + if (sourceTable) { + const sourceFk = cfg.dataSource?.foreignKey || "child_item_id"; + sourceData[sourceFk] = item.id; + Object.keys(item).forEach((key) => { + sourceData[`_display_${key}`] = (item as any)[key]; + sourceData[key] = (item as any)[key]; + }); + } + + const newNode: BomItemNode = { + tempId: generateTempId(), + parent_detail_id: null, + seq_no: 0, + level: 0, + children: [], + _isNew: true, + data: { + ...sourceData, + quantity: "1", + loss_rate: "0", + remark: "", + }, + }; + + if (addTargetParentId === null) { + newNode.seq_no = newTree.length + 1; + newNode.level = 0; + newTree = [...newTree, newNode]; + } else { + newTree = findAndUpdate(newTree, 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], + }; + }); + } } - 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], - }; - }); + if (addTargetParentId !== null) { setExpandedNodes((prev) => new Set([...prev, addTargetParentId])); } @@ -692,6 +793,101 @@ export function BomItemEditorComponent({ }); }, []); + // ─── 드래그 재정렬 ─── + const [dragId, setDragId] = useState(null); + const [dragOverId, setDragOverId] = useState(null); + + // 트리에서 노드를 제거하고 반환 + const removeNode = (nodes: BomItemNode[], tempId: string): { tree: BomItemNode[]; removed: BomItemNode | null } => { + const result: BomItemNode[] = []; + let removed: BomItemNode | null = null; + for (const node of nodes) { + if (node.tempId === tempId) { + removed = node; + } else { + const childResult = removeNode(node.children, tempId); + if (childResult.removed) removed = childResult.removed; + result.push({ ...node, children: childResult.tree }); + } + } + return { tree: result, removed }; + }; + + // 노드가 대상의 자손인지 확인 (자기 자신의 하위로 드래그 방지) + const isDescendant = (nodes: BomItemNode[], parentId: string, childId: string): boolean => { + const find = (list: BomItemNode[]): BomItemNode | null => { + for (const n of list) { + if (n.tempId === parentId) return n; + const found = find(n.children); + if (found) return found; + } + return null; + }; + const parent = find(nodes); + if (!parent) return false; + const check = (children: BomItemNode[]): boolean => { + for (const c of children) { + if (c.tempId === childId) return true; + if (check(c.children)) return true; + } + return false; + }; + return check(parent.children); + }; + + const handleDragStart = useCallback((e: React.DragEvent, tempId: string) => { + setDragId(tempId); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", tempId); + }, []); + + const handleDragOver = useCallback((e: React.DragEvent, tempId: string) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setDragOverId(tempId); + }, []); + + const handleDrop = useCallback((e: React.DragEvent, targetTempId: string) => { + e.preventDefault(); + setDragOverId(null); + if (!dragId || dragId === targetTempId) return; + + // 자기 자신의 하위로 드래그 방지 + if (isDescendant(treeData, dragId, targetTempId)) return; + + const { tree: treeWithout, removed } = removeNode(treeData, dragId); + if (!removed) return; + + // 대상 노드 바로 뒤에 같은 레벨로 삽입 + const insertAfter = (nodes: BomItemNode[], afterId: string, node: BomItemNode): { result: BomItemNode[]; inserted: boolean } => { + const result: BomItemNode[] = []; + let inserted = false; + for (const n of nodes) { + result.push(n); + if (n.tempId === afterId) { + result.push({ ...node, level: n.level, parent_detail_id: n.parent_detail_id }); + inserted = true; + } else if (!inserted) { + const childResult = insertAfter(n.children, afterId, node); + if (childResult.inserted) { + result[result.length - 1] = { ...n, children: childResult.result }; + inserted = true; + } + } + } + return { result, inserted }; + }; + + const { result, inserted } = insertAfter(treeWithout, targetTempId, removed); + if (inserted) { + const reindex = (nodes: BomItemNode[], depth = 0): BomItemNode[] => + nodes.map((n, i) => ({ ...n, seq_no: i + 1, level: depth, children: reindex(n.children, depth + 1) })); + notifyChange(reindex(result)); + } + + setDragId(null); + }, [dragId, treeData, notifyChange]); + // ─── 재귀 렌더링 ─── const renderNodes = (nodes: BomItemNode[], depth: number) => { @@ -711,6 +907,10 @@ export function BomItemEditorComponent({ onFieldChange={handleFieldChange} onDelete={handleDelete} onAddChild={handleAddChild} + onDragStart={handleDragStart} + onDragOver={handleDragOver} + onDrop={handleDrop} + isDragOver={dragOverId === node.tempId} /> {isExpanded && node.children.length > 0 && @@ -898,7 +1098,7 @@ export function BomItemEditorComponent({ {/* 트리 목록 */} -
+
{loading ? (
로딩 중... diff --git a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx index 536c1ddc..9573472d 100644 --- a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx @@ -84,30 +84,45 @@ export function BomTreeComponent({ return null; }, [formData, selectedRowsData]); - // 선택된 BOM 헤더 정보 추출 + // 선택된 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; + const raw = selectedRowsData?.[0] || (formData?.id ? formData : null); + if (!raw) return null; + return { + ...raw, + item_name: raw.item_id_item_name || raw.item_name || "", + item_code: raw.item_id_item_number || raw.item_code || "", + item_type: raw.item_id_division || raw.item_id_type || raw.item_type || "", + } as BomHeaderInfo; }, [formData, selectedRowsData]); // BOM 디테일 데이터 로드 + const detailTable = config.detailTable || "bom_detail"; + const foreignKey = config.foreignKey || "bom_id"; + const sourceFk = "child_item_id"; + const loadBomDetails = useCallback(async (bomId: string) => { if (!bomId) return; setLoading(true); try { - const result = await entityJoinApi.getTableDataWithJoins("bom_detail", { + const result = await entityJoinApi.getTableDataWithJoins(detailTable, { page: 1, size: 500, - search: { bom_id: bomId }, + search: { [foreignKey]: bomId }, sortBy: "seq_no", sortOrder: "asc", enableEntityJoin: true, }); - const rows = result.data || []; + const rows = (result.data || []).map((row: Record) => { + const mapped = { ...row }; + // 엔티티 조인 필드 매핑: child_item_id_item_name → child_item_name 등 + mapped.child_item_name = row[`${sourceFk}_item_name`] || row.child_item_name || ""; + mapped.child_item_code = row[`${sourceFk}_item_number`] || row.child_item_code || ""; + mapped.child_item_type = row[`${sourceFk}_type`] || row[`${sourceFk}_division`] || row.child_item_type || ""; + return mapped; + }); + const tree = buildTree(rows); setTreeData(tree); const firstLevelIds = new Set(tree.map((n: BomTreeNode) => n.id)); @@ -117,7 +132,7 @@ export function BomTreeComponent({ } finally { setLoading(false); } - }, []); + }, [detailTable, foreignKey]); // 평면 데이터 -> 트리 구조 변환 const buildTree = (flatData: any[]): BomTreeNode[] => { diff --git a/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx b/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx index 18898198..b389cff7 100644 --- a/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx +++ b/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx @@ -13,7 +13,7 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer { static componentDefinition = V2SelectDefinition; render(): React.ReactElement { - const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, ...restProps } = this.props; + const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, allComponents, ...restProps } = this.props as any; // 컴포넌트 설정 추출 const config = component.componentConfig || component.config || {}; @@ -107,8 +107,7 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer { // 디버깅 필요시 주석 해제 // console.log("🔍 [V2SelectRenderer]", { componentId: component.id, effectiveStyle, effectiveSize }); - // 🔧 restProps에서 style, size 제외 (effectiveStyle/effectiveSize가 우선되어야 함) - const { style: _style, size: _size, ...restPropsClean } = restProps as any; + const { style: _style, size: _size, allComponents: _allComp, ...restPropsClean } = restProps as any; return ( = { - ...dataToSave, // RepeaterFieldGroup의 개별 항목 데이터 - ...commonFields, // 범용 폼 모달의 공통 필드 (outbound_status 등) - 공통 필드가 우선! + ...dataToSave, + ...commonFields, created_by: context.userId, updated_by: context.userId, company_code: context.companyCode, @@ -1781,6 +1791,45 @@ export class ButtonActionExecutor { // 🔧 formData를 리피터에 전달하여 각 행에 병합 저장 const savedId = saveResult?.data?.id || saveResult?.data?.data?.id || formData.id || context.formData?.id; + // _deferSave 데이터 처리 (마스터-디테일 순차 저장: 메인 저장 후 디테일 저장) + if (savedId) { + for (const [fieldKey, fieldValue] of Object.entries(context.formData)) { + let parsedData = fieldValue; + if (typeof fieldValue === "string" && fieldValue.startsWith("[")) { + try { parsedData = JSON.parse(fieldValue); } catch { continue; } + } + if (!Array.isArray(parsedData) || parsedData.length === 0) continue; + if (!parsedData[0]?._deferSave) continue; + + const targetTable = parsedData[0]?._targetTable; + if (!targetTable) continue; + + for (const item of parsedData) { + const { _targetTable: _, _isNew, _deferSave: __, _fkColumn: fkCol, tempId: ___, ...data } = item; + if (!data.id || data.id === "") delete data.id; + + // FK 주입 + if (fkCol) data[fkCol] = savedId; + + // 시스템 필드 추가 + data.created_by = context.userId; + data.updated_by = context.userId; + data.company_code = context.companyCode; + + try { + const isNew = _isNew || !item.id || item.id === ""; + if (isNew) { + await apiClient.post(`/table-management/tables/${targetTable}/add`, data); + } else { + await apiClient.put(`/table-management/tables/${targetTable}/${item.id}`, data); + } + } catch (err: any) { + console.error(`[handleSave] 디테일 저장 실패 (${targetTable}):`, err.response?.data || err.message); + } + } + } + } + // 메인 폼 데이터 구성 (사용자 정보 포함) const mainFormData = { ...formData, diff --git a/frontend/types/v2-components.ts b/frontend/types/v2-components.ts index a7543b24..88ac1691 100644 --- a/frontend/types/v2-components.ts +++ b/frontend/types/v2-components.ts @@ -182,6 +182,8 @@ export interface V2SelectProps extends V2BaseProps { config: V2SelectConfig; value?: string | string[]; onChange?: (value: string | string[]) => void; + onFormDataChange?: (fieldName: string, value: any) => void; + formData?: Record; } // ===== V2Date =====