refactor: DynamicItemForm 훅/컴포넌트 분리 리팩토링

대규모 코드 구조 개선:
- 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 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-12-16 17:40:55 +09:00
parent b1587071f2
commit 25f9d4e55f
17 changed files with 1788 additions and 1347 deletions

View File

@@ -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) {

View File

@@ -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 (주석처리 - 롤백 시 사용)

View File

@@ -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 (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
.
<span className="block mt-2 font-medium text-foreground">
?
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>
</AlertDialogCancel>
<AlertDialogAction onClick={onGoToEdit}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -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 (
<div className="mt-4 space-y-4">
{/* 시방서 파일 */}
<div>
<Label htmlFor="specification_file"> (PDF)</Label>
<div className="mt-1.5">
{/* 기존 파일이 있고, 새 파일 선택 안한 경우: 파일명 + 버튼들 */}
{mode === 'edit' && existingSpecificationFile && !specificationFile ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-1 px-3 py-2 bg-gray-50 rounded-md border text-sm">
<FileText className="h-4 w-4 text-blue-600 shrink-0" />
<span className="truncate">{existingSpecificationFileName}</span>
</div>
<button
type="button"
onClick={() => onFileDownload(existingSpecificationFileId, existingSpecificationFileName)}
className="inline-flex items-center justify-center h-9 w-9 rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground text-blue-600"
title="다운로드"
>
<Download className="h-4 w-4" />
</button>
<label
htmlFor="specification_file_edit"
className="inline-flex items-center justify-center h-9 w-9 rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground text-gray-600 cursor-pointer"
title="수정"
>
<Pencil className="h-4 w-4" />
<input
id="specification_file_edit"
type="file"
accept=".pdf"
onChange={(e) => {
const file = e.target.files?.[0] || null;
setSpecificationFile(file);
}}
disabled={isSubmitting}
className="hidden"
/>
</label>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => onDeleteFile('specification')}
disabled={isDeletingFile === 'specification' || isSubmitting}
className="h-9 w-9 text-red-500 hover:text-red-700 hover:bg-red-50"
title="삭제"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
) : specificationFile ? (
/* 새 파일 선택된 경우: 커스텀 UI로 파일명 표시 */
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-1 px-3 py-2 bg-blue-50 rounded-md border border-blue-200 text-sm">
<FileText className="h-4 w-4 text-blue-600 shrink-0" />
<span className="truncate">{specificationFile.name}</span>
<span className="text-xs text-blue-500">( )</span>
</div>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setSpecificationFile(null)}
disabled={isSubmitting}
className="h-9 w-9 text-red-500 hover:text-red-700 hover:bg-red-50"
title="취소"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
) : (
/* 파일 없는 경우: 파일 선택 버튼 */
<div className="flex items-center gap-2">
<label
htmlFor="specification_file"
className="flex items-center gap-2 flex-1 px-3 py-2 bg-gray-50 rounded-md border text-sm cursor-pointer hover:bg-gray-100 transition-colors"
>
<Upload className="h-4 w-4 text-gray-500 shrink-0" />
<span className="text-gray-500">PDF </span>
</label>
<input
id="specification_file"
type="file"
accept=".pdf"
onChange={(e) => {
const file = e.target.files?.[0] || null;
setSpecificationFile(file);
}}
disabled={isSubmitting}
className="hidden"
/>
</div>
)}
</div>
</div>
{/* 인정서 파일 */}
<div>
<Label htmlFor="certification_file"> (PDF)</Label>
<div className="mt-1.5">
{/* 기존 파일이 있고, 새 파일 선택 안한 경우: 파일명 + 버튼들 */}
{mode === 'edit' && existingCertificationFile && !certificationFile ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-1 px-3 py-2 bg-gray-50 rounded-md border text-sm">
<FileText className="h-4 w-4 text-green-600 shrink-0" />
<span className="truncate">{existingCertificationFileName}</span>
</div>
<button
type="button"
onClick={() => onFileDownload(existingCertificationFileId, existingCertificationFileName)}
className="inline-flex items-center justify-center h-9 w-9 rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground text-green-600"
title="다운로드"
>
<Download className="h-4 w-4" />
</button>
<label
htmlFor="certification_file_edit"
className="inline-flex items-center justify-center h-9 w-9 rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground text-gray-600 cursor-pointer"
title="수정"
>
<Pencil className="h-4 w-4" />
<input
id="certification_file_edit"
type="file"
accept=".pdf"
onChange={(e) => {
const file = e.target.files?.[0] || null;
setCertificationFile(file);
}}
disabled={isSubmitting}
className="hidden"
/>
</label>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => onDeleteFile('certification')}
disabled={isDeletingFile === 'certification' || isSubmitting}
className="h-9 w-9 text-red-500 hover:text-red-700 hover:bg-red-50"
title="삭제"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
) : certificationFile ? (
/* 새 파일 선택된 경우: 커스텀 UI로 파일명 표시 */
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-1 px-3 py-2 bg-green-50 rounded-md border border-green-200 text-sm">
<FileText className="h-4 w-4 text-green-600 shrink-0" />
<span className="truncate">{certificationFile.name}</span>
<span className="text-xs text-green-500">( )</span>
</div>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setCertificationFile(null)}
disabled={isSubmitting}
className="h-9 w-9 text-red-500 hover:text-red-700 hover:bg-red-50"
title="취소"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
) : (
/* 파일 없는 경우: 파일 선택 버튼 */
<div className="flex items-center gap-2">
<label
htmlFor="certification_file"
className="flex items-center gap-2 flex-1 px-3 py-2 bg-gray-50 rounded-md border text-sm cursor-pointer hover:bg-gray-100 transition-colors"
>
<Upload className="h-4 w-4 text-gray-500 shrink-0" />
<span className="text-gray-500">PDF </span>
</label>
<input
id="certification_file"
type="file"
accept=".pdf"
onChange={(e) => {
const file = e.target.files?.[0] || null;
setCertificationFile(file);
}}
disabled={isSubmitting}
className="hidden"
/>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -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 (
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="p-2 bg-primary/10 rounded-lg hidden md:block">
<Package className="w-6 h-6 text-primary" />
</div>
<div>
<h1 className="text-xl md:text-2xl">
{mode === 'create' ? '품목 등록' : '품목 수정'}
</h1>
<p className="text-sm text-muted-foreground mt-1">
</p>
</div>
</div>
<div className="flex gap-1 sm:gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={onCancel}
className="gap-1 sm:gap-2"
disabled={isSubmitting}
>
<X className="h-4 w-4" />
<span className="hidden sm:inline"></span>
</Button>
<Button
type="submit"
size="sm"
disabled={!selectedItemType || isSubmitting}
className="gap-1 sm:gap-2"
>
<Save className="h-4 w-4" />
<span className="hidden sm:inline">{isSubmitting ? '저장 중...' : '저장'}</span>
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
'use client';
import { Alert, AlertDescription } from '@/components/ui/alert';
export interface ValidationAlertProps {
errors: Record<string, string>;
}
/**
* 밸리데이션 에러 Alert - 기존 ValidationAlert와 동일한 디자인
*/
export function ValidationAlert({ errors }: ValidationAlertProps) {
const errorCount = Object.keys(errors).length;
if (errorCount === 0) {
return null;
}
return (
<Alert className="bg-red-50 border-red-200">
<AlertDescription className="text-red-900">
<div className="flex items-start gap-2">
<span className="text-lg"></span>
<div className="flex-1">
<strong className="block mb-2">
({errorCount} )
</strong>
<ul className="space-y-1 text-sm">
{Object.entries(errors).map(([fieldKey, errorMessage]) => (
<li key={fieldKey} className="flex items-start gap-1">
<span></span>
<span>{errorMessage}</span>
</li>
))}
</ul>
</div>
</div>
</AlertDescription>
</Alert>
);
}

View File

@@ -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';

View File

@@ -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';

View File

@@ -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) {

View File

@@ -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,
};
}

