feat: Add BOM tree view and BOM item editor components
- Introduced new components for BOM tree view and BOM item editor, enhancing the data management capabilities within the application. - Updated the ComponentsPanel to include these new components with appropriate descriptions and default sizes. - Integrated the BOM item editor into the V2PropertiesPanel for seamless editing of BOM items. - Adjusted the SplitLineComponent to improve the handling of canvas split positions, ensuring better user experience during component interactions.
This commit is contained in:
@@ -1,24 +1,19 @@
|
||||
import axios, { AxiosResponse, AxiosError } from "axios";
|
||||
import axios, { AxiosResponse, AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||
|
||||
// API URL 동적 설정 - 환경변수 우선 사용
|
||||
const getApiBaseUrl = (): string => {
|
||||
// 1. 환경변수가 있으면 우선 사용
|
||||
if (process.env.NEXT_PUBLIC_API_URL) {
|
||||
return process.env.NEXT_PUBLIC_API_URL;
|
||||
}
|
||||
|
||||
// 2. 클라이언트 사이드에서 동적 설정
|
||||
if (typeof window !== "undefined") {
|
||||
const currentHost = window.location.hostname;
|
||||
const currentPort = window.location.port;
|
||||
const protocol = window.location.protocol;
|
||||
|
||||
// 프로덕션 환경: v1.vexplor.com → api.vexplor.com
|
||||
if (currentHost === "v1.vexplor.com") {
|
||||
return "https://api.vexplor.com/api";
|
||||
}
|
||||
|
||||
// 로컬 개발환경: localhost:9771 또는 localhost:3000 → localhost:8080
|
||||
if (
|
||||
(currentHost === "localhost" || currentHost === "127.0.0.1") &&
|
||||
(currentPort === "9771" || currentPort === "3000")
|
||||
@@ -27,57 +22,46 @@ const getApiBaseUrl = (): string => {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 기본값
|
||||
return "http://localhost:8080/api";
|
||||
};
|
||||
|
||||
export const API_BASE_URL = getApiBaseUrl();
|
||||
|
||||
// 이미지 URL을 완전한 URL로 변환하는 함수
|
||||
// 주의: 모듈 로드 시점이 아닌 런타임에 hostname을 확인해야 SSR 문제 방지
|
||||
export const getFullImageUrl = (imagePath: string): string => {
|
||||
// 빈 값 체크
|
||||
if (!imagePath) return "";
|
||||
|
||||
// 이미 전체 URL인 경우 그대로 반환
|
||||
if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
|
||||
return imagePath;
|
||||
}
|
||||
|
||||
// /uploads로 시작하는 상대 경로인 경우 API 서버 주소 추가
|
||||
if (imagePath.startsWith("/uploads")) {
|
||||
// 런타임에 현재 hostname 확인 (SSR 시점이 아닌 클라이언트에서 실행될 때)
|
||||
if (typeof window !== "undefined") {
|
||||
const currentHost = window.location.hostname;
|
||||
|
||||
// 프로덕션 환경: v1.vexplor.com → api.vexplor.com
|
||||
if (currentHost === "v1.vexplor.com") {
|
||||
return `https://api.vexplor.com${imagePath}`;
|
||||
}
|
||||
|
||||
// 로컬 개발환경
|
||||
if (currentHost === "localhost" || currentHost === "127.0.0.1") {
|
||||
return `http://localhost:8080${imagePath}`;
|
||||
}
|
||||
}
|
||||
|
||||
// SSR 또는 알 수 없는 환경에서는 API_BASE_URL 사용 (fallback)
|
||||
// 주의: 프로덕션 URL이 https://api.vexplor.com/api 이므로
|
||||
// 단순 .replace("/api", "")는 호스트명의 /api까지 제거하는 버그 발생
|
||||
// 반드시 문자열 끝의 /api만 제거해야 함
|
||||
const baseUrl = API_BASE_URL.replace(/\/api$/, "");
|
||||
if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) {
|
||||
return `${baseUrl}${imagePath}`;
|
||||
}
|
||||
|
||||
// 최종 fallback
|
||||
return imagePath;
|
||||
}
|
||||
|
||||
return imagePath;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// JWT 토큰 관리 유틸리티
|
||||
// ============================================
|
||||
const TokenManager = {
|
||||
getToken: (): string | null => {
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -107,20 +91,19 @@ const TokenManager = {
|
||||
}
|
||||
},
|
||||
|
||||
// 토큰이 곧 만료되는지 확인 (30분 이내)
|
||||
// 만료 30분 전부터 갱신 대상
|
||||
isTokenExpiringSoon: (token: string): boolean => {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
const expiryTime = payload.exp * 1000;
|
||||
const currentTime = Date.now();
|
||||
const thirtyMinutes = 30 * 60 * 1000; // 30분
|
||||
const thirtyMinutes = 30 * 60 * 1000;
|
||||
return expiryTime - currentTime < thirtyMinutes && expiryTime > currentTime;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// 토큰 만료까지 남은 시간 (밀리초)
|
||||
getTimeUntilExpiry: (token: string): number => {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
@@ -131,77 +114,85 @@ const TokenManager = {
|
||||
},
|
||||
};
|
||||
|
||||
// 토큰 갱신 중복 방지 플래그
|
||||
// ============================================
|
||||
// 토큰 갱신 로직 (중복 요청 방지)
|
||||
// ============================================
|
||||
let isRefreshing = false;
|
||||
let refreshPromise: Promise<string | null> | null = null;
|
||||
let refreshSubscribers: Array<(token: string) => void> = [];
|
||||
let failedRefreshSubscribers: Array<(error: Error) => void> = [];
|
||||
|
||||
// 토큰 갱신 함수
|
||||
const refreshToken = async (): Promise<string | null> => {
|
||||
// 이미 갱신 중이면 기존 Promise 반환
|
||||
if (isRefreshing && refreshPromise) {
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
const currentToken = TokenManager.getToken();
|
||||
if (!currentToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
`${API_BASE_URL}/auth/refresh`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${currentToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data?.success && response.data?.data?.token) {
|
||||
const newToken = response.data.data.token;
|
||||
TokenManager.setToken(newToken);
|
||||
console.log("[TokenManager] 토큰 갱신 성공");
|
||||
return newToken;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("[TokenManager] 토큰 갱신 실패:", error);
|
||||
return null;
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
refreshPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return refreshPromise;
|
||||
// 갱신 대기 중인 요청들에게 새 토큰 전달
|
||||
const onTokenRefreshed = (newToken: string) => {
|
||||
refreshSubscribers.forEach((callback) => callback(newToken));
|
||||
refreshSubscribers = [];
|
||||
failedRefreshSubscribers = [];
|
||||
};
|
||||
|
||||
// 자동 토큰 갱신 타이머
|
||||
let tokenRefreshTimer: NodeJS.Timeout | null = null;
|
||||
// 갱신 실패 시 대기 중인 요청들에게 에러 전달
|
||||
const onRefreshFailed = (error: Error) => {
|
||||
failedRefreshSubscribers.forEach((callback) => callback(error));
|
||||
refreshSubscribers = [];
|
||||
failedRefreshSubscribers = [];
|
||||
};
|
||||
|
||||
// 갱신 완료 대기 Promise 등록
|
||||
const waitForTokenRefresh = (): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
refreshSubscribers.push(resolve);
|
||||
failedRefreshSubscribers.push(reject);
|
||||
});
|
||||
};
|
||||
|
||||
const refreshToken = async (): Promise<string | null> => {
|
||||
try {
|
||||
const currentToken = TokenManager.getToken();
|
||||
if (!currentToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
`${API_BASE_URL}/auth/refresh`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${currentToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data?.success && response.data?.data?.token) {
|
||||
const newToken = response.data.data.token;
|
||||
TokenManager.setToken(newToken);
|
||||
return newToken;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 자동 토큰 갱신 (백그라운드)
|
||||
// ============================================
|
||||
let tokenRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// 자동 토큰 갱신 시작
|
||||
const startAutoRefresh = (): void => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// 기존 타이머 정리
|
||||
if (tokenRefreshTimer) {
|
||||
clearInterval(tokenRefreshTimer);
|
||||
}
|
||||
|
||||
// 10분마다 토큰 상태 확인
|
||||
// 5분마다 토큰 상태 확인 (기존 10분 → 5분으로 단축)
|
||||
tokenRefreshTimer = setInterval(
|
||||
async () => {
|
||||
const token = TokenManager.getToken();
|
||||
if (token && TokenManager.isTokenExpiringSoon(token)) {
|
||||
console.log("[TokenManager] 토큰 만료 임박, 자동 갱신 시작...");
|
||||
await refreshToken();
|
||||
}
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
); // 10분
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
|
||||
// 페이지 로드 시 즉시 확인
|
||||
const token = TokenManager.getToken();
|
||||
@@ -210,29 +201,49 @@ const startAutoRefresh = (): void => {
|
||||
}
|
||||
};
|
||||
|
||||
// 사용자 활동 감지 및 토큰 갱신
|
||||
// 페이지 포커스 복귀 시 토큰 갱신 체크 (백그라운드 탭 throttle 대응)
|
||||
const setupVisibilityRefresh = (): void => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) {
|
||||
const token = TokenManager.getToken();
|
||||
if (!token) return;
|
||||
|
||||
if (TokenManager.isTokenExpired(token)) {
|
||||
// 만료됐으면 갱신 시도
|
||||
refreshToken().then((newToken) => {
|
||||
if (!newToken) {
|
||||
redirectToLogin();
|
||||
}
|
||||
});
|
||||
} else if (TokenManager.isTokenExpiringSoon(token)) {
|
||||
refreshToken();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 사용자 활동 감지 기반 갱신
|
||||
const setupActivityBasedRefresh = (): void => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
let lastActivity = Date.now();
|
||||
let lastActivityCheck = Date.now();
|
||||
const activityThreshold = 5 * 60 * 1000; // 5분
|
||||
|
||||
const handleActivity = (): void => {
|
||||
const now = Date.now();
|
||||
// 마지막 활동으로부터 5분 이상 지났으면 토큰 상태 확인
|
||||
if (now - lastActivity > activityThreshold) {
|
||||
if (now - lastActivityCheck > activityThreshold) {
|
||||
const token = TokenManager.getToken();
|
||||
if (token && TokenManager.isTokenExpiringSoon(token)) {
|
||||
refreshToken();
|
||||
}
|
||||
lastActivityCheck = now;
|
||||
}
|
||||
lastActivity = now;
|
||||
};
|
||||
|
||||
// 사용자 활동 이벤트 감지
|
||||
["click", "keydown", "scroll", "mousemove"].forEach((event) => {
|
||||
// 너무 잦은 호출 방지를 위해 throttle 적용
|
||||
let throttleTimer: NodeJS.Timeout | null = null;
|
||||
["click", "keydown"].forEach((event) => {
|
||||
let throttleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
window.addEventListener(
|
||||
event,
|
||||
() => {
|
||||
@@ -240,7 +251,7 @@ const setupActivityBasedRefresh = (): void => {
|
||||
throttleTimer = setTimeout(() => {
|
||||
handleActivity();
|
||||
throttleTimer = null;
|
||||
}, 1000); // 1초 throttle
|
||||
}, 2000);
|
||||
}
|
||||
},
|
||||
{ passive: true },
|
||||
@@ -248,38 +259,56 @@ const setupActivityBasedRefresh = (): void => {
|
||||
});
|
||||
};
|
||||
|
||||
// 로그인 페이지 리다이렉트 (중복 방지)
|
||||
let isRedirecting = false;
|
||||
const redirectToLogin = (): void => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (isRedirecting) return;
|
||||
if (window.location.pathname === "/login") return;
|
||||
|
||||
isRedirecting = true;
|
||||
TokenManager.removeToken();
|
||||
window.location.href = "/login";
|
||||
};
|
||||
|
||||
// 클라이언트 사이드에서 자동 갱신 시작
|
||||
if (typeof window !== "undefined") {
|
||||
startAutoRefresh();
|
||||
setupVisibilityRefresh();
|
||||
setupActivityBasedRefresh();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Axios 인스턴스 생성
|
||||
// ============================================
|
||||
export const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 30000, // 30초로 증가 (다중 커넥션 처리 시간 고려)
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
withCredentials: true, // 쿠키 포함
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 요청 인터셉터
|
||||
// ============================================
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
// JWT 토큰 추가
|
||||
async (config: InternalAxiosRequestConfig) => {
|
||||
const token = TokenManager.getToken();
|
||||
|
||||
if (token && !TokenManager.isTokenExpired(token)) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
} else if (token && TokenManager.isTokenExpired(token)) {
|
||||
console.warn("❌ 토큰이 만료되었습니다.");
|
||||
// 토큰 제거
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("authToken");
|
||||
if (token) {
|
||||
if (!TokenManager.isTokenExpired(token)) {
|
||||
// 유효한 토큰 → 그대로 사용
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
} else {
|
||||
// 만료된 토큰 → 갱신 시도 후 사용
|
||||
const newToken = await refreshToken();
|
||||
if (newToken) {
|
||||
config.headers.Authorization = `Bearer ${newToken}`;
|
||||
}
|
||||
// 갱신 실패해도 요청은 보냄 (401 응답 인터셉터에서 처리)
|
||||
}
|
||||
} else {
|
||||
console.warn("⚠️ 토큰이 없습니다.");
|
||||
}
|
||||
|
||||
// FormData 요청 시 Content-Type 자동 처리
|
||||
@@ -287,18 +316,14 @@ apiClient.interceptors.request.use(
|
||||
delete config.headers["Content-Type"];
|
||||
}
|
||||
|
||||
// 언어 정보를 쿼리 파라미터에 추가 (GET 요청 시에만)
|
||||
// 언어 정보를 쿼리 파라미터에 추가 (GET 요청)
|
||||
if (config.method?.toUpperCase() === "GET") {
|
||||
// 우선순위: 전역 변수 > localStorage > 기본값
|
||||
let currentLang = "KR"; // 기본값
|
||||
let currentLang = "KR";
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
// 1순위: 전역 변수에서 확인
|
||||
if ((window as unknown as { __GLOBAL_USER_LANG?: string }).__GLOBAL_USER_LANG) {
|
||||
currentLang = (window as unknown as { __GLOBAL_USER_LANG: string }).__GLOBAL_USER_LANG;
|
||||
}
|
||||
// 2순위: localStorage에서 확인 (새 창이나 페이지 새로고침 시)
|
||||
else {
|
||||
} else {
|
||||
const storedLocale = localStorage.getItem("userLocale");
|
||||
if (storedLocale) {
|
||||
currentLang = storedLocale;
|
||||
@@ -316,19 +341,19 @@ apiClient.interceptors.request.use(
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
console.error("❌ API 요청 오류:", error);
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// 응답 인터셉터
|
||||
// ============================================
|
||||
apiClient.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
// 백엔드에서 보내주는 새로운 토큰 처리
|
||||
const newToken = response.headers["x-new-token"];
|
||||
if (newToken) {
|
||||
TokenManager.setToken(newToken);
|
||||
console.log("[TokenManager] 서버에서 새 토큰 수신, 저장 완료");
|
||||
}
|
||||
return response;
|
||||
},
|
||||
@@ -336,79 +361,80 @@ apiClient.interceptors.response.use(
|
||||
const status = error.response?.status;
|
||||
const url = error.config?.url;
|
||||
|
||||
// 409 에러 (중복 데이터)는 조용하게 처리
|
||||
// 409 에러 (중복 데이터) - 조용하게 처리
|
||||
if (status === 409) {
|
||||
// 중복 검사 API와 관계도 저장은 완전히 조용하게 처리
|
||||
if (url?.includes("/check-duplicate") || url?.includes("/dataflow-diagrams")) {
|
||||
// 중복 검사와 관계도 중복 이름은 정상적인 비즈니스 로직이므로 콘솔 출력 없음
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 일반 409 에러는 간단한 로그만 출력
|
||||
console.warn("데이터 중복:", {
|
||||
url: url,
|
||||
message: (error.response?.data as { message?: string })?.message || "중복된 데이터입니다.",
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 채번 규칙 미리보기 API 실패는 조용하게 처리 (화면 로드 시 자주 발생)
|
||||
// 채번 규칙 미리보기 API 실패는 조용하게 처리
|
||||
if (url?.includes("/numbering-rules/") && url?.includes("/preview")) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 다른 에러들은 기존처럼 상세 로그 출력
|
||||
console.error("API 응답 오류:", {
|
||||
status: status,
|
||||
statusText: error.response?.statusText,
|
||||
url: url,
|
||||
data: error.response?.data,
|
||||
message: error.message,
|
||||
});
|
||||
|
||||
// 401 에러 처리
|
||||
// 401 에러 처리 (핵심 개선)
|
||||
if (status === 401 && typeof window !== "undefined") {
|
||||
const errorData = error.response?.data as { error?: { code?: string } };
|
||||
const errorCode = errorData?.error?.code;
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
console.warn("[Auth] 401 오류 발생:", {
|
||||
url: url,
|
||||
errorCode: errorCode,
|
||||
token: TokenManager.getToken() ? "존재" : "없음",
|
||||
});
|
||||
// 이미 재시도한 요청이면 로그인으로
|
||||
if (originalRequest?._retry) {
|
||||
redirectToLogin();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 토큰 만료 에러인 경우 갱신 시도
|
||||
const originalRequest = error.config as typeof error.config & { _retry?: boolean };
|
||||
if (errorCode === "TOKEN_EXPIRED" && originalRequest && !originalRequest._retry) {
|
||||
console.log("[Auth] 토큰 만료, 갱신 시도...");
|
||||
originalRequest._retry = true;
|
||||
// 토큰 만료 에러 → 갱신 후 재시도
|
||||
if (errorCode === "TOKEN_EXPIRED" && originalRequest) {
|
||||
if (!isRefreshing) {
|
||||
isRefreshing = true;
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const newToken = await refreshToken();
|
||||
if (newToken && originalRequest) {
|
||||
try {
|
||||
const newToken = await refreshToken();
|
||||
if (newToken) {
|
||||
isRefreshing = false;
|
||||
onTokenRefreshed(newToken);
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
return apiClient.request(originalRequest);
|
||||
} else {
|
||||
isRefreshing = false;
|
||||
onRefreshFailed(new Error("토큰 갱신 실패"));
|
||||
redirectToLogin();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
} catch (refreshError) {
|
||||
isRefreshing = false;
|
||||
onRefreshFailed(refreshError as Error);
|
||||
redirectToLogin();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
} else {
|
||||
// 다른 요청이 이미 갱신 중 → 갱신 완료 대기 후 재시도
|
||||
try {
|
||||
const newToken = await waitForTokenRefresh();
|
||||
originalRequest._retry = true;
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
return apiClient.request(originalRequest);
|
||||
} catch {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
} catch (refreshError) {
|
||||
console.error("[Auth] 토큰 갱신 실패:", refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
// 토큰 갱신 실패 또는 다른 401 에러인 경우 로그아웃
|
||||
TokenManager.removeToken();
|
||||
|
||||
// 로그인 페이지가 아닌 경우에만 리다이렉트
|
||||
if (window.location.pathname !== "/login") {
|
||||
console.log("[Auth] 로그인 페이지로 리다이렉트");
|
||||
window.location.href = "/login";
|
||||
}
|
||||
// TOKEN_MISSING, INVALID_TOKEN 등 → 로그인으로
|
||||
redirectToLogin();
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
// 공통 응답 타입
|
||||
// ============================================
|
||||
// 공통 타입 및 헬퍼
|
||||
// ============================================
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
@@ -416,7 +442,6 @@ export interface ApiResponse<T = unknown> {
|
||||
errorCode?: string;
|
||||
}
|
||||
|
||||
// 사용자 정보 타입
|
||||
export interface UserInfo {
|
||||
userId: string;
|
||||
userName: string;
|
||||
@@ -430,13 +455,11 @@ export interface UserInfo {
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
// 현재 사용자 정보 조회
|
||||
export const getCurrentUser = async (): Promise<ApiResponse<UserInfo>> => {
|
||||
try {
|
||||
const response = await apiClient.get("/auth/me");
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("현재 사용자 정보 조회 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || error.message || "사용자 정보를 가져올 수 없습니다.",
|
||||
@@ -445,7 +468,6 @@ export const getCurrentUser = async (): Promise<ApiResponse<UserInfo>> => {
|
||||
}
|
||||
};
|
||||
|
||||
// API 호출 헬퍼 함수
|
||||
export const apiCall = async <T>(
|
||||
method: "GET" | "POST" | "PUT" | "DELETE",
|
||||
url: string,
|
||||
@@ -459,7 +481,6 @@ export const apiCall = async <T>(
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
console.error("API 호출 실패:", error);
|
||||
const axiosError = error as AxiosError;
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -114,6 +114,7 @@ import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트
|
||||
import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트
|
||||
import "./v2-split-line/SplitLineRenderer"; // V2 캔버스 분할선
|
||||
import "./v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰
|
||||
import "./v2-bom-item-editor/BomItemEditorRenderer"; // BOM 하위품목 편집기
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
|
||||
@@ -0,0 +1,709 @@
|
||||
"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;
|
||||
bom_id?: string;
|
||||
parent_detail_id: string | null;
|
||||
seq_no: number;
|
||||
level: number;
|
||||
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: BomItemNode[];
|
||||
_isNew?: boolean;
|
||||
_isDeleted?: boolean;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 트리 노드 행 렌더링 ───
|
||||
|
||||
interface TreeNodeRowProps {
|
||||
node: BomItemNode;
|
||||
depth: number;
|
||||
expanded: boolean;
|
||||
hasChildren: boolean;
|
||||
onToggle: () => void;
|
||||
onFieldChange: (tempId: string, field: string, value: string) => void;
|
||||
onDelete: (tempId: string) => void;
|
||||
onAddChild: (parentTempId: string) => void;
|
||||
}
|
||||
|
||||
function TreeNodeRow({
|
||||
node,
|
||||
depth,
|
||||
expanded,
|
||||
hasChildren,
|
||||
onToggle,
|
||||
onFieldChange,
|
||||
onDelete,
|
||||
onAddChild,
|
||||
}: TreeNodeRowProps) {
|
||||
const indentPx = depth * 32;
|
||||
|
||||
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>
|
||||
|
||||
{/* 품목코드 */}
|
||||
<span className="w-24 shrink-0 truncate font-mono text-xs font-medium">
|
||||
{node.child_item_code || "-"}
|
||||
</span>
|
||||
|
||||
{/* 품목명 */}
|
||||
<span className="min-w-[80px] flex-1 truncate text-xs">
|
||||
{node.child_item_name || "-"}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 수량 */}
|
||||
<Input
|
||||
value={node.quantity}
|
||||
onChange={(e) =>
|
||||
onFieldChange(node.tempId, "quantity", e.target.value)
|
||||
}
|
||||
className="h-7 w-16 shrink-0 text-center text-xs"
|
||||
placeholder="수량"
|
||||
/>
|
||||
|
||||
{/* 품목구분 셀렉트 */}
|
||||
<Select
|
||||
value={node.child_item_type || ""}
|
||||
onValueChange={(val) =>
|
||||
onFieldChange(node.tempId, "child_item_type", val)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-20 shrink-0 text-xs">
|
||||
<SelectValue placeholder="구분" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="assembly">조립</SelectItem>
|
||||
<SelectItem value="process">공정</SelectItem>
|
||||
<SelectItem value="purchase">구매</SelectItem>
|
||||
<SelectItem value="outsource">외주</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 하위 추가 버튼 */}
|
||||
<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,
|
||||
);
|
||||
|
||||
// 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]);
|
||||
|
||||
// ─── 데이터 로드 ───
|
||||
|
||||
const loadBomDetails = useCallback(
|
||||
async (id: string) => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await entityJoinApi.getTableDataWithJoins("bom_detail", {
|
||||
page: 1,
|
||||
size: 500,
|
||||
search: { bom_id: id },
|
||||
sortBy: "seq_no",
|
||||
sortOrder: "asc",
|
||||
enableEntityJoin: true,
|
||||
});
|
||||
|
||||
const rows = result.data || [];
|
||||
const tree = buildTree(rows);
|
||||
setTreeData(tree);
|
||||
|
||||
// 1레벨 기본 펼침
|
||||
const firstLevelIds = new Set<string>(
|
||||
tree.map((n) => n.tempId || n.id || ""),
|
||||
);
|
||||
setExpandedNodes(firstLevelIds);
|
||||
} catch (error) {
|
||||
console.error("[BomItemEditor] 데이터 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
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,
|
||||
bom_id: item.bom_id,
|
||||
parent_detail_id: item.parent_detail_id || null,
|
||||
seq_no: Number(item.seq_no) || 0,
|
||||
level: Number(item.level) || 0,
|
||||
child_item_id: item.child_item_id || "",
|
||||
child_item_code: item.child_item_code || "",
|
||||
child_item_name: item.child_item_name || "",
|
||||
child_item_type: item.child_item_type || "",
|
||||
quantity: item.quantity || "1",
|
||||
unit: item.unit || "EA",
|
||||
loss_rate: item.loss_rate || "0",
|
||||
remark: item.remark || "",
|
||||
children: [],
|
||||
});
|
||||
});
|
||||
|
||||
flatData.forEach((item) => {
|
||||
const nodeId = item.id || "";
|
||||
const node = nodeMap.get(nodeId);
|
||||
if (!node) return;
|
||||
|
||||
if (item.parent_detail_id && nodeMap.has(item.parent_detail_id)) {
|
||||
nodeMap.get(item.parent_detail_id)!.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({
|
||||
id: node.id,
|
||||
tempId: node.tempId,
|
||||
bom_id: node.bom_id,
|
||||
parent_detail_id: parentId,
|
||||
seq_no: String(idx + 1),
|
||||
level: String(level),
|
||||
child_item_id: node.child_item_id,
|
||||
child_item_code: node.child_item_code,
|
||||
child_item_name: node.child_item_name,
|
||||
child_item_type: node.child_item_type,
|
||||
quantity: node.quantity,
|
||||
unit: node.unit,
|
||||
loss_rate: node.loss_rate,
|
||||
remark: node.remark,
|
||||
_isNew: node._isNew,
|
||||
_targetTable: "bom_detail",
|
||||
});
|
||||
if (node.children.length > 0) {
|
||||
traverse(node.children, node.id || node.tempId, level + 1);
|
||||
}
|
||||
});
|
||||
};
|
||||
traverse(nodes, null, 0);
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
// 트리 변경 시 부모에게 알림
|
||||
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;
|
||||
};
|
||||
|
||||
// 필드 변경
|
||||
const handleFieldChange = useCallback(
|
||||
(tempId: string, field: string, value: string) => {
|
||||
const newTree = findAndUpdate(treeData, tempId, (node) => ({
|
||||
...node,
|
||||
[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) => {
|
||||
const newNode: BomItemNode = {
|
||||
tempId: generateTempId(),
|
||||
parent_detail_id: null,
|
||||
seq_no: 0,
|
||||
level: 0,
|
||||
child_item_id: item.id,
|
||||
child_item_code: item.item_number || "",
|
||||
child_item_name: item.item_name || "",
|
||||
child_item_type: item.type || "",
|
||||
quantity: "1",
|
||||
unit: item.unit || "EA",
|
||||
loss_rate: "0",
|
||||
remark: "",
|
||||
children: [],
|
||||
_isNew: true,
|
||||
};
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
// 펼침/접기 토글
|
||||
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}
|
||||
onToggle={() => toggleExpand(node.tempId)}
|
||||
onFieldChange={handleFieldChange}
|
||||
onDelete={handleDelete}
|
||||
onAddChild={handleAddChild}
|
||||
/>
|
||||
{isExpanded &&
|
||||
node.children.length > 0 &&
|
||||
renderNodes(node.children, depth + 1)}
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// ─── 디자인 모드 ───
|
||||
|
||||
if (isDesignMode) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 메인 렌더링 ───
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold">하위 품목 구성</h4>
|
||||
<Button
|
||||
onClick={handleAddRoot}
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
품목추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 트리 목록 */}
|
||||
<div className="space-y-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<span className="text-muted-foreground text-sm">로딩 중...</span>
|
||||
</div>
|
||||
) : treeData.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-md border border-dashed py-8">
|
||||
<Package className="text-muted-foreground mb-2 h-8 w-8" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
하위 품목이 없습니다.
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
"품목추가" 버튼을 눌러 추가하세요.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
renderNodes(treeData, 0)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 품목 검색 모달 */}
|
||||
<ItemSearchModal
|
||||
open={itemSearchOpen}
|
||||
onClose={() => setItemSearchOpen(false)}
|
||||
onSelect={handleItemSelect}
|
||||
companyCode={companyCode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BomItemEditorComponent;
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { BomItemEditorComponent } from "./BomItemEditorComponent";
|
||||
import { V2BomItemEditorDefinition } from "./index";
|
||||
|
||||
export class BomItemEditorRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = V2BomItemEditorDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <BomItemEditorComponent {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
BomItemEditorRenderer.registerSelf();
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
setTimeout(() => {
|
||||
BomItemEditorRenderer.registerSelf();
|
||||
}, 0);
|
||||
}
|
||||
30
frontend/lib/registry/components/v2-bom-item-editor/index.ts
Normal file
30
frontend/lib/registry/components/v2-bom-item-editor/index.ts
Normal 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;
|
||||
@@ -123,8 +123,8 @@ export const SplitLineComponent: React.FC<SplitLineComponentProps> = ({
|
||||
const startOffset = dragOffset;
|
||||
const scaleFactor = getScaleFactor();
|
||||
const cw = detectCanvasWidth();
|
||||
const MIN_POS = 50;
|
||||
const MAX_POS = cw - 50;
|
||||
const MIN_POS = Math.max(50, cw * 0.15);
|
||||
const MAX_POS = cw - Math.max(50, cw * 0.15);
|
||||
|
||||
setIsDragging(true);
|
||||
setCanvasSplit({ isDragging: true });
|
||||
|
||||
Reference in New Issue
Block a user