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:
320
src/components/quality/InspectionManagement/InspectionCreate.tsx
Normal file
320
src/components/quality/InspectionManagement/InspectionCreate.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 검사 등록 페이지
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ClipboardCheck, ImageIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { inspectionItemsTemplate, judgeMeasurement } from './mockData';
|
||||
import type { InspectionItem, QualityCheckItem, MeasurementItem } from './types';
|
||||
|
||||
export function InspectionCreate() {
|
||||
const router = useRouter();
|
||||
|
||||
// 폼 상태
|
||||
const [formData, setFormData] = useState({
|
||||
lotNo: 'WO-251219-05', // 자동 (예시)
|
||||
itemName: '조인트바', // 자동 (예시)
|
||||
processName: '조립 공정', // 자동 (예시)
|
||||
quantity: 50,
|
||||
inspector: '',
|
||||
remarks: '',
|
||||
});
|
||||
|
||||
// 검사 항목 상태
|
||||
const [inspectionItems, setInspectionItems] = useState<InspectionItem[]>(
|
||||
inspectionItemsTemplate.map(item => ({ ...item }))
|
||||
);
|
||||
|
||||
// validation 에러 상태
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
|
||||
// 폼 입력 핸들러
|
||||
const handleInputChange = (field: string, value: string | number) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
// 입력 시 에러 클리어
|
||||
if (validationErrors.length > 0) {
|
||||
setValidationErrors([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 품질 검사 항목 결과 변경 (양호/불량)
|
||||
const handleQualityResultChange = useCallback((itemId: string, result: '양호' | '불량') => {
|
||||
setInspectionItems(prev => prev.map(item => {
|
||||
if (item.id === itemId && item.type === 'quality') {
|
||||
return {
|
||||
...item,
|
||||
result,
|
||||
judgment: result === '양호' ? '적합' : '부적합',
|
||||
} as QualityCheckItem;
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
// 입력 시 에러 클리어
|
||||
setValidationErrors([]);
|
||||
}, []);
|
||||
|
||||
// 측정 항목 값 변경
|
||||
const handleMeasurementChange = useCallback((itemId: string, value: string) => {
|
||||
setInspectionItems(prev => prev.map(item => {
|
||||
if (item.id === itemId && item.type === 'measurement') {
|
||||
const measuredValue = parseFloat(value) || 0;
|
||||
const judgment = judgeMeasurement(item.spec, measuredValue);
|
||||
return {
|
||||
...item,
|
||||
measuredValue,
|
||||
judgment,
|
||||
} as MeasurementItem;
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
// 입력 시 에러 클리어
|
||||
setValidationErrors([]);
|
||||
}, []);
|
||||
|
||||
// 취소
|
||||
const handleCancel = () => {
|
||||
router.push('/quality/inspections');
|
||||
};
|
||||
|
||||
// validation 체크
|
||||
const validateForm = (): boolean => {
|
||||
const errors: string[] = [];
|
||||
|
||||
// 필수 필드: 작업자
|
||||
if (!formData.inspector.trim()) {
|
||||
errors.push('작업자는 필수 입력 항목입니다.');
|
||||
}
|
||||
|
||||
// 검사 항목 validation
|
||||
inspectionItems.forEach((item, index) => {
|
||||
if (item.type === 'quality') {
|
||||
const qualityItem = item as QualityCheckItem;
|
||||
if (!qualityItem.result) {
|
||||
errors.push(`${index + 1}. ${item.name}: 결과를 선택해주세요.`);
|
||||
}
|
||||
} else if (item.type === 'measurement') {
|
||||
const measurementItem = item as MeasurementItem;
|
||||
if (measurementItem.measuredValue === undefined || measurementItem.measuredValue === null) {
|
||||
errors.push(`${index + 1}. ${item.name}: 측정값을 입력해주세요.`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setValidationErrors(errors);
|
||||
return errors.length === 0;
|
||||
};
|
||||
|
||||
// 검사완료
|
||||
const handleSubmit = () => {
|
||||
// validation 체크
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: API 호출
|
||||
console.log('Submit:', { ...formData, items: inspectionItems });
|
||||
router.push('/quality/inspections');
|
||||
};
|
||||
|
||||
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">검사 등록</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>
|
||||
검사완료
|
||||
</Button>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 검사 개요 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">검사 개요</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">LOT NO (자동)</Label>
|
||||
<Input
|
||||
value={formData.lotNo}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">품목명 (자동)</Label>
|
||||
<Input
|
||||
value={formData.itemName}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">공정명 (자동)</Label>
|
||||
<Input
|
||||
value={formData.processName}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>수량</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.quantity}
|
||||
onChange={(e) => handleInputChange('quantity', parseInt(e.target.value) || 0)}
|
||||
placeholder="수량 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>작업자 *</Label>
|
||||
<Input
|
||||
value={formData.inspector}
|
||||
onChange={(e) => handleInputChange('inspector', e.target.value)}
|
||||
placeholder="작업자 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-3">
|
||||
<Label>특이사항</Label>
|
||||
<Input
|
||||
value={formData.remarks}
|
||||
onChange={(e) => handleInputChange('remarks', e.target.value)}
|
||||
placeholder="특이사항 입력"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 검사 기준 및 도해 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">검사 기준 및 도해</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center h-48 bg-muted rounded-lg border-2 border-dashed">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<ImageIcon className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p>템플릿에서 설정한 조인트바 표준 도면이 표시됨</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 검사 데이터 입력 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">검사 데이터 입력</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
* 측정값을 입력하면 판정이 자동 처리됩니다.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{inspectionItems.map((item, index) => (
|
||||
<div key={item.id} className="p-4 border rounded-lg">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="font-medium">
|
||||
{index + 1}. {item.name}
|
||||
{item.type === 'measurement' && ` (${(item as MeasurementItem).unit})`}
|
||||
</h4>
|
||||
<span className={`text-sm font-medium ${
|
||||
item.judgment === '적합' ? 'text-green-600' :
|
||||
item.judgment === '부적합' ? 'text-red-600' :
|
||||
'text-muted-foreground'
|
||||
}`}>
|
||||
판정: {item.judgment || '-'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">기준(Spec)</Label>
|
||||
<Input
|
||||
value={item.spec}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{item.type === 'quality' ? (
|
||||
<div className="space-y-2">
|
||||
<Label>결과 입력 *</Label>
|
||||
<RadioGroup
|
||||
value={(item as QualityCheckItem).result || ''}
|
||||
onValueChange={(value) => handleQualityResultChange(item.id, value as '양호' | '불량')}
|
||||
className="flex items-center gap-4 h-10"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="양호" id={`${item.id}-pass`} />
|
||||
<Label htmlFor={`${item.id}-pass`} className="cursor-pointer">양호</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="불량" id={`${item.id}-fail`} />
|
||||
<Label htmlFor={`${item.id}-fail`} className="cursor-pointer">불량</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label>측정값 입력 ({(item as MeasurementItem).unit}) *</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={(item as MeasurementItem).measuredValue || ''}
|
||||
onChange={(e) => handleMeasurementChange(item.id, e.target.value)}
|
||||
placeholder={`측정값 입력 (${(item as MeasurementItem).unit})`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
486
src/components/quality/InspectionManagement/InspectionDetail.tsx
Normal file
486
src/components/quality/InspectionManagement/InspectionDetail.tsx
Normal file
@@ -0,0 +1,486 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 검사 상세/수정 페이지
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { ClipboardCheck, Printer, Paperclip } 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 { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { mockInspections, judgmentColorMap, judgeMeasurement } from './mockData';
|
||||
import type { InspectionItem, QualityCheckItem, MeasurementItem } from './types';
|
||||
|
||||
interface InspectionDetailProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function InspectionDetail({ id }: InspectionDetailProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const isEditMode = searchParams.get('mode') === 'edit';
|
||||
|
||||
// 검사 데이터 조회 (mockData에서)
|
||||
const inspection = useMemo(() => {
|
||||
return mockInspections.find(i => i.id === id);
|
||||
}, [id]);
|
||||
|
||||
// 수정 폼 상태
|
||||
const [editReason, setEditReason] = useState('');
|
||||
const [inspectionItems, setInspectionItems] = useState<InspectionItem[]>(
|
||||
inspection?.items || []
|
||||
);
|
||||
|
||||
// validation 에러 상태
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
|
||||
// 품질 검사 항목 결과 변경 (양호/불량)
|
||||
const handleQualityResultChange = useCallback((itemId: string, result: '양호' | '불량') => {
|
||||
setInspectionItems(prev => prev.map(item => {
|
||||
if (item.id === itemId && item.type === 'quality') {
|
||||
return {
|
||||
...item,
|
||||
result,
|
||||
judgment: result === '양호' ? '적합' : '부적합',
|
||||
} as QualityCheckItem;
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
// 입력 시 에러 클리어
|
||||
setValidationErrors([]);
|
||||
}, []);
|
||||
|
||||
// 측정 항목 값 변경
|
||||
const handleMeasurementChange = useCallback((itemId: string, value: string) => {
|
||||
setInspectionItems(prev => prev.map(item => {
|
||||
if (item.id === itemId && item.type === 'measurement') {
|
||||
const measuredValue = parseFloat(value) || 0;
|
||||
const judgment = judgeMeasurement(item.spec, measuredValue);
|
||||
return {
|
||||
...item,
|
||||
measuredValue,
|
||||
judgment,
|
||||
} as MeasurementItem;
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
// 입력 시 에러 클리어
|
||||
setValidationErrors([]);
|
||||
}, []);
|
||||
|
||||
// 목록으로
|
||||
const handleBack = () => {
|
||||
router.push('/quality/inspections');
|
||||
};
|
||||
|
||||
// 수정 모드 진입
|
||||
const handleEditMode = () => {
|
||||
router.push(`/quality/inspections/${id}?mode=edit`);
|
||||
};
|
||||
|
||||
// 수정 취소
|
||||
const handleCancelEdit = () => {
|
||||
router.push(`/quality/inspections/${id}`);
|
||||
};
|
||||
|
||||
// validation 체크
|
||||
const validateForm = (): boolean => {
|
||||
const errors: string[] = [];
|
||||
|
||||
// 필수 필드: 수정 사유
|
||||
if (!editReason.trim()) {
|
||||
errors.push('수정 사유는 필수 입력 항목입니다.');
|
||||
}
|
||||
|
||||
// 검사 항목 validation
|
||||
inspectionItems.forEach((item, index) => {
|
||||
if (item.type === 'quality') {
|
||||
const qualityItem = item as QualityCheckItem;
|
||||
if (!qualityItem.result) {
|
||||
errors.push(`${index + 1}. ${item.name}: 결과를 선택해주세요.`);
|
||||
}
|
||||
} else if (item.type === 'measurement') {
|
||||
const measurementItem = item as MeasurementItem;
|
||||
if (measurementItem.measuredValue === undefined || measurementItem.measuredValue === null) {
|
||||
errors.push(`${index + 1}. ${item.name}: 측정값을 입력해주세요.`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setValidationErrors(errors);
|
||||
return errors.length === 0;
|
||||
};
|
||||
|
||||
// 수정 완료
|
||||
const handleSubmitEdit = () => {
|
||||
// validation 체크
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
// TODO: API 호출
|
||||
console.log('Submit Edit:', { editReason, items: inspectionItems });
|
||||
router.push(`/quality/inspections/${id}`);
|
||||
};
|
||||
|
||||
// 수정 사유 변경 핸들러
|
||||
const handleEditReasonChange = (value: string) => {
|
||||
setEditReason(value);
|
||||
// 입력 시 에러 클리어
|
||||
if (validationErrors.length > 0) {
|
||||
setValidationErrors([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 성적서 출력
|
||||
const handlePrintReport = () => {
|
||||
// TODO: 성적서 출력 기능
|
||||
console.log('Print Report');
|
||||
};
|
||||
|
||||
if (!inspection) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-muted-foreground">검사 데이터를 찾을 수 없습니다.</p>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// 상세 보기 모드
|
||||
if (!isEditMode) {
|
||||
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">검사 상세</h1>
|
||||
<Badge variant="outline" className="text-sm">{inspection.inspectionNo}</Badge>
|
||||
{inspection.result && (
|
||||
<Badge className={`${judgmentColorMap[inspection.result]} border-0`}>
|
||||
{inspection.result}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handlePrintReport}>
|
||||
<Printer className="w-4 h-4 mr-1.5" />
|
||||
성적서
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
목록
|
||||
</Button>
|
||||
<Button onClick={handleEditMode}>
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검사 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">검사 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">검사번호</Label>
|
||||
<p className="font-medium">{inspection.inspectionNo}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">검사유형</Label>
|
||||
<p className="font-medium">{inspection.inspectionType}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">검사일자</Label>
|
||||
<p className="font-medium">{inspection.inspectionDate || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">판정결과</Label>
|
||||
<p className="font-medium">
|
||||
{inspection.result && (
|
||||
<Badge className={`${judgmentColorMap[inspection.result]} border-0`}>
|
||||
{inspection.result}
|
||||
</Badge>
|
||||
)}
|
||||
{!inspection.result && '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">품목명</Label>
|
||||
<p className="font-medium">{inspection.itemName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">LOT NO</Label>
|
||||
<p className="font-medium">{inspection.lotNo}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">공정명</Label>
|
||||
<p className="font-medium">{inspection.processName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">검사자</Label>
|
||||
<p className="font-medium">{inspection.inspector || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 검사 결과 데이터 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">검사 결과 데이터</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[150px]">항목명</TableHead>
|
||||
<TableHead className="w-[150px]">기준(Spec)</TableHead>
|
||||
<TableHead className="w-[150px]">측정값/결과</TableHead>
|
||||
<TableHead className="w-[100px]">판정</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{inspection.items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-medium">{item.name}</TableCell>
|
||||
<TableCell>{item.spec}</TableCell>
|
||||
<TableCell>
|
||||
{item.type === 'quality'
|
||||
? (item as QualityCheckItem).result || '-'
|
||||
: `${(item as MeasurementItem).measuredValue || '-'} ${(item as MeasurementItem).unit}`
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={item.judgment ? judgmentColorMap[item.judgment] : ''}>
|
||||
{item.judgment || '-'}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{inspection.items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||
검사 데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 종합 의견 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">종합 의견</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm">{inspection.opinion || '의견이 없습니다.'}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 첨부 파일 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">첨부 파일</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{inspection.attachments && inspection.attachments.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{inspection.attachments.map((file) => (
|
||||
<div key={file.id} className="flex items-center gap-2 text-sm">
|
||||
<Paperclip className="w-4 h-4 text-muted-foreground" />
|
||||
<a href={file.fileUrl} className="text-blue-600 hover:underline">
|
||||
{file.fileName}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">첨부 파일이 없습니다.</p>
|
||||
)}
|
||||
</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">
|
||||
<ClipboardCheck className="w-6 h-6" />
|
||||
<h1 className="text-xl font-semibold">검사 수정</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCancelEdit}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmitEdit}>
|
||||
수정 완료
|
||||
</Button>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 검사 개요 (수정 불가) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">검사 개요 (수정 불가)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">LOT NO (비활성)</Label>
|
||||
<Input value={inspection.lotNo} disabled className="bg-muted" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">품목명 (비활성)</Label>
|
||||
<Input value={inspection.itemName} disabled className="bg-muted" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">공정명 (비활성)</Label>
|
||||
<Input value={inspection.processName} disabled className="bg-muted" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">수량 (비활성)</Label>
|
||||
<Input value={`${inspection.quantity} ${inspection.unit}`} disabled className="bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 수정 사유 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
수정 사유 (필수 <span className="text-red-500">★</span>)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
value={editReason}
|
||||
onChange={(e) => handleEditReasonChange(e.target.value)}
|
||||
placeholder="수정 사유를 입력하세요 (예: 오기입 수정, 높이 측정값 입력 오류)"
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 검사 데이터 수정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">검사 데이터 수정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{inspectionItems.map((item, index) => (
|
||||
<div key={item.id} className="p-4 border rounded-lg">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="font-medium">
|
||||
{index + 1}. {item.name}
|
||||
{item.type === 'measurement' && ` (${(item as MeasurementItem).unit})`}
|
||||
</h4>
|
||||
<span className={`text-sm font-medium ${
|
||||
item.judgment === '적합' ? 'text-green-600' :
|
||||
item.judgment === '부적합' ? 'text-red-600' :
|
||||
'text-muted-foreground'
|
||||
}`}>
|
||||
판정: {item.judgment || '-'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">기준(Spec)</Label>
|
||||
<Input value={item.spec} disabled className="bg-muted" />
|
||||
</div>
|
||||
|
||||
{item.type === 'quality' ? (
|
||||
<div className="space-y-2">
|
||||
<Label>결과 입력 *</Label>
|
||||
<RadioGroup
|
||||
value={(item as QualityCheckItem).result || ''}
|
||||
onValueChange={(value) => handleQualityResultChange(item.id, value as '양호' | '불량')}
|
||||
className="flex items-center gap-4 h-10"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="양호" id={`${item.id}-pass`} />
|
||||
<Label htmlFor={`${item.id}-pass`} className="cursor-pointer">양호</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="불량" id={`${item.id}-fail`} />
|
||||
<Label htmlFor={`${item.id}-fail`} className="cursor-pointer">불량</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label>측정값 입력 ({(item as MeasurementItem).unit}) *</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={(item as MeasurementItem).measuredValue || ''}
|
||||
onChange={(e) => handleMeasurementChange(item.id, e.target.value)}
|
||||
placeholder={`측정값 입력 (${(item as MeasurementItem).unit})`}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
※ (16.6으로 잘못 연결 16.6으로 수정)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
286
src/components/quality/InspectionManagement/InspectionList.tsx
Normal file
286
src/components/quality/InspectionManagement/InspectionList.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 검사 목록 페이지
|
||||
* IntegratedListTemplateV2 패턴 적용
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Plus, ClipboardCheck, Clock, PlayCircle, CheckCircle2, AlertTriangle } 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 { mockInspections, mockStats, statusColorMap, inspectionTypeLabels } from './mockData';
|
||||
import type { Inspection, InspectionStatus } from './types';
|
||||
|
||||
// 탭 필터 정의
|
||||
type TabFilter = '전체' | InspectionStatus;
|
||||
|
||||
// 페이지당 항목 수
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
export function InspectionList() {
|
||||
const router = useRouter();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<TabFilter>('전체');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
// 탭별 카운트 계산
|
||||
const tabCounts = useMemo(() => {
|
||||
return {
|
||||
전체: mockInspections.length,
|
||||
대기: mockInspections.filter((i) => i.status === '대기').length,
|
||||
진행중: mockInspections.filter((i) => i.status === '진행중').length,
|
||||
완료: mockInspections.filter((i) => i.status === '완료').length,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 탭 옵션
|
||||
const tabs: TabOption[] = [
|
||||
{ value: '전체', label: '전체', count: tabCounts.전체 },
|
||||
{ value: '대기', label: '대기', count: tabCounts.대기, color: 'gray' },
|
||||
{ value: '진행중', label: '진행중', count: tabCounts.진행중, color: 'blue' },
|
||||
{ value: '완료', label: '완료', count: tabCounts.완료, color: 'green' },
|
||||
];
|
||||
|
||||
// 통계 카드
|
||||
const stats: StatCard[] = [
|
||||
{
|
||||
label: '금일 대기 건수',
|
||||
value: `${mockStats.waitingCount}건`,
|
||||
icon: Clock,
|
||||
iconColor: 'text-gray-600',
|
||||
},
|
||||
{
|
||||
label: '진행 중 검사',
|
||||
value: `${mockStats.inProgressCount}건`,
|
||||
icon: PlayCircle,
|
||||
iconColor: 'text-blue-600',
|
||||
},
|
||||
{
|
||||
label: '금일 완료 건수',
|
||||
value: `${mockStats.completedCount}건`,
|
||||
icon: CheckCircle2,
|
||||
iconColor: 'text-green-600',
|
||||
},
|
||||
{
|
||||
label: '불량 발생률',
|
||||
value: `${mockStats.defectRate}%`,
|
||||
icon: AlertTriangle,
|
||||
iconColor: mockStats.defectRate > 0 ? 'text-red-600' : 'text-gray-400',
|
||||
},
|
||||
];
|
||||
|
||||
// 테이블 컬럼
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'inspectionType', label: '검사유형', className: 'w-[80px]' },
|
||||
{ key: 'requestDate', label: '요청일', className: 'w-[100px]' },
|
||||
{ key: 'itemName', label: '품목명', className: 'min-w-[150px]' },
|
||||
{ key: 'lotNo', label: 'LOT NO', className: 'min-w-[130px]' },
|
||||
{ key: 'status', label: '상태', className: 'w-[80px]' },
|
||||
{ key: 'inspector', label: '담당자', className: 'w-[80px]' },
|
||||
];
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredInspections = useMemo(() => {
|
||||
let result = [...mockInspections];
|
||||
|
||||
// 탭 필터
|
||||
if (activeTab !== '전체') {
|
||||
result = result.filter((i) => i.status === activeTab);
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
result = result.filter(
|
||||
(i) =>
|
||||
i.lotNo.toLowerCase().includes(term) ||
|
||||
i.itemName.toLowerCase().includes(term) ||
|
||||
i.processName.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [activeTab, searchTerm]);
|
||||
|
||||
// 페이지네이션 데이터
|
||||
const paginatedData = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
return filteredInspections.slice(startIndex, startIndex + ITEMS_PER_PAGE);
|
||||
}, [filteredInspections, currentPage]);
|
||||
|
||||
// 페이지네이션 설정
|
||||
const pagination = {
|
||||
currentPage,
|
||||
totalPages: Math.ceil(filteredInspections.length / ITEMS_PER_PAGE),
|
||||
totalItems: filteredInspections.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((i) => i.id)));
|
||||
}
|
||||
}, [paginatedData, selectedItems.size]);
|
||||
|
||||
// 상세 페이지 이동
|
||||
const handleView = useCallback((id: string) => {
|
||||
router.push(`/quality/inspections/${id}`);
|
||||
}, [router]);
|
||||
|
||||
// 등록 페이지 이동
|
||||
const handleCreate = () => {
|
||||
router.push('/quality/inspections/new');
|
||||
};
|
||||
|
||||
// 탭 변경 시 페이지 리셋
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value as TabFilter);
|
||||
setCurrentPage(1);
|
||||
setSelectedItems(new Set());
|
||||
};
|
||||
|
||||
// 검색 변경 시 페이지 리셋
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = (inspection: Inspection, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(inspection.id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={inspection.id}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${isSelected ? 'bg-blue-50' : ''}`}
|
||||
onClick={() => handleView(inspection.id)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(inspection.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{inspection.inspectionType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{inspection.requestDate}</TableCell>
|
||||
<TableCell className="font-medium">{inspection.itemName}</TableCell>
|
||||
<TableCell>{inspection.lotNo}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`${statusColorMap[inspection.status]} border-0`}>
|
||||
{inspection.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{inspection.inspector || '-'}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = (
|
||||
inspection: Inspection,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
isSelected: boolean,
|
||||
onToggle: () => void
|
||||
) => {
|
||||
return (
|
||||
<ListMobileCard
|
||||
id={inspection.id}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
onClick={() => handleView(inspection.id)}
|
||||
headerBadges={
|
||||
<>
|
||||
<Badge variant="outline" className="text-xs">#{globalIndex}</Badge>
|
||||
<Badge variant="outline" className="text-xs">{inspection.inspectionType}</Badge>
|
||||
</>
|
||||
}
|
||||
title={inspection.itemName}
|
||||
statusBadge={
|
||||
<Badge className={`${statusColorMap[inspection.status]} border-0`}>
|
||||
{inspection.status}
|
||||
</Badge>
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="검사유형" value={inspectionTypeLabels[inspection.inspectionType]} />
|
||||
<InfoField label="LOT NO" value={inspection.lotNo} />
|
||||
<InfoField label="요청일" value={inspection.requestDate} />
|
||||
<InfoField label="담당자" value={inspection.inspector || '-'} />
|
||||
<InfoField label="공정명" value={inspection.processName} />
|
||||
<InfoField label="수량" value={`${inspection.quantity} ${inspection.unit}`} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 헤더 액션
|
||||
const headerActions = (
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus className="w-4 h-4 mr-1.5" />
|
||||
검사 등록
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<IntegratedListTemplateV2<Inspection>
|
||||
title="검사 목록"
|
||||
description="품질검사 관리"
|
||||
icon={ClipboardCheck}
|
||||
headerActions={headerActions}
|
||||
stats={stats}
|
||||
searchValue={searchTerm}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="LOT번호, 품목명, 공정명 검색..."
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={handleTabChange}
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
totalCount={filteredInspections.length}
|
||||
allData={filteredInspections}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
getItemId={(inspection) => inspection.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
pagination={pagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
6
src/components/quality/InspectionManagement/index.ts
Normal file
6
src/components/quality/InspectionManagement/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// 검사관리 컴포넌트 및 타입 export
|
||||
export * from './types';
|
||||
export * from './mockData';
|
||||
export { InspectionList } from './InspectionList';
|
||||
export { InspectionCreate } from './InspectionCreate';
|
||||
export { InspectionDetail } from './InspectionDetail';
|
||||
304
src/components/quality/InspectionManagement/mockData.ts
Normal file
304
src/components/quality/InspectionManagement/mockData.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import type {
|
||||
Inspection,
|
||||
InspectionStats,
|
||||
InspectionItem,
|
||||
} from './types';
|
||||
|
||||
// 검사 항목 템플릿 (조인트바 예시)
|
||||
export const inspectionItemsTemplate: InspectionItem[] = [
|
||||
{
|
||||
id: 'item-1',
|
||||
name: '가공상태',
|
||||
type: 'quality',
|
||||
spec: '결함 없을 것',
|
||||
},
|
||||
{
|
||||
id: 'item-2',
|
||||
name: '높이(H)',
|
||||
type: 'measurement',
|
||||
spec: '16.5 ± 1',
|
||||
unit: 'mm',
|
||||
},
|
||||
{
|
||||
id: 'item-3',
|
||||
name: '길이(L)',
|
||||
type: 'measurement',
|
||||
spec: '300 ± 4',
|
||||
unit: 'mm',
|
||||
},
|
||||
];
|
||||
|
||||
// Mock 검사 데이터 (스크린샷 기반)
|
||||
export const mockInspections: Inspection[] = [
|
||||
{
|
||||
id: '1',
|
||||
inspectionNo: 'QC-251219-01',
|
||||
inspectionType: 'IQC',
|
||||
requestDate: '2025-12-19',
|
||||
itemName: 'EGI 철골판 1.5ST',
|
||||
lotNo: 'MAT-251219-01',
|
||||
processName: '입고 검사',
|
||||
quantity: 100,
|
||||
unit: 'EA',
|
||||
status: '대기',
|
||||
inspector: undefined,
|
||||
items: [],
|
||||
remarks: '',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
inspectionNo: 'QC-251219-02',
|
||||
inspectionType: 'PQC',
|
||||
requestDate: '2025-12-19',
|
||||
inspectionDate: '2025-12-19',
|
||||
itemName: '조인트바',
|
||||
lotNo: 'WO-251219-05',
|
||||
processName: '조립 공정',
|
||||
quantity: 50,
|
||||
unit: 'EA',
|
||||
status: '진행중',
|
||||
result: undefined,
|
||||
inspector: '홍길동',
|
||||
items: [
|
||||
{
|
||||
id: 'item-1',
|
||||
name: '가공상태',
|
||||
type: 'quality',
|
||||
spec: '결함 없을 것',
|
||||
result: '양호',
|
||||
judgment: '적합',
|
||||
},
|
||||
{
|
||||
id: 'item-2',
|
||||
name: '높이(H)',
|
||||
type: 'measurement',
|
||||
spec: '16.5 ± 1',
|
||||
unit: 'mm',
|
||||
measuredValue: 16.6,
|
||||
judgment: '적합',
|
||||
},
|
||||
{
|
||||
id: 'item-3',
|
||||
name: '길이(L)',
|
||||
type: 'measurement',
|
||||
spec: '300 ± 4',
|
||||
unit: 'mm',
|
||||
measuredValue: 301,
|
||||
judgment: '적합',
|
||||
},
|
||||
],
|
||||
remarks: '',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
inspectionNo: 'QC-251218-03',
|
||||
inspectionType: 'FQC',
|
||||
requestDate: '2025-12-18',
|
||||
inspectionDate: '2025-12-18',
|
||||
itemName: '방화샤터 완제품',
|
||||
lotNo: 'WO-251218-02',
|
||||
processName: '최종 검사',
|
||||
quantity: 10,
|
||||
unit: 'EA',
|
||||
status: '완료',
|
||||
result: '합격',
|
||||
inspector: '김철수',
|
||||
items: [
|
||||
{
|
||||
id: 'item-1',
|
||||
name: '가공상태',
|
||||
type: 'quality',
|
||||
spec: '결함 없을 것',
|
||||
result: '양호',
|
||||
judgment: '적합',
|
||||
},
|
||||
{
|
||||
id: 'item-2',
|
||||
name: '높이(H)',
|
||||
type: 'measurement',
|
||||
spec: '16.5 ± 1',
|
||||
unit: 'mm',
|
||||
measuredValue: 16.6,
|
||||
judgment: '적합',
|
||||
},
|
||||
{
|
||||
id: 'item-3',
|
||||
name: '길이(L)',
|
||||
type: 'measurement',
|
||||
spec: '300 ± 4',
|
||||
unit: 'mm',
|
||||
measuredValue: 301,
|
||||
judgment: '적합',
|
||||
},
|
||||
],
|
||||
remarks: '',
|
||||
opinion: '특이사항 없음. 후공정(포장) 인계 완료함.',
|
||||
attachments: [
|
||||
{
|
||||
id: 'att-1',
|
||||
fileName: '현장_검사_사진_01.jpg',
|
||||
fileUrl: '/uploads/inspection/현장_검사_사진_01.jpg',
|
||||
fileType: 'image/jpeg',
|
||||
uploadedAt: '2025-12-18T10:30:00',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
inspectionNo: 'QC-251218-04',
|
||||
inspectionType: 'PQC',
|
||||
requestDate: '2025-12-18',
|
||||
inspectionDate: '2025-12-18',
|
||||
itemName: '슬랫 성형품',
|
||||
lotNo: 'WO-251218-01',
|
||||
processName: '성형 공정',
|
||||
quantity: 200,
|
||||
unit: 'EA',
|
||||
status: '완료',
|
||||
result: '합격',
|
||||
inspector: '이영희',
|
||||
items: [
|
||||
{
|
||||
id: 'item-1',
|
||||
name: '가공상태',
|
||||
type: 'quality',
|
||||
spec: '결함 없을 것',
|
||||
result: '양호',
|
||||
judgment: '적합',
|
||||
},
|
||||
{
|
||||
id: 'item-2',
|
||||
name: '높이(H)',
|
||||
type: 'measurement',
|
||||
spec: '16.5 ± 1',
|
||||
unit: 'mm',
|
||||
measuredValue: 16.4,
|
||||
judgment: '적합',
|
||||
},
|
||||
{
|
||||
id: 'item-3',
|
||||
name: '길이(L)',
|
||||
type: 'measurement',
|
||||
spec: '300 ± 4',
|
||||
unit: 'mm',
|
||||
measuredValue: 299,
|
||||
judgment: '적합',
|
||||
},
|
||||
],
|
||||
remarks: '',
|
||||
opinion: '검사 완료. 이상 없음.',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
inspectionNo: 'QC-251218-05',
|
||||
inspectionType: 'IQC',
|
||||
requestDate: '2025-12-18',
|
||||
inspectionDate: '2025-12-18',
|
||||
itemName: '스테인레스 코일',
|
||||
lotNo: 'MAT-251218-03',
|
||||
processName: '입고 검사',
|
||||
quantity: 5,
|
||||
unit: 'ROLL',
|
||||
status: '완료',
|
||||
result: '합격',
|
||||
inspector: '박민수',
|
||||
items: [
|
||||
{
|
||||
id: 'item-1',
|
||||
name: '가공상태',
|
||||
type: 'quality',
|
||||
spec: '결함 없을 것',
|
||||
result: '양호',
|
||||
judgment: '적합',
|
||||
},
|
||||
{
|
||||
id: 'item-2',
|
||||
name: '두께',
|
||||
type: 'measurement',
|
||||
spec: '1.2 ± 0.1',
|
||||
unit: 'mm',
|
||||
measuredValue: 1.19,
|
||||
judgment: '적합',
|
||||
},
|
||||
{
|
||||
id: 'item-3',
|
||||
name: '폭',
|
||||
type: 'measurement',
|
||||
spec: '1000 ± 5',
|
||||
unit: 'mm',
|
||||
measuredValue: 1001,
|
||||
judgment: '적합',
|
||||
},
|
||||
],
|
||||
remarks: '',
|
||||
opinion: '입고 검사 완료. 품질 적합.',
|
||||
},
|
||||
];
|
||||
|
||||
// 통계 데이터 계산
|
||||
export const calculateStats = (inspections: Inspection[]): InspectionStats => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const waitingCount = inspections.filter(i => i.status === '대기').length;
|
||||
const inProgressCount = inspections.filter(i => i.status === '진행중').length;
|
||||
const completedToday = inspections.filter(
|
||||
i => i.status === '완료' && i.inspectionDate === today
|
||||
).length;
|
||||
|
||||
const totalCompleted = inspections.filter(i => i.status === '완료').length;
|
||||
const defectCount = inspections.filter(i => i.result === '불합격').length;
|
||||
const defectRate = totalCompleted > 0 ? (defectCount / totalCompleted) * 100 : 0;
|
||||
|
||||
return {
|
||||
waitingCount,
|
||||
inProgressCount,
|
||||
completedCount: completedToday,
|
||||
defectRate: Math.round(defectRate * 10) / 10,
|
||||
};
|
||||
};
|
||||
|
||||
// 기본 통계 (mockData 기준)
|
||||
export const mockStats: InspectionStats = {
|
||||
waitingCount: 1,
|
||||
inProgressCount: 1,
|
||||
completedCount: 3,
|
||||
defectRate: 0.0,
|
||||
};
|
||||
|
||||
// 검사유형 라벨
|
||||
export const inspectionTypeLabels: Record<string, string> = {
|
||||
IQC: '입고검사',
|
||||
PQC: '공정검사',
|
||||
FQC: '최종검사',
|
||||
};
|
||||
|
||||
// 상태 컬러 매핑
|
||||
export const statusColorMap: Record<string, string> = {
|
||||
대기: 'bg-gray-100 text-gray-800',
|
||||
진행중: 'bg-blue-100 text-blue-800',
|
||||
완료: 'bg-green-100 text-green-800',
|
||||
};
|
||||
|
||||
// 판정 컬러 매핑
|
||||
export const judgmentColorMap: Record<string, string> = {
|
||||
합격: 'bg-green-100 text-green-800',
|
||||
불합격: 'bg-red-100 text-red-800',
|
||||
적합: 'text-green-600',
|
||||
부적합: 'text-red-600',
|
||||
};
|
||||
|
||||
// 측정값 판정 함수
|
||||
export const judgeMeasurement = (spec: string, value: number): '적합' | '부적합' => {
|
||||
// spec 예시: "16.5 ± 1" 또는 "300 ± 4"
|
||||
const match = spec.match(/^([\d.]+)\s*±\s*([\d.]+)$/);
|
||||
if (!match) return '적합'; // 파싱 실패 시 기본 적합
|
||||
|
||||
const [, targetStr, toleranceStr] = match;
|
||||
const target = parseFloat(targetStr);
|
||||
const tolerance = parseFloat(toleranceStr);
|
||||
|
||||
const min = target - tolerance;
|
||||
const max = target + tolerance;
|
||||
|
||||
return value >= min && value <= max ? '적합' : '부적합';
|
||||
};
|
||||
109
src/components/quality/InspectionManagement/types.ts
Normal file
109
src/components/quality/InspectionManagement/types.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
// 검사관리 타입 정의
|
||||
|
||||
// 검사유형
|
||||
export type InspectionType = 'IQC' | 'PQC' | 'FQC';
|
||||
|
||||
// 검사 상태
|
||||
export type InspectionStatus = '대기' | '진행중' | '완료';
|
||||
|
||||
// 판정 결과
|
||||
export type JudgmentResult = '합격' | '불합격';
|
||||
|
||||
// 검사 항목 결과
|
||||
export type ItemJudgment = '적합' | '부적합';
|
||||
|
||||
// 검사 항목 (가공상태 - 양호/불량)
|
||||
export interface QualityCheckItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'quality'; // 양호/불량 선택
|
||||
spec: string;
|
||||
result?: '양호' | '불량';
|
||||
judgment?: ItemJudgment;
|
||||
}
|
||||
|
||||
// 측정 항목 (높이, 길이 등 - 수치 입력)
|
||||
export interface MeasurementItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'measurement'; // 수치 입력
|
||||
spec: string; // 예: "16.5 ± 1"
|
||||
unit: string; // 예: "mm"
|
||||
measuredValue?: number;
|
||||
judgment?: ItemJudgment;
|
||||
}
|
||||
|
||||
// 검사 항목 통합 타입
|
||||
export type InspectionItem = QualityCheckItem | MeasurementItem;
|
||||
|
||||
// 검사 데이터
|
||||
export interface Inspection {
|
||||
id: string;
|
||||
inspectionNo: string; // 검사번호 (예: QC-251219-05)
|
||||
inspectionType: InspectionType;
|
||||
requestDate: string;
|
||||
inspectionDate?: string;
|
||||
itemName: string; // 품목명
|
||||
lotNo: string;
|
||||
processName: string; // 공정명
|
||||
quantity: number;
|
||||
unit: string;
|
||||
status: InspectionStatus;
|
||||
result?: JudgmentResult;
|
||||
inspector?: string; // 검사자
|
||||
items: InspectionItem[]; // 검사 항목들
|
||||
remarks?: string; // 특이사항
|
||||
opinion?: string; // 종합 의견
|
||||
attachments?: InspectionAttachment[];
|
||||
}
|
||||
|
||||
// 첨부파일
|
||||
export interface InspectionAttachment {
|
||||
id: string;
|
||||
fileName: string;
|
||||
fileUrl: string;
|
||||
fileType: string;
|
||||
uploadedAt: string;
|
||||
}
|
||||
|
||||
// 통계 카드 데이터
|
||||
export interface InspectionStats {
|
||||
waitingCount: number;
|
||||
inProgressCount: number;
|
||||
completedCount: number;
|
||||
defectRate: number; // 불량 발생률 (%)
|
||||
}
|
||||
|
||||
// 검사 등록 폼 데이터
|
||||
export interface InspectionFormData {
|
||||
lotNo: string;
|
||||
itemName: string;
|
||||
processName: string;
|
||||
quantity: number;
|
||||
inspector: string;
|
||||
remarks?: string;
|
||||
items: InspectionItem[];
|
||||
}
|
||||
|
||||
// 검사 수정 폼 데이터
|
||||
export interface InspectionEditFormData extends InspectionFormData {
|
||||
editReason: string; // 수정 사유 (필수)
|
||||
}
|
||||
|
||||
// 필터 옵션
|
||||
export interface InspectionFilter {
|
||||
search: string;
|
||||
status: InspectionStatus | '전체';
|
||||
dateRange: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 테이블 컬럼 타입
|
||||
export interface InspectionTableColumn {
|
||||
key: keyof Inspection | 'no' | 'checkbox' | 'actions';
|
||||
label: string;
|
||||
width?: string;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
}
|
||||
Reference in New Issue
Block a user