View File

@@ -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<string, unknown>;
/** 품목 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<void>;
handleDeleteFile: (
fileType: ItemFileType,
callbacks?: {
onBendingDiagramDeleted?: () => void;
}
) => Promise<void>;
// 절곡 상세 정보 (edit 모드에서 로드)
loadedBendingDetails: BendingDetail[];
loadedWidthSum: string;
}
/**
* 파일 처리 커스텀 훅
*
* edit 모드에서 기존 파일 정보를 로드하고,
* 파일 다운로드/삭제 기능을 제공합니다.
*
* @param params - 훅 입력 파라미터
* @returns 파일 처리 관련 상태 및 핸들러
*/
export function useFileHandling({
mode,
initialData,
propItemId,
selectedItemType,
}: UseFileHandlingParams): FileHandlingResult {
// 기존 파일 URL 상태 (edit 모드에서 사용)
const [existingBendingDiagram, setExistingBendingDiagram] = useState<string>('');
const [existingBendingDiagramFileId, setExistingBendingDiagramFileId] = useState<number | null>(null);
const [existingSpecificationFile, setExistingSpecificationFile] = useState<string>('');
const [existingSpecificationFileName, setExistingSpecificationFileName] = useState<string>('');
const [existingSpecificationFileId, setExistingSpecificationFileId] = useState<number | null>(null);
const [existingCertificationFile, setExistingCertificationFile] = useState<string>('');
const [existingCertificationFileName, setExistingCertificationFileName] = useState<string>('');
const [existingCertificationFileId, setExistingCertificationFileId] = useState<number | null>(null);
const [isDeletingFile, setIsDeletingFile] = useState<string | null>(null);
// 절곡 상세 정보 (edit 모드에서 로드)
const [loadedBendingDetails, setLoadedBendingDetails] = useState<BendingDetail[]>([]);
const [loadedWidthSum, setLoadedWidthSum] = useState<string>('');
// 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<string, unknown>, 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,
};
}

View File

@@ -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,
};
}

View File

@@ -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<string>('');
// 부품 유형 변경 시 조건부 표시 관련 필드 초기화
// 2025-12-04: 절곡 ↔ 조립 부품 전환 시 formData 값이 유지되어
// 조건부 표시가 잘못 트리거되는 버그 수정
// 2025-12-04: setTimeout으로 초기화를 다음 틱으로 미뤄서 Select 두 번 클릭 문제 해결
useEffect(() => {
if (selectedItemType !== 'PT' || !partTypeFieldKey) return;
const currentPartType = selectedPartType;
const prevPartType = prevPartTypeRef.current;
// 이전 값이 있고, 현재 값과 다른 경우에만 초기화
if (prevPartType && prevPartType !== currentPartType) {
// console.log('[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<string>('');
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]);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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 반환 타입

View File

@@ -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 둘 다 체크

View File

@@ -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;
},