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:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -286,4 +286,9 @@ uploads/
|
||||
*.hwp
|
||||
*.hwpx
|
||||
|
||||
claude.md
|
||||
claude.md
|
||||
|
||||
# AI 에이전트 테스트 산출물
|
||||
*-test-screenshots/
|
||||
*-screenshots/
|
||||
*-test.mjs
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
278
docs/BOM_개발_현황.md
Normal 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 유효성 검증 (순환참조 방지 등)
|
||||
@@ -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>
|
||||
|
||||
{/* 트리 목록 */}
|
||||
|
||||
@@ -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"));
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
64
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/oracledb": "^6.9.1",
|
||||
"@types/pg": "^8.15.5"
|
||||
"@types/pg": "^8.15.5",
|
||||
"playwright": "^1.58.2"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user