feat(WEB): 품질검사 기능 대폭 확장 및 검사입력 모달 추가
품질검사관리: - InspectionCreate 생성 폼 대폭 개선 (+269줄) - InspectionDetail 상세 페이지 확장 (+424줄) - InspectionReportModal 검사성적서 모달 기능 강화 - InspectionReportDocument 문서 구조 개선 - ProductInspectionInputModal 제품검사 입력 모달 신규 추가 - types, mockData, actions 확장 자재입고: - ReceivingDetail 수입검사 연동 기능 추가 - ImportInspectionInputModal 수입검사 입력 모달 신규 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 수입검사 입력 모달
|
||||
*
|
||||
* 작업자 화면 중간검사 모달 양식 참고
|
||||
* 기획서: 스크린샷 2026-02-05 오후 9.58.16
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
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 { cn } from '@/lib/utils';
|
||||
|
||||
// 시료 탭 타입
|
||||
type SampleTab = 'N1' | 'N2' | 'N3';
|
||||
|
||||
// 검사 결과 데이터 타입
|
||||
export interface ImportInspectionData {
|
||||
sampleTab: SampleTab;
|
||||
productName: string;
|
||||
specification: string;
|
||||
// 겉모양
|
||||
appearanceStatus: 'ok' | 'ng' | null;
|
||||
// 치수
|
||||
thickness: number | null;
|
||||
width: number | null;
|
||||
length: number | null;
|
||||
// 판정
|
||||
judgment: 'pass' | 'fail' | null;
|
||||
// 물성치
|
||||
tensileStrength: number | null; // 인장강도 (270 이상)
|
||||
elongation: number | null; // 연신율 (36 이상)
|
||||
zincCoating: number | null; // 아연의 최소 부착량 (17 이상)
|
||||
// 내용
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface ImportInspectionInputModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
productName?: string;
|
||||
specification?: string;
|
||||
onComplete: (data: ImportInspectionData) => void;
|
||||
}
|
||||
|
||||
// OK/NG 버튼 컴포넌트
|
||||
function OkNgToggle({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: 'ok' | 'ng' | null;
|
||||
onChange: (v: 'ok' | 'ng') => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('ok')}
|
||||
className={cn(
|
||||
'px-6 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
value === 'ok'
|
||||
? 'bg-gray-900 text-white'
|
||||
: 'bg-gray-700 text-white hover:bg-gray-600'
|
||||
)}
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('ng')}
|
||||
className={cn(
|
||||
'px-6 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
value === 'ng'
|
||||
? 'bg-gray-900 text-white'
|
||||
: 'bg-gray-700 text-white hover:bg-gray-600'
|
||||
)}
|
||||
>
|
||||
NG
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 적합/부적합 버튼 컴포넌트
|
||||
function JudgmentToggle({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: 'pass' | 'fail' | null;
|
||||
onChange: (v: 'pass' | 'fail') => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('pass')}
|
||||
className={cn(
|
||||
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
value === 'pass'
|
||||
? 'bg-orange-500 text-white'
|
||||
: 'bg-gray-700 text-white hover:bg-gray-600'
|
||||
)}
|
||||
>
|
||||
적합
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('fail')}
|
||||
className={cn(
|
||||
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
value === 'fail'
|
||||
? 'bg-gray-900 text-white'
|
||||
: 'bg-gray-700 text-white hover:bg-gray-600'
|
||||
)}
|
||||
>
|
||||
부적합
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImportInspectionInputModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
productName = '',
|
||||
specification = '',
|
||||
onComplete,
|
||||
}: ImportInspectionInputModalProps) {
|
||||
const [formData, setFormData] = useState<ImportInspectionData>({
|
||||
sampleTab: 'N1',
|
||||
productName,
|
||||
specification,
|
||||
appearanceStatus: 'ok',
|
||||
thickness: 1.55,
|
||||
width: 1219,
|
||||
length: 480,
|
||||
judgment: 'pass',
|
||||
tensileStrength: null,
|
||||
elongation: null,
|
||||
zincCoating: null,
|
||||
content: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// 모달 열릴 때 초기화 - 기본값 적합 상태
|
||||
setFormData({
|
||||
sampleTab: 'N1',
|
||||
productName,
|
||||
specification,
|
||||
appearanceStatus: 'ok',
|
||||
thickness: 1.55,
|
||||
width: 1219,
|
||||
length: 480,
|
||||
judgment: 'pass',
|
||||
tensileStrength: null,
|
||||
elongation: null,
|
||||
zincCoating: null,
|
||||
content: '',
|
||||
});
|
||||
}
|
||||
}, [open, productName, specification]);
|
||||
|
||||
const handleComplete = () => {
|
||||
onComplete(formData);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// 숫자 입력 핸들러
|
||||
const handleNumberChange = (
|
||||
key: keyof ImportInspectionData,
|
||||
value: string
|
||||
) => {
|
||||
const num = value === '' ? null : parseFloat(value);
|
||||
setFormData((prev) => ({ ...prev, [key]: num }));
|
||||
};
|
||||
|
||||
const sampleTabs: SampleTab[] = ['N1', 'N2', 'N3'];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[95vw] max-w-[500px] sm:max-w-[500px] bg-gray-900 text-white border-gray-700">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white text-lg font-bold">
|
||||
수입검사
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 mt-4 max-h-[70vh] overflow-y-auto pr-2">
|
||||
{/* 제품명 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">제품명</Label>
|
||||
<Input
|
||||
value={formData.productName}
|
||||
readOnly
|
||||
className="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 규격 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">규격</Label>
|
||||
<Input
|
||||
value={formData.specification}
|
||||
readOnly
|
||||
className="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 시료 탭: N1, N2, N3 */}
|
||||
<div className="flex gap-2">
|
||||
{sampleTabs.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => setFormData((prev) => ({ ...prev, sampleTab: tab }))}
|
||||
className={cn(
|
||||
'px-6 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
formData.sampleTab === tab
|
||||
? 'bg-orange-500 text-white'
|
||||
: 'bg-gray-700 text-white hover:bg-gray-600'
|
||||
)}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 겉모양: OK/NG */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">겉모양</Label>
|
||||
<OkNgToggle
|
||||
value={formData.appearanceStatus}
|
||||
onChange={(v) => setFormData((prev) => ({ ...prev, appearanceStatus: v }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 두께 / 너비 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">두께 (1.55)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="1.55"
|
||||
value={formData.thickness ?? ''}
|
||||
onChange={(e) => handleNumberChange('thickness', e.target.value)}
|
||||
className="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">너비 (1219)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="1219"
|
||||
value={formData.width ?? ''}
|
||||
onChange={(e) => handleNumberChange('width', e.target.value)}
|
||||
className="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 길이 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">길이 (480)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="480"
|
||||
value={formData.length ?? ''}
|
||||
onChange={(e) => handleNumberChange('length', e.target.value)}
|
||||
className="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 판정: 적합/부적합 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">판정</Label>
|
||||
<JudgmentToggle
|
||||
value={formData.judgment}
|
||||
onChange={(v) => setFormData((prev) => ({ ...prev, judgment: v }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 인장강도 / 연신율 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">인장강도 (270 이상)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder=""
|
||||
value={formData.tensileStrength ?? ''}
|
||||
onChange={(e) => handleNumberChange('tensileStrength', e.target.value)}
|
||||
className="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">연신율 (36 이상)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder=""
|
||||
value={formData.elongation ?? ''}
|
||||
onChange={(e) => handleNumberChange('elongation', e.target.value)}
|
||||
className="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 아연의 최소 부착량 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">아연의 최소 부착량 (17 이상)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder=""
|
||||
value={formData.zincCoating ?? ''}
|
||||
onChange={(e) => handleNumberChange('zincCoating', e.target.value)}
|
||||
className="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">내용</Label>
|
||||
<Textarea
|
||||
value={formData.content}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, content: e.target.value }))
|
||||
}
|
||||
placeholder=""
|
||||
className="bg-gray-800 border-gray-700 text-white min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex gap-3 mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
className="flex-1 bg-gray-800 border-gray-700 text-white hover:bg-gray-700"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleComplete}
|
||||
className="flex-1 bg-orange-500 hover:bg-orange-600 text-white"
|
||||
>
|
||||
검사 완료
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { Upload, FileText, Search, X, Plus, ClipboardCheck } from 'lucide-react'
|
||||
import { FileDropzone } from '@/components/ui/file-dropzone';
|
||||
import { ItemSearchModal } from '@/components/quotes/ItemSearchModal';
|
||||
import { InspectionModalV2 } from '@/app/[locale]/(protected)/quality/qms/components/InspectionModalV2';
|
||||
import { ImportInspectionInputModal, type ImportInspectionData } from './ImportInspectionInputModal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -129,6 +130,8 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
|
||||
// 수입검사 성적서 모달 상태
|
||||
const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false);
|
||||
// 수입검사 입력 모달 상태
|
||||
const [isImportInspectionModalOpen, setIsImportInspectionModalOpen] = useState(false);
|
||||
const [isItemSearchOpen, setIsItemSearchOpen] = useState(false);
|
||||
const [isSupplierSearchOpen, setIsSupplierSearchOpen] = useState(false);
|
||||
|
||||
@@ -253,11 +256,23 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
// 수입검사하기 버튼 핸들러 - 모달로 표시
|
||||
// 수입검사하기 버튼 핸들러 - 수입검사 입력 모달 표시
|
||||
const handleInspection = () => {
|
||||
setIsImportInspectionModalOpen(true);
|
||||
};
|
||||
|
||||
// 수입검사성적서 보기 버튼 핸들러 - 성적서 모달 표시
|
||||
const handleViewInspectionReport = () => {
|
||||
setIsInspectionModalOpen(true);
|
||||
};
|
||||
|
||||
// 수입검사 완료 핸들러
|
||||
const handleImportInspectionComplete = (data: ImportInspectionData) => {
|
||||
console.log('수입검사 완료:', data);
|
||||
toast.success('수입검사가 완료되었습니다.');
|
||||
// TODO: API 호출하여 검사 결과 저장
|
||||
};
|
||||
|
||||
// 재고 조정 행 추가
|
||||
const handleAddAdjustment = () => {
|
||||
const newRecord: InventoryAdjustmentRecord = {
|
||||
@@ -689,10 +704,16 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
// ===== 커스텀 헤더 액션 (view/edit 모드) =====
|
||||
// 수정 버튼은 IntegratedDetailTemplate의 DetailActions에서 아이콘으로 제공하므로 중복 제거
|
||||
const customHeaderActions = (isViewMode || isEditMode) && detail ? (
|
||||
<Button variant="outline" size="sm" onClick={handleInspection}>
|
||||
<ClipboardCheck className="w-4 h-4 md:mr-2" />
|
||||
<span className="hidden md:inline">수입검사하기</span>
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleInspection}>
|
||||
<ClipboardCheck className="w-4 h-4 md:mr-2" />
|
||||
<span className="hidden md:inline">수입검사하기</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleViewInspectionReport}>
|
||||
<FileText className="w-4 h-4 md:mr-2" />
|
||||
<span className="hidden md:inline">수입검사성적서 보기</span>
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
// 에러 상태 표시 (view/edit 모드에서만)
|
||||
@@ -769,7 +790,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
/>
|
||||
*/}
|
||||
|
||||
{/* 수입검사 성적서 모달 */}
|
||||
{/* 수입검사 성적서 모달 (읽기 전용) */}
|
||||
<InspectionModalV2
|
||||
isOpen={isInspectionModalOpen}
|
||||
onClose={() => setIsInspectionModalOpen(false)}
|
||||
@@ -789,6 +810,16 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
itemName={detail?.itemName}
|
||||
specification={detail?.specification}
|
||||
supplier={detail?.supplier}
|
||||
readOnly={true}
|
||||
/>
|
||||
|
||||
{/* 수입검사 입력 모달 */}
|
||||
<ImportInspectionInputModal
|
||||
open={isImportInspectionModalOpen}
|
||||
onOpenChange={setIsImportInspectionModalOpen}
|
||||
productName={detail?.itemName}
|
||||
specification={detail?.specification}
|
||||
onComplete={handleImportInspectionComplete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user