feat: 품목 관리 및 마스터 데이터 관리 시스템 구현

주요 기능:
- 품목 CRUD 기능 (생성, 조회, 수정)
- 품목 마스터 데이터 관리 시스템
- BOM(Bill of Materials) 관리 기능
- 도면 캔버스 기능
- 품목 속성 및 카테고리 관리
- 스크린 인쇄 생산 관리 페이지

기술 개선:
- localStorage SSR 호환성 수정 (9개 useState 초기화)
- Shadcn UI 컴포넌트 추가 (table, tabs, alert, drawer 등)
- DataContext 및 DeveloperModeContext 추가
- API 라우트 구현 (items, master-data)
- 타입 정의 및 유틸리티 함수 추가

빌드 테스트:  성공 (3.1초)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-11-18 14:17:52 +09:00
parent 21edc932d9
commit 63f5df7d7d
56 changed files with 23927 additions and 149 deletions

View File

@@ -0,0 +1,585 @@
/**
* 품목 목록 Client Component
*
* Server Component에서 받은 데이터를 표시하고 상호작용 처리
*/
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { ItemMaster } from '@/types/item';
import { ITEM_TYPE_LABELS } from '@/types/item';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Search, Plus, Edit, Trash2, Package, GitBranch, ChevronLeft, ChevronRight } from 'lucide-react';
interface ItemListClientProps {
items: ItemMaster[];
}
/**
* 품목 유형별 Badge 색상 반환
*/
function getItemTypeBadge(itemType: string) {
const badges: Record<string, { variant: 'default' | 'secondary' | 'outline' | 'destructive'; className: string }> = {
FG: { variant: 'default', className: 'bg-purple-100 text-purple-700 border-purple-200' },
PT: { variant: 'default', className: 'bg-orange-100 text-orange-700 border-orange-200' },
SM: { variant: 'default', className: 'bg-green-100 text-green-700 border-green-200' },
RM: { variant: 'default', className: 'bg-blue-100 text-blue-700 border-blue-200' },
CS: { variant: 'default', className: 'bg-gray-100 text-gray-700 border-gray-200' },
};
const config = badges[itemType] || { variant: 'outline' as const, className: '' };
return (
<Badge variant="outline" className={config.className}>
{ITEM_TYPE_LABELS[itemType as keyof typeof ITEM_TYPE_LABELS]}
</Badge>
);
}
/**
* 조립품 품목코드 포맷팅 (하이픈 이후 제거)
*/
function formatItemCodeForAssembly(item: ItemMaster): string {
return item.itemCode;
}
export default function ItemListClient({ items: initialItems }: ItemListClientProps) {
const router = useRouter();
const [searchTerm, setSearchTerm] = useState('');
const [selectedType, setSelectedType] = useState<string>('all');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [selectAll, setSelectAll] = useState(false);
// 페이징 상태
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
// 필터링된 품목 목록
const filteredItems = initialItems.filter((item) => {
// 유형 필터
if (selectedType !== 'all' && item.itemType !== selectedType) {
return false;
}
// 검색어 필터
if (searchTerm) {
const search = searchTerm.toLowerCase();
return (
item.itemCode.toLowerCase().includes(search) ||
item.itemName.toLowerCase().includes(search) ||
item.specification?.toLowerCase().includes(search)
);
}
return true;
});
// 페이징 계산
const totalPages = Math.ceil(filteredItems.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedItems = filteredItems.slice(startIndex, endIndex);
// 검색이나 필터 변경 시 첫 페이지로 이동
const handleSearchChange = (value: string) => {
setSearchTerm(value);
setCurrentPage(1);
};
const handleTypeChange = (value: string) => {
setSelectedType(value);
setCurrentPage(1);
};
const handleView = (itemCode: string) => {
router.push(`/items/${encodeURIComponent(itemCode)}`);
};
const handleEdit = (itemCode: string) => {
router.push(`/items/${encodeURIComponent(itemCode)}/edit`);
};
const handleDelete = async (itemCode: string) => {
if (!confirm(`품목 "${itemCode}"을(를) 삭제하시겠습니까?`)) {
return;
}
try {
// TODO: API 연동 시 실제 삭제 로직 추가
// await deleteItem(itemCode);
alert('품목이 삭제되었습니다.');
router.refresh();
} catch {
alert('품목 삭제에 실패했습니다.');
}
};
// 체크박스 전체 선택/해제 (현재 페이지만)
const handleSelectAll = () => {
if (selectAll) {
setSelectedItems(new Set());
setSelectAll(false);
} else {
const allIds = new Set(paginatedItems.map((item) => item.id));
setSelectedItems(allIds);
setSelectAll(true);
}
};
// 개별 체크박스 선택/해제
const handleSelectItem = (itemId: string) => {
const newSelected = new Set(selectedItems);
if (newSelected.has(itemId)) {
newSelected.delete(itemId);
setSelectAll(false);
} else {
newSelected.add(itemId);
if (newSelected.size === paginatedItems.length) {
setSelectAll(true);
}
}
setSelectedItems(newSelected);
};
// 통계 데이터
const stats = [
{
label: '전체 품목',
value: initialItems.length,
icon: Package,
iconColor: 'text-blue-600',
},
{
label: '제품',
value: initialItems.filter((i) => i.itemType === 'FG').length,
icon: Package,
iconColor: 'text-purple-600',
},
{
label: '부품',
value: initialItems.filter((i) => i.itemType === 'PT').length,
icon: Package,
iconColor: 'text-orange-600',
},
{
label: '부자재',
value: initialItems.filter((i) => i.itemType === 'SM').length,
icon: Package,
iconColor: 'text-green-600',
},
];
return (
<div className="space-y-6">
{/* 페이지 헤더 */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="p-2 bg-primary/10 rounded-lg hidden md:block">
<Package className="w-6 h-6 text-primary" />
</div>
<div>
<div className="flex items-center gap-2">
<h1 className="text-xl md:text-2xl"> </h1>
<Badge variant="secondary" className="gap-1">
<GitBranch className="h-3 w-3" />
v1.0.0
</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
, , , ,
</p>
</div>
</div>
<div className="flex gap-2 flex-wrap items-center">
<Button onClick={() => router.push('/items/create')}>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 통계 카드 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map((stat, index) => (
<Card key={index}>
<CardContent className="p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">
{stat.label}
</p>
<p className="text-3xl md:text-4xl font-bold mt-2">{stat.value}</p>
</div>
<stat.icon className={`w-10 h-10 md:w-12 md:h-12 opacity-15 ${stat.iconColor}`} />
</div>
</CardContent>
</Card>
))}
</div>
{/* 검색 및 필터 */}
<Card>
<CardContent className="p-4 md:p-6">
<div className="flex gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="품목코드, 품목명, 규격 검색..."
value={searchTerm}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-10"
/>
</div>
<Select value={selectedType} onValueChange={handleTypeChange}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="품목 유형" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
({initialItems.length})
</SelectItem>
<SelectItem value="FG">
({initialItems.filter((i) => i.itemType === 'FG').length})
</SelectItem>
<SelectItem value="PT">
({initialItems.filter((i) => i.itemType === 'PT').length})
</SelectItem>
<SelectItem value="SM">
({initialItems.filter((i) => i.itemType === 'SM').length})
</SelectItem>
<SelectItem value="RM">
({initialItems.filter((i) => i.itemType === 'RM').length})
</SelectItem>
<SelectItem value="CS">
({initialItems.filter((i) => i.itemType === 'CS').length})
</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* 품목 목록 - 탭과 테이블 */}
<Card>
<CardHeader>
<CardTitle className="text-sm md:text-base">
{selectedType === 'all'
? `전체 목록 (${filteredItems.length}개)`
: `${ITEM_TYPE_LABELS[selectedType as keyof typeof ITEM_TYPE_LABELS]} 목록 (${filteredItems.length}개)`
}
</CardTitle>
</CardHeader>
<CardContent className="p-4 md:p-6">
<Tabs value={selectedType} onValueChange={handleTypeChange} className="w-full">
<div className="overflow-x-auto -mx-2 px-2 mb-6">
<TabsList className="inline-flex w-auto min-w-full md:grid md:w-full md:max-w-2xl md:grid-cols-6">
<TabsTrigger value="all" className="whitespace-nowrap">
({initialItems.length})
</TabsTrigger>
<TabsTrigger value="FG" className="whitespace-nowrap">
({initialItems.filter((i) => i.itemType === 'FG').length})
</TabsTrigger>
<TabsTrigger value="PT" className="whitespace-nowrap">
({initialItems.filter((i) => i.itemType === 'PT').length})
</TabsTrigger>
<TabsTrigger value="SM" className="whitespace-nowrap">
({initialItems.filter((i) => i.itemType === 'SM').length})
</TabsTrigger>
<TabsTrigger value="RM" className="whitespace-nowrap">
({initialItems.filter((i) => i.itemType === 'RM').length})
</TabsTrigger>
<TabsTrigger value="CS" className="whitespace-nowrap">
({initialItems.filter((i) => i.itemType === 'CS').length})
</TabsTrigger>
</TabsList>
</div>
<TabsContent value={selectedType} className="mt-0">
{/* 모바일 카드 뷰 */}
<div className="lg:hidden space-y-3">
{paginatedItems.length === 0 ? (
<div className="text-center py-8 text-muted-foreground border rounded-lg">
{searchTerm || selectedType !== 'all'
? '검색 결과가 없습니다.'
: '등록된 품목이 없습니다.'}
</div>
) : (
paginatedItems.map((item, index) => (
<div
key={`${item.id}-mobile-${index}`}
className="border rounded-lg p-4 space-y-3 bg-card hover:bg-muted/50 transition-colors"
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 flex-1 min-w-0">
<Checkbox
checked={selectedItems.has(item.id)}
onCheckedChange={() => handleSelectItem(item.id)}
onClick={(e) => e.stopPropagation()}
/>
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
{formatItemCodeForAssembly(item)}
</code>
{getItemTypeBadge(item.itemType)}
{item.itemType === 'PT' && item.partType && (
<Badge variant="outline" className="ml-1 bg-purple-50 text-purple-700 text-xs">
{item.partType === 'ASSEMBLY' ? '조립' :
item.partType === 'BENDING' ? '절곡' :
item.partType === 'PURCHASED' ? '구매' : ''}
</Badge>
)}
</div>
<div
className="font-medium cursor-pointer"
onClick={() => handleView(item.itemCode)}
>
{item.itemName}
</div>
{(item.specification || (item.itemCode?.includes('-') && item.itemCode.split('-').slice(1).join('-'))) && (
<div className="text-sm text-muted-foreground">
: {item.itemCode?.includes('-')
? item.itemCode.split('-').slice(1).join('-')
: item.specification}
</div>
)}
{item.unit && (
<div>
<Badge variant="secondary" className="text-xs">{item.unit}</Badge>
</div>
)}
</div>
</div>
</div>
<div className="flex items-center justify-end gap-1 pt-2 border-t">
<Button
variant="ghost"
size="sm"
onClick={() => handleView(item.itemCode)}
className="h-8 px-3"
>
<Search className="h-4 w-4 mr-1" />
<span className="text-xs"></span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(item.itemCode)}
className="h-8 px-3"
>
<Edit className="h-4 w-4 mr-1" />
<span className="text-xs"></span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(item.itemCode)}
className="h-8 px-2"
>
<Trash2 className="h-4 w-4 text-red-600" />
</Button>
</div>
</div>
))
)}
</div>
{/* 데스크톱 테이블 */}
<div className="hidden lg:block rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">
<Checkbox
checked={selectAll}
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead className="hidden md:table-cell"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="hidden md:table-cell"></TableHead>
<TableHead className="hidden md:table-cell"></TableHead>
<TableHead className="min-w-[100px] whitespace-nowrap"> </TableHead>
<TableHead className="text-right min-w-[100px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedItems.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-8 text-gray-500">
{searchTerm || selectedType !== 'all'
? '검색 결과가 없습니다.'
: '등록된 품목이 없습니다.'}
</TableCell>
</TableRow>
) : (
paginatedItems.map((item, index) => (
<TableRow key={item.id} className="hover:bg-gray-50">
<TableCell>
<Checkbox
checked={selectedItems.has(item.id)}
onCheckedChange={() => handleSelectItem(item.id)}
/>
</TableCell>
<TableCell className="text-muted-foreground cursor-pointer hidden md:table-cell">
{filteredItems.length - (startIndex + index)}
</TableCell>
<TableCell className="cursor-pointer">
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
{formatItemCodeForAssembly(item) || '-'}
</code>
</TableCell>
<TableCell className="cursor-pointer">
{getItemTypeBadge(item.itemType)}
{item.itemType === 'PT' && item.partType && (
<Badge variant="outline" className="ml-1 bg-purple-50 text-purple-700 text-xs hidden lg:inline-flex">
{item.partType === 'ASSEMBLY' ? '조립' :
item.partType === 'BENDING' ? '절곡' :
item.partType === 'PURCHASED' ? '구매' : ''}
</Badge>
)}
</TableCell>
<TableCell className="cursor-pointer">
<div className="flex items-center gap-2">
<span className="truncate max-w-[150px] md:max-w-none">
{item.itemName}
</span>
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground cursor-pointer hidden md:table-cell">
{item.itemCode?.includes('-')
? item.itemCode.split('-').slice(1).join('-')
: item.specification || '-'}
</TableCell>
<TableCell className="cursor-pointer hidden md:table-cell">
<Badge variant="secondary">{item.unit || '-'}</Badge>
</TableCell>
<TableCell className="whitespace-nowrap">
<Badge variant={item.isActive ? 'default' : 'secondary'}>
{item.isActive ? '활성' : '비활성'}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleView(item.itemCode)}
title="상세 보기"
>
<Search className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(item.itemCode)}
title="수정"
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(item.itemCode)}
title="삭제"
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 페이지네이션 */}
{filteredItems.length > 0 && (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-4">
<div className="text-sm text-muted-foreground">
{filteredItems.length} {startIndex + 1}-{Math.min(endIndex, filteredItems.length)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter((page) => {
// 현재 페이지 주변 5개만 표시
return (
page === 1 ||
page === totalPages ||
(page >= currentPage - 2 && page <= currentPage + 2)
);
})
.map((page, index, array) => {
// 페이지 번호 사이에 ... 표시
const showEllipsisBefore = index > 0 && array[index - 1] !== page - 1;
return (
<div key={page} className="flex items-center gap-1">
{showEllipsisBefore && (
<span className="px-2 text-muted-foreground">...</span>
)}
<Button
variant={currentPage === page ? 'default' : 'outline'}
size="sm"
onClick={() => setCurrentPage(page)}
className="w-8 h-8 p-0"
>
{page}
</Button>
</div>
);
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === totalPages}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
);
}