feat: 생산/품질/자재/출고/주문 관리 페이지 구현

- 생산관리: 대시보드, 작업지시, 작업실적, 작업자화면
- 품질관리: 검사관리 (리스트/등록/상세)
- 자재관리: 입고관리, 재고현황
- 출고관리: 출하관리 (리스트/등록/상세/수정)
- 주문관리: 수주관리, 생산의뢰
- 기존 컴포넌트 개선: CardTransactionInquiry, VendorDetail, QuoteRegistration
- IntegratedListTemplateV2 개선
- 공통 컴포넌트 분석 문서 추가

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-12-23 21:13:07 +09:00
parent 2ebcea0255
commit f0e8e51d06
108 changed files with 21156 additions and 84 deletions

View File

@@ -0,0 +1,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>
);
}

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

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

View File

@@ -0,0 +1,6 @@
// 검사관리 컴포넌트 및 타입 export
export * from './types';
export * from './mockData';
export { InspectionList } from './InspectionList';
export { InspectionCreate } from './InspectionCreate';
export { InspectionDetail } from './InspectionDetail';

View 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 ? '적합' : '부적합';
};

View 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';
}