diff --git a/src/app/[locale]/(protected)/items/[id]/edit/page.tsx b/src/app/[locale]/(protected)/items/[id]/edit/page.tsx
index 68f84f63..4ce9748c 100644
--- a/src/app/[locale]/(protected)/items/[id]/edit/page.tsx
+++ b/src/app/[locale]/(protected)/items/[id]/edit/page.tsx
@@ -15,10 +15,8 @@ import type { DynamicFormData, BOMLine } from '@/components/items/DynamicItemFor
import type { ItemType } from '@/types/item';
import { Loader2 } from 'lucide-react';
import {
- MATERIAL_TYPES,
isMaterialType,
transformMaterialDataForSave,
- convertOptionsToStandardFields,
} from '@/lib/utils/materialTransform';
import { DuplicateCodeError } from '@/lib/api/error-handler';
@@ -112,21 +110,10 @@ function mapApiResponseToFormData(data: ItemApiResponse): DynamicFormData {
}
});
- // Material(SM, RM, CS) options 필드 매핑
- // 백엔드에서 options: [{label: "standard_1", value: "옵션값"}, ...] 형태로 저장됨
- // 프론트엔드 폼에서는 standard_1: "옵션값" 형태로 사용
- // 2025-12-16: item_details 테이블 필드는 제외 (details에서 이미 매핑됨, options의 오래된 값이 덮어쓰는 버그 방지)
- const detailsFieldsInOptions = [
- 'files', 'bending_details', 'bending_diagram',
- 'specification_file', 'certification_file',
- ];
- if (data.options && Array.isArray(data.options)) {
- (data.options as Array<{ label: string; value: string }>).forEach((opt) => {
- if (opt.label && opt.value && !detailsFieldsInOptions.includes(opt.label)) {
- formData[opt.label] = opt.value;
- }
- });
- }
+ // 2025-12-16: options 매핑 로직 제거
+ // options는 백엔드가 품목기준관리 field_key 매핑용으로 내부적으로 사용하는 필드
+ // 프론트엔드는 백엔드가 정제해서 주는 필드(name, code, unit 등)만 사용
+ // options 내부 값을 직접 파싱하면 오래된 값과 최신 값이 꼬이는 버그 발생
// is_active 기본값 처리
if (formData['is_active'] === undefined) {
diff --git a/src/app/[locale]/(protected)/items/create/page.tsx b/src/app/[locale]/(protected)/items/create/page.tsx
index 8cb71899..50a9db3e 100644
--- a/src/app/[locale]/(protected)/items/create/page.tsx
+++ b/src/app/[locale]/(protected)/items/create/page.tsx
@@ -9,7 +9,8 @@
import { useState } from 'react';
import DynamicItemForm from '@/components/items/DynamicItemForm';
import type { DynamicFormData } from '@/components/items/DynamicItemForm/types';
-import { isMaterialType, transformMaterialDataForSave } from '@/lib/utils/materialTransform';
+// 2025-12-16: options 관련 변환 로직 제거
+// 백엔드가 품목기준관리 field_key 매핑을 처리하므로 프론트에서 변환 불필요
import { DuplicateCodeError } from '@/lib/api/error-handler';
// 기존 ItemForm (주석처리 - 롤백 시 사용)
diff --git a/src/components/items/DynamicItemForm/components/DuplicateCodeDialog.tsx b/src/components/items/DynamicItemForm/components/DuplicateCodeDialog.tsx
new file mode 100644
index 00000000..c66df2d7
--- /dev/null
+++ b/src/components/items/DynamicItemForm/components/DuplicateCodeDialog.tsx
@@ -0,0 +1,53 @@
+'use client';
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
+
+export interface DuplicateCodeDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onCancel: () => void;
+ onGoToEdit: () => void;
+}
+
+/**
+ * 품목코드 중복 확인 다이얼로그
+ */
+export function DuplicateCodeDialog({
+ open,
+ onOpenChange,
+ onCancel,
+ onGoToEdit,
+}: DuplicateCodeDialogProps) {
+ return (
+
+
+
+ 품목코드 중복
+
+ 입력하신 조건의 품목코드가 이미 존재합니다.
+
+ 기존 품목을 수정하시겠습니까?
+
+
+
+
+
+ 취소
+
+
+ 중복 품목 수정하러 가기
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/items/DynamicItemForm/components/FileUploadFields.tsx b/src/components/items/DynamicItemForm/components/FileUploadFields.tsx
new file mode 100644
index 00000000..51eb92b7
--- /dev/null
+++ b/src/components/items/DynamicItemForm/components/FileUploadFields.tsx
@@ -0,0 +1,240 @@
+'use client';
+
+import { FileText, Trash2, Download, Pencil, Upload } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+
+export interface FileUploadFieldsProps {
+ mode: 'create' | 'edit';
+ isSubmitting: boolean;
+ // 시방서 관련
+ specificationFile: File | null;
+ setSpecificationFile: (file: File | null) => void;
+ existingSpecificationFile: string;
+ existingSpecificationFileName: string;
+ existingSpecificationFileId: number | null;
+ // 인정서 관련
+ certificationFile: File | null;
+ setCertificationFile: (file: File | null) => void;
+ existingCertificationFile: string;
+ existingCertificationFileName: string;
+ existingCertificationFileId: number | null;
+ // 핸들러
+ onFileDownload: (fileId: number | null, fileName?: string) => void;
+ onDeleteFile: (fileType: 'specification' | 'certification') => void;
+ isDeletingFile: string | null;
+}
+
+/**
+ * FG(제품) 전용 파일 업로드 필드 - 시방서/인정서
+ */
+export function FileUploadFields({
+ mode,
+ isSubmitting,
+ specificationFile,
+ setSpecificationFile,
+ existingSpecificationFile,
+ existingSpecificationFileName,
+ existingSpecificationFileId,
+ certificationFile,
+ setCertificationFile,
+ existingCertificationFile,
+ existingCertificationFileName,
+ existingCertificationFileId,
+ onFileDownload,
+ onDeleteFile,
+ isDeletingFile,
+}: FileUploadFieldsProps) {
+ return (
+
+ {/* 시방서 파일 */}
+
+
+
+ {/* 기존 파일이 있고, 새 파일 선택 안한 경우: 파일명 + 버튼들 */}
+ {mode === 'edit' && existingSpecificationFile && !specificationFile ? (
+
+
+
+ {existingSpecificationFileName}
+
+
+
+
+
+ ) : specificationFile ? (
+ /* 새 파일 선택된 경우: 커스텀 UI로 파일명 표시 */
+
+
+
+ {specificationFile.name}
+ (새 파일)
+
+
+
+ ) : (
+ /* 파일 없는 경우: 파일 선택 버튼 */
+
+
+ {
+ const file = e.target.files?.[0] || null;
+ setSpecificationFile(file);
+ }}
+ disabled={isSubmitting}
+ className="hidden"
+ />
+
+ )}
+
+
+ {/* 인정서 파일 */}
+
+
+
+ {/* 기존 파일이 있고, 새 파일 선택 안한 경우: 파일명 + 버튼들 */}
+ {mode === 'edit' && existingCertificationFile && !certificationFile ? (
+
+
+
+ {existingCertificationFileName}
+
+
+
+
+
+ ) : certificationFile ? (
+ /* 새 파일 선택된 경우: 커스텀 UI로 파일명 표시 */
+
+
+
+ {certificationFile.name}
+ (새 파일)
+
+
+
+ ) : (
+ /* 파일 없는 경우: 파일 선택 버튼 */
+
+
+ {
+ const file = e.target.files?.[0] || null;
+ setCertificationFile(file);
+ }}
+ disabled={isSubmitting}
+ className="hidden"
+ />
+
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/items/DynamicItemForm/components/FormHeader.tsx b/src/components/items/DynamicItemForm/components/FormHeader.tsx
new file mode 100644
index 00000000..8e994474
--- /dev/null
+++ b/src/components/items/DynamicItemForm/components/FormHeader.tsx
@@ -0,0 +1,62 @@
+'use client';
+
+import { Package, Save, X } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+
+export interface FormHeaderProps {
+ mode: 'create' | 'edit';
+ selectedItemType: string;
+ isSubmitting: boolean;
+ onCancel: () => void;
+}
+
+/**
+ * 헤더 컴포넌트 - 기존 FormHeader와 동일한 디자인
+ */
+export function FormHeader({
+ mode,
+ selectedItemType,
+ isSubmitting,
+ onCancel,
+}: FormHeaderProps) {
+ return (
+
+
+
+
+
+ {mode === 'create' ? '품목 등록' : '품목 수정'}
+
+
+ 품목 정보를 입력하세요
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/items/DynamicItemForm/components/ValidationAlert.tsx b/src/components/items/DynamicItemForm/components/ValidationAlert.tsx
new file mode 100644
index 00000000..9c25923b
--- /dev/null
+++ b/src/components/items/DynamicItemForm/components/ValidationAlert.tsx
@@ -0,0 +1,41 @@
+'use client';
+
+import { Alert, AlertDescription } from '@/components/ui/alert';
+
+export interface ValidationAlertProps {
+ errors: Record;
+}
+
+/**
+ * 밸리데이션 에러 Alert - 기존 ValidationAlert와 동일한 디자인
+ */
+export function ValidationAlert({ errors }: ValidationAlertProps) {
+ const errorCount = Object.keys(errors).length;
+
+ if (errorCount === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+
⚠️
+
+
+ 입력 내용을 확인해주세요 ({errorCount}개 오류)
+
+
+ {Object.entries(errors).map(([fieldKey, errorMessage]) => (
+ -
+ •
+ {errorMessage}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/components/items/DynamicItemForm/components/index.ts b/src/components/items/DynamicItemForm/components/index.ts
new file mode 100644
index 00000000..b96d64b5
--- /dev/null
+++ b/src/components/items/DynamicItemForm/components/index.ts
@@ -0,0 +1,11 @@
+export { FormHeader } from './FormHeader';
+export type { FormHeaderProps } from './FormHeader';
+
+export { ValidationAlert } from './ValidationAlert';
+export type { ValidationAlertProps } from './ValidationAlert';
+
+export { FileUploadFields } from './FileUploadFields';
+export type { FileUploadFieldsProps } from './FileUploadFields';
+
+export { DuplicateCodeDialog } from './DuplicateCodeDialog';
+export type { DuplicateCodeDialogProps } from './DuplicateCodeDialog';
diff --git a/src/components/items/DynamicItemForm/hooks/index.ts b/src/components/items/DynamicItemForm/hooks/index.ts
index 551b2c99..93f87fec 100644
--- a/src/components/items/DynamicItemForm/hooks/index.ts
+++ b/src/components/items/DynamicItemForm/hooks/index.ts
@@ -1,3 +1,22 @@
export { useFormStructure } from './useFormStructure';
export { useDynamicFormState } from './useDynamicFormState';
export { useConditionalDisplay } from './useConditionalDisplay';
+export { useItemCodeGeneration } from './useItemCodeGeneration';
+export type {
+ BendingFieldKeys,
+ AssemblyFieldKeys,
+ PurchasedFieldKeys,
+ CategoryKeyWithId,
+ ItemCodeGenerationResult,
+ UseItemCodeGenerationParams,
+} from './useItemCodeGeneration';
+export { useFieldDetection } from './useFieldDetection';
+export type {
+ PartTypeDetectionResult,
+ UseFieldDetectionParams,
+ FieldDetectionResult,
+} from './useFieldDetection';
+export { usePartTypeHandling } from './usePartTypeHandling';
+export type { UsePartTypeHandlingParams } from './usePartTypeHandling';
+export { useFileHandling } from './useFileHandling';
+export type { UseFileHandlingParams, FileHandlingResult } from './useFileHandling';
diff --git a/src/components/items/DynamicItemForm/hooks/useDynamicFormState.ts b/src/components/items/DynamicItemForm/hooks/useDynamicFormState.ts
index 194b9312..00f4ff43 100644
--- a/src/components/items/DynamicItemForm/hooks/useDynamicFormState.ts
+++ b/src/components/items/DynamicItemForm/hooks/useDynamicFormState.ts
@@ -71,7 +71,7 @@ export function useDynamicFormState(
// 단일 필드 밸리데이션
const validateField = useCallback(
(field: ItemFieldResponse, value: DynamicFieldValue): string | null => {
- const fieldKey = field.field_key || `field_${field.id}`;
+ const _fieldKey = field.field_key || `field_${field.id}`;
// 필수 필드 체크
if (field.is_required) {
diff --git a/src/components/items/DynamicItemForm/hooks/useFieldDetection.ts b/src/components/items/DynamicItemForm/hooks/useFieldDetection.ts
new file mode 100644
index 00000000..a3e34555
--- /dev/null
+++ b/src/components/items/DynamicItemForm/hooks/useFieldDetection.ts
@@ -0,0 +1,175 @@
+'use client';
+
+import { useMemo } from 'react';
+import { DynamicFormData, ItemType, StructuredFieldConfig } from '../types';
+import { ItemFieldResponse } from '@/types/item';
+
+/**
+ * 부품 유형 탐지 결과
+ */
+export interface PartTypeDetectionResult {
+ /** 부품 유형 필드 키 (예: 'part_type') */
+ partTypeFieldKey: string;
+ /** 현재 선택된 부품 유형 값 (예: '절곡 부품') */
+ selectedPartType: string;
+ /** 절곡 부품 여부 */
+ isBendingPart: boolean;
+ /** 조립 부품 여부 */
+ isAssemblyPart: boolean;
+ /** 구매 부품 여부 */
+ isPurchasedPart: boolean;
+}
+
+/**
+ * useFieldDetection 훅 입력 파라미터
+ */
+export interface UseFieldDetectionParams {
+ /** 폼 구조 정보 */
+ structure: StructuredFieldConfig | null;
+ /** 현재 선택된 품목 유형 (FG, PT, SM, RM, CS) */
+ selectedItemType: ItemType;
+ /** 현재 폼 데이터 */
+ formData: DynamicFormData;
+}
+
+/**
+ * useFieldDetection 훅 반환 타입
+ */
+export interface FieldDetectionResult extends PartTypeDetectionResult {
+ /** BOM 필요 체크박스 필드 키 */
+ bomRequiredFieldKey: string;
+}
+
+/**
+ * 필드 탐지 커스텀 훅
+ *
+ * 폼 구조에서 특정 필드들을 탐지합니다:
+ * 1. PT 품목의 부품 유형 필드 (절곡/조립/구매 부품 판별)
+ * 2. BOM 필요 체크박스 필드
+ *
+ * @param params - 훅 입력 파라미터
+ * @returns 필드 탐지 결과
+ */
+export function useFieldDetection({
+ structure,
+ selectedItemType,
+ formData,
+}: UseFieldDetectionParams): FieldDetectionResult {
+ // 부품 유형 필드 탐지 (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('[useFieldDetection] 부품 유형 감지:', { partTypeFieldKey: foundPartTypeKey, currentPartType, isBending, isAssembly, isPurchased });
+
+ return {
+ partTypeFieldKey: foundPartTypeKey,
+ selectedPartType: currentPartType,
+ isBendingPart: isBending,
+ isAssemblyPart: isAssembly,
+ isPurchasedPart: isPurchased,
+ };
+ }, [structure, selectedItemType, formData]);
+
+ // 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('[useFieldDetection] 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('[useFieldDetection] BOM 체크박스 필드 발견 (직접필드):', { fieldKey, fieldName });
+ return field.field_key || `field_${field.id}`;
+ }
+ }
+
+ // console.log('[useFieldDetection] BOM 체크박스 필드를 찾지 못함');
+ return '';
+ }, [structure]);
+
+ return {
+ partTypeFieldKey,
+ selectedPartType,
+ isBendingPart,
+ isAssemblyPart,
+ isPurchasedPart,
+ bomRequiredFieldKey,
+ };
+}
\ No newline at end of file
diff --git a/src/components/items/DynamicItemForm/hooks/useFileHandling.ts b/src/components/items/DynamicItemForm/hooks/useFileHandling.ts
new file mode 100644
index 00000000..a6a5ef6a
--- /dev/null
+++ b/src/components/items/DynamicItemForm/hooks/useFileHandling.ts
@@ -0,0 +1,329 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { deleteItemFile, ItemFileType } from '@/lib/api/items';
+import { downloadFileById } from '@/lib/utils/fileDownload';
+import { BendingDetail } from '@/types/item';
+import { ItemType } from '@/types/item';
+
+/**
+ * 파일 정보 타입 (API 응답)
+ */
+interface FileInfo {
+ id: number;
+ file_id?: number;
+ file_name: string;
+ file_path: string;
+}
+
+/**
+ * files 객체 타입 (initialData에서 추출)
+ */
+interface FilesObject {
+ bending_diagram?: FileInfo[];
+ specification_file?: FileInfo[];
+ certification_file?: FileInfo[];
+}
+
+/**
+ * useFileHandling 훅 입력 파라미터
+ */
+export interface UseFileHandlingParams {
+ /** 폼 모드 (create/edit) */
+ mode: 'create' | 'edit';
+ /** 초기 데이터 (edit 모드에서 사용) */
+ initialData?: Record;
+ /** 품목 ID (edit 모드에서 파일 삭제 시 필요) */
+ propItemId?: number;
+ /** 현재 선택된 품목 유형 */
+ selectedItemType: ItemType | '';
+}
+
+/**
+ * useFileHandling 훅 반환 타입
+ */
+export interface FileHandlingResult {
+ // 기존 파일 상태 (edit 모드)
+ existingBendingDiagram: string;
+ existingBendingDiagramFileId: number | null;
+ existingSpecificationFile: string;
+ existingSpecificationFileName: string;
+ existingSpecificationFileId: number | null;
+ existingCertificationFile: string;
+ existingCertificationFileName: string;
+ existingCertificationFileId: number | null;
+
+ // 삭제 중 상태
+ isDeletingFile: string | null;
+
+ // 상태 설정 함수 (전개도 삭제 후 상태 초기화용)
+ setExistingBendingDiagram: (value: string) => void;
+ setExistingBendingDiagramFileId: (value: number | null) => void;
+
+ // 핸들러 함수
+ handleFileDownload: (fileId: number | null, fileName?: string) => Promise;
+ handleDeleteFile: (
+ fileType: ItemFileType,
+ callbacks?: {
+ onBendingDiagramDeleted?: () => void;
+ }
+ ) => Promise;
+
+ // 절곡 상세 정보 (edit 모드에서 로드)
+ loadedBendingDetails: BendingDetail[];
+ loadedWidthSum: string;
+}
+
+/**
+ * 파일 처리 커스텀 훅
+ *
+ * edit 모드에서 기존 파일 정보를 로드하고,
+ * 파일 다운로드/삭제 기능을 제공합니다.
+ *
+ * @param params - 훅 입력 파라미터
+ * @returns 파일 처리 관련 상태 및 핸들러
+ */
+export function useFileHandling({
+ mode,
+ initialData,
+ propItemId,
+ selectedItemType,
+}: UseFileHandlingParams): FileHandlingResult {
+ // 기존 파일 URL 상태 (edit 모드에서 사용)
+ const [existingBendingDiagram, setExistingBendingDiagram] = useState('');
+ const [existingBendingDiagramFileId, setExistingBendingDiagramFileId] = useState(null);
+ const [existingSpecificationFile, setExistingSpecificationFile] = useState('');
+ const [existingSpecificationFileName, setExistingSpecificationFileName] = useState('');
+ const [existingSpecificationFileId, setExistingSpecificationFileId] = useState(null);
+ const [existingCertificationFile, setExistingCertificationFile] = useState('');
+ const [existingCertificationFileName, setExistingCertificationFileName] = useState('');
+ const [existingCertificationFileId, setExistingCertificationFileId] = useState(null);
+ const [isDeletingFile, setIsDeletingFile] = useState(null);
+
+ // 절곡 상세 정보 (edit 모드에서 로드)
+ const [loadedBendingDetails, setLoadedBendingDetails] = useState([]);
+ const [loadedWidthSum, setLoadedWidthSum] = useState('');
+
+ // initialData에서 기존 파일 정보 및 전개도 상세 데이터 로드 (edit 모드)
+ useEffect(() => {
+ if (mode === 'edit' && initialData) {
+ // files 객체에서 파일 정보 추출 (단수: specification_file, certification_file)
+ // 2025-12-15: files가 JSON 문자열로 올 수 있으므로 파싱 처리
+ let filesRaw = initialData.files;
+
+ // JSON 문자열인 경우 파싱
+ if (typeof filesRaw === 'string') {
+ try {
+ filesRaw = JSON.parse(filesRaw);
+ console.log('[useFileHandling] files JSON 문자열 파싱 완료');
+ } catch (e) {
+ console.error('[useFileHandling] files JSON 파싱 실패:', e);
+ filesRaw = undefined;
+ }
+ }
+
+ const files = filesRaw as FilesObject | undefined;
+
+ // 2025-12-15: 파일 로드 디버깅
+ console.log('[useFileHandling] 파일 로드 시작');
+ console.log('[useFileHandling] initialData.files (raw):', initialData.files);
+ console.log('[useFileHandling] filesRaw 타입:', typeof filesRaw);
+ console.log('[useFileHandling] files 변수:', files);
+ console.log('[useFileHandling] specification_file:', files?.specification_file);
+ console.log('[useFileHandling] certification_file:', files?.certification_file);
+
+ // 전개도 파일 (배열의 마지막 파일 = 최신 파일을 가져옴)
+ const bendingFileArr = files?.bending_diagram;
+ const bendingFile = bendingFileArr && bendingFileArr.length > 0
+ ? bendingFileArr[bendingFileArr.length - 1]
+ : undefined;
+ if (bendingFile) {
+ console.log('[useFileHandling] bendingFile 전체 객체:', bendingFile);
+ console.log('[useFileHandling] bendingFile 키 목록:', Object.keys(bendingFile));
+ setExistingBendingDiagram(bendingFile.file_path);
+ // API에서 id 또는 file_id로 올 수 있음
+ const bendingFileId = bendingFile.id || bendingFile.file_id;
+ console.log('[useFileHandling] bendingFile ID 추출:', { id: bendingFile.id, file_id: bendingFile.file_id, final: bendingFileId });
+ setExistingBendingDiagramFileId(bendingFileId as number);
+ } else if (initialData.bending_diagram) {
+ setExistingBendingDiagram(initialData.bending_diagram as string);
+ }
+
+ // 시방서 파일 (배열의 마지막 파일 = 최신 파일을 가져옴)
+ const specFileArr = files?.specification_file;
+ const specFile = specFileArr && specFileArr.length > 0
+ ? specFileArr[specFileArr.length - 1]
+ : undefined;
+ console.log('[useFileHandling] specFile 전체 객체:', specFile);
+ console.log('[useFileHandling] specFile 키 목록:', specFile ? Object.keys(specFile) : 'undefined');
+ if (specFile?.file_path) {
+ setExistingSpecificationFile(specFile.file_path);
+ setExistingSpecificationFileName(specFile.file_name || '시방서');
+ // API에서 id 또는 file_id로 올 수 있음
+ const specFileId = specFile.id || specFile.file_id;
+ console.log('[useFileHandling] specFile ID 추출:', { id: specFile.id, file_id: specFile.file_id, final: specFileId });
+ setExistingSpecificationFileId(specFileId as number || null);
+ } else {
+ // 파일이 없으면 상태 초기화 (이전 값 제거)
+ setExistingSpecificationFile('');
+ setExistingSpecificationFileName('');
+ setExistingSpecificationFileId(null);
+ }
+
+ // 인정서 파일 (배열의 마지막 파일 = 최신 파일을 가져옴)
+ const certFileArr = files?.certification_file;
+ const certFile = certFileArr && certFileArr.length > 0
+ ? certFileArr[certFileArr.length - 1]
+ : undefined;
+ console.log('[useFileHandling] certFile 전체 객체:', certFile);
+ console.log('[useFileHandling] certFile 키 목록:', certFile ? Object.keys(certFile) : 'undefined');
+ if (certFile?.file_path) {
+ setExistingCertificationFile(certFile.file_path);
+ setExistingCertificationFileName(certFile.file_name || '인정서');
+ // API에서 id 또는 file_id로 올 수 있음
+ const certFileId = certFile.id || certFile.file_id;
+ console.log('[useFileHandling] certFile ID 추출:', { id: certFile.id, file_id: certFile.file_id, final: certFileId });
+ setExistingCertificationFileId(certFileId as number || null);
+ } else {
+ // 파일이 없으면 상태 초기화 (이전 값 제거)
+ setExistingCertificationFile('');
+ setExistingCertificationFileName('');
+ setExistingCertificationFileId(null);
+ }
+
+ // 전개도 상세 데이터 로드 (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 as string)
+ : []);
+
+ if (details.length > 0) {
+ // BendingDetail 형식으로 변환
+ // 2025-12-16: 명시적 Number() 변환 추가 - TypeScript 타입 캐스팅은 런타임 변환을 하지 않음
+ // 백엔드에서 문자열로 올 수 있으므로 명시적 숫자 변환 필수
+ const mappedDetails: BendingDetail[] = details.map((d: Record, index: number) => ({
+ id: (d.id as string) || `detail-${Date.now()}-${index}`,
+ no: Number(d.no) || index + 1,
+ input: Number(d.input) || 0,
+ // elongation은 0이 유효한 값이므로 NaN 체크 필요
+ elongation: !isNaN(Number(d.elongation)) ? Number(d.elongation) : -1,
+ calculated: Number(d.calculated) || 0,
+ sum: Number(d.sum) || 0,
+ shaded: Boolean(d.shaded),
+ aAngle: d.aAngle !== undefined ? Number(d.aAngle) : undefined,
+ }));
+ setLoadedBendingDetails(mappedDetails);
+
+ // 폭 합계도 계산하여 설정
+ const totalSum = mappedDetails.reduce((acc, detail) => {
+ return acc + detail.input + detail.elongation;
+ }, 0);
+ setLoadedWidthSum(totalSum.toString());
+ }
+ }
+ }
+ }, [mode, initialData]);
+
+ // 파일 다운로드 핸들러 (Blob 방식)
+ const handleFileDownload = async (fileId: number | null, fileName?: string) => {
+ if (!fileId) return;
+ try {
+ await downloadFileById(fileId, fileName);
+ } catch (error) {
+ console.error('[useFileHandling] 다운로드 실패:', error);
+ alert('파일 다운로드에 실패했습니다.');
+ }
+ };
+
+ // 파일 삭제 핸들러
+ const handleDeleteFile = async (
+ fileType: ItemFileType,
+ callbacks?: {
+ onBendingDiagramDeleted?: () => void;
+ }
+ ) => {
+ console.log('[useFileHandling] handleDeleteFile 호출:', {
+ fileType,
+ propItemId,
+ existingBendingDiagramFileId,
+ existingSpecificationFileId,
+ existingCertificationFileId,
+ });
+
+ if (!propItemId) {
+ console.error('[useFileHandling] propItemId가 없습니다');
+ return;
+ }
+
+ // 파일 ID 가져오기
+ let fileId: number | null = null;
+ if (fileType === 'bending_diagram') {
+ fileId = existingBendingDiagramFileId;
+ } else if (fileType === 'specification') {
+ fileId = existingSpecificationFileId;
+ } else if (fileType === 'certification') {
+ fileId = existingCertificationFileId;
+ }
+
+ console.log('[useFileHandling] 삭제할 파일 ID:', fileId);
+
+ if (!fileId) {
+ console.error('[useFileHandling] 파일 ID를 찾을 수 없습니다:', fileType);
+ alert('파일 ID를 찾을 수 없습니다.');
+ return;
+ }
+
+ const confirmMessage = fileType === 'bending_diagram' ? '전개도 이미지를' :
+ fileType === 'specification' ? '시방서 파일을' : '인정서 파일을';
+
+ if (!confirm(`${confirmMessage} 삭제하시겠습니까?`)) return;
+
+ try {
+ setIsDeletingFile(fileType);
+ await deleteItemFile(propItemId, fileId, selectedItemType || 'FG');
+
+ // 상태 업데이트
+ if (fileType === 'bending_diagram') {
+ setExistingBendingDiagram('');
+ setExistingBendingDiagramFileId(null);
+ // 콜백 호출 (부모 컴포넌트에서 bendingDiagram 상태 초기화)
+ callbacks?.onBendingDiagramDeleted?.();
+ } else if (fileType === 'specification') {
+ setExistingSpecificationFile('');
+ setExistingSpecificationFileName('');
+ setExistingSpecificationFileId(null);
+ } else if (fileType === 'certification') {
+ setExistingCertificationFile('');
+ setExistingCertificationFileName('');
+ setExistingCertificationFileId(null);
+ }
+
+ alert('파일이 삭제되었습니다.');
+ } catch (error) {
+ console.error('[useFileHandling] 파일 삭제 실패:', error);
+ alert('파일 삭제에 실패했습니다.');
+ } finally {
+ setIsDeletingFile(null);
+ }
+ };
+
+ return {
+ existingBendingDiagram,
+ existingBendingDiagramFileId,
+ existingSpecificationFile,
+ existingSpecificationFileName,
+ existingSpecificationFileId,
+ existingCertificationFile,
+ existingCertificationFileName,
+ existingCertificationFileId,
+ isDeletingFile,
+ setExistingBendingDiagram,
+ setExistingBendingDiagramFileId,
+ handleFileDownload,
+ handleDeleteFile,
+ loadedBendingDetails,
+ loadedWidthSum,
+ };
+}
\ No newline at end of file
diff --git a/src/components/items/DynamicItemForm/hooks/useItemCodeGeneration.ts b/src/components/items/DynamicItemForm/hooks/useItemCodeGeneration.ts
new file mode 100644
index 00000000..df9154ae
--- /dev/null
+++ b/src/components/items/DynamicItemForm/hooks/useItemCodeGeneration.ts
@@ -0,0 +1,524 @@
+/**
+ * 품목코드 자동생성 훅
+ *
+ * 품목 유형(FG/PT 등)에 따라 품목코드를 자동 생성
+ * - FG: 품목명 그대로 사용
+ * - PT-절곡: 품목명+종류+모양길이
+ * - PT-조립: 측면규격 기반
+ * - PT-구매: 품목명+용량+전원
+ * - 기타: 품목명-규격
+ */
+
+'use client';
+
+import { useMemo } from 'react';
+import type { DynamicFormStructure, DynamicFormData } from '../types';
+import type { ItemFieldResponse } from '@/types/item-master-api';
+import type { ItemType } from '@/types/item';
+import {
+ generateItemCode,
+ generateAssemblyItemNameSimple,
+ generateAssemblySpecification,
+ generateBendingItemCodeSimple,
+ generatePurchasedItemCode,
+} from '../utils/itemCodeGenerator';
+
+// 절곡부품 필드 키 타입
+export interface BendingFieldKeys {
+ material: string; // 재질
+ category: string; // 종류
+ widthSum: string; // 폭 합계
+ shapeLength: string; // 모양&길이
+ itemName: string; // 품목명 (절곡부품 코드 생성용)
+}
+
+// 조립부품 필드 키 타입
+export interface AssemblyFieldKeys {
+ sideSpecWidth: string; // 측면규격 가로
+ sideSpecHeight: string; // 측면규격 세로
+ assemblyLength: string; // 길이
+}
+
+// 구매부품 필드 키 타입
+export interface PurchasedFieldKeys {
+ itemName: string; // 품목명 (전동개폐기 등)
+ capacity: string; // 용량 (150, 300, etc.)
+ power: string; // 전원 (220V, 380V)
+}
+
+// 종류 필드 키 + ID (초기화용)
+export interface CategoryKeyWithId {
+ key: string;
+ id: number;
+}
+
+// 훅 반환 타입
+export interface ItemCodeGenerationResult {
+ // 기본 필드 정보
+ hasAutoItemCode: boolean;
+ itemNameKey: string;
+ allSpecificationKeys: string[];
+ statusFieldKey: string;
+ activeSpecificationKey: string;
+
+ // 절곡부품 관련
+ bendingFieldKeys: BendingFieldKeys;
+ autoBendingItemCode: string;
+ allCategoryKeysWithIds: CategoryKeyWithId[];
+
+ // 조립부품 관련
+ hasAssemblyFields: boolean;
+ assemblyFieldKeys: AssemblyFieldKeys;
+ autoAssemblyItemName: string;
+ autoAssemblySpec: string;
+
+ // 구매부품 관련
+ purchasedFieldKeys: PurchasedFieldKeys;
+ autoPurchasedItemCode: string;
+
+ // 일반 품목코드 자동생성
+ autoGeneratedItemCode: string;
+}
+
+// 훅 파라미터 타입
+export interface UseItemCodeGenerationParams {
+ structure: DynamicFormStructure | null;
+ selectedItemType: ItemType | '';
+ formData: DynamicFormData;
+ isBendingPart: boolean;
+ isAssemblyPart: boolean;
+ isPurchasedPart: boolean;
+ existingItemCodes: string[];
+ shouldShowSection: (sectionId: number) => boolean;
+ shouldShowField: (fieldId: number) => boolean;
+}
+
+/**
+ * 품목코드 자동생성 훅
+ */
+export function useItemCodeGeneration({
+ structure,
+ selectedItemType,
+ formData,
+ isBendingPart,
+ isAssemblyPart: _isAssemblyPart,
+ isPurchasedPart,
+ existingItemCodes,
+ shouldShowSection,
+ shouldShowField,
+}: UseItemCodeGenerationParams): ItemCodeGenerationResult {
+ // 품목코드 자동생성 관련 필드 정보
+ // field_key 또는 field_name 기준으로 품목명/규격 필드 탐지
+ 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;
+ }
+
+ // 규격 필드 탐지
+ const isSpecification = fieldKey.includes('specification') || fieldKey.includes('standard') ||
+ fieldKey.includes('규격') || fieldName.includes('규격') || fieldName.includes('사양');
+ if (isSpecification) {
+ specificationKeys.push(fieldKey);
+ }
+
+ // 품목 상태 필드 탐지
+ 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 {
+ hasAutoItemCode: !!foundItemNameKey,
+ itemNameKey: foundItemNameKey,
+ allSpecificationKeys: specificationKeys,
+ statusFieldKey: foundStatusFieldKey,
+ };
+ }, [structure]);
+
+ // 현재 표시 중인 규격 필드 키 (조건부 표시 고려)
+ 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]);
+
+ // 절곡부품 전용 필드 탐지 (재질, 종류, 폭 합계, 모양&길이)
+ const { bendingFieldKeys, autoBendingItemCode, allCategoryKeysWithIds } = useMemo(() => {
+ if (!structure || selectedItemType !== 'PT' || !isBendingPart) {
+ return {
+ bendingFieldKeys: {
+ material: '',
+ category: '',
+ widthSum: '',
+ shapeLength: '',
+ itemName: '',
+ },
+ autoBendingItemCode: '',
+ allCategoryKeysWithIds: [] as CategoryKeyWithId[],
+ };
+ }
+
+ let materialKey = '';
+ const categoryKeysWithIds: CategoryKeyWithId[] = [];
+ let widthSumKey = '';
+ let shapeLengthKey = '';
+ let bendingItemNameKey = '';
+
+ const checkField = (fieldKey: string, field: ItemFieldResponse) => {
+ const fieldName = field.field_name || '';
+ const lowerKey = fieldKey.toLowerCase();
+
+ // 절곡부품 품목명 필드 탐지 - bending_parts 우선
+ 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 === '품목명';
+
+ if (isBendingItemNameField) {
+ bendingItemNameKey = fieldKey;
+ } else if (isGeneralItemNameField && !bendingItemNameKey) {
+ bendingItemNameKey = fieldKey;
+ }
+
+ // 재질 필드
+ if (lowerKey.includes('material') || lowerKey.includes('재질') ||
+ lowerKey.includes('texture') || fieldName.includes('재질')) {
+ if (!materialKey) materialKey = fieldKey;
+ }
+
+ // 종류 필드 (type_1, type_2, type_3 등 모두 수집)
+ 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);
+ });
+
+ // 품목코드 자동생성 (품목명 + 종류 + 모양&길이)
+ const effectiveItemNameKey = bendingItemNameKey || itemNameKey;
+ const itemNameValue = effectiveItemNameKey ? (formData[effectiveItemNameKey] as string) || '' : '';
+
+ // 종류 필드 선택 - 값이 있는 필드 중 마지막 것을 선택
+ let activeCategoryKey = '';
+ let categoryValue = '';
+ for (const { key: catKey } of categoryKeysWithIds) {
+ const val = (formData[catKey] as string) || '';
+ if (val) {
+ activeCategoryKey = catKey;
+ categoryValue = val;
+ }
+ }
+
+ const shapeLengthValue = shapeLengthKey ? (formData[shapeLengthKey] as string) || '' : '';
+ const autoCode = generateBendingItemCodeSimple(itemNameValue, categoryValue, shapeLengthValue);
+
+ return {
+ bendingFieldKeys: {
+ material: materialKey,
+ category: activeCategoryKey,
+ widthSum: widthSumKey,
+ shapeLength: shapeLengthKey,
+ itemName: effectiveItemNameKey,
+ },
+ autoBendingItemCode: autoCode,
+ allCategoryKeysWithIds: categoryKeysWithIds,
+ };
+ }, [structure, selectedItemType, isBendingPart, formData, itemNameKey]);
+
+ // 조립 부품 필드 탐지 (측면규격 가로/세로, 길이)
+ 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) || '' : '';
+
+ const autoItemName = generateAssemblyItemNameSimple(selectedItemName, sideSpecWidth, sideSpecHeight);
+ const autoSpec = generateAssemblySpecification(sideSpecWidth, sideSpecHeight, assemblyLength);
+
+ return {
+ hasAssemblyFields: isAssembly,
+ assemblyFieldKeys: {
+ sideSpecWidth: sideSpecWidthKey,
+ sideSpecHeight: sideSpecHeightKey,
+ assemblyLength: assemblyLengthKey,
+ },
+ autoAssemblyItemName: autoItemName,
+ autoAssemblySpec: autoSpec,
+ };
+ }, [structure, selectedItemType, formData, itemNameKey]);
+
+ // 구매 부품(전동개폐기) 필드 탐지 - 품목명, 용량, 전원
+ const { purchasedFieldKeys, autoPurchasedItemCode } = useMemo(() => {
+ if (!structure || selectedItemType !== 'PT' || !isPurchasedPart) {
+ return {
+ purchasedFieldKeys: {
+ itemName: '',
+ capacity: '',
+ power: '',
+ },
+ autoPurchasedItemCode: '',
+ };
+ }
+
+ let purchasedItemNameKey = '';
+ let capacityKey = '';
+ let powerKey = '';
+
+ const checkField = (fieldKey: string, field: ItemFieldResponse) => {
+ const fieldName = field.field_name || '';
+ const lowerKey = fieldKey.toLowerCase();
+
+ // 구매 부품 품목명 필드 탐지
+ const isPurchasedItemNameField = lowerKey.includes('purchaseditemname');
+ const isItemNameField =
+ isPurchasedItemNameField ||
+ lowerKey.includes('item_name') ||
+ lowerKey.includes('품목명') ||
+ fieldName.includes('품목명') ||
+ fieldName === '품목명';
+
+ 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);
+
+ return {
+ purchasedFieldKeys: {
+ itemName: purchasedItemNameKey,
+ capacity: capacityKey,
+ power: powerKey,
+ },
+ autoPurchasedItemCode: autoCode,
+ };
+ }, [structure, selectedItemType, isPurchasedPart, formData]);
+
+ // 품목코드 자동생성 값 (일반)
+ const autoGeneratedItemCode = useMemo(() => {
+ if (!hasAutoItemCode) return '';
+
+ const itemName = (formData[itemNameKey] as string) || '';
+ const specification = activeSpecificationKey ? (formData[activeSpecificationKey] as string) || '' : '';
+
+ if (!itemName) return '';
+
+ // PT(부품)인 경우: 영문약어-순번 형식 사용
+ if (selectedItemType === 'PT') {
+ const generatedCode = generateItemCode(itemName, existingItemCodes);
+ return generatedCode;
+ }
+
+ // 기타 품목: 기존 방식 (품목명-규격)
+ if (!specification) return itemName;
+ return `${itemName}-${specification}`;
+ }, [hasAutoItemCode, itemNameKey, activeSpecificationKey, formData, selectedItemType, existingItemCodes]);
+
+ return {
+ // 기본 필드 정보
+ hasAutoItemCode,
+ itemNameKey,
+ allSpecificationKeys,
+ statusFieldKey,
+ activeSpecificationKey,
+
+ // 절곡부품 관련
+ bendingFieldKeys,
+ autoBendingItemCode,
+ allCategoryKeysWithIds,
+
+ // 조립부품 관련
+ hasAssemblyFields,
+ assemblyFieldKeys,
+ autoAssemblyItemName,
+ autoAssemblySpec,
+
+ // 구매부품 관련
+ purchasedFieldKeys,
+ autoPurchasedItemCode,
+
+ // 일반 품목코드 자동생성
+ autoGeneratedItemCode,
+ };
+}
\ No newline at end of file
diff --git a/src/components/items/DynamicItemForm/hooks/usePartTypeHandling.ts b/src/components/items/DynamicItemForm/hooks/usePartTypeHandling.ts
new file mode 100644
index 00000000..8500b957
--- /dev/null
+++ b/src/components/items/DynamicItemForm/hooks/usePartTypeHandling.ts
@@ -0,0 +1,193 @@
+'use client';
+
+import { useEffect, useRef } from 'react';
+import { DynamicFormData, ItemType, StructuredFieldConfig } from '../types';
+import { BendingFieldKeys, CategoryKeyWithId } from './useItemCodeGeneration';
+import { BendingDetail } from '@/types/item';
+
+/**
+ * usePartTypeHandling 훅 입력 파라미터
+ */
+export interface UsePartTypeHandlingParams {
+ /** 폼 구조 정보 */
+ structure: StructuredFieldConfig | null;
+ /** 현재 선택된 품목 유형 */
+ selectedItemType: ItemType | '';
+ /** 부품 유형 필드 키 */
+ partTypeFieldKey: string;
+ /** 현재 선택된 부품 유형 */
+ selectedPartType: string;
+ /** 품목명 필드 키 */
+ itemNameKey: string;
+ /** 필드 값 설정 함수 */
+ setFieldValue: (key: string, value: unknown) => void;
+ /** 현재 폼 데이터 */
+ formData: DynamicFormData;
+ /** 절곡부품 필드 키 정보 */
+ bendingFieldKeys: BendingFieldKeys;
+ /** 절곡 부품 여부 */
+ isBendingPart: boolean;
+ /** 모든 종류 필드 키와 ID */
+ allCategoryKeysWithIds: CategoryKeyWithId[];
+ /** 폼 모드 (create/edit) */
+ mode: 'create' | 'edit';
+ /** 절곡 상세 정보 배열 */
+ bendingDetails: BendingDetail[];
+}
+
+/**
+ * 부품 유형 처리 커스텀 훅
+ *
+ * 부품 유형 변경 시 관련 필드 초기화 로직을 처리합니다:
+ * 1. 부품 유형 변경 시 조건부 표시 관련 필드 초기화
+ * 2. 품목명 변경 시 종류 필드 초기화 (절곡 부품)
+ * 3. bendingDetails 로드 후 폭 합계 동기화 (edit 모드)
+ *
+ * @param params - 훅 입력 파라미터
+ */
+export function usePartTypeHandling({
+ structure,
+ selectedItemType,
+ partTypeFieldKey,
+ selectedPartType,
+ itemNameKey,
+ setFieldValue,
+ formData,
+ bendingFieldKeys,
+ isBendingPart,
+ allCategoryKeysWithIds,
+ mode,
+ bendingDetails,
+}: UsePartTypeHandlingParams): void {
+ // 이전 부품 유형 값 추적 (부품 유형 변경 감지용)
+ const prevPartTypeRef = useRef('');
+
+ // 부품 유형 변경 시 조건부 표시 관련 필드 초기화
+ // 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('[usePartTypeHandling] 부품 유형 변경 감지:', 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('[usePartTypeHandling] 초기화할 필드:', uniqueFields);
+
+ uniqueFields.forEach((fieldKey) => {
+ setFieldValue(fieldKey, '');
+ });
+ }, 0);
+ }
+
+ // 현재 값을 이전 값으로 저장
+ prevPartTypeRef.current = currentPartType;
+ }, [selectedItemType, partTypeFieldKey, selectedPartType, structure, itemNameKey, setFieldValue]);
+
+ // 2025-12-16: bendingDetails 로드 후 폭 합계를 formData에 동기화
+ // bendingFieldKeys.widthSum이 결정된 후에 실행되어야 함
+ const bendingWidthSumSyncedRef = useRef(false);
+ useEffect(() => {
+ // edit 모드이고, bendingDetails가 있고, widthSum 필드 키가 결정되었을 때만 실행
+ if (mode !== 'edit' || bendingDetails.length === 0 || !bendingFieldKeys.widthSum) {
+ return;
+ }
+
+ // 이미 동기화했으면 스킵 (중복 실행 방지)
+ if (bendingWidthSumSyncedRef.current) {
+ return;
+ }
+
+ const totalSum = bendingDetails.reduce((acc, detail) => {
+ return acc + detail.input + detail.elongation;
+ }, 0);
+
+ const sumString = totalSum.toString();
+ console.log('[usePartTypeHandling] bendingDetails 폭 합계 → formData 동기화:', {
+ widthSumKey: bendingFieldKeys.widthSum,
+ totalSum,
+ bendingDetailsCount: bendingDetails.length,
+ });
+
+ setFieldValue(bendingFieldKeys.widthSum, sumString);
+ bendingWidthSumSyncedRef.current = true;
+ }, [mode, bendingDetails, bendingFieldKeys.widthSum, setFieldValue]);
+
+ // 2025-12-04: 품목명 변경 시 종류 필드 값 초기화
+ // 품목명(A)→종류(A1) 선택 후 품목명(B)로 변경 시, 이전 종류(A1) 값이 남아있어서
+ // 새 종류(B1) 선택해도 이전 값이 품목코드에 적용되는 버그 수정
+ const prevItemNameValueRef = useRef('');
+
+ useEffect(() => {
+ if (!isBendingPart || !bendingFieldKeys.itemName) return;
+
+ const currentItemNameValue = (formData[bendingFieldKeys.itemName] as string) || '';
+ const prevItemNameValue = prevItemNameValueRef.current;
+
+ // 품목명이 변경되었고, 이전 값이 있었을 때만 종류 필드 초기화
+ if (prevItemNameValue && prevItemNameValue !== currentItemNameValue) {
+ // console.log('[usePartTypeHandling] 품목명 변경 감지:', prevItemNameValue, '→', currentItemNameValue);
+
+ // 모든 종류 필드 값 초기화
+ allCategoryKeysWithIds.forEach(({ key }) => {
+ const currentVal = (formData[key] as string) || '';
+ if (currentVal) {
+ // console.log('[usePartTypeHandling] 종류 필드 초기화:', key);
+ setFieldValue(key, '');
+ }
+ });
+ }
+
+ // 현재 값을 이전 값으로 저장
+ prevItemNameValueRef.current = currentItemNameValue;
+ }, [isBendingPart, bendingFieldKeys.itemName, formData, allCategoryKeysWithIds, setFieldValue]);
+}
\ No newline at end of file
diff --git a/src/components/items/DynamicItemForm/index.tsx b/src/components/items/DynamicItemForm/index.tsx
index 90bf96e3..32873e93 100644
--- a/src/components/items/DynamicItemForm/index.tsx
+++ b/src/components/items/DynamicItemForm/index.tsx
@@ -6,14 +6,12 @@
'use client';
-import { useState, useEffect, useMemo, useRef } from 'react';
+import { useState, useEffect, useMemo } from 'react';
import { useRouter } from 'next/navigation';
-import { Package, Save, X, FileText, Trash2, Download, Pencil, Upload } 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,
@@ -24,208 +22,15 @@ import {
import ItemTypeSelect from '../ItemTypeSelect';
import BendingDiagramSection from '../ItemForm/BendingDiagramSection';
import { DrawingCanvas } from '../DrawingCanvas';
-import { useFormStructure, useDynamicFormState, useConditionalDisplay } from './hooks';
+import { useFormStructure, useDynamicFormState, useConditionalDisplay, useFieldDetection, useItemCodeGeneration, usePartTypeHandling, useFileHandling } 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 { FormHeader, ValidationAlert, FileUploadFields, DuplicateCodeDialog } from './components';
+import type { DynamicItemFormProps, DynamicFormData, BOMLine, BOMSearchState } 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 { downloadFileById } from '@/lib/utils/fileDownload';
+import { uploadItemFile, 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 (
-
-
-
-
-
- {mode === 'create' ? '품목 등록' : '품목 수정'}
-
-
- 품목 정보를 입력하세요
-
-
-
-
-
-
-
-
-
- );
-}
-
-/**
- * 밸리데이션 에러 Alert - 기존 ValidationAlert와 동일한 디자인
- */
-function ValidationAlert({ errors }: { errors: Record }) {
- const errorCount = Object.keys(errors).length;
-
- if (errorCount === 0) {
- return null;
- }
-
- return (
-
-
-
-
⚠️
-
-
- 입력 내용을 확인해주세요 ({errorCount}개 오류)
-
-
- {Object.entries(errors).map(([fieldKey, errorMessage]) => (
- -
- •
- {errorMessage}
-
- ))}
-
-
-
-
-
- );
-}
-
-/**
- * 동적 섹션 렌더러
- */
-function DynamicSectionRenderer({
- section,
- formData,
- errors,
- onChange,
- disabled,
- unitOptions,
- autoGeneratedItemCode,
- shouldShowField,
-}: {
- section: DynamicSection;
- formData: DynamicFormData;
- errors: Record;
- 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 (
-
-
- {section.section.title}
- {section.section.description && (
-
- {section.section.description}
-
- )}
-
-
- {sortedFields.map((dynamicField) => {
- const field = dynamicField.field;
- const fieldKey = field.field_key || `field_${field.id}`;
-
- // 필드 조건부 표시 체크
- if (shouldShowField && !shouldShowField(field.id)) {
- return null;
- }
-
- return (
- onChange(fieldKey, value)}
- error={errors[fieldKey]}
- disabled={disabled}
- unitOptions={unitOptions}
- />
- );
- })}
-
- {/* 품목코드 자동생성 필드 (item_name + specification 있는 섹션에만 표시) */}
- {shouldShowItemCode && (
-
-
-
-
- * 품목코드는 '품목명-규격' 형식으로 자동 생성됩니다
-
-
- )}
-
-
- );
-}
/**
* 메인 DynamicItemForm 컴포넌트
@@ -275,151 +80,44 @@ export default function DynamicItemForm({
const [specificationFile, setSpecificationFile] = useState(null);
const [certificationFile, setCertificationFile] = useState(null);
- // 기존 파일 URL 상태 (edit 모드에서 사용)
- const [existingBendingDiagram, setExistingBendingDiagram] = useState('');
- const [existingBendingDiagramFileId, setExistingBendingDiagramFileId] = useState(null);
- const [existingSpecificationFile, setExistingSpecificationFile] = useState('');
- const [existingSpecificationFileName, setExistingSpecificationFileName] = useState('');
- const [existingSpecificationFileId, setExistingSpecificationFileId] = useState(null);
- const [existingCertificationFile, setExistingCertificationFile] = useState('');
- const [existingCertificationFileName, setExistingCertificationFileName] = useState('');
- const [existingCertificationFileId, setExistingCertificationFileId] = useState(null);
- const [isDeletingFile, setIsDeletingFile] = useState(null);
+ // 파일 처리 훅 (기존 파일 로드, 다운로드, 삭제)
+ const {
+ existingBendingDiagram,
+ existingBendingDiagramFileId,
+ existingSpecificationFile,
+ existingSpecificationFileName,
+ existingSpecificationFileId,
+ existingCertificationFile,
+ existingCertificationFileName,
+ existingCertificationFileId,
+ isDeletingFile,
+ setExistingBendingDiagram: _setExistingBendingDiagram,
+ setExistingBendingDiagramFileId: _setExistingBendingDiagramFileId,
+ handleFileDownload,
+ handleDeleteFile: handleDeleteFileFromHook,
+ loadedBendingDetails,
+ loadedWidthSum,
+ } = useFileHandling({
+ mode,
+ initialData,
+ propItemId,
+ selectedItemType,
+ });
// 품목코드 중복 체크 상태 관리
const [showDuplicateDialog, setShowDuplicateDialog] = useState(false);
const [duplicateCheckResult, setDuplicateCheckResult] = useState(null);
- const [pendingSubmitData, setPendingSubmitData] = useState(null);
+ const [_pendingSubmitData, setPendingSubmitData] = useState(null);
- // initialData에서 기존 파일 정보 및 전개도 상세 데이터 로드 (edit 모드)
+ // 훅에서 로드한 bendingDetails/widthSum을 로컬 상태와 동기화 (edit 모드)
useEffect(() => {
- if (mode === 'edit' && initialData) {
- // files 객체에서 파일 정보 추출 (단수: specification_file, certification_file)
- // 2025-12-15: files가 JSON 문자열로 올 수 있으므로 파싱 처리
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- let filesRaw = (initialData as any).files;
-
- // JSON 문자열인 경우 파싱
- if (typeof filesRaw === 'string') {
- try {
- filesRaw = JSON.parse(filesRaw);
- console.log('[DynamicItemForm] files JSON 문자열 파싱 완료');
- } catch (e) {
- console.error('[DynamicItemForm] files JSON 파싱 실패:', e);
- filesRaw = undefined;
- }
- }
-
- const files = filesRaw as {
- bending_diagram?: Array<{ id: number; file_name: string; file_path: string }>;
- specification_file?: Array<{ id: number; file_name: string; file_path: string }>;
- certification_file?: Array<{ id: number; file_name: string; file_path: string }>;
- } | undefined;
-
- // 2025-12-15: 파일 로드 디버깅
- console.log('[DynamicItemForm] 파일 로드 시작');
- console.log('[DynamicItemForm] initialData.files (raw):', (initialData as any).files);
- console.log('[DynamicItemForm] filesRaw 타입:', typeof filesRaw);
- console.log('[DynamicItemForm] files 변수:', files);
- console.log('[DynamicItemForm] specification_file:', files?.specification_file);
- console.log('[DynamicItemForm] certification_file:', files?.certification_file);
-
- // 전개도 파일 (배열의 마지막 파일 = 최신 파일을 가져옴)
- // 2025-12-15: .at(-1) 대신 slice(-1)[0] 사용 (ES2022 이전 호환성)
- const bendingFileArr = files?.bending_diagram;
- const bendingFile = bendingFileArr && bendingFileArr.length > 0
- ? bendingFileArr[bendingFileArr.length - 1]
- : undefined;
- if (bendingFile) {
- console.log('[DynamicItemForm] bendingFile 전체 객체:', bendingFile);
- console.log('[DynamicItemForm] bendingFile 키 목록:', Object.keys(bendingFile));
- setExistingBendingDiagram(bendingFile.file_path);
- // API에서 id 또는 file_id로 올 수 있음
- const bendingFileId = (bendingFile as Record).id || (bendingFile as Record).file_id;
- console.log('[DynamicItemForm] bendingFile ID 추출:', { id: (bendingFile as Record).id, file_id: (bendingFile as Record).file_id, final: bendingFileId });
- setExistingBendingDiagramFileId(bendingFileId as number);
- } else if (initialData.bending_diagram) {
- setExistingBendingDiagram(initialData.bending_diagram as string);
- }
-
- // 시방서 파일 (배열의 마지막 파일 = 최신 파일을 가져옴)
- // 2025-12-15: .at(-1) 대신 배열 인덱스 사용 (ES2022 이전 호환성)
- const specFileArr = files?.specification_file;
- const specFile = specFileArr && specFileArr.length > 0
- ? specFileArr[specFileArr.length - 1]
- : undefined;
- console.log('[DynamicItemForm] specFile 전체 객체:', specFile);
- console.log('[DynamicItemForm] specFile 키 목록:', specFile ? Object.keys(specFile) : 'undefined');
- if (specFile?.file_path) {
- setExistingSpecificationFile(specFile.file_path);
- setExistingSpecificationFileName(specFile.file_name || '시방서');
- // API에서 id 또는 file_id로 올 수 있음
- const specFileId = (specFile as Record).id || (specFile as Record).file_id;
- console.log('[DynamicItemForm] specFile ID 추출:', { id: (specFile as Record).id, file_id: (specFile as Record).file_id, final: specFileId });
- setExistingSpecificationFileId(specFileId as number || null);
- } else {
- // 파일이 없으면 상태 초기화 (이전 값 제거)
- setExistingSpecificationFile('');
- setExistingSpecificationFileName('');
- setExistingSpecificationFileId(null);
- }
-
- // 인정서 파일 (배열의 마지막 파일 = 최신 파일을 가져옴)
- // 2025-12-15: .at(-1) 대신 배열 인덱스 사용 (ES2022 이전 호환성)
- const certFileArr = files?.certification_file;
- const certFile = certFileArr && certFileArr.length > 0
- ? certFileArr[certFileArr.length - 1]
- : undefined;
- console.log('[DynamicItemForm] certFile 전체 객체:', certFile);
- console.log('[DynamicItemForm] certFile 키 목록:', certFile ? Object.keys(certFile) : 'undefined');
- if (certFile?.file_path) {
- setExistingCertificationFile(certFile.file_path);
- setExistingCertificationFileName(certFile.file_name || '인정서');
- // API에서 id 또는 file_id로 올 수 있음
- const certFileId = (certFile as Record).id || (certFile as Record).file_id;
- console.log('[DynamicItemForm] certFile ID 추출:', { id: (certFile as Record).id, file_id: (certFile as Record).file_id, final: certFileId });
- setExistingCertificationFileId(certFileId as number || null);
- } else {
- // 파일이 없으면 상태 초기화 (이전 값 제거)
- setExistingCertificationFile('');
- setExistingCertificationFileName('');
- setExistingCertificationFileId(null);
- }
-
- // 전개도 상세 데이터 로드 (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 형식으로 변환
- // 2025-12-16: 명시적 Number() 변환 추가 - TypeScript 타입 캐스팅은 런타임 변환을 하지 않음
- // 백엔드에서 문자열로 올 수 있으므로 명시적 숫자 변환 필수
- const mappedDetails: BendingDetail[] = details.map((d: Record, index: number) => ({
- id: (d.id as string) || `detail-${Date.now()}-${index}`,
- no: Number(d.no) || index + 1,
- input: Number(d.input) || 0,
- // elongation은 0이 유효한 값이므로 NaN 체크 필요
- elongation: !isNaN(Number(d.elongation)) ? Number(d.elongation) : -1,
- calculated: Number(d.calculated) || 0,
- sum: Number(d.sum) || 0,
- shaded: Boolean(d.shaded),
- aAngle: d.aAngle !== undefined ? Number(d.aAngle) : undefined,
- }));
- setBendingDetails(mappedDetails);
-
- // 폭 합계도 계산하여 설정
- const totalSum = mappedDetails.reduce((acc, detail) => {
- return acc + detail.input + detail.elongation;
- }, 0);
- setWidthSum(totalSum.toString());
- }
- }
+ if (loadedBendingDetails.length > 0) {
+ setBendingDetails(loadedBendingDetails);
}
- }, [mode, initialData]);
+ if (loadedWidthSum) {
+ setWidthSum(loadedWidthSum);
+ }
+ }, [loadedBendingDetails, loadedWidthSum]);
// initialBomLines prop으로 BOM 데이터 로드 (edit 모드)
// 2025-12-12: edit 페이지에서 별도로 전달받은 BOM 데이터 사용
@@ -430,81 +128,11 @@ export default function DynamicItemForm({
}
}, [mode, initialBomLines]);
- // 파일 다운로드 핸들러 (Blob 방식)
- const handleFileDownload = async (fileId: number | null, fileName?: string) => {
- if (!fileId) return;
- try {
- await downloadFileById(fileId, fileName);
- } catch (error) {
- console.error('[DynamicItemForm] 다운로드 실패:', error);
- alert('파일 다운로드에 실패했습니다.');
- }
- };
-
- // 파일 삭제 핸들러
+ // 파일 삭제 래퍼 (훅의 handleDeleteFile에 콜백 전달)
const handleDeleteFile = async (fileType: ItemFileType) => {
- console.log('[DynamicItemForm] handleDeleteFile 호출:', {
- fileType,
- propItemId,
- existingBendingDiagramFileId,
- existingSpecificationFileId,
- existingCertificationFileId,
+ await handleDeleteFileFromHook(fileType, {
+ onBendingDiagramDeleted: () => setBendingDiagram(''),
});
-
- if (!propItemId) {
- console.error('[DynamicItemForm] propItemId가 없습니다');
- return;
- }
-
- // 파일 ID 가져오기
- let fileId: number | null = null;
- if (fileType === 'bending_diagram') {
- fileId = existingBendingDiagramFileId;
- } else if (fileType === 'specification') {
- fileId = existingSpecificationFileId;
- } else if (fileType === 'certification') {
- fileId = existingCertificationFileId;
- }
-
- console.log('[DynamicItemForm] 삭제할 파일 ID:', fileId);
-
- if (!fileId) {
- console.error('[DynamicItemForm] 파일 ID를 찾을 수 없습니다:', fileType);
- alert('파일 ID를 찾을 수 없습니다.');
- return;
- }
-
- const confirmMessage = fileType === 'bending_diagram' ? '전개도 이미지를' :
- fileType === 'specification' ? '시방서 파일을' : '인정서 파일을';
-
- if (!confirm(`${confirmMessage} 삭제하시겠습니까?`)) return;
-
- try {
- setIsDeletingFile(fileType);
- await deleteItemFile(propItemId, fileId, selectedItemType || 'FG');
-
- // 상태 업데이트
- if (fileType === 'bending_diagram') {
- setExistingBendingDiagram('');
- setBendingDiagram('');
- setExistingBendingDiagramFileId(null);
- } else if (fileType === 'specification') {
- setExistingSpecificationFile('');
- setExistingSpecificationFileName('');
- setExistingSpecificationFileId(null);
- } else if (fileType === 'certification') {
- setExistingCertificationFile('');
- setExistingCertificationFileName('');
- setExistingCertificationFileId(null);
- }
-
- alert('파일이 삭제되었습니다.');
- } catch (error) {
- console.error('[DynamicItemForm] 파일 삭제 실패:', error);
- alert('파일 삭제에 실패했습니다.');
- } finally {
- setIsDeletingFile(null);
- }
};
// 조건부 표시 관리
@@ -636,671 +264,64 @@ export default function DynamicItemForm({
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('');
-
- // 부품 유형 변경 시 조건부 표시 관련 필드 초기화
- // 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-16: bendingDetails 로드 후 폭 합계를 formData에 동기화
- // bendingFieldKeys.widthSum이 결정된 후에 실행되어야 함
- const bendingWidthSumSyncedRef = useRef(false);
- useEffect(() => {
- // edit 모드이고, bendingDetails가 있고, widthSum 필드 키가 결정되었을 때만 실행
- if (mode !== 'edit' || bendingDetails.length === 0 || !bendingFieldKeys.widthSum) {
- return;
- }
-
- // 이미 동기화했으면 스킵 (중복 실행 방지)
- if (bendingWidthSumSyncedRef.current) {
- return;
- }
-
- const totalSum = bendingDetails.reduce((acc, detail) => {
- return acc + detail.input + detail.elongation;
- }, 0);
-
- const sumString = totalSum.toString();
- console.log('[DynamicItemForm] bendingDetails 폭 합계 → formData 동기화:', {
- widthSumKey: bendingFieldKeys.widthSum,
- totalSum,
- bendingDetailsCount: bendingDetails.length,
- });
-
- setFieldValue(bendingFieldKeys.widthSum, sumString);
- bendingWidthSumSyncedRef.current = true;
- }, [mode, bendingDetails, bendingFieldKeys.widthSum, setFieldValue]);
-
- // 2025-12-04: 품목명 변경 시 종류 필드 값 초기화
- // 품목명(A)→종류(A1) 선택 후 품목명(B)로 변경 시, 이전 종류(A1) 값이 남아있어서
- // 새 종류(B1) 선택해도 이전 값이 품목코드에 적용되는 버그 수정
- const prevItemNameValueRef = useRef('');
-
- 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]);
+ // 부품 유형 및 BOM 필드 탐지 (커스텀 훅으로 분리)
+ const {
+ partTypeFieldKey,
+ selectedPartType,
+ isBendingPart,
+ isAssemblyPart,
+ isPurchasedPart,
+ bomRequiredFieldKey,
+ } = useFieldDetection({
+ structure,
+ selectedItemType,
+ formData,
+ });
+
+ // 품목코드 자동생성 관련 정보 (커스텀 훅으로 분리)
+ const {
+ hasAutoItemCode,
+ itemNameKey,
+ allSpecificationKeys: _allSpecificationKeys,
+ statusFieldKey,
+ activeSpecificationKey,
+ bendingFieldKeys,
+ autoBendingItemCode,
+ allCategoryKeysWithIds,
+ hasAssemblyFields: _hasAssemblyFields,
+ assemblyFieldKeys: _assemblyFieldKeys,
+ autoAssemblyItemName,
+ autoAssemblySpec,
+ purchasedFieldKeys,
+ autoPurchasedItemCode,
+ autoGeneratedItemCode,
+ } = useItemCodeGeneration({
+ structure,
+ selectedItemType,
+ formData,
+ isBendingPart,
+ isAssemblyPart,
+ isPurchasedPart,
+ existingItemCodes,
+ shouldShowSection,
+ shouldShowField,
+ });
+
+ // 부품 유형 변경 시 필드 초기화 처리 (커스텀 훅으로 분리)
+ usePartTypeHandling({
+ structure,
+ selectedItemType,
+ partTypeFieldKey,
+ selectedPartType,
+ itemNameKey,
+ setFieldValue,
+ formData,
+ bendingFieldKeys,
+ isBendingPart,
+ allCategoryKeysWithIds,
+ mode,
+ bendingDetails,
+ });
// 품목 유형 변경 핸들러
const handleItemTypeChange = (type: ItemType) => {
@@ -1503,7 +524,6 @@ export default function DynamicItemForm({
}
// 품목 유형 및 BOM 데이터 추가
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
const submitData = {
...convertedData,
// 백엔드 필드명 사용
@@ -1818,196 +838,23 @@ export default function DynamicItemForm({
)}
{/* FG(제품) 전용: 인정 유효기간 종료일 다음에 시방서/인정서 파일 업로드 */}
{isCertEndDateField && selectedItemType === 'FG' && (
-
- {/* 시방서 파일 */}
-
-
-
- {/* 기존 파일이 있고, 새 파일 선택 안한 경우: 파일명 + 버튼들 */}
- {mode === 'edit' && existingSpecificationFile && !specificationFile ? (
-
-
-
- {existingSpecificationFileName}
-
-
-
-
-
- ) : specificationFile ? (
- /* 새 파일 선택된 경우: 커스텀 UI로 파일명 표시 */
-
-
-
- {specificationFile.name}
- (새 파일)
-
-
-
- ) : (
- /* 파일 없는 경우: 파일 선택 버튼 */
-
-
- {
- const file = e.target.files?.[0] || null;
- setSpecificationFile(file);
- }}
- disabled={isSubmitting}
- className="hidden"
- />
-
- )}
-
-
- {/* 인정서 파일 */}
-
-
-
- {/* 기존 파일이 있고, 새 파일 선택 안한 경우: 파일명 + 버튼들 */}
- {mode === 'edit' && existingCertificationFile && !certificationFile ? (
-
-
-
- {existingCertificationFileName}
-
-
-
-
-
- ) : certificationFile ? (
- /* 새 파일 선택된 경우: 커스텀 UI로 파일명 표시 */
-
-
-
- {certificationFile.name}
- (새 파일)
-
-
-
- ) : (
- /* 파일 없는 경우: 파일 선택 버튼 */
-
-
- {
- const file = e.target.files?.[0] || null;
- setCertificationFile(file);
- }}
- disabled={isSubmitting}
- className="hidden"
- />
-
- )}
-
-
-
+
)}
);
@@ -2164,6 +1011,7 @@ export default function DynamicItemForm({
// Base64 string을 File 객체로 변환 (업로드용)
// 2025-12-06: 드로잉 방식에서도 파일 업로드 지원
try {
+ // eslint-disable-next-line no-undef
const byteString = atob(imageData.split(',')[1]);
const mimeType = imageData.split(',')[0].split(':')[1].split(';')[0];
const arrayBuffer = new ArrayBuffer(byteString.length);
@@ -2171,6 +1019,7 @@ export default function DynamicItemForm({
for (let i = 0; i < byteString.length; i++) {
uint8Array[i] = byteString.charCodeAt(i);
}
+ // eslint-disable-next-line no-undef
const blob = new Blob([uint8Array], { type: mimeType });
const file = new File([blob], `bending_diagram_${Date.now()}.png`, { type: mimeType });
setBendingDiagramFile(file);
@@ -2190,27 +1039,12 @@ export default function DynamicItemForm({
/>
{/* 품목코드 중복 확인 다이얼로그 */}
-
-
-
- 품목코드 중복
-
- 입력하신 조건의 품목코드가 이미 존재합니다.
-
- 기존 품목을 수정하시겠습니까?
-
-
-
-
-
- 취소
-
-
- 중복 품목 수정하러 가기
-
-
-
-
+
);
}
diff --git a/src/components/items/DynamicItemForm/types.ts b/src/components/items/DynamicItemForm/types.ts
index c8c8dd38..e0e900f9 100644
--- a/src/components/items/DynamicItemForm/types.ts
+++ b/src/components/items/DynamicItemForm/types.ts
@@ -170,17 +170,6 @@ export interface DynamicFieldRendererProps {
unitOptions?: SimpleUnitOption[];
}
-/**
- * 동적 섹션 렌더러 Props
- */
-export interface DynamicSectionRendererProps {
- section: DynamicSection;
- formData: DynamicFormData;
- errors: DynamicFormErrors;
- onChange: (fieldKey: string, value: DynamicFieldValue) => void;
- disabled?: boolean;
- unitOptions?: SimpleUnitOption[];
-}
// ============================================
// Hook 반환 타입
diff --git a/src/components/items/ItemMasterDataManagement.tsx b/src/components/items/ItemMasterDataManagement.tsx
index f49f26e0..eb869cf3 100644
--- a/src/components/items/ItemMasterDataManagement.tsx
+++ b/src/components/items/ItemMasterDataManagement.tsx
@@ -306,16 +306,13 @@ export function ItemMasterDataManagement() {
description: section.description || null,
default_fields: null,
// ItemField → TemplateField 변환
- // 2025-11-28: field_key에서 사용자입력 부분만 추출 (백엔드 형식: {ID}_{사용자입력})
+ // 2025-12-16: field_key 전체 표시 (백엔드 형식: {ID}_{사용자입력})
fields: section.fields?.map(field => {
- const rawKey = field.field_key || '';
- const displayKey = rawKey.includes('_')
- ? rawKey.substring(rawKey.indexOf('_') + 1)
- : rawKey || field.field_name.toLowerCase().replace(/\s+/g, '_');
+ const rawKey = field.field_key || field.field_name.toLowerCase().replace(/\s+/g, '_');
return {
id: field.id.toString(),
name: field.field_name,
- fieldKey: displayKey,
+ fieldKey: rawKey,
property: {
inputType: field.field_type,
// 2025-11-27: is_required와 properties.required 둘 다 체크
@@ -334,7 +331,7 @@ export function ItemMasterDataManagement() {
);
// 2. 독립 섹션들 (page_id = null, 연결 해제된 섹션)
- // 2025-11-28: field_key에서 사용자입력 부분만 추출 (백엔드 형식: {ID}_{사용자입력})
+ // 2025-12-16: field_key 전체 표시 (백엔드 형식: {ID}_{사용자입력})
const unlinkedSections = independentSections.map(section => ({
id: section.id,
tenant_id: section.tenant_id || 0,
@@ -343,14 +340,11 @@ export function ItemMasterDataManagement() {
description: section.description || null,
default_fields: null,
fields: section.fields?.map(field => {
- const rawKey = field.field_key || '';
- const displayKey = rawKey.includes('_')
- ? rawKey.substring(rawKey.indexOf('_') + 1)
- : rawKey || field.field_name.toLowerCase().replace(/\s+/g, '_');
+ const rawKey = field.field_key || field.field_name.toLowerCase().replace(/\s+/g, '_');
return {
id: field.id.toString(),
name: field.field_name,
- fieldKey: displayKey,
+ fieldKey: rawKey,
property: {
inputType: field.field_type,
// 2025-11-27: is_required와 properties.required 둘 다 체크
diff --git a/src/components/items/ItemMasterDataManagement/services/fieldService.ts b/src/components/items/ItemMasterDataManagement/services/fieldService.ts
index c4dacb04..2ba89fab 100644
--- a/src/components/items/ItemMasterDataManagement/services/fieldService.ts
+++ b/src/components/items/ItemMasterDataManagement/services/fieldService.ts
@@ -91,17 +91,14 @@ export const fieldService = {
/**
* 필드 키 유효성 검사
* - 필수 입력
- * - 영문자로 시작
* - 영문, 숫자, 언더스코어만 허용
+ * 2025-12-16: 숫자로 시작하는 키도 허용 (예: 105_state)
*/
validateFieldKey: (key: string): SingleFieldValidation => {
if (!key || !key.trim()) {
return { valid: false, error: '필드 키를 입력해주세요' };
}
- if (!/^[a-zA-Z]/.test(key)) {
- return { valid: false, error: '영문자로 시작해야 합니다' };
- }
- if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(key)) {
+ if (!/^[a-zA-Z0-9_]+$/.test(key)) {
return { valid: false, error: '영문, 숫자, 언더스코어만 사용 가능합니다' };
}
return { valid: true };
@@ -110,8 +107,9 @@ export const fieldService = {
/**
* 필드 키 패턴 정규식
* UI에서 직접 사용 가능
+ * 2025-12-16: 숫자로 시작하는 키도 허용
*/
- fieldKeyPattern: /^[a-zA-Z][a-zA-Z0-9_]*$/,
+ fieldKeyPattern: /^[a-zA-Z0-9_]+$/,
/**
* 필드 키가 유효한지 간단 체크 (boolean 반환)
@@ -124,20 +122,11 @@ export const fieldService = {
// ===== Parsing =====
/**
- * field_key에서 사용자 입력 부분 추출
- * 형식: {ID}_{사용자입력} → 사용자입력 반환
- * 예: "123_itemCode" → "itemCode"
+ * field_key 반환 (전체 키 그대로 반환)
+ * 2025-12-16: 전체 field_key 표시로 변경 (예: "105_state" 그대로 표시)
*/
extractUserInputFromFieldKey: (fieldKey: string | null | undefined): string => {
if (!fieldKey) return '';
-
- // 언더스코어가 포함된 경우 첫 번째 언더스코어 이후 부분 반환
- const underscoreIndex = fieldKey.indexOf('_');
- if (underscoreIndex !== -1) {
- return fieldKey.substring(underscoreIndex + 1);
- }
-
- // 언더스코어가 없으면 전체 반환
return fieldKey;
},