Files
sam-react-prod/src/components/material/ReceivingManagement/ImportInspectionInputModal.tsx

798 lines
26 KiB
TypeScript

'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 (
<div className="flex gap-2 flex-1">
<button
type="button"
onClick={() => onChange('ok')}
className={cn(
'flex-1 py-2.5 rounded-lg text-sm font-bold transition-colors',
value === 'ok'
? 'bg-black text-white'
: 'bg-gray-100 text-gray-700 border border-gray-300 hover:bg-gray-200'
)}
>
OK
</button>
<button
type="button"
onClick={() => onChange('ng')}
className={cn(
'flex-1 py-2.5 rounded-lg text-sm font-bold transition-colors',
value === 'ng'
? 'bg-black text-white'
: 'bg-gray-100 text-gray-700 border border-gray-300 hover:bg-gray-200'
)}
>
NG
</button>
</div>
);
}
// ===== 적합/부적합 토글 =====
function JudgmentToggle({
value,
onChange,
}: {
value: 'pass' | 'fail' | null;
onChange: (v: 'pass' | 'fail') => void;
}) {
return (
<div className="flex gap-2 flex-1">
<button
type="button"
onClick={() => onChange('pass')}
className={cn(
'flex-1 py-2.5 rounded-lg text-sm font-bold transition-colors',
value === 'pass'
? 'bg-orange-600 text-white'
: 'bg-gray-100 text-gray-700 border border-gray-300 hover:bg-gray-200'
)}
>
</button>
<button
type="button"
onClick={() => onChange('fail')}
className={cn(
'flex-1 py-2.5 rounded-lg text-sm font-bold transition-colors',
value === 'fail'
? 'bg-black text-white'
: 'bg-gray-100 text-gray-700 border border-gray-300 hover:bg-gray-200'
)}
>
</button>
</div>
);
}
// ===== Main Component =====
export function ImportInspectionInputModal({
open,
onOpenChange,
itemId,
itemName,
specification,
supplier,
inspector,
lotSize,
materialNo,
receivingId,
onSave,
}: ImportInspectionInputModalProps) {
// Template
const [template, setTemplate] = useState<InspectionTemplateResponse | null>(null);
const [resolveData, setResolveData] = useState<DocumentResolveResponse | null>(null);
const [isLoadingTemplate, setIsLoadingTemplate] = useState(false);
const [isSaving, setIsSaving] = useState(false);
// Form
const [currentTab, setCurrentTab] = useState(0);
const [measurements, setMeasurements] = useState<Record<string, Record<number, string>>>({});
const [okngValues, setOkngValues] = useState<Record<string, Record<number, 'ok' | 'ng' | null>>>({});
const [overallResult, setOverallResult] = useState<'pass' | 'fail' | null>(null);
const [remark, setRemark] = useState('');
// Photos
const [newPhotos, setNewPhotos] = useState<File[]>([]);
const [existingPhotos, setExistingPhotos] = useState<ExistingFile[]>([]);
// ===== 단일 / 다중 항목 분리 =====
// 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<string, { tolerance: RawToleranceData | null; criteria: RawCriteriaData | null }>();
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<string, number>();
if (!resolveData?.item?.attributes || !template) return map;
const attrs = resolveData.item.attributes as { thickness?: number; width?: number; length?: number };
const dimensionNames: Record<string, number | undefined> = {
'두께': 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<string, Record<number, string>> = {};
const okng: Record<string, Record<number, 'ok' | 'ng' | null>> = {};
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 (
<div key={`${item.id}-${nIndex}`} className="col-span-2 space-y-2">
<span className="text-sm font-bold">{labelText}</span>
<OkNgToggle
value={okngValues[item.id]?.[nIndex] || null}
onChange={(v) => handleOkngChange(item.id, nIndex, v)}
/>
</div>
);
}
// numeric / single_value / substitute / both → 라벨 위 + 입력 아래
const result = getMeasurementResult(item.id, nIndex);
const isSubstitute = item.measurementType === 'substitute';
return (
<div key={`${item.id}-${nIndex}`} className="space-y-1.5">
<span className="text-sm font-bold">{labelText}</span>
<div className="relative">
<Input
type={isSubstitute ? 'text' : 'number'}
inputMode={isSubstitute ? 'text' : 'decimal'}
step="any"
value={measurements[item.id]?.[nIndex] ?? ''}
onChange={(e) => 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'
)}
/>
</div>
</div>
);
};
// ===== Render =====
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[95vw] max-w-[500px] sm:max-w-[500px] max-h-[90vh] flex flex-col p-0">
<DialogHeader className="px-6 pt-6 pb-0 shrink-0">
<DialogTitle className="text-lg font-bold"></DialogTitle>
</DialogHeader>
{isLoadingTemplate ? (
<div className="flex items-center justify-center py-12 px-6">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground"> ...</span>
</div>
) : !template ? (
<div className="text-center py-12 text-muted-foreground text-sm px-6">
릿 .
</div>
) : (
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-4 space-y-5">
{/* 기본 정보 (읽기전용 인풋 스타일) */}
<div className="space-y-3">
<div className="space-y-1.5">
<span className="text-sm font-bold"></span>
<Input
value={template.headerInfo.productName}
readOnly
className="h-11 bg-gray-100 border-gray-300 rounded-lg"
/>
</div>
<div className="space-y-1.5">
<span className="text-sm font-bold"></span>
<Input
value={template.headerInfo.specification}
readOnly
className="h-11 bg-gray-100 border-gray-300 rounded-lg"
/>
</div>
</div>
{/* N탭 측정 항목 (measurementCount > 1) — 그룹 카드 */}
{multiItems.length > 0 && maxN > 0 && (
<div className="border border-gray-300 rounded-xl overflow-hidden">
{/* N 탭 (밑줄 스타일) */}
<div className="flex border-b border-gray-300 bg-gray-50">
{Array.from({ length: maxN }, (_, i) => (
<button
key={i}
type="button"
onClick={() => setCurrentTab(i)}
className={cn(
'flex-1 py-2.5 text-sm font-medium transition-colors relative',
currentTab === i
? 'text-black'
: 'text-gray-400 hover:text-gray-600'
)}
>
N{i + 1}
{currentTab === i && (
<span className="absolute bottom-0 left-0 right-0 h-[2px] bg-black" />
)}
</button>
))}
</div>
{/* 현재 탭 항목 (2단 그리드) */}
<div className="grid grid-cols-2 gap-3 p-4">
{multiItems.map((item) => {
if (currentTab >= item.measurementCount) return null;
return renderItemInput(item, currentTab);
})}
</div>
</div>
)}
{/* 단일 측정 항목 (measurementCount <= 1, 2단 그리드) */}
{singleItems.length > 0 && (
<div className="grid grid-cols-2 gap-3">
{singleItems.map((item) => renderItemInput(item, 0))}
</div>
)}
{/* 사진 첨부 */}
<div className="space-y-2">
<FileDropzone
accept=".jpg,.jpeg,.png,.gif,.webp,.heic,.pdf"
multiple
maxSize={10}
compact
onFilesSelect={(files) => setNewPhotos((prev) => [...prev, ...files])}
title="클릭하거나 파일을 드래그하세요"
description="이미지/PDF 파일 (최대 10MB)"
/>
{(newPhotos.length > 0 || existingPhotos.length > 0) && (
<FileList
files={newPhotos.map((f) => ({ file: f }))}
existingFiles={existingPhotos}
onRemove={(index) =>
setNewPhotos((prev) => prev.filter((_, i) => i !== index))
}
onRemoveExisting={(id) =>
setExistingPhotos((prev) => prev.filter((p) => p.id !== id))
}
compact
/>
)}
</div>
{/* 내용 (비고) */}
<div className="space-y-1.5">
<span className="text-sm font-bold"></span>
<Textarea
value={remark}
onChange={(e) => setRemark(e.target.value)}
className="min-h-[80px] rounded-lg border-gray-300"
/>
</div>
</div>
)}
{/* 하단 고정: 판정 + 버튼 */}
<div className="shrink-0 border-t bg-white px-5 pb-5 pt-3 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)]">
{template && (
<div className="space-y-2 mb-4">
<span className="text-sm font-bold"></span>
<JudgmentToggle
value={overallResult}
onChange={(v) => setOverallResult(v)}
/>
</div>
)}
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="flex-1 h-11 rounded-lg border-gray-300 font-bold"
disabled={isSaving}
>
</Button>
<Button
onClick={handleSave}
className="flex-1 h-11 rounded-lg bg-black hover:bg-gray-800 text-white font-bold"
disabled={isSaving || !template}
>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
'검사 완료'
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}