diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts
index e454742a..4b3d212a 100644
--- a/backend-node/src/app.ts
+++ b/backend-node/src/app.ts
@@ -105,6 +105,7 @@ import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRou
import scheduleRoutes from "./routes/scheduleRoutes"; // 스케줄 자동 생성
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
+import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
@@ -289,6 +290,7 @@ app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여
app.use("/api/schedule", scheduleRoutes); // 스케줄 자동 생성
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
+app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
app.use("/api/departments", departmentRoutes); // 부서 관리
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리
diff --git a/backend-node/src/controllers/bomController.ts b/backend-node/src/controllers/bomController.ts
new file mode 100644
index 00000000..870833fc
--- /dev/null
+++ b/backend-node/src/controllers/bomController.ts
@@ -0,0 +1,111 @@
+/**
+ * BOM 이력/버전 관리 컨트롤러
+ */
+
+import { Request, Response } from "express";
+import { logger } from "../utils/logger";
+import * as bomService from "../services/bomService";
+
+// ─── 이력 (History) ─────────────────────────────
+
+export async function getBomHistory(req: Request, res: Response) {
+ try {
+ const { bomId } = req.params;
+ const companyCode = (req as any).user?.companyCode || "*";
+ const tableName = (req.query.tableName as string) || undefined;
+
+ const data = await bomService.getBomHistory(bomId, companyCode, tableName);
+ res.json({ success: true, data });
+ } catch (error: any) {
+ logger.error("BOM 이력 조회 실패", { error: error.message });
+ res.status(500).json({ success: false, message: error.message });
+ }
+}
+
+export async function addBomHistory(req: Request, res: Response) {
+ try {
+ const { bomId } = req.params;
+ const companyCode = (req as any).user?.companyCode || "*";
+ const changedBy = (req as any).user?.userName || (req as any).user?.userId || "";
+
+ const { change_type, change_description, revision, version, tableName } = req.body;
+ if (!change_type) {
+ res.status(400).json({ success: false, message: "change_type은 필수입니다" });
+ return;
+ }
+
+ const result = await bomService.addBomHistory(bomId, companyCode, {
+ change_type,
+ change_description,
+ revision,
+ version,
+ changed_by: changedBy,
+ }, tableName);
+ res.json({ success: true, data: result });
+ } catch (error: any) {
+ logger.error("BOM 이력 등록 실패", { error: error.message });
+ res.status(500).json({ success: false, message: error.message });
+ }
+}
+
+// ─── 버전 (Version) ─────────────────────────────
+
+export async function getBomVersions(req: Request, res: Response) {
+ try {
+ const { bomId } = req.params;
+ const companyCode = (req as any).user?.companyCode || "*";
+ const tableName = (req.query.tableName as string) || undefined;
+
+ const data = await bomService.getBomVersions(bomId, companyCode, tableName);
+ res.json({ success: true, data });
+ } catch (error: any) {
+ logger.error("BOM 버전 목록 조회 실패", { error: error.message });
+ res.status(500).json({ success: false, message: error.message });
+ }
+}
+
+export async function createBomVersion(req: Request, res: Response) {
+ try {
+ const { bomId } = req.params;
+ const companyCode = (req as any).user?.companyCode || "*";
+ const createdBy = (req as any).user?.userName || (req as any).user?.userId || "";
+ const { tableName, detailTable } = req.body || {};
+
+ const result = await bomService.createBomVersion(bomId, companyCode, createdBy, tableName, detailTable);
+ res.json({ success: true, data: result });
+ } catch (error: any) {
+ logger.error("BOM 버전 생성 실패", { error: error.message });
+ res.status(500).json({ success: false, message: error.message });
+ }
+}
+
+export async function loadBomVersion(req: Request, res: Response) {
+ try {
+ const { bomId, versionId } = req.params;
+ const companyCode = (req as any).user?.companyCode || "*";
+ const { tableName, detailTable } = req.body || {};
+
+ const result = await bomService.loadBomVersion(bomId, versionId, companyCode, tableName, detailTable);
+ res.json({ success: true, data: result });
+ } catch (error: any) {
+ logger.error("BOM 버전 불러오기 실패", { error: error.message });
+ res.status(500).json({ success: false, message: error.message });
+ }
+}
+
+export async function deleteBomVersion(req: Request, res: Response) {
+ try {
+ const { bomId, versionId } = req.params;
+ const tableName = (req.query.tableName as string) || undefined;
+
+ const deleted = await bomService.deleteBomVersion(bomId, versionId, tableName);
+ if (!deleted) {
+ res.status(404).json({ success: false, message: "버전을 찾을 수 없습니다" });
+ return;
+ }
+ res.json({ success: true });
+ } catch (error: any) {
+ logger.error("BOM 버전 삭제 실패", { error: error.message });
+ res.status(500).json({ success: false, message: error.message });
+ }
+}
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/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts
index a74ba8d6..0cb7a850 100644
--- a/backend-node/src/controllers/tableManagementController.ts
+++ b/backend-node/src/controllers/tableManagementController.ts
@@ -958,13 +958,14 @@ export async function addTableData(
}
// 데이터 추가
- await tableManagementService.addTableData(tableName, data);
+ const result = await tableManagementService.addTableData(tableName, data);
- logger.info(`테이블 데이터 추가 완료: ${tableName}`);
+ logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${result.insertedId}`);
- const response: ApiResponse = {
+ const response: ApiResponse<{ id: string | null }> = {
success: true,
message: "테이블 데이터를 성공적으로 추가했습니다.",
+ data: { id: result.insertedId },
};
res.status(201).json(response);
diff --git a/backend-node/src/routes/bomRoutes.ts b/backend-node/src/routes/bomRoutes.ts
new file mode 100644
index 00000000..a6d4fa10
--- /dev/null
+++ b/backend-node/src/routes/bomRoutes.ts
@@ -0,0 +1,23 @@
+/**
+ * BOM 이력/버전 관리 라우트
+ */
+
+import { Router } from "express";
+import { authenticateToken } from "../middleware/authMiddleware";
+import * as bomController from "../controllers/bomController";
+
+const router = Router();
+
+router.use(authenticateToken);
+
+// 이력
+router.get("/:bomId/history", bomController.getBomHistory);
+router.post("/:bomId/history", bomController.addBomHistory);
+
+// 버전
+router.get("/:bomId/versions", bomController.getBomVersions);
+router.post("/:bomId/versions", bomController.createBomVersion);
+router.post("/:bomId/versions/:versionId/load", bomController.loadBomVersion);
+router.delete("/:bomId/versions/:versionId", bomController.deleteBomVersion);
+
+export default router;
diff --git a/backend-node/src/services/bomService.ts b/backend-node/src/services/bomService.ts
new file mode 100644
index 00000000..687326df
--- /dev/null
+++ b/backend-node/src/services/bomService.ts
@@ -0,0 +1,181 @@
+/**
+ * BOM 이력 및 버전 관리 서비스
+ * 설정 패널에서 지정한 테이블명을 동적으로 사용
+ */
+
+import { query, queryOne, transaction } from "../database/db";
+import { logger } from "../utils/logger";
+
+// SQL 인젝션 방지: 테이블명은 알파벳, 숫자, 언더스코어만 허용
+function safeTableName(name: string, fallback: string): string {
+ if (!name || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) return fallback;
+ return name;
+}
+
+// ─── 이력 (History) ─────────────────────────────
+
+export async function getBomHistory(bomId: string, companyCode: string, tableName?: string) {
+ const table = safeTableName(tableName || "", "bom_history");
+ const sql = companyCode === "*"
+ ? `SELECT * FROM ${table} WHERE bom_id = $1 ORDER BY changed_date DESC`
+ : `SELECT * FROM ${table} WHERE bom_id = $1 AND company_code = $2 ORDER BY changed_date DESC`;
+ const params = companyCode === "*" ? [bomId] : [bomId, companyCode];
+ return query(sql, params);
+}
+
+export async function addBomHistory(
+ bomId: string,
+ companyCode: string,
+ data: {
+ revision?: string;
+ version?: string;
+ change_type: string;
+ change_description?: string;
+ changed_by?: string;
+ },
+ tableName?: string,
+) {
+ const table = safeTableName(tableName || "", "bom_history");
+ const sql = `
+ INSERT INTO ${table} (bom_id, revision, version, change_type, change_description, changed_by, company_code)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
+ RETURNING *
+ `;
+ return queryOne(sql, [
+ bomId,
+ data.revision || null,
+ data.version || null,
+ data.change_type,
+ data.change_description || null,
+ data.changed_by || null,
+ companyCode,
+ ]);
+}
+
+// ─── 버전 (Version) ─────────────────────────────
+
+export async function getBomVersions(bomId: string, companyCode: string, tableName?: string) {
+ const table = safeTableName(tableName || "", "bom_version");
+ const sql = companyCode === "*"
+ ? `SELECT * FROM ${table} WHERE bom_id = $1 ORDER BY created_date DESC`
+ : `SELECT * FROM ${table} WHERE bom_id = $1 AND company_code = $2 ORDER BY created_date DESC`;
+ const params = companyCode === "*" ? [bomId] : [bomId, companyCode];
+ return query(sql, params);
+}
+
+export async function createBomVersion(
+ bomId: string, companyCode: string, createdBy: string,
+ versionTableName?: string, detailTableName?: string,
+) {
+ const vTable = safeTableName(versionTableName || "", "bom_version");
+ const dTable = safeTableName(detailTableName || "", "bom_detail");
+
+ return transaction(async (client) => {
+ const bomRow = await client.query(`SELECT * FROM bom WHERE id = $1`, [bomId]);
+ if (bomRow.rows.length === 0) throw new Error("BOM을 찾을 수 없습니다");
+ const bomData = bomRow.rows[0];
+
+ const detailRows = await client.query(
+ `SELECT * FROM ${dTable} WHERE bom_id = $1 ORDER BY parent_detail_id NULLS FIRST, id`,
+ [bomId],
+ );
+
+ const lastVersion = await client.query(
+ `SELECT version_name FROM ${vTable} WHERE bom_id = $1 ORDER BY created_date DESC LIMIT 1`,
+ [bomId],
+ );
+ let nextVersionNum = 1;
+ if (lastVersion.rows.length > 0) {
+ const parsed = parseFloat(lastVersion.rows[0].version_name);
+ if (!isNaN(parsed)) nextVersionNum = Math.floor(parsed) + 1;
+ }
+ const versionName = `${nextVersionNum}.0`;
+
+ const snapshot = {
+ bom: bomData,
+ details: detailRows.rows,
+ detailTable: dTable,
+ created_at: new Date().toISOString(),
+ };
+
+ const insertSql = `
+ INSERT INTO ${vTable} (bom_id, version_name, revision, status, snapshot_data, created_by, company_code)
+ VALUES ($1, $2, $3, 'developing', $4, $5, $6)
+ RETURNING *
+ `;
+ const result = await client.query(insertSql, [
+ bomId,
+ versionName,
+ bomData.revision ? parseInt(bomData.revision, 10) || 0 : 0,
+ JSON.stringify(snapshot),
+ createdBy,
+ companyCode,
+ ]);
+
+ logger.info("BOM 버전 생성", { bomId, versionName, companyCode, vTable, dTable });
+ return result.rows[0];
+ });
+}
+
+export async function loadBomVersion(
+ bomId: string, versionId: string, companyCode: string,
+ versionTableName?: string, detailTableName?: string,
+) {
+ const vTable = safeTableName(versionTableName || "", "bom_version");
+ const dTable = safeTableName(detailTableName || "", "bom_detail");
+
+ return transaction(async (client) => {
+ const verRow = await client.query(
+ `SELECT * FROM ${vTable} WHERE id = $1 AND bom_id = $2`,
+ [versionId, bomId],
+ );
+ if (verRow.rows.length === 0) throw new Error("버전을 찾을 수 없습니다");
+
+ const snapshot = verRow.rows[0].snapshot_data;
+ if (!snapshot || !snapshot.bom) throw new Error("스냅샷 데이터가 없습니다");
+
+ // 스냅샷에 기록된 detailTable을 우선 사용, 없으면 파라미터 사용
+ const snapshotDetailTable = safeTableName(snapshot.detailTable || "", dTable);
+
+ await client.query(`DELETE FROM ${snapshotDetailTable} WHERE bom_id = $1`, [bomId]);
+
+ const b = snapshot.bom;
+ await client.query(
+ `UPDATE bom SET base_qty = $1, unit = $2, revision = $3, remark = $4 WHERE id = $5`,
+ [b.base_qty || null, b.unit || null, b.revision || null, b.remark || null, bomId],
+ );
+
+ const oldToNew: Record = {};
+ for (const d of snapshot.details || []) {
+ const insertResult = await client.query(
+ `INSERT INTO ${snapshotDetailTable} (bom_id, parent_detail_id, child_item_id, quantity, unit, process_type, loss_rate, remark, level, base_qty, revision, company_code)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id`,
+ [
+ bomId,
+ d.parent_detail_id ? (oldToNew[d.parent_detail_id] || null) : null,
+ d.child_item_id,
+ d.quantity,
+ d.unit,
+ d.process_type,
+ d.loss_rate,
+ d.remark,
+ d.level,
+ d.base_qty,
+ d.revision,
+ companyCode,
+ ],
+ );
+ oldToNew[d.id] = insertResult.rows[0].id;
+ }
+
+ logger.info("BOM 버전 불러오기 완료", { bomId, versionId, vTable, snapshotDetailTable });
+ return { restored: true, versionName: verRow.rows[0].version_name };
+ });
+}
+
+export async function deleteBomVersion(bomId: string, versionId: string, tableName?: string) {
+ const table = safeTableName(tableName || "", "bom_version");
+ const sql = `DELETE FROM ${table} WHERE id = $1 AND bom_id = $2 RETURNING id`;
+ const result = await query(sql, [versionId, bomId]);
+ return result.length > 0;
+}
diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts
index 76459ec6..96f97fac 100644
--- a/backend-node/src/services/tableManagementService.ts
+++ b/backend-node/src/services/tableManagementService.ts
@@ -2636,7 +2636,7 @@ export class TableManagementService {
async addTableData(
tableName: string,
data: Record
- ): Promise<{ skippedColumns: string[]; savedColumns: string[] }> {
+ ): Promise<{ skippedColumns: string[]; savedColumns: string[]; insertedId: string | null }> {
try {
logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`);
logger.info(`추가할 데이터:`, data);
@@ -2749,19 +2749,21 @@ export class TableManagementService {
const insertQuery = `
INSERT INTO "${tableName}" (${columnNames})
VALUES (${placeholders})
+ RETURNING id
`;
logger.info(`실행할 쿼리: ${insertQuery}`);
logger.info(`쿼리 파라미터:`, values);
- await query(insertQuery, values);
+ const insertResult = await query(insertQuery, values) as any[];
+ const insertedId = insertResult?.[0]?.id ?? null;
- logger.info(`테이블 데이터 추가 완료: ${tableName}`);
+ logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${insertedId}`);
- // 무시된 컬럼과 저장된 컬럼 정보 반환
return {
skippedColumns,
savedColumns: existingColumns,
+ insertedId,
};
} catch (error) {
logger.error(`테이블 데이터 추가 오류: ${tableName}`, error);
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/screen/panels/V2PropertiesPanel.tsx b/frontend/components/screen/panels/V2PropertiesPanel.tsx
index cb0ca751..2999ed74 100644
--- a/frontend/components/screen/panels/V2PropertiesPanel.tsx
+++ b/frontend/components/screen/panels/V2PropertiesPanel.tsx
@@ -217,6 +217,7 @@ export const V2PropertiesPanel: React.FC = ({
"v2-biz": require("@/components/v2/config-panels/V2BizConfigPanel").V2BizConfigPanel,
"v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel").V2HierarchyConfigPanel,
"v2-bom-item-editor": require("@/components/v2/config-panels/V2BomItemEditorConfigPanel").V2BomItemEditorConfigPanel,
+ "v2-bom-tree": require("@/components/v2/config-panels/V2BomTreeConfigPanel").V2BomTreeConfigPanel,
};
const V2ConfigPanel = v2ConfigPanels[componentId];
@@ -240,7 +241,7 @@ export const V2PropertiesPanel: React.FC = ({
if (componentId === "v2-list") {
extraProps.currentTableName = currentTableName;
}
- if (componentId === "v2-bom-item-editor") {
+ if (componentId === "v2-bom-item-editor" || componentId === "v2-bom-tree") {
extraProps.currentTableName = currentTableName;
extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
}
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/V2BomTreeConfigPanel.tsx b/frontend/components/v2/config-panels/V2BomTreeConfigPanel.tsx
new file mode 100644
index 00000000..7c8c3ed1
--- /dev/null
+++ b/frontend/components/v2/config-panels/V2BomTreeConfigPanel.tsx
@@ -0,0 +1,1074 @@
+"use client";
+
+/**
+ * BOM 트리 뷰 설정 패널
+ *
+ * V2BomItemEditorConfigPanel 구조 기반:
+ * - 기본 탭: 디테일 테이블 + 엔티티 선택 + 트리 설정
+ * - 컬럼 탭: 소스 표시 컬럼 + 디테일 컬럼 + 선택된 컬럼 상세
+ */
+
+import React, { useState, useEffect, useMemo, useCallback } from "react";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Separator } from "@/components/ui/separator";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import {
+ Database,
+ Link2,
+ Trash2,
+ GripVertical,
+ ArrowRight,
+ ChevronDown,
+ ChevronRight,
+ Eye,
+ EyeOff,
+ Check,
+ ChevronsUpDown,
+ GitBranch,
+} from "lucide-react";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { tableTypeApi } from "@/lib/api/screen";
+import { tableManagementApi } from "@/lib/api/tableManagement";
+import { cn } from "@/lib/utils";
+
+interface TableRelation {
+ tableName: string;
+ tableLabel: string;
+ foreignKeyColumn: string;
+ referenceColumn: string;
+}
+
+interface ColumnOption {
+ columnName: string;
+ displayName: string;
+ inputType?: string;
+ detailSettings?: {
+ codeGroup?: string;
+ referenceTable?: string;
+ referenceColumn?: string;
+ displayColumn?: string;
+ format?: string;
+ };
+}
+
+interface EntityColumnOption {
+ columnName: string;
+ displayName: string;
+ referenceTable?: string;
+ referenceColumn?: string;
+ displayColumn?: string;
+}
+
+interface TreeColumnConfig {
+ key: string;
+ title: string;
+ width?: string;
+ visible?: boolean;
+ hidden?: boolean;
+ isSourceDisplay?: boolean;
+}
+
+interface BomTreeConfig {
+ detailTable?: string;
+ foreignKey?: string;
+ parentKey?: string;
+
+ historyTable?: string;
+ versionTable?: string;
+
+ dataSource?: {
+ sourceTable?: string;
+ foreignKey?: string;
+ referenceKey?: string;
+ displayColumn?: string;
+ };
+
+ columns: TreeColumnConfig[];
+
+ features?: {
+ showExpandAll?: boolean;
+ showHeader?: boolean;
+ showQuantity?: boolean;
+ showLossRate?: boolean;
+ showHistory?: boolean;
+ showVersion?: boolean;
+ };
+}
+
+interface V2BomTreeConfigPanelProps {
+ config: BomTreeConfig;
+ onChange: (config: BomTreeConfig) => void;
+ currentTableName?: string;
+ screenTableName?: string;
+}
+
+export function V2BomTreeConfigPanel({
+ config: propConfig,
+ onChange,
+ currentTableName: propCurrentTableName,
+ screenTableName,
+}: V2BomTreeConfigPanelProps) {
+ const currentTableName = screenTableName || propCurrentTableName;
+
+ const config: BomTreeConfig = useMemo(
+ () => ({
+ columns: [],
+ ...propConfig,
+ dataSource: { ...propConfig?.dataSource },
+ features: {
+ showExpandAll: true,
+ showHeader: true,
+ showQuantity: true,
+ showLossRate: true,
+ ...propConfig?.features,
+ },
+ }),
+ [propConfig],
+ );
+
+ const [detailTableColumns, setDetailTableColumns] = useState([]);
+ const [entityColumns, setEntityColumns] = useState([]);
+ const [sourceTableColumns, setSourceTableColumns] = useState([]);
+ const [allTables, setAllTables] = useState<{ tableName: string; displayName: string }[]>([]);
+ const [relatedTables, setRelatedTables] = useState([]);
+ const [loadingColumns, setLoadingColumns] = useState(false);
+ const [loadingSourceColumns, setLoadingSourceColumns] = useState(false);
+ const [loadingTables, setLoadingTables] = useState(false);
+ const [loadingRelations, setLoadingRelations] = useState(false);
+ const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
+ const [expandedColumn, setExpandedColumn] = useState(null);
+
+ const updateConfig = useCallback(
+ (updates: Partial) => {
+ onChange({ ...config, ...updates });
+ },
+ [config, onChange],
+ );
+
+ const updateFeatures = useCallback(
+ (field: string, value: any) => {
+ updateConfig({ features: { ...config.features, [field]: value } });
+ },
+ [config.features, updateConfig],
+ );
+
+ // 전체 테이블 목록 로드
+ useEffect(() => {
+ const loadTables = async () => {
+ setLoadingTables(true);
+ try {
+ const response = await tableManagementApi.getTableList();
+ if (response.success && response.data) {
+ setAllTables(
+ response.data.map((t: any) => ({
+ tableName: t.tableName || t.table_name,
+ displayName: t.displayName || t.table_label || t.tableName || t.table_name,
+ })),
+ );
+ }
+ } catch (error) {
+ console.error("테이블 목록 로드 실패:", error);
+ } finally {
+ setLoadingTables(false);
+ }
+ };
+ loadTables();
+ }, []);
+
+ // 연관 테이블 로드
+ useEffect(() => {
+ const loadRelatedTables = async () => {
+ const baseTable = currentTableName;
+ if (!baseTable) {
+ setRelatedTables([]);
+ return;
+ }
+ setLoadingRelations(true);
+ try {
+ const { apiClient } = await import("@/lib/api/client");
+ const response = await apiClient.get(
+ `/table-management/columns/${baseTable}/referenced-by`,
+ );
+ if (response.data.success && response.data.data) {
+ setRelatedTables(
+ response.data.data.map((rel: any) => ({
+ tableName: rel.tableName || rel.table_name,
+ tableLabel: rel.tableLabel || rel.table_label || rel.tableName || rel.table_name,
+ foreignKeyColumn: rel.columnName || rel.column_name,
+ referenceColumn: rel.referenceColumn || rel.reference_column || "id",
+ })),
+ );
+ }
+ } catch (error) {
+ console.error("연관 테이블 로드 실패:", error);
+ setRelatedTables([]);
+ } finally {
+ setLoadingRelations(false);
+ }
+ };
+ loadRelatedTables();
+ }, [currentTableName]);
+
+ // 디테일 테이블 선택
+ const handleDetailTableSelect = useCallback(
+ (tableName: string) => {
+ const relation = relatedTables.find((r) => r.tableName === tableName);
+ updateConfig({
+ detailTable: tableName,
+ foreignKey: relation?.foreignKeyColumn || config.foreignKey,
+ });
+ },
+ [relatedTables, config.foreignKey, updateConfig],
+ );
+
+ // 디테일 테이블 컬럼 로드
+ useEffect(() => {
+ const loadColumns = async () => {
+ if (!config.detailTable) {
+ setDetailTableColumns([]);
+ setEntityColumns([]);
+ return;
+ }
+ setLoadingColumns(true);
+ try {
+ const columnData = await tableTypeApi.getColumns(config.detailTable);
+ const cols: ColumnOption[] = [];
+ const entityCols: EntityColumnOption[] = [];
+
+ for (const c of columnData) {
+ let detailSettings: any = null;
+ if (c.detailSettings) {
+ try {
+ detailSettings =
+ typeof c.detailSettings === "string" ? JSON.parse(c.detailSettings) : c.detailSettings;
+ } catch {
+ // ignore
+ }
+ }
+
+ const col: ColumnOption = {
+ columnName: c.columnName || c.column_name,
+ displayName: c.displayName || c.columnLabel || c.columnName || c.column_name,
+ inputType: c.inputType || c.input_type,
+ detailSettings: detailSettings
+ ? {
+ codeGroup: detailSettings.codeGroup,
+ referenceTable: detailSettings.referenceTable,
+ referenceColumn: detailSettings.referenceColumn,
+ displayColumn: detailSettings.displayColumn,
+ format: detailSettings.format,
+ }
+ : undefined,
+ };
+ cols.push(col);
+
+ if (col.inputType === "entity") {
+ const refTable = detailSettings?.referenceTable || c.referenceTable;
+ if (refTable) {
+ entityCols.push({
+ columnName: col.columnName,
+ displayName: col.displayName,
+ referenceTable: refTable,
+ referenceColumn: detailSettings?.referenceColumn || c.referenceColumn || "id",
+ displayColumn: detailSettings?.displayColumn || c.displayColumn,
+ });
+ }
+ }
+ }
+
+ setDetailTableColumns(cols);
+ setEntityColumns(entityCols);
+ } catch (error) {
+ console.error("컬럼 로드 실패:", error);
+ setDetailTableColumns([]);
+ setEntityColumns([]);
+ } finally {
+ setLoadingColumns(false);
+ }
+ };
+ loadColumns();
+ }, [config.detailTable]);
+
+ // 소스(엔티티) 테이블 컬럼 로드
+ useEffect(() => {
+ const loadSourceColumns = async () => {
+ const sourceTable = config.dataSource?.sourceTable;
+ if (!sourceTable) {
+ setSourceTableColumns([]);
+ return;
+ }
+ setLoadingSourceColumns(true);
+ try {
+ const columnData = await tableTypeApi.getColumns(sourceTable);
+ setSourceTableColumns(
+ columnData.map((c: any) => ({
+ columnName: c.columnName || c.column_name,
+ displayName: c.displayName || c.columnLabel || c.columnName || c.column_name,
+ inputType: c.inputType || c.input_type,
+ })),
+ );
+ } catch (error) {
+ console.error("소스 테이블 컬럼 로드 실패:", error);
+ setSourceTableColumns([]);
+ } finally {
+ setLoadingSourceColumns(false);
+ }
+ };
+ loadSourceColumns();
+ }, [config.dataSource?.sourceTable]);
+
+ // 엔티티 컬럼 선택 시 소스 테이블 자동 설정
+ const handleEntityColumnSelect = (columnName: string) => {
+ const selectedEntity = entityColumns.find((c) => c.columnName === columnName);
+ if (selectedEntity) {
+ updateConfig({
+ dataSource: {
+ ...config.dataSource,
+ sourceTable: selectedEntity.referenceTable || "",
+ foreignKey: selectedEntity.columnName,
+ referenceKey: selectedEntity.referenceColumn || "id",
+ displayColumn: selectedEntity.displayColumn,
+ },
+ });
+ }
+ };
+
+ // 컬럼 토글
+ const toggleDetailColumn = (column: ColumnOption) => {
+ const exists = config.columns.findIndex((c) => c.key === column.columnName && !c.isSourceDisplay);
+ if (exists >= 0) {
+ updateConfig({ columns: config.columns.filter((c) => c.key !== column.columnName || c.isSourceDisplay) });
+ } else {
+ const newCol: TreeColumnConfig = {
+ key: column.columnName,
+ title: column.displayName,
+ width: "auto",
+ visible: true,
+ };
+ updateConfig({ columns: [...config.columns, newCol] });
+ }
+ };
+
+ const toggleSourceDisplayColumn = (column: ColumnOption) => {
+ const exists = config.columns.some((c) => c.key === column.columnName && c.isSourceDisplay);
+ if (exists) {
+ updateConfig({ columns: config.columns.filter((c) => c.key !== column.columnName) });
+ } else {
+ const newCol: TreeColumnConfig = {
+ key: column.columnName,
+ title: column.displayName,
+ width: "auto",
+ visible: true,
+ isSourceDisplay: true,
+ };
+ updateConfig({ columns: [...config.columns, newCol] });
+ }
+ };
+
+ const isColumnAdded = (columnName: string) =>
+ config.columns.some((c) => c.key === columnName && !c.isSourceDisplay);
+
+ const isSourceColumnSelected = (columnName: string) =>
+ config.columns.some((c) => c.key === columnName && c.isSourceDisplay);
+
+ const updateColumnProp = (key: string, field: keyof TreeColumnConfig, value: any) => {
+ updateConfig({
+ columns: config.columns.map((col) => (col.key === key ? { ...col, [field]: value } : col)),
+ });
+ };
+
+ // FK/시스템 컬럼 제외한 표시 가능 컬럼
+ const displayableColumns = useMemo(() => {
+ const fkColumn = config.dataSource?.foreignKey;
+ const systemCols = ["id", "created_at", "updated_at", "created_by", "updated_by", "company_code", "created_date"];
+ return detailTableColumns.filter(
+ (col) => col.columnName !== fkColumn && col.inputType !== "entity" && !systemCols.includes(col.columnName),
+ );
+ }, [detailTableColumns, config.dataSource?.foreignKey]);
+
+ // FK 후보 컬럼
+ const fkCandidateColumns = useMemo(() => {
+ const systemCols = ["created_at", "updated_at", "created_by", "updated_by", "company_code", "created_date"];
+ return detailTableColumns.filter((c) => !systemCols.includes(c.columnName));
+ }, [detailTableColumns]);
+
+ return (
+
+
+
+
+ 기본
+
+
+ 컬럼
+
+
+
+ {/* ─── 기본 설정 탭 ─── */}
+
+ {/* 디테일 테이블 */}
+
+
+
+
+
+
+
+
+ {config.detailTable
+ ? allTables.find((t) => t.tableName === config.detailTable)?.displayName || config.detailTable
+ : "미설정"}
+
+ {config.detailTable && config.foreignKey && (
+
+ FK: {config.foreignKey} → {currentTableName || "메인 테이블"}.id
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 테이블을 찾을 수 없습니다.
+
+
+ {relatedTables.length > 0 && (
+
+ {relatedTables.map((rel) => (
+ {
+ handleDetailTableSelect(rel.tableName);
+ setTableComboboxOpen(false);
+ }}
+ className="text-xs"
+ >
+
+
+ {rel.tableLabel}
+
+ ({rel.foreignKeyColumn})
+
+
+ ))}
+
+ )}
+
+
+ {allTables
+ .filter((t) => !relatedTables.some((r) => r.tableName === t.tableName))
+ .map((table) => (
+ {
+ handleDetailTableSelect(table.tableName);
+ setTableComboboxOpen(false);
+ }}
+ className="text-xs"
+ >
+
+
+ {table.displayName}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {/* 트리 구조 설정 */}
+
+
+
+
+
+
+ 메인 FK와 부모-자식 계층 FK를 선택하세요
+
+
+ {fkCandidateColumns.length > 0 ? (
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
+
+ {loadingColumns ? "로딩 중..." : "디테일 테이블을 먼저 선택하세요"}
+
+
+ )}
+
+
+
+
+ {/* 엔티티 선택 (품목 참조) */}
+
+
+
+ 트리 노드에 표시할 품목 정보의 소스 엔티티
+
+
+ {entityColumns.length > 0 ? (
+
+ ) : (
+
+
+ {loadingColumns
+ ? "로딩 중..."
+ : !config.detailTable
+ ? "디테일 테이블을 먼저 선택하세요"
+ : "엔티티 타입 컬럼이 없습니다"}
+
+
+ )}
+
+ {config.dataSource?.sourceTable && (
+
+
선택된 엔티티
+
+
참조 테이블: {config.dataSource.sourceTable}
+
FK 컬럼: {config.dataSource.foreignKey}
+
+
+ )}
+
+
+
+
+ {/* 이력/버전 테이블 설정 */}
+
+
+
+ BOM 변경 이력과 버전 관리에 사용할 테이블을 선택하세요
+
+
+
+
+
+ updateFeatures("showHistory", !!checked)}
+ />
+
+
+ {(config.features?.showHistory ?? true) && (
+
+
+
+
+
+
+
+
+
+ 테이블을 찾을 수 없습니다.
+
+
+ {allTables.map((table) => (
+ updateConfig({ historyTable: table.tableName })}
+ className="text-xs"
+ >
+
+
+ {table.displayName}
+
+ ))}
+
+
+
+
+
+ )}
+
+
+
+
+ updateFeatures("showVersion", !!checked)}
+ />
+
+
+ {(config.features?.showVersion ?? true) && (
+
+
+
+
+
+
+
+
+
+ 테이블을 찾을 수 없습니다.
+
+
+ {allTables.map((table) => (
+ updateConfig({ versionTable: table.tableName })}
+ className="text-xs"
+ >
+
+
+ {table.displayName}
+
+ ))}
+
+
+
+
+
+ )}
+
+
+
+
+
+
+ {/* 표시 옵션 */}
+
+
+
+
+ updateFeatures("showExpandAll", !!checked)}
+ />
+
+
+
+
+
+ updateFeatures("showQuantity", !!checked)}
+ />
+
+
+
+ updateFeatures("showLossRate", !!checked)}
+ />
+
+
+
+
+
+ {/* 메인 화면 테이블 참고 */}
+ {currentTableName && (
+ <>
+
+
+
+
+
{currentTableName}
+
+ 컬럼 {detailTableColumns.length}개 / 엔티티 {entityColumns.length}개
+
+
+
+ >
+ )}
+
+
+ {/* ─── 컬럼 설정 탭 ─── */}
+
+
+
+
+ 트리 노드에 표시할 소스/디테일 컬럼을 선택하세요
+
+
+ {/* 소스 테이블 컬럼 (표시용) */}
+ {config.dataSource?.sourceTable && (
+ <>
+
+
+ 소스 테이블 ({config.dataSource.sourceTable}) - 표시용
+
+ {loadingSourceColumns ? (
+
로딩 중...
+ ) : sourceTableColumns.length === 0 ? (
+
컬럼 정보가 없습니다
+ ) : (
+
+ {sourceTableColumns.map((column) => (
+
toggleSourceDisplayColumn(column)}
+ >
+ toggleSourceDisplayColumn(column)}
+ className="pointer-events-none h-3.5 w-3.5"
+ />
+
+ {column.displayName}
+ 표시
+
+ ))}
+
+ )}
+ >
+ )}
+
+ {/* 디테일 테이블 컬럼 */}
+
+
+ 디테일 테이블 ({config.detailTable || "미선택"}) - 직접 컬럼
+
+ {loadingColumns ? (
+
로딩 중...
+ ) : displayableColumns.length === 0 ? (
+
컬럼 정보가 없습니다
+ ) : (
+
+ {displayableColumns.map((column) => (
+
toggleDetailColumn(column)}
+ >
+ toggleDetailColumn(column)}
+ className="pointer-events-none h-3.5 w-3.5"
+ />
+
+ {column.displayName}
+ {column.inputType}
+
+ ))}
+
+ )}
+
+
+ {/* 선택된 컬럼 상세 */}
+ {config.columns.length > 0 && (
+ <>
+
+
+
+
+ {config.columns.map((col, index) => (
+
+
e.dataTransfer.setData("columnIndex", String(index))}
+ onDragOver={(e) => e.preventDefault()}
+ onDrop={(e) => {
+ e.preventDefault();
+ const fromIndex = parseInt(e.dataTransfer.getData("columnIndex"), 10);
+ if (fromIndex !== index) {
+ const newColumns = [...config.columns];
+ const [movedCol] = newColumns.splice(fromIndex, 1);
+ newColumns.splice(index, 0, movedCol);
+ updateConfig({ columns: newColumns });
+ }
+ }}
+ >
+
+
+ {!col.isSourceDisplay && (
+
+ )}
+
+ {col.isSourceDisplay ? (
+
+ ) : (
+
+ )}
+
+ updateColumnProp(col.key, "title", e.target.value)}
+ placeholder="제목"
+ className="h-6 flex-1 text-xs"
+ />
+
+ {!col.isSourceDisplay && (
+
+ )}
+
+
+
+
+ {/* 확장 상세 */}
+ {!col.isSourceDisplay && expandedColumn === col.key && (
+
+
+
+ updateColumnProp(col.key, "width", e.target.value)}
+ placeholder="auto, 100px, 20%"
+ className="h-6 text-xs"
+ />
+
+
+ )}
+
+ ))}
+
+
+ >
+ )}
+
+
+
+ );
+}
+
+V2BomTreeConfigPanel.displayName = "V2BomTreeConfigPanel";
+
+export default V2BomTreeConfigPanel;
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 ff0285a2..72af2a34 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 => {
@@ -551,24 +630,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에서 추출한 현재 값 전달
// 새로운 기능들 전달
@@ -608,9 +697,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({
+ |
+ 0 && selectedItems.size === items.length}
+ onCheckedChange={(checked) => {
+ if (checked) setSelectedItems(new Set(items.map((i) => i.id)));
+ else setSelectedItems(new Set());
+ }}
+ />
+ |
품목코드 |
품목명 |
구분 |
@@ -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",
+ )}
>
+ | 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}
|
@@ -208,6 +241,25 @@ function ItemSearchModal({
)}
+
+ {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/BomDetailEditModal.tsx b/frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx
new file mode 100644
index 00000000..04ce8ebb
--- /dev/null
+++ b/frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx
@@ -0,0 +1,212 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Loader2 } from "lucide-react";
+import apiClient from "@/lib/api/client";
+
+interface BomDetailEditModalProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ node: Record
| null;
+ isRootNode?: boolean;
+ tableName: string;
+ onSaved?: () => void;
+}
+
+export function BomDetailEditModal({
+ open,
+ onOpenChange,
+ node,
+ isRootNode = false,
+ tableName,
+ onSaved,
+}: BomDetailEditModalProps) {
+ const [formData, setFormData] = useState>({});
+ const [saving, setSaving] = useState(false);
+
+ useEffect(() => {
+ if (node && open) {
+ if (isRootNode) {
+ setFormData({
+ base_qty: node.base_qty || "",
+ unit: node.unit || "",
+ remark: node.remark || "",
+ });
+ } else {
+ setFormData({
+ quantity: node.quantity || "",
+ unit: node.unit || node.detail_unit || "",
+ process_type: node.process_type || "",
+ base_qty: node.base_qty || "",
+ loss_rate: node.loss_rate || "",
+ remark: node.remark || "",
+ });
+ }
+ }
+ }, [node, open, isRootNode]);
+
+ const handleChange = (field: string, value: string) => {
+ setFormData((prev) => ({ ...prev, [field]: value }));
+ };
+
+ const handleSave = async () => {
+ if (!node) return;
+ setSaving(true);
+ try {
+ const targetTable = isRootNode ? "bom" : tableName;
+ const realId = isRootNode ? node.id?.replace("__root_", "") : node.id;
+ await apiClient.put(`/table-management/tables/${targetTable}/${realId}`, formData);
+ onSaved?.();
+ onOpenChange(false);
+ } catch (error) {
+ console.error("[BomDetailEdit] 저장 실패:", error);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ if (!node) return null;
+
+ const itemCode = isRootNode
+ ? node.child_item_code || node.item_code || node.bom_number || "-"
+ : node.child_item_code || "-";
+ const itemName = isRootNode
+ ? node.child_item_name || node.item_name || "-"
+ : node.child_item_name || "-";
+
+ return (
+
+ );
+}
diff --git a/frontend/lib/registry/components/v2-bom-tree/BomHistoryModal.tsx b/frontend/lib/registry/components/v2-bom-tree/BomHistoryModal.tsx
new file mode 100644
index 00000000..9d66a8df
--- /dev/null
+++ b/frontend/lib/registry/components/v2-bom-tree/BomHistoryModal.tsx
@@ -0,0 +1,147 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Loader2 } from "lucide-react";
+import { cn } from "@/lib/utils";
+import apiClient from "@/lib/api/client";
+
+interface BomHistoryItem {
+ id: string;
+ revision: string;
+ version: string;
+ change_type: string;
+ change_description: string;
+ changed_by: string;
+ changed_date: string;
+}
+
+interface BomHistoryModalProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ bomId: string | null;
+ tableName?: string;
+}
+
+const CHANGE_TYPE_STYLE: Record = {
+ "등록": "bg-blue-50 text-blue-600 ring-blue-200",
+ "수정": "bg-amber-50 text-amber-600 ring-amber-200",
+ "추가": "bg-emerald-50 text-emerald-600 ring-emerald-200",
+ "삭제": "bg-red-50 text-red-600 ring-red-200",
+};
+
+export function BomHistoryModal({ open, onOpenChange, bomId, tableName = "bom_history" }: BomHistoryModalProps) {
+ const [history, setHistory] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ if (open && bomId) {
+ loadHistory();
+ }
+ }, [open, bomId]);
+
+ const loadHistory = async () => {
+ if (!bomId) return;
+ setLoading(true);
+ try {
+ const res = await apiClient.get(`/bom/${bomId}/history`, { params: { tableName } });
+ if (res.data?.success) {
+ setHistory(res.data.data || []);
+ }
+ } catch (error) {
+ console.error("[BomHistory] 로드 실패:", error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const formatDate = (dateStr: string) => {
+ if (!dateStr) return "-";
+ try {
+ return new Date(dateStr).toLocaleString("ko-KR", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ } catch {
+ return dateStr;
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx
index 536c1ddc..88461200 100644
--- a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx
+++ b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx
@@ -1,47 +1,45 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
-import { ChevronRight, ChevronDown, Package, Layers, Box, AlertCircle } from "lucide-react";
+import {
+ ChevronRight,
+ ChevronDown,
+ Package,
+ Layers,
+ Box,
+ AlertCircle,
+ Expand,
+ Shrink,
+ Loader2,
+ History,
+ GitBranch,
+ Check,
+} from "lucide-react";
import { cn } from "@/lib/utils";
import { entityJoinApi } from "@/lib/api/entityJoin";
+import { Button } from "@/components/ui/button";
+import { BomDetailEditModal } from "./BomDetailEditModal";
+import { BomHistoryModal } from "./BomHistoryModal";
+import { BomVersionModal } from "./BomVersionModal";
-/**
- * BOM 트리 노드 데이터
- */
interface BomTreeNode {
id: string;
- bom_id: string;
- parent_detail_id: string | null;
- seq_no: string;
- level: string;
- child_item_id: string;
- child_item_code: string;
- child_item_name: string;
- child_item_type: string;
- quantity: string;
- unit: string;
- loss_rate: string;
- remark: string;
+ [key: string]: any;
children: BomTreeNode[];
}
-/**
- * BOM 헤더 정보
- */
interface BomHeaderInfo {
id: string;
- bom_number: string;
- item_code: string;
- item_name: string;
- item_type: string;
- base_qty: string;
- unit: string;
- version: string;
- revision: string;
- status: string;
- effective_date: string;
- expired_date: string;
- remark: string;
+ [key: string]: any;
+}
+
+interface TreeColumnDef {
+ key: string;
+ title: string;
+ width?: string;
+ visible?: boolean;
+ hidden?: boolean;
+ isSourceDisplay?: boolean;
}
interface BomTreeComponentProps {
@@ -54,10 +52,10 @@ interface BomTreeComponentProps {
[key: string]: any;
}
-/**
- * BOM 트리 컴포넌트
- * 좌측 패널에서 BOM 헤더 선택 시 계층 구조로 BOM 디테일을 표시
- */
+// 컬럼은 설정 패널에서만 추가 (하드코딩 금지)
+const EMPTY_COLUMNS: TreeColumnDef[] = [];
+const INDENT_PX = 16;
+
export function BomTreeComponent({
component,
formData,
@@ -72,101 +70,179 @@ export function BomTreeComponent({
const [loading, setLoading] = useState(false);
const [selectedNodeId, setSelectedNodeId] = useState(null);
- const config = component?.componentConfig || {};
+ const [viewMode, setViewMode] = useState<"tree" | "level">("tree");
+
+ const [editModalOpen, setEditModalOpen] = useState(false);
+ const [editTargetNode, setEditTargetNode] = useState(null);
+ const [historyModalOpen, setHistoryModalOpen] = useState(false);
+ const [versionModalOpen, setVersionModalOpen] = useState(false);
+ const [colWidths, setColWidths] = useState>({});
+
+ const handleResizeStart = useCallback((colKey: string, e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ const startX = e.clientX;
+ const th = (e.target as HTMLElement).closest("th");
+ const startWidth = th?.offsetWidth || 100;
+ const onMove = (ev: MouseEvent) => {
+ setColWidths((prev) => ({ ...prev, [colKey]: Math.max(40, startWidth + (ev.clientX - startX)) }));
+ };
+ const onUp = () => {
+ document.removeEventListener("mousemove", onMove);
+ document.removeEventListener("mouseup", onUp);
+ document.body.style.cursor = "";
+ document.body.style.userSelect = "";
+ };
+ document.body.style.cursor = "col-resize";
+ document.body.style.userSelect = "none";
+ document.addEventListener("mousemove", onMove);
+ document.addEventListener("mouseup", onUp);
+ }, []);
+
+ const config = component?.componentConfig || {};
+ const overrides = component?.overrides || {};
- // 선택된 BOM 헤더에서 bom_id 추출
const selectedBomId = useMemo(() => {
- // SplitPanel에서 좌측 선택 시 formData나 selectedRowsData로 전달됨
- if (selectedRowsData && selectedRowsData.length > 0) {
- return selectedRowsData[0]?.id;
- }
+ if (selectedRowsData && selectedRowsData.length > 0) return selectedRowsData[0]?.id;
if (formData?.id) return formData.id;
return null;
}, [formData, selectedRowsData]);
- // 선택된 BOM 헤더 정보 추출
const selectedHeaderData = useMemo(() => {
- if (selectedRowsData && selectedRowsData.length > 0) {
- return selectedRowsData[0] as BomHeaderInfo;
- }
- if (formData?.id) return formData as unknown as BomHeaderInfo;
- return null;
+ 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 loadBomDetails = useCallback(async (bomId: string) => {
+ const detailTable = overrides.detailTable || config.detailTable || "bom_detail";
+ const foreignKey = overrides.foreignKey || config.foreignKey || "bom_id";
+ const parentKey = overrides.parentKey || config.parentKey || "parent_detail_id";
+ const sourceFk = config.dataSource?.foreignKey || "child_item_id";
+ const historyTable = config.historyTable || "bom_history";
+ const versionTable = config.versionTable || "bom_version";
+
+ const displayColumns = useMemo(() => {
+ const configured = config.columns as TreeColumnDef[] | undefined;
+ if (configured && configured.length > 0) return configured.filter((c) => !c.hidden);
+ return EMPTY_COLUMNS;
+ }, [config.columns]);
+
+ const features = config.features || {};
+ const showHistory = features.showHistory !== false;
+ const showVersion = features.showVersion !== false;
+
+ // ─── 데이터 로드 ───
+
+ // BOM 헤더 데이터로 가상 0레벨 루트 노드 생성
+ const buildVirtualRoot = useCallback((headerData: BomHeaderInfo | null, children: BomTreeNode[]): BomTreeNode | null => {
+ if (!headerData) return null;
+ return {
+ id: `__root_${headerData.id}`,
+ _isVirtualRoot: true,
+ level: "0",
+ child_item_name: headerData.item_name || "",
+ child_item_code: headerData.item_code || headerData.bom_number || "",
+ child_item_type: headerData.item_type || "",
+ item_name: headerData.item_name || "",
+ item_number: headerData.item_code || "",
+ quantity: "-",
+ base_qty: headerData.base_qty || "",
+ unit: headerData.unit || "",
+ revision: headerData.revision || "",
+ loss_rate: "",
+ process_type: "",
+ remark: headerData.remark || "",
+ children,
+ };
+ }, []);
+
+ const loadBomDetails = useCallback(async (bomId: string, headerData: BomHeaderInfo | null) => {
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 tree = buildTree(rows);
- setTreeData(tree);
- const firstLevelIds = new Set(tree.map((n: BomTreeNode) => n.id));
- setExpandedNodes(firstLevelIds);
+ 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}_`, "");
+ const aliasKey = `child_${shortKey}`;
+ if (!mapped[aliasKey]) mapped[aliasKey] = row[key];
+ if (!mapped[shortKey]) mapped[shortKey] = row[key];
+ }
+ }
+ 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 detailTree = buildTree(rows);
+
+ // BOM 헤더를 가상 0레벨 루트로 삽입
+ const virtualRoot = buildVirtualRoot(headerData, detailTree);
+ if (virtualRoot) {
+ setTreeData([virtualRoot]);
+ setExpandedNodes(new Set([virtualRoot.id]));
+ } else {
+ setTreeData(detailTree);
+ const firstLevelIds = new Set(detailTree.map((n: BomTreeNode) => n.id));
+ setExpandedNodes(firstLevelIds);
+ }
} catch (error) {
console.error("[BomTree] 데이터 로드 실패:", error);
} finally {
setLoading(false);
}
- }, []);
+ }, [detailTable, foreignKey, sourceFk, buildVirtualRoot]);
- // 평면 데이터 -> 트리 구조 변환
const buildTree = (flatData: any[]): BomTreeNode[] => {
const nodeMap = new Map();
const roots: BomTreeNode[] = [];
-
- // 모든 노드를 맵에 등록
- flatData.forEach((item) => {
- nodeMap.set(item.id, { ...item, children: [] });
- });
-
- // 부모-자식 관계 설정
+ flatData.forEach((item) => nodeMap.set(item.id, { ...item, children: [] }));
flatData.forEach((item) => {
const node = nodeMap.get(item.id)!;
- if (item.parent_detail_id && nodeMap.has(item.parent_detail_id)) {
- nodeMap.get(item.parent_detail_id)!.children.push(node);
+ if (item[parentKey] && nodeMap.has(item[parentKey])) {
+ nodeMap.get(item[parentKey])!.children.push(node);
} else {
roots.push(node);
}
});
-
return roots;
};
- // 선택된 BOM 변경 시 데이터 로드
useEffect(() => {
if (selectedBomId) {
setHeaderInfo(selectedHeaderData);
- loadBomDetails(selectedBomId);
+ loadBomDetails(selectedBomId, selectedHeaderData);
} else {
setHeaderInfo(null);
setTreeData([]);
}
}, [selectedBomId, selectedHeaderData, loadBomDetails]);
- // 노드 펼치기/접기 토글
const toggleNode = useCallback((nodeId: string) => {
setExpandedNodes((prev) => {
const next = new Set(prev);
- if (next.has(nodeId)) {
- next.delete(nodeId);
- } else {
- next.add(nodeId);
- }
+ if (next.has(nodeId)) next.delete(nodeId);
+ else next.add(nodeId);
return next;
});
}, []);
- // 전체 펼치기
const expandAll = useCallback(() => {
const allIds = new Set();
const collectIds = (nodes: BomTreeNode[]) => {
@@ -179,270 +255,595 @@ export function BomTreeComponent({
setExpandedNodes(allIds);
}, [treeData]);
- // 전체 접기
- const collapseAll = useCallback(() => {
- setExpandedNodes(new Set());
- }, []);
+ const collapseAll = useCallback(() => setExpandedNodes(new Set()), []);
+
+ // ─── 유틸 ───
- // 품목 구분 라벨
const getItemTypeLabel = (type: string) => {
- switch (type) {
- case "product": return "제품";
- case "semi": return "반제품";
- case "material": return "원자재";
- case "part": return "부품";
- default: return type || "-";
- }
+ const map: Record = { product: "제품", semi: "반제품", material: "원자재", part: "부품" };
+ return map[type] || type || "-";
};
- // 품목 구분 아이콘 & 색상
- const getItemTypeStyle = (type: string) => {
- switch (type) {
- case "product":
- return { icon: Package, color: "text-blue-600", bg: "bg-blue-50" };
- case "semi":
- return { icon: Layers, color: "text-amber-600", bg: "bg-amber-50" };
- case "material":
- return { icon: Box, color: "text-emerald-600", bg: "bg-emerald-50" };
- default:
- return { icon: Box, color: "text-gray-500", bg: "bg-gray-50" };
- }
+ const getItemTypeBadge = (type: string) => {
+ const map: Record = {
+ product: "bg-blue-50 text-blue-600 ring-blue-200",
+ semi: "bg-amber-50 text-amber-600 ring-amber-200",
+ material: "bg-emerald-50 text-emerald-600 ring-emerald-200",
+ part: "bg-purple-50 text-purple-600 ring-purple-200",
+ };
+ return map[type] || "bg-gray-50 text-gray-500 ring-gray-200";
};
- // 디자인 모드 미리보기
+ const getItemIcon = (type: string) => {
+ const map: Record = { product: Package, semi: Layers };
+ return map[type] || Box;
+ };
+
+ const getItemIconColor = (type: string) => {
+ const map: Record = {
+ product: "text-blue-500",
+ semi: "text-amber-500",
+ material: "text-emerald-500",
+ part: "text-purple-500",
+ };
+ return map[type] || "text-gray-400";
+ };
+
+ // ─── 셀 렌더링 ───
+
+ const renderCellValue = (node: BomTreeNode, col: TreeColumnDef, depth: number) => {
+ const value = node[col.key];
+
+ if (col.key === "child_item_type" || col.key === "item_type") {
+ const label = getItemTypeLabel(String(value || ""));
+ return (
+
+ {label}
+
+ );
+ }
+
+ if (col.key === "level") {
+ const displayLevel = node._isVirtualRoot ? 0 : depth;
+ return (
+
+ {displayLevel}
+
+ );
+ }
+
+ if (col.key === "child_item_code") {
+ return {value || "-"};
+ }
+
+ if (col.key === "child_item_name") {
+ return {value || "-"};
+ }
+
+ if (col.key === "quantity" || col.key === "base_qty") {
+ return (
+
+ {value != null && value !== "" && value !== "0" ? value : "-"}
+
+ );
+ }
+
+ if (col.key === "loss_rate") {
+ const num = Number(value);
+ if (!num) return -;
+ return {value}%;
+ }
+
+ if (col.key === "revision") {
+ return (
+
+ {value != null && value !== "" && value !== "0" ? value : "-"}
+
+ );
+ }
+
+ if (col.key === "unit") {
+ return {value || "-"};
+ }
+
+ return {value ?? "-"};
+ };
+
+ // ─── 디자인 모드 ───
+
if (isDesignMode) {
+ const configuredColumns = (config.columns || []).filter((c: TreeColumnDef) => !c.hidden);
+
return (
-
-
-
-
BOM 트리 뷰
+
+
+
+
BOM 트리 뷰
+
{detailTable}
+ {config.dataSource?.sourceTable && (
+
+ {config.dataSource.sourceTable}
+
+ )}
-
-
-
-
-
완제품 A (제품)
-
수량: 1
+
+ {configuredColumns.length === 0 ? (
+
+
+
컬럼 미설정
+
설정 패널 > 컬럼 탭에서 표시할 컬럼을 선택하세요
-
-
-
-
반제품 B (반제품)
-
수량: 2
+ ) : (
+
+
+
+
+ |
+ {configuredColumns.map((col: TreeColumnDef) => (
+
+ {col.title || col.key}
+ |
+ ))}
+
+
+
+
+ |
+
+ |
+ {configuredColumns.map((col: TreeColumnDef, i: number) => (
+
+ {col.key === "level" ? "0" : col.key.includes("type") ? (
+ 제품
+ ) : col.key.includes("quantity") || col.key.includes("qty") ? "30" : `예시${i + 1}`}
+ |
+ ))}
+
+
+ |
+
+ |
+ {configuredColumns.map((col: TreeColumnDef, i: number) => (
+
+ {col.key === "level" ? "1" : col.key.includes("type") ? (
+ 반제품
+ ) : col.key.includes("quantity") || col.key.includes("qty") ? "3" : `예시${i + 1}`}
+ |
+ ))}
+
+
+
-
-
-
- 원자재 C (원자재)
- 수량: 5
-
-
+ )}
);
}
- // 선택 안 된 상태
+ // ─── 미선택 상태 ───
+
if (!selectedBomId) {
return (
-
-
-
좌측에서 BOM을 선택하세요
-
선택한 BOM의 구성 정보가 트리로 표시됩니다
+
+
+
+
BOM을 선택해주세요
+
좌측 목록에서 BOM을 선택하면 구성이 표시됩니다
);
}
+ // ─── 트리 평탄화 ───
+
+ const flattenedRows = useMemo(() => {
+ const rows: { node: BomTreeNode; depth: number }[] = [];
+ const traverse = (nodes: BomTreeNode[], depth: number) => {
+ for (const node of nodes) {
+ rows.push({ node, depth });
+ if (node.children.length > 0 && expandedNodes.has(node.id)) {
+ traverse(node.children, depth + 1);
+ }
+ }
+ };
+ traverse(treeData, 0);
+ return rows;
+ }, [treeData, expandedNodes]);
+
+ // 레벨 뷰용: 전체 노드 평탄화 (expand 상태 무관)
+ const allFlattenedRows = useMemo(() => {
+ const rows: { node: BomTreeNode; depth: number }[] = [];
+ const traverse = (nodes: BomTreeNode[], depth: number) => {
+ for (const node of nodes) {
+ rows.push({ node, depth });
+ if (node.children.length > 0) traverse(node.children, depth + 1);
+ }
+ };
+ traverse(treeData, 0);
+ return rows;
+ }, [treeData]);
+
+ const maxDepth = useMemo(() => {
+ return allFlattenedRows.reduce((max, r) => Math.max(max, r.depth), 0);
+ }, [allFlattenedRows]);
+
+ const visibleRows = viewMode === "level" ? allFlattenedRows : flattenedRows;
+ const levelColumnsForView = useMemo(() => {
+ return Array.from({ length: maxDepth + 1 }, (_, i) => i);
+ }, [maxDepth]);
+
+ // 레벨 뷰에서 "level" 컬럼을 제외한 데이터 컬럼
+ const dataColumnsForLevelView = useMemo(() => {
+ return displayColumns.filter((c) => c.key !== "level");
+ }, [displayColumns]);
+
+ // ─── 메인 렌더링 ───
+
return (
-
+
{/* 헤더 정보 */}
- {headerInfo && (
-
-
-
-
{headerInfo.item_name || "-"}
-
- {headerInfo.bom_number || "-"}
-
-
+
+
- {headerInfo.status === "active" ? "사용" : "미사용"}
-
-
-
-
품목코드: {headerInfo.item_code || "-"}
-
구분: {getItemTypeLabel(headerInfo.item_type)}
-
기준수량: {headerInfo.base_qty || "1"} {headerInfo.unit || ""}
-
버전: v{headerInfo.version || "1.0"} (차수 {headerInfo.revision || "1"})
+
+
+
+
+
+ {headerInfo.item_name || "-"}
+
+
+ {getItemTypeLabel(headerInfo.item_type)}
+
+
+ {headerInfo.status === "active" ? "사용" : "미사용"}
+
+
+
+ 품목코드 {headerInfo.item_code || "-"}
+ 기준수량 {headerInfo.base_qty || "1"}
+ 버전 v{headerInfo.version || "1"} (차수 {headerInfo.revision || "1"})
+
+
)}
- {/* 트리 툴바 */}
-
-
BOM 구성
-
- {treeData.length}건
+ {/* 툴바 */}
+
+
BOM 구성
+
+ {allFlattenedRows.length}
-
-
-
+
+ {showHistory && (
+
+ )}
+ {showVersion && (
+
+ )}
+
+
+
+
+
+ {features.showExpandAll !== false && (
+
+
+
+
+ )}
- {/* 트리 컨텐츠 */}
-
+ {/* 테이블 */}
+
{loading ? (
-
-
로딩 중...
+
+
+
+ ) : displayColumns.length === 0 ? (
+
+
+
표시할 컬럼이 설정되지 않았습니다
+
디자인 모드에서 컬럼을 추가하세요
) : treeData.length === 0 ? (
-
-
-
등록된 하위 품목이 없습니다
+
+ ) : viewMode === "level" ? (
+ /* ═══ 레벨 뷰 ═══ */
+
+
+
+ {levelColumnsForView.map((lvl) => (
+ |
+ {lvl}
+ |
+ ))}
+ {dataColumnsForLevelView.map((col) => {
+ const centered = ["quantity", "loss_rate", "base_qty", "revision", "seq_no"].includes(col.key);
+ const w = colWidths[col.key];
+ return (
+
+ {col.title}
+ handleResizeStart(col.key, e)}
+ />
+ |
+ );
+ })}
+
+
+
+ {allFlattenedRows.map(({ node, depth }, rowIdx) => {
+ const isRoot = !!node._isVirtualRoot;
+ const displayDepth = isRoot ? 0 : depth;
+
+ return (
+ setSelectedNodeId(node.id)}
+ onDoubleClick={() => {
+ setEditTargetNode(node);
+ setEditModalOpen(true);
+ }}
+ >
+ {levelColumnsForView.map((lvl) => (
+ |
+ {displayDepth === lvl ? (
+
+ ) : null}
+ |
+ ))}
+ {dataColumnsForLevelView.map((col) => {
+ const centered = ["quantity", "loss_rate", "base_qty", "revision", "seq_no"].includes(col.key);
+ return (
+
+ {renderCellValue(node, col, depth)}
+ |
+ );
+ })}
+
+ );
+ })}
+
+
) : (
-
- {treeData.map((node) => (
-
- ))}
-
+ /* ═══ 트리 뷰 ═══ */
+
+
+
+ |
+ {displayColumns.map((col) => {
+ const centered = ["quantity", "loss_rate", "level", "base_qty", "revision", "seq_no"].includes(col.key);
+ const w = colWidths[col.key];
+ return (
+
+ {col.title}
+ handleResizeStart(col.key, e)}
+ />
+ |
+ );
+ })}
+
+
+
+ {flattenedRows.map(({ node, depth }, rowIdx) => {
+ const hasChildren = node.children.length > 0;
+ const isExpanded = expandedNodes.has(node.id);
+ const isSelected = selectedNodeId === node.id;
+ const isRoot = !!node._isVirtualRoot;
+ const itemType = node.child_item_type || node.item_type || "";
+ const ItemIcon = getItemIcon(itemType);
+
+ return (
+ {
+ setSelectedNodeId(node.id);
+ if (hasChildren) toggleNode(node.id);
+ }}
+ onDoubleClick={() => {
+ setEditTargetNode(node);
+ setEditModalOpen(true);
+ }}
+ >
+ |
+
+
+ {hasChildren ? (
+ isExpanded ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ )}
+
+
+
+
+
+ |
+
+ {displayColumns.map((col) => {
+ const centered = ["quantity", "loss_rate", "level", "base_qty", "revision", "seq_no"].includes(col.key);
+ return (
+
+ {renderCellValue(node, col, depth)}
+ |
+ );
+ })}
+
+ );
+ })}
+
+
)}
+
+ {/* 품목 수정 모달 */}
+
{
+ if (selectedBomId) loadBomDetails(selectedBomId, selectedHeaderData);
+ }}
+ />
+
+ {showHistory && (
+
+ )}
+
+ {showVersion && (
+ {
+ if (selectedBomId) loadBomDetails(selectedBomId, selectedHeaderData);
+ }}
+ />
+ )}
);
}
-/**
- * 트리 노드 행 (재귀 렌더링)
- */
-interface TreeNodeRowProps {
- node: BomTreeNode;
- depth: number;
- expandedNodes: Set
;
- selectedNodeId: string | null;
- onToggle: (id: string) => void;
- onSelect: (id: string) => void;
- getItemTypeLabel: (type: string) => string;
- getItemTypeStyle: (type: string) => { icon: any; color: string; bg: string };
-}
-
-function TreeNodeRow({
- node,
- depth,
- expandedNodes,
- selectedNodeId,
- onToggle,
- onSelect,
- getItemTypeLabel,
- getItemTypeStyle,
-}: TreeNodeRowProps) {
- const isExpanded = expandedNodes.has(node.id);
- const hasChildren = node.children.length > 0;
- const isSelected = selectedNodeId === node.id;
- const style = getItemTypeStyle(node.child_item_type);
- const ItemIcon = style.icon;
-
- return (
- <>
- {
- onSelect(node.id);
- if (hasChildren) onToggle(node.id);
- }}
- >
- {/* 펼치기/접기 화살표 */}
-
- {hasChildren ? (
- isExpanded ? (
-
- ) : (
-
- )
- ) : (
-
- )}
-
-
- {/* 품목 타입 아이콘 */}
-
-
-
-
- {/* 품목 정보 */}
-
-
- {node.child_item_name || "-"}
-
-
- {node.child_item_code || ""}
-
-
- {getItemTypeLabel(node.child_item_type)}
-
-
-
- {/* 수량/단위 */}
-
-
- 수량: {node.quantity || "0"} {node.unit || ""}
-
- {node.loss_rate && node.loss_rate !== "0" && (
-
- 로스: {node.loss_rate}%
-
- )}
-
-
-
- {/* 하위 노드 재귀 렌더링 */}
- {hasChildren && isExpanded && (
-
- {node.children.map((child) => (
-
- ))}
-
- )}
- >
- );
-}
+export default BomTreeComponent;
diff --git a/frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx b/frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx
new file mode 100644
index 00000000..e9075f64
--- /dev/null
+++ b/frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx
@@ -0,0 +1,221 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Loader2, Plus, Trash2, Download } from "lucide-react";
+import { cn } from "@/lib/utils";
+import apiClient from "@/lib/api/client";
+
+interface BomVersion {
+ id: string;
+ version_name: string;
+ revision: number;
+ status: string;
+ created_by: string;
+ created_date: string;
+}
+
+interface BomVersionModalProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ bomId: string | null;
+ tableName?: string;
+ detailTable?: string;
+ onVersionLoaded?: () => void;
+}
+
+const STATUS_STYLE: Record = {
+ developing: { label: "개발중", className: "bg-red-50 text-red-600 ring-red-200" },
+ active: { label: "사용", className: "bg-emerald-50 text-emerald-600 ring-emerald-200" },
+ inactive: { label: "사용중지", className: "bg-gray-100 text-gray-500 ring-gray-200" },
+};
+
+export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_version", detailTable = "bom_detail", onVersionLoaded }: BomVersionModalProps) {
+ const [versions, setVersions] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [creating, setCreating] = useState(false);
+ const [actionId, setActionId] = useState(null);
+
+ useEffect(() => {
+ if (open && bomId) loadVersions();
+ }, [open, bomId]);
+
+ const loadVersions = async () => {
+ if (!bomId) return;
+ setLoading(true);
+ try {
+ const res = await apiClient.get(`/bom/${bomId}/versions`, { params: { tableName } });
+ if (res.data?.success) setVersions(res.data.data || []);
+ } catch (error) {
+ console.error("[BomVersion] 로드 실패:", error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleCreateVersion = async () => {
+ if (!bomId) return;
+ setCreating(true);
+ try {
+ const res = await apiClient.post(`/bom/${bomId}/versions`, { tableName, detailTable });
+ if (res.data?.success) loadVersions();
+ } catch (error) {
+ console.error("[BomVersion] 생성 실패:", error);
+ } finally {
+ setCreating(false);
+ }
+ };
+
+ const handleLoadVersion = async (versionId: string) => {
+ if (!bomId) return;
+ setActionId(versionId);
+ try {
+ const res = await apiClient.post(`/bom/${bomId}/versions/${versionId}/load`, { tableName, detailTable });
+ if (res.data?.success) {
+ onVersionLoaded?.();
+ onOpenChange(false);
+ }
+ } catch (error) {
+ console.error("[BomVersion] 불러오기 실패:", error);
+ } finally {
+ setActionId(null);
+ }
+ };
+
+ const handleDeleteVersion = async (versionId: string) => {
+ if (!bomId || !confirm("이 버전을 삭제하시겠습니까?")) return;
+ setActionId(versionId);
+ try {
+ const res = await apiClient.delete(`/bom/${bomId}/versions/${versionId}`, { params: { tableName } });
+ if (res.data?.success) loadVersions();
+ } catch (error) {
+ console.error("[BomVersion] 삭제 실패:", error);
+ } finally {
+ setActionId(null);
+ }
+ };
+
+ const formatDate = (dateStr: string) => {
+ if (!dateStr) return "-";
+ try {
+ return new Date(dateStr).toLocaleDateString("ko-KR", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ });
+ } catch {
+ return dateStr;
+ }
+ };
+
+ const getStatus = (status: string) => STATUS_STYLE[status] || STATUS_STYLE.inactive;
+
+ return (
+
+ );
+}
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,65 @@ export class ButtonActionExecutor {
// 🔧 formData를 리피터에 전달하여 각 행에 병합 저장
const savedId = saveResult?.data?.id || saveResult?.data?.data?.id || formData.id || context.formData?.id;
+ // _deferSave 데이터 처리 (마스터-디테일 순차 저장: 레벨별 저장 + temp→real ID 매핑)
+ 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;
+
+ // 레벨별 그룹핑 (레벨 0 먼저 저장 → 레벨 1 → ...)
+ const maxLevel = Math.max(...parsedData.map((item: any) => Number(item.level) || 0));
+ const tempIdToRealId = new Map();
+
+ for (let lvl = 0; lvl <= maxLevel; lvl++) {
+ const levelItems = parsedData.filter((item: any) => (Number(item.level) || 0) === lvl);
+
+ for (const item of levelItems) {
+ const { _targetTable: _, _isNew, _deferSave: __, _fkColumn: fkCol, tempId, ...data } = item;
+ if (!data.id || data.id === "") delete data.id;
+
+ // FK 주입 (bom_id 등)
+ if (fkCol) data[fkCol] = savedId;
+
+ // parent_detail_id의 temp 참조를 실제 ID로 교체
+ if (data.parent_detail_id && tempIdToRealId.has(data.parent_detail_id)) {
+ data.parent_detail_id = tempIdToRealId.get(data.parent_detail_id);
+ }
+
+ // 시스템 필드 추가
+ 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) {
+ const res = await apiClient.post(`/table-management/tables/${targetTable}/add`, data);
+ const newId = res.data?.data?.id || res.data?.id;
+ if (newId && tempId) {
+ tempIdToRealId.set(tempId, newId);
+ }
+ } else {
+ await apiClient.put(`/table-management/tables/${targetTable}/${item.id}`, data);
+ if (item.id && tempId) {
+ tempIdToRealId.set(tempId, item.id);
+ }
+ }
+ } 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 =====