Files
sam-react-prod/src/components/items/DynamicItemForm/index.tsx
유병철 020d74f36c feat(WEB): DynamicItemForm 필드 타입 확장 및 컴포넌트 레지스트리 추가
- DynamicFieldRenderer에 신규 필드 타입 추가 (Currency, File, MultiSelect, Radio, Reference, Toggle, UnitValue, Computed)
- DynamicTableSection 및 TableCellRenderer 추가
- 필드 프리셋 및 설정 구조 분리
- 컴포넌트 레지스트리 개발 도구 페이지 추가
- UniversalListPage 개선
- 근태관리 코드 정리
- 즐겨찾기 기능 및 동적 필드 타입 백엔드 스펙 문서 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 11:17:57 +09:00

1051 lines
43 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* DynamicItemForm - 품목기준관리 API 기반 동적 품목 등록 폼
*
* 기존 ItemForm과 100% 동일한 디자인 유지
*/
'use client';
import { useState, useEffect, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { cn } from '@/lib/utils';
import { useMenuStore } from '@/stores/menuStore';
import { Save, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { FormSectionSkeleton } from '@/components/ui/skeleton';
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, useFieldDetection, useItemCodeGeneration, usePartTypeHandling, useFileHandling } from './hooks';
import { DynamicFieldRenderer } from './fields';
import { DynamicBOMSection } from './sections';
import { FormHeader, ValidationAlert, FileUploadFields, DuplicateCodeDialog } from './components';
import type { DynamicItemFormProps, DynamicFormData, BOMLine, BOMSearchState } from './types';
import type { ItemType, BendingDetail } from '@/types/item';
import type { ItemFieldResponse } from '@/types/item-master-api';
import { uploadItemFile, ItemFileType, checkItemCodeDuplicate, DuplicateCheckResult } from '@/lib/api/items';
import { DuplicateCodeError } from '@/lib/api/error-handler';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
/**
* 메인 DynamicItemForm 컴포넌트
*/
export default function DynamicItemForm({
mode,
itemType: initialItemType,
itemId: propItemId,
initialData,
initialBomLines,
onSubmit,
}: DynamicItemFormProps) {
const router = useRouter();
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
// 품목 유형 상태 (변경 가능)
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);
// 파일 처리 훅 (기존 파일 로드, 다운로드, 삭제)
const {
existingBendingDiagram,
existingBendingDiagramFileId,
existingSpecificationFile,
existingSpecificationFileName,
existingSpecificationFileId,
existingCertificationFile,
existingCertificationFileName,
existingCertificationFileId,
isDeletingFile,
setExistingBendingDiagram: _setExistingBendingDiagram,
setExistingBendingDiagramFileId: _setExistingBendingDiagramFileId,
handleFileDownload,
handleDeleteFile: handleDeleteFileFromHook,
loadedBendingDetails,
loadedWidthSum,
} = useFileHandling({
mode,
initialData,
propItemId,
selectedItemType,
});
// 품목코드 중복 체크 상태 관리
const [showDuplicateDialog, setShowDuplicateDialog] = useState(false);
const [duplicateCheckResult, setDuplicateCheckResult] = useState<DuplicateCheckResult | null>(null);
const [_pendingSubmitData, setPendingSubmitData] = useState<DynamicFormData | null>(null);
// 훅에서 로드한 bendingDetails/widthSum을 로컬 상태와 동기화 (edit 모드)
useEffect(() => {
if (loadedBendingDetails.length > 0) {
setBendingDetails(loadedBendingDetails);
}
if (loadedWidthSum) {
setWidthSum(loadedWidthSum);
}
}, [loadedBendingDetails, loadedWidthSum]);
// initialBomLines prop으로 BOM 데이터 로드 (edit 모드)
// 2025-12-12: edit 페이지에서 별도로 전달받은 BOM 데이터 사용
useEffect(() => {
if (mode === 'edit' && initialBomLines && initialBomLines.length > 0) {
setBomLines(initialBomLines);
}
}, [mode, initialBomLines]);
// 파일 삭제 래퍼 (훅의 handleDeleteFile에 콜백 전달)
const handleDeleteFile = async (fileType: ItemFileType) => {
await handleDeleteFileFromHook(fileType, {
onBendingDiagramDeleted: () => setBendingDiagram(''),
});
};
// 조건부 표시 관리
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);
}
} 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(() => {
if (mode !== 'edit' || !structure || !initialData || isEditDataMapped) return;
// structure의 field_key들 확인
const fieldKeys: string[] = [];
structure.sections.forEach((section) => {
section.fields.forEach((f) => {
fieldKeys.push(f.field.field_key || `field_${f.field.id}`);
});
});
// 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]);
// 부품 유형 및 BOM 필드 탐지 (커스텀 훅으로 분리)
const {
partTypeFieldKey,
selectedPartType,
isBendingPart,
isAssemblyPart,
isPurchasedPart,
bomRequiredFieldKey,
} = useFieldDetection({
structure,
selectedItemType,
formData,
});
// 품목코드 자동생성 관련 정보 (커스텀 훅으로 분리)
const {
hasAutoItemCode,
itemNameKey,
allSpecificationKeys: _allSpecificationKeys,
statusFieldKey,
activeSpecificationKey,
bendingFieldKeys,
autoBendingItemCode,
allCategoryKeysWithIds,
hasAssemblyFields: _hasAssemblyFields,
assemblyFieldKeys: _assemblyFieldKeys,
autoAssemblyItemName,
autoAssemblySpec,
purchasedFieldKeys,
autoPurchasedItemCode,
autoGeneratedItemCode,
} = useItemCodeGeneration({
structure,
selectedItemType,
formData,
isBendingPart,
isAssemblyPart,
isPurchasedPart,
existingItemCodes,
shouldShowSection,
shouldShowField,
});
// 부품 유형 변경 시 필드 초기화 처리 (커스텀 훅으로 분리)
usePartTypeHandling({
structure,
selectedItemType,
partTypeFieldKey,
selectedPartType,
itemNameKey,
setFieldValue,
formData,
bendingFieldKeys,
isBendingPart,
allCategoryKeysWithIds,
mode,
bendingDetails,
});
// 품목 유형 변경 핸들러
const handleItemTypeChange = (type: ItemType) => {
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 {
await uploadItemFile(itemId, bendingDiagramFile, 'bending_diagram', {
fieldKey: 'bending_diagram',
// 수정 모드: 기존 파일 ID가 있으면 덮어쓰기, 없으면 새 파일 등록
fileId: existingBendingDiagramFileId ?? undefined,
bendingDetails: bendingDetails.length > 0 ? bendingDetails.map(d => ({
angle: d.aAngle || 0,
length: d.input || 0,
type: d.shaded ? 'shaded' : 'normal',
})) : undefined,
});
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[DynamicItemForm] 전개도 파일 업로드 실패:', error);
fileUploadErrors.push('전개도 이미지');
}
}
// FG (제품) 시방서 업로드
if (selectedItemType === 'FG' && specificationFile) {
try {
await uploadItemFile(itemId, specificationFile, 'specification', {
fieldKey: 'specification_file',
// 수정 모드: 기존 파일 ID가 있으면 덮어쓰기, 없으면 새 파일 등록
fileId: existingSpecificationFileId ?? undefined,
});
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[DynamicItemForm] 시방서 파일 업로드 실패:', error);
fileUploadErrors.push('시방서');
}
}
// FG (제품) 인정서 업로드
if (selectedItemType === 'FG' && certificationFile) {
try {
// 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',
// 수정 모드: 기존 파일 ID가 있으면 덮어쓰기, 없으면 새 파일 등록
fileId: existingCertificationFileId ?? undefined,
certificationNumber: certNumber,
certificationStartDate: certStartDate,
certificationEndDate: certEndDate,
});
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[DynamicItemForm] 인정서 파일 업로드 실패:', error);
fileUploadErrors.push('인정서');
}
}
// 파일 업로드 실패 경고 (품목은 저장됨)
if (fileUploadErrors.length > 0) {
console.warn('[DynamicItemForm] 일부 파일 업로드 실패:', fileUploadErrors.join(', '));
// 품목은 저장되었으므로 경고만 표시하고 진행
alert(`품목이 저장되었습니다.\n\n일부 파일 업로드에 실패했습니다: ${fileUploadErrors.join(', ')}\n수정 화면에서 다시 업로드해 주세요.`);
}
}
router.push('/production/screen-production');
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가 백엔드 필드명과 일치하므로 직접 사용
// 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;
}
//
// 품목코드 결정
// 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 데이터 추가
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;
//
// 2025-12-11: 품목코드 중복 체크 (조립/절곡 부품만 해당)
// PT-조립부품, PT-절곡부품은 품목코드가 자동생성되므로 중복 체크 필요
const needsDuplicateCheck = selectedItemType === 'PT' && (isAssemblyPart || isBendingPart) && finalCode;
if (needsDuplicateCheck) {
// 수정 모드에서는 자기 자신 제외 (propItemId)
const excludeId = mode === 'edit' ? propItemId : undefined;
const duplicateResult = await checkItemCodeDuplicate(finalCode, excludeId);
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(`/production/screen-production/${itemId}?mode=edit&type=${itemType}&id=${itemId}`);
}
};
// 중복 다이얼로그에서 "취소" 버튼 클릭 핸들러
const handleCancelDuplicate = () => {
setShowDuplicateDialog(false);
setDuplicateCheckResult(null);
setPendingSubmitData(null);
};
// 로딩 상태
if (isLoading && selectedItemType) {
return <FormSectionSkeleton />;
}
// 에러 상태
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 pb-24">
{/* Validation 에러 Alert */}
<ValidationAlert errors={errors} />
{/* 헤더 */}
<FormHeader mode={mode} />
{/* 기본 정보 - 목업과 동일한 레이아웃 (모든 필드 한 줄씩) */}
<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}
formData={formData}
/>
);
})}
{/* 첫 번째 섹션의 필드 렌더링 */}
{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}
formData={formData}
/>
{/* 규격 필드 바로 다음에 품목코드 자동생성 필드 표시 (절곡부품 제외) */}
{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">
* &apos;++&apos; (: 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">
* &apos;++&apos; (: 전동개폐기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' && (
<FileUploadFields
mode={mode}
isSubmitting={isSubmitting}
specificationFile={specificationFile}
setSpecificationFile={setSpecificationFile}
existingSpecificationFile={existingSpecificationFile}
existingSpecificationFileName={existingSpecificationFileName}
existingSpecificationFileId={existingSpecificationFileId}
certificationFile={certificationFile}
setCertificationFile={setCertificationFile}
existingCertificationFile={existingCertificationFile}
existingCertificationFileName={existingCertificationFileName}
existingCertificationFileId={existingCertificationFileId}
onFileDownload={handleFileDownload}
onDeleteFile={handleDeleteFile}
isDeletingFile={isDeletingFile}
/>
)}
</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}
formData={formData}
/>
);
})}
</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}
existingBendingDiagram={existingBendingDiagram}
existingBendingDiagramFileId={existingBendingDiagramFileId}
onDeleteExistingFile={() => handleDeleteFile('bending_diagram')}
isDeletingFile={isDeletingFile === 'bending_diagram'}
/>
)}
{/* 절곡품 전개도 섹션 (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}
existingBendingDiagram={existingBendingDiagram}
existingBendingDiagramFileId={existingBendingDiagramFileId}
onDeleteExistingFile={() => handleDeleteFile('bending_diagram')}
isDeletingFile={isDeletingFile === 'bending_diagram'}
/>
)}
{/* BOM 섹션 (부품구성 필요 체크 시에만 표시) */}
{selectedItemType && bomSection && (() => {
// bomRequiredFieldKey는 useMemo에서 structure 기반으로 미리 계산됨
const bomValue = bomRequiredFieldKey ? formData[bomRequiredFieldKey] : undefined;
const isBomRequired = bomValue === true || bomValue === 'true' || bomValue === '1' || bomValue === 1;
// 디버깅 로그
//
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 {
// eslint-disable-next-line no-undef
const byteString = atob(imageData.split(',')[1]);
const mimeType = imageData.split(',')[0].split(':')[1].split(';')[0];
const arrayBuffer = new ArrayBuffer(byteString.length);
const uint8Array = new Uint8Array(arrayBuffer);
for (let i = 0; i < byteString.length; i++) {
uint8Array[i] = byteString.charCodeAt(i);
}
// eslint-disable-next-line no-undef
const blob = new Blob([uint8Array], { type: mimeType });
const file = new File([blob], `bending_diagram_${Date.now()}.png`, { type: mimeType });
setBendingDiagramFile(file);
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[DynamicItemForm] 드로잉 캔버스 → File 변환 실패:', error);
}
setIsDrawingOpen(false);
}}
initialImage={bendingDiagram}
title={isAssemblyPart ? "조립품 전개도" : "절곡품 전개도"}
description={isAssemblyPart
? "조립품 전개도(바라시)를 그리거나 편집합니다."
: "절곡품 전개도를 그리거나 편집합니다."
}
/>
{/* 품목코드 중복 확인 다이얼로그 */}
<DuplicateCodeDialog
open={showDuplicateDialog}
onOpenChange={setShowDuplicateDialog}
onCancel={handleCancelDuplicate}
onGoToEdit={handleGoToEditDuplicate}
/>
{/* 하단 액션 버튼 (sticky) */}
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[48px] ${sidebarCollapsed ? 'md:left-[156px]' : 'md:left-[316px]'} flex items-center justify-between`}>
<Button
type="button"
variant="outline"
onClick={() => router.back()}
disabled={isSubmitting}
size="sm"
className="md:size-default"
>
<X className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
<Button
type="submit"
disabled={!selectedItemType || isSubmitting}
size="sm"
className="md:size-default"
>
<Save className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline">{isSubmitting ? '저장 중...' : '저장'}</span>
</Button>
</div>
</form>
);
}