feat: Implement BOM Excel upload and download functionality

- Added endpoints for uploading BOM data from Excel and downloading BOM data in Excel format.
- Developed the `createBomFromExcel` function to handle Excel uploads, including validation and error handling.
- Implemented the `downloadBomExcelData` function to retrieve BOM data for Excel downloads.
- Created a new `BomExcelUploadModal` component for the frontend to facilitate Excel file uploads.
- Updated BOM routes to include new Excel upload and download routes, enhancing BOM management capabilities.
This commit is contained in:
kjs 2026-02-27 07:50:22 +09:00
parent d50f705c44
commit 929b68299a
10 changed files with 1299 additions and 20 deletions

View File

@ -143,6 +143,70 @@ export async function initializeBomVersion(req: Request, res: Response) {
}
}
// ─── BOM 엑셀 업로드/다운로드 ─────────────────────────
export async function createBomFromExcel(req: Request, res: Response) {
try {
const companyCode = (req as any).user?.companyCode || "*";
const userId = (req as any).user?.userName || (req as any).user?.userId || "";
const { rows } = req.body;
if (!rows || !Array.isArray(rows) || rows.length === 0) {
res.status(400).json({ success: false, message: "업로드할 데이터가 없습니다" });
return;
}
const result = await bomService.createBomFromExcel(companyCode, userId, rows);
if (!result.success) {
res.status(400).json({ success: false, message: result.errors.join(", "), data: result });
return;
}
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 createBomVersionFromExcel(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const userId = (req as any).user?.userName || (req as any).user?.userId || "";
const { rows, versionName } = req.body;
if (!rows || !Array.isArray(rows) || rows.length === 0) {
res.status(400).json({ success: false, message: "업로드할 데이터가 없습니다" });
return;
}
const result = await bomService.createBomVersionFromExcel(bomId, companyCode, userId, rows, versionName);
if (!result.success) {
res.status(400).json({ success: false, message: result.errors.join(", "), data: result });
return;
}
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 downloadBomExcelData(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const data = await bomService.downloadBomExcelData(bomId, companyCode);
res.json({ success: true, data });
} catch (error: any) {
logger.error("BOM 엑셀 다운로드 데이터 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteBomVersion(req: Request, res: Response) {
try {
const { bomId, versionId } = req.params;

View File

@ -17,6 +17,11 @@ router.get("/:bomId/header", bomController.getBomHeader);
router.get("/:bomId/history", bomController.getBomHistory);
router.post("/:bomId/history", bomController.addBomHistory);
// 엑셀 업로드/다운로드
router.post("/excel-upload", bomController.createBomFromExcel);
router.post("/:bomId/excel-upload-version", bomController.createBomVersionFromExcel);
router.get("/:bomId/excel-download", bomController.downloadBomExcelData);
// 버전
router.get("/:bomId/versions", bomController.getBomVersions);
router.post("/:bomId/versions", bomController.createBomVersion);

View File

@ -319,6 +319,485 @@ export async function initializeBomVersion(
});
}
// ─── BOM 엑셀 업로드 ─────────────────────────────
interface BomExcelRow {
level: number;
item_number: string;
item_name?: string;
quantity: number;
unit?: string;
process_type?: string;
remark?: string;
}
interface BomExcelUploadResult {
success: boolean;
insertedCount: number;
skippedCount: number;
errors: string[];
unmatchedItems: string[];
createdBomId?: string;
}
/**
* BOM - BOM
*
* :
* 0 = BOM ( ) bom INSERT
* 1 = bom_detail (parent_detail_id=null, DB level=0)
* 2 = bom_detail (parent_detail_id=ID, DB level=1)
* N = ... bom_detail (DB level=N-1)
*/
export async function createBomFromExcel(
companyCode: string,
userId: string,
rows: BomExcelRow[],
): Promise<BomExcelUploadResult> {
const result: BomExcelUploadResult = {
success: false,
insertedCount: 0,
skippedCount: 0,
errors: [],
unmatchedItems: [],
};
if (!rows || rows.length === 0) {
result.errors.push("업로드할 데이터가 없습니다");
return result;
}
const headerRow = rows.find(r => r.level === 0);
const detailRows = rows.filter(r => r.level > 0);
if (!headerRow) {
result.errors.push("레벨 0(BOM 마스터) 행이 필요합니다");
return result;
}
if (!headerRow.item_number?.trim()) {
result.errors.push("레벨 0(BOM 마스터)의 품번은 필수입니다");
return result;
}
if (detailRows.length === 0) {
result.errors.push("하위품목이 없습니다 (레벨 1 이상의 행이 필요합니다)");
return result;
}
// 레벨 유효성 검사
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (row.level < 0) {
result.errors.push(`${i + 1}행: 레벨은 0 이상이어야 합니다`);
}
if (i > 0 && row.level > rows[i - 1].level + 1) {
result.errors.push(`${i + 1}행: 레벨이 이전 행보다 2 이상 깊어질 수 없습니다 (현재: ${row.level}, 이전: ${rows[i - 1].level})`);
}
if (row.level > 0 && !row.item_number?.trim()) {
result.errors.push(`${i + 1}행: 품번은 필수입니다`);
}
}
if (result.errors.length > 0) {
return result;
}
return transaction(async (client) => {
// 1. 모든 품번 일괄 조회 (헤더 + 디테일)
const allItemNumbers = [...new Set(rows.filter(r => r.item_number?.trim()).map(r => r.item_number.trim()))];
const itemLookup = await client.query(
`SELECT id, item_number, item_name, unit FROM item_info
WHERE company_code = $1 AND item_number = ANY($2::text[])`,
[companyCode, allItemNumbers],
);
const itemMap = new Map<string, { id: string; item_name: string; unit: string }>();
for (const item of itemLookup.rows) {
itemMap.set(item.item_number, { id: item.id, item_name: item.item_name, unit: item.unit });
}
for (const num of allItemNumbers) {
if (!itemMap.has(num)) {
result.unmatchedItems.push(num);
}
}
if (result.unmatchedItems.length > 0) {
result.errors.push(`매칭되지 않는 품번이 있습니다: ${result.unmatchedItems.join(", ")}`);
return result;
}
// 2. bom 마스터 생성 (레벨 0)
const headerItemInfo = itemMap.get(headerRow.item_number.trim())!;
// 동일 품목으로 이미 BOM이 존재하는지 확인
const dupCheck = await client.query(
`SELECT id FROM bom WHERE item_id = $1 AND company_code = $2 AND status = 'active'`,
[headerItemInfo.id, companyCode],
);
if (dupCheck.rows.length > 0) {
result.errors.push(`해당 품목(${headerRow.item_number})으로 등록된 BOM이 이미 존재합니다`);
return result;
}
const bomInsert = await client.query(
`INSERT INTO bom (item_id, item_code, item_name, base_qty, unit, version, status, remark, writer, company_code)
VALUES ($1, $2, $3, $4, $5, '1.0', 'active', $6, $7, $8)
RETURNING id`,
[
headerItemInfo.id,
headerRow.item_number.trim(),
headerItemInfo.item_name,
String(headerRow.quantity || 1),
headerRow.unit || headerItemInfo.unit || null,
headerRow.remark || null,
userId,
companyCode,
],
);
const newBomId = bomInsert.rows[0].id;
result.createdBomId = newBomId;
// 3. bom_version 생성
const versionInsert = await client.query(
`INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code)
VALUES ($1, '1.0', 0, 'active', $2, $3) RETURNING id`,
[newBomId, userId, companyCode],
);
const versionId = versionInsert.rows[0].id;
await client.query(
`UPDATE bom SET current_version_id = $1 WHERE id = $2`,
[versionId, newBomId],
);
// 4. bom_detail INSERT (레벨 1+ → DB level = 엑셀 level - 1)
const levelStack: string[] = [];
const seqCounterByParent = new Map<string, number>();
for (let i = 0; i < detailRows.length; i++) {
const row = detailRows[i];
const itemInfo = itemMap.get(row.item_number.trim())!;
const dbLevel = row.level - 1;
while (levelStack.length > dbLevel) {
levelStack.pop();
}
const parentDetailId = levelStack.length > 0 ? levelStack[levelStack.length - 1] : null;
const parentKey = parentDetailId || "__root__";
const currentSeq = (seqCounterByParent.get(parentKey) || 0) + 1;
seqCounterByParent.set(parentKey, currentSeq);
const insertResult = await client.query(
`INSERT INTO bom_detail (bom_id, version_id, parent_detail_id, child_item_id, level, seq_no, quantity, unit, loss_rate, process_type, remark, writer, company_code)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '0', $9, $10, $11, $12)
RETURNING id`,
[
newBomId,
versionId,
parentDetailId,
itemInfo.id,
String(dbLevel),
String(currentSeq),
String(row.quantity || 1),
row.unit || itemInfo.unit || null,
row.process_type || null,
row.remark || null,
userId,
companyCode,
],
);
levelStack.push(insertResult.rows[0].id);
result.insertedCount++;
}
// 5. 이력 기록
await client.query(
`INSERT INTO bom_history (bom_id, change_type, change_description, changed_by, company_code)
VALUES ($1, 'excel_upload', $2, $3, $4)`,
[newBomId, `엑셀 업로드로 BOM 생성 (하위품목 ${result.insertedCount}건)`, userId, companyCode],
);
result.success = true;
logger.info("BOM 엑셀 업로드 - 새 BOM 생성 완료", {
newBomId, companyCode,
insertedCount: result.insertedCount,
});
return result;
});
}
/**
* BOM - BOM에
*
* 0 ( )
* 1 bom_detail로 INSERT, bom_version에
*/
export async function createBomVersionFromExcel(
bomId: string,
companyCode: string,
userId: string,
rows: BomExcelRow[],
versionName?: string,
): Promise<BomExcelUploadResult> {
const result: BomExcelUploadResult = {
success: false,
insertedCount: 0,
skippedCount: 0,
errors: [],
unmatchedItems: [],
};
if (!rows || rows.length === 0) {
result.errors.push("업로드할 데이터가 없습니다");
return result;
}
const detailRows = rows.filter(r => r.level > 0);
result.skippedCount = rows.length - detailRows.length;
if (detailRows.length === 0) {
result.errors.push("하위품목이 없습니다 (레벨 1 이상의 행이 필요합니다)");
return result;
}
// 레벨 유효성 검사
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (row.level < 0) {
result.errors.push(`${i + 1}행: 레벨은 0 이상이어야 합니다`);
}
if (i > 0 && row.level > rows[i - 1].level + 1) {
result.errors.push(`${i + 1}행: 레벨이 이전 행보다 2 이상 깊어질 수 없습니다`);
}
if (row.level > 0 && !row.item_number?.trim()) {
result.errors.push(`${i + 1}행: 품번은 필수입니다`);
}
}
if (result.errors.length > 0) {
return result;
}
return transaction(async (client) => {
// 1. BOM 존재 확인
const bomRow = await client.query(
`SELECT id, version FROM bom WHERE id = $1 AND company_code = $2`,
[bomId, companyCode],
);
if (bomRow.rows.length === 0) {
result.errors.push("BOM을 찾을 수 없습니다");
return result;
}
// 2. 품번 → item_info 매핑
const uniqueItemNumbers = [...new Set(detailRows.map(r => r.item_number.trim()))];
const itemLookup = await client.query(
`SELECT id, item_number, item_name, unit FROM item_info
WHERE company_code = $1 AND item_number = ANY($2::text[])`,
[companyCode, uniqueItemNumbers],
);
const itemMap = new Map<string, { id: string; item_name: string; unit: string }>();
for (const item of itemLookup.rows) {
itemMap.set(item.item_number, { id: item.id, item_name: item.item_name, unit: item.unit });
}
for (const num of uniqueItemNumbers) {
if (!itemMap.has(num)) {
result.unmatchedItems.push(num);
}
}
if (result.unmatchedItems.length > 0) {
result.errors.push(`매칭되지 않는 품번이 있습니다: ${result.unmatchedItems.join(", ")}`);
return result;
}
// 3. 버전명 결정 (미입력 시 자동 채번)
let finalVersionName = versionName?.trim();
if (!finalVersionName) {
const countResult = await client.query(
`SELECT COUNT(*)::int as cnt FROM bom_version WHERE bom_id = $1`,
[bomId],
);
finalVersionName = `${(countResult.rows[0].cnt || 0) + 1}.0`;
}
// 중복 체크
const dupCheck = await client.query(
`SELECT id FROM bom_version WHERE bom_id = $1 AND version_name = $2`,
[bomId, finalVersionName],
);
if (dupCheck.rows.length > 0) {
result.errors.push(`이미 존재하는 버전명입니다: ${finalVersionName}`);
return result;
}
// 4. bom_version 생성
const versionInsert = await client.query(
`INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code)
VALUES ($1, $2, 0, 'developing', $3, $4) RETURNING id`,
[bomId, finalVersionName, userId, companyCode],
);
const newVersionId = versionInsert.rows[0].id;
// 5. bom_detail INSERT
const levelStack: string[] = [];
const seqCounterByParent = new Map<string, number>();
for (let i = 0; i < detailRows.length; i++) {
const row = detailRows[i];
const itemInfo = itemMap.get(row.item_number.trim())!;
const dbLevel = row.level - 1;
while (levelStack.length > dbLevel) {
levelStack.pop();
}
const parentDetailId = levelStack.length > 0 ? levelStack[levelStack.length - 1] : null;
const parentKey = parentDetailId || "__root__";
const currentSeq = (seqCounterByParent.get(parentKey) || 0) + 1;
seqCounterByParent.set(parentKey, currentSeq);
const insertResult = await client.query(
`INSERT INTO bom_detail (bom_id, version_id, parent_detail_id, child_item_id, level, seq_no, quantity, unit, loss_rate, process_type, remark, writer, company_code)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '0', $9, $10, $11, $12)
RETURNING id`,
[
bomId,
newVersionId,
parentDetailId,
itemInfo.id,
String(dbLevel),
String(currentSeq),
String(row.quantity || 1),
row.unit || itemInfo.unit || null,
row.process_type || null,
row.remark || null,
userId,
companyCode,
],
);
levelStack.push(insertResult.rows[0].id);
result.insertedCount++;
}
// 6. BOM 헤더의 version과 current_version_id 갱신
await client.query(
`UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`,
[finalVersionName, newVersionId, bomId],
);
// 7. 이력 기록
await client.query(
`INSERT INTO bom_history (bom_id, change_type, change_description, changed_by, company_code)
VALUES ($1, 'excel_upload', $2, $3, $4)`,
[bomId, `엑셀 업로드로 새 버전 ${finalVersionName} 생성 (하위품목 ${result.insertedCount}건)`, userId, companyCode],
);
result.success = true;
result.createdBomId = bomId;
logger.info("BOM 엑셀 업로드 - 새 버전 생성 완료", {
bomId, companyCode, versionName: finalVersionName,
insertedCount: result.insertedCount,
});
return result;
});
}
/**
* BOM
*
* :
* 0 = BOM ( )
* 1 = (DB level=0)
* N = DB level N-1
*
* DFS로 -
*/
export async function downloadBomExcelData(
bomId: string,
companyCode: string,
): Promise<Record<string, any>[]> {
// BOM 헤더 정보 조회 (최상위 품목)
const bomHeader = await queryOne<Record<string, any>>(
`SELECT b.*, ii.item_number, ii.item_name, ii.division, ii.unit as item_unit
FROM bom b
LEFT JOIN item_info ii ON b.item_id = ii.id
WHERE b.id = $1 AND b.company_code = $2`,
[bomId, companyCode],
);
if (!bomHeader) return [];
const flatList: Record<string, any>[] = [];
// 레벨 0: BOM 헤더 (최상위 품목)
flatList.push({
level: 0,
item_number: bomHeader.item_number || "",
item_name: bomHeader.item_name || "",
quantity: bomHeader.base_qty || "1",
unit: bomHeader.item_unit || bomHeader.unit || "",
process_type: "",
remark: bomHeader.remark || "",
_is_header: true,
});
// 하위 품목 조회
const versionId = bomHeader.current_version_id;
const whereVersion = versionId ? `AND bd.version_id = $3` : `AND bd.version_id IS NULL`;
const params = versionId ? [bomId, companyCode, versionId] : [bomId, companyCode];
const details = await query(
`SELECT bd.*, ii.item_number, ii.item_name, ii.division, ii.unit as item_unit, ii.size, ii.material
FROM bom_detail bd
LEFT JOIN item_info ii ON bd.child_item_id = ii.id
WHERE bd.bom_id = $1 AND bd.company_code = $2 ${whereVersion}
ORDER BY bd.parent_detail_id NULLS FIRST, bd.seq_no::int`,
params,
);
// 부모 ID별 자식 목록으로 맵 구성
const childrenMap = new Map<string, any[]>();
const roots: any[] = [];
for (const d of details) {
if (!d.parent_detail_id) {
roots.push(d);
} else {
if (!childrenMap.has(d.parent_detail_id)) childrenMap.set(d.parent_detail_id, []);
childrenMap.get(d.parent_detail_id)!.push(d);
}
}
// DFS: depth로 정확한 레벨 계산 (DB level 무시, 실제 트리 깊이 사용)
const dfs = (nodes: any[], depth: number) => {
for (const node of nodes) {
flatList.push({
level: depth,
item_number: node.item_number || "",
item_name: node.item_name || "",
quantity: node.quantity || "1",
unit: node.unit || node.item_unit || "",
process_type: node.process_type || "",
remark: node.remark || "",
});
const children = childrenMap.get(node.id) || [];
if (children.length > 0) {
dfs(children, depth + 1);
}
}
};
// 루트 노드들은 레벨 1 (BOM 헤더가 0이므로)
dfs(roots, 1);
return flatList;
}
/**
* 삭제: 해당 version_id의 bom_detail
*/

View File

@ -0,0 +1,78 @@
# BOM 엑셀 업로드 기능 개발 계획
## 개요
탑씰(COMPANY_7) BOM관리 화면(screen_id=4168)에 엑셀 업로드 기능을 추가한다.
BOM은 트리 구조(parent_detail_id 자기참조)이므로 범용 엑셀 업로드를 사용할 수 없고,
BOM 전용 엑셀 업로드 컴포넌트를 개발한다.
## 핵심 구조
### DB 테이블
- `bom` (마스터): id(UUID), item_id(→item_info), version, current_version_id
- `bom_detail` (디테일-트리): id(UUID), bom_id(FK), parent_detail_id(자기참조), child_item_id(→item_info), level, seq_no, quantity, unit, loss_rate, process_type, version_id
- `item_info`: id, item_number(품번), item_name(품명), division(구분), unit, size, material
### 엑셀 포맷 설계 (화면과 동일한 레벨 체계)
엑셀 파일은 다음 컬럼으로 구성:
| 레벨 | 품번 | 품명 | 소요량 | 단위 | 로스율(%) | 공정구분 | 비고 |
|------|------|------|--------|------|-----------|----------|------|
| 0 | PROD-001 | 완제품A | 1 | EA | 0 | | ← BOM 헤더 (건너뜀) |
| 1 | P-001 | 부품A | 2 | EA | 0 | | ← 직접 자품목 |
| 2 | P-002 | 부품B | 3 | EA | 5 | 가공 | ← P-001의 하위 |
| 1 | P-003 | 부품C | 1 | KG | 0 | | ← 직접 자품목 |
| 2 | P-004 | 부품D | 4 | EA | 0 | 조립 | ← P-003의 하위 |
| 1 | P-005 | 부품E | 1 | EA | 0 | | ← 직접 자품목 |
- 레벨 0: BOM 헤더 (최상위 품목) → 업로드 시 건너뜀 (이미 존재)
- 레벨 1: 직접 자품목 → bom_detail (parent_detail_id=null, DB level=0)
- 레벨 2: 자품목의 하위 → bom_detail (parent_detail_id=부모ID, DB level=1)
- 레벨 N: → bom_detail (DB level=N-1)
- 품번으로 item_info를 조회하여 child_item_id 자동 매핑
### 트리 변환 로직 (레벨 1 이상만 처리)
엑셀 행을 순서대로 순회하면서 (레벨 0 건너뜀):
1. 각 행의 엑셀 레벨에서 -1하여 DB 레벨 계산
2. 스택으로 부모-자식 관계 추적
```
행1(레벨0) → BOM 헤더, 건너뜀
행2(레벨1) → DB level=0, 스택: [행2] → parent_detail_id = null
행3(레벨2) → DB level=1, 스택: [행2, 행3] → parent_detail_id = 행2.id
행4(레벨1) → DB level=0, 스택: [행4] → parent_detail_id = null
행5(레벨2) → DB level=1, 스택: [행4, 행5] → parent_detail_id = 행4.id
행6(레벨1) → DB level=0, 스택: [행6] → parent_detail_id = null
```
## 테스트 계획
### 1단계: 백엔드 API
- [x] 테스트 1: 품번으로 item_info 일괄 조회 (존재하는 품번)
- [x] 테스트 2: 존재하지 않는 품번 에러 처리
- [x] 테스트 3: 플랫 데이터 → 트리 구조 변환 (parent_detail_id 계산)
- [x] 테스트 4: bom_detail INSERT (version_id 포함)
- [x] 테스트 5: 기존 디테일 처리 (추가 모드 vs 전체교체 모드)
### 2단계: 프론트엔드 모달
- [x] 테스트 6: 엑셀 파일 파싱 및 미리보기
- [x] 테스트 7: 품번 매핑 결과 표시 (성공/실패)
- [x] 테스트 8: 업로드 실행 및 결과 표시
### 3단계: 통합
- [x] 테스트 9: BomTreeComponent에 엑셀 업로드 버튼 추가
- [x] 테스트 10: 업로드 후 트리 자동 새로고침
## 구현 파일 목록
### 백엔드
1. `backend-node/src/services/bomService.ts` - `uploadBomExcel()` 함수 추가
2. `backend-node/src/controllers/bomController.ts` - `uploadBomExcel` 핸들러 추가
3. `backend-node/src/routes/bomRoutes.ts` - `POST /:bomId/excel-upload` 라우트 추가
### 프론트엔드
4. `frontend/lib/registry/components/v2-bom-tree/BomExcelUploadModal.tsx` - 전용 모달 신규
5. `frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx` - 업로드 버튼 추가
## 진행 상태
- 완료된 테스트는 [x]로 표시
- 현재 진행 중인 테스트는 [진행중]으로 표시

View File

@ -1242,8 +1242,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
}
} else {
// UPDATE 모드 - PUT (전체 업데이트)
// originalData 비교 없이 formData 전체를 보냄
const recordId = formData.id;
// VIEW에서 온 데이터의 경우 master_id를 우선 사용 (마스터-디테일 구조)
const recordId = formData.master_id || formData.id;
if (!recordId) {
console.error("[EditModal] UPDATE 실패: formData에 id가 없습니다.", {
@ -1296,15 +1296,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
if (response.success) {
toast.success("데이터가 수정되었습니다.");
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
if (modalState.onSave) {
try {
modalState.onSave();
} catch (callbackError) {
console.error("onSave 콜백 에러:", callbackError);
}
}
// 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어)
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
try {
@ -1375,6 +1366,10 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
}
// 리피터 저장 완료 후 메인 테이블 새로고침
if (modalState.onSave) {
try { modalState.onSave(); } catch {}
}
handleClose();
} else {
throw new Error(response.message || "수정에 실패했습니다.");

View File

@ -428,7 +428,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
{
page: 1,
size: 1000,
search: { [config.foreignKeyColumn]: fkValue },
dataFilter: {
enabled: true,
filters: [{ columnName: config.foreignKeyColumn, operator: "equals", value: fkValue }],
},
autoFilter: true,
}
);

View File

@ -40,6 +40,7 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
// 🆕 그룹화 설정 (예: groupByColumn: "inbound_number")
const groupByColumn = rawConfig.groupByColumn;
const groupBySourceColumn = rawConfig.groupBySourceColumn || rawConfig.groupByColumn;
const targetTable = rawConfig.targetTable;
// 🆕 DB 컬럼 정보를 적용한 config 생성 (webType → type 매핑)
@ -86,8 +87,8 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
// 🆕 formData와 config.fields의 필드 이름 매칭 확인
const matchingFields = configFields.filter((field: any) => formData?.[field.name] !== undefined);
// 🆕 그룹 키 값 (예: formData.inbound_number)
const groupKeyValue = groupByColumn ? formData?.[groupByColumn] : null;
// 🆕 그룹 키 값: groupBySourceColumn(formData 키)과 groupByColumn(DB 컬럼)을 분리
const groupKeyValue = groupBySourceColumn ? formData?.[groupBySourceColumn] : null;
// 🆕 분할 패널 위치 및 좌측 선택 데이터 확인
const splitPanelPosition = screenContext?.splitPanelPosition;

View File

@ -0,0 +1,609 @@
"use client";
import React, { useState, useRef, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import {
Upload,
FileSpreadsheet,
AlertCircle,
CheckCircle2,
Download,
Loader2,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { importFromExcel } from "@/lib/utils/excelExport";
import { apiClient } from "@/lib/api/client";
interface BomExcelUploadModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
/** bomId가 있으면 "새 버전 등록" 모드, 없으면 "새 BOM 생성" 모드 */
bomId?: string;
bomName?: string;
}
interface ParsedRow {
rowIndex: number;
level: number;
item_number: string;
item_name: string;
quantity: number;
unit: string;
process_type: string;
remark: string;
valid: boolean;
error?: string;
isHeader?: boolean;
}
type UploadStep = "upload" | "preview" | "result";
const EXPECTED_HEADERS = ["레벨", "품번", "품명", "소요량", "단위", "공정구분", "비고"];
const HEADER_MAP: Record<string, string> = {
"레벨": "level",
"level": "level",
"품번": "item_number",
"품목코드": "item_number",
"item_number": "item_number",
"item_code": "item_number",
"품명": "item_name",
"품목명": "item_name",
"item_name": "item_name",
"소요량": "quantity",
"수량": "quantity",
"quantity": "quantity",
"qty": "quantity",
"단위": "unit",
"unit": "unit",
"공정구분": "process_type",
"공정": "process_type",
"process_type": "process_type",
"비고": "remark",
"remark": "remark",
};
export function BomExcelUploadModal({
open,
onOpenChange,
onSuccess,
bomId,
bomName,
}: BomExcelUploadModalProps) {
const isVersionMode = !!bomId;
const [step, setStep] = useState<UploadStep>("upload");
const [parsedRows, setParsedRows] = useState<ParsedRow[]>([]);
const [fileName, setFileName] = useState<string>("");
const [uploading, setUploading] = useState(false);
const [uploadResult, setUploadResult] = useState<any>(null);
const [downloading, setDownloading] = useState(false);
const [versionName, setVersionName] = useState<string>("");
const fileInputRef = useRef<HTMLInputElement>(null);
const reset = useCallback(() => {
setStep("upload");
setParsedRows([]);
setFileName("");
setUploadResult(null);
setUploading(false);
setVersionName("");
if (fileInputRef.current) fileInputRef.current.value = "";
}, []);
const handleClose = useCallback(() => {
reset();
onOpenChange(false);
}, [reset, onOpenChange]);
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setFileName(file.name);
try {
const rawData = await importFromExcel(file);
if (!rawData || rawData.length === 0) {
toast.error("엑셀 파일에 데이터가 없습니다");
return;
}
const firstRow = rawData[0];
const excelHeaders = Object.keys(firstRow);
const fieldMap: Record<string, string> = {};
for (const header of excelHeaders) {
const normalized = header.trim().toLowerCase();
const mapped = HEADER_MAP[normalized] || HEADER_MAP[header.trim()];
if (mapped) {
fieldMap[header] = mapped;
}
}
const hasItemNumber = excelHeaders.some(h => {
const n = h.trim().toLowerCase();
return HEADER_MAP[n] === "item_number" || HEADER_MAP[h.trim()] === "item_number";
});
if (!hasItemNumber) {
toast.error("품번 컬럼을 찾을 수 없습니다. 컬럼명을 확인해주세요.");
return;
}
const parsed: ParsedRow[] = [];
for (let index = 0; index < rawData.length; index++) {
const row = rawData[index];
const getField = (fieldName: string): any => {
for (const [excelKey, mappedField] of Object.entries(fieldMap)) {
if (mappedField === fieldName) return row[excelKey];
}
return undefined;
};
const levelRaw = getField("level");
const level = typeof levelRaw === "number" ? levelRaw : parseInt(String(levelRaw || "0"), 10);
const itemNumber = String(getField("item_number") || "").trim();
const itemName = String(getField("item_name") || "").trim();
const quantityRaw = getField("quantity");
const quantity = typeof quantityRaw === "number" ? quantityRaw : parseFloat(String(quantityRaw || "1"));
const unit = String(getField("unit") || "").trim();
const processType = String(getField("process_type") || "").trim();
const remark = String(getField("remark") || "").trim();
let valid = true;
let error = "";
const isHeader = level === 0;
if (!itemNumber) {
valid = false;
error = "품번 필수";
} else if (isNaN(level) || level < 0) {
valid = false;
error = "레벨 오류";
} else if (index > 0) {
const prevLevel = parsed[index - 1]?.level ?? 0;
if (level > prevLevel + 1) {
valid = false;
error = `레벨 점프 (이전: ${prevLevel})`;
}
}
parsed.push({
rowIndex: index + 1,
isHeader,
level,
item_number: itemNumber,
item_name: itemName,
quantity: isNaN(quantity) ? 1 : quantity,
unit,
process_type: processType,
remark,
valid,
error,
});
}
const filtered = parsed.filter(r => r.item_number !== "");
// 새 BOM 생성 모드: 레벨 0 필수
if (!isVersionMode) {
const hasHeader = filtered.some(r => r.level === 0);
if (!hasHeader) {
toast.error("레벨 0(BOM 마스터) 행이 필요합니다. 첫 행에 최상위 품목을 입력해주세요.");
return;
}
}
setParsedRows(filtered);
setStep("preview");
} catch (err: any) {
toast.error(`파일 파싱 실패: ${err.message}`);
}
}, [isVersionMode]);
const handleUpload = useCallback(async () => {
const invalidRows = parsedRows.filter(r => !r.valid);
if (invalidRows.length > 0) {
toast.error(`유효하지 않은 행이 ${invalidRows.length}건 있습니다.`);
return;
}
setUploading(true);
try {
const rowPayload = parsedRows.map(r => ({
level: r.level,
item_number: r.item_number,
item_name: r.item_name,
quantity: r.quantity,
unit: r.unit,
process_type: r.process_type,
remark: r.remark,
}));
let res;
if (isVersionMode) {
res = await apiClient.post(`/bom/${bomId}/excel-upload-version`, {
rows: rowPayload,
versionName: versionName.trim() || undefined,
});
} else {
res = await apiClient.post("/bom/excel-upload", { rows: rowPayload });
}
if (res.data?.success) {
setUploadResult(res.data.data);
setStep("result");
const msg = isVersionMode
? `새 버전 생성 완료: 하위품목 ${res.data.data.insertedCount}`
: `BOM 생성 완료: 하위품목 ${res.data.data.insertedCount}`;
toast.success(msg);
onSuccess?.();
} else {
const errData = res.data?.data;
if (errData?.unmatchedItems?.length > 0) {
toast.error(`매칭 안 되는 품번: ${errData.unmatchedItems.join(", ")}`);
setParsedRows(prev => prev.map(r => {
if (errData.unmatchedItems.includes(r.item_number)) {
return { ...r, valid: false, error: "품번 미등록" };
}
return r;
}));
} else {
toast.error(res.data?.message || "업로드 실패");
}
}
} catch (err: any) {
toast.error(`업로드 오류: ${err.response?.data?.message || err.message}`);
} finally {
setUploading(false);
}
}, [parsedRows, isVersionMode, bomId, versionName, onSuccess]);
const handleDownloadTemplate = useCallback(async () => {
setDownloading(true);
try {
const XLSX = await import("xlsx");
let data: Record<string, any>[] = [];
if (isVersionMode && bomId) {
// 기존 BOM 데이터를 템플릿으로 다운로드
try {
const res = await apiClient.get(`/bom/${bomId}/excel-download`);
if (res.data?.success && res.data.data?.length > 0) {
data = res.data.data.map((row: any) => ({
"레벨": row.level,
"품번": row.item_number,
"품명": row.item_name,
"소요량": row.quantity,
"단위": row.unit,
"공정구분": row.process_type,
"비고": row.remark,
}));
}
} catch { /* 데이터 없으면 빈 템플릿 */ }
}
if (data.length === 0) {
if (isVersionMode) {
data = [
{ "레벨": 1, "품번": "(자품목 품번)", "품명": "(자품목 품명)", "소요량": 2, "단위": "EA", "공정구분": "", "비고": "" },
{ "레벨": 2, "품번": "(하위품목 품번)", "품명": "(하위 품명)", "소요량": 1, "단위": "EA", "공정구분": "", "비고": "" },
];
} else {
data = [
{ "레벨": 0, "품번": "(최상위 품번)", "품명": "(최상위 품명)", "소요량": 1, "단위": "EA", "공정구분": "", "비고": "BOM 마스터" },
{ "레벨": 1, "품번": "(자품목 품번)", "품명": "(자품목 품명)", "소요량": 2, "단위": "EA", "공정구분": "", "비고": "" },
{ "레벨": 2, "품번": "(하위품목 품번)", "품명": "(하위 품명)", "소요량": 1, "단위": "EA", "공정구분": "", "비고": "" },
];
}
}
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "BOM");
ws["!cols"] = [
{ wch: 6 }, { wch: 18 }, { wch: 20 }, { wch: 10 },
{ wch: 8 }, { wch: 12 }, { wch: 20 },
];
const filename = bomName ? `BOM_${bomName}.xlsx` : "BOM_template.xlsx";
XLSX.writeFile(wb, filename);
toast.success("템플릿 다운로드 완료");
} catch (err: any) {
toast.error(`다운로드 실패: ${err.message}`);
} finally {
setDownloading(false);
}
}, [isVersionMode, bomId, bomName]);
const headerRow = parsedRows.find(r => r.isHeader);
const detailRows = parsedRows.filter(r => !r.isHeader);
const validCount = parsedRows.filter(r => r.valid).length;
const invalidCount = parsedRows.filter(r => !r.valid).length;
const title = isVersionMode ? "BOM 새 버전 엑셀 업로드" : "BOM 엑셀 업로드";
const description = isVersionMode
? `${bomName || "선택된 BOM"}의 새 버전을 엑셀 파일로 생성합니다. 레벨 0 행은 건너뜁니다.`
: "엑셀 파일로 새 BOM을 생성합니다. 레벨 0 = BOM 마스터, 레벨 1 이상 = 하위품목.";
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) handleClose(); }}>
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">{title}</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">{description}</DialogDescription>
</DialogHeader>
{/* Step 1: 파일 업로드 */}
{step === "upload" && (
<div className="space-y-4">
{/* 새 버전 모드: 버전명 입력 */}
{isVersionMode && (
<div>
<Label className="text-xs sm:text-sm"> ( )</Label>
<Input
value={versionName}
onChange={(e) => setVersionName(e.target.value)}
placeholder="예: 2.0"
className="h-8 text-xs sm:h-10 sm:text-sm mt-1"
/>
</div>
)}
<div
className={cn(
"border-2 border-dashed rounded-lg p-8 text-center cursor-pointer",
"hover:border-primary/50 hover:bg-muted/50 transition-colors",
"border-muted-foreground/25",
)}
onClick={() => fileInputRef.current?.click()}
>
<FileSpreadsheet className="mx-auto h-10 w-10 text-muted-foreground mb-3" />
<p className="text-sm font-medium"> </p>
<p className="text-xs text-muted-foreground mt-1">.xlsx, .xls, .csv </p>
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls,.csv"
className="hidden"
onChange={handleFileSelect}
/>
</div>
<div className="rounded-md bg-muted/50 p-3">
<p className="text-xs font-medium mb-2"> </p>
<div className="flex flex-wrap gap-1">
{EXPECTED_HEADERS.map((h, i) => (
<span
key={h}
className={cn(
"text-[10px] px-2 py-0.5 rounded-full",
i < 2 ? "bg-primary/10 text-primary font-medium" : "bg-muted text-muted-foreground",
)}
>
{h}{i < 2 ? " *" : ""}
</span>
))}
</div>
<p className="text-[10px] text-muted-foreground mt-1.5">
{isVersionMode
? "* 레벨 1 = 직접 자품목, 레벨 2 = 자품목의 자품목. 레벨 0 행이 있으면 건너뜁니다."
: "* 레벨 0 = BOM 마스터(최상위 품목, 1행), 레벨 1 = 직접 자품목, 레벨 2 = 자품목의 자품목."
}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleDownloadTemplate}
disabled={downloading}
className="w-full"
>
{downloading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
{isVersionMode && bomName ? `현재 BOM 데이터로 템플릿 다운로드` : "빈 템플릿 다운로드"}
</Button>
</div>
)}
{/* Step 2: 미리보기 */}
{step === "preview" && (
<div className="flex flex-col flex-1 min-h-0 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">{fileName}</span>
{!isVersionMode && headerRow && (
<span className="text-xs font-medium">: {headerRow.item_number}</span>
)}
<span className="text-xs">
<span className="font-medium">{detailRows.length}</span>
</span>
{invalidCount > 0 && (
<span className="text-xs text-destructive flex items-center gap-1">
<AlertCircle className="h-3 w-3" /> {invalidCount}
</span>
)}
</div>
<Button variant="ghost" size="sm" onClick={reset} className="h-7 text-xs">
<X className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="flex-1 min-h-0 overflow-auto border rounded-md">
<table className="w-full text-xs">
<thead className="bg-muted/50 sticky top-0">
<tr>
<th className="px-2 py-1.5 text-left font-medium w-8">#</th>
<th className="px-2 py-1.5 text-left font-medium w-12"></th>
<th className="px-2 py-1.5 text-center font-medium w-12"></th>
<th className="px-2 py-1.5 text-left font-medium"></th>
<th className="px-2 py-1.5 text-left font-medium"></th>
<th className="px-2 py-1.5 text-right font-medium w-16"></th>
<th className="px-2 py-1.5 text-left font-medium w-14"></th>
<th className="px-2 py-1.5 text-left font-medium w-20"></th>
<th className="px-2 py-1.5 text-left font-medium"></th>
</tr>
</thead>
<tbody>
{parsedRows.map((row) => (
<tr
key={row.rowIndex}
className={cn(
"border-t hover:bg-muted/30",
row.isHeader && "bg-blue-50/50",
!row.valid && "bg-destructive/5",
)}
>
<td className="px-2 py-1 text-muted-foreground">{row.rowIndex}</td>
<td className="px-2 py-1">
{row.isHeader ? (
<span className="text-[10px] text-blue-600 font-medium bg-blue-50 px-1.5 py-0.5 rounded">
{isVersionMode ? "건너뜀" : "마스터"}
</span>
) : row.valid ? (
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
) : (
<span className="flex items-center gap-1" title={row.error}>
<AlertCircle className="h-3.5 w-3.5 text-destructive" />
</span>
)}
</td>
<td className="px-2 py-1 text-center">
<span
className={cn(
"inline-block rounded px-1.5 py-0.5 text-[10px] font-mono",
row.isHeader ? "bg-blue-100 text-blue-700 font-medium" : "bg-muted",
)}
style={{ marginLeft: `${row.level * 8}px` }}
>
{row.level}
</span>
</td>
<td className={cn("px-2 py-1 font-mono", row.isHeader && "font-semibold")}>{row.item_number}</td>
<td className={cn("px-2 py-1", row.isHeader && "font-semibold")}>{row.item_name}</td>
<td className="px-2 py-1 text-right font-mono">{row.quantity}</td>
<td className="px-2 py-1">{row.unit}</td>
<td className="px-2 py-1">{row.process_type}</td>
<td className="px-2 py-1 text-muted-foreground truncate max-w-[100px]">{row.remark}</td>
</tr>
))}
</tbody>
</table>
</div>
{invalidCount > 0 && (
<div className="rounded-md bg-destructive/10 p-2.5 text-xs text-destructive">
<div className="font-medium mb-1"> ({invalidCount})</div>
<ul className="space-y-0.5 ml-3 list-disc">
{parsedRows.filter(r => !r.valid).slice(0, 5).map(r => (
<li key={r.rowIndex}>{r.rowIndex}: {r.error}</li>
))}
{invalidCount > 5 && <li>... {invalidCount - 5}</li>}
</ul>
</div>
)}
<div className="text-xs text-muted-foreground">
{isVersionMode
? "레벨 1 이상의 하위품목으로 새 버전을 생성합니다."
: "레벨 0 품목으로 새 BOM 마스터를 생성하고, 레벨 1 이상은 하위품목으로 등록합니다."
}
</div>
</div>
)}
{/* Step 3: 결과 */}
{step === "result" && uploadResult && (
<div className="space-y-4 py-4">
<div className="flex flex-col items-center text-center">
<div className="w-14 h-14 rounded-full bg-green-100 flex items-center justify-center mb-3">
<CheckCircle2 className="h-7 w-7 text-green-600" />
</div>
<h3 className="text-lg font-semibold">
{isVersionMode ? "새 버전 생성 완료" : "BOM 생성 완료"}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{uploadResult.insertedCount} .
</p>
</div>
<div className={cn("grid gap-3 max-w-xs mx-auto", isVersionMode ? "grid-cols-1" : "grid-cols-2")}>
{!isVersionMode && (
<div className="rounded-lg bg-muted/50 p-3 text-center">
<div className="text-2xl font-bold text-blue-600">1</div>
<div className="text-xs text-muted-foreground">BOM </div>
</div>
)}
<div className="rounded-lg bg-muted/50 p-3 text-center">
<div className="text-2xl font-bold text-green-600">{uploadResult.insertedCount}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
</div>
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
{step === "upload" && (
<Button
variant="outline"
onClick={handleClose}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
)}
{step === "preview" && (
<>
<Button
variant="outline"
onClick={reset}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleUpload}
disabled={uploading || invalidCount > 0}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{uploading ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> ...</>
) : (
<><Upload className="mr-2 h-4 w-4" />
{isVersionMode ? `새 버전 생성 (${detailRows.length}건)` : `BOM 생성 (${detailRows.length}건)`}
</>
)}
</Button>
</>
)}
{step === "result" && (
<Button
onClick={handleClose}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -14,6 +14,7 @@ import {
History,
GitBranch,
Check,
FileSpreadsheet,
} from "lucide-react";
import { cn } from "@/lib/utils";
@ -22,6 +23,7 @@ import { Button } from "@/components/ui/button";
import { BomDetailEditModal } from "./BomDetailEditModal";
import { BomHistoryModal } from "./BomHistoryModal";
import { BomVersionModal } from "./BomVersionModal";
import { BomExcelUploadModal } from "./BomExcelUploadModal";
interface BomTreeNode {
id: string;
@ -77,6 +79,7 @@ export function BomTreeComponent({
const [editTargetNode, setEditTargetNode] = useState<BomTreeNode | null>(null);
const [historyModalOpen, setHistoryModalOpen] = useState(false);
const [versionModalOpen, setVersionModalOpen] = useState(false);
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const [colWidths, setColWidths] = useState<Record<string, number>>({});
const handleResizeStart = useCallback((colKey: string, e: React.MouseEvent) => {
@ -824,6 +827,15 @@ export function BomTreeComponent({
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => setExcelUploadOpen(true)}
className="h-6 gap-1 px-2 text-[10px]"
>
<FileSpreadsheet 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">
<button
@ -1136,6 +1148,18 @@ export function BomTreeComponent({
}}
/>
)}
{selectedBomId && (
<BomExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
bomId={selectedBomId}
bomName={headerInfo?.item_name || ""}
onSuccess={() => {
window.dispatchEvent(new CustomEvent("refreshTable"));
}}
/>
)}
</div>
);
}

View File

@ -20,6 +20,7 @@ import {
Trash2,
Settings,
Move,
FileSpreadsheet,
} from "lucide-react";
import { dataApi } from "@/lib/api/data";
import { entityJoinApi } from "@/lib/api/entityJoin";
@ -43,6 +44,7 @@ import { useSplitPanel } from "./SplitPanelContext";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { PanelInlineComponent } from "./types";
import { cn } from "@/lib/utils";
import { BomExcelUploadModal } from "../v2-bom-tree/BomExcelUploadModal";
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
// 추가 props
@ -500,6 +502,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [showAddModal, setShowAddModal] = useState(false);
const [addModalPanel, setAddModalPanel] = useState<"left" | "right" | "left-item" | null>(null);
const [addModalFormData, setAddModalFormData] = useState<Record<string, any>>({});
const [bomExcelUploadOpen, setBomExcelUploadOpen] = useState(false);
// 수정 모달 상태
const [showEditModal, setShowEditModal] = useState(false);
@ -3010,6 +3013,13 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<CardTitle className="text-base font-semibold">
{componentConfig.leftPanel?.title || "좌측 패널"}
</CardTitle>
<div className="flex items-center gap-1">
{!isDesignMode && (componentConfig.leftPanel as any)?.showBomExcelUpload && (
<Button size="sm" variant="outline" onClick={() => setBomExcelUploadOpen(true)}>
<FileSpreadsheet className="mr-1 h-4 w-4" />
</Button>
)}
{!isDesignMode && componentConfig.leftPanel?.showAdd && (
<Button size="sm" variant="outline" onClick={() => handleAddClick("left")}>
<Plus className="mr-1 h-4 w-4" />
@ -3017,6 +3027,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</Button>
)}
</div>
</div>
</CardHeader>
{componentConfig.leftPanel?.showSearch && (
<div className="flex-shrink-0 border-b p-2">
@ -5070,6 +5081,16 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</DialogFooter>
</DialogContent>
</Dialog>
{(componentConfig.leftPanel as any)?.showBomExcelUpload && (
<BomExcelUploadModal
open={bomExcelUploadOpen}
onOpenChange={setBomExcelUploadOpen}
onSuccess={() => {
loadLeftData();
}}
/>
)}
</div>
);
};