Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node

This commit is contained in:
kjs 2026-02-26 13:46:57 +09:00
commit 495594913f
11 changed files with 1262 additions and 255 deletions

5
.gitignore vendored
View File

@ -287,3 +287,8 @@ uploads/
*.hwpx *.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) ───────────────────────────── // ─── 버전 (Version) ─────────────────────────────
export async function getBomVersions(req: Request, res: Response) { 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 companyCode = (req as any).user?.companyCode || "*";
const tableName = (req.query.tableName as string) || undefined; const tableName = (req.query.tableName as string) || undefined;
const data = await bomService.getBomVersions(bomId, companyCode, tableName); const result = await bomService.getBomVersions(bomId, companyCode, tableName);
res.json({ success: true, data }); res.json({
success: true,
data: result.versions,
currentVersionId: result.currentVersionId,
});
} catch (error: any) { } catch (error: any) {
logger.error("BOM 버전 목록 조회 실패", { error: error.message }); logger.error("BOM 버전 목록 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message }); res.status(500).json({ success: false, message: error.message });
@ -93,12 +116,26 @@ export async function loadBomVersion(req: Request, res: Response) {
} }
} }
export async function activateBomVersion(req: Request, res: Response) {
try {
const { bomId, versionId } = req.params;
const { tableName } = req.body || {};
const result = await bomService.activateBomVersion(bomId, versionId, tableName);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 버전 사용 확정 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteBomVersion(req: Request, res: Response) { export async function deleteBomVersion(req: Request, res: Response) {
try { try {
const { bomId, versionId } = req.params; const { bomId, versionId } = req.params;
const tableName = (req.query.tableName as string) || undefined; 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) { if (!deleted) {
res.status(404).json({ success: false, message: "버전을 찾을 수 없습니다" }); res.status(404).json({ success: false, message: "버전을 찾을 수 없습니다" });
return; return;

View File

@ -10,6 +10,9 @@ const router = Router();
router.use(authenticateToken); router.use(authenticateToken);
// BOM 헤더 (entity join 포함)
router.get("/:bomId/header", bomController.getBomHeader);
// 이력 // 이력
router.get("/:bomId/history", bomController.getBomHistory); router.get("/:bomId/history", bomController.getBomHistory);
router.post("/:bomId/history", bomController.addBomHistory); router.post("/:bomId/history", bomController.addBomHistory);
@ -18,6 +21,7 @@ router.post("/:bomId/history", bomController.addBomHistory);
router.get("/:bomId/versions", bomController.getBomVersions); router.get("/:bomId/versions", bomController.getBomVersions);
router.post("/:bomId/versions", bomController.createBomVersion); router.post("/:bomId/versions", bomController.createBomVersion);
router.post("/:bomId/versions/:versionId/load", bomController.loadBomVersion); router.post("/:bomId/versions/:versionId/load", bomController.loadBomVersion);
router.post("/:bomId/versions/:versionId/activate", bomController.activateBomVersion);
router.delete("/:bomId/versions/:versionId", bomController.deleteBomVersion); router.delete("/:bomId/versions/:versionId", bomController.deleteBomVersion);
export default router; export default router;

View File

@ -1,12 +1,11 @@
/** /**
* BOM * BOM
* * (Row) 관리: bom_detail.version_id로
*/ */
import { query, queryOne, transaction } from "../database/db"; import { query, queryOne, transaction } from "../database/db";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
// SQL 인젝션 방지: 테이블명은 알파벳, 숫자, 언더스코어만 허용
function safeTableName(name: string, fallback: string): string { function safeTableName(name: string, fallback: string): string {
if (!name || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) return fallback; if (!name || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) return fallback;
return name; return name;
@ -54,15 +53,48 @@ export async function addBomHistory(
// ─── 버전 (Version) ───────────────────────────── // ─── 버전 (Version) ─────────────────────────────
export async function getBomVersions(bomId: string, companyCode: string, tableName?: string) { // ─── BOM 헤더 조회 (entity join 포함) ─────────────────────────────
const table = safeTableName(tableName || "", "bom_version");
const sql = companyCode === "*" export async function getBomHeader(bomId: string, tableName?: string) {
? `SELECT * FROM ${table} WHERE bom_id = $1 ORDER BY created_date DESC` const table = safeTableName(tableName || "", "bom");
: `SELECT * FROM ${table} WHERE bom_id = $1 AND company_code = $2 ORDER BY created_date DESC`; const sql = `
const params = companyCode === "*" ? [bomId] : [bomId, companyCode]; SELECT b.*,
return query(sql, params); 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( export async function createBomVersion(
bomId: string, companyCode: string, createdBy: string, bomId: string, companyCode: string, createdBy: string,
versionTableName?: string, detailTableName?: string, versionTableName?: string, detailTableName?: string,
@ -75,11 +107,7 @@ export async function createBomVersion(
if (bomRow.rows.length === 0) throw new Error("BOM을 찾을 수 없습니다"); if (bomRow.rows.length === 0) throw new Error("BOM을 찾을 수 없습니다");
const bomData = bomRow.rows[0]; 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( const lastVersion = await client.query(
`SELECT version_name FROM ${vTable} WHERE bom_id = $1 ORDER BY created_date DESC LIMIT 1`, `SELECT version_name FROM ${vTable} WHERE bom_id = $1 ORDER BY created_date DESC LIMIT 1`,
[bomId], [bomId],
@ -91,67 +119,38 @@ export async function createBomVersion(
} }
const versionName = `${nextVersionNum}.0`; const versionName = `${nextVersionNum}.0`;
const snapshot = { // 새 버전 레코드 생성 (snapshot_data 없이)
bom: bomData,
details: detailRows.rows,
detailTable: dTable,
created_at: new Date().toISOString(),
};
const insertSql = ` const insertSql = `
INSERT INTO ${vTable} (bom_id, version_name, revision, status, snapshot_data, created_by, company_code) INSERT INTO ${vTable} (bom_id, version_name, revision, status, created_by, company_code)
VALUES ($1, $2, $3, 'developing', $4, $5, $6) VALUES ($1, $2, $3, 'developing', $4, $5)
RETURNING * RETURNING *
`; `;
const result = await client.query(insertSql, [ const newVersion = await client.query(insertSql, [
bomId, bomId,
versionName, versionName,
bomData.revision ? parseInt(bomData.revision, 10) || 0 : 0, bomData.revision ? parseInt(bomData.revision, 10) || 0 : 0,
JSON.stringify(snapshot),
createdBy, createdBy,
companyCode, companyCode,
]); ]);
const newVersionId = newVersion.rows[0].id;
logger.info("BOM 버전 생성", { bomId, versionName, companyCode, vTable, dTable }); // 현재 활성 버전의 bom_detail 행을 복사
return result.rows[0]; 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`,
export async function loadBomVersion( [bomId, sourceVersionId],
bomId: string, versionId: string, companyCode: string,
versionTableName?: string, detailTableName?: string,
) {
const vTable = safeTableName(versionTableName || "", "bom_version");
const dTable = safeTableName(detailTableName || "", "bom_detail");
return transaction(async (client) => {
const verRow = await client.query(
`SELECT * FROM ${vTable} WHERE id = $1 AND bom_id = $2`,
[versionId, bomId],
);
if (verRow.rows.length === 0) throw new Error("버전을 찾을 수 없습니다");
const snapshot = verRow.rows[0].snapshot_data;
if (!snapshot || !snapshot.bom) throw new Error("스냅샷 데이터가 없습니다");
// 스냅샷에 기록된 detailTable을 우선 사용, 없으면 파라미터 사용
const snapshotDetailTable = safeTableName(snapshot.detailTable || "", dTable);
await client.query(`DELETE FROM ${snapshotDetailTable} WHERE bom_id = $1`, [bomId]);
const b = snapshot.bom;
await client.query(
`UPDATE bom SET base_qty = $1, unit = $2, revision = $3, remark = $4 WHERE id = $5`,
[b.base_qty || null, b.unit || null, b.revision || null, b.remark || null, bomId],
); );
// old ID → new ID 매핑 (parent_detail_id 유지)
const oldToNew: Record<string, string> = {}; const oldToNew: Record<string, string> = {};
for (const d of snapshot.details || []) { for (const d of sourceDetails.rows) {
const insertResult = await client.query( 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) `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) RETURNING id`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id`,
[ [
bomId, bomId,
newVersionId,
d.parent_detail_id ? (oldToNew[d.parent_detail_id] || null) : null, d.parent_detail_id ? (oldToNew[d.parent_detail_id] || null) : null,
d.child_item_id, d.child_item_id,
d.quantity, d.quantity,
@ -162,20 +161,132 @@ export async function loadBomVersion(
d.level, d.level,
d.base_qty, d.base_qty,
d.revision, d.revision,
d.seq_no,
d.writer,
companyCode, companyCode,
], ],
); );
oldToNew[d.id] = insertResult.rows[0].id; oldToNew[d.id] = insertResult.rows[0].id;
} }
logger.info("BOM 버전 불러오기 완료", { bomId, versionId, vTable, snapshotDetailTable }); logger.info("BOM 버전 생성 - 디테일 복사 완료", {
return { restored: true, versionName: verRow.rows[0].version_name }; 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];
}); });
} }
export async function deleteBomVersion(bomId: string, versionId: string, tableName?: string) { /**
const table = safeTableName(tableName || "", "bom_version"); * 불러오기: bom_detail / current_version_id만
const sql = `DELETE FROM ${table} WHERE id = $1 AND bom_id = $2 RETURNING id`; */
const result = await query(sql, [versionId, bomId]); export async function loadBomVersion(
return result.length > 0; bomId: string, versionId: string, companyCode: string,
versionTableName?: string, _detailTableName?: string,
) {
const vTable = safeTableName(versionTableName || "", "bom_version");
return transaction(async (client) => {
const verRow = await client.query(
`SELECT * FROM ${vTable} WHERE id = $1 AND bom_id = $2`,
[versionId, bomId],
);
if (verRow.rows.length === 0) throw new Error("버전을 찾을 수 없습니다");
const versionName = verRow.rows[0].version_name;
// BOM 헤더의 version과 current_version_id만 전환
await client.query(
`UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`,
[versionName, versionId, bomId],
);
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");
return transaction(async (client) => {
const verRow = await client.query(
`SELECT version_name FROM ${table} WHERE id = $1 AND bom_id = $2`,
[versionId, bomId],
);
if (verRow.rows.length === 0) throw new Error("버전을 찾을 수 없습니다");
// 기존 active -> inactive
await client.query(
`UPDATE ${table} SET status = 'inactive' WHERE bom_id = $1 AND status = 'active'`,
[bomId],
);
// 선택한 버전 -> active
await client.query(
`UPDATE ${table} SET status = 'active' WHERE id = $1`,
[versionId],
);
// BOM 헤더 갱신
const versionName = verRow.rows[0].version_name;
await client.query(
`UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`,
[versionName, versionId, bomId],
);
logger.info("BOM 버전 사용 확정", { bomId, versionId, versionName });
return { activated: true, versionName };
});
}
/**
* 삭제: 해당 version_id의 bom_detail
*/
export async function deleteBomVersion(
bomId: string, versionId: string,
tableName?: string, detailTableName?: string,
) {
const table = safeTableName(tableName || "", "bom_version");
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"; "use client";
import React, { useState, useEffect, useCallback, useMemo } from "react"; import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { import {
GripVertical, GripVertical,
Plus, Plus,
@ -487,6 +487,28 @@ export function BomItemEditorComponent({
return null; return null;
}, [propBomId, formData, selectedRowsData]); }, [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(() => { useEffect(() => {
@ -544,17 +566,31 @@ export function BomItemEditorComponent({
referenceTable: sourceTable, referenceTable: sourceTable,
})); }));
const result = await entityJoinApi.getTableDataWithJoins(mainTableName, { // 서버에서 최신 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, page: 1,
size: 500, size: 500,
search: { [fkColumn]: id }, search: JSON.stringify(searchFilter),
sortBy: "seq_no", sortBy: "seq_no",
sortOrder: "asc", sortOrder: "asc",
enableEntityJoin: true, enableEntityJoin: true,
additionalJoinColumns: additionalJoinColumns.length > 0 ? additionalJoinColumns : undefined, 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 }; const mapped = { ...row };
for (const key of Object.keys(row)) { for (const key of Object.keys(row)) {
if (key.startsWith(`${sourceFk}_`)) { if (key.startsWith(`${sourceFk}_`)) {
@ -578,14 +614,20 @@ export function BomItemEditorComponent({
setLoading(false); 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(() => { 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); loadBomDetails(bomId);
} }
}, [bomId, isDesignMode, loadBomDetails]); }, [bomId, isDesignMode, loadBomDetails, formData?.current_version_id]);
// ─── 트리 빌드 (동적 데이터) ─── // ─── 트리 빌드 (동적 데이터) ───
@ -669,6 +711,164 @@ export function BomItemEditorComponent({
[onChange, flattenTree], [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, ...node,
data: { ...node.data, [field]: value }, data: { ...node.data, [field]: value },
})); }));
notifyChange(newTree); notifyChangeWithDirty(newTree);
}, },
[treeData, notifyChange], [treeData, notifyChangeWithDirty],
); );
// 노드 삭제 // 노드 삭제
const handleDelete = useCallback( const handleDelete = useCallback(
(tempId: string) => { (tempId: string) => {
const newTree = findAndUpdate(treeData, tempId, () => null); 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])); 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) { if (inserted) {
const reindex = (nodes: BomItemNode[], depth = 0): BomItemNode[] => const reindex = (nodes: BomItemNode[], depth = 0): BomItemNode[] =>
nodes.map((n, i) => ({ ...n, seq_no: i + 1, level: depth, children: reindex(n.children, depth + 1) })); 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); setDragId(null);
}, [dragId, treeData, notifyChange]); }, [dragId, treeData, notifyChangeWithDirty]);
// ─── 재귀 렌더링 ─── // ─── 재귀 렌더링 ───
@ -1086,15 +1286,29 @@ export function BomItemEditorComponent({
<div className="space-y-3"> <div className="space-y-3">
{/* 헤더 */} {/* 헤더 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="text-sm font-semibold"> </h4> <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 <Button
onClick={handleAddRoot} onClick={handleAddRoot}
variant="outline"
size="sm" size="sm"
className="h-8 text-xs" className="h-7 text-xs"
> >
<Plus className="mr-1 h-3.5 w-3.5" /> <Plus className="mr-1 h-3 w-3" />
</Button> </Button>
<Button
onClick={handleSaveAll}
disabled={saving || !hasChanges}
size="sm"
className="h-7 text-xs"
>
{saving ? "저장중..." : "저장"}
</Button>
</div>
</div> </div>
{/* 트리 목록 */} {/* 트리 목록 */}

View File

@ -16,7 +16,8 @@ import {
Check, Check,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { apiClient } from "@/lib/api/client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { BomDetailEditModal } from "./BomDetailEditModal"; import { BomDetailEditModal } from "./BomDetailEditModal";
import { BomHistoryModal } from "./BomHistoryModal"; import { BomHistoryModal } from "./BomHistoryModal";
@ -108,6 +109,7 @@ export function BomTreeComponent({
return null; return null;
}, [formData, selectedRowsData]); }, [formData, selectedRowsData]);
const selectedHeaderData = useMemo(() => { const selectedHeaderData = useMemo(() => {
const raw = selectedRowsData?.[0] || (formData?.id ? formData : null); const raw = selectedRowsData?.[0] || (formData?.id ? formData : null);
if (!raw) return null; if (!raw) return null;
@ -165,16 +167,27 @@ export function BomTreeComponent({
if (!bomId) return; if (!bomId) return;
setLoading(true); setLoading(true);
try { try {
const result = await entityJoinApi.getTableDataWithJoins(detailTable, { 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, page: 1,
size: 500, size: 500,
search: { [foreignKey]: bomId }, search: JSON.stringify(searchFilter),
sortBy: "seq_no", sortBy: "seq_no",
sortOrder: "asc", sortOrder: "asc",
enableEntityJoin: true, 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 }; const mapped = { ...row };
for (const key of Object.keys(row)) { for (const key of Object.keys(row)) {
if (key.startsWith(`${sourceFk}_`)) { if (key.startsWith(`${sourceFk}_`)) {
@ -192,7 +205,6 @@ export function BomTreeComponent({
const detailTree = buildTree(rows); const detailTree = buildTree(rows);
// BOM 헤더를 가상 0레벨 루트로 삽입
const virtualRoot = buildVirtualRoot(headerData, detailTree); const virtualRoot = buildVirtualRoot(headerData, detailTree);
if (virtualRoot) { if (virtualRoot) {
setTreeData([virtualRoot]); setTreeData([virtualRoot]);
@ -224,15 +236,141 @@ export function BomTreeComponent({
return roots; 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(() => { useEffect(() => {
if (selectedBomId) { if (!selectedBomId) {
setHeaderInfo(selectedHeaderData);
loadBomDetails(selectedBomId, selectedHeaderData);
} else {
setHeaderInfo(null); setHeaderInfo(null);
setTreeData([]); 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) => { const toggleNode = useCallback((nodeId: string) => {
setExpandedNodes((prev) => { setExpandedNodes((prev) => {
@ -357,19 +495,92 @@ export function BomTreeComponent({
if (isDesignMode) { if (isDesignMode) {
const configuredColumns = (config.columns || []).filter((c: TreeColumnDef) => !c.hidden); const configuredColumns = (config.columns || []).filter((c: TreeColumnDef) => !c.hidden);
const previewSampleValue = (col: TreeColumnDef, rowIdx: number): React.ReactNode => {
if (col.key === "level") return rowIdx === 0 ? "0" : "1";
if (col.key.includes("type") || col.key.includes("division")) {
const badge = rowIdx === 0
? { bg: "bg-blue-50 text-blue-500 ring-blue-200", label: "제품" }
: { bg: "bg-amber-50 text-amber-500 ring-amber-200", label: "반제품" };
return ( return (
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-white shadow-sm"> <span className={cn("rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 ring-inset", badge.bg)}>
<div className="flex items-center gap-2 border-b bg-gray-50/80 px-4 py-2.5"> {badge.label}
<Package className="h-4 w-4 text-primary" />
<span className="text-sm font-semibold">BOM </span>
<span className="rounded-md bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-500">{detailTable}</span>
{config.dataSource?.sourceTable && (
<span className="rounded-md bg-blue-50 px-1.5 py-0.5 text-[10px] text-blue-500">
{config.dataSource.sourceTable}
</span> </span>
)} );
}
if (col.key.includes("quantity") || col.key.includes("qty")) return rowIdx === 0 ? "30" : "3";
if (col.key.includes("unit")) return "EA";
if (col.key.includes("process")) {
const badge = rowIdx === 0
? { bg: "bg-emerald-50 text-emerald-600 ring-emerald-200", label: "제조" }
: { bg: "bg-purple-50 text-purple-600 ring-purple-200", label: "외주" };
return (
<span className={cn("rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 ring-inset", badge.bg)}>
{badge.label}
</span>
);
}
return `예시${rowIdx + 1}`;
};
return (
<div className="flex h-full flex-col bg-white">
{/* 헤더 (실제 화면과 동일 구조) */}
<div className="border-b px-5 py-3">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50">
<Package className="h-4 w-4 text-blue-500" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-gray-400">BOM </h3>
<span className="inline-flex items-center rounded-md bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-500 ring-1 ring-inset ring-blue-200">
</span>
</div>
<div className="mt-0.5 flex gap-3 text-[10px] text-gray-300">
<span> <b className="text-gray-400">SAMPLE-001</b></span>
<span> <b className="text-gray-400">1</b></span>
</div>
</div>
</div>
</div> </div>
{/* 툴바 (실제 화면과 동일 구조) */}
<div className="flex items-center border-b bg-gray-50/50 px-5 py-1.5">
<span className="text-xs font-medium text-gray-500">BOM </span>
<span className="ml-2 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold text-primary">2</span>
<div className="ml-auto flex items-center gap-1">
{showHistory && (
<Button variant="outline" size="sm" disabled className="h-6 gap-1 px-2 text-[10px]">
<History className="h-3 w-3" />
</Button>
)}
{showVersion && (
<Button variant="outline" size="sm" disabled className="h-6 gap-1 px-2 text-[10px]">
<GitBranch className="h-3 w-3" />
</Button>
)}
<div className="mx-1 h-4 w-px bg-gray-200" />
<div className="flex overflow-hidden rounded-md border">
<span className="h-6 bg-primary px-2 text-[10px] font-medium leading-6 text-primary-foreground"></span>
<span className="h-6 border-l bg-white px-2 text-[10px] font-medium leading-6 text-gray-500"></span>
</div>
{features.showExpandAll !== false && (
<div className="flex gap-1">
<Button variant="ghost" size="sm" disabled className="h-6 gap-1 px-2 text-[10px] text-gray-400">
<Expand className="h-3 w-3" />
</Button>
<Button variant="ghost" size="sm" disabled className="h-6 gap-1 px-2 text-[10px] text-gray-400">
<Shrink className="h-3 w-3" />
</Button>
</div>
)}
</div>
</div>
{/* 테이블 */}
{configuredColumns.length === 0 ? ( {configuredColumns.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6"> <div className="flex flex-1 flex-col items-center justify-center gap-2 p-6">
<AlertCircle className="h-8 w-8 text-gray-200" /> <AlertCircle className="h-8 w-8 text-gray-200" />
@ -377,46 +588,58 @@ export function BomTreeComponent({
<p className="text-[11px] text-gray-300"> &gt; </p> <p className="text-[11px] text-gray-300"> &gt; </p>
</div> </div>
) : ( ) : (
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-auto">
<table className="w-full text-xs"> <table className="w-full border-collapse text-xs">
<thead> <thead className="sticky top-0 z-10">
<tr className="border-b bg-gray-50"> <tr className="border-b bg-gray-50">
<th className="w-10 border-r border-gray-100 px-2 py-2"></th> <th className="px-2 py-2.5 text-center" style={{ width: "52px" }}></th>
{configuredColumns.map((col: TreeColumnDef) => ( {configuredColumns.map((col: TreeColumnDef) => {
const centered = ["quantity", "loss_rate", "base_qty", "revision", "seq_no", "unit", "level"].includes(col.key)
|| col.key.includes("qty") || col.key.includes("quantity");
return (
<th <th
key={col.key} key={col.key}
className="border-r border-gray-100 px-3 py-2 text-left text-[11px] font-semibold text-gray-600" className={cn(
style={{ width: col.width }} "px-3 py-2.5 text-[11px] font-semibold text-gray-500",
centered ? "text-center" : "text-left",
)}
> >
{col.title || col.key} {col.title || col.key}
</th> </th>
))} );
})}
</tr> </tr>
</thead> </thead>
<tbody className="text-gray-400"> <tbody>
<tr className="border-b bg-white"> {/* 0레벨 루트 */}
<td className="border-r border-gray-50 px-2 py-2"> <tr className="border-b hover:bg-gray-50/50">
<ChevronDown className="h-3.5 w-3.5 text-gray-300" /> <td className="px-2 py-2 text-center">
<ChevronDown className="inline h-3.5 w-3.5 text-gray-400" />
</td> </td>
{configuredColumns.map((col: TreeColumnDef, i: number) => ( {configuredColumns.map((col: TreeColumnDef) => {
<td key={col.key} className="border-r border-gray-50 px-3 py-2"> const centered = ["quantity", "loss_rate", "base_qty", "revision", "seq_no", "unit", "level"].includes(col.key)
{col.key === "level" ? "0" : col.key.includes("type") ? ( || col.key.includes("qty") || col.key.includes("quantity");
<span className="rounded-md bg-blue-50 px-1.5 py-0.5 text-[10px] text-blue-500 ring-1 ring-inset ring-blue-200"></span> return (
) : col.key.includes("quantity") || col.key.includes("qty") ? "30" : `예시${i + 1}`} <td key={col.key} className={cn("px-3 py-2 text-gray-600", centered && "text-center")}>
{previewSampleValue(col, 0)}
</td> </td>
))} );
})}
</tr> </tr>
<tr className="border-b bg-gray-50/30"> {/* 1레벨 자식 */}
<td className="border-r border-gray-50 px-2 py-2 pl-7"> <tr className="border-b hover:bg-gray-50/50">
<span className="inline-block h-1 w-1 rounded-full bg-gray-300" /> <td className="px-2 py-2 text-center" style={{ paddingLeft: `${INDENT_PX + 8}px` }}>
<Layers className="inline h-3.5 w-3.5 text-gray-300" />
</td> </td>
{configuredColumns.map((col: TreeColumnDef, i: number) => ( {configuredColumns.map((col: TreeColumnDef) => {
<td key={col.key} className="border-r border-gray-50 px-3 py-2 text-gray-300"> const centered = ["quantity", "loss_rate", "base_qty", "revision", "seq_no", "unit", "level"].includes(col.key)
{col.key === "level" ? "1" : col.key.includes("type") ? ( || col.key.includes("qty") || col.key.includes("quantity");
<span className="rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] text-amber-500 ring-1 ring-inset ring-amber-200"></span> return (
) : col.key.includes("quantity") || col.key.includes("qty") ? "3" : `예시${i + 1}`} <td key={col.key} className={cn("px-3 py-2 text-gray-400", centered && "text-center")}>
{previewSampleValue(col, 1)}
</td> </td>
))} );
})}
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -483,6 +706,13 @@ export function BomTreeComponent({
return displayColumns.filter((c) => c.key !== "level"); return displayColumns.filter((c) => c.key !== "level");
}, [displayColumns]); }, [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 ( return (
@ -617,15 +847,23 @@ export function BomTreeComponent({
/* ═══ 레벨 뷰 ═══ */ /* ═══ 레벨 뷰 ═══ */
<table <table
className="w-full border-collapse text-xs" 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"> <thead className="sticky top-0 z-10">
<tr className="border-b bg-gray-50"> <tr className="border-b bg-gray-50">
{levelColumnsForView.map((lvl) => ( {levelColumnsForView.map((lvl) => (
<th <th
key={`lv-${lvl}`} key={`lv-${lvl}`}
className="whitespace-nowrap px-0.5 py-2.5 text-center text-[10px] font-semibold text-gray-500" 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} {lvl}
</th> </th>
@ -657,19 +895,22 @@ export function BomTreeComponent({
const isRoot = !!node._isVirtualRoot; const isRoot = !!node._isVirtualRoot;
const displayDepth = isRoot ? 0 : depth; 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 ( return (
<tr <tr
key={node.id} key={node.id}
className={cn( className={cn("cursor-pointer border-b transition-colors", lvlDepthBg)}
"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",
)}
onClick={() => setSelectedNodeId(node.id)} onClick={() => setSelectedNodeId(node.id)}
onDoubleClick={() => { onDoubleClick={() => {
setEditTargetNode(node); setEditTargetNode(node);
@ -680,7 +921,6 @@ export function BomTreeComponent({
<td <td
key={`lv-${lvl}`} key={`lv-${lvl}`}
className="py-2 text-center" className="py-2 text-center"
style={{ width: "30px", minWidth: "30px", maxWidth: "30px" }}
> >
{displayDepth === lvl ? ( {displayDepth === lvl ? (
<Check className="mx-auto h-3.5 w-3.5 text-gray-700" /> <Check className="mx-auto h-3.5 w-3.5 text-gray-700" />
@ -708,10 +948,16 @@ export function BomTreeComponent({
</table> </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"> <thead className="sticky top-0 z-10">
<tr className="border-b bg-gray-50"> <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) => { {displayColumns.map((col) => {
const centered = ["quantity", "loss_rate", "level", "base_qty", "revision", "seq_no"].includes(col.key); const centered = ["quantity", "loss_rate", "level", "base_qty", "revision", "seq_no"].includes(col.key);
const w = colWidths[col.key]; const w = colWidths[col.key];
@ -743,19 +989,28 @@ export function BomTreeComponent({
const itemType = node.child_item_type || node.item_type || ""; const itemType = node.child_item_type || node.item_type || "";
const ItemIcon = getItemIcon(itemType); 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 ( return (
<tr <tr
key={node.id} key={node.id}
className={cn( className={cn("group cursor-pointer border-b transition-colors", depthBg)}
"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",
)}
onClick={() => { onClick={() => {
setSelectedNodeId(node.id); setSelectedNodeId(node.id);
if (hasChildren) toggleNode(node.id); if (hasChildren) toggleNode(node.id);
@ -765,7 +1020,8 @@ export function BomTreeComponent({
setEditModalOpen(true); 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"> <div className="flex items-center gap-1">
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center"> <span className="flex h-4 w-4 flex-shrink-0 items-center justify-center">
{hasChildren ? ( {hasChildren ? (
@ -817,7 +1073,7 @@ export function BomTreeComponent({
isRootNode={!!editTargetNode?._isVirtualRoot} isRootNode={!!editTargetNode?._isVirtualRoot}
tableName={detailTable} tableName={detailTable}
onSaved={() => { onSaved={() => {
if (selectedBomId) loadBomDetails(selectedBomId, selectedHeaderData); if (selectedBomId) loadBomDetails(selectedBomId, headerInfo);
}} }}
/> />
@ -838,7 +1094,7 @@ export function BomTreeComponent({
tableName={versionTable} tableName={versionTable}
detailTable={detailTable} detailTable={detailTable}
onVersionLoaded={() => { onVersionLoaded={() => {
if (selectedBomId) loadBomDetails(selectedBomId, selectedHeaderData); window.dispatchEvent(new CustomEvent("refreshTable"));
}} }}
/> />
)} )}

View File

@ -10,7 +10,7 @@ import {
DialogFooter, DialogFooter,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Loader2, Plus, Trash2, Download } from "lucide-react"; import { Loader2, Plus, Trash2, Download, ShieldCheck } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
@ -80,8 +80,8 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve
try { try {
const res = await apiClient.post(`/bom/${bomId}/versions/${versionId}/load`, { tableName, detailTable }); const res = await apiClient.post(`/bom/${bomId}/versions/${versionId}/load`, { tableName, detailTable });
if (res.data?.success) { if (res.data?.success) {
loadVersions();
onVersionLoaded?.(); onVersionLoaded?.();
onOpenChange(false);
} }
} catch (error) { } catch (error) {
console.error("[BomVersion] 불러오기 실패:", error); console.error("[BomVersion] 불러오기 실패:", error);
@ -90,6 +90,22 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve
} }
}; };
const handleActivateVersion = async (versionId: string) => {
if (!bomId || !confirm("이 버전을 사용 확정하시겠습니까?\n기존 사용중 버전은 사용중지로 변경됩니다.")) return;
setActionId(versionId);
try {
const res = await apiClient.post(`/bom/${bomId}/versions/${versionId}/activate`, { tableName });
if (res.data?.success) {
loadVersions();
window.dispatchEvent(new CustomEvent("refreshTable"));
}
} catch (error) {
console.error("[BomVersion] 사용 확정 실패:", error);
} finally {
setActionId(null);
}
};
const handleDeleteVersion = async (versionId: string) => { const handleDeleteVersion = async (versionId: string) => {
if (!bomId || !confirm("이 버전을 삭제하시겠습니까?")) return; if (!bomId || !confirm("이 버전을 삭제하시겠습니까?")) return;
setActionId(versionId); setActionId(versionId);
@ -179,7 +195,22 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve
{isActing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />} {isActing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
</Button> </Button>
{ver.status !== "active" && ( {ver.status === "active" ? (
<span className="flex h-7 items-center rounded-md bg-emerald-50 px-2 text-[10px] font-medium text-emerald-600 ring-1 ring-inset ring-emerald-200">
</span>
) : (
<>
<Button
variant="outline"
size="sm"
onClick={() => handleActivateVersion(ver.id)}
disabled={isActing}
className="h-7 gap-1 px-2 text-[10px] border-emerald-300 text-emerald-600 hover:bg-emerald-50"
>
<ShieldCheck className="h-3 w-3" />
</Button>
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
@ -190,6 +221,7 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
</Button> </Button>
</>
)} )}
</div> </div>
</div> </div>

View File

@ -20,8 +20,8 @@ function normalizeFormDataArrays(formData: Record<string, any>): Record<string,
if (Array.isArray(value)) { if (Array.isArray(value)) {
// 배열 내 숫자를 문자열로 변환 후 쉼표 구분 // 배열 내 숫자를 문자열로 변환 후 쉼표 구분
const stringValue = value const stringValue = value
.map(v => typeof v === "number" ? String(v) : v) .map((v) => (typeof v === "number" ? String(v) : v))
.filter(v => v !== null && v !== undefined && v !== "") .filter((v) => v !== null && v !== undefined && v !== "")
.join(","); .join(",");
console.log(`🔧 [normalizeFormDataArrays] 배열→문자열: ${key}`, { original: value, converted: stringValue }); console.log(`🔧 [normalizeFormDataArrays] 배열→문자열: ${key}`, { original: value, converted: stringValue });
normalized[key] = stringValue; normalized[key] = stringValue;
@ -588,6 +588,7 @@ export class ButtonActionExecutor {
skipDefaultSave: false, skipDefaultSave: false,
validationFailed: false, validationFailed: false,
validationErrors: [] as string[], validationErrors: [] as string[],
pendingPromises: [] as Promise<void>[],
}; };
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("beforeFormSave", { new CustomEvent("beforeFormSave", {
@ -595,15 +596,15 @@ export class ButtonActionExecutor {
}), }),
); );
// 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함 // 비동기 핸들러가 등록한 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)); await new Promise((resolve) => setTimeout(resolve, 100));
}
// 🔧 디버그: beforeFormSave 이벤트 후 formData 확인
console.log("🔍 [handleSave] beforeFormSave 이벤트 후:", {
keys: Object.keys(context.formData || {}),
hasCompanyImage: "company_image" in (context.formData || {}),
companyImageValue: context.formData?.company_image,
});
// 검증 실패 시 저장 중단 // 검증 실패 시 저장 중단
if (beforeSaveEventDetail.validationFailed) { if (beforeSaveEventDetail.validationFailed) {
@ -611,20 +612,6 @@ export class ButtonActionExecutor {
return false; return false;
} }
// 🔧 skipDefaultSave 플래그 확인 - SelectedItemsDetailInput 등에서 자체 UPSERT 처리 시 기본 저장 건너뛰기
if (beforeSaveEventDetail.skipDefaultSave) {
return true;
}
// 🆕 _tableSection_ 데이터가 있는지 확인 (TableSectionRenderer 사용 시)
// beforeFormSave 이벤트 후에 체크해야 UniversalFormModal에서 병합된 데이터를 확인할 수 있음
const hasTableSectionData = Object.keys(context.formData || {}).some(
(k) => k.startsWith("_tableSection_") || k.startsWith("__tableSection_"),
);
if (hasTableSectionData) {
}
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용 // 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
// 단, _tableSection_ 데이터가 있으면 건너뛰기 (handleUniversalFormModalTableSectionSave가 처리) // 단, _tableSection_ 데이터가 있으면 건너뛰기 (handleUniversalFormModalTableSectionSave가 처리)
// EditModal이 내부에서 직접 repeaterSave 이벤트를 발행하고 완료를 기다림 // EditModal이 내부에서 직접 repeaterSave 이벤트를 발행하고 완료를 기다림
@ -638,6 +625,25 @@ export class ButtonActionExecutor {
} }
} }
console.log("⚠️ [handleSave] onSave 콜백 없음 - 기본 저장 로직 실행");
// 🔧 디버그: beforeFormSave 이벤트 후 formData 확인
console.log("🔍 [handleSave] beforeFormSave 이벤트 후:", {
keys: Object.keys(context.formData || {}),
hasCompanyImage: "company_image" in (context.formData || {}),
companyImageValue: context.formData?.company_image,
});
// skipDefaultSave 플래그 확인
if (beforeSaveEventDetail.skipDefaultSave) {
return true;
}
// _tableSection_ 데이터 확인 (TableSectionRenderer 사용 시)
const hasTableSectionData = Object.keys(context.formData || {}).some(
(k) => k.startsWith("_tableSection_") || k.startsWith("__tableSection_"),
);
console.log("⚠️ [handleSave] 기본 저장 로직 실행 (onSave 콜백 없음 또는 _tableSection_ 데이터 있음)"); console.log("⚠️ [handleSave] 기본 저장 로직 실행 (onSave 콜백 없음 또는 _tableSection_ 데이터 있음)");
// 🆕 렉 구조 컴포넌트 일괄 저장 감지 // 🆕 렉 구조 컴포넌트 일괄 저장 감지
@ -1038,7 +1044,8 @@ export class ButtonActionExecutor {
const value = formData[key]; const value = formData[key];
if (Array.isArray(value)) { if (Array.isArray(value)) {
// 리피터 데이터인지 확인 (객체 배열이고 _targetTable 또는 _isNewItem이 있으면 리피터) // 리피터 데이터인지 확인 (객체 배열이고 _targetTable 또는 _isNewItem이 있으면 리피터)
const isRepeaterData = value.length > 0 && const isRepeaterData =
value.length > 0 &&
typeof value[0] === "object" && typeof value[0] === "object" &&
value[0] !== null && value[0] !== null &&
("_targetTable" in value[0] || "_isNewItem" in value[0] || "_existingRecord" in value[0]); ("_targetTable" in value[0] || "_isNewItem" in value[0] || "_existingRecord" in value[0]);
@ -1046,10 +1053,13 @@ export class ButtonActionExecutor {
if (!isRepeaterData) { if (!isRepeaterData) {
// 🔧 다중 선택 배열 → 쉼표 구분 문자열로 변환 // 🔧 다중 선택 배열 → 쉼표 구분 문자열로 변환
const stringValue = value const stringValue = value
.map(v => typeof v === "number" ? String(v) : v) .map((v) => (typeof v === "number" ? String(v) : v))
.filter(v => v !== null && v !== undefined && v !== "") .filter((v) => v !== null && v !== undefined && v !== "")
.join(","); .join(",");
console.log(`🔧 [handleSave UPDATE] 배열→문자열 변환: ${key}`, { original: value, converted: stringValue }); console.log(`🔧 [handleSave UPDATE] 배열→문자열 변환: ${key}`, {
original: value,
converted: stringValue,
});
formData[key] = stringValue; formData[key] = stringValue;
} }
} }
@ -1215,7 +1225,8 @@ export class ButtonActionExecutor {
const value = dataWithUserInfo[key]; const value = dataWithUserInfo[key];
if (Array.isArray(value)) { if (Array.isArray(value)) {
// 리피터 데이터인지 확인 (객체 배열이고 _targetTable 또는 _isNewItem이 있으면 리피터) // 리피터 데이터인지 확인 (객체 배열이고 _targetTable 또는 _isNewItem이 있으면 리피터)
const isRepeaterData = value.length > 0 && const isRepeaterData =
value.length > 0 &&
typeof value[0] === "object" && typeof value[0] === "object" &&
value[0] !== null && value[0] !== null &&
("_targetTable" in value[0] || "_isNewItem" in value[0] || "_existingRecord" in value[0]); ("_targetTable" in value[0] || "_isNewItem" in value[0] || "_existingRecord" in value[0]);
@ -1226,8 +1237,8 @@ export class ButtonActionExecutor {
} else { } else {
// 🔧 다중 선택 배열 → 쉼표 구분 문자열로 변환 // 🔧 다중 선택 배열 → 쉼표 구분 문자열로 변환
const stringValue = value const stringValue = value
.map(v => typeof v === "number" ? String(v) : v) .map((v) => (typeof v === "number" ? String(v) : v))
.filter(v => v !== null && v !== undefined && v !== "") .filter((v) => v !== null && v !== undefined && v !== "")
.join(","); .join(",");
console.log(`🔧 [handleSave] 배열→문자열 변환: ${key}`, { original: value, converted: stringValue }); console.log(`🔧 [handleSave] 배열→문자열 변환: ${key}`, { original: value, converted: stringValue });
dataWithUserInfo[key] = stringValue; dataWithUserInfo[key] = stringValue;
@ -1498,7 +1509,8 @@ export class ButtonActionExecutor {
// V2Repeater가 동일 테이블에 존재하는지 allComponents로 감지 // V2Repeater가 동일 테이블에 존재하는지 allComponents로 감지
// (useCustomTable 미설정 = 화면 테이블에 직접 저장하는 리피터) // (useCustomTable 미설정 = 화면 테이블에 직접 저장하는 리피터)
const hasRepeaterOnSameTable = context.allComponents?.some((c: any) => { const hasRepeaterOnSameTable =
context.allComponents?.some((c: any) => {
const compType = c.componentType || c.overrides?.type; const compType = c.componentType || c.overrides?.type;
if (compType !== "v2-repeater") return false; if (compType !== "v2-repeater") return false;
const compConfig = c.componentConfig || c.overrides || {}; const compConfig = c.componentConfig || c.overrides || {};
@ -1800,7 +1812,11 @@ export class ButtonActionExecutor {
for (const [fieldKey, fieldValue] of Object.entries(context.formData)) { for (const [fieldKey, fieldValue] of Object.entries(context.formData)) {
let parsedData = fieldValue; let parsedData = fieldValue;
if (typeof fieldValue === "string" && fieldValue.startsWith("[")) { if (typeof fieldValue === "string" && fieldValue.startsWith("[")) {
try { parsedData = JSON.parse(fieldValue); } catch { continue; } try {
parsedData = JSON.parse(fieldValue);
} catch {
continue;
}
} }
if (!Array.isArray(parsedData) || parsedData.length === 0) continue; if (!Array.isArray(parsedData) || parsedData.length === 0) continue;
if (!parsedData[0]?._deferSave) continue; if (!parsedData[0]?._deferSave) continue;
@ -5280,7 +5296,8 @@ export class ButtonActionExecutor {
// 시스템 컬럼 제외 // 시스템 컬럼 제외
if (SYSTEM_COLUMNS.includes(lowerKey)) return false; if (SYSTEM_COLUMNS.includes(lowerKey)) return false;
// _name, _label 등 조인된 보조 필드 제외 // _name, _label 등 조인된 보조 필드 제외
if (lowerKey.endsWith("_name") || lowerKey.endsWith("_label") || lowerKey.endsWith("_value_label")) return false; if (lowerKey.endsWith("_name") || lowerKey.endsWith("_label") || lowerKey.endsWith("_value_label"))
return false;
return true; return true;
}); });
// 라벨이 없으므로 최소한 column_labels 비워두지 않음 (컬럼명 그대로 표시되지만 시스템 컬럼은 제외됨) // 라벨이 없으므로 최소한 column_labels 비워두지 않음 (컬럼명 그대로 표시되지만 시스템 컬럼은 제외됨)

64
package-lock.json generated
View File

@ -17,7 +17,8 @@
}, },
"devDependencies": { "devDependencies": {
"@types/oracledb": "^6.9.1", "@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": { "node_modules/@azure-rest/core-client": {
@ -470,6 +471,7 @@
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz", "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==", "integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.17.8", "@babel/runtime": "^7.17.8",
"@types/react-reconciler": "^0.32.0", "@types/react-reconciler": "^0.32.0",
@ -710,6 +712,7 @@
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz", "resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==", "integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0", "@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3", "@tweenjs/tween.js": "~23.1.3",
@ -1076,8 +1079,7 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/d3-color": { "node_modules/d3-color": {
"version": "3.1.0", "version": "3.1.0",
@ -1136,6 +1138,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC", "license": "ISC",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -1473,6 +1476,21 @@
"node": ">= 6" "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": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@ -2061,6 +2079,38 @@
"pathe": "^2.0.3" "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": { "node_modules/postgres-array": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@ -2116,6 +2166,7 @@
"integrity": "sha512-aRvldGE5UUJTtVmFiH3WfNFNiqFlAtePUxcI0UEGlnXCX7DqhiMT5TRYwncHFeA/Reca5W6ToXXyCMTeFPdSXA==", "integrity": "sha512-aRvldGE5UUJTtVmFiH3WfNFNiqFlAtePUxcI0UEGlnXCX7DqhiMT5TRYwncHFeA/Reca5W6ToXXyCMTeFPdSXA==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@prisma/config": "6.16.2", "@prisma/config": "6.16.2",
"@prisma/engines": "6.16.2" "@prisma/engines": "6.16.2"
@ -2331,8 +2382,7 @@
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.2", "version": "7.7.2",
@ -2451,7 +2501,8 @@
"version": "0.180.0", "version": "0.180.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/three-mesh-bvh": { "node_modules/three-mesh-bvh": {
"version": "0.8.3", "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", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }

View File

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