diff --git a/src/components/business/construction/item-management/ItemDetailClient.tsx b/src/components/business/construction/item-management/ItemDetailClient.tsx index c54c6a60..cca78995 100644 --- a/src/components/business/construction/item-management/ItemDetailClient.tsx +++ b/src/components/business/construction/item-management/ItemDetailClient.tsx @@ -30,6 +30,7 @@ import { UNIT_OPTIONS, } from './constants'; import { getItem, createItem, updateItem, deleteItem, getCategoryOptions } from './actions'; +import { BomTreeViewer } from '@/components/items/BomTreeViewer'; interface ItemDetailClientProps { itemId?: string; @@ -460,6 +461,9 @@ export default function ItemDetailClient({ )} + + {/* BOM 트리 */} + {itemId && } ), [ formData, @@ -469,6 +473,7 @@ export default function ItemDetailClient({ handleAddOrderItem, handleRemoveOrderItem, handleOrderItemChange, + itemId, ]); return ( diff --git a/src/components/items/BomTreeViewer.tsx b/src/components/items/BomTreeViewer.tsx new file mode 100644 index 00000000..2c66fa36 --- /dev/null +++ b/src/components/items/BomTreeViewer.tsx @@ -0,0 +1,304 @@ +'use client'; + +/** + * BOM Tree 시각화 컴포넌트 + * + * API: GET /api/proxy/items/{id}/bom/tree + * 재귀적 트리 렌더링 + 유형별 뱃지 색상 + 펼침/접힘 + */ + +import { useState, useEffect, useCallback } from 'react'; +import { ChevronDown, ChevronRight, ChevronsUpDown, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Package } from 'lucide-react'; + +// BOM 트리 노드 타입 +interface BomTreeNode { + id: number; + code: string; + name: string; + item_type: string; + specification?: string; + unit?: string; + quantity: number; + depth: number; + children: BomTreeNode[]; +} + +// 유형별 뱃지 스타일 +const ITEM_TYPE_COLORS: Record = { + FG: 'bg-blue-100 text-blue-800', + PT: 'bg-green-100 text-green-800', + RM: 'bg-orange-100 text-orange-800', + SM: 'bg-purple-100 text-purple-800', + CS: 'bg-gray-100 text-gray-800', + BN: 'bg-pink-100 text-pink-800', + 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; +} + +// 개별 트리 노드 컴포넌트 +function BomTreeNodeItem({ + node, + level = 0, + expandedNodes, + onToggle, +}: { + node: BomTreeNode; + level?: number; + expandedNodes: Set; + onToggle: (id: number) => void; +}) { + const hasChildren = node.children.length > 0; + const isOpen = expandedNodes.has(node.id); + + return ( +
+
+ {/* 펼침/접힘 */} + {hasChildren ? ( + + ) : ( + + )} + + {/* 유형 뱃지 */} + + {node.item_type} + + + {/* 코드 */} + + {node.code} + + + {/* 품목명 */} + {node.name} + + {/* 규격 */} + {node.specification && ( + + ({node.specification}) + + )} + + {/* 수량 */} + + ×{node.quantity} + + + {/* 단위 */} + {node.unit && ( + {node.unit} + )} +
+ + {/* 자식 노드 */} + {isOpen && hasChildren && node.children.map((child) => ( + + ))} +
+ ); +} + +interface BomTreeViewerProps { + itemId: string; + itemType: string; +} + +export function BomTreeViewer({ itemId, itemType }: BomTreeViewerProps) { + const [treeData, setTreeData] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [expandedNodes, setExpandedNodes] = useState>(new Set()); + + // 트리 데이터 로드 + const loadTree = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const response = await fetch(`/api/proxy/items/${itemId}/bom/tree`); + 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); + } else { + setTreeData([]); + } + } catch { + setError('BOM 트리를 불러오는 중 오류가 발생했습니다.'); + } finally { + setIsLoading(false); + } + }, [itemId]); + + useEffect(() => { + // FG/PT (또는 한글: 제품/부품)만 BOM 트리 로드 + const isBomTarget = ['FG', 'PT', '제품', '부품'].includes(itemType); + if (isBomTarget) { + loadTree(); + } else { + setIsLoading(false); + } + }, [loadTree, itemType]); + + // BOM 대상이 아니면 렌더링하지 않음 + if (!['FG', 'PT', '제품', '부품'].includes(itemType)) return null; + + // 로딩 + if (isLoading) { + return ( + + + + BOM 트리를 불러오는 중... + + + ); + } + + // 에러 + if (error) { + return ( + + + {error} + + + + ); + } + + // 데이터 없음 + if (treeData.length === 0) { + return ( + + + + + BOM 트리 + + + +

+ 등록된 BOM 정보가 없습니다. +

+
+
+ ); + } + + const toggleNode = (id: number) => { + setExpandedNodes((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const expandAll = () => setExpandedNodes(collectNodeIds(treeData)); + const collapseAll = () => setExpandedNodes(new Set()); + + // 총 노드 수 계산 + const totalCount = collectNodeIds(treeData).size; + + return ( + + +
+ + + BOM 트리 + +
+ + 총 {totalCount}개 품목 + + + +
+
+
+ + {/* 범례 */} +
+ {Object.entries(ITEM_TYPE_LABELS).map(([type, label]) => ( +
+ + {type} + + {label} +
+ ))} +
+ + {/* 트리 */} +
+ {treeData.map((node) => ( + + ))} +
+
+
+ ); +} diff --git a/src/components/items/ItemDetailClient.tsx b/src/components/items/ItemDetailClient.tsx index 8fb254e9..b1dd133d 100644 --- a/src/components/items/ItemDetailClient.tsx +++ b/src/components/items/ItemDetailClient.tsx @@ -28,6 +28,7 @@ import { TableRow, } from '@/components/ui/table'; import { ArrowLeft, Edit, Package, FileImage, Download, FileText, Check, Calendar } from 'lucide-react'; +import { BomTreeViewer } from './BomTreeViewer'; import { downloadFileById } from '@/lib/utils/fileDownload'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { useMenuStore } from '@/stores/menuStore'; @@ -554,60 +555,9 @@ export default function ItemDetailClient({ item }: ItemDetailClientProps) { )} - {/* BOM 정보 - 절곡 부품은 제외 */} - {(item.itemType === 'FG' || (item.itemType === 'PT' && item.partType !== 'BENDING')) && item.bom && item.bom.length > 0 && ( - - -
- - - 부품 구성 (BOM) - - - 총 {item.bom.length}개 품목 - -
-
- -
- - - - 번호 - 품목코드 - 품목명 - 수량 - 단위 - - - - {item.bom.map((line, index) => ( - - {index + 1} - - - {line.childItemCode} - - - -
- {line.childItemName} - {line.isBending && ( - - 절곡품 - - )} -
-
- {line.quantity} - {line.unit} -
- ))} -
-
-
-
-
+ {/* BOM 트리 - FG/PT만 표시 (절곡 부품 제외) */} + {(item.itemType === 'FG' || (item.itemType === 'PT' && item.partType !== 'BENDING')) && ( + )} {/* 하단 액션 버튼 (sticky) */} diff --git a/src/components/material/ReceivingManagement/InventoryAdjustmentDialog.tsx b/src/components/material/ReceivingManagement/InventoryAdjustmentDialog.tsx index d2afe837..b0bac1ea 100644 --- a/src/components/material/ReceivingManagement/InventoryAdjustmentDialog.tsx +++ b/src/components/material/ReceivingManagement/InventoryAdjustmentDialog.tsx @@ -124,7 +124,7 @@ export function InventoryAdjustmentDialog({ open, onOpenChange, onSave }: Props) return ( - + 재고 조정 @@ -162,7 +162,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 b70810fa..1e323744 100644 --- a/src/components/material/ReceivingManagement/ReceivingDetail.tsx +++ b/src/components/material/ReceivingManagement/ReceivingDetail.tsx @@ -57,7 +57,6 @@ import { RECEIVING_STATUS_OPTIONS, type ReceivingDetail as ReceivingDetailType, type ReceivingStatus, - type InventoryAdjustmentRecord, } from './types'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { toast } from 'sonner'; @@ -148,9 +147,6 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { file?: { id: number; display_name?: string; original_name?: string; file_path: string; file_size: number; mime_type?: string }; }>>([]); - // 재고 조정 이력 상태 - const [adjustments, setAdjustments] = useState([]); - // Dev 모드 폼 자동 채우기 useDevFill( 'receiving', @@ -188,10 +184,6 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { if (result.success && result.data) { setDetail(result.data); - // 재고 조정 이력 설정 - if (result.data.inventoryAdjustments) { - setAdjustments(result.data.inventoryAdjustments); - } // 기존 성적서 파일 정보 설정 if (result.data.certificateFileId) { setExistingCertFile({ @@ -326,30 +318,6 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { loadData(); }; - // 재고 조정 행 추가 - const handleAddAdjustment = () => { - const newRecord: InventoryAdjustmentRecord = { - id: `adj-${Date.now()}`, - adjustmentDate: getTodayString(), - quantity: 0, - inspector: getLoggedInUserName() || '홍길동', - }; - setAdjustments((prev) => [...prev, newRecord]); - }; - - // 재고 조정 행 삭제 - const handleRemoveAdjustment = (adjId: string) => { - setAdjustments((prev) => prev.filter((a) => a.id !== adjId)); - }; - - // 재고 조정 수량 변경 - const handleAdjustmentQtyChange = (adjId: string, value: string) => { - const numValue = value === '' || value === '-' ? 0 : Number(value); - setAdjustments((prev) => - prev.map((a) => (a.id === adjId ? { ...a, quantity: numValue } : a)) - ); - }; - // 취소 핸들러 - 등록 모드면 목록으로, 수정 모드면 상세로 이동 const handleCancel = () => { if (isNewMode) { @@ -496,47 +464,9 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { - {/* 재고 조정 */} - - - 재고 조정 - - -
-
- - - No - 조정일시 - 증감 수량 - 검수자 - - - - {adjustments.length > 0 ? ( - adjustments.map((adj, idx) => ( - - {idx + 1} - {adj.adjustmentDate} - {adj.quantity} - {adj.inspector} - - )) - ) : ( - - - 재고 조정 이력이 없습니다. - - - )} - -
-
- -
); - }, [detail, adjustments, inspectionAttachments, existingCertFile]); + }, [detail, inspectionAttachments, existingCertFile]); // ===== 등록/수정 폼 콘텐츠 ===== const renderFormContent = useCallback(() => { @@ -779,88 +709,9 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { - {/* 재고 조정 */} - - - 재고 조정 - - - -
- - - - No - 조정일시 - 증감 수량 - 검수자 - - - - - {adjustments.length > 0 ? ( - adjustments.map((adj, idx) => ( - - {idx + 1} - - { - setAdjustments((prev) => - prev.map((a) => - a.id === adj.id ? { ...a, adjustmentDate: date } : a - ) - ); - }} - size="sm" - /> - - - handleAdjustmentQtyChange(adj.id, e.target.value)} - className="h-8 text-sm text-center w-[100px] mx-auto" - placeholder="0" - /> - - {adj.inspector} - - - - - )) - ) : ( - - - 재고 조정 이력이 없습니다. - - - )} - -
-
-
-
); - }, [formData, adjustments, uploadedFile, existingCertFile]); + }, [formData, uploadedFile, existingCertFile]); // ===== 커스텀 헤더 액션 (view/edit 모드) ===== // 수정 버튼은 IntegratedDetailTemplate의 DetailActions에서 아이콘으로 제공하므로 중복 제거 diff --git a/src/components/material/ReceivingManagement/ReceivingList.tsx b/src/components/material/ReceivingManagement/ReceivingList.tsx index 948f1d57..242254d6 100644 --- a/src/components/material/ReceivingManagement/ReceivingList.tsx +++ b/src/components/material/ReceivingManagement/ReceivingList.tsx @@ -37,7 +37,6 @@ import { type FilterFieldConfig, } from '@/components/templates/UniversalListPage'; import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard'; -import { InventoryAdjustmentDialog } from './InventoryAdjustmentDialog'; import { getReceivings, getReceivingStats } from './actions'; import { RECEIVING_STATUS_LABELS, @@ -84,9 +83,6 @@ export function ReceivingList() { const [stats, setStats] = useState(null); const [totalItems, setTotalItems] = useState(0); - // ===== 재고 조정 팝업 상태 ===== - const [isAdjustmentOpen, setIsAdjustmentOpen] = useState(false); - // ===== 날짜 범위 상태 (최근 30일) ===== const today = new Date(); const thirtyDaysAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000); @@ -290,17 +286,9 @@ export function ReceivingList() { // 통계 카드 stats: statCards, - // 헤더 액션 (재고 조정 + 입고 등록 버튼) + // 헤더 액션 (입고 등록 버튼) headerActions: () => (
- + + +
+ + + + No + 조정일시 + 증감 수량 + 조정 후 재고 + 사유 + 검수자 + + + + {adjustments.length > 0 ? ( + adjustments.map((adj, idx) => ( + + {idx + 1} + {adj.adjusted_at} + 0 ? 'text-blue-600' : 'text-red-600'}`}> + {adj.quantity > 0 ? `+${adj.quantity}` : adj.quantity} + + {adj.balance_qty} + {adj.remark || '-'} + {adj.inspector} + + )) + ) : ( + + + 재고 조정 이력이 없습니다. + + + )} + +
+
+
+ + ); + // 상세 보기 모드 렌더링 const renderViewContent = useCallback(() => { if (!detail) return null; return ( - - - 기본 정보 - - -
- {/* Row 1: 자재번호, 품목코드, 품목유형, 품목명 */} -
- {renderReadOnlyField('자재번호', detail.stockNumber)} - {renderReadOnlyField('품목코드', detail.itemCode)} - {renderReadOnlyField('품목유형', ITEM_TYPE_LABELS[detail.itemType] || '-')} - {renderReadOnlyField('품목명', detail.itemName)} -
+
+ + + 기본 정보 + + +
+ {/* Row 1: 자재번호, 품목코드, 품목유형, 품목명 */} +
+ {renderReadOnlyField('자재번호', detail.stockNumber)} + {renderReadOnlyField('품목코드', detail.itemCode)} + {renderReadOnlyField('품목유형', ITEM_TYPE_LABELS[detail.itemType] || '-')} + {renderReadOnlyField('품목명', detail.itemName)} +
- {/* Row 2: 규격, 단위, 재고량, 안전재고 */} -
- {renderReadOnlyField('규격', detail.specification)} - {renderReadOnlyField('단위', detail.unit)} - {renderReadOnlyField('재고량', detail.calculatedQty)} - {renderReadOnlyField('안전재고', detail.safetyStock)} -
+ {/* Row 2: 규격, 단위, 재고량, 안전재고 */} +
+ {renderReadOnlyField('규격', detail.specification)} + {renderReadOnlyField('단위', detail.unit)} + {renderReadOnlyField('재고량', detail.calculatedQty)} + {renderReadOnlyField('안전재고', detail.safetyStock)} +
- {/* Row 3: 재공품, 상태 */} -
- {renderReadOnlyField('재공품', USE_STATUS_LABELS[detail.wipStatus])} - {renderReadOnlyField('상태', USE_STATUS_LABELS[detail.useStatus])} + {/* Row 3: 재공품, 상태 */} +
+ {renderReadOnlyField('재공품', USE_STATUS_LABELS[detail.wipStatus])} + {renderReadOnlyField('상태', USE_STATUS_LABELS[detail.useStatus])} +
-
-
-
+ + + + {renderAdjustmentSection()} +
); - }, [detail]); + }, [detail, adjustments]); // 수정 모드 렌더링 const renderFormContent = useCallback(() => { if (!detail) return null; return ( - - - 기본 정보 - - -
- {/* Row 1: 자재번호, 품목코드, 품목유형, 품목명 (읽기 전용) */} -
- {renderReadOnlyField('자재번호', detail.stockNumber, true)} - {renderReadOnlyField('품목코드', detail.itemCode, true)} - {renderReadOnlyField('품목유형', ITEM_TYPE_LABELS[detail.itemType] || '-', true)} - {renderReadOnlyField('품목명', detail.itemName, true)} -
+
+ + + 기본 정보 + + +
+ {/* Row 1: 자재번호, 품목코드, 품목유형, 품목명 (읽기 전용) */} +
+ {renderReadOnlyField('자재번호', detail.stockNumber, true)} + {renderReadOnlyField('품목코드', detail.itemCode, true)} + {renderReadOnlyField('품목유형', ITEM_TYPE_LABELS[detail.itemType] || '-', true)} + {renderReadOnlyField('품목명', detail.itemName, true)} +
- {/* Row 2: 규격, 단위, 재고량 (읽기 전용) + 안전재고 (수정 가능) */} -
- {renderReadOnlyField('규격', detail.specification, true)} - {renderReadOnlyField('단위', detail.unit, true)} - {renderReadOnlyField('재고량', detail.calculatedQty, true)} + {/* Row 2: 규격, 단위, 재고량 (읽기 전용) + 안전재고 (수정 가능) */} +
+ {renderReadOnlyField('규격', detail.specification, true)} + {renderReadOnlyField('단위', detail.unit, true)} + {renderReadOnlyField('재고량', detail.calculatedQty, true)} - {/* 안전재고 (수정 가능) */} -
- - handleInputChange('safetyStock', e.target.value)} - className="mt-1.5 border-gray-300 focus:border-blue-500 focus:ring-blue-500" - min={0} - /> + {/* 안전재고 (수정 가능) */} +
+ + handleInputChange('safetyStock', e.target.value)} + className="mt-1.5 border-gray-300 focus:border-blue-500 focus:ring-blue-500" + min={0} + /> +
+
+ + {/* Row 3: 재공품 (읽기 전용), 상태 (수정 가능) */} +
+ {/* 재공품 (읽기 전용) */} + {renderReadOnlyField('재공품', USE_STATUS_LABELS[detail.wipStatus], true)} + {/* 상태 (수정 가능) */} +
+ + +
+ + - {/* Row 3: 재공품 (읽기 전용), 상태 (수정 가능) */} -
- {/* 재공품 (읽기 전용) */} - {renderReadOnlyField('재공품', USE_STATUS_LABELS[detail.wipStatus], true)} - {/* 상태 (수정 가능) */} -
- - -
-
-
- - + {renderAdjustmentSection()} +
); - }, [detail, formData]); + }, [detail, formData, adjustments]); // 에러 상태 표시 if (!isLoading && (error || !detail)) { @@ -301,15 +442,70 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) { } return ( - | undefined} - itemId={id} - isLoading={isLoading} - renderView={() => renderViewContent()} - renderForm={() => renderFormContent()} - onSubmit={async () => { await handleSave(); return { success: true }; }} - /> + <> + | undefined} + itemId={id} + isLoading={isLoading} + renderView={() => renderViewContent()} + renderForm={() => renderFormContent()} + onSubmit={async () => { await handleSave(); return { success: true }; }} + /> + + {/* 재고 조정 등록 다이얼로그 */} + + + + 재고 조정 + +
+
+ + setAdjustmentForm((prev) => ({ ...prev, quantity: e.target.value }))} + placeholder="양수: 증가, 음수: 감소" + className="mt-1.5" + /> +
+
+ +