From 85862dbaceac9f4b387d0bd6c742c194ab865fe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Wed, 18 Mar 2026 17:50:04 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20[material]=20BOM=20=ED=8A=B8=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=EC=96=B4=20=EA=B0=9C=EC=84=A0=20+=20=EC=9E=85?= =?UTF-8?q?=EA=B3=A0=EA=B4=80=EB=A6=AC=20=EB=8B=A4=EC=9D=B4=EC=96=BC?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/items/BomTreeViewer.tsx | 264 ++++++++++-------- .../ImportInspectionInputModal.tsx | 11 +- .../InventoryAdjustmentDialog.tsx | 3 + .../ReceivingManagement/ReceivingDetail.tsx | 58 ++-- .../ReceivingProcessDialog.tsx | 3 + .../ReceivingManagement/SuccessDialog.tsx | 7 + 6 files changed, 201 insertions(+), 145 deletions(-) diff --git a/src/components/items/BomTreeViewer.tsx b/src/components/items/BomTreeViewer.tsx index 2c66fa36..533b9f29 100644 --- a/src/components/items/BomTreeViewer.tsx +++ b/src/components/items/BomTreeViewer.tsx @@ -4,7 +4,9 @@ * BOM Tree 시각화 컴포넌트 * * API: GET /api/proxy/items/{id}/bom/tree - * 재귀적 트리 렌더링 + 유형별 뱃지 색상 + 펼침/접힘 + * 3단계 트리: FG(루트) → CAT(카테고리 그룹) → PT(부품) + * CAT 노드: 카테고리 헤더로 렌더링 (접힘/펼침, count 표시) + * PT 노드: 품목 행으로 렌더링 (코드, 품목명, 수량, 단위) */ import { useState, useEffect, useCallback } from 'react'; @@ -22,7 +24,8 @@ interface BomTreeNode { item_type: string; specification?: string; unit?: string; - quantity: number; + quantity?: number; + count?: number; // CAT 노드 — 하위 품목 건수 depth: number; children: BomTreeNode[]; } @@ -38,60 +41,70 @@ const ITEM_TYPE_COLORS: Record = { SF: 'bg-cyan-100 text-cyan-800', }; -const ITEM_TYPE_LABELS: Record = { - FG: '완제품', - PT: '부품', - RM: '원자재', - SM: '부자재', - CS: '소모품', - BN: '절곡품', - SF: '반제품', -}; - -// 모든 노드의 ID를 재귀적으로 수집 -function collectNodeIds(nodes: BomTreeNode[]): Set { - const ids = new Set(); - function walk(node: BomTreeNode) { - ids.add(node.id); - node.children.forEach(walk); - } - nodes.forEach(walk); - return ids; +// 노드별 고유 키 생성 (CAT 노드는 id=0이므로 이름 기반) +function getNodeKey(node: BomTreeNode, index: number): string { + if (node.item_type === 'CAT') return `cat-${index}-${node.name}`; + return `item-${node.id}`; } -// 개별 트리 노드 컴포넌트 -function BomTreeNodeItem({ +// 모든 노드의 키를 재귀적으로 수집 +function collectAllKeys(nodes: BomTreeNode[]): Set { + const keys = new Set(); + function walk(node: BomTreeNode, index: number) { + keys.add(getNodeKey(node, index)); + node.children?.forEach((child, i) => walk(child, i)); + } + nodes.forEach((node, i) => walk(node, i)); + return keys; +} + +// 카테고리 노드 컴포넌트 +function CategoryNode({ node, - level = 0, - expandedNodes, + nodeKey, + isOpen, onToggle, }: { node: BomTreeNode; - level?: number; - expandedNodes: Set; - onToggle: (id: number) => void; + nodeKey: string; + isOpen: boolean; + onToggle: (key: string) => void; }) { - const hasChildren = node.children.length > 0; - const isOpen = expandedNodes.has(node.id); + const hasChildren = node.children && node.children.length > 0; return (
+ {/* 카테고리 헤더 */}
hasChildren && onToggle(nodeKey)} > - {/* 펼침/접힘 */} - {hasChildren ? ( - - ) : ( - + {hasChildren && ( + isOpen + ? + : )} + {node.name} + {node.count ?? node.children?.length ?? 0}건 +
+ {/* 하위 품목 (접힘/펼침) */} + {isOpen && hasChildren && ( +
+ {node.children.map((child, i) => ( + + ))} +
+ )} +
+ ); +} + +// 품목(PT) 노드 컴포넌트 +function ItemNode({ node }: { node: BomTreeNode }) { + return ( +
+
{/* 유형 뱃지 */} - {/* 코드 */} - + {/* 코드 — PC만 인라인 표시 */} + {node.code} {/* 품목명 */} - {node.name} + {node.name} - {/* 규격 */} - {node.specification && ( - - ({node.specification}) - - )} - - {/* 수량 */} - - ×{node.quantity} + {/* 수량 + 단위 */} + + {node.quantity != null && ( + x{node.quantity} + )} + {node.unit && ( + {node.unit} + )} - - {/* 단위 */} - {node.unit && ( - {node.unit} - )}
- - {/* 자식 노드 */} - {isOpen && hasChildren && node.children.map((child) => ( - - ))} + {/* 코드 2줄 — 모바일만 */} + + {node.code} +
); } +// 범용 노드 렌더러 (CAT 분기) +function BomNodeRenderer({ + node, + index, + expandedNodes, + onToggle, +}: { + node: BomTreeNode; + index: number; + expandedNodes: Set; + onToggle: (key: string) => void; +}) { + const nodeKey = getNodeKey(node, index); + const isCategory = node.item_type === 'CAT'; + + if (isCategory) { + return ( + + ); + } + + return ; +} + interface BomTreeViewerProps { itemId: string; itemType: string; } export function BomTreeViewer({ itemId, itemType }: BomTreeViewerProps) { - const [treeData, setTreeData] = useState([]); + const [treeData, setTreeData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const [expandedNodes, setExpandedNodes] = useState>(new Set()); + const [expandedNodes, setExpandedNodes] = useState>(new Set()); + const [allExpanded, setAllExpanded] = useState(true); // 트리 데이터 로드 const loadTree = useCallback(async () => { @@ -160,13 +189,14 @@ export function BomTreeViewer({ itemId, itemType }: BomTreeViewerProps) { const result = await response.json(); if (result.success && result.data) { - const data = Array.isArray(result.data) ? result.data : [result.data]; - setTreeData(data); - // 기본: 2단계까지 펼침 - const allIds = collectNodeIds(data); - setExpandedNodes(allIds); + const root = result.data as BomTreeNode; + setTreeData(root); + // 기본: 모든 카테고리 펼침 + if (root.children) { + setExpandedNodes(collectAllKeys(root.children)); + } } else { - setTreeData([]); + setTreeData(null); } } catch { setError('BOM 트리를 불러오는 중 오류가 발생했습니다.'); @@ -176,7 +206,6 @@ export function BomTreeViewer({ itemId, itemType }: BomTreeViewerProps) { }, [itemId]); useEffect(() => { - // FG/PT (또는 한글: 제품/부품)만 BOM 트리 로드 const isBomTarget = ['FG', 'PT', '제품', '부품'].includes(itemType); if (isBomTarget) { loadTree(); @@ -215,13 +244,13 @@ export function BomTreeViewer({ itemId, itemType }: BomTreeViewerProps) { } // 데이터 없음 - if (treeData.length === 0) { + if (!treeData || !treeData.children || treeData.children.length === 0) { return ( - BOM 트리 + 부품 구성 (BOM) @@ -233,66 +262,65 @@ export function BomTreeViewer({ itemId, itemType }: BomTreeViewerProps) { ); } - const toggleNode = (id: number) => { + const toggleNode = (key: string) => { setExpandedNodes((prev) => { const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); + if (next.has(key)) next.delete(key); + else next.add(key); return next; }); }; - const expandAll = () => setExpandedNodes(collectNodeIds(treeData)); - const collapseAll = () => setExpandedNodes(new Set()); + const expandAll = () => { setExpandedNodes(collectAllKeys(treeData.children)); setAllExpanded(true); }; + const collapseAll = () => { setExpandedNodes(new Set()); setAllExpanded(false); }; + const toggleAll = () => { if (allExpanded) collapseAll(); else expandAll(); }; - // 총 노드 수 계산 - const totalCount = collectNodeIds(treeData).size; + // 카테고리 그룹 수 & 총 품목 수 + const categories = treeData.children.filter(n => n.item_type === 'CAT'); + const totalItems = categories.reduce((sum, cat) => sum + (cat.count ?? cat.children?.length ?? 0), 0); + const groupCount = categories.length; return ( -
+ {/* PC: 한 줄 레이아웃 */} +
- BOM 트리 + 부품 구성 (BOM)
- 총 {totalCount}개 품목 + 총 {totalItems}개 품목 · {groupCount}개 그룹 - -
+ {/* 모바일: 줄바꿈 레이아웃 */} +
+ + + 부품 구성 (BOM) + + + 총 {totalItems}개 품목 · {groupCount}개 그룹 + + +
- {/* 범례 */} -
- {Object.entries(ITEM_TYPE_LABELS).map(([type, label]) => ( -
- - {type} - - {label} -
- ))} -
- - {/* 트리 */} -
- {treeData.map((node) => ( - + {treeData.children.map((node, i) => ( + diff --git a/src/components/material/ReceivingManagement/ImportInspectionInputModal.tsx b/src/components/material/ReceivingManagement/ImportInspectionInputModal.tsx index 25090005..63d0e7d2 100644 --- a/src/components/material/ReceivingManagement/ImportInspectionInputModal.tsx +++ b/src/components/material/ReceivingManagement/ImportInspectionInputModal.tsx @@ -18,6 +18,8 @@ import { DialogContent, DialogHeader, DialogTitle, + DialogDescription, + VisuallyHidden, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -668,7 +670,11 @@ export function ImportInspectionInputModal({ // 캡처 실패 시 무시 — rendered_html 없이 저장 진행 } - // 5. 저장 API 호출 + // 5. 저장 API 호출 (rendered_html 500KB 초과 시 제외 — 413 에러 방지) + const MAX_HTML_SIZE = 500 * 1024; + const safeHtml = renderedHtml && renderedHtml.length <= MAX_HTML_SIZE + ? renderedHtml : undefined; + const result = await saveInspectionData({ templateId: parseInt(template.templateId), itemId, @@ -677,7 +683,7 @@ export function ImportInspectionInputModal({ attachments, receivingId, inspectionResult: overallResult, - rendered_html: renderedHtml, + rendered_html: safeHtml, }); if (result.success) { @@ -755,6 +761,7 @@ export function ImportInspectionInputModal({ 수입검사 + 수입검사 항목 입력 {isLoadingTemplate ? ( diff --git a/src/components/material/ReceivingManagement/InventoryAdjustmentDialog.tsx b/src/components/material/ReceivingManagement/InventoryAdjustmentDialog.tsx index b0bac1ea..12b398b1 100644 --- a/src/components/material/ReceivingManagement/InventoryAdjustmentDialog.tsx +++ b/src/components/material/ReceivingManagement/InventoryAdjustmentDialog.tsx @@ -19,6 +19,8 @@ import { DialogContent, DialogHeader, DialogTitle, + DialogDescription, + VisuallyHidden, } from '@/components/ui/dialog'; import { Select, @@ -127,6 +129,7 @@ export function InventoryAdjustmentDialog({ open, onOpenChange, onSave }: Props) 재고 조정 + 재고 수량 조정
diff --git a/src/components/material/ReceivingManagement/ReceivingDetail.tsx b/src/components/material/ReceivingManagement/ReceivingDetail.tsx index f5dc2a9f..32523ebd 100644 --- a/src/components/material/ReceivingManagement/ReceivingDetail.tsx +++ b/src/components/material/ReceivingManagement/ReceivingDetail.tsx @@ -67,28 +67,30 @@ interface Props { mode?: 'view' | 'edit' | 'new'; } -// 초기 폼 데이터 -const INITIAL_FORM_DATA: Partial = { - materialNo: '', - supplierMaterialNo: '', - lotNo: '', - itemCode: '', - itemName: '', - specification: '', - unit: 'EA', - supplier: '', - manufacturer: '', - receivingQty: undefined, - receivingDate: '', - createdBy: '', - status: 'receiving_pending', - remark: '', - inspectionDate: '', - inspectionResult: '', - certificateFile: undefined, - certificateFileId: undefined, - inventoryAdjustments: [], -}; +// 초기 폼 데이터 (동적 함수 — 세션 사용자 이름 + 오늘 날짜 기본값) +function createInitialFormData(): Partial { + return { + materialNo: '', + supplierMaterialNo: '', + lotNo: '', + itemCode: '', + itemName: '', + specification: '', + unit: 'EA', + supplier: '', + manufacturer: '', + receivingQty: undefined, + receivingDate: getTodayString(), + createdBy: getLoggedInUserName(), + status: 'receiving_pending', + remark: '', + inspectionDate: '', + inspectionResult: '', + certificateFile: undefined, + certificateFileId: undefined, + inventoryAdjustments: [], + }; +} // localStorage에서 로그인 사용자 정보 가져오기 function getLoggedInUser(): { name: string; department: string } { @@ -122,7 +124,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { const [error, setError] = useState(null); // 폼 데이터 (등록/수정 모드용) - const [formData, setFormData] = useState>(INITIAL_FORM_DATA); + const [formData, setFormData] = useState>(createInitialFormData); // 업로드된 파일 상태 (File 객체) const [uploadedFile, setUploadedFile] = useState(null); @@ -275,8 +277,14 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { const result = await createReceiving(saveData); if (result.success) { toast.success('입고가 등록되었습니다.'); - router.push('/ko/material/receiving-management'); - return { success: true }; + const newId = result.data?.id; + if (newId) { + router.push(`/ko/material/receiving-management/${newId}?mode=view`); + } else { + router.push('/ko/material/receiving-management'); + } + // 커스텀 네비게이션 처리: error='' → 템플릿의 navigateToList() 호출 방지 + return { success: false, error: '' }; } else { toast.error(result.error || '등록에 실패했습니다.'); return { success: false, error: result.error }; diff --git a/src/components/material/ReceivingManagement/ReceivingProcessDialog.tsx b/src/components/material/ReceivingManagement/ReceivingProcessDialog.tsx index 266f9f02..affeda88 100644 --- a/src/components/material/ReceivingManagement/ReceivingProcessDialog.tsx +++ b/src/components/material/ReceivingManagement/ReceivingProcessDialog.tsx @@ -19,6 +19,8 @@ import { DialogContent, DialogHeader, DialogTitle, + DialogDescription, + VisuallyHidden, } from '@/components/ui/dialog'; import { Alert, AlertDescription } from '@/components/ui/alert'; import type { ReceivingDetail, ReceivingProcessFormData } from './types'; @@ -99,6 +101,7 @@ export function ReceivingProcessDialog({ open, onOpenChange, detail, onComplete 입고 처리 + 입고 처리 정보 입력
diff --git a/src/components/material/ReceivingManagement/SuccessDialog.tsx b/src/components/material/ReceivingManagement/SuccessDialog.tsx index b0215e87..775ab846 100644 --- a/src/components/material/ReceivingManagement/SuccessDialog.tsx +++ b/src/components/material/ReceivingManagement/SuccessDialog.tsx @@ -9,6 +9,9 @@ import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, + DialogTitle, + DialogDescription, + VisuallyHidden, } from '@/components/ui/dialog'; interface Props { @@ -27,6 +30,10 @@ export function SuccessDialog({ open, type, lotNo, onClose }: Props) { return ( !newOpen && onClose()}> + + 처리 완료 + 처리 결과 안내 +