From 46ea3612fde929459401aa1e35bf837f7bbc0286 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 26 Feb 2026 13:09:32 +0900 Subject: [PATCH] feat: Enhance BOM management with new header retrieval and version handling - Added a new endpoint to retrieve BOM headers with entity join support, improving data accessibility. - Updated the BOM service to include logic for fetching current version IDs and handling version-related data more effectively. - Enhanced the BOM tree component to utilize the new BOM header API for better data management. - Implemented version ID fallback mechanisms to ensure accurate data representation during BOM operations. - Improved the overall user experience by integrating new features for version management and data loading. --- .gitignore | 7 +- backend-node/src/controllers/bomController.ts | 30 +- backend-node/src/routes/bomRoutes.ts | 3 + backend-node/src/services/bomService.ts | 229 ++++++++++----- docs/BOM_개발_현황.md | 278 ++++++++++++++++++ .../BomItemEditorComponent.tsx | 274 +++++++++++++++-- .../v2-bom-tree/BomTreeComponent.tsx | 256 +++++++++++++--- .../v2-bom-tree/BomVersionModal.tsx | 2 +- frontend/lib/utils/buttonActions.ts | 80 ++--- package-lock.json | 64 +++- package.json | 3 +- 11 files changed, 1011 insertions(+), 215 deletions(-) create mode 100644 docs/BOM_개발_현황.md diff --git a/.gitignore b/.gitignore index a771d2c9..e9b5b7fb 100644 --- a/.gitignore +++ b/.gitignore @@ -286,4 +286,9 @@ uploads/ *.hwp *.hwpx -claude.md \ No newline at end of file +claude.md + +# AI 에이전트 테스트 산출물 +*-test-screenshots/ +*-screenshots/ +*-test.mjs \ No newline at end of file diff --git a/backend-node/src/controllers/bomController.ts b/backend-node/src/controllers/bomController.ts index 7db45174..8355b148 100644 --- a/backend-node/src/controllers/bomController.ts +++ b/backend-node/src/controllers/bomController.ts @@ -48,6 +48,25 @@ export async function addBomHistory(req: Request, res: Response) { } } +// ─── BOM 헤더 조회 (entity join 포함) ───────────────────────── + +export async function getBomHeader(req: Request, res: Response) { + try { + const { bomId } = req.params; + const tableName = (req.query.tableName as string) || undefined; + + const data = await bomService.getBomHeader(bomId, tableName); + if (!data) { + res.status(404).json({ success: false, message: "BOM을 찾을 수 없습니다" }); + return; + } + res.json({ success: true, data }); + } 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) { @@ -56,8 +75,12 @@ export async function getBomVersions(req: Request, res: Response) { 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 }); + const result = await bomService.getBomVersions(bomId, companyCode, tableName); + res.json({ + success: true, + data: result.versions, + currentVersionId: result.currentVersionId, + }); } catch (error: any) { logger.error("BOM 버전 목록 조회 실패", { error: error.message }); res.status(500).json({ success: false, message: error.message }); @@ -110,8 +133,9 @@ export async function deleteBomVersion(req: Request, res: Response) { try { const { bomId, versionId } = req.params; const tableName = (req.query.tableName as string) || undefined; + const detailTable = (req.query.detailTable as string) || undefined; - const deleted = await bomService.deleteBomVersion(bomId, versionId, tableName); + const deleted = await bomService.deleteBomVersion(bomId, versionId, tableName, detailTable); if (!deleted) { res.status(404).json({ success: false, message: "버전을 찾을 수 없습니다" }); return; diff --git a/backend-node/src/routes/bomRoutes.ts b/backend-node/src/routes/bomRoutes.ts index 8da360ba..f6e3ee62 100644 --- a/backend-node/src/routes/bomRoutes.ts +++ b/backend-node/src/routes/bomRoutes.ts @@ -10,6 +10,9 @@ const router = Router(); router.use(authenticateToken); +// BOM 헤더 (entity join 포함) +router.get("/:bomId/header", bomController.getBomHeader); + // 이력 router.get("/:bomId/history", bomController.getBomHistory); router.post("/:bomId/history", bomController.addBomHistory); diff --git a/backend-node/src/services/bomService.ts b/backend-node/src/services/bomService.ts index fbfd836d..89da38a9 100644 --- a/backend-node/src/services/bomService.ts +++ b/backend-node/src/services/bomService.ts @@ -1,12 +1,11 @@ /** * BOM 이력 및 버전 관리 서비스 - * 설정 패널에서 지정한 테이블명을 동적으로 사용 + * 행(Row) 기반 버전 관리: bom_detail.version_id로 버전별 데이터 분리 */ 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; @@ -54,15 +53,48 @@ export async function addBomHistory( // ─── 버전 (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); +// ─── BOM 헤더 조회 (entity join 포함) ───────────────────────────── + +export async function getBomHeader(bomId: string, tableName?: string) { + const table = safeTableName(tableName || "", "bom"); + const sql = ` + SELECT b.*, + i.item_name, i.item_number, i.division as item_type, i.unit + FROM ${table} b + LEFT JOIN item_info i ON b.item_id = i.id + WHERE b.id = $1 + LIMIT 1 + `; + return queryOne>(sql, [bomId]); } +export async function getBomVersions(bomId: string, companyCode: string, tableName?: string) { + const table = safeTableName(tableName || "", "bom_version"); + const dTable = "bom_detail"; + + // 버전 목록 + 각 버전별 디테일 건수 + 현재 활성 버전 ID + const sql = companyCode === "*" + ? `SELECT v.*, (SELECT COUNT(*) FROM ${dTable} d WHERE d.version_id = v.id) as detail_count + FROM ${table} v WHERE v.bom_id = $1 ORDER BY v.created_date DESC` + : `SELECT v.*, (SELECT COUNT(*) FROM ${dTable} d WHERE d.version_id = v.id) as detail_count + FROM ${table} v WHERE v.bom_id = $1 AND v.company_code = $2 ORDER BY v.created_date DESC`; + const params = companyCode === "*" ? [bomId] : [bomId, companyCode]; + const versions = await query(sql, params); + + // bom.current_version_id도 함께 반환 + const bomRow = await queryOne<{ current_version_id: string }>( + `SELECT current_version_id FROM bom WHERE id = $1`, [bomId], + ); + + return { + versions, + currentVersionId: bomRow?.current_version_id || null, + }; +} + +/** + * 새 버전 생성: 현재 활성 버전의 bom_detail 행을 복사하여 새 version_id로 INSERT + */ export async function createBomVersion( bomId: string, companyCode: string, createdBy: string, versionTableName?: string, detailTableName?: string, @@ -75,11 +107,7 @@ export async function createBomVersion( 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], @@ -91,41 +119,80 @@ export async function createBomVersion( } const versionName = `${nextVersionNum}.0`; - const snapshot = { - bom: bomData, - details: detailRows.rows, - detailTable: dTable, - created_at: new Date().toISOString(), - }; - + // 새 버전 레코드 생성 (snapshot_data 없이) 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) + INSERT INTO ${vTable} (bom_id, version_name, revision, status, created_by, company_code) + VALUES ($1, $2, $3, 'developing', $4, $5) RETURNING * `; - const result = await client.query(insertSql, [ + const newVersion = await client.query(insertSql, [ bomId, versionName, bomData.revision ? parseInt(bomData.revision, 10) || 0 : 0, - JSON.stringify(snapshot), createdBy, companyCode, ]); + const newVersionId = newVersion.rows[0].id; - // BOM 헤더의 version 필드도 업데이트 - await client.query(`UPDATE bom SET version = $1 WHERE id = $2`, [versionName, bomId]); + // 현재 활성 버전의 bom_detail 행을 복사 + const sourceVersionId = bomData.current_version_id; + if (sourceVersionId) { + const sourceDetails = await client.query( + `SELECT * FROM ${dTable} WHERE bom_id = $1 AND version_id = $2 ORDER BY parent_detail_id NULLS FIRST, id`, + [bomId, sourceVersionId], + ); - logger.info("BOM 버전 생성", { bomId, versionName, companyCode, vTable, dTable }); - return result.rows[0]; + // old ID → new ID 매핑 (parent_detail_id 유지) + const oldToNew: Record = {}; + for (const d of sourceDetails.rows) { + const insertResult = await client.query( + `INSERT INTO ${dTable} (bom_id, version_id, parent_detail_id, child_item_id, quantity, unit, process_type, loss_rate, remark, level, base_qty, revision, seq_no, writer, company_code) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id`, + [ + bomId, + newVersionId, + 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, + d.seq_no, + d.writer, + companyCode, + ], + ); + oldToNew[d.id] = insertResult.rows[0].id; + } + + logger.info("BOM 버전 생성 - 디테일 복사 완료", { + bomId, versionName, sourceVersionId, copiedCount: sourceDetails.rows.length, + }); + } + + // BOM 헤더의 version과 current_version_id 갱신 + await client.query( + `UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`, + [versionName, newVersionId, bomId], + ); + + logger.info("BOM 버전 생성 완료", { bomId, versionName, newVersionId, companyCode }); + return newVersion.rows[0]; }); } +/** + * 버전 불러오기: bom_detail 삭제/복원 없이 current_version_id만 전환 + */ export async function loadBomVersion( bomId: string, versionId: string, companyCode: string, - versionTableName?: string, detailTableName?: 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( @@ -134,49 +201,22 @@ export async function loadBomVersion( ); if (verRow.rows.length === 0) throw new Error("버전을 찾을 수 없습니다"); - const snapshot = verRow.rows[0].snapshot_data; - if (!snapshot || !snapshot.bom) throw new Error("스냅샷 데이터가 없습니다"); + const versionName = verRow.rows[0].version_name; - // 스냅샷에 기록된 detailTable을 우선 사용, 없으면 파라미터 사용 - const snapshotDetailTable = safeTableName(snapshot.detailTable || "", dTable); - - await client.query(`DELETE FROM ${snapshotDetailTable} WHERE bom_id = $1`, [bomId]); - - const b = snapshot.bom; - const loadedVersionName = verRow.rows[0].version_name; + // BOM 헤더의 version과 current_version_id만 전환 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], + `UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`, + [versionName, versionId, 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: loadedVersionName }; + logger.info("BOM 버전 불러오기 완료", { bomId, versionId, versionName }); + return { restored: true, versionName }; }); } +/** + * 사용 확정: 선택 버전을 active로 변경 + current_version_id 갱신 + */ export async function activateBomVersion(bomId: string, versionId: string, tableName?: string) { const table = safeTableName(tableName || "", "bom_version"); @@ -197,11 +237,11 @@ export async function activateBomVersion(bomId: string, versionId: string, table `UPDATE ${table} SET status = 'active' WHERE id = $1`, [versionId], ); - // BOM 헤더 version도 갱신 + // BOM 헤더 갱신 const versionName = verRow.rows[0].version_name; await client.query( - `UPDATE bom SET version = $1 WHERE id = $2`, - [versionName, bomId], + `UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`, + [versionName, versionId, bomId], ); logger.info("BOM 버전 사용 확정", { bomId, versionId, versionName }); @@ -209,15 +249,44 @@ export async function activateBomVersion(bomId: string, versionId: string, table }); } -export async function deleteBomVersion(bomId: string, versionId: string, tableName?: string) { +/** + * 버전 삭제: 해당 version_id의 bom_detail 행도 함께 삭제 + */ +export async function deleteBomVersion( + bomId: string, versionId: string, + tableName?: string, detailTableName?: string, +) { const table = safeTableName(tableName || "", "bom_version"); - // active 상태 버전은 삭제 불가 - const checkSql = `SELECT status FROM ${table} WHERE id = $1 AND bom_id = $2`; - const checkResult = await query(checkSql, [versionId, bomId]); - if (checkResult.length > 0 && checkResult[0].status === "active") { - throw new Error("사용중인 버전은 삭제할 수 없습니다"); - } - 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; + const dTable = safeTableName(detailTableName || "", "bom_detail"); + + return transaction(async (client) => { + // active 상태 버전은 삭제 불가 + const checkResult = await client.query( + `SELECT status FROM ${table} WHERE id = $1 AND bom_id = $2`, + [versionId, bomId], + ); + if (checkResult.rows.length === 0) throw new Error("버전을 찾을 수 없습니다"); + if (checkResult.rows[0].status === "active") { + throw new Error("사용중인 버전은 삭제할 수 없습니다"); + } + + // 해당 버전의 bom_detail 행 삭제 + const deleteDetails = await client.query( + `DELETE FROM ${dTable} WHERE bom_id = $1 AND version_id = $2`, + [bomId, versionId], + ); + + // 버전 레코드 삭제 + const deleteVersion = await client.query( + `DELETE FROM ${table} WHERE id = $1 AND bom_id = $2 RETURNING id`, + [versionId, bomId], + ); + + logger.info("BOM 버전 삭제", { + bomId, versionId, + deletedDetails: deleteDetails.rowCount, + }); + + return deleteVersion.rows.length > 0; + }); } diff --git a/docs/BOM_개발_현황.md b/docs/BOM_개발_현황.md new file mode 100644 index 00000000..45c33546 --- /dev/null +++ b/docs/BOM_개발_현황.md @@ -0,0 +1,278 @@ +# BOM 관리 시스템 개발 현황 + +## 1. 개요 + +BOM(Bill of Materials) 관리 시스템은 제품의 구성 부품을 계층적으로 관리하는 기능입니다. +V2 컴포넌트 기반으로 구현되어 있으며, 설정 패널을 통해 모든 기능을 동적으로 구성할 수 있습니다. + +--- + +## 2. 아키텍처 + +### 2.1 전체 구조 + +``` +[프론트엔드] [백엔드] [데이터베이스] +v2-bom-tree (트리 뷰) ──── /api/bom ────── bomService.ts ────── bom, bom_detail +v2-bom-item-editor ──── /api/table-management ──────────── bom_history, bom_version +V2BomTreeConfigPanel (설정 패널) +``` + +### 2.2 관련 파일 목록 + +#### 프론트엔드 + +| 파일 | 설명 | +|------|------| +| `frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx` | BOM 트리/레벨 뷰 메인 컴포넌트 | +| `frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx` | 버전 관리 모달 | +| `frontend/lib/registry/components/v2-bom-tree/BomHistoryModal.tsx` | 이력 관리 모달 | +| `frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx` | BOM 항목 수정 모달 | +| `frontend/lib/registry/components/v2-bom-tree/BomTreeRenderer.tsx` | 트리 렌더러 | +| `frontend/lib/registry/components/v2-bom-tree/index.ts` | 컴포넌트 정의 (v2-bom-tree) | +| `frontend/components/v2/config-panels/V2BomTreeConfigPanel.tsx` | BOM 트리 설정 패널 | +| `frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx` | BOM 항목 편집기 (에디터 모드) | + +#### 백엔드 + +| 파일 | 설명 | +|------|------| +| `backend-node/src/routes/bomRoutes.ts` | BOM API 라우트 정의 | +| `backend-node/src/controllers/bomController.ts` | BOM 컨트롤러 (이력/버전) | +| `backend-node/src/services/bomService.ts` | BOM 서비스 (비즈니스 로직) | + +#### 데이터베이스 + +| 파일 | 설명 | +|------|------| +| `db/migrations/062_create_bom_history_version_tables.sql` | 이력/버전 테이블 DDL | + +--- + +## 3. 데이터베이스 스키마 + +### 3.1 bom (BOM 헤더) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| id | VARCHAR (UUID) | PK | +| item_id | VARCHAR | 완제품 품목 ID (item_info FK) | +| bom_name | VARCHAR | BOM 명칭 | +| version | VARCHAR | 현재 사용중인 버전명 | +| revision | VARCHAR | 차수 | +| base_qty | NUMERIC | 기준수량 | +| unit | VARCHAR | 단위 | +| remark | TEXT | 비고 | +| company_code | VARCHAR | 회사 코드 (멀티테넌시) | + +### 3.2 bom_detail (BOM 상세 - 자식 품목) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| id | VARCHAR (UUID) | PK | +| bom_id | VARCHAR | BOM 헤더 FK | +| parent_detail_id | VARCHAR | 부모 detail FK (NULL = 1레벨) | +| child_item_id | VARCHAR | 자식 품목 ID (item_info FK) | +| quantity | NUMERIC | 구성수량 (소요량) | +| unit | VARCHAR | 단위 | +| process_type | VARCHAR | 공정구분 (제조/외주 등) | +| loss_rate | NUMERIC | 손실율 | +| level | INTEGER | 레벨 | +| base_qty | NUMERIC | 기준수량 | +| revision | VARCHAR | 차수 | +| remark | TEXT | 비고 | +| company_code | VARCHAR | 회사 코드 | + +### 3.3 bom_history (BOM 이력) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| id | VARCHAR (UUID) | PK | +| bom_id | VARCHAR | BOM 헤더 FK | +| revision | VARCHAR | 차수 | +| version | VARCHAR | 버전 | +| change_type | VARCHAR | 변경구분 (등록/수정/추가/삭제) | +| change_description | TEXT | 변경내용 | +| changed_by | VARCHAR | 변경자 | +| changed_date | TIMESTAMP | 변경일시 | +| company_code | VARCHAR | 회사 코드 | + +### 3.4 bom_version (BOM 버전) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| id | VARCHAR (UUID) | PK | +| bom_id | VARCHAR | BOM 헤더 FK | +| version_name | VARCHAR | 버전명 (1.0, 2.0 ...) | +| revision | INTEGER | 생성 시점의 차수 | +| status | VARCHAR | 상태 (developing / active / inactive) | +| snapshot_data | JSONB | 스냅샷 (bom 헤더 + bom_detail 전체) | +| created_by | VARCHAR | 생성자 | +| created_date | TIMESTAMP | 생성일시 | +| company_code | VARCHAR | 회사 코드 | + +--- + +## 4. API 명세 + +### 4.1 이력 API + +| Method | Path | 설명 | +|--------|------|------| +| GET | `/api/bom/:bomId/history` | 이력 목록 조회 | +| POST | `/api/bom/:bomId/history` | 이력 등록 | + +**Query Params**: `tableName` (설정 패널에서 지정한 이력 테이블명, 기본값: `bom_history`) + +### 4.2 버전 API + +| Method | Path | 설명 | +|--------|------|------| +| GET | `/api/bom/:bomId/versions` | 버전 목록 조회 | +| POST | `/api/bom/:bomId/versions` | 신규 버전 생성 | +| POST | `/api/bom/:bomId/versions/:versionId/load` | 버전 불러오기 (데이터 복원) | +| POST | `/api/bom/:bomId/versions/:versionId/activate` | 버전 사용 확정 | +| DELETE | `/api/bom/:bomId/versions/:versionId` | 버전 삭제 | + +**Body/Query**: `tableName`, `detailTable` (설정 패널에서 지정한 테이블명) + +--- + +## 5. 버전 관리 구조 + +### 5.1 핵심 원리 + +**각 버전은 생성 시점의 BOM 전체 구조(헤더 + 모든 디테일)를 JSONB 스냅샷으로 저장합니다.** + +``` +버전 1.0 (active) + └─ snapshot_data: { bom: {...}, details: [{...}, {...}, ...] } + +버전 2.0 (developing) + └─ snapshot_data: { bom: {...}, details: [{...}, {...}, ...] } + +버전 3.0 (inactive) + └─ snapshot_data: { bom: {...}, details: [{...}, {...}, ...] } +``` + +### 5.2 버전 상태 (status) + +| 상태 | 설명 | +|------|------| +| `developing` | 개발중 - 신규 생성 시 기본 상태 | +| `active` | 사용중 - "사용 확정" 후 운영 상태 | +| `inactive` | 사용중지 - 이전에 active였다가 다른 버전이 확정된 경우 | + +### 5.3 버전 워크플로우 + +``` +[현재 BOM 데이터] + │ + ▼ +신규 버전 생성 ───► 버전 N.0 (status: developing) + │ + ├── 불러오기: 해당 스냅샷의 데이터로 현재 BOM을 복원 + │ (status 변경 없음, BOM 헤더 version 변경 없음) + │ + ├── 사용 확정: status → active, + │ 기존 active 버전 → inactive, + │ BOM 헤더의 version 필드 갱신 + │ + └── 삭제: active 상태가 아닌 경우만 삭제 가능 +``` + +### 5.4 불러오기 vs 사용 확정 + +| 동작 | 불러오기 (Load) | 사용 확정 (Activate) | +|------|----------------|---------------------| +| BOM 데이터 복원 | O (detail 전체 교체) | X | +| BOM 헤더 업데이트 | O (base_qty, unit 등) | version 필드만 | +| 버전 status 변경 | X | active로 변경 | +| 기존 active 비활성화 | X | O (→ inactive) | +| BOM 목록 새로고침 | O (refreshTable) | O (refreshTable) | + +--- + +## 6. 설정 패널 구성 + +`V2BomTreeConfigPanel.tsx`에서 아래 항목을 설정할 수 있습니다: + +### 6.1 기본 탭 + +| 설정 항목 | 설명 | 기본값 | +|-----------|------|--------| +| 디테일 테이블 | BOM 상세 데이터 테이블 | `bom_detail` | +| 외래키 | BOM 헤더와의 연결 키 | `bom_id` | +| 부모키 | 부모-자식 관계 키 | `parent_detail_id` | +| 이력 테이블 | BOM 변경 이력 테이블 | `bom_history` | +| 버전 테이블 | BOM 버전 관리 테이블 | `bom_version` | +| 이력 기능 표시 | 이력 버튼 노출 여부 | `true` | +| 버전 기능 표시 | 버전 버튼 노출 여부 | `true` | + +### 6.2 컬럼 탭 + +- 소스 테이블 (bom/item_info 등)에서 표시할 컬럼 선택 +- 디테일 테이블에서 표시할 컬럼 선택 +- 컬럼 순서 드래그앤드롭 +- 컬럼별 라벨, 너비, 정렬 설정 + +--- + +## 7. 뷰 모드 + +### 7.1 트리 뷰 (기본) + +- 계층적 들여쓰기로 부모-자식 관계 표현 +- 레벨별 시각 구분: + - **0레벨 (가상 루트)**: 파란색 배경 + 파란 좌측 바 + - **1레벨**: 흰색 배경 + 초록 좌측 바 + - **2레벨**: 연회색 배경 + 주황 좌측 바 + - **3레벨 이상**: 진회색 배경 + 보라 좌측 바 +- 펼침/접힘 (정전개/역전개) + +### 7.2 레벨 뷰 + +- 평면 테이블 형태로 표시 +- "레벨0", "레벨1", "레벨2" ... 컬럼에 체크마크로 계층 표시 +- 같은 레벨별 배경색 구분 적용 + +--- + +## 8. 주요 기능 목록 + +| 기능 | 상태 | 설명 | +|------|------|------| +| BOM 트리 표시 | 완료 | 계층적 트리 뷰 + 레벨 뷰 | +| BOM 항목 편집 | 완료 | 더블클릭으로 수정 모달 (0레벨: bom, 하위: bom_detail) | +| 이력 관리 | 완료 | 변경 이력 조회/등록 모달 | +| 버전 관리 | 완료 | 버전 생성/불러오기/사용 확정/삭제 | +| 설정 패널 | 완료 | 테이블/컬럼/기능 동적 설정 | +| 디자인 모드 프리뷰 | 완료 | 실제 화면과 일치하는 디자인 모드 표시 | +| 컬럼 크기 조절 | 완료 | 헤더 드래그로 컬럼 너비 변경 | +| 텍스트 말줄임 | 완료 | 긴 텍스트 `...` 처리 | +| 레벨별 시각 구분 | 완료 | 배경색 + 좌측 컬러 바 | +| 정전개/역전개 | 완료 | 전체 펼침/접기 토글 | +| 좌우 스크롤 | 완료 | 컬럼 크기가 커질 때 수평 스크롤 | +| BOM 목록 자동 새로고침 | 완료 | 버전 불러오기/확정 후 좌측 패널 자동 리프레시 | +| BOM 하위 품목 저장 | 완료 | BomItemEditorComponent에서 직접 INSERT/UPDATE/DELETE | +| 차수 (Revision) 자동 증가 | 미구현 | BOM 변경 시 헤더 revision 자동 +1 | + +--- + +## 9. 보안 고려사항 + +- **SQL 인젝션 방지**: `safeTableName()` 함수로 테이블명 검증 (`^[a-zA-Z_][a-zA-Z0-9_]*$`) +- **멀티테넌시**: 모든 API에서 `company_code` 필터링 적용 +- **최고 관리자**: `company_code = "*"` 시 전체 데이터 조회 가능 +- **인증**: `authenticateToken` 미들웨어로 모든 라우트 보호 + +--- + +## 10. 향후 개선 사항 + +- [ ] 차수(Revision) 자동 증가 구현 (BOM 헤더 레벨) +- [ ] 버전 비교 기능 (두 버전 간 diff) +- [ ] BOM 복사 기능 +- [ ] 이력 자동 등록 (수정/저장 시 자동으로 이력 생성) +- [ ] Excel 내보내기/가져오기 +- [ ] BOM 유효성 검증 (순환참조 방지 등) 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 95f9987e..e4521ac0 100644 --- a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback, useMemo } from "react"; +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { GripVertical, Plus, @@ -487,6 +487,28 @@ export function BomItemEditorComponent({ return null; }, [propBomId, formData, selectedRowsData]); + // BOM 전용 API로 현재 current_version_id 조회 + const fetchCurrentVersionId = useCallback(async (id: string): Promise => { + try { + const res = await apiClient.get(`/bom/${id}/versions`); + if (res.data?.success) { + // bom.current_version_id를 직접 반환 (불러오기와 사용확정 구분) + if (res.data.currentVersionId) return res.data.currentVersionId; + // fallback: active 상태 버전 + const activeVersion = res.data.data?.find((v: any) => v.status === "active"); + if (activeVersion) return activeVersion.id; + } + } catch (e) { + console.error("[BomItemEditor] current_version_id 조회 실패:", e); + } + return null; + }, []); + + // formData에서 가져오는 versionId (fallback용) + const propsVersionId = (formData?.current_version_id as string) + || (selectedRowsData?.[0]?.current_version_id as string) + || null; + // ─── 카테고리 옵션 로드 (리피터 방식) ─── useEffect(() => { @@ -544,17 +566,31 @@ export function BomItemEditorComponent({ referenceTable: sourceTable, })); - const result = await entityJoinApi.getTableDataWithJoins(mainTableName, { - page: 1, - size: 500, - search: { [fkColumn]: id }, - sortBy: "seq_no", - sortOrder: "asc", - enableEntityJoin: true, - additionalJoinColumns: additionalJoinColumns.length > 0 ? additionalJoinColumns : undefined, + // 서버에서 최신 current_version_id 조회 (항상 최신 보장) + const freshVersionId = await fetchCurrentVersionId(id); + const effectiveVersionId = freshVersionId || propsVersionId; + + const searchFilter: Record = { [fkColumn]: id }; + if (effectiveVersionId) { + searchFilter.version_id = effectiveVersionId; + } + + // autoFilter 비활성화: BOM 전용 API로 company_code 관리 + const res = await apiClient.get(`/table-management/tables/${mainTableName}/data-with-joins`, { + params: { + page: 1, + size: 500, + search: JSON.stringify(searchFilter), + sortBy: "seq_no", + sortOrder: "asc", + enableEntityJoin: true, + additionalJoinColumns: additionalJoinColumns.length > 0 ? JSON.stringify(additionalJoinColumns) : undefined, + autoFilter: JSON.stringify({ enabled: false }), + }, }); - const rows = (result.data || []).map((row: Record) => { + const rawData = res.data?.data?.data || res.data?.data || []; + const rows = (Array.isArray(rawData) ? rawData : []).map((row: Record) => { const mapped = { ...row }; for (const key of Object.keys(row)) { if (key.startsWith(`${sourceFk}_`)) { @@ -578,14 +614,20 @@ export function BomItemEditorComponent({ setLoading(false); } }, - [mainTableName, fkColumn, sourceFk, sourceTable, columns], + [mainTableName, fkColumn, sourceFk, sourceTable, columns, fetchCurrentVersionId, propsVersionId], ); + // formData.current_version_id가 변경될 때도 재로드 (버전 전환 시 반영) + const formVersionRef = useRef(null); useEffect(() => { - if (bomId && !isDesignMode) { + if (!bomId || isDesignMode) return; + const currentFormVersion = formData?.current_version_id as string || null; + // bomId가 바뀌거나, formData의 current_version_id가 바뀌면 재로드 + if (formVersionRef.current !== currentFormVersion || !formVersionRef.current) { + formVersionRef.current = currentFormVersion; loadBomDetails(bomId); } - }, [bomId, isDesignMode, loadBomDetails]); + }, [bomId, isDesignMode, loadBomDetails, formData?.current_version_id]); // ─── 트리 빌드 (동적 데이터) ─── @@ -669,6 +711,164 @@ export function BomItemEditorComponent({ [onChange, flattenTree], ); + // ─── DB 저장 (INSERT/UPDATE/DELETE 일괄) ─── + + const [saving, setSaving] = useState(false); + const [hasChanges, setHasChanges] = useState(false); + + const originalDataRef = React.useRef>(new Set()); + useEffect(() => { + if (treeData.length > 0 && originalDataRef.current.size === 0) { + const collectIds = (nodes: BomItemNode[]) => { + nodes.forEach((n) => { + if (n.id) originalDataRef.current.add(n.id); + collectIds(n.children); + }); + }; + collectIds(treeData); + } + }, [treeData]); + + const markChanged = useCallback(() => setHasChanges(true), []); + const originalNotifyChange = notifyChange; + const notifyChangeWithDirty = useCallback( + (newTree: BomItemNode[]) => { + originalNotifyChange(newTree); + markChanged(); + }, + [originalNotifyChange, markChanged], + ); + + // EditModal 저장 시 beforeFormSave 이벤트로 디테일 데이터도 함께 저장 + useEffect(() => { + if (isDesignMode || !bomId) return; + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + console.log("[BomItemEditor] beforeFormSave 이벤트 수신:", { + bomId, + treeDataLength: treeData.length, + hasRef: !!handleSaveAllRef.current, + }); + if (treeData.length > 0 && handleSaveAllRef.current) { + const savePromise = handleSaveAllRef.current(); + if (detail?.pendingPromises) { + detail.pendingPromises.push(savePromise); + console.log("[BomItemEditor] pendingPromises에 저장 Promise 등록 완료"); + } + } + }; + window.addEventListener("beforeFormSave", handler); + console.log("[BomItemEditor] beforeFormSave 리스너 등록:", { bomId, isDesignMode }); + return () => window.removeEventListener("beforeFormSave", handler); + }, [isDesignMode, bomId, treeData.length]); + + const handleSaveAllRef = React.useRef<(() => Promise) | null>(null); + + const handleSaveAll = useCallback(async () => { + if (!bomId) return; + setSaving(true); + try { + // 저장 시점에도 최신 version_id 조회 + const saveVersionId = await fetchCurrentVersionId(bomId) || propsVersionId; + + const collectAll = (nodes: BomItemNode[], parentRealId: string | null, level: number): any[] => { + const result: any[] = []; + nodes.forEach((node, idx) => { + result.push({ + node, + parentRealId, + level, + seqNo: idx + 1, + }); + if (node.children.length > 0) { + result.push(...collectAll(node.children, node.id || node.tempId, level + 1)); + } + }); + return result; + }; + + const allNodes = collectAll(treeData, null, 0); + const tempToReal: Record = {}; + let savedCount = 0; + + for (const { node, parentRealId, level, seqNo } of allNodes) { + const realParentId = parentRealId + ? tempToReal[parentRealId] || parentRealId + : null; + + if (node._isNew) { + const payload: Record = { + ...node.data, + [fkColumn]: bomId, + [parentKeyColumn]: realParentId, + seq_no: String(seqNo), + level: String(level), + company_code: companyCode || undefined, + version_id: saveVersionId || undefined, + }; + delete payload.id; + delete payload.tempId; + delete payload._isNew; + delete payload._isDeleted; + + const resp = await apiClient.post( + `/table-management/tables/${mainTableName}/add`, + payload, + ); + const newId = resp.data?.data?.id; + if (newId) tempToReal[node.tempId] = newId; + savedCount++; + } else if (node.id) { + const updatedData: Record = { + ...node.data, + id: node.id, + [parentKeyColumn]: realParentId, + seq_no: String(seqNo), + level: String(level), + }; + delete updatedData.tempId; + delete updatedData._isNew; + delete updatedData._isDeleted; + Object.keys(updatedData).forEach((k) => { + if (k.startsWith(`${sourceFk}_`)) delete updatedData[k]; + }); + + await apiClient.put( + `/table-management/tables/${mainTableName}/edit`, + { originalData: { id: node.id }, updatedData }, + ); + savedCount++; + } + } + + const currentIds = new Set(allNodes.filter((a) => a.node.id).map((a) => a.node.id)); + for (const oldId of originalDataRef.current) { + if (!currentIds.has(oldId)) { + await apiClient.delete( + `/table-management/tables/${mainTableName}/delete`, + { data: [{ id: oldId }] }, + ); + savedCount++; + } + } + + originalDataRef.current = new Set(allNodes.filter((a) => a.node.id || tempToReal[a.node.tempId]).map((a) => a.node.id || tempToReal[a.node.tempId])); + setHasChanges(false); + if (bomId) loadBomDetails(bomId); + window.dispatchEvent(new CustomEvent("refreshTable")); + console.log(`[BomItemEditor] ${savedCount}건 저장 완료`); + } catch (error) { + console.error("[BomItemEditor] 저장 실패:", error); + alert("저장 중 오류가 발생했습니다."); + } finally { + setSaving(false); + } + }, [bomId, treeData, fkColumn, parentKeyColumn, mainTableName, companyCode, sourceFk, loadBomDetails, fetchCurrentVersionId, propsVersionId]); + + useEffect(() => { + handleSaveAllRef.current = handleSaveAll; + }, [handleSaveAll]); + // ─── 노드 조작 함수들 ─── // 트리에서 특정 노드 찾기 (재귀) @@ -699,18 +899,18 @@ export function BomItemEditorComponent({ ...node, data: { ...node.data, [field]: value }, })); - notifyChange(newTree); + notifyChangeWithDirty(newTree); }, - [treeData, notifyChange], + [treeData, notifyChangeWithDirty], ); // 노드 삭제 const handleDelete = useCallback( (tempId: string) => { const newTree = findAndUpdate(treeData, tempId, () => null); - notifyChange(newTree); + notifyChangeWithDirty(newTree); }, - [treeData, notifyChange], + [treeData, notifyChangeWithDirty], ); // 하위 품목 추가 시작 (모달 열기) @@ -778,9 +978,9 @@ export function BomItemEditorComponent({ setExpandedNodes((prev) => new Set([...prev, addTargetParentId])); } - notifyChange(newTree); + notifyChangeWithDirty(newTree); }, - [addTargetParentId, treeData, notifyChange, cfg], + [addTargetParentId, treeData, notifyChangeWithDirty, cfg], ); // 펼침/접기 토글 @@ -882,11 +1082,11 @@ export function BomItemEditorComponent({ 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)); + notifyChangeWithDirty(reindex(result)); } setDragId(null); - }, [dragId, treeData, notifyChange]); + }, [dragId, treeData, notifyChangeWithDirty]); // ─── 재귀 렌더링 ─── @@ -1086,15 +1286,29 @@ export function BomItemEditorComponent({
{/* 헤더 */}
-

