798 lines
26 KiB
TypeScript
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>
|
|
);
|
|
}
|