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:
byeongcheolryu
2025-12-23 21:13:07 +09:00
parent 2ebcea0255
commit f0e8e51d06
108 changed files with 21156 additions and 84 deletions

View File

@@ -0,0 +1,262 @@
'use client';
/**
* 재고현황 상세 페이지
*/
import { useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { Package, AlertCircle, ArrowLeft } 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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { PageLayout } from '@/components/organisms/PageLayout';
import { getStockDetail } from './mockData';
import {
ITEM_TYPE_LABELS,
ITEM_TYPE_STYLES,
STOCK_STATUS_LABELS,
LOT_STATUS_LABELS,
} from './types';
import type { StockDetail, LotDetail } from './types';
interface StockStatusDetailProps {
id: string;
}
export function StockStatusDetail({ id }: StockStatusDetailProps) {
const router = useRouter();
// Mock 데이터에서 상세 정보 가져오기 (동적 생성)
const detail: StockDetail | undefined = getStockDetail(id);
// 가장 오래된 LOT 찾기 (FIFO 권장용)
const oldestLot = useMemo(() => {
if (!detail || detail.lots.length === 0) return null;
return detail.lots.reduce((oldest, lot) =>
lot.daysElapsed > oldest.daysElapsed ? lot : oldest
);
}, [detail]);
// 총 수량 계산
const totalQty = useMemo(() => {
if (!detail) return 0;
return detail.lots.reduce((sum, lot) => sum + lot.qty, 0);
}, [detail]);
// 목록으로 돌아가기
const handleGoBack = () => {
router.push('/ko/material/stock-status');
};
if (!detail) {
return (
<PageLayout>
<div className="space-y-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Package className="w-6 h-6" />
<h1 className="text-xl font-semibold"> </h1>
</div>
<Button variant="outline" onClick={handleGoBack}>
</Button>
</div>
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
.
</CardContent>
</Card>
</div>
</PageLayout>
);
}
return (
<PageLayout>
<div className="space-y-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Package className="w-6 h-6" />
<h1 className="text-xl font-semibold"> </h1>
<span className="text-muted-foreground">{detail.itemCode}</span>
<Badge variant="outline" className="text-xs">
{STOCK_STATUS_LABELS[detail.status]}
</Badge>
</div>
<Button variant="outline" onClick={handleGoBack}>
</Button>
</div>
{/* 기본 정보 */}
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-base font-medium"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium">{detail.itemCode}</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium">{detail.itemName}</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1"></div>
<Badge className={`text-xs ${ITEM_TYPE_STYLES[detail.itemType]}`}>
{ITEM_TYPE_LABELS[detail.itemType]}
</Badge>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium">{detail.category}</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium">{detail.specification || '-'}</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium">{detail.unit}</div>
</div>
</div>
</CardContent>
</Card>
{/* 재고 현황 */}
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-base font-medium"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-3 gap-6">
<div>
<div className="text-sm text-muted-foreground mb-1"> </div>
<div className="text-2xl font-bold">
{detail.currentStock} <span className="text-base font-normal">{detail.unit}</span>
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1"> </div>
<div className="text-lg font-medium">
{detail.safetyStock} <span className="text-sm font-normal">{detail.unit}</span>
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1"> </div>
<div className="font-medium">{detail.location}</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1">LOT </div>
<div className="font-medium">{detail.lotCount}</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1"> </div>
<div className="font-medium">{detail.lastReceiptDate}</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1"> </div>
<Badge variant="outline" className="text-xs">
{STOCK_STATUS_LABELS[detail.status]}
</Badge>
</div>
</div>
</CardContent>
</Card>
{/* LOT별 상세 재고 */}
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-medium">LOT별 </CardTitle>
<div className="text-sm text-muted-foreground">
FIFO · LOT부터
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="w-[60px] text-center">FIFO</TableHead>
<TableHead className="min-w-[100px]">LOT번호</TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[70px] text-center"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detail.lots.map((lot: LotDetail) => (
<TableRow key={lot.id}>
<TableCell className="text-center">
<div className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-medium">
{lot.fifoOrder}
</div>
</TableCell>
<TableCell className="font-medium">{lot.lotNo}</TableCell>
<TableCell>{lot.receiptDate}</TableCell>
<TableCell className="text-center">
<span className={lot.daysElapsed > 30 ? 'text-orange-600 font-medium' : ''}>
{lot.daysElapsed}
</span>
</TableCell>
<TableCell>{lot.supplier}</TableCell>
<TableCell>{lot.poNumber}</TableCell>
<TableCell className="text-center">
{lot.qty} {lot.unit}
</TableCell>
<TableCell className="text-center">{lot.location}</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="text-xs">
{LOT_STATUS_LABELS[lot.status]}
</Badge>
</TableCell>
</TableRow>
))}
{/* 합계 행 */}
<TableRow className="bg-muted/30 font-medium">
<TableCell colSpan={6} className="text-right">
:
</TableCell>
<TableCell className="text-center">
{totalQty} {detail.unit}
</TableCell>
<TableCell colSpan={2} />
</TableRow>
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* FIFO 권장 메시지 */}
{oldestLot && oldestLot.daysElapsed > 30 && (
<div className="flex items-start gap-2 p-4 bg-orange-50 border border-orange-200 rounded-lg">
<AlertCircle className="w-5 h-5 text-orange-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-orange-800">
<span className="font-medium">FIFO :</span> LOT {oldestLot.lotNo}{' '}
{oldestLot.daysElapsed} . .
</div>
</div>
)}
</div>
</PageLayout>
);
}

