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:
323
src/components/material/ReceivingManagement/InspectionCreate.tsx
Normal file
323
src/components/material/ReceivingManagement/InspectionCreate.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 수입검사 등록 (IQC) 페이지
|
||||
* - 검사 대상 선택
|
||||
* - 검사 정보 입력 (검사일, 검사자*, LOT번호)
|
||||
* - 검사 항목 테이블 (겉모양, 두께, 폭, 길이)
|
||||
* - 종합 의견
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ClipboardCheck, Calendar } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { mockInspectionTargets, defaultInspectionItems, generateLotNo } from './mockData';
|
||||
import type { InspectionCheckItem } from './types';
|
||||
import { SuccessDialog } from './SuccessDialog';
|
||||
|
||||
interface Props {
|
||||
id?: string; // 특정 발주건으로 바로 진입하는 경우
|
||||
}
|
||||
|
||||
export function InspectionCreate({ id }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
// 선택된 검사 대상
|
||||
const [selectedTargetId, setSelectedTargetId] = useState<string>(
|
||||
id || mockInspectionTargets[2]?.id || ''
|
||||
);
|
||||
|
||||
// 검사 정보
|
||||
const [inspectionDate, setInspectionDate] = useState(() => {
|
||||
const today = new Date();
|
||||
return today.toISOString().split('T')[0];
|
||||
});
|
||||
const [inspector, setInspector] = useState('');
|
||||
const [lotNo, setLotNo] = useState(() => generateLotNo());
|
||||
|
||||
// 검사 항목
|
||||
const [inspectionItems, setInspectionItems] = useState<InspectionCheckItem[]>(
|
||||
defaultInspectionItems.map((item) => ({ ...item }))
|
||||
);
|
||||
|
||||
// 종합 의견
|
||||
const [opinion, setOpinion] = useState('');
|
||||
|
||||
// 유효성 검사 에러
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
|
||||
// 성공 다이얼로그
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
|
||||
// 선택된 대상 정보
|
||||
const selectedTarget = useMemo(() => {
|
||||
return mockInspectionTargets.find((t) => t.id === selectedTargetId);
|
||||
}, [selectedTargetId]);
|
||||
|
||||
// 대상 선택 핸들러
|
||||
const handleTargetSelect = useCallback((targetId: string) => {
|
||||
setSelectedTargetId(targetId);
|
||||
setValidationErrors([]);
|
||||
}, []);
|
||||
|
||||
// 판정 변경 핸들러
|
||||
const handleJudgmentChange = useCallback((itemId: string, judgment: '적' | '부적') => {
|
||||
setInspectionItems((prev) =>
|
||||
prev.map((item) => (item.id === itemId ? { ...item, judgment } : item))
|
||||
);
|
||||
setValidationErrors([]);
|
||||
}, []);
|
||||
|
||||
// 비고 변경 핸들러
|
||||
const handleRemarkChange = useCallback((itemId: string, remark: string) => {
|
||||
setInspectionItems((prev) =>
|
||||
prev.map((item) => (item.id === itemId ? { ...item, remark } : item))
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 유효성 검사
|
||||
const validateForm = useCallback((): boolean => {
|
||||
const errors: string[] = [];
|
||||
|
||||
// 필수 필드: 검사자
|
||||
if (!inspector.trim()) {
|
||||
errors.push('검사자는 필수 입력 항목입니다.');
|
||||
}
|
||||
|
||||
// 검사 항목 판정 확인
|
||||
inspectionItems.forEach((item, index) => {
|
||||
if (!item.judgment) {
|
||||
errors.push(`${index + 1}. ${item.name}: 판정을 선택해주세요.`);
|
||||
}
|
||||
});
|
||||
|
||||
setValidationErrors(errors);
|
||||
return errors.length === 0;
|
||||
}, [inspector, inspectionItems]);
|
||||
|
||||
// 검사 저장
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: API 호출
|
||||
console.log('검사 저장:', {
|
||||
targetId: selectedTargetId,
|
||||
inspectionDate,
|
||||
inspector,
|
||||
lotNo,
|
||||
items: inspectionItems,
|
||||
opinion,
|
||||
});
|
||||
|
||||
setShowSuccess(true);
|
||||
}, [validateForm, selectedTargetId, inspectionDate, inspector, lotNo, inspectionItems, opinion]);
|
||||
|
||||
// 취소 - 목록으로
|
||||
const handleCancel = useCallback(() => {
|
||||
router.push('/ko/material/receiving-management');
|
||||
}, [router]);
|
||||
|
||||
// 성공 다이얼로그 닫기
|
||||
const handleSuccessClose = useCallback(() => {
|
||||
setShowSuccess(false);
|
||||
router.push('/ko/material/receiving-management');
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<ClipboardCheck className="w-6 h-6" />
|
||||
<h1 className="text-xl font-semibold">수입검사 등록 (IQC)</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>
|
||||
<ClipboardCheck className="w-4 h-4 mr-1.5" />
|
||||
검사 저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* 좌측: 검사 대상 선택 */}
|
||||
<div className="lg:col-span-1 space-y-2">
|
||||
<Label className="text-sm font-medium">검사 대상 선택</Label>
|
||||
<div className="space-y-2 border rounded-lg p-2 bg-white">
|
||||
{mockInspectionTargets.map((target) => (
|
||||
<div
|
||||
key={target.id}
|
||||
onClick={() => handleTargetSelect(target.id)}
|
||||
className={`p-3 rounded-lg cursor-pointer border transition-colors ${
|
||||
selectedTargetId === target.id
|
||||
? 'bg-blue-50 border-blue-300'
|
||||
: 'bg-white border-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<p className="font-medium text-sm">{target.orderNo}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{target.supplier} · {target.qty}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 검사 정보 및 항목 */}
|
||||
<div className="lg:col-span-3 space-y-6">
|
||||
{/* Validation 에러 표시 */}
|
||||
{validationErrors.length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({validationErrors.length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{validationErrors.map((error, index) => (
|
||||
<li key={index} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>{error}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 검사 정보 */}
|
||||
<div className="space-y-4 bg-white p-4 rounded-lg border">
|
||||
<h3 className="font-medium">검사 정보</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">검사일</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="date"
|
||||
value={inspectionDate}
|
||||
onChange={(e) => setInspectionDate(e.target.value)}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Calendar className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
검사자 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={inspector}
|
||||
onChange={(e) => {
|
||||
setInspector(e.target.value);
|
||||
setValidationErrors([]);
|
||||
}}
|
||||
placeholder="검사자명 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">LOT번호 (YYMMDD-##)</Label>
|
||||
<Input value={lotNo} onChange={(e) => setLotNo(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검사 항목 */}
|
||||
<div className="space-y-4 bg-white p-4 rounded-lg border">
|
||||
<h3 className="font-medium">검사 항목</h3>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium">검사항목</th>
|
||||
<th className="px-3 py-2 text-left font-medium">규격</th>
|
||||
<th className="px-3 py-2 text-left font-medium">검사방법</th>
|
||||
<th className="px-3 py-2 text-center font-medium w-[100px]">판정</th>
|
||||
<th className="px-3 py-2 text-left font-medium w-[120px]">비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{inspectionItems.map((item) => (
|
||||
<tr key={item.id} className="border-t">
|
||||
<td className="px-3 py-2">{item.name}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-xs">
|
||||
{item.specification}
|
||||
</td>
|
||||
<td className="px-3 py-2">{item.method}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Select
|
||||
value={item.judgment || ''}
|
||||
onValueChange={(value) =>
|
||||
handleJudgmentChange(item.id, value as '적' | '부적')
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="적">적</SelectItem>
|
||||
<SelectItem value="부적">부적</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Input
|
||||
value={item.remark || ''}
|
||||
onChange={(e) => handleRemarkChange(item.id, e.target.value)}
|
||||
placeholder="비고"
|
||||
className="h-8"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 종합 의견 */}
|
||||
<div className="space-y-2 bg-white p-4 rounded-lg border">
|
||||
<Label className="text-sm font-medium">종합 의견</Label>
|
||||
<Textarea
|
||||
value={opinion}
|
||||
onChange={(e) => setOpinion(e.target.value)}
|
||||
placeholder="검사 관련 특이사항 입력"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 성공 다이얼로그 */}
|
||||
<SuccessDialog
|
||||
open={showSuccess}
|
||||
type="inspection"
|
||||
lotNo={lotNo}
|
||||
onClose={handleSuccessClose}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
281
src/components/material/ReceivingManagement/ReceivingDetail.tsx
Normal file
281
src/components/material/ReceivingManagement/ReceivingDetail.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 입고 상세 페이지
|
||||
* 상태에 따라 다른 UI 표시:
|
||||
* - 검사대기: 입고증, 목록, 검사등록 버튼
|
||||
* - 배송중/발주완료: 목록, 입고처리 버튼 (입고증 없음)
|
||||
* - 입고대기: 목록 버튼만 (입고증 없음)
|
||||
* - 입고완료: 입고증, 목록 버튼
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Package, FileText, List, ClipboardCheck, Download } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { mockReceivingItems, mockReceivingDetails } from './mockData';
|
||||
import { RECEIVING_STATUS_LABELS, RECEIVING_STATUS_STYLES } from './types';
|
||||
import type { ReceivingDetail as ReceivingDetailType } from './types';
|
||||
import { ReceivingProcessDialog } from './ReceivingProcessDialog';
|
||||
import { ReceivingReceiptDialog } from './ReceivingReceiptDialog';
|
||||
import { SuccessDialog } from './SuccessDialog';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function ReceivingDetail({ id }: Props) {
|
||||
const router = useRouter();
|
||||
const [isReceivingProcessDialogOpen, setIsReceivingProcessDialogOpen] = useState(false);
|
||||
const [isReceiptDialogOpen, setIsReceiptDialogOpen] = useState(false);
|
||||
const [successDialog, setSuccessDialog] = useState<{
|
||||
open: boolean;
|
||||
type: 'inspection' | 'receiving';
|
||||
lotNo?: string;
|
||||
}>({ open: false, type: 'receiving' });
|
||||
|
||||
// 데이터 가져오기
|
||||
const detail: ReceivingDetailType | undefined = useMemo(() => {
|
||||
// 먼저 상세 데이터에서 찾기
|
||||
if (mockReceivingDetails[id]) {
|
||||
return mockReceivingDetails[id];
|
||||
}
|
||||
// 없으면 목록에서 변환
|
||||
const item = mockReceivingItems.find((i) => i.id === id);
|
||||
if (item) {
|
||||
return {
|
||||
id: item.id,
|
||||
orderNo: item.orderNo,
|
||||
orderDate: undefined,
|
||||
supplier: item.supplier,
|
||||
itemCode: item.itemCode,
|
||||
itemName: item.itemName,
|
||||
specification: undefined,
|
||||
orderQty: item.orderQty,
|
||||
orderUnit: item.orderUnit,
|
||||
dueDate: undefined,
|
||||
status: item.status,
|
||||
receivingDate: undefined,
|
||||
receivingQty: item.receivingQty,
|
||||
receivingLot: item.lotNo,
|
||||
supplierLot: undefined,
|
||||
receivingLocation: undefined,
|
||||
receivingManager: undefined,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, [id]);
|
||||
|
||||
// 목록으로 돌아가기
|
||||
const handleGoBack = useCallback(() => {
|
||||
router.push('/ko/material/receiving-management');
|
||||
}, [router]);
|
||||
|
||||
// 입고증 다이얼로그 열기
|
||||
const handleOpenReceipt = useCallback(() => {
|
||||
setIsReceiptDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
// 검사등록 페이지로 이동
|
||||
const handleGoToInspection = useCallback(() => {
|
||||
router.push('/ko/material/receiving-management/inspection');
|
||||
}, [router]);
|
||||
|
||||
// 입고처리 다이얼로그 열기
|
||||
const handleOpenReceivingProcessDialog = useCallback(() => {
|
||||
setIsReceivingProcessDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
// 입고 완료 처리
|
||||
const handleReceivingComplete = useCallback((lotNo: string) => {
|
||||
setIsReceivingProcessDialogOpen(false);
|
||||
setSuccessDialog({ open: true, type: 'receiving', lotNo });
|
||||
}, []);
|
||||
|
||||
// 성공 다이얼로그 닫기
|
||||
const handleSuccessDialogClose = useCallback(() => {
|
||||
setSuccessDialog({ open: false, type: 'receiving' });
|
||||
router.push('/ko/material/receiving-management');
|
||||
}, [router]);
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-muted-foreground">데이터를 찾을 수 없습니다.</p>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// 상태별 버튼 구성
|
||||
// 검사등록 버튼: 검사대기만
|
||||
const showInspectionButton = detail.status === 'inspection_pending';
|
||||
// 입고처리 버튼: 발주완료, 배송중만
|
||||
const showReceivingProcessButton =
|
||||
detail.status === 'order_completed' || detail.status === 'shipping';
|
||||
// 입고증 버튼: 검사대기, 입고완료만 (입고대기, 발주완료, 배송중은 없음)
|
||||
const showReceiptButton =
|
||||
detail.status === 'inspection_pending' || detail.status === 'completed';
|
||||
|
||||
// 입고 정보 표시 여부: 검사대기, 입고대기, 입고완료
|
||||
const showReceivingInfo =
|
||||
detail.status === 'inspection_pending' ||
|
||||
detail.status === 'receiving_pending' ||
|
||||
detail.status === 'completed';
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm: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-lg text-muted-foreground">{detail.orderNo}</span>
|
||||
<Badge className={`${RECEIVING_STATUS_STYLES[detail.status]}`}>
|
||||
{RECEIVING_STATUS_LABELS[detail.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{showReceiptButton && (
|
||||
<Button variant="outline" onClick={handleOpenReceipt}>
|
||||
<FileText className="w-4 h-4 mr-1.5" />
|
||||
입고증
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={handleGoBack}>
|
||||
<List className="w-4 h-4 mr-1.5" />
|
||||
목록
|
||||
</Button>
|
||||
{showInspectionButton && (
|
||||
<Button onClick={handleGoToInspection}>
|
||||
<ClipboardCheck className="w-4 h-4 mr-1.5" />
|
||||
검사등록
|
||||
</Button>
|
||||
)}
|
||||
{showReceivingProcessButton && (
|
||||
<Button onClick={handleOpenReceivingProcessDialog}>
|
||||
<Download className="w-4 h-4 mr-1.5" />
|
||||
입고처리
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 발주 정보 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base">발주 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">발주번호</p>
|
||||
<p className="font-medium">{detail.orderNo}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">발주일자</p>
|
||||
<p className="font-medium">{detail.orderDate || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">공급업체</p>
|
||||
<p className="font-medium">{detail.supplier}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">품목코드</p>
|
||||
<p className="font-medium">{detail.itemCode}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">품목명</p>
|
||||
<p className="font-medium">{detail.itemName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">규격</p>
|
||||
<p className="font-medium">{detail.specification || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">발주수량</p>
|
||||
<p className="font-medium">
|
||||
{detail.orderQty} {detail.orderUnit}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">납기일</p>
|
||||
<p className="font-medium">{detail.dueDate || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 입고 정보 - 검사대기/입고대기/입고완료 상태에서만 표시 */}
|
||||
{showReceivingInfo && (
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base">입고 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">입고일자</p>
|
||||
<p className="font-medium">{detail.receivingDate || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">입고수량</p>
|
||||
<p className="font-medium">
|
||||
{detail.receivingQty !== undefined
|
||||
? `${detail.receivingQty} ${detail.orderUnit}`
|
||||
: '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">입고LOT</p>
|
||||
<p className="font-medium">{detail.receivingLot || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">공급업체LOT</p>
|
||||
<p className="font-medium">{detail.supplierLot || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">입고위치</p>
|
||||
<p className="font-medium">{detail.receivingLocation || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">입고담당</p>
|
||||
<p className="font-medium">{detail.receivingManager || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 입고증 다이얼로그 */}
|
||||
<ReceivingReceiptDialog
|
||||
open={isReceiptDialogOpen}
|
||||
onOpenChange={setIsReceiptDialogOpen}
|
||||
detail={detail}
|
||||
/>
|
||||
|
||||
{/* 입고처리 다이얼로그 */}
|
||||
<ReceivingProcessDialog
|
||||
open={isReceivingProcessDialogOpen}
|
||||
onOpenChange={setIsReceivingProcessDialogOpen}
|
||||
detail={detail}
|
||||
onComplete={handleReceivingComplete}
|
||||
/>
|
||||
|
||||
{/* 성공 다이얼로그 */}
|
||||
<SuccessDialog
|
||||
open={successDialog.open}
|
||||
type={successDialog.type}
|
||||
lotNo={successDialog.lotNo}
|
||||
onClose={handleSuccessDialogClose}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
313
src/components/material/ReceivingManagement/ReceivingList.tsx
Normal file
313
src/components/material/ReceivingManagement/ReceivingList.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 입고 목록 페이지
|
||||
* IntegratedListTemplateV2 패턴 적용
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Package,
|
||||
Truck,
|
||||
ClipboardCheck,
|
||||
Calendar,
|
||||
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 { mockReceivingItems, mockStats, mockFilterTabs } from './mockData';
|
||||
import { RECEIVING_STATUS_LABELS, RECEIVING_STATUS_STYLES } from './types';
|
||||
import type { ReceivingItem, ReceivingStatus } from './types';
|
||||
|
||||
// 페이지당 항목 수
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
export function ReceivingList() {
|
||||
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' | 'receiving_pending' | 'completed'>('all');
|
||||
|
||||
// 통계 카드
|
||||
const stats: StatCard[] = [
|
||||
{
|
||||
label: '입고대기',
|
||||
value: `${mockStats.receivingPendingCount}건`,
|
||||
icon: Package,
|
||||
iconColor: 'text-yellow-600',
|
||||
},
|
||||
{
|
||||
label: '배송중',
|
||||
value: `${mockStats.shippingCount}건`,
|
||||
icon: Truck,
|
||||
iconColor: 'text-blue-600',
|
||||
},
|
||||
{
|
||||
label: '검사대기',
|
||||
value: `${mockStats.inspectionPendingCount}건`,
|
||||
icon: ClipboardCheck,
|
||||
iconColor: 'text-orange-600',
|
||||
},
|
||||
{
|
||||
label: '금일입고',
|
||||
value: `${mockStats.todayReceivingCount}건`,
|
||||
icon: Calendar,
|
||||
iconColor: 'text-green-600',
|
||||
},
|
||||
];
|
||||
|
||||
// 테이블 컬럼
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'orderNo', label: '발주번호', className: 'min-w-[150px]' },
|
||||
{ key: 'itemCode', label: '품목코드', className: 'min-w-[130px]' },
|
||||
{ key: 'itemName', label: '품목명', className: 'min-w-[150px]' },
|
||||
{ key: 'supplier', label: '공급업체', className: 'min-w-[100px]' },
|
||||
{ key: 'orderQty', label: '발주수량', className: 'w-[100px] text-center' },
|
||||
{ key: 'receivingQty', label: '입고수량', className: 'w-[100px] text-center' },
|
||||
{ key: 'lotNo', label: 'LOT번호', className: 'w-[120px] text-center' },
|
||||
{ key: 'status', label: '상태', className: 'w-[100px] text-center' },
|
||||
];
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredResults = useMemo(() => {
|
||||
let result = [...mockReceivingItems];
|
||||
|
||||
// 탭 필터
|
||||
if (activeFilter === 'receiving_pending') {
|
||||
result = result.filter((item) =>
|
||||
['receiving_pending', 'inspection_pending'].includes(item.status)
|
||||
);
|
||||
} else if (activeFilter === 'completed') {
|
||||
result = result.filter((item) => item.status === 'completed');
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
result = result.filter(
|
||||
(item) =>
|
||||
item.orderNo.toLowerCase().includes(term) ||
|
||||
item.itemCode.toLowerCase().includes(term) ||
|
||||
item.itemName.toLowerCase().includes(term) ||
|
||||
item.supplier.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' | 'receiving_pending' | 'completed');
|
||||
setCurrentPage(1);
|
||||
setSelectedItems(new Set());
|
||||
};
|
||||
|
||||
// 상세 보기
|
||||
const handleView = useCallback(
|
||||
(id: string) => {
|
||||
router.push(`/ko/material/receiving-management/${id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = (item: ReceivingItem, 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.orderNo}</TableCell>
|
||||
<TableCell>{item.itemCode}</TableCell>
|
||||
<TableCell className="max-w-[150px] truncate">{item.itemName}</TableCell>
|
||||
<TableCell>{item.supplier}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.orderQty} {item.orderUnit}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.receivingQty !== undefined ? item.receivingQty : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.lotNo || '-'}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={`text-xs ${RECEIVING_STATUS_STYLES[item.status]}`}>
|
||||
{RECEIVING_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = (
|
||||
item: ReceivingItem,
|
||||
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.orderNo}
|
||||
</Badge>
|
||||
</>
|
||||
}
|
||||
title={item.itemName}
|
||||
statusBadge={
|
||||
<Badge className={`text-xs ${RECEIVING_STATUS_STYLES[item.status]}`}>
|
||||
{RECEIVING_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="품목코드" value={item.itemCode} />
|
||||
<InfoField label="공급업체" value={item.supplier} />
|
||||
<InfoField label="발주수량" value={`${item.orderQty} ${item.orderUnit}`} />
|
||||
<InfoField
|
||||
label="입고수량"
|
||||
value={item.receivingQty !== undefined ? `${item.receivingQty}` : '-'}
|
||||
/>
|
||||
<InfoField label="LOT번호" value={item.lotNo || '-'} />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 탭 옵션
|
||||
const tabs: TabOption[] = mockFilterTabs.map((tab) => ({
|
||||
value: tab.key,
|
||||
label: tab.label,
|
||||
count: tab.count,
|
||||
}));
|
||||
|
||||
// 하단 요약
|
||||
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}건 / 입고대기 {mockStats.receivingPendingCount}건 / 검사대기{' '}
|
||||
{mockStats.inspectionPendingCount}건
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
return (
|
||||
<IntegratedListTemplateV2<ReceivingItem>
|
||||
title="입고 목록"
|
||||
icon={Package}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 입고처리 다이얼로그
|
||||
* - 발주 정보 표시
|
||||
* - 입고LOT*, 공급업체LOT, 입고수량*, 입고위치* 입력
|
||||
* - 비고 입력
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Download } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { generateLotNo } from './mockData';
|
||||
import type { ReceivingDetail } from './types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
detail: ReceivingDetail;
|
||||
onComplete: (lotNo: string) => void;
|
||||
}
|
||||
|
||||
export function ReceivingProcessDialog({ open, onOpenChange, detail, onComplete }: Props) {
|
||||
// 폼 데이터
|
||||
const [receivingLot, setReceivingLot] = useState(() => generateLotNo());
|
||||
const [supplierLot, setSupplierLot] = useState('');
|
||||
const [receivingQty, setReceivingQty] = useState<string>(detail.orderQty.toString());
|
||||
const [receivingLocation, setReceivingLocation] = useState('');
|
||||
const [remark, setRemark] = useState('');
|
||||
|
||||
// 유효성 검사 에러
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
|
||||
// 유효성 검사
|
||||
const validateForm = useCallback((): boolean => {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!receivingLot.trim()) {
|
||||
errors.push('입고LOT는 필수 입력 항목입니다.');
|
||||
}
|
||||
|
||||
if (!receivingQty.trim() || isNaN(Number(receivingQty)) || Number(receivingQty) <= 0) {
|
||||
errors.push('입고수량은 필수 입력 항목입니다. 유효한 숫자를 입력해주세요.');
|
||||
}
|
||||
|
||||
if (!receivingLocation.trim()) {
|
||||
errors.push('입고위치는 필수 입력 항목입니다.');
|
||||
}
|
||||
|
||||
setValidationErrors(errors);
|
||||
return errors.length === 0;
|
||||
}, [receivingLot, receivingQty, receivingLocation]);
|
||||
|
||||
// 입고 처리
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: API 호출
|
||||
console.log('입고 처리:', {
|
||||
detailId: detail.id,
|
||||
receivingLot,
|
||||
supplierLot,
|
||||
receivingQty: Number(receivingQty),
|
||||
receivingLocation,
|
||||
remark,
|
||||
});
|
||||
|
||||
onComplete(receivingLot);
|
||||
}, [validateForm, detail.id, receivingLot, supplierLot, receivingQty, receivingLocation, remark, onComplete]);
|
||||
|
||||
// 취소
|
||||
const handleCancel = useCallback(() => {
|
||||
onOpenChange(false);
|
||||
}, [onOpenChange]);
|
||||
|
||||
// 다이얼로그 닫힐 때 상태 초기화
|
||||
const handleOpenChange = useCallback(
|
||||
(newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
setValidationErrors([]);
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
},
|
||||
[onOpenChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>입고 처리</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 mt-4">
|
||||
{/* 발주 정보 요약 */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">발주번호:</span>{' '}
|
||||
<span className="font-medium">{detail.orderNo}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">공급업체:</span>{' '}
|
||||
<span className="font-medium">{detail.supplier}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">품목:</span>{' '}
|
||||
<span className="font-medium">{detail.itemName}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">발주수량:</span>{' '}
|
||||
<span className="font-medium">{detail.orderQty} {detail.orderUnit}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation 에러 표시 */}
|
||||
{validationErrors.length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({validationErrors.length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{validationErrors.map((error, index) => (
|
||||
<li key={index} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>{error}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 입력 필드 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">
|
||||
입고LOT <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={receivingLot}
|
||||
onChange={(e) => {
|
||||
setReceivingLot(e.target.value);
|
||||
setValidationErrors([]);
|
||||
}}
|
||||
placeholder="예: 251223-41"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">공급업체LOT</Label>
|
||||
<Input
|
||||
value={supplierLot}
|
||||
onChange={(e) => setSupplierLot(e.target.value)}
|
||||
placeholder="예: 2402944"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">
|
||||
입고수량 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={receivingQty}
|
||||
onChange={(e) => {
|
||||
setReceivingQty(e.target.value);
|
||||
setValidationErrors([]);
|
||||
}}
|
||||
placeholder="수량 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">
|
||||
입고위치 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={receivingLocation}
|
||||
onChange={(e) => {
|
||||
setReceivingLocation(e.target.value);
|
||||
setValidationErrors([]);
|
||||
}}
|
||||
placeholder="예: A-01"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">비고</Label>
|
||||
<Textarea
|
||||
value={remark}
|
||||
onChange={(e) => setRemark(e.target.value)}
|
||||
placeholder="특이사항 입력"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex justify-end gap-2 mt-6 pt-4 border-t">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>
|
||||
입고 처리
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 입고증 다이얼로그 (인쇄용)
|
||||
* - 작업일지(WorkLogModal) 스타일 적용
|
||||
*/
|
||||
|
||||
import { Printer, Download, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
|
||||
import type { ReceivingDetail } from './types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
detail: ReceivingDetail;
|
||||
}
|
||||
|
||||
export function ReceivingReceiptDialog({ open, onOpenChange, detail }: Props) {
|
||||
const handlePrint = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
// TODO: PDF 다운로드 기능
|
||||
console.log('PDF 다운로드:', detail);
|
||||
};
|
||||
|
||||
const today = new Date();
|
||||
const formattedDate = `${today.getFullYear()}년 ${today.getMonth() + 1}월 ${today.getDate()}일`;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-y-auto p-0 gap-0 bg-gray-100">
|
||||
{/* 접근성을 위한 숨겨진 타이틀 */}
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>입고증 - {detail.orderNo}</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
|
||||
{/* 모달 헤더 - 작업일지 스타일 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b bg-white sticky top-0 z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-semibold text-lg">입고증</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{detail.supplier}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({detail.orderNo})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||
<Printer className="w-4 h-4 mr-1.5" />
|
||||
인쇄
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleDownload}>
|
||||
<Download className="w-4 h-4 mr-1.5" />
|
||||
다운로드
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 문서 본문 */}
|
||||
<div className="m-6 p-8 bg-white rounded-lg shadow-sm print:m-0 print:shadow-none">
|
||||
{/* 제목 */}
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold">입고증</h2>
|
||||
<p className="text-sm text-muted-foreground">RECEIVING SLIP</p>
|
||||
</div>
|
||||
|
||||
{/* 입고 정보 / 공급업체 정보 */}
|
||||
<div className="grid grid-cols-2 gap-8 mb-8">
|
||||
{/* 입고 정보 */}
|
||||
<div className="space-y-3 text-sm">
|
||||
<h3 className="font-semibold border-b pb-2">입고 정보</h3>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-y-2">
|
||||
<span className="text-muted-foreground">입고번호</span>
|
||||
<span className="font-medium">{detail.orderNo}</span>
|
||||
<span className="text-muted-foreground">입고일자</span>
|
||||
<span>{detail.receivingDate || today.toISOString().split('T')[0]}</span>
|
||||
<span className="text-muted-foreground">발주번호</span>
|
||||
<span>{detail.orderNo}</span>
|
||||
<span className="text-muted-foreground">입고LOT</span>
|
||||
<span>{detail.receivingLot || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공급업체 정보 */}
|
||||
<div className="space-y-3 text-sm">
|
||||
<h3 className="font-semibold border-b pb-2">공급업체 정보</h3>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-y-2">
|
||||
<span className="text-muted-foreground">업체명</span>
|
||||
<span className="font-medium">{detail.supplier}</span>
|
||||
<span className="text-muted-foreground">공급업체LOT</span>
|
||||
<span>{detail.supplierLot || '-'}</span>
|
||||
<span className="text-muted-foreground">담당자</span>
|
||||
<span>{detail.receivingManager || '-'}</span>
|
||||
<span className="text-muted-foreground">입고위치</span>
|
||||
<span>{detail.receivingLocation || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 입고 품목 상세 */}
|
||||
<div className="mb-8">
|
||||
<h3 className="font-semibold text-sm mb-3">입고 품목 상세</h3>
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="border-y bg-gray-50">
|
||||
<th className="px-3 py-2 text-center font-medium w-12">No</th>
|
||||
<th className="px-3 py-2 text-left font-medium">품목코드</th>
|
||||
<th className="px-3 py-2 text-left font-medium">품목명</th>
|
||||
<th className="px-3 py-2 text-left font-medium">규격</th>
|
||||
<th className="px-3 py-2 text-center font-medium w-24">발주수량</th>
|
||||
<th className="px-3 py-2 text-center font-medium w-24">입고수량</th>
|
||||
<th className="px-3 py-2 text-center font-medium w-16">단위</th>
|
||||
<th className="px-3 py-2 text-left font-medium w-24">비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b">
|
||||
<td className="px-3 py-2 text-center">1</td>
|
||||
<td className="px-3 py-2">{detail.itemCode}</td>
|
||||
<td className="px-3 py-2">{detail.itemName}</td>
|
||||
<td className="px-3 py-2">{detail.specification || '-'}</td>
|
||||
<td className="px-3 py-2 text-center">{detail.orderQty}</td>
|
||||
<td className="px-3 py-2 text-center">{detail.receivingQty || '-'}</td>
|
||||
<td className="px-3 py-2 text-center">{detail.orderUnit}</td>
|
||||
<td className="px-3 py-2">-</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 수입검사 안내 */}
|
||||
<div className="mb-8 p-4 bg-gray-50 rounded-lg text-sm">
|
||||
<p className="flex items-start gap-2">
|
||||
<span className="font-medium">📋 수입검사 안내</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
본 입고건에 대한 <span className="font-medium text-blue-600">수입검사(IQC)</span>가 필요합니다.<br />
|
||||
품질관리 > 수입검사(IQC) 메뉴에서 검사를 진행해주세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 서명란 */}
|
||||
<div className="grid grid-cols-3 gap-6 mb-8">
|
||||
<div className="border rounded p-4 text-center">
|
||||
<p className="text-sm font-medium mb-12">입고담당</p>
|
||||
<p className="text-xs text-muted-foreground">(인)</p>
|
||||
</div>
|
||||
<div className="border rounded p-4 text-center">
|
||||
<p className="text-sm font-medium mb-12">품질검사</p>
|
||||
<p className="text-xs text-muted-foreground">(인)</p>
|
||||
</div>
|
||||
<div className="border rounded p-4 text-center">
|
||||
<p className="text-sm font-medium mb-12">창고담당</p>
|
||||
<p className="text-xs text-muted-foreground">(인)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 발행일 / 회사명 */}
|
||||
<div className="text-right text-sm text-muted-foreground">
|
||||
<p>발행일: {formattedDate}</p>
|
||||
<p>(주) 코드빌더스</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 성공 다이얼로그 (검사 등록 완료 / 입고 처리 완료)
|
||||
*/
|
||||
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
type: 'inspection' | 'receiving';
|
||||
lotNo?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SuccessDialog({ open, type, lotNo, onClose }: Props) {
|
||||
const title = type === 'inspection' ? '수입검사가 합격 처리되었습니다.' : '입고 처리가 완료되었습니다.';
|
||||
const message = type === 'inspection'
|
||||
? `LOT번호: ${lotNo}\n입고 처리가 완료되었습니다.`
|
||||
: `LOT번호: ${lotNo}\n재고에 반영되었습니다.`;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(newOpen) => !newOpen && onClose()}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<div className="flex flex-col items-center text-center py-6 space-y-4">
|
||||
<div className="w-16 h-16 rounded-full bg-green-100 flex items-center justify-center">
|
||||
<CheckCircle2 className="w-10 h-10 text-green-600" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
<div className="text-sm text-muted-foreground whitespace-pre-line">
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={onClose} className="w-full mt-4">
|
||||
확인
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
11
src/components/material/ReceivingManagement/index.ts
Normal file
11
src/components/material/ReceivingManagement/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 입고관리 컴포넌트 내보내기
|
||||
*/
|
||||
|
||||
export { ReceivingList } from './ReceivingList';
|
||||
export { ReceivingDetail } from './ReceivingDetail';
|
||||
export { InspectionCreate } from './InspectionCreate';
|
||||
export { ReceivingProcessDialog } from './ReceivingProcessDialog';
|
||||
export { ReceivingReceiptDialog } from './ReceivingReceiptDialog';
|
||||
export { SuccessDialog } from './SuccessDialog';
|
||||
export * from './types';
|
||||
158
src/components/material/ReceivingManagement/mockData.ts
Normal file
158
src/components/material/ReceivingManagement/mockData.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 입고관리 목업 데이터
|
||||
*/
|
||||
|
||||
import type {
|
||||
ReceivingItem,
|
||||
ReceivingDetail,
|
||||
ReceivingStats,
|
||||
FilterTab,
|
||||
InspectionTarget,
|
||||
InspectionCheckItem,
|
||||
} from './types';
|
||||
|
||||
// 목업 입고 목록
|
||||
export const mockReceivingItems: ReceivingItem[] = [
|
||||
{
|
||||
id: '1',
|
||||
orderNo: 'KD-SO-250312-001',
|
||||
itemCode: 'RM-COIL-08',
|
||||
itemName: '갈바코일 0.8T',
|
||||
supplier: '포항철강',
|
||||
orderQty: 5,
|
||||
orderUnit: '톤',
|
||||
receivingQty: undefined,
|
||||
lotNo: undefined,
|
||||
status: 'inspection_pending',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
orderNo: 'KD-SO-250311-001',
|
||||
itemCode: 'PP-ENDLOCK-01',
|
||||
itemName: '엔드락 2100',
|
||||
supplier: '부품산업',
|
||||
orderQty: 200,
|
||||
orderUnit: 'EA',
|
||||
receivingQty: undefined,
|
||||
lotNo: undefined,
|
||||
status: 'receiving_pending',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
orderNo: 'KD-SO-250310-001',
|
||||
itemCode: 'RM-SCREEN-01',
|
||||
itemName: '방충망 원단 1016',
|
||||
supplier: '한국망사',
|
||||
orderQty: 100,
|
||||
orderUnit: 'M',
|
||||
receivingQty: undefined,
|
||||
lotNo: undefined,
|
||||
status: 'inspection_pending',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
orderNo: 'KD-SO-250302-001',
|
||||
itemCode: 'PP-MOTOR-01',
|
||||
itemName: '튜블러모터',
|
||||
supplier: '모터공사',
|
||||
orderQty: 50,
|
||||
orderUnit: 'EA',
|
||||
receivingQty: undefined,
|
||||
lotNo: undefined,
|
||||
status: 'order_completed',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
orderNo: 'KD-SO-250301-001',
|
||||
itemCode: 'RM-COIL-05',
|
||||
itemName: '갈바코일 0.5T',
|
||||
supplier: '철강공업',
|
||||
orderQty: 10,
|
||||
orderUnit: '톤',
|
||||
receivingQty: undefined,
|
||||
lotNo: undefined,
|
||||
status: 'shipping',
|
||||
},
|
||||
];
|
||||
|
||||
// 상세 정보 목업
|
||||
export const mockReceivingDetails: Record<string, ReceivingDetail> = {
|
||||
'1': {
|
||||
id: '1',
|
||||
orderNo: 'KD-SO-250312-001',
|
||||
orderDate: undefined,
|
||||
supplier: '포항철강',
|
||||
itemCode: 'RM-COIL-08',
|
||||
itemName: '갈바코일 0.8T',
|
||||
specification: undefined,
|
||||
orderQty: 5,
|
||||
orderUnit: '톤',
|
||||
dueDate: undefined,
|
||||
status: 'inspection_pending',
|
||||
receivingDate: undefined,
|
||||
receivingQty: undefined,
|
||||
receivingLot: undefined,
|
||||
supplierLot: undefined,
|
||||
receivingLocation: undefined,
|
||||
receivingManager: '자재팀',
|
||||
},
|
||||
'5': {
|
||||
id: '5',
|
||||
orderNo: 'KD-SO-250301-001',
|
||||
orderDate: undefined,
|
||||
supplier: '철강공업',
|
||||
itemCode: 'RM-COIL-05',
|
||||
itemName: '갈바코일 0.5T',
|
||||
specification: undefined,
|
||||
orderQty: 10,
|
||||
orderUnit: '톤',
|
||||
dueDate: undefined,
|
||||
status: 'shipping',
|
||||
receivingDate: undefined,
|
||||
receivingQty: undefined,
|
||||
receivingLot: undefined,
|
||||
supplierLot: undefined,
|
||||
receivingLocation: undefined,
|
||||
receivingManager: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
// 통계 목업
|
||||
export const mockStats: ReceivingStats = {
|
||||
receivingPendingCount: 2,
|
||||
shippingCount: 1,
|
||||
inspectionPendingCount: 2,
|
||||
todayReceivingCount: 0,
|
||||
};
|
||||
|
||||
// 필터 탭 목업
|
||||
export const mockFilterTabs: FilterTab[] = [
|
||||
{ key: 'all', label: '전체', count: 5 },
|
||||
{ key: 'receiving_pending', label: '입고대기', count: 2 },
|
||||
{ key: 'completed', label: '입고완료', count: 2 },
|
||||
];
|
||||
|
||||
// 검사 대상 목업
|
||||
export const mockInspectionTargets: InspectionTarget[] = [
|
||||
{ id: '3', orderNo: 'KD-SO-250310-001', supplier: '한국망사', qty: '100EA' },
|
||||
{ id: '2', orderNo: 'KD-SO-250311-001', supplier: '부품산업', qty: '200EA' },
|
||||
{ id: '1', orderNo: 'KD-SO-250312-001', supplier: '포항철강', qty: '5EA' },
|
||||
];
|
||||
|
||||
// 검사 항목 템플릿
|
||||
export const defaultInspectionItems: InspectionCheckItem[] = [
|
||||
{ id: '1', name: '겉모양', specification: '사용상 해로운 결함이 없을 것', method: '육안검사', judgment: '', remark: '' },
|
||||
{ id: '2', name: '두께', specification: '규격 참조', method: '마이크로미터', judgment: '', remark: '' },
|
||||
{ id: '3', name: '폭', specification: '규격 참조', method: '줄자', judgment: '', remark: '' },
|
||||
{ id: '4', name: '길이', specification: '규격 참조', method: '줄자', judgment: '', remark: '' },
|
||||
];
|
||||
|
||||
// LOT 번호 생성
|
||||
export function generateLotNo(): string {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear().toString().slice(-2);
|
||||
const month = (now.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = now.getDate().toString().padStart(2, '0');
|
||||
const seq = Math.floor(Math.random() * 100).toString().padStart(2, '0');
|
||||
return `${year}${month}${day}-${seq}`;
|
||||
}
|
||||
117
src/components/material/ReceivingManagement/types.ts
Normal file
117
src/components/material/ReceivingManagement/types.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 입고관리 타입 정의
|
||||
*/
|
||||
|
||||
// 입고 상태
|
||||
export type ReceivingStatus =
|
||||
| 'order_completed' // 발주완료
|
||||
| 'shipping' // 배송중
|
||||
| 'inspection_pending' // 검사대기
|
||||
| 'receiving_pending' // 입고대기
|
||||
| 'completed'; // 입고완료
|
||||
|
||||
// 상태 라벨
|
||||
export const RECEIVING_STATUS_LABELS: Record<ReceivingStatus, string> = {
|
||||
order_completed: '발주완료',
|
||||
shipping: '배송중',
|
||||
inspection_pending: '검사대기',
|
||||
receiving_pending: '입고대기',
|
||||
completed: '입고완료',
|
||||
};
|
||||
|
||||
// 상태 스타일
|
||||
export const RECEIVING_STATUS_STYLES: Record<ReceivingStatus, string> = {
|
||||
order_completed: 'bg-gray-100 text-gray-800',
|
||||
shipping: 'bg-blue-100 text-blue-800',
|
||||
inspection_pending: 'bg-orange-100 text-orange-800',
|
||||
receiving_pending: 'bg-yellow-100 text-yellow-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
};
|
||||
|
||||
// 입고 목록 아이템
|
||||
export interface ReceivingItem {
|
||||
id: string;
|
||||
orderNo: string; // 발주번호
|
||||
itemCode: string; // 품목코드
|
||||
itemName: string; // 품목명
|
||||
supplier: string; // 공급업체
|
||||
orderQty: number; // 발주수량
|
||||
orderUnit: string; // 발주단위
|
||||
receivingQty?: number; // 입고수량
|
||||
lotNo?: string; // LOT번호
|
||||
status: ReceivingStatus; // 상태
|
||||
}
|
||||
|
||||
// 입고 상세 정보
|
||||
export interface ReceivingDetail {
|
||||
id: string;
|
||||
orderNo: string; // 발주번호
|
||||
orderDate?: string; // 발주일자
|
||||
supplier: string; // 공급업체
|
||||
itemCode: string; // 품목코드
|
||||
itemName: string; // 품목명
|
||||
specification?: string; // 규격
|
||||
orderQty: number; // 발주수량
|
||||
orderUnit: string; // 발주단위
|
||||
dueDate?: string; // 납기일
|
||||
status: ReceivingStatus;
|
||||
// 입고 정보
|
||||
receivingDate?: string; // 입고일자
|
||||
receivingQty?: number; // 입고수량
|
||||
receivingLot?: string; // 입고LOT
|
||||
supplierLot?: string; // 공급업체LOT
|
||||
receivingLocation?: string; // 입고위치
|
||||
receivingManager?: string; // 입고담당
|
||||
}
|
||||
|
||||
// 검사 대상 아이템
|
||||
export interface InspectionTarget {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
supplier: string;
|
||||
qty: string;
|
||||
}
|
||||
|
||||
// 검사 항목
|
||||
export interface InspectionCheckItem {
|
||||
id: string;
|
||||
name: string; // 검사항목
|
||||
specification: string; // 규격
|
||||
method: string; // 검사방법
|
||||
judgment: '적' | '부적' | ''; // 판정
|
||||
remark?: string; // 비고
|
||||
}
|
||||
|
||||
// 검사 등록 폼 데이터
|
||||
export interface InspectionFormData {
|
||||
targetId: string; // 검사 대상 ID
|
||||
inspectionDate: string; // 검사일
|
||||
inspector: string; // 검사자
|
||||
lotNo: string; // LOT번호
|
||||
items: InspectionCheckItem[];
|
||||
opinion?: string; // 종합 의견
|
||||
}
|
||||
|
||||
// 입고처리 폼 데이터
|
||||
export interface ReceivingProcessFormData {
|
||||
receivingLot: string; // 입고LOT *
|
||||
supplierLot?: string; // 공급업체LOT
|
||||
receivingQty: number; // 입고수량 *
|
||||
receivingLocation: string; // 입고위치 *
|
||||
remark?: string; // 비고
|
||||
}
|
||||
|
||||
// 통계 데이터
|
||||
export interface ReceivingStats {
|
||||
receivingPendingCount: number; // 입고대기
|
||||
shippingCount: number; // 배송중
|
||||
inspectionPendingCount: number; // 검사대기
|
||||
todayReceivingCount: number; // 금일입고
|
||||
}
|
||||
|
||||
// 필터 탭
|
||||
export interface FilterTab {
|
||||
key: 'all' | 'receiving_pending' | 'completed';
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
262
src/components/material/StockStatus/StockStatusDetail.tsx
Normal file
262
src/components/material/StockStatus/StockStatusDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
3
src/components/material/StockStatus/index.ts
Normal file
3
src/components/material/StockStatus/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { StockStatusList } from './StockStatusList';
|
||||
export { StockStatusDetail } from './StockStatusDetail';
|
||||
export * from './types';
|
||||
534
src/components/material/StockStatus/mockData.ts
Normal file
534
src/components/material/StockStatus/mockData.ts
Normal 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();
|
||||
111
src/components/material/StockStatus/types.ts
Normal file
111
src/components/material/StockStatus/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user