하위 품목 구성

- +

+ 하위 품목 구성 + {hasChanges && (미저장)} +

+
+ + +
{/* 트리 목록 */} diff --git a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx index 6061f4cd..957b8d85 100644 --- a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx @@ -16,7 +16,8 @@ import { Check, } from "lucide-react"; import { cn } from "@/lib/utils"; -import { entityJoinApi } from "@/lib/api/entityJoin"; + +import { apiClient } from "@/lib/api/client"; import { Button } from "@/components/ui/button"; import { BomDetailEditModal } from "./BomDetailEditModal"; import { BomHistoryModal } from "./BomHistoryModal"; @@ -108,6 +109,7 @@ export function BomTreeComponent({ return null; }, [formData, selectedRowsData]); + const selectedHeaderData = useMemo(() => { const raw = selectedRowsData?.[0] || (formData?.id ? formData : null); if (!raw) return null; @@ -165,16 +167,27 @@ export function BomTreeComponent({ if (!bomId) return; setLoading(true); try { - const result = await entityJoinApi.getTableDataWithJoins(detailTable, { - page: 1, - size: 500, - search: { [foreignKey]: bomId }, - sortBy: "seq_no", - sortOrder: "asc", - enableEntityJoin: true, + const searchFilter: Record = { [foreignKey]: bomId }; + const versionId = headerData?.current_version_id; + if (versionId) { + searchFilter.version_id = versionId; + } + + // autoFilter 비활성화: BOM 전용 API로 company_code 관리하므로 autoFilter 불필요 + const res = await apiClient.get(`/table-management/tables/${detailTable}/data-with-joins`, { + params: { + page: 1, + size: 500, + search: JSON.stringify(searchFilter), + sortBy: "seq_no", + sortOrder: "asc", + enableEntityJoin: true, + autoFilter: JSON.stringify({ enabled: false }), + }, }); - const rows = (result.data || []).map((row: Record) => { + const rawData = res.data?.data?.data || res.data?.data || []; + const rows = (Array.isArray(rawData) ? rawData : []).map((row: Record) => { const mapped = { ...row }; for (const key of Object.keys(row)) { if (key.startsWith(`${sourceFk}_`)) { @@ -192,7 +205,6 @@ export function BomTreeComponent({ const detailTree = buildTree(rows); - // BOM 헤더를 가상 0레벨 루트로 삽입 const virtualRoot = buildVirtualRoot(headerData, detailTree); if (virtualRoot) { setTreeData([virtualRoot]); @@ -224,15 +236,141 @@ export function BomTreeComponent({ return roots; }; + // BOM 전용 API로 최신 current_version_id 조회 (company_code 필터 무관) + const fetchCurrentVersionId = useCallback(async (bomId: string): Promise => { + try { + const res = await apiClient.get(`/bom/${bomId}/versions`); + if (res.data?.success) { + if (res.data.currentVersionId) return res.data.currentVersionId; + const activeVersion = res.data.data?.find((v: any) => v.status === "active"); + return activeVersion?.id || null; + } + } catch (e) { + console.error("[BomTree] active 버전 조회 실패:", e); + } + return null; + }, []); + + // BOM 전용 헤더 API로 최신 데이터 조회 (autoFilter 영향 없음) + const fetchBomHeader = useCallback(async (bomId: string): Promise => { + try { + const res = await apiClient.get(`/bom/${bomId}/header`); + if (res.data?.success && res.data.data) { + const raw = res.data.data; + return { + ...raw, + id: raw.id, + item_name: raw.item_name || "", + item_code: raw.item_number || raw.item_code || "", + item_type: raw.item_type || raw.division || "", + } as BomHeaderInfo; + } + } catch (e) { + console.error("[BomTree] BOM 헤더 API 조회 실패:", e); + } + return null; + }, []); + + // BOM 선택 시 전용 API로 헤더 + 디테일 로드 + const loadingBomIdRef = React.useRef(null); + useEffect(() => { - if (selectedBomId) { - setHeaderInfo(selectedHeaderData); - loadBomDetails(selectedBomId, selectedHeaderData); - } else { + if (!selectedBomId) { setHeaderInfo(null); setTreeData([]); + loadingBomIdRef.current = null; + return; } - }, [selectedBomId, selectedHeaderData, loadBomDetails]); + + // 현재 요청 ID로 stale 응답 필터링 (React StrictMode 호환) + const requestId = selectedBomId; + loadingBomIdRef.current = requestId; + + const load = async () => { + let header = await fetchBomHeader(requestId); + if (!header && selectedHeaderData) { + header = { ...selectedHeaderData, id: requestId } as BomHeaderInfo; + const freshVersionId = await fetchCurrentVersionId(requestId); + if (freshVersionId) header.current_version_id = freshVersionId; + } + // stale 응답 무시: 다른 BOM이 선택됐거나 useEffect가 다시 실행된 경우 + if (loadingBomIdRef.current !== requestId || !header) return; + + setHeaderInfo(header); + loadBomDetails(requestId, header); + }; + load(); + }, [selectedBomId, selectedHeaderData, loadBomDetails, fetchBomHeader, fetchCurrentVersionId]); + + // refreshTable 이벤트 수신 시 BOM 헤더 + 디테일 최신 데이터로 갱신 + useEffect(() => { + const handleRefresh = async () => { + if (!selectedBomId) return; + try { + let header = await fetchBomHeader(selectedBomId); + if (!header && headerInfo) { + // API 실패 시 현재 headerInfo + 최신 version_id로 fallback + const freshVersionId = await fetchCurrentVersionId(selectedBomId); + header = { ...headerInfo, current_version_id: freshVersionId || headerInfo.current_version_id }; + } + if (header) { + setHeaderInfo(header); + loadBomDetails(selectedBomId, header); + } + } catch (e) { + console.error("[BomTree] refreshTable 헤더 갱신 실패:", e); + } + }; + + window.addEventListener("refreshTable", handleRefresh); + return () => window.removeEventListener("refreshTable", handleRefresh); + }, [selectedBomId, loadBomDetails, fetchBomHeader, fetchCurrentVersionId, headerInfo]); + + // EditModal 열릴 때 editData를 최신 headerInfo로 보정 (버전/마스터 데이터 stale 방지) + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (!detail?.editData || !headerInfo) return; + const editId = String(detail.editData.id || ""); + const bomId = String(selectedBomId || ""); + if (editId !== bomId) return; + + console.log("[BomTree] openEditModal 가로채기 - editData 보정", { + oldVersion: detail.editData.version, + newVersion: headerInfo.version, + oldCurrentVersionId: detail.editData.current_version_id, + newCurrentVersionId: headerInfo.current_version_id, + }); + + // headerInfo의 모든 필드를 editData에 덮어쓰기 (최신 서버 데이터 보장) + Object.keys(headerInfo).forEach((key) => { + if ((headerInfo as any)[key] !== undefined && (headerInfo as any)[key] !== null) { + detail.editData[key] = (headerInfo as any)[key]; + } + }); + }; + // capture: true → EditModal 리스너(bubble)보다 반드시 먼저 실행 + window.addEventListener("openEditModal", handler, true); + return () => window.removeEventListener("openEditModal", handler, true); + }, [selectedBomId, headerInfo]); + + // EditModal 저장 시 version 값을 현재 headerInfo 기준으로 보정 + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail?.formData && detail.formData.id === selectedBomId && headerInfo) { + if (headerInfo.version && detail.formData.version !== headerInfo.version) { + console.log("[BomTree] formData.version 보정:", detail.formData.version, "→", headerInfo.version); + detail.formData.version = headerInfo.version; + } + if (headerInfo.revision && detail.formData.revision !== headerInfo.revision) { + detail.formData.revision = headerInfo.revision; + } + } + }; + window.addEventListener("beforeFormSave", handler); + return () => window.removeEventListener("beforeFormSave", handler); + }, [selectedBomId, headerInfo]); const toggleNode = useCallback((nodeId: string) => { setExpandedNodes((prev) => { @@ -568,6 +706,13 @@ export function BomTreeComponent({ return displayColumns.filter((c) => c.key !== "level"); }, [displayColumns]); + // 트리/레벨 뷰 전환 시 데이터 열 위치 고정을 위한 공통 접두 영역 너비 + const prefixAreaWidth = useMemo(() => { + const treeIconWidth = Math.max(52, maxDepth * INDENT_PX + 44); + const levelColsWidth = (maxDepth + 1) * 30; + return Math.max(treeIconWidth, levelColsWidth); + }, [maxDepth]); + // ─── 메인 렌더링 ─── return ( @@ -702,15 +847,23 @@ export function BomTreeComponent({ /* ═══ 레벨 뷰 ═══ */ + + {levelColumnsForView.map((lvl) => { + const eachWidth = Math.floor(prefixAreaWidth / levelColumnsForView.length); + return ; + })} + {dataColumnsForLevelView.map((col) => ( + + ))} + {levelColumnsForView.map((lvl) => ( @@ -742,19 +895,22 @@ export function BomTreeComponent({ const isRoot = !!node._isVirtualRoot; const displayDepth = isRoot ? 0 : depth; + const lvlDepthBg = isRoot + ? "border-gray-200 bg-blue-50/50 font-medium hover:bg-blue-50/70" + : selectedNodeId === node.id + ? "border-gray-100 bg-primary/5" + : depth === 1 + ? "border-gray-100 bg-white hover:bg-gray-50/60" + : depth === 2 + ? "border-gray-100 bg-gray-50/40 hover:bg-gray-100/50" + : depth >= 3 + ? "border-gray-100 bg-gray-100/40 hover:bg-gray-100/60" + : "border-gray-100 bg-white hover:bg-gray-50/60"; + return ( setSelectedNodeId(node.id)} onDoubleClick={() => { setEditTargetNode(node); @@ -765,7 +921,6 @@ export function BomTreeComponent({
{lvl}
{displayDepth === lvl ? ( @@ -793,10 +948,16 @@ export function BomTreeComponent({
) : ( /* ═══ 트리 뷰 ═══ */ - +
+ + + {displayColumns.map((col) => ( + + ))} + - + {displayColumns.map((col) => { const centered = ["quantity", "loss_rate", "level", "base_qty", "revision", "seq_no"].includes(col.key); const w = colWidths[col.key]; @@ -828,19 +989,28 @@ export function BomTreeComponent({ const itemType = node.child_item_type || node.item_type || ""; const ItemIcon = getItemIcon(itemType); + const depthBg = isRoot + ? "border-gray-200 bg-blue-50/50 font-medium hover:bg-blue-50/70" + : isSelected + ? "border-gray-100 bg-primary/5" + : depth === 1 + ? "border-gray-100 bg-white hover:bg-gray-50/60" + : depth === 2 + ? "border-gray-100 bg-gray-50/40 hover:bg-gray-100/50" + : depth >= 3 + ? "border-gray-100 bg-gray-100/40 hover:bg-gray-100/60" + : "border-gray-100 bg-white hover:bg-gray-50/60"; + + const depthBarColor = isRoot + ? "bg-blue-400" + : depth === 1 ? "bg-emerald-400" + : depth === 2 ? "bg-amber-400" + : depth >= 3 ? "bg-purple-400" : "bg-gray-300"; + return ( { setSelectedNodeId(node.id); if (hasChildren) toggleNode(node.id); @@ -850,7 +1020,8 @@ export function BomTreeComponent({ setEditModalOpen(true); }} > -
+ +
{hasChildren ? ( @@ -902,7 +1073,7 @@ export function BomTreeComponent({ isRootNode={!!editTargetNode?._isVirtualRoot} tableName={detailTable} onSaved={() => { - if (selectedBomId) loadBomDetails(selectedBomId, selectedHeaderData); + if (selectedBomId) loadBomDetails(selectedBomId, headerInfo); }} /> @@ -923,7 +1094,6 @@ export function BomTreeComponent({ tableName={versionTable} detailTable={detailTable} onVersionLoaded={() => { - if (selectedBomId) loadBomDetails(selectedBomId, selectedHeaderData); window.dispatchEvent(new CustomEvent("refreshTable")); }} /> diff --git a/frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx b/frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx index ccca8676..d36bfe6e 100644 --- a/frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx +++ b/frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx @@ -80,8 +80,8 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve try { const res = await apiClient.post(`/bom/${bomId}/versions/${versionId}/load`, { tableName, detailTable }); if (res.data?.success) { - onVersionLoaded?.(); loadVersions(); + onVersionLoaded?.(); } } catch (error) { console.error("[BomVersion] 불러오기 실패:", error); diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 2c4af6e3..29c0c9d5 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -558,6 +558,34 @@ export class ButtonActionExecutor { return false; } + // 저장 전 이벤트 발생 (BomItemEditor 등 하위 컴포넌트 연동 저장) + const beforeSaveEventDetail = { + formData: context.formData, + skipDefaultSave: false, + validationFailed: false, + validationErrors: [] as string[], + pendingPromises: [] as Promise[], + }; + window.dispatchEvent( + new CustomEvent("beforeFormSave", { + detail: beforeSaveEventDetail, + }), + ); + + // 비동기 핸들러가 등록한 Promise들 대기 + 동기 핸들러를 위한 최소 대기 + if (beforeSaveEventDetail.pendingPromises.length > 0) { + console.log(`[handleSave] 비동기 beforeFormSave 핸들러 ${beforeSaveEventDetail.pendingPromises.length}건 대기 중...`); + await Promise.all(beforeSaveEventDetail.pendingPromises); + } else { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + // 검증 실패 시 저장 중단 + if (beforeSaveEventDetail.validationFailed) { + console.log("❌ [handleSave] 검증 실패로 저장 중단:", beforeSaveEventDetail.validationErrors); + return false; + } + // 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용 if (onSave) { try { @@ -571,32 +599,6 @@ export class ButtonActionExecutor { console.log("⚠️ [handleSave] onSave 콜백 없음 - 기본 저장 로직 실행"); - // 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집) - // context.formData를 이벤트 detail에 포함하여 직접 수정 가능하게 함 - // skipDefaultSave 플래그를 통해 기본 저장 로직을 건너뛸 수 있음 - - // 🔧 디버그: beforeFormSave 이벤트 전 formData 확인 - console.log("🔍 [handleSave] beforeFormSave 이벤트 전:", { - keys: Object.keys(context.formData || {}), - hasCompanyImage: "company_image" in (context.formData || {}), - companyImageValue: context.formData?.company_image, - }); - - const beforeSaveEventDetail = { - formData: context.formData, - skipDefaultSave: false, - validationFailed: false, - validationErrors: [] as string[], - }; - window.dispatchEvent( - new CustomEvent("beforeFormSave", { - detail: beforeSaveEventDetail, - }), - ); - - // 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함 - await new Promise((resolve) => setTimeout(resolve, 100)); - // 🔧 디버그: beforeFormSave 이벤트 후 formData 확인 console.log("🔍 [handleSave] beforeFormSave 이벤트 후:", { keys: Object.keys(context.formData || {}), @@ -604,38 +606,16 @@ export class ButtonActionExecutor { companyImageValue: context.formData?.company_image, }); - // 검증 실패 시 저장 중단 - if (beforeSaveEventDetail.validationFailed) { - console.log("❌ [handleSave] 검증 실패로 저장 중단:", beforeSaveEventDetail.validationErrors); - return false; - } - - // 🔧 skipDefaultSave 플래그 확인 - SelectedItemsDetailInput 등에서 자체 UPSERT 처리 시 기본 저장 건너뛰기 + // skipDefaultSave 플래그 확인 if (beforeSaveEventDetail.skipDefaultSave) { return true; } - // 🆕 _tableSection_ 데이터가 있는지 확인 (TableSectionRenderer 사용 시) - // beforeFormSave 이벤트 후에 체크해야 UniversalFormModal에서 병합된 데이터를 확인할 수 있음 + // _tableSection_ 데이터 확인 (TableSectionRenderer 사용 시) const hasTableSectionData = Object.keys(context.formData || {}).some( (k) => k.startsWith("_tableSection_") || k.startsWith("__tableSection_"), ); - if (hasTableSectionData) { - } - - // 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용 - // 단, _tableSection_ 데이터가 있으면 건너뛰기 (handleUniversalFormModalTableSectionSave가 처리) - if (onSave && !hasTableSectionData) { - try { - await onSave(); - return true; - } catch (error) { - console.error("❌ [handleSave] onSave 콜백 실행 오류:", error); - throw error; - } - } - console.log("⚠️ [handleSave] 기본 저장 로직 실행 (onSave 콜백 없음 또는 _tableSection_ 데이터 있음)"); // 🆕 렉 구조 컴포넌트 일괄 저장 감지 diff --git a/package-lock.json b/package-lock.json index becf751e..e6ada6f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,8 @@ }, "devDependencies": { "@types/oracledb": "^6.9.1", - "@types/pg": "^8.15.5" + "@types/pg": "^8.15.5", + "playwright": "^1.58.2" } }, "node_modules/@azure-rest/core-client": { @@ -470,6 +471,7 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz", "integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/react-reconciler": "^0.32.0", @@ -710,6 +712,7 @@ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz", "integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==", "license": "MIT", + "peer": true, "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", @@ -1076,8 +1079,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/d3-color": { "version": "3.1.0", @@ -1136,6 +1138,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -1473,6 +1476,21 @@ "node": ">= 6" } }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -2061,6 +2079,38 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -2116,6 +2166,7 @@ "integrity": "sha512-aRvldGE5UUJTtVmFiH3WfNFNiqFlAtePUxcI0UEGlnXCX7DqhiMT5TRYwncHFeA/Reca5W6ToXXyCMTeFPdSXA==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/config": "6.16.2", "@prisma/engines": "6.16.2" @@ -2331,8 +2382,7 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/semver": { "version": "7.7.2", @@ -2451,7 +2501,8 @@ "version": "0.180.0", "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/three-mesh-bvh": { "version": "0.8.3", @@ -2547,6 +2598,7 @@ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } diff --git a/package.json b/package.json index c6c80dce..16b3b1e8 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "devDependencies": { "@types/oracledb": "^6.9.1", - "@types/pg": "^8.15.5" + "@types/pg": "^8.15.5", + "playwright": "^1.58.2" } }