View 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}
/>
);
}

View File

@@ -0,0 +1,3 @@
export { StockStatusList } from './StockStatusList';
export { StockStatusDetail } from './StockStatusDetail';
export * from './types';

View File

@@ -0,0 +1,534 @@
/**
* 재고현황 Mock 데이터
*/
import type { StockItem, StockDetail, StockStats, FilterTab } from './types';
// 재고 상태 결정 함수
function getStockStatus(stockQty: number, safetyStock: number): 'normal' | 'low' | 'out' {
if (stockQty === 0) return 'out';
if (stockQty < safetyStock) return 'low';
return 'normal';
}
// 시드 기반 의사 난수 생성 (일관된 결과 보장)
function seededRandom(seed: number): number {
const x = Math.sin(seed) * 10000;
return x - Math.floor(x);
}
function seededInt(seed: number, min: number, max: number): number {
return Math.floor(seededRandom(seed) * (max - min + 1)) + min;
}
// 위치 코드 생성 (시드 기반)
function generateLocation(type: string, seed: number): string {
const prefixes: Record<string, string[]> = {
raw_material: ['A'],
bent_part: ['C', 'D'],
purchased_part: ['I', 'J'],
sub_material: ['A', 'B'],
consumable: ['B'],
};
const prefixList = prefixes[type] || ['A'];
const prefixIndex = seededInt(seed, 0, prefixList.length - 1);
const prefix = prefixList[prefixIndex];
return `${prefix}-${String(seededInt(seed + 1, 1, 10)).padStart(2, '0')}`;
}
// 원자재 데이터 (4개)
const rawMaterialItems: StockItem[] = [
{
id: 'rm-1',
itemCode: 'SCR-FABRIC-WHT-03T',
itemName: '스크린원단-백색-0.3T',
itemType: 'raw_material',
unit: 'm²',
stockQty: 500,
safetyStock: 100,
lotCount: 3,
lotDaysElapsed: 21,
status: 'normal',
location: 'A-01',
},
{
id: 'rm-2',
itemCode: 'SCR-FABRIC-GRY-03T',
itemName: '스크린원단-회색-0.3T',
itemType: 'raw_material',
unit: 'm²',
stockQty: 350,
safetyStock: 80,
lotCount: 2,
lotDaysElapsed: 15,
status: 'normal',
location: 'A-02',
},
{
id: 'rm-3',
itemCode: 'SCR-FABRIC-BLK-03T',
itemName: '스크린원단-흑색-0.3T',
itemType: 'raw_material',
unit: 'm²',
stockQty: 280,
safetyStock: 70,
lotCount: 2,
lotDaysElapsed: 18,
status: 'normal',
location: 'A-03',
},
{
id: 'rm-4',
itemCode: 'SCR-FABRIC-BEI-03T',
itemName: '스크린원단-베이지-0.3T',
itemType: 'raw_material',
unit: 'm²',
stockQty: 420,
safetyStock: 90,
lotCount: 4,
lotDaysElapsed: 12,
status: 'normal',
location: 'A-04',
},
];
// 절곡부품 데이터 (41개)
const bentPartItems: StockItem[] = Array.from({ length: 41 }, (_, i) => {
const types = ['브라켓', '지지대', '연결판', '고정판', '받침대', '가이드', '프레임'];
const variants = ['A', 'B', 'C', 'D', 'E', 'S', 'M', 'L', 'XL'];
const type = types[i % types.length];
const variant = variants[i % variants.length];
const seed = i * 100;
const stockQty = seededInt(seed, 50, 500);
const safetyStock = seededInt(seed + 1, 20, 100);
return {
id: `bp-${i + 1}`,
itemCode: `BENT-${type.toUpperCase().slice(0, 3)}-${variant}-${String(i + 1).padStart(2, '0')}`,
itemName: `${type}-${variant}형-${i + 1}`,
itemType: 'bent_part' as const,
unit: 'EA',
stockQty,
safetyStock,
lotCount: seededInt(seed + 2, 1, 5),
lotDaysElapsed: seededInt(seed + 3, 0, 45),
status: getStockStatus(stockQty, safetyStock),
location: generateLocation('bent_part', seed + 4),
};
});
// 구매부품 데이터 (80개)
const purchasedPartItems: StockItem[] = [
// 각파이프류 (20개)
...Array.from({ length: 20 }, (_, i) => {
const sizes = ['30×30', '40×40', '50×50', '60×60', '75×75'];
const lengths = ['3000', '4000', '5000', '6000'];
const size = sizes[i % sizes.length];
const length = lengths[i % lengths.length];
const seed = 1000 + i * 10;
const stockQty = seededInt(seed, 80, 300);
const safetyStock = seededInt(seed + 1, 30, 60);
return {
id: `pp-sqp-${i + 1}`,
itemCode: `SQP-${size.replace('×', '')}-${length.slice(0, 2)}`,
itemName: `각파이프 ${size} L:${length}`,
itemType: 'purchased_part' as const,
unit: 'EA',
stockQty,
safetyStock,
lotCount: seededInt(seed + 2, 2, 5),
lotDaysElapsed: seededInt(seed + 3, 0, 40),
status: getStockStatus(stockQty, safetyStock),
location: 'I-05',
};
}),
// 앵글류 (15개)
...Array.from({ length: 15 }, (_, i) => {
const sizes = ['50×50', '65×65', '75×75', '90×90', '100×100'];
const lengths = ['3000', '4000', '5000'];
const size = sizes[i % sizes.length];
const length = lengths[i % lengths.length];
const seed = 2000 + i * 10;
const stockQty = seededInt(seed, 60, 200);
const safetyStock = seededInt(seed + 1, 20, 50);
return {
id: `pp-ang-${i + 1}`,
itemCode: `ANG-${size.replace('×', '')}-${length.slice(0, 2)}`,
itemName: `앵글 ${size} L:${length}`,
itemType: 'purchased_part' as const,
unit: 'EA',
stockQty,
safetyStock,
lotCount: seededInt(seed + 2, 2, 4),
lotDaysElapsed: seededInt(seed + 3, 0, 35),
status: getStockStatus(stockQty, safetyStock),
location: 'I-04',
};
}),
// 전동개폐기류 (10개)
...Array.from({ length: 10 }, (_, i) => {
const weights = ['300KG', '500KG', '700KG', '1000KG'];
const voltages = ['110V', '220V', '380V'];
const types = ['유선', '무선'];
const weight = weights[i % weights.length];
const voltage = voltages[i % voltages.length];
const type = types[i % types.length];
const seed = 3000 + i * 10;
const stockQty = seededInt(seed, 10, 50);
const safetyStock = seededInt(seed + 1, 5, 15);
return {
id: `pp-motor-${i + 1}`,
itemCode: `MOTOR-${voltage}${weight}${type === '무선' ? '-W' : ''}`,
itemName: `전동개폐기-${voltage}${weight}${type}`,
itemType: 'purchased_part' as const,
unit: 'EA',
stockQty,
safetyStock,
lotCount: seededInt(seed + 2, 1, 3),
lotDaysElapsed: seededInt(seed + 3, 0, 30),
status: getStockStatus(stockQty, safetyStock),
location: 'I-01',
};
}),
// 볼트/너트류 (15개)
...Array.from({ length: 15 }, (_, i) => {
const sizes = ['M6', 'M8', 'M10', 'M12', 'M16'];
const lengths = ['20', '30', '40', '50', '60'];
const size = sizes[i % sizes.length];
const length = lengths[i % lengths.length];
const seed = 4000 + i * 10;
const stockQty = seededInt(seed, 500, 2000);
const safetyStock = seededInt(seed + 1, 100, 500);
return {
id: `pp-bolt-${i + 1}`,
itemCode: `BOLT-${size}-${length}`,
itemName: `볼트 ${size}×${length}mm`,
itemType: 'purchased_part' as const,
unit: 'EA',
stockQty,
safetyStock,
lotCount: seededInt(seed + 2, 3, 6),
lotDaysElapsed: seededInt(seed + 3, 0, 25),
status: getStockStatus(stockQty, safetyStock),
location: 'J-01',
};
}),
// 베어링류 (10개)
...Array.from({ length: 10 }, (_, i) => {
const types = ['6200', '6201', '6202', '6203', '6204', '6205', '6206', '6207', '6208', '6209'];
const type = types[i % types.length];
const seed = 5000 + i * 10;
const stockQty = seededInt(seed, 30, 150);
const safetyStock = seededInt(seed + 1, 10, 40);
return {
id: `pp-bearing-${i + 1}`,
itemCode: `BEARING-${type}`,
itemName: `베어링 ${type}`,
itemType: 'purchased_part' as const,
unit: 'EA',
stockQty,
safetyStock,
lotCount: seededInt(seed + 2, 2, 4),
lotDaysElapsed: seededInt(seed + 3, 0, 20),
status: getStockStatus(stockQty, safetyStock),
location: 'J-02',
};
}),
// 스프링류 (10개)
...Array.from({ length: 10 }, (_, i) => {
const types = ['인장', '압축', '토션'];
const sizes = ['S', 'M', 'L', 'XL'];
const type = types[i % types.length];
const size = sizes[i % sizes.length];
const seed = 6000 + i * 10;
const stockQty = seededInt(seed, 100, 400);
const safetyStock = seededInt(seed + 1, 30, 80);
return {
id: `pp-spring-${i + 1}`,
itemCode: `SPRING-${type.toUpperCase().slice(0, 2)}-${size}`,
itemName: `스프링-${type}-${size}`,
itemType: 'purchased_part' as const,
unit: 'EA',
stockQty,
safetyStock,
lotCount: seededInt(seed + 2, 2, 5),
lotDaysElapsed: seededInt(seed + 3, 0, 30),
status: getStockStatus(stockQty, safetyStock),
location: 'J-03',
};
}),
];
// 부자재 데이터 (7개)
const subMaterialItems: StockItem[] = [
{
id: 'sm-1',
itemCode: 'SEW-WHT',
itemName: '미싱실-백색',
itemType: 'sub_material',
unit: 'M',
stockQty: 5000,
safetyStock: 1000,
lotCount: 3,
lotDaysElapsed: 28,
status: 'normal',
location: 'A-04',
},
{
id: 'sm-2',
itemCode: 'ALU-BAR',
itemName: '하단바-알루미늄',
itemType: 'sub_material',
unit: 'EA',
stockQty: 120,
safetyStock: 30,
lotCount: 1,
lotDaysElapsed: 5,
status: 'normal',
location: 'A-03',
},
{
id: 'sm-3',
itemCode: 'END-CAP-STD',
itemName: '앤드락-표준',
itemType: 'sub_material',
unit: 'EA',
stockQty: 800,
safetyStock: 200,
lotCount: 2,
lotDaysElapsed: 12,
status: 'normal',
location: 'A-02',
},
{
id: 'sm-4',
itemCode: 'SILICON-TRANS',
itemName: '실리콘-투명',
itemType: 'sub_material',
unit: 'EA',
stockQty: 200,
safetyStock: 50,
lotCount: 5,
lotDaysElapsed: 37,
status: 'normal',
location: 'B-03',
},
{
id: 'sm-5',
itemCode: 'TAPE-DBL-25',
itemName: '양면테이프-25mm',
itemType: 'sub_material',
unit: 'EA',
stockQty: 150,
safetyStock: 40,
lotCount: 2,
lotDaysElapsed: 10,
status: 'normal',
location: 'B-02',
},
{
id: 'sm-6',
itemCode: 'RIVET-STL-4',
itemName: '리벳-스틸-4mm',
itemType: 'sub_material',
unit: 'EA',
stockQty: 3000,
safetyStock: 500,
lotCount: 4,
lotDaysElapsed: 8,
status: 'normal',
location: 'B-01',
},
{
id: 'sm-7',
itemCode: 'WASHER-M8',
itemName: '와셔-M8',
itemType: 'sub_material',
unit: 'EA',
stockQty: 2500,
safetyStock: 400,
lotCount: 3,
lotDaysElapsed: 15,
status: 'normal',
location: 'B-04',
},
];
// 소모품 데이터 (2개)
const consumableItems: StockItem[] = [
{
id: 'cs-1',
itemCode: 'PKG-BOX-L',
itemName: '포장박스-대형',
itemType: 'consumable',
unit: 'EA',
stockQty: 200,
safetyStock: 50,
lotCount: 2,
lotDaysElapsed: 8,
status: 'normal',
location: 'B-01',
},
{
id: 'cs-2',
itemCode: 'PKG-BOX-M',
itemName: '포장박스-중형',
itemType: 'consumable',
unit: 'EA',
stockQty: 350,
safetyStock: 80,
lotCount: 3,
lotDaysElapsed: 5,
status: 'normal',
location: 'B-02',
},
];
// 재고 목록 Mock 데이터 (134개)
export const mockStockItems: StockItem[] = [
...rawMaterialItems, // 4개
...bentPartItems, // 41개
...purchasedPartItems, // 80개
...subMaterialItems, // 7개
...consumableItems, // 2개
];
// ID로 아이템 찾기 헬퍼
export function findStockItemById(id: string): StockItem | undefined {
return mockStockItems.find(item => item.id === id);
}
// 재고 상세 Mock 데이터 생성 함수
export function generateStockDetail(item: StockItem): StockDetail {
const suppliers = ['포스코', '현대제철', '동국제강', '세아제강', '한국철강', '삼성물산'];
const locations = ['A-01', 'A-02', 'B-01', 'B-02', 'C-01'];
// 시드 기반으로 일관된 LOT 데이터 생성
const itemSeed = parseInt(item.id.replace(/\D/g, '') || '0', 10);
// LOT 데이터 생성
const lots = Array.from({ length: item.lotCount }, (_, i) => {
const lotSeed = itemSeed * 1000 + i * 100;
const daysAgo = seededInt(lotSeed, 5, 60);
const date = new Date('2025-12-23'); // 고정 날짜 사용
date.setDate(date.getDate() - daysAgo);
const dateStr = date.toISOString().split('T')[0];
const lotDate = dateStr.replace(/-/g, '').slice(2);
return {
id: `lot-${item.id}-${i + 1}`,
fifoOrder: i + 1,
lotNo: `${lotDate}-${String(i + 1).padStart(2, '0')}`,
receiptDate: dateStr,
daysElapsed: daysAgo,
supplier: suppliers[seededInt(lotSeed + 1, 0, suppliers.length - 1)],
poNumber: `PO-${lotDate}-${String(seededInt(lotSeed + 2, 1, 99)).padStart(2, '0')}`,
qty: Math.floor(item.stockQty / item.lotCount) + (i === 0 ? item.stockQty % item.lotCount : 0),
unit: item.unit,
location: locations[seededInt(lotSeed + 3, 0, locations.length - 1)],
status: 'available' as const,
};
});
// 카테고리 추출
const categoryMap: Record<string, string> = {
'SQP': '각파이프',
'ANG': '앵글',
'MOTOR': '전동개폐기',
'BOLT': '볼트',
'BEARING': '베어링',
'SPRING': '스프링',
'BENT': '절곡부품',
'SCR': '스크린원단',
'SEW': '미싱실',
'ALU': '알루미늄바',
'END': '앤드락',
'SILICON': '실리콘',
'TAPE': '테이프',
'RIVET': '리벳',
'WASHER': '와셔',
'PKG': '포장재',
};
const prefix = item.itemCode.split('-')[0];
const category = categoryMap[prefix] || '기타';
return {
id: item.id,
itemCode: item.itemCode,
itemName: item.itemName,
itemType: item.itemType,
category,
specification: '-',
unit: item.unit,
currentStock: item.stockQty,
safetyStock: item.safetyStock,
location: item.location,
lotCount: item.lotCount,
lastReceiptDate: lots[lots.length - 1]?.receiptDate || '2025-12-23',
status: item.status,
lots,
};
}
// 재고 상세 Mock 데이터 (동적 생성)
export const mockStockDetails: Record<string, StockDetail> = {};
// 상세 데이터 가져오기 함수
export function getStockDetail(id: string): StockDetail | undefined {
// 캐시된 데이터가 있으면 반환
if (mockStockDetails[id]) {
return mockStockDetails[id];
}
// 없으면 생성
const item = findStockItemById(id);
if (item) {
mockStockDetails[id] = generateStockDetail(item);
return mockStockDetails[id];
}
return undefined;
}
// 통계 데이터 계산
const calculateStats = (): StockStats => {
const normalCount = mockStockItems.filter(item => item.status === 'normal').length;
const lowCount = mockStockItems.filter(item => item.status === 'low').length;
const outCount = mockStockItems.filter(item => item.status === 'out').length;
return {
totalItems: mockStockItems.length,
normalCount,
lowCount,
outCount,
};
};
export const mockStats: StockStats = calculateStats();
// 필터 탭 데이터 계산
const calculateFilterTabs = (): FilterTab[] => {
const rawMaterialCount = mockStockItems.filter(item => item.itemType === 'raw_material').length;
const bentPartCount = mockStockItems.filter(item => item.itemType === 'bent_part').length;
const purchasedPartCount = mockStockItems.filter(item => item.itemType === 'purchased_part').length;
const subMaterialCount = mockStockItems.filter(item => item.itemType === 'sub_material').length;
const consumableCount = mockStockItems.filter(item => item.itemType === 'consumable').length;
return [
{ key: 'all', label: '전체', count: mockStockItems.length },
{ key: 'raw_material', label: '원자재', count: rawMaterialCount },
{ key: 'bent_part', label: '절곡부품', count: bentPartCount },
{ key: 'purchased_part', label: '구매부품', count: purchasedPartCount },
{ key: 'sub_material', label: '부자재', count: subMaterialCount },
{ key: 'consumable', label: '소모품', count: consumableCount },
];
};
export const mockFilterTabs: FilterTab[] = calculateFilterTabs();

