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.
This commit is contained in:
DDD1542
2026-02-26 13:09:32 +09:00
parent 0f3ec495a5
commit 46ea3612fd
11 changed files with 1011 additions and 215 deletions

7
.gitignore vendored
View File

@@ -286,4 +286,9 @@ uploads/
*.hwp
*.hwpx
claude.md
claude.md
# AI 에이전트 테스트 산출물
*-test-screenshots/
*-screenshots/
*-test.mjs

View File

@@ -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;

View File

@@ -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);

View File

@@ -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<Record<string, any>>(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<string, string> = {};
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<string, string> = {};
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;
});
}

278
docs/BOM_개발_현황.md Normal file
View File

@@ -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 유효성 검증 (순환참조 방지 등)

View File

@@ -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<string | null> => {
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<string, any> = { [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<string, any>) => {
const rawData = res.data?.data?.data || res.data?.data || [];
const rows = (Array.isArray(rawData) ? rawData : []).map((row: Record<string, any>) => {
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<string | null>(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<Set<string>>(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<void>) | 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<string, string> = {};
let savedCount = 0;
for (const { node, parentRealId, level, seqNo } of allNodes) {
const realParentId = parentRealId
? tempToReal[parentRealId] || parentRealId
: null;
if (node._isNew) {
const payload: Record<string, any> = {
...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<string, any> = {
...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({
<div className="space-y-3">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold"> </h4>
<Button
onClick={handleAddRoot}
size="sm"
className="h-8 text-xs"
>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
<h4 className="text-sm font-semibold">
{hasChanges && <span className="ml-1.5 text-[10px] text-amber-500">()</span>}
</h4>
<div className="flex gap-1.5">
<Button
onClick={handleAddRoot}
variant="outline"
size="sm"
className="h-7 text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
<Button
onClick={handleSaveAll}
disabled={saving || !hasChanges}
size="sm"
className="h-7 text-xs"
>
{saving ? "저장중..." : "저장"}
</Button>
</div>
</div>
{/* 트리 목록 */}

View File

@@ -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<string, any> = { [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<string, any>) => {
const rawData = res.data?.data?.data || res.data?.data || [];
const rows = (Array.isArray(rawData) ? rawData : []).map((row: Record<string, any>) => {
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<string | null> => {
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<BomHeaderInfo | null> => {
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<string | null>(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({
/* ═══ 레벨 뷰 ═══ */
<table
className="w-full border-collapse text-xs"
style={{ minWidth: `${(maxDepth + 1) * 30 + dataColumnsForLevelView.length * 90}px` }}
style={{ minWidth: `${prefixAreaWidth + dataColumnsForLevelView.length * 90}px` }}
>
<colgroup>
{levelColumnsForView.map((lvl) => {
const eachWidth = Math.floor(prefixAreaWidth / levelColumnsForView.length);
return <col key={`lv-col-${lvl}`} style={{ width: `${eachWidth}px`, minWidth: `${eachWidth}px` }} />;
})}
{dataColumnsForLevelView.map((col) => (
<col key={`dc-col-${col.key}`} style={{ width: colWidths[col.key] ? `${colWidths[col.key]}px` : undefined }} />
))}
</colgroup>
<thead className="sticky top-0 z-10">
<tr className="border-b bg-gray-50">
{levelColumnsForView.map((lvl) => (
<th
key={`lv-${lvl}`}
className="whitespace-nowrap px-0.5 py-2.5 text-center text-[10px] font-semibold text-gray-500"
style={{ width: "30px", minWidth: "30px", maxWidth: "30px" }}
>
{lvl}
</th>
@@ -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 (
<tr
key={node.id}
className={cn(
"cursor-pointer border-b transition-colors",
isRoot
? "border-gray-200 bg-blue-50/40 font-medium hover:bg-blue-50/60"
: selectedNodeId === node.id
? "border-gray-100 bg-primary/5"
: rowIdx % 2 === 0
? "border-gray-100 bg-white hover:bg-gray-50/80"
: "border-gray-100 bg-gray-50/30 hover:bg-gray-50/80",
)}
className={cn("cursor-pointer border-b transition-colors", lvlDepthBg)}
onClick={() => setSelectedNodeId(node.id)}
onDoubleClick={() => {
setEditTargetNode(node);
@@ -765,7 +921,6 @@ export function BomTreeComponent({
<td
key={`lv-${lvl}`}
className="py-2 text-center"
style={{ width: "30px", minWidth: "30px", maxWidth: "30px" }}
>
{displayDepth === lvl ? (
<Check className="mx-auto h-3.5 w-3.5 text-gray-700" />
@@ -793,10 +948,16 @@ export function BomTreeComponent({
</table>
) : (
/* ═══ 트리 뷰 ═══ */
<table className="w-full border-collapse text-xs" style={{ minWidth: `${Math.max(52, maxDepth * INDENT_PX + 44) + displayColumns.length * 90}px` }}>
<table className="w-full border-collapse text-xs" style={{ minWidth: `${prefixAreaWidth + displayColumns.length * 90}px` }}>
<colgroup>
<col style={{ width: `${prefixAreaWidth}px`, minWidth: `${prefixAreaWidth}px` }} />
{displayColumns.map((col) => (
<col key={`tc-col-${col.key}`} style={{ width: colWidths[col.key] ? `${colWidths[col.key]}px` : undefined }} />
))}
</colgroup>
<thead className="sticky top-0 z-10">
<tr className="border-b bg-gray-50">
<th className="px-2 py-2.5" style={{ width: `${Math.max(52, maxDepth * INDENT_PX + 44)}px` }}></th>
<th className="px-2 py-2.5"></th>
{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 (
<tr
key={node.id}
className={cn(
"group cursor-pointer border-b transition-colors",
isRoot
? "border-gray-200 bg-blue-50/40 font-medium hover:bg-blue-50/60"
: isSelected
? "border-gray-100 bg-primary/5"
: rowIdx % 2 === 0
? "border-gray-100 bg-white hover:bg-gray-50/80"
: "border-gray-100 bg-gray-50/30 hover:bg-gray-50/80",
)}
className={cn("group cursor-pointer border-b transition-colors", depthBg)}
onClick={() => {
setSelectedNodeId(node.id);
if (hasChildren) toggleNode(node.id);
@@ -850,7 +1020,8 @@ export function BomTreeComponent({
setEditModalOpen(true);
}}
>
<td className="px-1 py-2" style={{ paddingLeft: `${depth * INDENT_PX + 8}px` }}>
<td className="relative px-1 py-2" style={{ paddingLeft: `${depth * INDENT_PX + 8}px` }}>
<div className={cn("absolute left-0 top-0 h-full w-[3px]", depthBarColor)} />
<div className="flex items-center gap-1">
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center">
{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"));
}}
/>

View File

@@ -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);

View File

@@ -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<void>[],
};
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_ 데이터 있음)");
// 🆕 렉 구조 컴포넌트 일괄 저장 감지

64
package-lock.json generated
View File

@@ -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"
}

View File

@@ -12,6 +12,7 @@
},
"devDependencies": {
"@types/oracledb": "^6.9.1",
"@types/pg": "^8.15.5"
"@types/pg": "^8.15.5",
"playwright": "^1.58.2"
}
}