Files
sam-react-prod/src/components/items/DynamicItemForm/index.tsx
byeongcheolryu c026130a65 feat: 품목관리 파일 업로드 기능 개선
- 파일 업로드 API에 field_key, file_id 파라미터 추가
- ItemMaster 타입에 files 필드 추가 (새 API 구조 지원)
- DynamicItemForm에서 files 객체 파싱 로직 추가
- 시방서/인정서 파일 UI 개선: 파일명 표시 + 다운로드/수정/삭제 버튼
- 기존 API 구조와 새 API 구조 모두 지원 (폴백 처리)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 18:35:43 +09:00

2045 lines
86 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* DynamicItemForm - 품목기준관리 API 기반 동적 품목 등록 폼
*
* 기존 ItemForm과 100% 동일한 디자인 유지
*/
'use client';
import { useState, useEffect, useMemo, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { Package, Save, X, FileText, Trash2, Download, Pencil } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { PageLoadingSpinner } from '@/components/ui/loading-spinner';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import ItemTypeSelect from '../ItemTypeSelect';
import BendingDiagramSection from '../ItemForm/BendingDiagramSection';
import { DrawingCanvas } from '../DrawingCanvas';
import { useFormStructure, useDynamicFormState, useConditionalDisplay } from './hooks';
import { DynamicFieldRenderer } from './fields';
import { DynamicBOMSection } from './sections';
import {
generateItemCode,
generateAssemblyItemNameSimple,
generateAssemblySpecification,
generateBendingItemCodeSimple,
generatePurchasedItemCode,
} from './utils/itemCodeGenerator';
import type { DynamicItemFormProps, DynamicFormData, DynamicSection, DynamicFieldValue, BOMLine, BOMSearchState, ItemSaveResult } from './types';
import type { ItemType, BendingDetail } from '@/types/item';
import type { ItemFieldResponse } from '@/types/item-master-api';
import { uploadItemFile, deleteItemFile, ItemFileType, checkItemCodeDuplicate, DuplicateCheckResult } from '@/lib/api/items';
import { DuplicateCodeError } from '@/lib/api/error-handler';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
/**
* 헤더 컴포넌트 - 기존 FormHeader와 동일한 디자인
*/
function FormHeader({
mode,
selectedItemType,
isSubmitting,
onCancel,
}: {
mode: 'create' | 'edit';
selectedItemType: string;
isSubmitting: boolean;
onCancel: () => void;
}) {
return (
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="p-2 bg-primary/10 rounded-lg hidden md:block">
<Package className="w-6 h-6 text-primary" />
</div>
<div>
<h1 className="text-xl md:text-2xl">
{mode === 'create' ? '품목 등록' : '품목 수정'}
</h1>
<p className="text-sm text-muted-foreground mt-1">
</p>
</div>
</div>
<div className="flex gap-1 sm:gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={onCancel}
className="gap-1 sm:gap-2"
disabled={isSubmitting}
>
<X className="h-4 w-4" />
<span className="hidden sm:inline"></span>
</Button>
<Button
type="submit"
size="sm"
disabled={!selectedItemType || isSubmitting}
className="gap-1 sm:gap-2"
>
<Save className="h-4 w-4" />
<span className="hidden sm:inline">{isSubmitting ? '저장 중...' : '저장'}</span>
</Button>
</div>
</div>
);
}
/**
* 밸리데이션 에러 Alert - 기존 ValidationAlert와 동일한 디자인
*/
function ValidationAlert({ errors }: { errors: Record<string, string> }) {
const errorCount = Object.keys(errors).length;
if (errorCount === 0) {
return null;
}
return (
<Alert className="bg-red-50 border-red-200">
<AlertDescription className="text-red-900">
<div className="flex items-start gap-2">
<span className="text-lg"></span>
<div className="flex-1">
<strong className="block mb-2">
({errorCount} )
</strong>
<ul className="space-y-1 text-sm">
{Object.entries(errors).map(([fieldKey, errorMessage]) => (
<li key={fieldKey} className="flex items-start gap-1">
<span></span>
<span>{errorMessage}</span>
</li>
))}
</ul>
</div>
</div>
</AlertDescription>
</Alert>
);
}
/**
* 동적 섹션 렌더러
*/
function DynamicSectionRenderer({
section,
formData,
errors,
onChange,
disabled,
unitOptions,
autoGeneratedItemCode,
shouldShowField,
}: {
section: DynamicSection;
formData: DynamicFormData;
errors: Record<string, string>;
onChange: (fieldKey: string, value: DynamicFieldValue) => void;
disabled?: boolean;
unitOptions: { label: string; value: string }[];
autoGeneratedItemCode?: string;
shouldShowField?: (fieldId: number) => boolean;
}) {
// 필드를 order_no 기준 정렬
const sortedFields = [...section.fields].sort((a, b) => a.orderNo - b.orderNo);
// 이 섹션에 item_name과 specification 필드가 둘 다 있는지 체크
// field_key가 "{id}_item_name" 형식으로 올 수 있어서 includes로 체크
const fieldKeys = sortedFields.map((f) => f.field.field_key || `field_${f.field.id}`);
const hasItemName = fieldKeys.some((k) => k.includes('item_name'));
const hasSpecification = fieldKeys.some((k) => k.includes('specification'));
const shouldShowItemCode = hasItemName && hasSpecification && autoGeneratedItemCode !== undefined;
return (
<Card>
<CardHeader>
<CardTitle>{section.section.title}</CardTitle>
{section.section.description && (
<p className="text-sm text-muted-foreground">
{section.section.description}
</p>
)}
</CardHeader>
<CardContent className="space-y-4">
{sortedFields.map((dynamicField) => {
const field = dynamicField.field;
const fieldKey = field.field_key || `field_${field.id}`;
// 필드 조건부 표시 체크
if (shouldShowField && !shouldShowField(field.id)) {
return null;
}
return (
<DynamicFieldRenderer
key={field.id}
field={field}
value={formData[fieldKey]}
onChange={(value) => onChange(fieldKey, value)}
error={errors[fieldKey]}
disabled={disabled}
unitOptions={unitOptions}
/>
);
})}
{/* 품목코드 자동생성 필드 (item_name + specification 있는 섹션에만 표시) */}
{shouldShowItemCode && (
<div>
<Label htmlFor="item_code_auto"> ()</Label>
<Input
id="item_code_auto"
value={autoGeneratedItemCode || ''}
placeholder="품목명과 규격이 입력되면 자동으로 생성됩니다"
disabled
className="bg-muted text-muted-foreground"
/>
<p className="text-xs text-muted-foreground mt-1">
* &apos;-&apos;
</p>
</div>
)}
</CardContent>
</Card>
);
}
/**
* 메인 DynamicItemForm 컴포넌트
*/
export default function DynamicItemForm({
mode,
itemType: initialItemType,
itemId: propItemId,
initialData,
initialBomLines,
onSubmit,
}: DynamicItemFormProps) {
const router = useRouter();
// 품목 유형 상태 (변경 가능)
const [selectedItemType, setSelectedItemType] = useState<ItemType | ''>(initialItemType || '');
// 폼 구조 로드 (품목 유형에 따라)
const { structure, isLoading, error: structureError, unitOptions } = useFormStructure(
selectedItemType as 'FG' | 'PT' | 'SM' | 'RM' | 'CS'
);
// 폼 상태 관리
const {
formData,
errors,
isSubmitting,
setFieldValue,
validateAll,
handleSubmit,
resetForm,
} = useDynamicFormState(initialData);
// BOM 상태 관리
const [bomLines, setBomLines] = useState<BOMLine[]>([]);
const [bomSearchStates, setBomSearchStates] = useState<Record<string, BOMSearchState>>({});
// 절곡품 전개도 상태 관리 (PT - 절곡 부품 전용)
const [bendingDiagramInputMethod, setBendingDiagramInputMethod] = useState<'file' | 'drawing'>('file');
const [bendingDiagram, setBendingDiagram] = useState<string>('');
const [bendingDiagramFile, setBendingDiagramFile] = useState<File | null>(null);
const [isDrawingOpen, setIsDrawingOpen] = useState(false);
const [bendingDetails, setBendingDetails] = useState<BendingDetail[]>([]);
const [widthSum, setWidthSum] = useState<string>('');
// FG(제품) 전용 파일 업로드 상태 관리
const [specificationFile, setSpecificationFile] = useState<File | null>(null);
const [certificationFile, setCertificationFile] = useState<File | null>(null);
// 기존 파일 URL 상태 (edit 모드에서 사용)
const [existingBendingDiagram, setExistingBendingDiagram] = useState<string>('');
const [existingBendingDiagramFileId, setExistingBendingDiagramFileId] = useState<number | null>(null);
const [existingSpecificationFile, setExistingSpecificationFile] = useState<string>('');
const [existingSpecificationFileName, setExistingSpecificationFileName] = useState<string>('');
const [existingSpecificationFileId, setExistingSpecificationFileId] = useState<number | null>(null);
const [existingCertificationFile, setExistingCertificationFile] = useState<string>('');
const [existingCertificationFileName, setExistingCertificationFileName] = useState<string>('');
const [existingCertificationFileId, setExistingCertificationFileId] = useState<number | null>(null);
const [isDeletingFile, setIsDeletingFile] = useState<string | null>(null);
// 품목코드 중복 체크 상태 관리
const [showDuplicateDialog, setShowDuplicateDialog] = useState(false);
const [duplicateCheckResult, setDuplicateCheckResult] = useState<DuplicateCheckResult | null>(null);
const [pendingSubmitData, setPendingSubmitData] = useState<DynamicFormData | null>(null);
// initialData에서 기존 파일 정보 및 전개도 상세 데이터 로드 (edit 모드)
useEffect(() => {
if (mode === 'edit' && initialData) {
// 새 API 구조: files 객체에서 파일 정보 추출
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const files = (initialData as any).files as {
bending_diagram?: Array<{ id: number; file_name: string; file_path: string }>;
specification?: Array<{ id: number; file_name: string; file_path: string }>;
certification?: Array<{ id: number; file_name: string; file_path: string }>;
} | undefined;
// 전개도 파일 (새 API 구조 우선, 기존 구조 폴백)
if (files?.bending_diagram?.[0]) {
const bendingFile = files.bending_diagram[0];
setExistingBendingDiagram(bendingFile.file_path);
setExistingBendingDiagramFileId(bendingFile.id);
} else if (initialData.bending_diagram) {
setExistingBendingDiagram(initialData.bending_diagram as string);
}
// 시방서 파일 (새 API 구조 우선, 기존 구조 폴백)
if (files?.specification?.[0]) {
const specFile = files.specification[0];
setExistingSpecificationFile(specFile.file_path);
setExistingSpecificationFileName(specFile.file_name);
setExistingSpecificationFileId(specFile.id);
} else if (initialData.specification_file) {
setExistingSpecificationFile(initialData.specification_file as string);
setExistingSpecificationFileName((initialData.specification_file_name as string) || '시방서');
}
// 인정서 파일 (새 API 구조 우선, 기존 구조 폴백)
if (files?.certification?.[0]) {
const certFile = files.certification[0];
setExistingCertificationFile(certFile.file_path);
setExistingCertificationFileName(certFile.file_name);
setExistingCertificationFileId(certFile.id);
} else if (initialData.certification_file) {
setExistingCertificationFile(initialData.certification_file as string);
setExistingCertificationFileName((initialData.certification_file_name as string) || '인정서');
}
// 전개도 상세 데이터 로드 (bending_details)
if (initialData.bending_details) {
const details = Array.isArray(initialData.bending_details)
? initialData.bending_details
: (typeof initialData.bending_details === 'string'
? JSON.parse(initialData.bending_details)
: []);
if (details.length > 0) {
// BendingDetail 형식으로 변환
const mappedDetails: BendingDetail[] = details.map((d: Record<string, unknown>, index: number) => ({
id: (d.id as string) || `detail-${Date.now()}-${index}`,
no: (d.no as number) || index + 1,
input: (d.input as number) ?? 0,
elongation: (d.elongation as number) ?? -1,
calculated: (d.calculated as number) ?? 0,
sum: (d.sum as number) ?? 0,
shaded: (d.shaded as boolean) ?? false,
aAngle: d.aAngle as number | undefined,
}));
setBendingDetails(mappedDetails);
// 폭 합계도 계산하여 설정
const totalSum = mappedDetails.reduce((acc, detail) => {
return acc + detail.input + detail.elongation;
}, 0);
setWidthSum(totalSum.toString());
}
}
}
}, [mode, initialData]);
// initialBomLines prop으로 BOM 데이터 로드 (edit 모드)
// 2025-12-12: edit 페이지에서 별도로 전달받은 BOM 데이터 사용
useEffect(() => {
if (mode === 'edit' && initialBomLines && initialBomLines.length > 0) {
setBomLines(initialBomLines);
console.log('[DynamicItemForm] initialBomLines로 BOM 데이터 로드:', initialBomLines.length, '건');
}
}, [mode, initialBomLines]);
// Storage 경로를 전체 URL로 변환
const getStorageUrl = (path: string | undefined): string | null => {
if (!path) return null;
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
return `${apiUrl}/storage/${path}`;
};
// 파일 삭제 핸들러
const handleDeleteFile = async (fileType: ItemFileType) => {
if (!propItemId) return;
const confirmMessage = fileType === 'bending_diagram' ? '전개도 이미지를' :
fileType === 'specification' ? '시방서 파일을' : '인정서 파일을';
if (!confirm(`${confirmMessage} 삭제하시겠습니까?`)) return;
try {
setIsDeletingFile(fileType);
await deleteItemFile(propItemId, fileType);
// 상태 업데이트
if (fileType === 'bending_diagram') {
setExistingBendingDiagram('');
setBendingDiagram('');
} else if (fileType === 'specification') {
setExistingSpecificationFile('');
setExistingSpecificationFileName('');
} else if (fileType === 'certification') {
setExistingCertificationFile('');
setExistingCertificationFileName('');
}
alert('파일이 삭제되었습니다.');
} catch (error) {
console.error('[DynamicItemForm] 파일 삭제 실패:', error);
alert('파일 삭제에 실패했습니다.');
} finally {
setIsDeletingFile(null);
}
};
// 조건부 표시 관리
const { shouldShowSection, shouldShowField } = useConditionalDisplay(structure, formData);
// PT(부품) 품목코드 자동생성용 - 기존 품목코드 목록
const [existingItemCodes, setExistingItemCodes] = useState<string[]>([]);
// PT(부품) 선택 시 기존 품목코드 목록 조회
useEffect(() => {
if (selectedItemType === 'PT') {
// PT 품목 목록 조회하여 기존 코드 수집
const fetchExistingCodes = async () => {
try {
const response = await fetch('/api/proxy/items?type=PT&size=1000');
const result = await response.json();
if (result.success && result.data?.data) {
const codes = result.data.data
.map((item: { code?: string; item_code?: string }) => item.code || item.item_code || '')
.filter((code: string) => code);
setExistingItemCodes(codes);
// console.log('[DynamicItemForm] PT 기존 품목코드 로드:', codes.length, '개');
}
} catch (err) {
console.error('[DynamicItemForm] PT 품목코드 조회 실패:', err);
setExistingItemCodes([]);
}
};
fetchExistingCodes();
} else {
setExistingItemCodes([]);
}
}, [selectedItemType]);
// 품목 유형 변경 시 폼 초기화 (create 모드)
useEffect(() => {
if (selectedItemType && mode === 'create' && structure) {
// 기본값 설정
const defaults: DynamicFormData = {
item_type: selectedItemType,
};
// 구조에서 기본값 추출
structure.sections.forEach((section) => {
section.fields.forEach((f) => {
const field = f.field;
const fieldKey = field.field_key || `field_${field.id}`;
if (field.default_value !== null && field.default_value !== undefined) {
defaults[fieldKey] = field.default_value;
}
});
});
structure.directFields.forEach((f) => {
const field = f.field;
const fieldKey = field.field_key || `field_${field.id}`;
if (field.default_value !== null && field.default_value !== undefined) {
defaults[fieldKey] = field.default_value;
}
});
resetForm(defaults);
// BOM 상태 초기화 - 빈 상태로 시작 (사용자가 추가 버튼으로 행 추가)
setBomLines([]);
setBomSearchStates({});
}
}, [selectedItemType, structure, mode, resetForm]);
// Edit 모드: initialData를 폼에 직접 로드
// 2025-12-09: field_key 통일로 복잡한 매핑 로직 제거
// 백엔드에서 field_key 그대로 응답하므로 직접 사용 가능
const [isEditDataMapped, setIsEditDataMapped] = useState(false);
useEffect(() => {
console.log('[DynamicItemForm] Edit useEffect 체크:', {
mode,
hasStructure: !!structure,
hasInitialData: !!initialData,
isEditDataMapped,
structureSections: structure?.sections?.length,
});
if (mode !== 'edit' || !structure || !initialData || isEditDataMapped) return;
console.log('[DynamicItemForm] Edit mode: initialData 직접 로드 (field_key 통일됨)');
console.log('[DynamicItemForm] initialData:', initialData);
// structure의 field_key들 확인
const fieldKeys: string[] = [];
structure.sections.forEach((section) => {
section.fields.forEach((f) => {
fieldKeys.push(f.field.field_key || `field_${f.field.id}`);
});
});
console.log('[DynamicItemForm] structure field_keys:', fieldKeys);
console.log('[DynamicItemForm] initialData keys:', Object.keys(initialData));
// field_key가 통일되었으므로 initialData를 그대로 사용
// 기존 레거시 데이터(98_unit 형식)도 그대로 동작
resetForm(initialData);
setIsEditDataMapped(true);
}, [mode, structure, initialData, isEditDataMapped, resetForm]);
// 모든 필드 목록 (밸리데이션용) - 숨겨진 섹션/필드 제외
const allFields = useMemo<ItemFieldResponse[]>(() => {
if (!structure) return [];
const fields: ItemFieldResponse[] = [];
// 표시되는 섹션의 표시되는 필드만 포함
structure.sections.forEach((section) => {
// 섹션이 숨겨져 있으면 스킵 (조건부 표시)
if (!shouldShowSection(section.section.id)) return;
section.fields.forEach((f) => {
// 필드가 숨겨져 있으면 스킵 (조건부 표시)
if (!shouldShowField(f.field.id)) return;
fields.push(f.field);
});
});
// 직접 필드도 필터링 (조건부 표시)
structure.directFields.forEach((f) => {
if (!shouldShowField(f.field.id)) return;
fields.push(f.field);
});
return fields;
}, [structure, shouldShowSection, shouldShowField]);
// 품목코드 자동생성 관련 필드 정보
// field_key 또는 field_name 기준으로 품목명/규격 필드 탐지
// 2025-12-03: 연동 드롭다운 로직 제거 - 조건부 섹션 표시로 대체
const { hasAutoItemCode, itemNameKey, allSpecificationKeys, statusFieldKey } = useMemo(() => {
if (!structure) return { hasAutoItemCode: false, itemNameKey: '', allSpecificationKeys: [] as string[], statusFieldKey: '' };
let foundItemNameKey = '';
let foundStatusFieldKey = '';
const specificationKeys: string[] = []; // 모든 규격 필드 키 수집
const checkField = (fieldKey: string, field: ItemFieldResponse) => {
const fieldName = field.field_name || '';
// 품목명 필드 탐지 (field_key 또는 field_name 기준)
const isItemName = fieldKey.includes('item_name') || fieldName.includes('품목명');
if (isItemName && !foundItemNameKey) {
foundItemNameKey = fieldKey;
}
// 규격 필드 탐지
// specification, standard, 규격, 사양 모두 지원
const isSpecification = fieldKey.includes('specification') || fieldKey.includes('standard') ||
fieldKey.includes('규격') || fieldName.includes('규격') || fieldName.includes('사양');
if (isSpecification) {
specificationKeys.push(fieldKey);
}
// 품목 상태 필드 탐지 (is_active, status, 품목상태, 품목 상태)
const isStatusField = fieldKey.includes('is_active') || fieldKey.includes('status') ||
fieldKey.includes('active') || fieldName.includes('품목상태') ||
fieldName.includes('품목 상태') || fieldName === '상태';
if (isStatusField && !foundStatusFieldKey) {
foundStatusFieldKey = fieldKey;
}
};
structure.sections.forEach((section) => {
section.fields.forEach((f) => {
const key = f.field.field_key || `field_${f.field.id}`;
checkField(key, f.field);
});
});
structure.directFields.forEach((f) => {
const key = f.field.field_key || `field_${f.field.id}`;
checkField(key, f.field);
});
return {
// PT(부품)도 품목코드 자동생성 포함
hasAutoItemCode: !!foundItemNameKey,
itemNameKey: foundItemNameKey,
allSpecificationKeys: specificationKeys,
statusFieldKey: foundStatusFieldKey,
};
}, [structure]);
// 현재 표시 중인 규격 필드 키 (조건부 표시 고려)
// 2025-12-03: 조건부 표시로 숨겨진 필드는 제외하고, 실제 표시되는 규격 필드만 선택
const activeSpecificationKey = useMemo(() => {
if (!structure || allSpecificationKeys.length === 0) return '';
// 모든 규격 필드 중 현재 표시 중인 첫 번째 필드 찾기
for (const section of structure.sections) {
// 섹션이 숨겨져 있으면 스킵
if (!shouldShowSection(section.section.id)) continue;
for (const f of section.fields) {
const fieldKey = f.field.field_key || `field_${f.field.id}`;
// 필드가 숨겨져 있으면 스킵
if (!shouldShowField(f.field.id)) continue;
// 규격 필드인지 확인
if (allSpecificationKeys.includes(fieldKey)) {
return fieldKey;
}
}
}
// 직접 필드에서도 찾기
for (const f of structure.directFields) {
const fieldKey = f.field.field_key || `field_${f.field.id}`;
if (!shouldShowField(f.field.id)) continue;
if (allSpecificationKeys.includes(fieldKey)) {
return fieldKey;
}
}
// 표시 중인 규격 필드가 없으면 첫 번째 규격 필드 반환 (fallback)
return allSpecificationKeys[0] || '';
}, [structure, allSpecificationKeys, shouldShowSection, shouldShowField]);
// 부품 유형 필드 탐지 (PT 품목에서 절곡/조립/구매 부품 판별용)
const { partTypeFieldKey, selectedPartType, isBendingPart, isAssemblyPart, isPurchasedPart } = useMemo(() => {
if (!structure || selectedItemType !== 'PT') {
return { partTypeFieldKey: '', selectedPartType: '', isBendingPart: false, isAssemblyPart: false, isPurchasedPart: false };
}
let foundPartTypeKey = '';
// 모든 필드에서 부품 유형 필드 찾기
const checkField = (fieldKey: string, field: ItemFieldResponse) => {
const fieldName = field.field_name || '';
// part_type, 부품유형, 부품 유형 등 탐지
const isPartType = fieldKey.includes('part_type') ||
fieldName.includes('부품유형') ||
fieldName.includes('부품 유형');
if (isPartType && !foundPartTypeKey) {
foundPartTypeKey = fieldKey;
}
};
structure.sections.forEach((section) => {
section.fields.forEach((f) => {
const key = f.field.field_key || `field_${f.field.id}`;
checkField(key, f.field);
});
});
structure.directFields.forEach((f) => {
const key = f.field.field_key || `field_${f.field.id}`;
checkField(key, f.field);
});
const currentPartType = (formData[foundPartTypeKey] as string) || '';
// "절곡 부품", "BENDING", "절곡부품" 등 다양한 형태 지원
const isBending = currentPartType.includes('절곡') || currentPartType.toUpperCase() === 'BENDING';
// "조립 부품", "ASSEMBLY", "조립부품" 등 다양한 형태 지원
const isAssembly = currentPartType.includes('조립') || currentPartType.toUpperCase() === 'ASSEMBLY';
// "구매 부품", "PURCHASED", "구매부품" 등 다양한 형태 지원
const isPurchased = currentPartType.includes('구매') || currentPartType.toUpperCase() === 'PURCHASED';
// console.log('[DynamicItemForm] 부품 유형 감지:', { partTypeFieldKey: foundPartTypeKey, currentPartType, isBending, isAssembly, isPurchased });
return {
partTypeFieldKey: foundPartTypeKey,
selectedPartType: currentPartType,
isBendingPart: isBending,
isAssemblyPart: isAssembly,
isPurchasedPart: isPurchased,
};
}, [structure, selectedItemType, formData]);
// 이전 부품 유형 값 추적 (부품 유형 변경 감지용)
const prevPartTypeRef = useRef<string>('');
// 부품 유형 변경 시 조건부 표시 관련 필드 초기화
// 2025-12-04: 절곡 ↔ 조립 부품 전환 시 formData 값이 유지되어
// 조건부 표시가 잘못 트리거되는 버그 수정
// 2025-12-04: setTimeout으로 초기화를 다음 틱으로 미뤄서 Select 두 번 클릭 문제 해결
useEffect(() => {
if (selectedItemType !== 'PT' || !partTypeFieldKey) return;
const currentPartType = selectedPartType;
const prevPartType = prevPartTypeRef.current;
// 이전 값이 있고, 현재 값과 다른 경우에만 초기화
if (prevPartType && prevPartType !== currentPartType) {
// console.log('[DynamicItemForm] 부품 유형 변경 감지:', prevPartType, '→', currentPartType);
// setTimeout으로 다음 틱에서 초기화 실행
// → 부품 유형 Select 값 변경이 먼저 완료된 후 초기화
setTimeout(() => {
// 조건부 표시 대상이 될 수 있는 필드들 수집 및 초기화
// (품목명, 재질, 종류, 폭 합계, 모양&길이 등)
const fieldsToReset: string[] = [];
// structure에서 조건부 표시 설정이 있는 필드들 찾기
if (structure) {
structure.sections.forEach((section) => {
section.fields.forEach((f) => {
const field = f.field;
const fieldKey = field.field_key || `field_${field.id}`;
const fieldName = field.field_name || '';
// 부품 유형 필드는 초기화에서 제외
if (fieldKey === partTypeFieldKey) return;
// 조건부 표시 트리거 필드 (display_condition이 있는 필드)
if (field.display_condition) {
fieldsToReset.push(fieldKey);
}
// 조건부 표시 대상 필드 (재질, 종류, 폭 합계, 모양&길이 등)
const isBendingRelated =
fieldName.includes('재질') || fieldName.includes('종류') ||
fieldName.includes('폭') || fieldName.includes('모양') ||
fieldName.includes('길이') || fieldKey.includes('material') ||
fieldKey.includes('category') || fieldKey.includes('width') ||
fieldKey.includes('shape') || fieldKey.includes('length');
if (isBendingRelated) {
fieldsToReset.push(fieldKey);
}
});
});
// 품목명 필드도 초기화 (조건부 표시 트리거 역할)
if (itemNameKey) {
fieldsToReset.push(itemNameKey);
}
}
// 중복 제거 후 초기화
const uniqueFields = [...new Set(fieldsToReset)];
// console.log('[DynamicItemForm] 초기화할 필드:', uniqueFields);
uniqueFields.forEach((fieldKey) => {
setFieldValue(fieldKey, '');
});
}, 0);
}
// 현재 값을 이전 값으로 저장
prevPartTypeRef.current = currentPartType;
}, [selectedItemType, partTypeFieldKey, selectedPartType, structure, itemNameKey, setFieldValue]);
// 절곡부품 전용 필드 탐지 (재질, 종류, 폭 합계, 모양&길이)
// 2025-12-04: 조건부 표시 고려하여 종류 필드 선택 로직 개선
const { bendingFieldKeys, autoBendingItemCode, allCategoryKeysWithIds } = useMemo(() => {
if (!structure || selectedItemType !== 'PT' || !isBendingPart) {
return {
bendingFieldKeys: {
material: '', // 재질
category: '', // 종류
widthSum: '', // 폭 합계
shapeLength: '', // 모양&길이
itemName: '', // 품목명 (절곡부품 코드 생성용)
},
autoBendingItemCode: '',
allCategoryKeysWithIds: [] as Array<{ key: string; id: number }>,
};
}
let materialKey = '';
const categoryKeysWithIds: Array<{ key: string; id: number }> = []; // 종류 필드 + ID
let widthSumKey = '';
let shapeLengthKey = '';
let bendingItemNameKey = ''; // 절곡부품용 품목명 키
const checkField = (fieldKey: string, field: ItemFieldResponse) => {
const fieldName = field.field_name || '';
const lowerKey = fieldKey.toLowerCase();
// 절곡부품 품목명 필드 탐지 - bending_parts 우선
// 2025-12-04: 조립부품/절곡부품 품목명 필드가 모두 있을 때 절곡부품용 우선 선택
const isBendingItemNameField =
lowerKey.includes('bending_parts') ||
lowerKey.includes('bending_item') ||
lowerKey.includes('절곡부품') ||
lowerKey.includes('절곡_부품') ||
fieldName.includes('절곡부품') ||
fieldName.includes('절곡 부품');
const isGeneralItemNameField =
lowerKey.includes('item_name') ||
lowerKey.includes('품목명') ||
fieldName.includes('품목명') ||
fieldName === '품목명';
// bending_parts는 무조건 우선 (덮어쓰기)
if (isBendingItemNameField) {
// console.log('[checkField] 절곡부품 품목명 필드 발견!', { fieldKey, fieldName });
bendingItemNameKey = fieldKey;
}
// 일반 품목명은 아직 없을 때만
else if (isGeneralItemNameField && !bendingItemNameKey) {
// console.log('[checkField] 일반 품목명 필드 발견!', { fieldKey, fieldName });
bendingItemNameKey = fieldKey;
}
// 재질 필드
if (lowerKey.includes('material') || lowerKey.includes('재질') ||
lowerKey.includes('texture') || fieldName.includes('재질')) {
if (!materialKey) materialKey = fieldKey;
}
// 종류 필드 (type_1, type_2, type_3 등 모두 수집) - ID와 함께 저장
if ((lowerKey.includes('category') || lowerKey.includes('종류') ||
lowerKey.includes('type_') || fieldName === '종류' || fieldName.includes('종류')) &&
!lowerKey.includes('item_name') && !lowerKey.includes('item_type') &&
!lowerKey.includes('part_type') && !fieldName.includes('품목명')) {
categoryKeysWithIds.push({ key: fieldKey, id: field.id });
}
// 폭 합계 필드
if (lowerKey.includes('width_sum') || lowerKey.includes('폭합계') ||
lowerKey.includes('폭_합계') || lowerKey.includes('width_total') ||
fieldName.includes('폭 합계') || fieldName.includes('폭합계')) {
if (!widthSumKey) widthSumKey = fieldKey;
}
// 모양&길이 필드
if (lowerKey.includes('shape_length') || lowerKey.includes('모양') ||
fieldName.includes('모양') || fieldName.includes('길이')) {
if (!shapeLengthKey) shapeLengthKey = fieldKey;
}
};
// 모든 필드 검사
structure.sections.forEach((section) => {
section.fields.forEach((f) => {
const key = f.field.field_key || `field_${f.field.id}`;
checkField(key, f.field);
});
});
structure.directFields.forEach((f) => {
const key = f.field.field_key || `field_${f.field.id}`;
checkField(key, f.field);
});
// 품목코드 자동생성 (품목명 + 종류 + 모양&길이)
// itemNameKey 또는 직접 탐지한 bendingItemNameKey 사용
const effectiveItemNameKey = bendingItemNameKey || itemNameKey;
const itemNameValue = effectiveItemNameKey ? (formData[effectiveItemNameKey] as string) || '' : '';
// 2025-12-04: 종류 필드 선택 - 조건부 표시로 현재 보이는 필드만 검사
// shouldShowField를 직접 호출할 수 없으므로, 값이 있는 필드 중 마지막 것을 선택
// (품목명 변경 시 이전 종류는 초기화되므로, 현재 표시되는 종류만 값이 있음)
let activeCategoryKey = '';
let categoryValue = '';
for (const { key: catKey, id: catId } of categoryKeysWithIds) {
const val = (formData[catKey] as string) || '';
if (val) {
// 마지막으로 선택된 종류 필드를 사용 (최신 값)
activeCategoryKey = catKey;
categoryValue = val;
// break 제거 - 마지막 값이 있는 필드 사용
}
}
const shapeLengthValue = shapeLengthKey ? (formData[shapeLengthKey] as string) || '' : '';
const autoCode = generateBendingItemCodeSimple(itemNameValue, categoryValue, shapeLengthValue);
// console.log('[DynamicItemForm] 절곡부품 필드 탐지:', { bendingItemNameKey, materialKey, activeCategoryKey, autoCode });
return {
bendingFieldKeys: {
material: materialKey,
category: activeCategoryKey, // 현재 활성화된 종류 필드
widthSum: widthSumKey,
shapeLength: shapeLengthKey,
itemName: effectiveItemNameKey,
},
autoBendingItemCode: autoCode,
allCategoryKeysWithIds: categoryKeysWithIds, // 모든 종류 필드 키+ID (초기화용)
};
}, [structure, selectedItemType, isBendingPart, formData, itemNameKey]);
// 2025-12-04: 품목명 변경 시 종류 필드 값 초기화
// 품목명(A)→종류(A1) 선택 후 품목명(B)로 변경 시, 이전 종류(A1) 값이 남아있어서
// 새 종류(B1) 선택해도 이전 값이 품목코드에 적용되는 버그 수정
const prevItemNameValueRef = useRef<string>('');
useEffect(() => {
if (!isBendingPart || !bendingFieldKeys.itemName) return;
const currentItemNameValue = (formData[bendingFieldKeys.itemName] as string) || '';
const prevItemNameValue = prevItemNameValueRef.current;
// 품목명이 변경되었고, 이전 값이 있었을 때만 종류 필드 초기화
if (prevItemNameValue && prevItemNameValue !== currentItemNameValue) {
// console.log('[DynamicItemForm] 품목명 변경 감지:', prevItemNameValue, '→', currentItemNameValue);
// 모든 종류 필드 값 초기화
allCategoryKeysWithIds.forEach(({ key }) => {
const currentVal = (formData[key] as string) || '';
if (currentVal) {
// console.log('[DynamicItemForm] 종류 필드 초기화:', key);
setFieldValue(key, '');
}
});
}
// 현재 값을 이전 값으로 저장
prevItemNameValueRef.current = currentItemNameValue;
}, [isBendingPart, bendingFieldKeys.itemName, formData, allCategoryKeysWithIds, setFieldValue]);
// BOM 필요 체크박스 필드 키 탐지 (structure에서 직접 검색)
const bomRequiredFieldKey = useMemo(() => {
if (!structure) return '';
// 모든 섹션의 필드에서 BOM 관련 체크박스 필드 찾기
for (const section of structure.sections) {
for (const f of section.fields) {
const field = f.field;
const fieldKey = field.field_key || '';
const fieldName = field.field_name || '';
const fieldType = field.field_type || '';
// 체크박스 타입이고 BOM 관련 필드인지 확인
const isCheckbox = fieldType.toLowerCase() === 'checkbox' || fieldType.toLowerCase() === 'boolean';
const isBomRelated =
fieldKey.toLowerCase().includes('bom') ||
fieldName.toLowerCase().includes('bom') ||
fieldName.includes('부품구성') ||
fieldKey.includes('부품구성');
if (isCheckbox && isBomRelated) {
// console.log('[DynamicItemForm] BOM 체크박스 필드 발견:', { fieldKey, fieldName });
return field.field_key || `field_${field.id}`;
}
}
}
// 직접 필드에서도 찾기
for (const f of structure.directFields) {
const field = f.field;
const fieldKey = field.field_key || '';
const fieldName = field.field_name || '';
const fieldType = field.field_type || '';
const isCheckbox = fieldType.toLowerCase() === 'checkbox' || fieldType.toLowerCase() === 'boolean';
const isBomRelated =
fieldKey.toLowerCase().includes('bom') ||
fieldName.toLowerCase().includes('bom') ||
fieldName.includes('부품구성') ||
fieldKey.includes('부품구성');
if (isCheckbox && isBomRelated) {
// console.log('[DynamicItemForm] BOM 체크박스 필드 발견 (직접필드):', { fieldKey, fieldName });
return field.field_key || `field_${field.id}`;
}
}
// console.log('[DynamicItemForm] BOM 체크박스 필드를 찾지 못함');
return '';
}, [structure]);
// 조립 부품 필드 탐지 (측면규격 가로/세로, 길이) - 자동생성용
// 2025-12-03: 필드 탐지 조건 개선 - 더 정확한 매칭
const { hasAssemblyFields, assemblyFieldKeys, autoAssemblyItemName, autoAssemblySpec } = useMemo(() => {
if (!structure || selectedItemType !== 'PT') {
return {
hasAssemblyFields: false,
assemblyFieldKeys: { sideSpecWidth: '', sideSpecHeight: '', assemblyLength: '' },
autoAssemblyItemName: '',
autoAssemblySpec: '',
};
}
let sideSpecWidthKey = '';
let sideSpecHeightKey = '';
let assemblyLengthKey = '';
const checkField = (fieldKey: string, field: ItemFieldResponse) => {
const fieldName = field.field_name || '';
const lowerKey = fieldKey.toLowerCase();
// 측면규격 가로 - 더 정확한 조건 (측면 + 가로 조합)
const isWidthField = lowerKey.includes('side_spec_width') || lowerKey.includes('sidespecwidth') ||
fieldName.includes('측면규격(가로)') || fieldName.includes('측면 규격(가로)') ||
fieldName.includes('측면규격 가로') || fieldName.includes('측면 가로') ||
(fieldName.includes('측면') && fieldName.includes('가로'));
if (isWidthField && !sideSpecWidthKey) {
sideSpecWidthKey = fieldKey;
}
// 측면규격 세로 - 더 정확한 조건 (측면 + 세로 조합)
const isHeightField = lowerKey.includes('side_spec_height') || lowerKey.includes('sidespecheight') ||
fieldName.includes('측면규격(세로)') || fieldName.includes('측면 규격(세로)') ||
fieldName.includes('측면규격 세로') || fieldName.includes('측면 세로') ||
(fieldName.includes('측면') && fieldName.includes('세로'));
if (isHeightField && !sideSpecHeightKey) {
sideSpecHeightKey = fieldKey;
}
// 길이 - 조립 부품 길이 필드
const isLengthField = lowerKey.includes('assembly_length') || lowerKey.includes('assemblylength') ||
lowerKey === 'length' || lowerKey.endsWith('_length') ||
fieldName === '길이' || fieldName.includes('조립') && fieldName.includes('길이');
if (isLengthField && !assemblyLengthKey) {
assemblyLengthKey = fieldKey;
}
};
// 모든 필드 검사
structure.sections.forEach((section) => {
section.fields.forEach((f) => {
const key = f.field.field_key || `field_${f.field.id}`;
checkField(key, f.field);
});
});
structure.directFields.forEach((f) => {
const key = f.field.field_key || `field_${f.field.id}`;
checkField(key, f.field);
});
// 조립 부품 여부: 측면규격 가로/세로, 길이 필드가 모두 있어야 함
const isAssembly = !!(sideSpecWidthKey && sideSpecHeightKey && assemblyLengthKey);
// 자동생성 값 계산
const selectedItemName = itemNameKey ? (formData[itemNameKey] as string) || '' : '';
const sideSpecWidth = sideSpecWidthKey ? (formData[sideSpecWidthKey] as string) || '' : '';
const sideSpecHeight = sideSpecHeightKey ? (formData[sideSpecHeightKey] as string) || '' : '';
const assemblyLength = assemblyLengthKey ? (formData[assemblyLengthKey] as string) || '' : '';
// 품목명: 선택한 품목명 가로x세로
const autoItemName = generateAssemblyItemNameSimple(selectedItemName, sideSpecWidth, sideSpecHeight);
// 규격: 가로x세로x길이(네자리)
const autoSpec = generateAssemblySpecification(sideSpecWidth, sideSpecHeight, assemblyLength);
// console.log('[DynamicItemForm] 조립 부품 필드 탐지:', { isAssembly, autoItemName, autoSpec });
return {
hasAssemblyFields: isAssembly,
assemblyFieldKeys: {
sideSpecWidth: sideSpecWidthKey,
sideSpecHeight: sideSpecHeightKey,
assemblyLength: assemblyLengthKey,
},
autoAssemblyItemName: autoItemName,
autoAssemblySpec: autoSpec,
};
}, [structure, selectedItemType, formData, itemNameKey]);
// 구매 부품(전동개폐기) 필드 탐지 - 품목명, 용량, 전원
// 2025-12-04: 구매 부품 품목코드 자동생성 추가
const { purchasedFieldKeys, autoPurchasedItemCode } = useMemo(() => {
if (!structure || selectedItemType !== 'PT' || !isPurchasedPart) {
return {
purchasedFieldKeys: {
itemName: '', // 품목명 (전동개폐기 등)
capacity: '', // 용량 (150, 300, etc.)
power: '', // 전원 (220V, 380V)
},
autoPurchasedItemCode: '',
};
}
let purchasedItemNameKey = '';
let capacityKey = '';
let powerKey = '';
const checkField = (fieldKey: string, field: ItemFieldResponse) => {
const fieldName = field.field_name || '';
const lowerKey = fieldKey.toLowerCase();
// 구매 부품 품목명 필드 탐지 - PurchasedItemName 우선 탐지
const isPurchasedItemNameField = lowerKey.includes('purchaseditemname');
const isItemNameField =
isPurchasedItemNameField ||
lowerKey.includes('item_name') ||
lowerKey.includes('품목명') ||
fieldName.includes('품목명') ||
fieldName === '품목명';
// PurchasedItemName을 우선적으로 사용 (더 정확한 매칭)
if (isPurchasedItemNameField) {
purchasedItemNameKey = fieldKey; // 덮어쓰기 (우선순위 높음)
} else if (isItemNameField && !purchasedItemNameKey) {
purchasedItemNameKey = fieldKey;
}
// 용량 필드 탐지
const isCapacityField =
lowerKey.includes('capacity') ||
lowerKey.includes('용량') ||
fieldName.includes('용량') ||
fieldName === '용량';
if (isCapacityField && !capacityKey) {
capacityKey = fieldKey;
}
// 전원 필드 탐지
const isPowerField =
lowerKey.includes('power') ||
lowerKey.includes('전원') ||
fieldName.includes('전원') ||
fieldName === '전원';
if (isPowerField && !powerKey) {
powerKey = fieldKey;
}
};
// 모든 필드 검사
structure.sections.forEach((section) => {
section.fields.forEach((f) => {
const key = f.field.field_key || `field_${f.field.id}`;
checkField(key, f.field);
});
});
structure.directFields.forEach((f) => {
const key = f.field.field_key || `field_${f.field.id}`;
checkField(key, f.field);
});
// 품목코드 자동생성: 품목명 + 용량 + 전원
const itemNameValue = purchasedItemNameKey ? (formData[purchasedItemNameKey] as string) || '' : '';
const capacityValue = capacityKey ? (formData[capacityKey] as string) || '' : '';
const powerValue = powerKey ? (formData[powerKey] as string) || '' : '';
const autoCode = generatePurchasedItemCode(itemNameValue, capacityValue, powerValue);
// console.log('[DynamicItemForm] 구매 부품 필드 탐지:', { purchasedItemNameKey, autoCode });
return {
purchasedFieldKeys: {
itemName: purchasedItemNameKey,
capacity: capacityKey,
power: powerKey,
},
autoPurchasedItemCode: autoCode,
};
}, [structure, selectedItemType, isPurchasedPart, formData]);
// 품목코드 자동생성 값
// PT(부품): 영문약어-순번 (예: GR-001, MOTOR-002)
// 기타 품목: 품목명-규격 (기존 방식)
// 2025-12-03: 연동 드롭다운 로직 제거 - 단순화
// 2025-12-03: activeSpecificationKey 사용하여 조건부 표시 고려
const autoGeneratedItemCode = useMemo(() => {
if (!hasAutoItemCode) return '';
// field_key가 "{id}_item_name" 형식일 수 있어서 동적으로 키 사용
const itemName = (formData[itemNameKey] as string) || '';
// 현재 표시 중인 규격 필드 값 사용 (조건부 표시 고려)
const specification = activeSpecificationKey ? (formData[activeSpecificationKey] as string) || '' : '';
if (!itemName) return '';
// PT(부품)인 경우: 영문약어-순번 형식 사용
if (selectedItemType === 'PT') {
// generateItemCode는 품목명을 기반으로 영문약어를 찾고 순번을 계산
const generatedCode = generateItemCode(itemName, existingItemCodes);
return generatedCode;
}
// 기타 품목: 기존 방식 (품목명-규격)
if (!specification) return itemName;
return `${itemName}-${specification}`;
}, [hasAutoItemCode, itemNameKey, activeSpecificationKey, formData, selectedItemType, existingItemCodes]);
// 품목 유형 변경 핸들러
const handleItemTypeChange = (type: ItemType) => {
setSelectedItemType(type);
};
// 실제 저장 로직 (중복 체크 후 호출)
const executeSubmit = async (submitData: DynamicFormData) => {
try {
await handleSubmit(async () => {
// 품목 저장 (ID 반환)
const result = await onSubmit(submitData);
const itemId = result?.id;
// 파일 업로드 (품목 ID가 있을 때만)
if (itemId) {
const fileUploadErrors: string[] = [];
// PT (절곡/조립) 전개도 이미지 업로드
if (selectedItemType === 'PT' && (isBendingPart || isAssemblyPart) && bendingDiagramFile) {
try {
console.log('[DynamicItemForm] 전개도 파일 업로드 시작:', bendingDiagramFile.name);
await uploadItemFile(itemId, bendingDiagramFile, 'bending_diagram', {
fieldKey: 'bending_diagram',
fileId: 0,
bendingDetails: bendingDetails.length > 0 ? bendingDetails.map(d => ({
angle: d.aAngle || 0,
length: d.input || 0,
type: d.shaded ? 'shaded' : 'normal',
})) : undefined,
});
console.log('[DynamicItemForm] 전개도 파일 업로드 성공');
} catch (error) {
console.error('[DynamicItemForm] 전개도 파일 업로드 실패:', error);
fileUploadErrors.push('전개도 이미지');
}
}
// FG (제품) 시방서 업로드
if (selectedItemType === 'FG' && specificationFile) {
try {
console.log('[DynamicItemForm] 시방서 파일 업로드 시작:', specificationFile.name);
await uploadItemFile(itemId, specificationFile, 'specification', {
fieldKey: 'specification_file',
fileId: 0,
});
console.log('[DynamicItemForm] 시방서 파일 업로드 성공');
} catch (error) {
console.error('[DynamicItemForm] 시방서 파일 업로드 실패:', error);
fileUploadErrors.push('시방서');
}
}
// FG (제품) 인정서 업로드
if (selectedItemType === 'FG' && certificationFile) {
try {
console.log('[DynamicItemForm] 인정서 파일 업로드 시작:', certificationFile.name);
// formData에서 인정서 관련 필드 추출
const certNumber = Object.entries(formData).find(([key]) =>
key.includes('certification_number') || key.includes('인정번호')
)?.[1] as string | undefined;
const certStartDate = Object.entries(formData).find(([key]) =>
key.includes('certification_start') || key.includes('인정_유효기간_시작')
)?.[1] as string | undefined;
const certEndDate = Object.entries(formData).find(([key]) =>
key.includes('certification_end') || key.includes('인정_유효기간_종료')
)?.[1] as string | undefined;
await uploadItemFile(itemId, certificationFile, 'certification', {
fieldKey: 'certification_file',
fileId: 0,
certificationNumber: certNumber,
certificationStartDate: certStartDate,
certificationEndDate: certEndDate,
});
console.log('[DynamicItemForm] 인정서 파일 업로드 성공');
} catch (error) {
console.error('[DynamicItemForm] 인정서 파일 업로드 실패:', error);
fileUploadErrors.push('인정서');
}
}
// 파일 업로드 실패 경고 (품목은 저장됨)
if (fileUploadErrors.length > 0) {
console.warn('[DynamicItemForm] 일부 파일 업로드 실패:', fileUploadErrors.join(', '));
// 품목은 저장되었으므로 경고만 표시하고 진행
alert(`품목이 저장되었습니다.\n\n일부 파일 업로드에 실패했습니다: ${fileUploadErrors.join(', ')}\n수정 화면에서 다시 업로드해 주세요.`);
}
}
router.push('/items');
router.refresh();
});
} catch (error) {
// 2025-12-11: 백엔드에서 중복 에러 반환 시 다이얼로그 표시
// 사전 체크를 우회하거나 동시 등록 시에도 안전하게 처리
if (error instanceof DuplicateCodeError) {
console.warn('[DynamicItemForm] 저장 시점 중복 에러 감지:', error);
setDuplicateCheckResult({
isDuplicate: true,
duplicateId: error.duplicateId,
});
setPendingSubmitData(submitData);
setShowDuplicateDialog(true);
return;
}
// 그 외 에러는 상위로 전파
throw error;
}
};
// 폼 제출 핸들러
const handleFormSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// 밸리데이션 - 조건부 표시로 숨겨진 필드는 이미 allFields에서 제외됨
// 2025-12-03: 연동 드롭다운 로직 제거 - 단순화
const isValid = validateAll(allFields);
if (!isValid) {
return;
}
// 2025-12-09: field_key 통일로 변환 로직 제거
// formData의 field_key가 백엔드 필드명과 일치하므로 직접 사용
console.log('[DynamicItemForm] 저장 시 formData:', formData);
// is_active 필드만 boolean 변환 (드롭다운 값 → boolean)
const convertedData: Record<string, any> = {};
Object.entries(formData).forEach(([key, value]) => {
if (key === 'is_active' || key.endsWith('_is_active')) {
// "활성", true, "true", "1", 1 등을 true로, 나머지는 false로
const isActive = value === true || value === 'true' || value === '1' ||
value === 1 || value === '활성' || value === 'active';
convertedData[key] = isActive;
} else {
convertedData[key] = value;
}
});
// 품목명 값 추출 (품목코드와 품목명 모두 필요)
// 2025-12-04: 절곡 부품은 별도 품목명 필드(bendingFieldKeys.itemName) 사용
const effectiveItemNameKeyForSubmit = isBendingPart && bendingFieldKeys.itemName
? bendingFieldKeys.itemName
: itemNameKey;
const itemNameValue = effectiveItemNameKeyForSubmit
? (formData[effectiveItemNameKeyForSubmit] as string) || ''
: '';
// 조립/절곡/구매 부품 자동생성 값 결정
// 조립 부품: 품목명 = "품목명 가로x세로", 규격 = "가로x세로x길이"
// 절곡 부품: 품목명 = bendingFieldKeys.itemName에서 선택한 값, 규격 = 없음 (품목코드로 대체)
// 구매 부품: 품목명 = purchasedFieldKeys.itemName에서 선택한 값
let finalName: string;
let finalSpec: string | undefined;
if (isAssemblyPart && autoAssemblyItemName) {
// 조립 부품: 자동생성 품목명/규격 사용
finalName = autoAssemblyItemName;
finalSpec = autoAssemblySpec;
} else if (isBendingPart) {
// 절곡 부품: bendingFieldKeys.itemName의 값 사용
finalName = itemNameValue || convertedData.name || '';
finalSpec = convertedData.spec;
} else if (isPurchasedPart) {
// 구매 부품: purchasedFieldKeys.itemName의 값 사용
const purchasedItemNameValue = purchasedFieldKeys.itemName
? (formData[purchasedFieldKeys.itemName] as string) || ''
: '';
finalName = purchasedItemNameValue || convertedData.name || '';
finalSpec = convertedData.spec;
} else {
// 기타: 기존 로직
finalName = convertedData.name || itemNameValue;
finalSpec = convertedData.spec;
}
// console.log('[DynamicItemForm] 품목명/규격 결정:', { finalName, finalSpec });
// 품목코드 결정
// 2025-12-11: 수정 모드에서는 기존 코드 유지 (자동생성으로 코드가 변경되는 버그 수정)
// 생성 모드에서만 자동생성 코드 사용
let finalCode: string;
if (mode === 'edit' && initialData?.code) {
// 수정 모드: DB에서 받은 기존 코드 유지
finalCode = initialData.code as string;
} else if (isBendingPart && autoBendingItemCode) {
// 생성 모드: 절곡 부품 자동생성
finalCode = autoBendingItemCode;
} else if (isPurchasedPart && autoPurchasedItemCode) {
// 생성 모드: 구매 부품 자동생성
finalCode = autoPurchasedItemCode;
} else if (hasAutoItemCode && autoGeneratedItemCode) {
// 생성 모드: 일반 자동생성
finalCode = autoGeneratedItemCode;
} else {
finalCode = convertedData.code || itemNameValue;
}
// 품목 유형 및 BOM 데이터 추가
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const submitData = {
...convertedData,
// 백엔드 필드명 사용
product_type: selectedItemType, // item_type → product_type
// 2025-12-03: 조립 부품 자동생성 품목명/규격 사용
// 2025-12-04: 절곡 부품도 자동생성 품목코드 사용
name: finalName, // 조립 부품: 품목명 가로x세로, 절곡 부품: 품목명 필드값, 기타: 품목명 필드값
spec: finalSpec, // 조립 부품: 가로x세로x길이, 기타: 규격 필드값
code: finalCode, // 절곡 부품: autoBendingItemCode, 기타: autoGeneratedItemCode
// BOM 데이터를 배열로 포함 (백엔드는 child_item_id, child_item_type, quantity만 저장)
bom: bomLines.map((line) => ({
child_item_id: line.childItemId ? Number(line.childItemId) : null,
child_item_type: line.childItemType || 'PRODUCT', // PRODUCT(FG/PT) 또는 MATERIAL(SM/RM/CS)
quantity: line.quantity || 1,
})).filter(item => item.child_item_id !== null), // child_item_id 없는 항목 제외
// 절곡품 전개도 데이터 (PT - 절곡 부품 전용)
...(selectedItemType === 'PT' && isBendingPart ? {
part_type: 'BENDING',
bending_diagram: bendingDiagram || null,
bending_details: bendingDetails.length > 0 ? bendingDetails : null,
width_sum: widthSum || null,
} : {}),
// 조립품 전개도 데이터 (PT - 조립 부품 전용)
...(selectedItemType === 'PT' && isAssemblyPart ? {
part_type: 'ASSEMBLY',
bending_diagram: bendingDiagram || null, // 조립품도 동일한 전개도 필드 사용
} : {}),
// 구매품 데이터 (PT - 구매 부품 전용)
...(selectedItemType === 'PT' && isPurchasedPart ? {
part_type: 'PURCHASED',
} : {}),
// FG(제품)은 단위 필드가 없으므로 기본값 'EA' 설정
...(selectedItemType === 'FG' && !convertedData.unit ? {
unit: 'EA',
} : {}),
} as DynamicFormData;
// console.log('[DynamicItemForm] 제출 데이터:', submitData);
// 2025-12-11: 품목코드 중복 체크 (조립/절곡 부품만 해당)
// PT-조립부품, PT-절곡부품은 품목코드가 자동생성되므로 중복 체크 필요
const needsDuplicateCheck = selectedItemType === 'PT' && (isAssemblyPart || isBendingPart) && finalCode;
if (needsDuplicateCheck) {
console.log('[DynamicItemForm] 품목코드 중복 체크:', finalCode);
// 수정 모드에서는 자기 자신 제외 (propItemId)
const excludeId = mode === 'edit' ? propItemId : undefined;
const duplicateResult = await checkItemCodeDuplicate(finalCode, excludeId);
console.log('[DynamicItemForm] 중복 체크 결과:', duplicateResult);
if (duplicateResult.isDuplicate) {
// 중복 발견 → 다이얼로그 표시
setDuplicateCheckResult(duplicateResult);
setPendingSubmitData(submitData);
setShowDuplicateDialog(true);
return; // 저장 중단, 사용자 선택 대기
}
}
// 중복 없음 → 바로 저장
await executeSubmit(submitData);
};
// 중복 다이얼로그에서 "중복 품목 수정" 버튼 클릭 핸들러
const handleGoToEditDuplicate = () => {
if (duplicateCheckResult?.duplicateId) {
setShowDuplicateDialog(false);
// 2025-12-11: 수정 페이지 URL 형식 맞춤
// /items/{code}/edit?type={itemType}&id={itemId}
// duplicateItemType이 없으면 현재 선택된 품목 유형 사용
const itemType = duplicateCheckResult.duplicateItemType || selectedItemType || 'PT';
const itemId = duplicateCheckResult.duplicateId;
// code는 없으므로 id를 path에 사용 (edit 페이지에서 id 쿼리 파라미터로 조회)
router.push(`/items/${itemId}/edit?type=${itemType}&id=${itemId}`);
}
};
// 중복 다이얼로그에서 "취소" 버튼 클릭 핸들러
const handleCancelDuplicate = () => {
setShowDuplicateDialog(false);
setDuplicateCheckResult(null);
setPendingSubmitData(null);
};
// 로딩 상태
if (isLoading && selectedItemType) {
return (
<PageLoadingSpinner
text="폼 구조를 불러오는 중..."
minHeight="min-h-[40vh]"
/>
);
}
// 에러 상태
if (structureError) {
return (
<Alert className="bg-red-50 border-red-200">
<AlertDescription className="text-red-900">
: {structureError}
</AlertDescription>
</Alert>
);
}
// 섹션 정렬
const sortedSections = structure
? [...structure.sections].sort((a, b) => a.orderNo - b.orderNo)
: [];
// 직접 필드 정렬
const sortedDirectFields = structure
? [...structure.directFields].sort((a, b) => a.orderNo - b.orderNo)
: [];
// 일반 섹션들 (BOM 제외) - 기본 정보 카드에 통합할 섹션들
const normalSections = sortedSections.filter((s) => s.section.type !== 'bom');
// BOM 섹션 - 별도 카드로 렌더링
const bomSection = sortedSections.find((s) => s.section.type === 'bom');
// 첫 번째 일반 섹션 (기본 필드용)
const firstDefaultSection = normalSections[0];
// 나머지 일반 섹션들 (하위 섹션으로 렌더링)
const additionalSections = normalSections.slice(1);
// 통합 섹션의 필드 정렬
const firstSectionFields = firstDefaultSection
? [...firstDefaultSection.fields].sort((a, b) => a.orderNo - b.orderNo)
: [];
return (
<form onSubmit={handleFormSubmit} className="space-y-6">
{/* Validation 에러 Alert */}
<ValidationAlert errors={errors} />
{/* 헤더 */}
<FormHeader
mode={mode}
selectedItemType={selectedItemType}
isSubmitting={isSubmitting}
onCancel={() => router.back()}
/>
{/* 기본 정보 - 목업과 동일한 레이아웃 (모든 필드 한 줄씩) */}
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-base font-medium"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 품목 유형 선택 */}
<div>
<ItemTypeSelect
value={selectedItemType}
onChange={handleItemTypeChange}
disabled={mode === 'edit'}
required
/>
<p className="text-xs text-muted-foreground mt-1">
*
</p>
</div>
{/* 직접 필드 (페이지에 직접 연결된 필드) */}
{selectedItemType && sortedDirectFields.map((dynamicField) => {
const field = dynamicField.field;
const fieldKey = field.field_key || `field_${field.id}`;
// 필드 조건부 표시 체크
if (!shouldShowField(field.id)) {
return null;
}
return (
<DynamicFieldRenderer
key={field.id}
field={field}
value={formData[fieldKey]}
onChange={(value) => setFieldValue(fieldKey, value)}
error={errors[fieldKey]}
disabled={isSubmitting}
unitOptions={unitOptions}
/>
);
})}
{/* 첫 번째 섹션의 필드 렌더링 */}
{selectedItemType && firstSectionFields.map((dynamicField) => {
const field = dynamicField.field;
const fieldKey = field.field_key || `field_${field.id}`;
// 필드 조건부 표시 체크 (백엔드 설정 그대로 유지)
if (!shouldShowField(field.id)) {
return null;
}
const isSpecField = fieldKey === activeSpecificationKey;
const isStatusField = fieldKey === statusFieldKey;
// 품목명 필드인지 체크 (FG 품목코드 자동생성 위치)
const isItemNameField = fieldKey === itemNameKey;
// 비고 필드인지 체크 (절곡부품 품목코드 자동생성 위치)
const fieldName = field.field_name || '';
const isNoteField = fieldKey.includes('note') || fieldKey.includes('비고') ||
fieldName.includes('비고') || fieldName === '비고';
// 인정 유효기간 종료일 필드인지 체크 (FG 시방서/인정서 파일 업로드 위치)
const isCertEndDateField = fieldKey.includes('certification_end') ||
fieldKey.includes('인정_유효기간_종료') ||
fieldName.includes('인정 유효기간 종료') ||
fieldName.includes('유효기간 종료');
// 절곡부품 박스 스타일링 (재질, 폭합계, 모양&길이)
const isBendingBoxField = isBendingPart && (
fieldKey === bendingFieldKeys.material ||
fieldKey === bendingFieldKeys.widthSum ||
fieldKey === bendingFieldKeys.shapeLength
);
const isFirstBendingBoxField = isBendingPart && fieldKey === bendingFieldKeys.material;
const isLastBendingBoxField = isBendingPart && fieldKey === bendingFieldKeys.shapeLength;
return (
<div
key={field.id}
className={cn(
isBendingBoxField && 'border-x border-gray-200 px-4 bg-gray-50/30',
isFirstBendingBoxField && 'border-t rounded-t-lg pt-4 mt-4',
isBendingBoxField && !isFirstBendingBoxField && '-mt-4 pt-4',
isLastBendingBoxField && 'border-b rounded-b-lg pb-4'
)}
>
<DynamicFieldRenderer
field={field}
value={formData[fieldKey]}
onChange={(value) => setFieldValue(fieldKey, value)}
error={errors[fieldKey]}
disabled={isSubmitting}
unitOptions={unitOptions}
/>
{/* 규격 필드 바로 다음에 품목코드 자동생성 필드 표시 (절곡부품 제외) */}
{isSpecField && hasAutoItemCode && !isBendingPart && (
<div className="mt-4">
<Label htmlFor="item_code_auto"> ()</Label>
<Input
id="item_code_auto"
value={autoGeneratedItemCode || ''}
placeholder="품목명과 규격이 입력되면 자동으로 생성됩니다"
disabled
className="bg-muted text-muted-foreground"
/>
<p className="text-xs text-muted-foreground mt-1">
{selectedItemType === 'PT'
? "* 품목코드는 '영문약어-순번' 형식으로 자동 생성됩니다"
: "* 품목코드는 '품목명-규격' 형식으로 자동 생성됩니다"}
</p>
</div>
)}
{/* 품목 상태 필드 하단 안내 메시지 */}
{isStatusField && (
<p className="text-xs text-muted-foreground mt-1">
*
</p>
)}
{/* 비고 필드 다음에 절곡부품 품목코드 자동생성 */}
{isNoteField && isBendingPart && (
<div className="mt-4">
<Label htmlFor="bending_item_code_auto"> ()</Label>
<Input
id="bending_item_code_auto"
value={autoBendingItemCode || ''}
placeholder="품목명과 종류가 입력되면 자동으로 생성됩니다"
disabled
className="bg-muted text-muted-foreground"
/>
<p className="text-xs text-muted-foreground mt-1">
* &apos;++&apos; (: RM30)
</p>
</div>
)}
{/* 비고 필드 다음에 구매부품(전동개폐기) 품목코드 자동생성 */}
{isNoteField && isPurchasedPart && (
<div className="mt-4">
<Label htmlFor="purchased_item_code_auto"> ()</Label>
<Input
id="purchased_item_code_auto"
value={autoPurchasedItemCode || ''}
placeholder="품목명, 용량, 전원을 선택하면 자동으로 생성됩니다"
disabled
className="bg-muted text-muted-foreground"
/>
<p className="text-xs text-muted-foreground mt-1">
* &apos;++&apos; (: 전동개폐기150KG380V)
</p>
</div>
)}
{/* FG(제품) 전용: 품목명 필드 다음에 품목코드 자동생성 */}
{isItemNameField && selectedItemType === 'FG' && (
<div className="mt-4">
<Label htmlFor="fg_item_code_auto"> ()</Label>
<Input
id="fg_item_code_auto"
value={(formData[itemNameKey] as string) || ''}
placeholder="품목명이 입력되면 자동으로 동일하게 생성됩니다"
disabled
className="bg-muted text-muted-foreground"
/>
<p className="text-xs text-muted-foreground mt-1">
* (FG)
</p>
</div>
)}
{/* FG(제품) 전용: 인정 유효기간 종료일 다음에 시방서/인정서 파일 업로드 */}
{isCertEndDateField && selectedItemType === 'FG' && (
<div className="mt-4 space-y-4">
{/* 시방서 파일 */}
<div>
<Label htmlFor="specification_file"> (PDF)</Label>
<div className="mt-1.5">
{/* 기존 파일이 있고, 새 파일 선택 안한 경우: 파일명 + 버튼들 */}
{mode === 'edit' && existingSpecificationFile && !specificationFile ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-1 px-3 py-2 bg-gray-50 rounded-md border text-sm">
<FileText className="h-4 w-4 text-blue-600 shrink-0" />
<span className="truncate">{existingSpecificationFileName}</span>
</div>
<a
href={getStorageUrl(existingSpecificationFile) || '#'}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center h-9 w-9 rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground text-blue-600"
title="다운로드"
>
<Download className="h-4 w-4" />
</a>
<label
htmlFor="specification_file_edit"
className="inline-flex items-center justify-center h-9 w-9 rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground text-gray-600 cursor-pointer"
title="수정"
>
<Pencil className="h-4 w-4" />
<input
id="specification_file_edit"
type="file"
accept=".pdf"
onChange={(e) => {
const file = e.target.files?.[0] || null;
setSpecificationFile(file);
}}
disabled={isSubmitting}
className="hidden"
/>
</label>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => handleDeleteFile('specification')}
disabled={isDeletingFile === 'specification' || isSubmitting}
className="h-9 w-9 text-red-500 hover:text-red-700 hover:bg-red-50"
title="삭제"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
) : (
<div className="space-y-2">
{/* 새 파일 업로드 (기존 파일 없거나, 새 파일 선택 중) */}
<Input
id="specification_file"
type="file"
accept=".pdf"
onChange={(e) => {
const file = e.target.files?.[0] || null;
setSpecificationFile(file);
}}
disabled={isSubmitting}
className="cursor-pointer"
/>
{specificationFile && (
<p className="text-xs text-muted-foreground">
: {specificationFile.name}
</p>
)}
</div>
)}
</div>
</div>
{/* 인정서 파일 */}
<div>
<Label htmlFor="certification_file"> (PDF)</Label>
<div className="mt-1.5">
{/* 기존 파일이 있고, 새 파일 선택 안한 경우: 파일명 + 버튼들 */}
{mode === 'edit' && existingCertificationFile && !certificationFile ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-1 px-3 py-2 bg-gray-50 rounded-md border text-sm">
<FileText className="h-4 w-4 text-green-600 shrink-0" />
<span className="truncate">{existingCertificationFileName}</span>
</div>
<a
href={getStorageUrl(existingCertificationFile) || '#'}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center h-9 w-9 rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground text-green-600"
title="다운로드"
>
<Download className="h-4 w-4" />
</a>
<label
htmlFor="certification_file_edit"
className="inline-flex items-center justify-center h-9 w-9 rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground text-gray-600 cursor-pointer"
title="수정"
>
<Pencil className="h-4 w-4" />
<input
id="certification_file_edit"
type="file"
accept=".pdf"
onChange={(e) => {
const file = e.target.files?.[0] || null;
setCertificationFile(file);
}}
disabled={isSubmitting}
className="hidden"
/>
</label>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => handleDeleteFile('certification')}
disabled={isDeletingFile === 'certification' || isSubmitting}
className="h-9 w-9 text-red-500 hover:text-red-700 hover:bg-red-50"
title="삭제"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
) : (
<div className="space-y-2">
{/* 새 파일 업로드 (기존 파일 없거나, 새 파일 선택 중) */}
<Input
id="certification_file"
type="file"
accept=".pdf"
onChange={(e) => {
const file = e.target.files?.[0] || null;
setCertificationFile(file);
}}
disabled={isSubmitting}
className="cursor-pointer"
/>
{certificationFile && (
<p className="text-xs text-muted-foreground">
: {certificationFile.name}
</p>
)}
</div>
)}
</div>
</div>
</div>
)}
</div>
);
})}
{/* 추가 섹션들 (기본 정보 카드 내에 하위 섹션으로 통합) */}
{selectedItemType && additionalSections.map((section) => {
// 조건부 표시 체크
if (!shouldShowSection(section.section.id)) {
return null;
}
// 부품 유형에 따른 섹션 필터링
const sectionTitle = section.section.title || '';
const isPurchaseSection = sectionTitle.includes('구매 부품');
const isAssemblySectionTitle = sectionTitle.includes('조립 부품');
// 조립 부품 선택 시 구매 부품 섹션 숨김
if (isAssemblyPart && isPurchaseSection) {
return null;
}
// 구매 부품 선택 시 조립 부품 섹션 숨김
if (!isAssemblyPart && !isBendingPart && isAssemblySectionTitle) {
return null;
}
// 섹션 필드 정렬
const sectionFields = [...section.fields].sort((a, b) => a.orderNo - b.orderNo);
return (
<div key={section.section.id} className="pt-4 border-t">
{/* 하위 섹션 제목 */}
<h3 className="text-sm font-medium mb-4">{section.section.title}</h3>
{section.section.description && (
<p className="text-xs text-muted-foreground mb-4">
{section.section.description}
</p>
)}
{/* 하위 섹션 필드들 */}
<div className="space-y-4">
{sectionFields.map((dynamicField) => {
const field = dynamicField.field;
const fieldKey = field.field_key || `field_${field.id}`;
// 필드 조건부 표시 체크
if (!shouldShowField(field.id)) {
return null;
}
return (
<DynamicFieldRenderer
key={field.id}
field={field}
value={formData[fieldKey]}
onChange={(value) => setFieldValue(fieldKey, value)}
error={errors[fieldKey]}
disabled={isSubmitting}
unitOptions={unitOptions}
/>
);
})}
</div>
</div>
);
})}
</CardContent>
</Card>
{/* 품목 유형 선택 안내 경고 */}
{!selectedItemType && (
<Alert className="bg-orange-50 border-orange-200">
<AlertDescription className="text-orange-900">
</AlertDescription>
</Alert>
)}
{/* 조립품 전개도 섹션 (PT - 조립 부품 전용) - 품목명 선택 시 표시 */}
{selectedItemType === 'PT' && isAssemblyPart && (
<BendingDiagramSection
selectedPartType="ASSEMBLY"
bendingDiagramInputMethod={bendingDiagramInputMethod}
setBendingDiagramInputMethod={setBendingDiagramInputMethod}
bendingDiagram={bendingDiagram}
setBendingDiagram={setBendingDiagram}
setBendingDiagramFile={setBendingDiagramFile}
setIsDrawingOpen={setIsDrawingOpen}
bendingDetails={bendingDetails}
setBendingDetails={setBendingDetails}
setWidthSum={setWidthSum}
widthSumFieldKey={bendingFieldKeys.widthSum}
setValue={(key, value) => setFieldValue(key, value)}
isSubmitting={isSubmitting}
/>
)}
{/* 절곡품 전개도 섹션 (PT - 절곡 부품 전용) */}
{selectedItemType === 'PT' && isBendingPart && (
<BendingDiagramSection
selectedPartType="BENDING"
bendingDiagramInputMethod={bendingDiagramInputMethod}
setBendingDiagramInputMethod={setBendingDiagramInputMethod}
bendingDiagram={bendingDiagram}
setBendingDiagram={setBendingDiagram}
setBendingDiagramFile={setBendingDiagramFile}
setIsDrawingOpen={setIsDrawingOpen}
bendingDetails={bendingDetails}
setBendingDetails={setBendingDetails}
setWidthSum={setWidthSum}
widthSumFieldKey={bendingFieldKeys.widthSum}
setValue={(key, value) => setFieldValue(key, value)}
isSubmitting={isSubmitting}
/>
)}
{/* BOM 섹션 (부품구성 필요 체크 시에만 표시) */}
{selectedItemType && bomSection && (() => {
// bomRequiredFieldKey는 useMemo에서 structure 기반으로 미리 계산됨
const bomValue = bomRequiredFieldKey ? formData[bomRequiredFieldKey] : undefined;
const isBomRequired = bomValue === true || bomValue === 'true' || bomValue === '1' || bomValue === 1;
// 디버깅 로그
// console.log('[DynamicItemForm] BOM 체크 디버깅:', { bomRequiredFieldKey, bomValue, isBomRequired });
if (!isBomRequired) return null;
return (
<DynamicBOMSection
section={bomSection}
bomLines={bomLines}
setBomLines={setBomLines}
bomSearchStates={bomSearchStates}
setBomSearchStates={setBomSearchStates}
isSubmitting={isSubmitting}
/>
);
})()}
{/* 전개도 그리기 다이얼로그 (절곡품/조립품 공용) */}
<DrawingCanvas
open={isDrawingOpen}
onOpenChange={setIsDrawingOpen}
onSave={(imageData) => {
setBendingDiagram(imageData);
// Base64 string을 File 객체로 변환 (업로드용)
// 2025-12-06: 드로잉 방식에서도 파일 업로드 지원
try {
const byteString = atob(imageData.split(',')[1]);
const mimeType = imageData.split(',')[0].split(':')[1].split(';')[0];
const arrayBuffer = new ArrayBuffer(byteString.length);
const uint8Array = new Uint8Array(arrayBuffer);
for (let i = 0; i < byteString.length; i++) {
uint8Array[i] = byteString.charCodeAt(i);
}
const blob = new Blob([uint8Array], { type: mimeType });
const file = new File([blob], `bending_diagram_${Date.now()}.png`, { type: mimeType });
setBendingDiagramFile(file);
console.log('[DynamicItemForm] 드로잉 캔버스 → File 변환 성공:', file.name);
} catch (error) {
console.error('[DynamicItemForm] 드로잉 캔버스 → File 변환 실패:', error);
}
setIsDrawingOpen(false);
}}
initialImage={bendingDiagram}
title={isAssemblyPart ? "조립품 전개도" : "절곡품 전개도"}
description={isAssemblyPart
? "조립품 전개도(바라시)를 그리거나 편집합니다."
: "절곡품 전개도를 그리거나 편집합니다."
}
/>
{/* 품목코드 중복 확인 다이얼로그 */}
<AlertDialog open={showDuplicateDialog} onOpenChange={setShowDuplicateDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
.
<span className="block mt-2 font-medium text-foreground">
?
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancelDuplicate}>
</AlertDialogCancel>
<AlertDialogAction onClick={handleGoToEditDuplicate}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</form>
);
}