View File

@@ -0,0 +1,111 @@
/**
* 재고현황 타입 정의
*/
// 품목유형
export type ItemType = 'raw_material' | 'bent_part' | 'purchased_part' | 'sub_material' | 'consumable';
// 품목유형 라벨
export const ITEM_TYPE_LABELS: Record<ItemType, string> = {
raw_material: '원자재',
bent_part: '절곡부품',
purchased_part: '구매부품',
sub_material: '부자재',
consumable: '소모품',
};
// 품목유형 스타일 (뱃지용)
export const ITEM_TYPE_STYLES: Record<ItemType, string> = {
raw_material: 'bg-blue-100 text-blue-800',
bent_part: 'bg-purple-100 text-purple-800',
purchased_part: 'bg-gray-100 text-gray-800',
sub_material: 'bg-green-100 text-green-800',
consumable: 'bg-orange-100 text-orange-800',
};
// 재고 상태
export type StockStatusType = 'normal' | 'low' | 'out';
// 재고 상태 라벨
export const STOCK_STATUS_LABELS: Record<StockStatusType, string> = {
normal: '정상',
low: '부족',
out: '없음',
};
// LOT 상태
export type LotStatusType = 'available' | 'reserved' | 'used';
export const LOT_STATUS_LABELS: Record<LotStatusType, string> = {
available: '사용가능',
reserved: '예약됨',
used: '사용완료',
};
// 재고 목록 아이템
export interface StockItem {
id: string;
itemCode: string; // 품목코드
itemName: string; // 품목명
itemType: ItemType; // 품목유형
unit: string; // 단위 (EA, M, m² 등)
stockQty: number; // 재고량
safetyStock: number; // 안전재고
lotCount: number; // LOT 개수
lotDaysElapsed: number; // 경과일 (가장 오래된 LOT 기준)
status: StockStatusType; // 상태
location: string; // 위치
}
// LOT별 상세 재고
export interface LotDetail {
id: string;
fifoOrder: number; // FIFO 순서
lotNo: string; // LOT번호
receiptDate: string; // 입고일
daysElapsed: number; // 경과일
supplier: string; // 공급업체
poNumber: string; // 발주번호
qty: number; // 수량
unit: string; // 단위
location: string; // 위치
status: LotStatusType; // 상태
}
// 재고 상세 정보
export interface StockDetail {
// 기본 정보
id: string;
itemCode: string; // 품목코드
itemName: string; // 품목명
itemType: ItemType; // 품목유형
category: string; // 카테고리
specification: string; // 규격
unit: string; // 단위
// 재고 현황
currentStock: number; // 현재 재고량
safetyStock: number; // 안전 재고
location: string; // 재고 위치
lotCount: number; // LOT 개수
lastReceiptDate: string; // 최근 입고일
status: StockStatusType; // 재고 상태
// LOT별 상세 재고
lots: LotDetail[];
}
// 통계 데이터
export interface StockStats {
totalItems: number; // 전체 품목 수
normalCount: number; // 정상 재고 수
lowCount: number; // 재고 부족 수
outCount: number; // 재고 없음 수
}
// 필터 탭
export interface FilterTab {
key: 'all' | ItemType;
label: string;
count: number;
}