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:
@@ -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) {
|
||||
|
||||
@@ -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 (주석처리 - 롤백 시 사용)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
11
src/components/items/DynamicItemForm/components/index.ts
Normal file
11
src/components/items/DynamicItemForm/components/index.ts
Normal 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';
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
175
src/components/items/DynamicItemForm/hooks/useFieldDetection.ts
Normal file
175
src/components/items/DynamicItemForm/hooks/useFieldDetection.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
329
src/components/items/DynamicItemForm/hooks/useFileHandling.ts
Normal file
329
src/components/items/DynamicItemForm/hooks/useFileHandling.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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 반환 타입
|
||||
|
||||
@@ -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 둘 다 체크
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user