feat: Enhance BOM management with new header retrieval and version handling
- Added a new endpoint to retrieve BOM headers with entity join support, improving data accessibility. - Updated the BOM service to include logic for fetching current version IDs and handling version-related data more effectively. - Enhanced the BOM tree component to utilize the new BOM header API for better data management. - Implemented version ID fallback mechanisms to ensure accurate data representation during BOM operations. - Improved the overall user experience by integrating new features for version management and data loading.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import {
|
||||
GripVertical,
|
||||
Plus,
|
||||
@@ -487,6 +487,28 @@ export function BomItemEditorComponent({
|
||||
return null;
|
||||
}, [propBomId, formData, selectedRowsData]);
|
||||
|
||||
// BOM 전용 API로 현재 current_version_id 조회
|
||||
const fetchCurrentVersionId = useCallback(async (id: string): Promise<string | null> => {
|
||||
try {
|
||||
const res = await apiClient.get(`/bom/${id}/versions`);
|
||||
if (res.data?.success) {
|
||||
// bom.current_version_id를 직접 반환 (불러오기와 사용확정 구분)
|
||||
if (res.data.currentVersionId) return res.data.currentVersionId;
|
||||
// fallback: active 상태 버전
|
||||
const activeVersion = res.data.data?.find((v: any) => v.status === "active");
|
||||
if (activeVersion) return activeVersion.id;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[BomItemEditor] current_version_id 조회 실패:", e);
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// formData에서 가져오는 versionId (fallback용)
|
||||
const propsVersionId = (formData?.current_version_id as string)
|
||||
|| (selectedRowsData?.[0]?.current_version_id as string)
|
||||
|| null;
|
||||
|
||||
// ─── 카테고리 옵션 로드 (리피터 방식) ───
|
||||
|
||||
useEffect(() => {
|
||||
@@ -544,17 +566,31 @@ export function BomItemEditorComponent({
|
||||
referenceTable: sourceTable,
|
||||
}));
|
||||
|
||||
const result = await entityJoinApi.getTableDataWithJoins(mainTableName, {
|
||||
page: 1,
|
||||
size: 500,
|
||||
search: { [fkColumn]: id },
|
||||
sortBy: "seq_no",
|
||||
sortOrder: "asc",
|
||||
enableEntityJoin: true,
|
||||
additionalJoinColumns: additionalJoinColumns.length > 0 ? additionalJoinColumns : undefined,
|
||||
// 서버에서 최신 current_version_id 조회 (항상 최신 보장)
|
||||
const freshVersionId = await fetchCurrentVersionId(id);
|
||||
const effectiveVersionId = freshVersionId || propsVersionId;
|
||||
|
||||
const searchFilter: Record<string, any> = { [fkColumn]: id };
|
||||
if (effectiveVersionId) {
|
||||
searchFilter.version_id = effectiveVersionId;
|
||||
}
|
||||
|
||||
// autoFilter 비활성화: BOM 전용 API로 company_code 관리
|
||||
const res = await apiClient.get(`/table-management/tables/${mainTableName}/data-with-joins`, {
|
||||
params: {
|
||||
page: 1,
|
||||
size: 500,
|
||||
search: JSON.stringify(searchFilter),
|
||||
sortBy: "seq_no",
|
||||
sortOrder: "asc",
|
||||
enableEntityJoin: true,
|
||||
additionalJoinColumns: additionalJoinColumns.length > 0 ? JSON.stringify(additionalJoinColumns) : undefined,
|
||||
autoFilter: JSON.stringify({ enabled: false }),
|
||||
},
|
||||
});
|
||||
|
||||
const rows = (result.data || []).map((row: Record<string, any>) => {
|
||||
const rawData = res.data?.data?.data || res.data?.data || [];
|
||||
const rows = (Array.isArray(rawData) ? rawData : []).map((row: Record<string, any>) => {
|
||||
const mapped = { ...row };
|
||||
for (const key of Object.keys(row)) {
|
||||
if (key.startsWith(`${sourceFk}_`)) {
|
||||
@@ -578,14 +614,20 @@ export function BomItemEditorComponent({
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[mainTableName, fkColumn, sourceFk, sourceTable, columns],
|
||||
[mainTableName, fkColumn, sourceFk, sourceTable, columns, fetchCurrentVersionId, propsVersionId],
|
||||
);
|
||||
|
||||
// formData.current_version_id가 변경될 때도 재로드 (버전 전환 시 반영)
|
||||
const formVersionRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (bomId && !isDesignMode) {
|
||||
if (!bomId || isDesignMode) return;
|
||||
const currentFormVersion = formData?.current_version_id as string || null;
|
||||
// bomId가 바뀌거나, formData의 current_version_id가 바뀌면 재로드
|
||||
if (formVersionRef.current !== currentFormVersion || !formVersionRef.current) {
|
||||
formVersionRef.current = currentFormVersion;
|
||||
loadBomDetails(bomId);
|
||||
}
|
||||
}, [bomId, isDesignMode, loadBomDetails]);
|
||||
}, [bomId, isDesignMode, loadBomDetails, formData?.current_version_id]);
|
||||
|
||||
// ─── 트리 빌드 (동적 데이터) ───
|
||||
|
||||
@@ -669,6 +711,164 @@ export function BomItemEditorComponent({
|
||||
[onChange, flattenTree],
|
||||
);
|
||||
|
||||
// ─── DB 저장 (INSERT/UPDATE/DELETE 일괄) ───
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const originalDataRef = React.useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
if (treeData.length > 0 && originalDataRef.current.size === 0) {
|
||||
const collectIds = (nodes: BomItemNode[]) => {
|
||||
nodes.forEach((n) => {
|
||||
if (n.id) originalDataRef.current.add(n.id);
|
||||
collectIds(n.children);
|
||||
});
|
||||
};
|
||||
collectIds(treeData);
|
||||
}
|
||||
}, [treeData]);
|
||||
|
||||
const markChanged = useCallback(() => setHasChanges(true), []);
|
||||
const originalNotifyChange = notifyChange;
|
||||
const notifyChangeWithDirty = useCallback(
|
||||
(newTree: BomItemNode[]) => {
|
||||
originalNotifyChange(newTree);
|
||||
markChanged();
|
||||
},
|
||||
[originalNotifyChange, markChanged],
|
||||
);
|
||||
|
||||
// EditModal 저장 시 beforeFormSave 이벤트로 디테일 데이터도 함께 저장
|
||||
useEffect(() => {
|
||||
if (isDesignMode || !bomId) return;
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
console.log("[BomItemEditor] beforeFormSave 이벤트 수신:", {
|
||||
bomId,
|
||||
treeDataLength: treeData.length,
|
||||
hasRef: !!handleSaveAllRef.current,
|
||||
});
|
||||
if (treeData.length > 0 && handleSaveAllRef.current) {
|
||||
const savePromise = handleSaveAllRef.current();
|
||||
if (detail?.pendingPromises) {
|
||||
detail.pendingPromises.push(savePromise);
|
||||
console.log("[BomItemEditor] pendingPromises에 저장 Promise 등록 완료");
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("beforeFormSave", handler);
|
||||
console.log("[BomItemEditor] beforeFormSave 리스너 등록:", { bomId, isDesignMode });
|
||||
return () => window.removeEventListener("beforeFormSave", handler);
|
||||
}, [isDesignMode, bomId, treeData.length]);
|
||||
|
||||
const handleSaveAllRef = React.useRef<(() => Promise<void>) | null>(null);
|
||||
|
||||
const handleSaveAll = useCallback(async () => {
|
||||
if (!bomId) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
// 저장 시점에도 최신 version_id 조회
|
||||
const saveVersionId = await fetchCurrentVersionId(bomId) || propsVersionId;
|
||||
|
||||
const collectAll = (nodes: BomItemNode[], parentRealId: string | null, level: number): any[] => {
|
||||
const result: any[] = [];
|
||||
nodes.forEach((node, idx) => {
|
||||
result.push({
|
||||
node,
|
||||
parentRealId,
|
||||
level,
|
||||
seqNo: idx + 1,
|
||||
});
|
||||
if (node.children.length > 0) {
|
||||
result.push(...collectAll(node.children, node.id || node.tempId, level + 1));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const allNodes = collectAll(treeData, null, 0);
|
||||
const tempToReal: Record<string, string> = {};
|
||||
let savedCount = 0;
|
||||
|
||||
for (const { node, parentRealId, level, seqNo } of allNodes) {
|
||||
const realParentId = parentRealId
|
||||
? tempToReal[parentRealId] || parentRealId
|
||||
: null;
|
||||
|
||||
if (node._isNew) {
|
||||
const payload: Record<string, any> = {
|
||||
...node.data,
|
||||
[fkColumn]: bomId,
|
||||
[parentKeyColumn]: realParentId,
|
||||
seq_no: String(seqNo),
|
||||
level: String(level),
|
||||
company_code: companyCode || undefined,
|
||||
version_id: saveVersionId || undefined,
|
||||
};
|
||||
delete payload.id;
|
||||
delete payload.tempId;
|
||||
delete payload._isNew;
|
||||
delete payload._isDeleted;
|
||||
|
||||
const resp = await apiClient.post(
|
||||
`/table-management/tables/${mainTableName}/add`,
|
||||
payload,
|
||||
);
|
||||
const newId = resp.data?.data?.id;
|
||||
if (newId) tempToReal[node.tempId] = newId;
|
||||
savedCount++;
|
||||
} else if (node.id) {
|
||||
const updatedData: Record<string, any> = {
|
||||
...node.data,
|
||||
id: node.id,
|
||||
[parentKeyColumn]: realParentId,
|
||||
seq_no: String(seqNo),
|
||||
level: String(level),
|
||||
};
|
||||
delete updatedData.tempId;
|
||||
delete updatedData._isNew;
|
||||
delete updatedData._isDeleted;
|
||||
Object.keys(updatedData).forEach((k) => {
|
||||
if (k.startsWith(`${sourceFk}_`)) delete updatedData[k];
|
||||
});
|
||||
|
||||
await apiClient.put(
|
||||
`/table-management/tables/${mainTableName}/edit`,
|
||||
{ originalData: { id: node.id }, updatedData },
|
||||
);
|
||||
savedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const currentIds = new Set(allNodes.filter((a) => a.node.id).map((a) => a.node.id));
|
||||
for (const oldId of originalDataRef.current) {
|
||||
if (!currentIds.has(oldId)) {
|
||||
await apiClient.delete(
|
||||
`/table-management/tables/${mainTableName}/delete`,
|
||||
{ data: [{ id: oldId }] },
|
||||
);
|
||||
savedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
originalDataRef.current = new Set(allNodes.filter((a) => a.node.id || tempToReal[a.node.tempId]).map((a) => a.node.id || tempToReal[a.node.tempId]));
|
||||
setHasChanges(false);
|
||||
if (bomId) loadBomDetails(bomId);
|
||||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||
console.log(`[BomItemEditor] ${savedCount}건 저장 완료`);
|
||||
} catch (error) {
|
||||
console.error("[BomItemEditor] 저장 실패:", error);
|
||||
alert("저장 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [bomId, treeData, fkColumn, parentKeyColumn, mainTableName, companyCode, sourceFk, loadBomDetails, fetchCurrentVersionId, propsVersionId]);
|
||||
|
||||
useEffect(() => {
|
||||
handleSaveAllRef.current = handleSaveAll;
|
||||
}, [handleSaveAll]);
|
||||
|
||||
// ─── 노드 조작 함수들 ───
|
||||
|
||||
// 트리에서 특정 노드 찾기 (재귀)
|
||||
@@ -699,18 +899,18 @@ export function BomItemEditorComponent({
|
||||
...node,
|
||||
data: { ...node.data, [field]: value },
|
||||
}));
|
||||
notifyChange(newTree);
|
||||
notifyChangeWithDirty(newTree);
|
||||
},
|
||||
[treeData, notifyChange],
|
||||
[treeData, notifyChangeWithDirty],
|
||||
);
|
||||
|
||||
// 노드 삭제
|
||||
const handleDelete = useCallback(
|
||||
(tempId: string) => {
|
||||
const newTree = findAndUpdate(treeData, tempId, () => null);
|
||||
notifyChange(newTree);
|
||||
notifyChangeWithDirty(newTree);
|
||||
},
|
||||
[treeData, notifyChange],
|
||||
[treeData, notifyChangeWithDirty],
|
||||
);
|
||||
|
||||
// 하위 품목 추가 시작 (모달 열기)
|
||||
@@ -778,9 +978,9 @@ export function BomItemEditorComponent({
|
||||
setExpandedNodes((prev) => new Set([...prev, addTargetParentId]));
|
||||
}
|
||||
|
||||
notifyChange(newTree);
|
||||
notifyChangeWithDirty(newTree);
|
||||
},
|
||||
[addTargetParentId, treeData, notifyChange, cfg],
|
||||
[addTargetParentId, treeData, notifyChangeWithDirty, cfg],
|
||||
);
|
||||
|
||||
// 펼침/접기 토글
|
||||
@@ -882,11 +1082,11 @@ export function BomItemEditorComponent({
|
||||
if (inserted) {
|
||||
const reindex = (nodes: BomItemNode[], depth = 0): BomItemNode[] =>
|
||||
nodes.map((n, i) => ({ ...n, seq_no: i + 1, level: depth, children: reindex(n.children, depth + 1) }));
|
||||
notifyChange(reindex(result));
|
||||
notifyChangeWithDirty(reindex(result));
|
||||
}
|
||||
|
||||
setDragId(null);
|
||||
}, [dragId, treeData, notifyChange]);
|
||||
}, [dragId, treeData, notifyChangeWithDirty]);
|
||||
|
||||
// ─── 재귀 렌더링 ───
|
||||
|
||||
@@ -1086,15 +1286,29 @@ export function BomItemEditorComponent({
|
||||
<div className="space-y-3">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold">하위 품목 구성</h4>
|
||||
<Button
|
||||
onClick={handleAddRoot}
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
품목추가
|
||||
</Button>
|
||||
<h4 className="text-sm font-semibold">
|
||||
하위 품목 구성
|
||||
{hasChanges && <span className="ml-1.5 text-[10px] text-amber-500">(미저장)</span>}
|
||||
</h4>
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
onClick={handleAddRoot}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
품목추가
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveAll}
|
||||
disabled={saving || !hasChanges}
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{saving ? "저장중..." : "저장"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 트리 목록 */}
|
||||
|
||||
Reference in New Issue
Block a user