From 25f9d4e55f3c49a91f5739d880e5021a786b0142 Mon Sep 17 00:00:00 2001 From: byeongcheolryu Date: Tue, 16 Dec 2025 17:40:55 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20DynamicItemForm=20=ED=9B=85/?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 대규모 코드 구조 개선: - useFieldDetection: 필드 감지 로직 분리 - useFileHandling: 파일 업로드 로직 분리 - useItemCodeGeneration: 품목코드 자동생성 로직 분리 - usePartTypeHandling: 파트타입 처리 로직 분리 - FormHeader, ValidationAlert, FileUploadFields 컴포넌트 분리 - DuplicateCodeDialog 컴포넌트 분리 - index.tsx 1300줄+ 감소로 가독성 및 유지보수성 향상 - BOM 검색 최적화 (검색어 입력 시에만 API 호출) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../(protected)/items/[id]/edit/page.tsx | 21 +- .../(protected)/items/create/page.tsx | 3 +- .../components/DuplicateCodeDialog.tsx | 53 + .../components/FileUploadFields.tsx | 240 +++ .../DynamicItemForm/components/FormHeader.tsx | 62 + .../components/ValidationAlert.tsx | 41 + .../items/DynamicItemForm/components/index.ts | 11 + .../items/DynamicItemForm/hooks/index.ts | 19 + .../hooks/useDynamicFormState.ts | 2 +- .../hooks/useFieldDetection.ts | 175 ++ .../DynamicItemForm/hooks/useFileHandling.ts | 329 ++++ .../hooks/useItemCodeGeneration.ts | 524 ++++++ .../hooks/usePartTypeHandling.ts | 193 +++ .../items/DynamicItemForm/index.tsx | 1410 ++--------------- src/components/items/DynamicItemForm/types.ts | 11 - .../items/ItemMasterDataManagement.tsx | 18 +- .../services/fieldService.ts | 23 +- 17 files changed, 1788 insertions(+), 1347 deletions(-) create mode 100644 src/components/items/DynamicItemForm/components/DuplicateCodeDialog.tsx create mode 100644 src/components/items/DynamicItemForm/components/FileUploadFields.tsx create mode 100644 src/components/items/DynamicItemForm/components/FormHeader.tsx create mode 100644 src/components/items/DynamicItemForm/components/ValidationAlert.tsx create mode 100644 src/components/items/DynamicItemForm/components/index.ts create mode 100644 src/components/items/DynamicItemForm/hooks/useFieldDetection.ts create mode 100644 src/components/items/DynamicItemForm/hooks/useFileHandling.ts create mode 100644 src/components/items/DynamicItemForm/hooks/useItemCodeGeneration.ts create mode 100644 src/components/items/DynamicItemForm/hooks/usePartTypeHandling.ts 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; },