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