diff --git a/src/components/material/ReceivingManagement/ImportInspectionInputModal.tsx b/src/components/material/ReceivingManagement/ImportInspectionInputModal.tsx index 79eb2ef8..f3420a6a 100644 --- a/src/components/material/ReceivingManagement/ImportInspectionInputModal.tsx +++ b/src/components/material/ReceivingManagement/ImportInspectionInputModal.tsx @@ -1,13 +1,18 @@ 'use client'; /** - * 수입검사 입력 모달 + * 수입검사 입력 모달 (패드/모바일용) * - * 작업자 화면 중간검사 모달 양식 참고 - * 기획서: 스크린샷 2026-02-05 오후 9.58.16 + * API 기반 동적 폼: + * - getInspectionTemplate()로 검사항목 로드 + * - 단일 측정 (measurementCount <= 1): 탭 없이 항상 표시 + * - 다중 측정 (measurementCount > 1): N1~Nn 탭으로 전환 + * - 사진 다중 첨부 + * - 자동 판정 (tolerance / standard_criteria 기반) + * - saveInspectionData()로 저장 */ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; import { Dialog, DialogContent, @@ -18,41 +23,110 @@ 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 { FileDropzone } from '@/components/ui/file-dropzone'; +import { FileList, type ExistingFile } from '@/components/ui/file-list'; +import { Loader2 } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { toast } from 'sonner'; +import { + getInspectionTemplate, + uploadInspectionFiles, + saveInspectionData, + type InspectionTemplateResponse, + type DocumentResolveResponse, +} from './actions'; -// 시료 탭 타입 -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; -} - +// ===== Props ===== interface ImportInspectionInputModalProps { open: boolean; onOpenChange: (open: boolean) => void; - productName?: string; + itemId?: number; + itemName?: string; specification?: string; - onComplete: (data: ImportInspectionData) => void; + supplier?: string; + inspector?: string; + lotSize?: number; + materialNo?: string; + receivingId: string; + onSave?: () => void; } -// OK/NG 버튼 컴포넌트 +// ===== Raw data types for auto-judgment ===== +interface RawToleranceData { + type?: string; + value?: string | number; + plus?: string | number; + minus?: string | number; + min?: string | number; + max?: string | number; + op?: string; +} + +interface RawCriteriaData { + min?: number | null; + min_op?: 'gt' | 'gte' | null; + max?: number | null; + max_op?: 'lt' | 'lte' | null; +} + +type InspectionItem = InspectionTemplateResponse['inspectionItems'][number]; + +// ===== Auto-judgment logic ===== +function calculateAutoResult( + value: string, + rawTolerance: RawToleranceData | null | undefined, + rawCriteria: RawCriteriaData | null | undefined, + baseValue: number | null | undefined, +): 'ok' | 'ng' | null { + const num = parseFloat(value); + if (isNaN(num)) return null; + + // 1. Tolerance-based (needs base value) + if (rawTolerance && baseValue != null) { + switch (rawTolerance.type) { + case 'symmetric': { + const tol = parseFloat(String(rawTolerance.value || 0)); + return Math.abs(num - baseValue) <= tol ? 'ok' : 'ng'; + } + case 'asymmetric': { + const plus = parseFloat(String(rawTolerance.plus || 0)); + const minus = parseFloat(String(rawTolerance.minus || 0)); + return (num >= baseValue - minus && num <= baseValue + plus) ? 'ok' : 'ng'; + } + case 'range': { + const min = rawTolerance.min != null ? parseFloat(String(rawTolerance.min)) : -Infinity; + const max = rawTolerance.max != null ? parseFloat(String(rawTolerance.max)) : Infinity; + return (num >= min && num <= max) ? 'ok' : 'ng'; + } + case 'limit': { + const lv = parseFloat(String(rawTolerance.value || 0)); + switch (rawTolerance.op) { + case 'gte': return num >= lv ? 'ok' : 'ng'; + case 'gt': return num > lv ? 'ok' : 'ng'; + case 'lte': return num <= lv ? 'ok' : 'ng'; + case 'lt': return num < lv ? 'ok' : 'ng'; + default: return null; + } + } + } + } + + // 2. Criteria-based (min/max range) + if (rawCriteria && (rawCriteria.min != null || rawCriteria.max != null)) { + let pass = true; + if (rawCriteria.min != null) { + pass = pass && (rawCriteria.min_op === 'gt' ? num > rawCriteria.min : num >= rawCriteria.min); + } + if (rawCriteria.max != null) { + pass = pass && (rawCriteria.max_op === 'lt' ? num < rawCriteria.max : num <= rawCriteria.max); + } + return pass ? 'ok' : 'ng'; + } + + return null; +} + +// ===== OK/NG 토글 ===== function OkNgToggle({ value, onChange, @@ -61,15 +135,15 @@ function OkNgToggle({ onChange: (v: 'ok' | 'ng') => void; }) { return ( -
+
- ))} -
- - {/* 겉모양: OK/NG */} -
- - setFormData((prev) => ({ ...prev, appearanceStatus: v }))} - /> -
- - {/* 두께 / 너비 */} -
-
- - handleNumberChange('thickness', e.target.value)} - className="" - /> + ) : ( +
+ {/* 기본 정보 (읽기전용 인풋 스타일) */} +
+
+ 제품명 + +
+
+ 규격 + +
+ + {/* N탭 측정 항목 (measurementCount > 1) — 그룹 카드 */} + {multiItems.length > 0 && maxN > 0 && ( +
+ {/* N 탭 (밑줄 스타일) */} +
+ {Array.from({ length: maxN }, (_, i) => ( + + ))} +
+ + {/* 현재 탭 항목 (2단 그리드) */} +
+ {multiItems.map((item) => { + if (currentTab >= item.measurementCount) return null; + return renderItemInput(item, currentTab); + })} +
+
+ )} + + {/* 단일 측정 항목 (measurementCount <= 1, 2단 그리드) */} + {singleItems.length > 0 && ( +
+ {singleItems.map((item) => renderItemInput(item, 0))} +
+ )} + + {/* 사진 첨부 */}
- - handleNumberChange('width', e.target.value)} - className="" + setNewPhotos((prev) => [...prev, ...files])} + title="클릭하거나 사진을 드래그하세요" + description="이미지 파일 (최대 10MB)" + /> + {(newPhotos.length > 0 || existingPhotos.length > 0) && ( + ({ file: f }))} + existingFiles={existingPhotos} + onRemove={(index) => + setNewPhotos((prev) => prev.filter((_, i) => i !== index)) + } + onRemoveExisting={(id) => + setExistingPhotos((prev) => prev.filter((p) => p.id !== id)) + } + compact + /> + )} +
+ + {/* 내용 (비고) */} +
+ 내용 +