diff --git a/backend-node/src/controllers/bomController.ts b/backend-node/src/controllers/bomController.ts index 58657db7..7651813a 100644 --- a/backend-node/src/controllers/bomController.ts +++ b/backend-node/src/controllers/bomController.ts @@ -143,6 +143,73 @@ export async function initializeBomVersion(req: Request, res: Response) { } } +// ─── BOM 복사 (TASK:ERP-028) ───────────────────────── + +/** + * POST /bom/:bomId/copy-to-items + * 기준 BOM의 트리(편집본)를 대상 품목 N개에 복제 + * - conflictStrategy = "skip": 대상 품목에 BOM 있으면 skipped[] + * - conflictStrategy = "new_version": 대상 품목 BOM에 새 draft 버전 추가 (없으면 새 BOM 생성) + */ +export async function copyBomToItems(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 { targetItemIds, conflictStrategy, editedTree } = req.body || {}; + + // ─── 페이로드 검증 ───────────────────── + if (!Array.isArray(targetItemIds) || targetItemIds.length === 0) { + res.status(400).json({ success: false, message: "targetItemIds는 1개 이상의 배열이어야 합니다" }); + return; + } + if (conflictStrategy !== "skip" && conflictStrategy !== "new_version") { + res.status(400).json({ success: false, message: "conflictStrategy는 'skip' 또는 'new_version'이어야 합니다" }); + return; + } + if (!Array.isArray(editedTree) || editedTree.length === 0) { + res.status(400).json({ success: false, message: "editedTree는 1개 이상의 노드 배열이어야 합니다" }); + return; + } + + // 트리 노드 필수 필드 검증 + for (const n of editedTree) { + if (!n || typeof n !== "object") { + res.status(400).json({ success: false, message: "editedTree 노드는 객체여야 합니다" }); + return; + } + if (!n.tempId) { + res.status(400).json({ success: false, message: "editedTree 각 노드에는 tempId가 필요합니다" }); + return; + } + if (!n.childItemId) { + res.status(400).json({ success: false, message: `노드 ${n.tempId}: childItemId가 필요합니다` }); + return; + } + const qty = Number(n.quantity); + if (!Number.isFinite(qty) || qty <= 0) { + res.status(400).json({ success: false, message: `노드 ${n.tempId}: quantity는 0보다 커야 합니다` }); + return; + } + } + + const data = await bomService.copyBomToItems({ + sourceBomId: bomId, + companyCode, + userId, + targetItemIds, + conflictStrategy, + editedTree, + }); + + res.json({ success: true, data }); + } catch (error: any) { + logger.error("BOM 복사 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + // ─── BOM 엑셀 업로드/다운로드 ───────────────────────── export async function createBomFromExcel(req: Request, res: Response) { diff --git a/backend-node/src/routes/bomRoutes.ts b/backend-node/src/routes/bomRoutes.ts index 6ca79e7f..70afe132 100644 --- a/backend-node/src/routes/bomRoutes.ts +++ b/backend-node/src/routes/bomRoutes.ts @@ -22,6 +22,9 @@ router.post("/excel-upload", bomController.createBomFromExcel); router.post("/:bomId/excel-upload-version", bomController.createBomVersionFromExcel); router.get("/:bomId/excel-download", bomController.downloadBomExcelData); +// BOM 복사 — 다른 품목으로 전체 트리 복제 (TASK:ERP-028) +router.post("/:bomId/copy-to-items", bomController.copyBomToItems); + // 버전 router.get("/:bomId/versions", bomController.getBomVersions); router.post("/:bomId/versions", bomController.createBomVersion); diff --git a/backend-node/src/services/bomService.ts b/backend-node/src/services/bomService.ts index 0abb42e1..e71ef3aa 100644 --- a/backend-node/src/services/bomService.ts +++ b/backend-node/src/services/bomService.ts @@ -959,3 +959,408 @@ export async function deleteBomDetailSubstitute(id: string) { const sql = `DELETE FROM bom_detail_substitute WHERE id = $1 RETURNING id`; return queryOne(sql, [id]); } + +// ─── BOM 복사 (TASK:ERP-028) ───────────────────────────── + +export interface BomTreeNodeInput { + tempId: string; + parentTempId: string | null; + childItemId: string; + quantity: number | string; + unit?: string | null; + processType?: string | null; + lossRate?: number | string | null; + remark?: string | null; +} + +export interface CopyBomToItemsParams { + sourceBomId: string; + companyCode: string; + userId: string; + targetItemIds: string[]; // item_info.id (UUID) + conflictStrategy: "skip" | "new_version"; + editedTree: BomTreeNodeInput[]; +} + +export interface CopyBomToItemsResult { + success_new: string[]; + success_versioned: { itemId: string; newVersionName: string }[]; + skipped: { itemId: string; reason: string }[]; + failed: { itemId: string; error: string }[]; +} + +/** + * 트리 위상 정렬 + 무결성 검증 + * - parentTempId가 가리키는 tempId가 트리 안에 존재해야 함 + * - 사이클 차단 + * - 루트 노드(parentTempId=null)는 1개 이상 존재해야 함 + * 반환: 부모→자식 순서로 정렬된 노드 배열 + */ +export function topoSortAndValidateTree(nodes: BomTreeNodeInput[]): BomTreeNodeInput[] { + if (!Array.isArray(nodes) || nodes.length === 0) { + throw new Error("트리 노드가 없습니다"); + } + const tempIdSet = new Set(); + for (const n of nodes) { + if (!n.tempId) throw new Error("tempId가 비어있는 노드가 있습니다"); + if (tempIdSet.has(n.tempId)) throw new Error(`중복 tempId: ${n.tempId}`); + tempIdSet.add(n.tempId); + } + // parentTempId 참조 무결성 + for (const n of nodes) { + if (n.parentTempId && !tempIdSet.has(n.parentTempId)) { + throw new Error(`노드 ${n.tempId}의 parentTempId(${n.parentTempId})가 트리에 없습니다`); + } + } + + // 위상 정렬 (Kahn's algorithm) + const childrenOf = new Map(); + const indegree = new Map(); + for (const n of nodes) { + indegree.set(n.tempId, 0); + } + for (const n of nodes) { + if (n.parentTempId) { + indegree.set(n.tempId, (indegree.get(n.tempId) || 0) + 1); + const arr = childrenOf.get(n.parentTempId) || []; + arr.push(n.tempId); + childrenOf.set(n.parentTempId, arr); + } + } + const queue: string[] = []; + for (const [id, deg] of indegree.entries()) { + if (deg === 0) queue.push(id); + } + if (queue.length === 0) throw new Error("루트 노드가 없습니다 (사이클 의심)"); + + const byId = new Map(); + for (const n of nodes) byId.set(n.tempId, n); + + const sorted: BomTreeNodeInput[] = []; + while (queue.length > 0) { + const cur = queue.shift()!; + sorted.push(byId.get(cur)!); + const children = childrenOf.get(cur) || []; + for (const c of children) { + indegree.set(c, (indegree.get(c) || 0) - 1); + if (indegree.get(c) === 0) queue.push(c); + } + } + if (sorted.length !== nodes.length) { + throw new Error("트리에 사이클이 있습니다"); + } + return sorted; +} + +/** + * 대상 bom_id의 다음 version_name 채번 (충돌 시 +0.1) + * - 기존 version_name이 숫자 형식이면 max+0.1 + * - 아니면 "1.0" 부터 시도 + */ +export async function nextVersionName(client: any, bomId: string): Promise { + const res = await client.query( + `SELECT version_name FROM bom_version WHERE bom_id = $1`, + [bomId], + ); + const names: string[] = res.rows.map((r: any) => String(r.version_name || "")); + if (names.length === 0) return "1.0"; + + let maxVal = 0; + let hasNumeric = false; + for (const n of names) { + const v = parseFloat(n); + if (!Number.isNaN(v)) { + hasNumeric = true; + if (v > maxVal) maxVal = v; + } + } + if (!hasNumeric) { + // 숫자 버전이 하나도 없으면 "1.0" 시도 (existing string 버전들과 충돌 시 별도 처리) + if (!names.includes("1.0")) return "1.0"; + // 1.0도 있으면 1.1, 1.2... + let candidate = 1.0; + while (names.includes(candidate.toFixed(1))) candidate += 0.1; + return candidate.toFixed(1); + } + const next = (maxVal + 0.1).toFixed(1); + // 안전망: next가 이미 있으면 +0.1 반복 + let nextNum = parseFloat(next); + let candidate = nextNum.toFixed(1); + while (names.includes(candidate)) { + nextNum += 0.1; + candidate = nextNum.toFixed(1); + } + return candidate; +} + +/** + * 새 버전 레코드 + bom_detail 트리 INSERT + * - sortedTree는 topoSortAndValidateTree()가 부모→자식 순서로 보장 + */ +export async function insertVersionAndDetails( + client: any, + params: { + bomId: string; + versionName: string; + revision: number; + status: string; // "draft" 등 + createdBy: string; + companyCode: string; + sortedTree: BomTreeNodeInput[]; + }, +): Promise<{ versionId: string; insertedCount: number }> { + const versionRes = await client.query( + `INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, + [ + params.bomId, + params.versionName, + params.revision, + params.status, + params.createdBy, + params.companyCode, + ], + ); + const newVersionId = versionRes.rows[0].id; + + // tempId → 새 detail id 매핑 (parent 재배선용) + const tempToNewId: Record = {}; + let seq = 1; + for (const node of params.sortedTree) { + const parentDetailId = node.parentTempId ? (tempToNewId[node.parentTempId] || null) : null; + // level 계산: parent가 없으면 1, 있으면 parent level +1 + // (간단 처리: 정렬 순서대로 매기는 BFS이므로 트리 깊이별 누적이 들어맞음. 정확한 level은 클라이언트가 안 보낸 경우 1로 기본) + const insertRes = await client.query( + `INSERT INTO bom_detail + (bom_id, version_id, parent_detail_id, child_item_id, quantity, unit, process_type, loss_rate, remark, level, base_qty, revision, seq_no, writer, company_code) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + RETURNING id`, + [ + params.bomId, + newVersionId, + parentDetailId, + node.childItemId, + node.quantity != null ? String(node.quantity) : null, + node.unit || null, + node.processType || null, + node.lossRate != null ? String(node.lossRate) : null, + node.remark || null, + null, + null, + String(params.revision), + String(seq++), + params.createdBy, + params.companyCode, + ], + ); + tempToNewId[node.tempId] = insertRes.rows[0].id; + } + + return { versionId: newVersionId, insertedCount: params.sortedTree.length }; +} + +/** + * BOM 복사 메인 진입 + * - 품목 1개당 1트랜잭션 (일부 실패해도 다음 진행) + * - 자기 자신(기준 BOM의 item) 차단은 failed[]에 누적 + */ +export async function copyBomToItems(params: CopyBomToItemsParams): Promise { + const { sourceBomId, companyCode, userId, targetItemIds, conflictStrategy, editedTree } = params; + + // 1) 기준 BOM 헤더 조회 + const sourceBom = await queryOne>( + `SELECT id, item_id, item_code, item_name, item_type, base_qty, unit, remark, company_code + FROM bom WHERE id = $1`, + [sourceBomId], + ); + if (!sourceBom) { + throw new Error("기준 BOM을 찾을 수 없습니다"); + } + + // 2) 트리 검증 + 위상정렬 (요청 전체에 1회만 수행 — 동일 트리를 N개 품목에 적용) + const sortedTree = topoSortAndValidateTree(editedTree); + + const result: CopyBomToItemsResult = { + success_new: [], + success_versioned: [], + skipped: [], + failed: [], + }; + + const sourceItemId = sourceBom.item_id; + const todayStr = new Date().toISOString().slice(0, 10); // YYYY-MM-DD + + // 3) 대상 품목별 루프 — 품목 1개당 1트랜잭션 + for (const targetItemId of targetItemIds) { + // 자기 자신 차단 + if (targetItemId === sourceItemId) { + result.failed.push({ + itemId: targetItemId, + error: "기준 BOM의 품목 자체로는 복사할 수 없습니다", + }); + continue; + } + + try { + // 대상 품목 조회 (item_code/item_name 갱신용) + const targetItem = await queryOne>( + `SELECT id, item_number, item_name, unit FROM item_info WHERE id = $1`, + [targetItemId], + ); + if (!targetItem) { + result.failed.push({ itemId: targetItemId, error: "대상 품목을 찾을 수 없습니다" }); + continue; + } + + // 충돌 처리: 대상 품목에 이미 BOM이 있는지 확인 + const existingBom = await queryOne<{ id: string }>( + `SELECT id FROM bom WHERE item_id = $1 ${ + companyCode !== "*" ? "AND company_code = $2" : "" + } LIMIT 1`, + companyCode !== "*" ? [targetItemId, companyCode] : [targetItemId], + ); + + if (conflictStrategy === "skip") { + if (existingBom) { + result.skipped.push({ itemId: targetItemId, reason: "기존 BOM이 존재하여 스킵" }); + continue; + } + + // 신규 BOM 생성 (스킵 모드 — 없을 때만 생성) + await transaction(async (client) => { + await createNewBomWithTree(client, { + companyCode, + createdBy: userId, + targetItemId, + targetItemCode: targetItem.item_number || "", + targetItemName: targetItem.item_name || "", + sourceBom, + sortedTree, + todayStr, + }); + }); + result.success_new.push(targetItemId); + } else { + // new_version 모드 + if (existingBom) { + // 기존 bom_id에 새 draft 버전 append (race condition 대비 1회 재시도) + let attempted = false; + let newVersionName = ""; + // eslint-disable-next-line no-constant-condition + while (true) { + try { + await transaction(async (client) => { + newVersionName = await nextVersionName(client, existingBom.id); + await insertVersionAndDetails(client, { + bomId: existingBom.id, + versionName: newVersionName, + revision: 0, + status: "draft", + createdBy: userId, + companyCode, + sortedTree, + }); + // bom.current_version_id는 변경하지 않음 (draft만 추가) + }); + break; + } catch (e: any) { + if (!attempted && /duplicate|unique|version_name/i.test(e.message || "")) { + attempted = true; + continue; + } + throw e; + } + } + result.success_versioned.push({ itemId: targetItemId, newVersionName }); + } else { + // 기존 BOM 없음 → 신규 생성 + await transaction(async (client) => { + await createNewBomWithTree(client, { + companyCode, + createdBy: userId, + targetItemId, + targetItemCode: targetItem.item_number || "", + targetItemName: targetItem.item_name || "", + sourceBom, + sortedTree, + todayStr, + }); + }); + result.success_new.push(targetItemId); + } + } + } catch (err: any) { + logger.error("BOM 복사 실패 (품목)", { + targetItemId, + error: err.message, + }); + result.failed.push({ itemId: targetItemId, error: err.message || "알 수 없는 오류" }); + } + } + + return result; +} + +/** + * 신규 BOM 마스터 + 초기 버전 + 트리 INSERT (한 트랜잭션 내부에서 호출) + */ +async function createNewBomWithTree( + client: any, + args: { + companyCode: string; + createdBy: string; + targetItemId: string; + targetItemCode: string; + targetItemName: string; + sourceBom: Record; + sortedTree: BomTreeNodeInput[]; + todayStr: string; + }, +) { + // bom_number 자동 채번: BOM-{YYYYMMDD}-{랜덤4} + const rand = Math.floor(1000 + Math.random() * 9000); + const bomNumber = `BOM-${args.todayStr.replace(/-/g, "")}-${rand}`; + + const newBomRes = await client.query( + `INSERT INTO bom + (id, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, writer, created_date, updated_date) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NULL, $13, $14, NOW(), NOW()) + RETURNING id`, + [ + args.companyCode, + bomNumber, + args.targetItemId, + args.targetItemCode, + args.targetItemName, + args.sourceBom.item_type || null, + args.sourceBom.base_qty || null, + args.sourceBom.unit || null, + "1.0", + "0", + "draft", + args.todayStr, + args.sourceBom.remark || null, + args.createdBy, + ], + ); + const newBomId = newBomRes.rows[0].id; + + // 초기 버전 + 트리 INSERT (version_name="1.0" — 신규 BOM이므로 충돌 없음) + const { versionId } = await insertVersionAndDetails(client, { + bomId: newBomId, + versionName: "1.0", + revision: 0, + status: "draft", + createdBy: args.createdBy, + companyCode: args.companyCode, + sortedTree: args.sortedTree, + }); + + // 신규 BOM은 초기 버전이 곧 current_version + await client.query( + `UPDATE bom SET current_version_id = $1 WHERE id = $2`, + [versionId, newBomId], + ); + + return { newBomId, versionId }; +} diff --git a/frontend/app/(main)/COMPANY_7/production/bom/page.tsx b/frontend/app/(main)/COMPANY_7/production/bom/page.tsx index 061db82d..4ad087c2 100644 --- a/frontend/app/(main)/COMPANY_7/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_7/production/bom/page.tsx @@ -31,6 +31,9 @@ import { import { Checkbox } from "@/components/ui/checkbox"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Progress } from "@/components/ui/progress"; +import { SmartSelect } from "@/components/common/SmartSelect"; import { ResizableHandle, ResizablePanel, @@ -202,6 +205,22 @@ interface TreeNode extends BomDetail { _isVirtualRoot?: boolean; // 가상 루트(BOM 마스터) 노드 } +// 복사 모달 트리 편집기용 행 (평탄화 + 부모 임시 ID로 연결) +interface CopyTreeRow { + tempId: string; + parentTempId: string | null; + level: number; + childItemId: string; + childItemNumber: string; + childItemName: string; + quantity: string; + unit: string; + processType: string; + lossRate: string; + remark: string; + _invalid?: boolean; // 수량 0 이하 차단 하이라이트 +} + // ─── 트리 구성 헬퍼 ───────────────────────────── function buildTree(details: BomDetail[]): TreeNode[] { const nodeMap = new Map(); @@ -380,7 +399,34 @@ export default function BomManagementPage() { const [draggedRowId, setDraggedRowId] = useState(null); const [dragOverRowId, setDragOverRowId] = useState(null); + // ─── BOM 복사 모달 (TASK:ERP-028) ─────────────── + const [copyModalOpen, setCopyModalOpen] = useState(false); + // 좌측: 대상 품목 검색/체크 + const [copySearchKeyword, setCopySearchKeyword] = useState(""); + const [copyFilteredItems, setCopyFilteredItems] = useState([]); + const [copySearchLoading, setCopySearchLoading] = useState(false); + const [copyPage, setCopyPage] = useState(1); + const [copyTotal, setCopyTotal] = useState(0); + const [copyCheckedIds, setCopyCheckedIds] = useState([]); + const [existingBomItemIds, setExistingBomItemIds] = useState>(new Set()); + // 중앙: 옵션 + 진행률 + const [copyConflictStrategy, setCopyConflictStrategy] = useState<"skip" | "new_version">("skip"); + const [copying, setCopying] = useState(false); + const [copyProgress, setCopyProgress] = useState({ current: 0, total: 0 }); + const [copyResult, setCopyResult] = useState({ new: 0, versioned: 0, skipped: 0, failed: 0 }); + // 우측: 편집 트리 (평탄화된 행 목록) + const [copyTreeRows, setCopyTreeRows] = useState([]); + // 자식 추가용 품목 검색 모달 + const [copyChildSearchOpen, setCopyChildSearchOpen] = useState(false); + const [copyChildSearchKw, setCopyChildSearchKw] = useState(""); + const [copyChildSearchResults, setCopyChildSearchResults] = useState([]); + const [copyChildSearchLoading, setCopyChildSearchLoading] = useState(false); + const [copyChildAddTarget, setCopyChildAddTarget] = useState<{ mode: "sibling" | "child" | "root"; rowTempId: string | null }>({ mode: "root", rowTempId: null }); + const copyPageSize = 20; + const copyTotalPages = Math.max(1, Math.ceil(copyTotal / copyPageSize)); + // ─── 데이터 로드 ────────────────────────────── + // (디바운스 useEffect는 핸들러 정의 이후에 위치) const fetchBomList = useCallback(async () => { setLoading(true); try { @@ -1087,6 +1133,348 @@ export default function BomManagementPage() { }; // ─── BOM 삭제 ──────────────────────────────── + // ─── BOM 복사 모달 핸들러 (TASK:ERP-028) ─────────── + // 트리(TreeNode[]) → 평탄화된 CopyTreeRow[] + const flattenTreeForCopy = useCallback((nodes: TreeNode[], parentTempId: string | null = null, level = 0): CopyTreeRow[] => { + const out: CopyTreeRow[] = []; + for (const n of nodes) { + const tempId = crypto.randomUUID(); + out.push({ + tempId, + parentTempId, + level, + childItemId: n.child_item_id || "", + childItemNumber: n.item_number || "", + childItemName: n.item_name || "", + quantity: n.quantity || "1", + unit: n.unit || "", + processType: n.process_type || "", + lossRate: n.loss_rate || "0", + remark: n.remark || "", + }); + if (n.children && n.children.length > 0) { + out.push(...flattenTreeForCopy(n.children, tempId, level + 1)); + } + } + return out; + }, []); + + // 대상 품목별 "기존 BOM 있음" 판정용 set 로드 + const fetchExistingBomItemIds = useCallback(async () => { + try { + const res = await apiClient.post(`/table-management/tables/${BOM_TABLE}/data`, { + page: 1, size: 0, autoFilter: true, + }); + const rows = res.data?.data?.data || res.data?.data?.rows || []; + const ids = new Set(rows.map((r: any) => r.item_id).filter(Boolean)); + setExistingBomItemIds(ids); + } catch { + setExistingBomItemIds(new Set()); + } + }, []); + + // 대상 품목 검색 (좌측 목록) + const searchCopyTargets = useCallback(async (page?: number, kw?: string) => { + const p = page ?? copyPage; + const keyword = (kw ?? copySearchKeyword).trim(); + setCopySearchLoading(true); + try { + const filters: any[] = []; + if (keyword) { + filters.push({ columnName: "item_name", operator: "contains", value: keyword }); + } + const res = await apiClient.post(`/table-management/tables/item_info/data`, { + page: p, size: copyPageSize, + dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, + autoFilter: true, + }); + let resData = res.data?.data; + let rows = resData?.data || resData?.rows || []; + + // 품명으로 못 찾으면 품목코드로 재시도 + if (keyword && rows.length === 0) { + const res2 = await apiClient.post(`/table-management/tables/item_info/data`, { + page: p, size: copyPageSize, + dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "contains", value: keyword }] }, + autoFilter: true, + }); + resData = res2.data?.data; + rows = resData?.data || resData?.rows || []; + } + + // 기준 BOM의 item_id는 자기 자신이므로 제외 + const sourceItemId = bomHeader?.item_id || ""; + const filtered = rows.filter((r: any) => r.id !== sourceItemId); + setCopyFilteredItems(filtered); + setCopyTotal(resData?.total || resData?.totalCount || filtered.length); + } catch { + setCopyFilteredItems([]); + setCopyTotal(0); + } finally { + setCopySearchLoading(false); + } + }, [copyPage, copySearchKeyword, bomHeader]); + + const openCopyModal = async () => { + if (!selectedBomId || !bomHeader) { + toast.error("복사 기준 BOM을 먼저 선택해주세요"); + return; + } + // 트리 초기값: 현재 화면의 트리(편집본 우선, 없으면 원본) + const baseTree = editingTree.length > 0 ? editingTree : treeNodes; + const flat = flattenTreeForCopy(baseTree, null, 0); + setCopyTreeRows(flat); + setCopySearchKeyword(""); + setCopyPage(1); + setCopyCheckedIds([]); + setCopyConflictStrategy("skip"); + setCopyProgress({ current: 0, total: 0 }); + setCopyResult({ new: 0, versioned: 0, skipped: 0, failed: 0 }); + setCopyModalOpen(true); + await fetchExistingBomItemIds(); + searchCopyTargets(1, ""); + }; + + const handleCopySearch = () => { + setCopyPage(1); + searchCopyTargets(1); + }; + + const toggleCopyChecked = (itemId: string) => { + setCopyCheckedIds((prev) => prev.includes(itemId) ? prev.filter((c) => c !== itemId) : [...prev, itemId]); + }; + + // 트리 행 수정 + const updateCopyTreeRow = (tempId: string, patch: Partial) => { + setCopyTreeRows((prev) => prev.map((r) => r.tempId === tempId ? { ...r, ...patch, _invalid: false } : r)); + }; + + // 트리 행 삭제 (자손 포함) + const deleteCopyTreeRow = (tempId: string) => { + setCopyTreeRows((prev) => { + const toRemove = new Set([tempId]); + let changed = true; + while (changed) { + changed = false; + for (const r of prev) { + if (r.parentTempId && toRemove.has(r.parentTempId) && !toRemove.has(r.tempId)) { + toRemove.add(r.tempId); + changed = true; + } + } + } + return prev.filter((r) => !toRemove.has(r.tempId)); + }); + }; + + // 자식/형제/루트 행 추가 — 품목 검색 모달 오픈 + const openCopyChildSearch = (mode: "sibling" | "child" | "root", rowTempId: string | null) => { + setCopyChildAddTarget({ mode, rowTempId }); + setCopyChildSearchKw(""); + setCopyChildSearchResults([]); + setCopyChildSearchOpen(true); + // 빈 검색 즉시 1페이지 로드 + void searchCopyChildItems(""); + }; + + const searchCopyChildItems = async (kw: string) => { + setCopyChildSearchLoading(true); + try { + const filters: any[] = []; + const keyword = kw.trim(); + if (keyword) { + filters.push({ columnName: "item_name", operator: "contains", value: keyword }); + } + const res = await apiClient.post(`/table-management/tables/item_info/data`, { + page: 1, size: 30, + dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, + autoFilter: true, + }); + let rows = res.data?.data?.data || res.data?.data?.rows || []; + if (keyword && rows.length === 0) { + const res2 = await apiClient.post(`/table-management/tables/item_info/data`, { + page: 1, size: 30, + dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "contains", value: keyword }] }, + autoFilter: true, + }); + rows = res2.data?.data?.data || res2.data?.data?.rows || []; + } + // 기준 BOM의 item_id는 자식 후보에서 제외 (재귀 차단) + const sourceItemId = bomHeader?.item_id || ""; + setCopyChildSearchResults(rows.filter((r: any) => r.id !== sourceItemId)); + } catch { + setCopyChildSearchResults([]); + } finally { + setCopyChildSearchLoading(false); + } + }; + + // 검색 결과에서 품목 선택 → 트리에 행 추가 + const selectCopyChildItem = (item: any) => { + const tempId = crypto.randomUUID(); + const target = copyChildAddTarget; + const newRow: CopyTreeRow = { + tempId, + parentTempId: null, + level: 0, + childItemId: item.id, + childItemNumber: item.item_number || "", + childItemName: item.item_name || "", + quantity: "1", + unit: item.inventory_unit || "", + processType: "", + lossRate: "0", + remark: "", + }; + + setCopyTreeRows((prev) => { + if (target.mode === "root" || !target.rowTempId) { + newRow.parentTempId = null; + newRow.level = 0; + return [...prev, newRow]; + } + const baseRow = prev.find((r) => r.tempId === target.rowTempId); + if (!baseRow) return prev; + if (target.mode === "child") { + newRow.parentTempId = baseRow.tempId; + newRow.level = baseRow.level + 1; + // baseRow 바로 아래(같은 부모 마지막)에 삽입 + const idx = prev.findIndex((r) => r.tempId === baseRow.tempId); + // baseRow의 자손 마지막 위치 찾기 + let insertIdx = idx + 1; + const descendants = new Set([baseRow.tempId]); + for (let i = idx + 1; i < prev.length; i++) { + if (prev[i].parentTempId && descendants.has(prev[i].parentTempId!)) { + descendants.add(prev[i].tempId); + insertIdx = i + 1; + } else if (prev[i].level <= baseRow.level) { + break; + } + } + return [...prev.slice(0, insertIdx), newRow, ...prev.slice(insertIdx)]; + } + // sibling + newRow.parentTempId = baseRow.parentTempId; + newRow.level = baseRow.level; + const idx = prev.findIndex((r) => r.tempId === baseRow.tempId); + // baseRow의 자손 끝나는 위치 + let insertIdx = idx + 1; + const descendants = new Set([baseRow.tempId]); + for (let i = idx + 1; i < prev.length; i++) { + if (prev[i].parentTempId && descendants.has(prev[i].parentTempId!)) { + descendants.add(prev[i].tempId); + insertIdx = i + 1; + } else { + break; + } + } + return [...prev.slice(0, insertIdx), newRow, ...prev.slice(insertIdx)]; + }); + setCopyChildSearchOpen(false); + }; + + // 복사 실행 + const handleCopyExecute = async () => { + if (!selectedBomId || !bomHeader) { + toast.error("기준 BOM이 선택되어 있지 않아요"); + return; + } + if (copyCheckedIds.length === 0) { + toast.error("복사할 대상 품목을 선택해주세요"); + return; + } + if (copyTreeRows.length === 0) { + toast.error("최소 1개 이상의 자품목이 필요합니다"); + return; + } + // 수량 검증 — quantity <= 0 또는 빈 값 차단 + const invalidRows = copyTreeRows.filter((r) => { + const q = Number(r.quantity); + return !Number.isFinite(q) || q <= 0; + }); + if (invalidRows.length > 0) { + const ids = new Set(invalidRows.map((r) => r.tempId)); + setCopyTreeRows((prev) => prev.map((r) => ({ ...r, _invalid: ids.has(r.tempId) }))); + toast.error("수량은 1 이상이어야 합니다 (해당 행 강조 표시)"); + return; + } + + const ok = await confirm( + `선택한 ${copyCheckedIds.length}개 품목에 편집된 BOM 트리(${copyTreeRows.length}개 행)를 복사할까요?`, + { + description: copyConflictStrategy === "skip" + ? "이미 BOM 있는 품목은 건너뜁니다." + : "이미 BOM 있는 품목은 새 draft 버전으로 추가됩니다.", + variant: "info", + confirmText: "복사", + } + ); + if (!ok) return; + + setCopying(true); + setCopyProgress({ current: 0, total: copyCheckedIds.length }); + setCopyResult({ new: 0, versioned: 0, skipped: 0, failed: 0 }); + try { + // 백엔드는 N개 품목을 한 번에 처리 — 진행률은 즉시 100%로 마감 + const body = { + targetItemIds: copyCheckedIds, + conflictStrategy: copyConflictStrategy, + editedTree: copyTreeRows.map((r) => ({ + tempId: r.tempId, + parentTempId: r.parentTempId, + childItemId: r.childItemId, + quantity: Number(r.quantity), + unit: r.unit || "", + processType: r.processType || null, + lossRate: r.lossRate ? Number(r.lossRate) : null, + remark: r.remark || null, + })), + }; + const res = await apiClient.post(`/bom/${selectedBomId}/copy-to-items`, body); + const data = res.data?.data || {}; + const newCount = (data.success_new || []).length; + const verCount = (data.success_versioned || []).length; + const skipCount = (data.skipped || []).length; + const failCount = (data.failed || []).length; + setCopyResult({ new: newCount, versioned: verCount, skipped: skipCount, failed: failCount }); + setCopyProgress({ current: copyCheckedIds.length, total: copyCheckedIds.length }); + + toast.success(`신규 ${newCount}건 / 새 버전 ${verCount}건 / 스킵 ${skipCount}건 / 실패 ${failCount}건`); + if (failCount === 0 && (newCount + verCount) > 0) { + // 완전 성공 시 모달 닫고 목록 새로고침 + setTimeout(() => setCopyModalOpen(false), 400); + fetchBomList(); + } else { + fetchBomList(); + } + } catch (err: any) { + toast.error(err?.response?.data?.message || "BOM 복사에 실패했어요"); + } finally { + setCopying(false); + } + }; + + // 복사 모달 — 좌측 검색 디바운스 (200ms) + useEffect(() => { + if (!copyModalOpen) return; + const handle = setTimeout(() => { + setCopyPage(1); + searchCopyTargets(1, copySearchKeyword); + }, 200); + return () => clearTimeout(handle); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [copySearchKeyword, copyModalOpen]); + + // 복사 모달 — 자식 추가 검색 디바운스 (200ms) + useEffect(() => { + if (!copyChildSearchOpen) return; + const handle = setTimeout(() => { + void searchCopyChildItems(copyChildSearchKw); + }, 200); + return () => clearTimeout(handle); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [copyChildSearchKw, copyChildSearchOpen]); + const handleDeleteBom = async () => { if (checkedIds.length === 0) { toast.error("삭제할 BOM을 선택해주세요"); @@ -1739,6 +2127,16 @@ export default function BomManagementPage() { 수정 +
+ {copyPage} / {copyTotalPages} + +
+ + + + {/* 중앙: 복사 옵션 */} +
+
+ +
+
+
+ + setCopyConflictStrategy(v as "skip" | "new_version")} + className="space-y-1.5" + > +
+ + +
+
+ + +
+
+
+ +
+ +
+ 새 버전1.0 + 상태draft + 적용일TODAY + 만료일없음 +
+
+ + {/* 진행률 + 결과 */} + {(copying || copyProgress.total > 0) && ( +
+ + 0 ? (copyProgress.current / copyProgress.total) * 100 : 0} className="h-1.5" /> +
+ {copyProgress.current} / {copyProgress.total}건 {copying && "처리 중..."} +
+ {(copyResult.new + copyResult.versioned + copyResult.skipped + copyResult.failed) > 0 && ( +
+ 신규 {copyResult.new}건 + 새 버전 {copyResult.versioned}건 + 스킵 {copyResult.skipped}건 + 실패 {copyResult.failed}건 +
+ )} +
+ )} +
+
+ + {/* 우측: 트리 편집기 */} +
+
+ + +
+ + {/* 기준 BOM 정보 readonly 카드 */} +
+
품목코드
{bomHeader?.item_code || "-"}
+
품명
{bomHeader?.item_name || "-"}
+
BOM 유형
{BOM_TYPE_OPTIONS.find((o) => o.code === bomHeader?.bom_type)?.label || bomHeader?.bom_type || "-"}
+
기준수량/단위
{bomHeader?.base_qty || "-"} {bomHeader?.unit || ""}
+
+ +
+ {copyTreeRows.length === 0 ? ( +
+ + 트리 행이 비어 있어요. 우상단 [루트 행 추가]로 시작하세요. +
+ ) : ( + + + + 품목 + 수량 + 단위 + 공정 + 손실율(%) + 비고 + 작업 + + + + {copyTreeRows.map((r) => ( + + +
+ {r.childItemNumber || "-"} + {r.childItemName || r.childItemId} +
+
+ + updateCopyTreeRow(r.tempId, { quantity: e.target.value })} /> + + + updateCopyTreeRow(r.tempId, { unit: e.target.value })} /> + + + updateCopyTreeRow(r.tempId, { processType: v })} + options={processOptions} + placeholder="공정" + /> + + + updateCopyTreeRow(r.tempId, { lossRate: e.target.value })} /> + + + updateCopyTreeRow(r.tempId, { remark: e.target.value })} /> + + +
+ + + +
+
+
+ ))} +
+
+ )} +
+
+ + + +
+
+ 대상 {copyCheckedIds.length}건 · 트리 {copyTreeRows.length}행 · 충돌 처리 {copyConflictStrategy === "skip" ? "스킵" : "새 버전"} +
+
+ + +
+
+
+ + + + {/* 복사 모달 — 자식/형제/루트 행 추가용 품목 검색 */} + + + + 자품목 검색 — 클릭해서 추가 + + {copyChildAddTarget.mode === "root" && "루트(레벨 0)에 새 자품목을 추가합니다"} + {copyChildAddTarget.mode === "child" && "선택한 행의 하위 자식으로 추가합니다"} + {copyChildAddTarget.mode === "sibling" && "선택한 행과 같은 레벨의 형제로 추가합니다"} + + +
+
+ + setCopyChildSearchKw(e.target.value)} /> +
+
+ {copyChildSearchLoading ? ( +
+ 불러오는 중... +
+ ) : copyChildSearchResults.length === 0 ? ( +
검색 결과가 없어요
+ ) : ( + + + + + + {copyChildSearchResults.map((it: any) => ( + selectCopyChildItem(it)}> + + + + + ))} + +
품번품명
{it.item_number || "-"}{it.item_name || "-"}
+ )} +
+
+ + + +
+
+ {ConfirmDialogComponent} ; -} diff --git a/frontend/app/(pop)/pop/inbound/cart/page.tsx b/frontend/app/(pop)/pop/inbound/cart/page.tsx deleted file mode 100644 index b6dada4f..00000000 --- a/frontend/app/(pop)/pop/inbound/cart/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -"use client"; - -import { PopShell } from "@/components/pop/hardcoded"; -import { InboundCartPage } from "@/components/pop/hardcoded/inbound/InboundCartPage"; - -export default function InboundCartRoute() { - return ( - - - - ); -} diff --git a/frontend/app/(pop)/pop/inbound/purchase/page.tsx b/frontend/app/(pop)/pop/inbound/purchase/page.tsx deleted file mode 100644 index e558ced6..00000000 --- a/frontend/app/(pop)/pop/inbound/purchase/page.tsx +++ /dev/null @@ -1,57 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import { PopShell } from "@/components/pop/hardcoded"; -import { PurchaseInbound } from "@/components/pop/hardcoded/inbound"; -import { useCartSync } from "@/components/pop/hardcoded/common/useCartSync"; - -export default function PurchaseInboundPage() { - const router = useRouter(); - const cart = useCartSync("pop-purchase-inbound", "purchase_detail"); - const [saving, setSaving] = useState(false); - - const handleCartClick = async () => { - if (cart.isDirty) { - setSaving(true); - const ok = await cart.saveToDb(); - setSaving(false); - if (!ok) return; // save failed, don't navigate - } - router.push("/pop/inbound/cart"); - }; - - return ( - - {saving ? ( - - - - - ) : ( - - - - )} - {cart.cartCount > 0 && ( - - {cart.cartCount} - - )} - - } - > - - - ); -} diff --git a/frontend/app/(pop)/pop/outbound/cart/page.tsx b/frontend/app/(pop)/pop/outbound/cart/page.tsx deleted file mode 100644 index a04273b8..00000000 --- a/frontend/app/(pop)/pop/outbound/cart/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -"use client"; - -import { PopShell } from "@/components/pop/hardcoded"; -import { OutboundCartPage } from "@/components/pop/hardcoded/outbound/OutboundCartPage"; - -export default function OutboundCartRoute() { - return ( - - - - ); -} diff --git a/frontend/app/(pop)/pop/outbound/sales/page.tsx b/frontend/app/(pop)/pop/outbound/sales/page.tsx deleted file mode 100644 index e93d3c74..00000000 --- a/frontend/app/(pop)/pop/outbound/sales/page.tsx +++ /dev/null @@ -1,57 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import { PopShell } from "@/components/pop/hardcoded"; -import { SalesOutbound } from "@/components/pop/hardcoded/outbound"; -import { useCartSync } from "@/components/pop/hardcoded/common/useCartSync"; - -export default function SalesOutboundPage() { - const router = useRouter(); - const cart = useCartSync("pop-sales-outbound", "shipment_instruction_detail"); - const [saving, setSaving] = useState(false); - - const handleCartClick = async () => { - if (cart.isDirty) { - setSaving(true); - const ok = await cart.saveToDb(); - setSaving(false); - if (!ok) return; - } - router.push("/pop/outbound/cart"); - }; - - return ( - - {saving ? ( - - - - - ) : ( - - - - )} - {cart.cartCount > 0 && ( - - {cart.cartCount} - - )} - - } - > - - - ); -}