feat: 생산/품질/자재/출고/주문 관리 페이지 구현
- 생산관리: 대시보드, 작업지시, 작업실적, 작업자화면 - 품질관리: 검사관리 (리스트/등록/상세) - 자재관리: 입고관리, 재고현황 - 출고관리: 출하관리 (리스트/등록/상세/수정) - 주문관리: 수주관리, 생산의뢰 - 기존 컴포넌트 개선: CardTransactionInquiry, VendorDetail, QuoteRegistration - IntegratedListTemplateV2 개선 - 공통 컴포넌트 분석 문서 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
342
src/components/material/StockStatus/StockStatusList.tsx
Normal file
342
src/components/material/StockStatus/StockStatusList.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 재고현황 목록 페이지
|
||||
* IntegratedListTemplateV2 패턴 적용
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Package,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Download,
|
||||
Eye,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
IntegratedListTemplateV2,
|
||||
TabOption,
|
||||
TableColumn,
|
||||
StatCard,
|
||||
} from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
||||
import { mockStockItems, mockStats, mockFilterTabs } from './mockData';
|
||||
import { ITEM_TYPE_LABELS, ITEM_TYPE_STYLES, STOCK_STATUS_LABELS } from './types';
|
||||
import type { StockItem, ItemType } from './types';
|
||||
|
||||
// 페이지당 항목 수
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
export function StockStatusList() {
|
||||
const router = useRouter();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [activeFilter, setActiveFilter] = useState<'all' | ItemType>('all');
|
||||
|
||||
// 통계 카드
|
||||
const stats: StatCard[] = [
|
||||
{
|
||||
label: '전체 품목',
|
||||
value: `${mockStats.totalItems}종`,
|
||||
icon: Package,
|
||||
iconColor: 'text-gray-600',
|
||||
},
|
||||
{
|
||||
label: '정상 재고',
|
||||
value: `${mockStats.normalCount}종`,
|
||||
icon: CheckCircle2,
|
||||
iconColor: 'text-green-600',
|
||||
},
|
||||
{
|
||||
label: '재고 부족',
|
||||
value: `${mockStats.lowCount}종`,
|
||||
icon: Clock,
|
||||
iconColor: 'text-orange-600',
|
||||
},
|
||||
{
|
||||
label: '재고 없음',
|
||||
value: `${mockStats.outCount}종`,
|
||||
icon: AlertCircle,
|
||||
iconColor: 'text-red-600',
|
||||
},
|
||||
];
|
||||
|
||||
// 테이블 컬럼
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'itemCode', label: '품목코드', className: 'min-w-[120px]' },
|
||||
{ key: 'itemName', label: '품목명', className: 'min-w-[200px]' },
|
||||
{ key: 'itemType', label: '품목유형', className: 'w-[100px]' },
|
||||
{ key: 'unit', label: '단위', className: 'w-[60px] text-center' },
|
||||
{ key: 'stockQty', label: '재고량', className: 'w-[80px] text-center' },
|
||||
{ key: 'safetyStock', label: '안전재고', className: 'w-[80px] text-center' },
|
||||
{ key: 'lot', label: 'LOT', className: 'w-[100px] text-center' },
|
||||
{ key: 'status', label: '상태', className: 'w-[60px] text-center' },
|
||||
{ key: 'location', label: '위치', className: 'w-[60px] text-center' },
|
||||
{ key: 'actions', label: '작업', className: 'w-[60px] text-center' },
|
||||
];
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredResults = useMemo(() => {
|
||||
let result = [...mockStockItems];
|
||||
|
||||
// 품목유형 필터
|
||||
if (activeFilter !== 'all') {
|
||||
result = result.filter((item) => item.itemType === activeFilter);
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
result = result.filter(
|
||||
(item) =>
|
||||
item.itemCode.toLowerCase().includes(term) ||
|
||||
item.itemName.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [searchTerm, activeFilter]);
|
||||
|
||||
// 페이지네이션 데이터
|
||||
const paginatedData = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
return filteredResults.slice(startIndex, startIndex + ITEMS_PER_PAGE);
|
||||
}, [filteredResults, currentPage]);
|
||||
|
||||
// 페이지네이션 설정
|
||||
const pagination = {
|
||||
currentPage,
|
||||
totalPages: Math.ceil(filteredResults.length / ITEMS_PER_PAGE),
|
||||
totalItems: filteredResults.length,
|
||||
itemsPerPage: ITEMS_PER_PAGE,
|
||||
onPageChange: setCurrentPage,
|
||||
};
|
||||
|
||||
// 선택 핸들러
|
||||
const handleToggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((item) => item.id)));
|
||||
}
|
||||
}, [paginatedData, selectedItems.size]);
|
||||
|
||||
// 검색 변경 시 페이지 리셋
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// 탭 변경 시 페이지 리셋
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveFilter(value as 'all' | ItemType);
|
||||
setCurrentPage(1);
|
||||
setSelectedItems(new Set());
|
||||
};
|
||||
|
||||
// 엑셀 다운로드
|
||||
const handleExcelDownload = useCallback(() => {
|
||||
console.log('엑셀 다운로드:', filteredResults);
|
||||
// TODO: 엑셀 다운로드 기능 구현
|
||||
}, [filteredResults]);
|
||||
|
||||
// 상세 보기
|
||||
const handleView = useCallback((id: string) => {
|
||||
router.push(`/ko/material/stock-status/${id}`);
|
||||
}, [router]);
|
||||
|
||||
// 재고부족 수 계산
|
||||
const lowStockCount = useMemo(() => {
|
||||
return filteredResults.filter((item) => item.status === 'low').length;
|
||||
}, [filteredResults]);
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = (item: StockItem, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(item.id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${isSelected ? 'bg-blue-50' : ''}`}
|
||||
onClick={() => handleView(item.id)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(item.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">{item.itemCode}</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">{item.itemName}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`text-xs ${ITEM_TYPE_STYLES[item.itemType]}`}>
|
||||
{ITEM_TYPE_LABELS[item.itemType]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-center">{item.stockQty}</TableCell>
|
||||
<TableCell className="text-center">{item.safetyStock}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<span>{item.lotCount}개</span>
|
||||
{item.lotDaysElapsed > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{item.lotDaysElapsed}일 경과
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={item.status === 'low' ? 'text-orange-600 font-medium' : ''}>
|
||||
{STOCK_STATUS_LABELS[item.status]}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.location}</TableCell>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
{isSelected && (
|
||||
<Eye
|
||||
className="w-5 h-5 text-gray-500 hover:text-gray-700 cursor-pointer mx-auto"
|
||||
onClick={() => handleView(item.id)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = (
|
||||
item: StockItem,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
isSelected: boolean,
|
||||
onToggle: () => void
|
||||
) => {
|
||||
return (
|
||||
<ListMobileCard
|
||||
id={item.id}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
onClick={() => handleView(item.id)}
|
||||
headerBadges={
|
||||
<>
|
||||
<Badge variant="outline" className="text-xs">#{globalIndex}</Badge>
|
||||
<Badge variant="outline" className="text-xs">{item.itemCode}</Badge>
|
||||
</>
|
||||
}
|
||||
title={item.itemName}
|
||||
statusBadge={
|
||||
<Badge className={`text-xs ${ITEM_TYPE_STYLES[item.itemType]}`}>
|
||||
{ITEM_TYPE_LABELS[item.itemType]}
|
||||
</Badge>
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="단위" value={item.unit} />
|
||||
<InfoField label="위치" value={item.location} />
|
||||
<InfoField label="재고량" value={`${item.stockQty}`} />
|
||||
<InfoField label="안전재고" value={`${item.safetyStock}`} />
|
||||
<InfoField
|
||||
label="LOT"
|
||||
value={`${item.lotCount}개${item.lotDaysElapsed > 0 ? ` (${item.lotDaysElapsed}일 경과)` : ''}`}
|
||||
/>
|
||||
<InfoField
|
||||
label="상태"
|
||||
value={STOCK_STATUS_LABELS[item.status]}
|
||||
className={item.status === 'low' ? 'text-orange-600' : ''}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
isSelected && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={(e) => { e.stopPropagation(); handleView(item.id); }}
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
상세
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 탭 옵션 (TabChip 사용)
|
||||
const tabs: TabOption[] = mockFilterTabs.map((tab) => ({
|
||||
value: tab.key,
|
||||
label: tab.label,
|
||||
count: tab.count,
|
||||
}));
|
||||
|
||||
// 헤더 액션
|
||||
const headerActions = (
|
||||
<Button variant="outline" onClick={handleExcelDownload}>
|
||||
<Download className="w-4 h-4 mr-1.5" />
|
||||
엑셀 다운로드
|
||||
</Button>
|
||||
);
|
||||
|
||||
// 하단 요약 (테이블 푸터)
|
||||
const tableFooter = (
|
||||
<TableRow className="bg-gray-50 hover:bg-gray-50">
|
||||
<TableCell colSpan={tableColumns.length + 1} className="py-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
총 {filteredResults.length}종 / 재고부족 {lowStockCount}종
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
return (
|
||||
<IntegratedListTemplateV2<StockItem>
|
||||
title="재고 목록"
|
||||
icon={Package}
|
||||
headerActions={headerActions}
|
||||
stats={stats}
|
||||
searchValue={searchTerm}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="품목코드, 품목명 검색..."
|
||||
tabs={tabs}
|
||||
activeTab={activeFilter}
|
||||
onTabChange={handleTabChange}
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
totalCount={filteredResults.length}
|
||||
allData={filteredResults}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
pagination={pagination}
|
||||
tableFooter={tableFooter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user