From 17173be3501fcb3c678d25df7936e5cad2e75d6b Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 6 May 2026 17:32:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(bom):=20COMPANY=5F7=20BOM=EC=97=90=20?= =?UTF-8?q?=EB=8C=80=EC=B2=B4=20=ED=92=88=EB=AA=A9=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bom_detail_substitute 테이블 신규 (varchar PK, FK 미설정 / 코드 조인) - 백엔드: 단일 행 substitute CRUD + BOM 단위 갯수 맵 API 5종 추가 - 프론트(COMPANY_7): 트리 행에 '대체 N' 뱃지 + 대체 품목 모달 · 드래그앤드롭으로 우선순위 변경 + 자동 채번/재할당 · 250ms debounce 실시간 검색, 결과 클릭 시 자동 행 추가 · inline blur 저장, zebra 행 구분, sticky 헤더 단색 처리 - 트리뷰 액션 버튼 통합: 가상 루트 선택 시 1레벨, 일반 행 선택 시 하위로 추가 Co-Authored-By: Claude Opus 4.7 (1M context) --- backend-node/src/controllers/bomController.ts | 90 ++++ backend-node/src/routes/bomRoutes.ts | 7 + backend-node/src/services/bomService.ts | 114 +++++ .../(main)/COMPANY_7/production/bom/page.tsx | 467 +++++++++++++++++- 4 files changed, 669 insertions(+), 9 deletions(-) diff --git a/backend-node/src/controllers/bomController.ts b/backend-node/src/controllers/bomController.ts index b98baad1..58657db7 100644 --- a/backend-node/src/controllers/bomController.ts +++ b/backend-node/src/controllers/bomController.ts @@ -224,3 +224,93 @@ export async function deleteBomVersion(req: Request, res: Response) { res.status(500).json({ success: false, message: error.message }); } } + +// ─── 대체품 (Substitute) ───────────────────────────── + +export async function getBomDetailSubstitutes(req: Request, res: Response) { + try { + const { detailId } = req.params; + const companyCode = (req as any).user?.companyCode || "*"; + const data = await bomService.getBomDetailSubstitutes(detailId, companyCode); + res.json({ success: true, data }); + } catch (error: any) { + logger.error("대체품 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function getBomSubstituteCounts(req: Request, res: Response) { + try { + const { bomId } = req.params; + const companyCode = (req as any).user?.companyCode || "*"; + const versionId = (req.query.versionId as string) || undefined; + const rows = await bomService.getBomSubstituteCounts(bomId, companyCode, versionId); + const map: Record = {}; + for (const r of rows as any[]) { + map[r.bom_detail_id] = Number(r.count); + } + res.json({ success: true, data: map }); + } catch (error: any) { + logger.error("대체품 갯수 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createBomDetailSubstitute(req: Request, res: Response) { + try { + const { detailId } = req.params; + const companyCode = (req as any).user?.companyCode || "*"; + const writer = (req as any).user?.userName || (req as any).user?.userId || ""; + const { substitute_item_id, priority, ratio, remark, status } = req.body; + + if (!substitute_item_id) { + res.status(400).json({ success: false, message: "substitute_item_id는 필수입니다" }); + return; + } + + const data = await bomService.createBomDetailSubstitute({ + bom_detail_id: detailId, + substitute_item_id, + priority, + ratio, + remark, + status, + company_code: companyCode, + writer, + }); + res.json({ success: true, data }); + } catch (error: any) { + logger.error("대체품 등록 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function updateBomDetailSubstitute(req: Request, res: Response) { + try { + const { id } = req.params; + const data = await bomService.updateBomDetailSubstitute(id, req.body); + if (!data) { + res.status(404).json({ success: false, message: "대체품을 찾을 수 없습니다" }); + return; + } + res.json({ success: true, data }); + } catch (error: any) { + logger.error("대체품 수정 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deleteBomDetailSubstitute(req: Request, res: Response) { + try { + const { id } = req.params; + const result = await bomService.deleteBomDetailSubstitute(id); + if (!result) { + res.status(404).json({ success: false, message: "대체품을 찾을 수 없습니다" }); + return; + } + res.json({ success: true }); + } catch (error: any) { + logger.error("대체품 삭제 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/routes/bomRoutes.ts b/backend-node/src/routes/bomRoutes.ts index ccdbad64..6ca79e7f 100644 --- a/backend-node/src/routes/bomRoutes.ts +++ b/backend-node/src/routes/bomRoutes.ts @@ -30,4 +30,11 @@ router.post("/:bomId/versions/:versionId/load", bomController.loadBomVersion); router.post("/:bomId/versions/:versionId/activate", bomController.activateBomVersion); router.delete("/:bomId/versions/:versionId", bomController.deleteBomVersion); +// 대체품 (Substitute) +router.get("/:bomId/substitute-counts", bomController.getBomSubstituteCounts); +router.get("/details/:detailId/substitutes", bomController.getBomDetailSubstitutes); +router.post("/details/:detailId/substitutes", bomController.createBomDetailSubstitute); +router.put("/substitutes/:id", bomController.updateBomDetailSubstitute); +router.delete("/substitutes/:id", bomController.deleteBomDetailSubstitute); + export default router; diff --git a/backend-node/src/services/bomService.ts b/backend-node/src/services/bomService.ts index 691c24d4..0abb42e1 100644 --- a/backend-node/src/services/bomService.ts +++ b/backend-node/src/services/bomService.ts @@ -845,3 +845,117 @@ export async function deleteBomVersion( return deleteVersion.rows.length > 0; }); } + +// ─── 대체품 (Substitute) ───────────────────────────── + +export async function getBomDetailSubstitutes(detailId: string, companyCode: string) { + const sql = ` + SELECT s.*, + i.item_name AS substitute_item_name, + i.item_number AS substitute_item_number, + i.unit AS substitute_unit, + i.inventory_unit AS substitute_inventory_unit, + i.size AS substitute_size, + i.material AS substitute_material, + i.division AS substitute_division + FROM bom_detail_substitute s + LEFT JOIN item_info i ON s.substitute_item_id = i.id + WHERE s.bom_detail_id = $1 + ${companyCode === "*" ? "" : "AND s.company_code = $2"} + ORDER BY + CASE WHEN s.priority ~ '^[0-9]+$' THEN s.priority::int ELSE 9999 END ASC, + s.created_date ASC + `; + const params = companyCode === "*" ? [detailId] : [detailId, companyCode]; + return query(sql, params); +} + +export async function getBomSubstituteCounts( + bomId: string, + companyCode: string, + versionId?: string, +) { + const params: any[] = [bomId]; + let sql = ` + SELECT s.bom_detail_id, COUNT(*)::int AS count + FROM bom_detail_substitute s + JOIN bom_detail d ON d.id = s.bom_detail_id + WHERE d.bom_id = $1 + `; + if (companyCode !== "*") { + params.push(companyCode); + sql += ` AND s.company_code = $${params.length}`; + } + if (versionId) { + params.push(versionId); + sql += ` AND d.version_id = $${params.length}`; + } + sql += ` GROUP BY s.bom_detail_id`; + return query(sql, params); +} + +export async function createBomDetailSubstitute(data: { + bom_detail_id: string; + substitute_item_id: string; + priority?: string; + ratio?: string; + remark?: string; + status?: string; + company_code: string; + writer?: string; +}) { + const sql = ` + INSERT INTO bom_detail_substitute + (bom_detail_id, substitute_item_id, priority, ratio, remark, status, company_code, writer) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING * + `; + return queryOne(sql, [ + data.bom_detail_id, + data.substitute_item_id, + data.priority ?? null, + data.ratio ?? "1", + data.remark ?? null, + data.status ?? "active", + data.company_code, + data.writer ?? null, + ]); +} + +export async function updateBomDetailSubstitute( + id: string, + data: { + substitute_item_id?: string; + priority?: string; + ratio?: string; + remark?: string; + status?: string; + }, +) { + const fields: string[] = []; + const values: any[] = []; + let idx = 1; + + for (const [key, value] of Object.entries(data)) { + if (value !== undefined) { + fields.push(`${key} = $${idx++}`); + values.push(value); + } + } + if (fields.length === 0) return null; + fields.push(`updated_date = CURRENT_TIMESTAMP`); + values.push(id); + + const sql = ` + UPDATE bom_detail_substitute + SET ${fields.join(", ")} + WHERE id = $${idx} + RETURNING * + `; + return queryOne(sql, values); +} + +export async function deleteBomDetailSubstitute(id: string) { + const sql = `DELETE FROM bom_detail_substitute WHERE id = $1 RETURNING id`; + return queryOne(sql, [id]); +} diff --git a/frontend/app/(main)/COMPANY_7/production/bom/page.tsx b/frontend/app/(main)/COMPANY_7/production/bom/page.tsx index 01494a5d..061db82d 100644 --- a/frontend/app/(main)/COMPANY_7/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_7/production/bom/page.tsx @@ -60,6 +60,7 @@ import { Save, Package, Pencil, + GripVertical, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -171,6 +172,26 @@ interface BomHistoryEntry { changed_date?: string; } +interface BomSubstitute { + id: string; + bom_detail_id: string; + substitute_item_id: string; + priority?: string; + ratio?: string; + remark?: string; + status?: string; + substitute_item_name?: string; + substitute_item_number?: string; + substitute_unit?: string; + substitute_inventory_unit?: string; + substitute_size?: string; + substitute_material?: string; + substitute_division?: string; + created_date?: string; + _isNew?: boolean; // DB에 아직 없는 신규 행 + _tempId?: string; // 임시 ID +} + interface TreeNode extends BomDetail { children: TreeNode[]; expanded?: boolean; @@ -346,6 +367,19 @@ export default function BomManagementPage() { const [treeItemSearchOpen, setTreeItemSearchOpen] = useState(false); const [expandedNodes, setExpandedNodes] = useState>(new Set()); + // 대체품 (Substitute) + const [substituteCounts, setSubstituteCounts] = useState>({}); + const [substituteModalOpen, setSubstituteModalOpen] = useState(false); + const [substituteTargetNode, setSubstituteTargetNode] = useState(null); + const [substituteList, setSubstituteList] = useState([]); + const [substituteLoading, setSubstituteLoading] = useState(false); + const [substituteSaving, setSubstituteSaving] = useState(false); + const [substituteSearchKeyword, setSubstituteSearchKeyword] = useState(""); + const [substituteSearchResults, setSubstituteSearchResults] = useState([]); + const [substituteSearchLoading, setSubstituteSearchLoading] = useState(false); + const [draggedRowId, setDraggedRowId] = useState(null); + const [dragOverRowId, setDragOverRowId] = useState(null); + // ─── 데이터 로드 ────────────────────────────── const fetchBomList = useCallback(async () => { setLoading(true); @@ -459,6 +493,18 @@ export default function BomManagementPage() { }).catch(() => {}); }, []); + // 대체품 갯수 맵 로드 (트리뷰 뱃지용) — fetchBomDetail보다 먼저 선언 + const fetchSubstituteCounts = useCallback(async (bomId: string, versionId?: string | null) => { + try { + const res = await apiClient.get(`/bom/${bomId}/substitute-counts`, { + params: versionId ? { versionId } : undefined, + }); + setSubstituteCounts(res.data?.data || {}); + } catch { + setSubstituteCounts({}); + } + }, []); + // ─── BOM 상세 로드 ──────────────────────────── const fetchBomDetail = useCallback(async (bomId: string) => { setDetailLoading(true); @@ -550,12 +596,15 @@ export default function BomManagementPage() { }; collectIds(tree); setExpandedNodes(allIds); + + // 대체품 갯수 맵 로드 (트리 행 뱃지용) + fetchSubstituteCounts(bomId, versionId); } catch (err: any) { toast.error("BOM 상세 조회에 실패했어요"); } finally { setDetailLoading(false); } - }, [categoryOptions]); + }, [categoryOptions, fetchSubstituteCounts]); // 버전 목록 로드 const fetchVersions = useCallback(async (bomId: string) => { @@ -572,6 +621,19 @@ export default function BomManagementPage() { } }, []); + // 단일 행의 대체품 목록 로드 + const fetchSubstitutes = useCallback(async (detailId: string) => { + setSubstituteLoading(true); + try { + const res = await apiClient.get(`/bom/details/${detailId}/substitutes`); + setSubstituteList(res.data?.data || []); + } catch { + setSubstituteList([]); + } finally { + setSubstituteLoading(false); + } + }, []); + // 이력 목록 로드 const fetchHistory = useCallback(async (bomId: string) => { setHistoryLoading(true); @@ -1430,6 +1492,171 @@ export default function BomManagementPage() { } }; + // ─── 대체품 (Substitute) 핸들러 ────────────────── + const openSubstituteModal = (node: TreeNode) => { + if (!node) return; + if ((node as any)._isVirtualRoot) { + toast.error("BOM 마스터 행에는 대체품을 등록할 수 없어요"); + return; + } + if (!node.id || node._isNew) { + toast.error("먼저 트리를 저장한 뒤에 대체품을 등록해주세요"); + return; + } + setSubstituteTargetNode(node); + setSubstituteList([]); + setSubstituteSearchKeyword(""); + setSubstituteSearchResults([]); + setDraggedRowId(null); + setDragOverRowId(null); + setSubstituteModalOpen(true); + fetchSubstitutes(node.id); + }; + + const closeSubstituteModal = () => { + setSubstituteModalOpen(false); + setSubstituteTargetNode(null); + setSubstituteList([]); + setSubstituteSearchKeyword(""); + setSubstituteSearchResults([]); + setDraggedRowId(null); + setDragOverRowId(null); + }; + + // 검색 결과 클릭 → 자동 행 추가 + POST + 우선순위 자동 채번 + const handleSelectItem = async (item: any) => { + if (!substituteTargetNode?.id) return; + const nextPriority = String(substituteList.length + 1); + setSubstituteSaving(true); + try { + await apiClient.post(`/bom/details/${substituteTargetNode.id}/substitutes`, { + substitute_item_id: item.id, + priority: nextPriority, + ratio: "1", + remark: null, + }); + toast.success(`"${item.item_name || item.item_number}" 추가됨`); + fetchSubstitutes(substituteTargetNode.id); + if (selectedBomId) fetchSubstituteCounts(selectedBomId, currentVersionId); + } catch { + toast.error("대체품 추가에 실패했어요"); + } finally { + setSubstituteSaving(false); + } + }; + + const handleDeleteSubstitute = async (id: string) => { + if (!substituteTargetNode?.id) return; + const ok = await confirm("이 대체품을 삭제하시겠어요?", { variant: "destructive" }); + if (!ok) return; + try { + await apiClient.delete(`/bom/substitutes/${id}`); + toast.success("대체품을 삭제했어요"); + // 삭제 후 priority 재할당 (gap 메우기) + const remaining = substituteList.filter((s) => s.id !== id); + const reassigned = remaining.map((s, idx) => ({ ...s, priority: String(idx + 1) })); + setSubstituteList(reassigned); + // 변경된 priority를 PUT으로 동기화 + await Promise.all( + reassigned.map((s) => apiClient.put(`/bom/substitutes/${s.id}`, { priority: s.priority }).catch(() => null)) + ); + if (substituteTargetNode?.id) fetchSubstitutes(substituteTargetNode.id); + if (selectedBomId) fetchSubstituteCounts(selectedBomId, currentVersionId); + } catch { + toast.error("대체품 삭제에 실패했어요"); + } + }; + + const handleUpdateSubstituteField = async (id: string, field: keyof BomSubstitute, value: string) => { + setSubstituteList((prev) => prev.map((s) => (s.id === id ? { ...s, [field]: value } : s))); + try { + await apiClient.put(`/bom/substitutes/${id}`, { [field]: value }); + } catch { + toast.error("대체품 수정에 실패했어요"); + if (substituteTargetNode?.id) fetchSubstitutes(substituteTargetNode.id); + } + }; + + // 드래그 앤 드롭으로 우선순위 변경 + const handleRowDragStart = (e: React.DragEvent, rowId: string) => { + setDraggedRowId(rowId); + e.dataTransfer.effectAllowed = "move"; + }; + + const handleRowDragOver = (e: React.DragEvent, rowId: string) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + if (dragOverRowId !== rowId) setDragOverRowId(rowId); + }; + + const handleRowDragLeave = () => setDragOverRowId(null); + + const handleRowDrop = async (e: React.DragEvent, targetId: string) => { + e.preventDefault(); + const sourceId = draggedRowId; + setDraggedRowId(null); + setDragOverRowId(null); + if (!sourceId || sourceId === targetId) return; + + const sourceIdx = substituteList.findIndex((s) => s.id === sourceId); + const targetIdx = substituteList.findIndex((s) => s.id === targetId); + if (sourceIdx === -1 || targetIdx === -1) return; + + const newList = [...substituteList]; + const [moved] = newList.splice(sourceIdx, 1); + newList.splice(targetIdx, 0, moved); + + // 우선순위 재할당 + const reassigned = newList.map((s, idx) => ({ ...s, priority: String(idx + 1) })); + setSubstituteList(reassigned); + + // 변경된 priority 일괄 PUT + try { + await Promise.all( + reassigned + .filter((s) => !s._isNew) + .map((s) => apiClient.put(`/bom/substitutes/${s.id}`, { priority: s.priority })) + ); + } catch { + toast.error("우선순위 변경에 실패했어요"); + if (substituteTargetNode?.id) fetchSubstitutes(substituteTargetNode.id); + } + }; + + // 모달 열려있을 때 실시간 검색 — 키워드 없어도 기본 50건 fetch + useEffect(() => { + if (!substituteModalOpen) return; + const kw = substituteSearchKeyword.trim(); + const handler = setTimeout(async () => { + setSubstituteSearchLoading(true); + try { + const filters: any[] = []; + if (kw) filters.push({ columnName: "item_name", operator: "contains", value: kw }); + const res = await apiClient.post(`/table-management/tables/item_info/data`, { + page: 1, size: 50, + dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, + autoFilter: true, + sort: { columnName: "item_number", order: "asc" }, + }); + let results = res.data?.data?.data || res.data?.data?.rows || []; + if (kw && results.length === 0) { + const res2 = await apiClient.post(`/table-management/tables/item_info/data`, { + page: 1, size: 50, + dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "contains", value: kw }] }, + autoFilter: true, + }); + results = res2.data?.data?.data || res2.data?.data?.rows || []; + } + setSubstituteSearchResults(results); + } catch { + setSubstituteSearchResults([]); + } finally { + setSubstituteSearchLoading(false); + } + }, kw ? 250 : 0); + return () => clearTimeout(handler); + }, [substituteSearchKeyword, substituteModalOpen]); + // ─── 상태 뱃지 렌더 ─────────────────────────── const renderStatusBadge = (status?: string) => { const raw = status || ""; @@ -1601,18 +1828,16 @@ export default function BomManagementPage() { {/* 상단 액션바 */}
- @@ -1631,6 +1856,19 @@ export default function BomManagementPage() { > 선택 삭제 +
+ + + + {ConfirmDialogComponent}