'use client'; /** * 수입검사 입력 모달 (패드/모바일용) * * API 기반 동적 폼: * - getInspectionTemplate()로 검사항목 로드 * - 단일 측정 (measurementCount <= 1): 탭 없이 항상 표시 * - 다중 측정 (measurementCount > 1): N1~Nn 탭으로 전환 * - 사진 다중 첨부 * - 자동 판정 (tolerance / standard_criteria 기반) * - saveInspectionData()로 저장 */ import { useState, useEffect, useMemo, useCallback } 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 { 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'; // ===== Props ===== interface ImportInspectionInputModalProps { open: boolean; onOpenChange: (open: boolean) => void; itemId?: number; itemName?: string; specification?: string; supplier?: string; inspector?: string; lotSize?: number; materialNo?: string; receivingId: string; onSave?: () => void; } // ===== 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, }: { value: 'ok' | 'ng' | null; onChange: (v: 'ok' | 'ng') => void; }) { return (
); } // ===== 적합/부적합 토글 ===== function JudgmentToggle({ value, onChange, }: { value: 'pass' | 'fail' | null; onChange: (v: 'pass' | 'fail') => void; }) { return (
); } // ===== Main Component ===== export function ImportInspectionInputModal({ open, onOpenChange, itemId, itemName, specification, supplier, inspector, lotSize, materialNo, receivingId, onSave, }: ImportInspectionInputModalProps) { // Template const [template, setTemplate] = useState(null); const [resolveData, setResolveData] = useState(null); const [isLoadingTemplate, setIsLoadingTemplate] = useState(false); const [isSaving, setIsSaving] = useState(false); // Form const [currentTab, setCurrentTab] = useState(0); const [measurements, setMeasurements] = useState>>({}); const [okngValues, setOkngValues] = useState>>({}); const [overallResult, setOverallResult] = useState<'pass' | 'fail' | null>(null); const [remark, setRemark] = useState(''); // Photos const [newPhotos, setNewPhotos] = useState([]); const [existingPhotos, setExistingPhotos] = useState([]); // ===== 단일 / 다중 항목 분리 ===== // isFirstInItem === false인 항목은 같은 category+item의 옵션 행(중복)이므로 제외 const { singleItems, multiItems, maxN } = useMemo(() => { if (!template) return { singleItems: [] as InspectionItem[], multiItems: [] as InspectionItem[], maxN: 0 }; const unique = template.inspectionItems.filter(i => i.isFirstInItem !== false); const single = unique.filter(i => i.measurementCount <= 1); const multi = unique.filter(i => i.measurementCount > 1); const n = multi.length > 0 ? Math.max(...multi.map(i => i.measurementCount)) : 0; return { singleItems: single, multiItems: multi, maxN: n }; }, [template]); // ===== Raw data map (tolerance / criteria) for auto-judgment ===== const rawDataMap = useMemo(() => { const map = new Map(); if (!resolveData) return map; for (const section of resolveData.template.sections) { for (const item of section.items) { map.set(String(item.id), { tolerance: (item.tolerance && typeof item.tolerance === 'object') ? item.tolerance as RawToleranceData : null, criteria: item.standard_criteria || null, }); } } return map; }, [resolveData]); // ===== Base values from item attributes (두께, 너비, 길이) ===== const baseValueMap = useMemo(() => { const map = new Map(); if (!resolveData?.item?.attributes || !template) return map; const attrs = resolveData.item.attributes as { thickness?: number; width?: number; length?: number }; const dimensionNames: Record = { '두께': attrs.thickness, '너비': attrs.width, '길이': attrs.length, }; for (const item of template.inspectionItems) { const key = item.subName || item.name; if (dimensionNames[key] != null) { map.set(item.id, dimensionNames[key]!); } } return map; }, [resolveData, template]); // ===== Load template on modal open ===== useEffect(() => { if (!open || !itemId) return; setIsLoadingTemplate(true); getInspectionTemplate({ itemId, itemName, specification, supplier, inspector, lotSize, materialNo, }) .then((result) => { if (result.success && result.data) { setTemplate(result.data); if (result.resolveData) { setResolveData(result.resolveData); loadExistingData(result.data, result.resolveData); } else { resetForm(); } } else { toast.error(result.error || '검사 템플릿 로드 실패'); } }) .finally(() => setIsLoadingTemplate(false)); }, [open, itemId]); // eslint-disable-line react-hooks/exhaustive-deps // ===== Load existing saved data (재검사) ===== const loadExistingData = useCallback( (tmpl: InspectionTemplateResponse, resolve: DocumentResolveResponse) => { const doc = resolve.document; if (!doc?.data?.length) { resetForm(); return; } const meas: Record> = {}; const okng: Record> = {}; let savedRemark = ''; let savedOverall: 'pass' | 'fail' | null = null; for (const d of doc.data) { const key = d.field_key; const val = d.field_value || ''; // {itemId}_n{index} - 숫자 측정값 const nMatch = key.match(/^(\d+)_n(\d+)$/); if (nMatch) { const id = nMatch[1]; const idx = parseInt(nMatch[2]) - 1; if (!meas[id]) meas[id] = {}; meas[id][idx] = val; continue; } // {itemId}_okng_n{index} - OK/NG 값 const okngMatch = key.match(/^(\d+)_okng_n(\d+)$/); if (okngMatch) { const id = okngMatch[1]; const idx = parseInt(okngMatch[2]) - 1; if (!okng[id]) okng[id] = {}; okng[id][idx] = val === 'ok' ? 'ok' : val === 'ng' ? 'ng' : null; continue; } if (key === 'remark') savedRemark = val; if (key === 'overall_result') savedOverall = val === 'pass' ? 'pass' : val === 'fail' ? 'fail' : null; } setMeasurements(meas); setOkngValues(okng); setRemark(savedRemark); setOverallResult(savedOverall); setCurrentTab(0); setNewPhotos([]); // 기존 첨부 사진 로드 if (doc.attachments?.length) { setExistingPhotos( doc.attachments .filter((a) => a.attachment_type === 'image' && a.file) .map((a) => ({ id: a.file_id, name: a.file!.display_name || a.file!.original_name || `file-${a.file_id}`, size: a.file!.file_size, url: `/api/proxy/files/${a.file_id}/download`, })) ); } else { setExistingPhotos([]); } }, [] ); const resetForm = useCallback(() => { setMeasurements({}); setOkngValues({}); setOverallResult(null); setRemark(''); setNewPhotos([]); setExistingPhotos([]); setCurrentTab(0); }, []); // ===== 단일 측정값의 자동 판정 ===== const getMeasurementResult = useCallback( (itemId: string, nIndex: number): 'ok' | 'ng' | null => { const v = measurements[itemId]?.[nIndex]; if (!v) return null; const raw = rawDataMap.get(itemId); const base = baseValueMap.get(itemId); return calculateAutoResult(v, raw?.tolerance, raw?.criteria, base); }, [measurements, rawDataMap, baseValueMap] ); // ===== 항목별 종합 판정 (c=0: 모든 N 측정값이 입력+통과해야 OK) ===== const getItemResult = useCallback( (item: InspectionItem): 'ok' | 'ng' | null => { const isOkng = item.measurementType === 'okng'; let allFilled = true; let hasNg = false; for (let n = 0; n < item.measurementCount; n++) { if (isOkng) { const v = okngValues[item.id]?.[n]; if (!v) { allFilled = false; } else if (v === 'ng') { hasNg = true; } } else { const v = measurements[item.id]?.[n]; if (!v) { allFilled = false; } else { const raw = rawDataMap.get(item.id); const base = baseValueMap.get(item.id); const r = calculateAutoResult(v, raw?.tolerance, raw?.criteria, base); if (r === 'ng') hasNg = true; } } } // NG가 하나라도 있으면 즉시 부적합 if (hasNg) return 'ng'; // 모든 N이 입력 완료되어야 OK if (allFilled) return 'ok'; // 미완료 return null; }, [measurements, okngValues, rawDataMap, baseValueMap] ); // ===== 종합 판정 자동 계산 (모든 항목 완료+OK여야 적합) ===== useEffect(() => { if (!template) return; // 실제 렌더링 대상 항목만 (중복 옵션 행 제외) const targetItems = template.inspectionItems.filter(i => i.isFirstInItem !== false); let hasNg = false; let allComplete = true; for (const item of targetItems) { const r = getItemResult(item); if (r === 'ng') { hasNg = true; } else if (r === null) { allComplete = false; } } // NG가 있으면 즉시 부적합 if (hasNg) { setOverallResult('fail'); } else if (allComplete) { // 모든 항목이 완료되고 전부 OK → 적합 setOverallResult('pass'); } else { // 미완료 상태에서는 판정 초기화 setOverallResult(null); } }, [template, getItemResult]); // ===== Handlers ===== const handleMeasurementChange = useCallback( (id: string, nIndex: number, value: string) => { setMeasurements((prev) => ({ ...prev, [id]: { ...prev[id], [nIndex]: value }, })); }, [] ); const handleOkngChange = useCallback( (id: string, nIndex: number, value: 'ok' | 'ng') => { setOkngValues((prev) => ({ ...prev, [id]: { ...prev[id], [nIndex]: value }, })); }, [] ); // ===== 저장 ===== const handleSave = async () => { if (!template || !resolveData || !itemId) return; setIsSaving(true); try { // 1. 사진 업로드 let uploadedFileIds: number[] = []; if (newPhotos.length > 0) { const uploadResult = await uploadInspectionFiles(newPhotos); if (!uploadResult.success) { toast.error(uploadResult.error || '사진 업로드 실패'); return; } uploadedFileIds = (uploadResult.data || []).map((f) => f.id); } // 2. field_key 기반 데이터 배열 생성 const data: Array<{ section_id?: number | null; row_index: number; field_key: string; field_value: string | null; }> = []; for (const item of template.inspectionItems) { const isOkng = item.measurementType === 'okng'; for (let n = 0; n < item.measurementCount; n++) { if (isOkng) { data.push({ row_index: 0, field_key: `${item.id}_okng_n${n + 1}`, field_value: okngValues[item.id]?.[n] || null, }); } else { data.push({ row_index: 0, field_key: `${item.id}_n${n + 1}`, field_value: measurements[item.id]?.[n] || null, }); } } // 항목별 판정 data.push({ row_index: 0, field_key: `${item.id}_result`, field_value: getItemResult(item), }); } // 종합판정 + 비고 data.push({ row_index: 0, field_key: 'overall_result', field_value: overallResult }); data.push({ row_index: 0, field_key: 'remark', field_value: remark || null }); // 3. 첨부파일 (기존 + 신규) const attachments = [ ...existingPhotos.map((p) => ({ file_id: Number(p.id), attachment_type: 'image', })), ...uploadedFileIds.map((id) => ({ file_id: id, attachment_type: 'image', })), ]; // 4. 저장 API 호출 const result = await saveInspectionData({ templateId: parseInt(template.templateId), itemId, title: `수입검사 - ${template.headerInfo.productName}`, data, attachments, receivingId, inspectionResult: overallResult, }); if (result.success) { toast.success('수입검사가 저장되었습니다.'); onSave?.(); onOpenChange(false); } else { toast.error(result.error || '저장에 실패했습니다.'); } } finally { setIsSaving(false); } }; // ===== 항목 라벨 텍스트 (기준값 포함) ===== const getItemLabelText = (item: InspectionItem) => { const name = item.subName || item.name; const base = baseValueMap.get(item.id); const desc = item.standard.description; if (base != null) { return `${name} (${base})`; } if (desc && desc !== '-' && desc !== name) { return `${name} (${desc})`; } return name; }; // ===== 단일 항목 렌더링 (라벨 위, 입력 아래) ===== const renderItemInput = (item: InspectionItem, nIndex: number) => { const isOkng = item.measurementType === 'okng'; const labelText = getItemLabelText(item); if (isOkng) { return (
{labelText} handleOkngChange(item.id, nIndex, v)} />
); } // numeric / single_value / substitute / both → 라벨 위 + 입력 아래 const result = getMeasurementResult(item.id, nIndex); const isSubstitute = item.measurementType === 'substitute'; return (
{labelText}
handleMeasurementChange(item.id, nIndex, e.target.value)} className={cn( 'h-11 w-full rounded-lg border-gray-300', result === 'ok' && 'border-green-500 bg-green-50', result === 'ng' && 'border-red-500 bg-red-50' )} />
); }; // ===== Render ===== return ( 수입검사 {isLoadingTemplate ? (
검사항목 로딩 중...
) : !template ? (
검사 템플릿을 불러올 수 없습니다.
) : (
{/* 기본 정보 (읽기전용 인풋 스타일) */}
제품명
규격
{/* 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))}
)} {/* 사진 첨부 */}
setNewPhotos((prev) => [...prev, ...files])} title="클릭하거나 파일을 드래그하세요" description="이미지/PDF 파일 (최대 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 /> )}
{/* 내용 (비고) */}
내용