feat: [material] 재고현황 상세 개선 + 입고관리 정리 + BOM 트리뷰어 추가

- 재고현황 상세 페이지 대폭 개선
- 입고관리 상세/목록 코드 정리
- BomTreeViewer 컴포넌트 신규
- 품목 상세 수정
This commit is contained in:
유병철
2026-03-18 11:15:19 +09:00
parent 87287552fd
commit 0bcc7c5417
9 changed files with 654 additions and 333 deletions

View File

@@ -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<string, string> = {
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<string, string> = {
FG: '완제품',
PT: '부품',
RM: '원자재',
SM: '부자재',
CS: '소모품',
BN: '절곡품',
SF: '반제품',
};
// 모든 노드의 ID를 재귀적으로 수집
function collectNodeIds(nodes: BomTreeNode[]): Set<number> {
const ids = new Set<number>();
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<number>;
onToggle: (id: number) => void;
}) {
const hasChildren = node.children.length > 0;
const isOpen = expandedNodes.has(node.id);
return (
<div>
<div
className="flex items-center gap-2 py-1.5 px-2 hover:bg-gray-50 rounded cursor-default"
style={{ paddingLeft: level * 24 + 8 }}
>
{/* 펼침/접힘 */}
{hasChildren ? (
<button
onClick={() => onToggle(node.id)}
className="w-5 h-5 flex items-center justify-center text-gray-500 hover:text-gray-800 shrink-0"
>
{isOpen ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</button>
) : (
<span className="w-5 shrink-0" />
)}
{/* 유형 뱃지 */}
<Badge
variant="outline"
className={`text-[10px] px-1.5 py-0 font-medium shrink-0 ${ITEM_TYPE_COLORS[node.item_type] || 'bg-gray-100 text-gray-800'}`}
>
{node.item_type}
</Badge>
{/* 코드 */}
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded text-gray-600 shrink-0">
{node.code}
</code>
{/* 품목명 */}
<span className="text-sm truncate">{node.name}</span>
{/* 규격 */}
{node.specification && (
<span className="text-xs text-muted-foreground truncate hidden md:inline">
({node.specification})
</span>
)}
{/* 수량 */}
<span className="text-sm text-blue-600 font-medium ml-auto shrink-0">
×{node.quantity}
</span>
{/* 단위 */}
{node.unit && (
<span className="text-xs text-muted-foreground shrink-0">{node.unit}</span>
)}
</div>
{/* 자식 노드 */}
{isOpen && hasChildren && node.children.map((child) => (
<BomTreeNodeItem
key={child.id}
node={child}
level={level + 1}
expandedNodes={expandedNodes}
onToggle={onToggle}
/>
))}
</div>
);
}
interface BomTreeViewerProps {
itemId: string;
itemType: string;
}
export function BomTreeViewer({ itemId, itemType }: BomTreeViewerProps) {
const [treeData, setTreeData] = useState<BomTreeNode[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedNodes, setExpandedNodes] = useState<Set<number>>(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 (
<Card>
<CardContent className="py-8 flex items-center justify-center text-muted-foreground">
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
BOM ...
</CardContent>
</Card>
);
}
// 에러
if (error) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
{error}
<Button variant="link" size="sm" onClick={loadTree} className="ml-2">
</Button>
</CardContent>
</Card>
);
}
// 데이터 없음
if (treeData.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
BOM
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground text-center py-4">
BOM .
</p>
</CardContent>
</Card>
);
}
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 (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
BOM
</CardTitle>
<div className="flex items-center gap-2">
<Badge variant="outline" className="bg-blue-50 text-blue-700">
{totalCount}
</Badge>
<Button variant="outline" size="sm" onClick={expandAll} className="h-7 text-xs">
<ChevronsUpDown className="w-3 h-3 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={collapseAll} className="h-7 text-xs">
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{/* 범례 */}
<div className="flex flex-wrap gap-2 mb-3 pb-3 border-b">
{Object.entries(ITEM_TYPE_LABELS).map(([type, label]) => (
<div key={type} className="flex items-center gap-1">
<Badge
variant="outline"
className={`text-[10px] px-1.5 py-0 ${ITEM_TYPE_COLORS[type] || ''}`}
>
{type}
</Badge>
<span className="text-xs text-muted-foreground">{label}</span>
</div>
))}
</div>
{/* 트리 */}
<div className="border rounded-md">
{treeData.map((node) => (
<BomTreeNodeItem
key={node.id}
node={node}
level={0}
expandedNodes={expandedNodes}
onToggle={toggleNode}
/>
))}
</div>
</CardContent>
</Card>
);
}

View File

@@ -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) {
</Card>
)}
{/* BOM 정보 - 절곡 부품 제외 */}
{(item.itemType === 'FG' || (item.itemType === 'PT' && item.partType !== 'BENDING')) && item.bom && item.bom.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
(BOM)
</CardTitle>
<Badge variant="outline" className="bg-blue-50 text-blue-700">
{item.bom.length}
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{item.bom.map((line, index) => (
<TableRow key={line.id}>
<TableCell>{index + 1}</TableCell>
<TableCell>
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
{line.childItemCode}
</code>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{line.childItemName}
{line.isBending && (
<Badge variant="outline" className="text-xs bg-purple-50 text-purple-700">
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-right">{line.quantity}</TableCell>
<TableCell>{line.unit}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* BOM 트리 - FG/PT만 표시 (절곡 부품 제외) */}
{(item.itemType === 'FG' || (item.itemType === 'PT' && item.partType !== 'BENDING')) && (
<BomTreeViewer itemId={item.id} itemType={item.itemType} />
)}
{/* 하단 액션 버튼 (sticky) */}