Files
sam-react-prod/src/components/items/DynamicItemForm/index.tsx
byeongcheolryu 48dbba0e5f feat: 단가관리 페이지 마이그레이션 및 HR 관리 기능 추가
## 단가관리 (Pricing Management)
- 단가 목록 페이지 (IntegratedListTemplateV2 공통 템플릿 적용)
- 단가 등록/수정 폼 (원가/마진 자동 계산)
- 이력 조회, 수정 이력, 최종 확정 다이얼로그
- 판매관리 > 단가관리 네비게이션 메뉴 추가

## HR 관리 (Human Resources)
- 사원관리 (목록, 등록, 수정, 상세, CSV 업로드)
- 부서관리 (트리 구조)
- 근태관리 (기본 구조)

## 품목관리 개선
- Radix UI Select controlled mode 버그 수정 (key prop 적용)
- DynamicItemForm 파일 업로드 지원
- 수정 페이지 데이터 로딩 개선

## 문서화
- 단가관리 마이그레이션 체크리스트
- HR 관리 구현 체크리스트
- Radix UI Select 버그 수정 가이드

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-06 11:36:38 +09:00

1939 lines
80 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, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { Package, Save, X, FileText, Trash2, Download } 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 } from '@/lib/api/items';
/**
* 헤더 컴포넌트 - 기존 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">
* &apos;-&apos;
</p>
</div>
)}
</CardContent>
</Card>
);
}
/**
* 메인 DynamicItemForm 컴포넌트
*/
export default function DynamicItemForm({
mode,
itemType: initialItemType,
itemId: propItemId,
initialData,
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 [existingSpecificationFile, setExistingSpecificationFile] = useState<string>('');
const [existingSpecificationFileName, setExistingSpecificationFileName] = useState<string>('');
const [existingCertificationFile, setExistingCertificationFile] = useState<string>('');
const [existingCertificationFileName, setExistingCertificationFileName] = useState<string>('');
const [isDeletingFile, setIsDeletingFile] = useState<string | null>(null);
// initialData에서 기존 파일 정보 로드 (edit 모드)
useEffect(() => {
if (mode === 'edit' && initialData) {
if (initialData.bending_diagram) {
setExistingBendingDiagram(initialData.bending_diagram as string);
}
if (initialData.specification_file) {
setExistingSpecificationFile(initialData.specification_file as string);
setExistingSpecificationFileName((initialData.specification_file_name as string) || '시방서');
}
if (initialData.certification_file) {
setExistingCertificationFile(initialData.certification_file as string);
setExistingCertificationFileName((initialData.certification_file_name as string) || '인정서');
}
}
}, [mode, initialData]);
// 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 모드: structure 로드 후 initialData를 field_key 형식으로 변환
// 2025-12-04: initialData 키(item_name)와 structure의 field_key(98_item_name)가 다른 문제 해결
const [isEditDataMapped, setIsEditDataMapped] = useState(false);
useEffect(() => {
if (mode !== 'edit' || !structure || !initialData) return;
// 이미 매핑된 데이터가 formData에 있으면 스킵 (98_unit 같은 field_key 형식)
// StrictMode 리렌더에서도 안전하게 동작
const hasFieldKeyData = Object.keys(formData).some(key => /^\d+_/.test(key));
if (hasFieldKeyData) {
console.log('[DynamicItemForm] Edit mode: 이미 field_key 형식 데이터 있음, 매핑 스킵');
return;
}
console.log('[DynamicItemForm] Edit mode: mapping initialData to field_key format');
console.log('[DynamicItemForm] initialData:', initialData);
// initialData의 간단한 키를 structure의 field_key로 매핑
// 예: { item_name: '테스트' } → { '98_item_name': '테스트' }
const mappedData: DynamicFormData = {};
// field_key에서 실제 필드명 추출하는 함수
// 예: '98_item_name' → 'item_name', '110_품목명' → '품목명'
const extractFieldName = (fieldKey: string): string => {
const underscoreIndex = fieldKey.indexOf('_');
if (underscoreIndex > 0) {
return fieldKey.substring(underscoreIndex + 1);
}
return fieldKey;
};
// structure에서 모든 필드의 field_key 수집
const fieldKeyMap: Record<string, string> = {}; // 간단한 키 → field_key 매핑
// 영문 → 한글 필드명 별칭 (API 응답 키 → structure field_name 매핑)
// API는 영문 키(unit, note)로 응답하지만, structure field_key는 한글(단위, 비고) 포함
const fieldAliases: Record<string, string> = {
'unit': '단위',
'note': '비고',
'remarks': '비고', // Material 모델은 remarks 사용
'item_name': '품목명',
'specification': '규격',
'description': '설명',
};
structure.sections.forEach((section) => {
section.fields.forEach((f) => {
const field = f.field;
const fieldKey = field.field_key || `field_${field.id}`;
const simpleName = extractFieldName(fieldKey);
fieldKeyMap[simpleName] = fieldKey;
// field_name도 매핑에 추가 (한글 필드명 지원)
if (field.field_name) {
fieldKeyMap[field.field_name] = fieldKey;
}
});
});
structure.directFields.forEach((f) => {
const field = f.field;
const fieldKey = field.field_key || `field_${field.id}`;
const simpleName = extractFieldName(fieldKey);
fieldKeyMap[simpleName] = fieldKey;
if (field.field_name) {
fieldKeyMap[field.field_name] = fieldKey;
}
});
console.log('[DynamicItemForm] fieldKeyMap:', fieldKeyMap);
// initialData를 field_key 형식으로 변환
Object.entries(initialData).forEach(([key, value]) => {
// 이미 field_key 형식인 경우 그대로 사용
if (key.includes('_') && /^\d+_/.test(key)) {
mappedData[key] = value;
}
// 간단한 키인 경우 field_key로 변환
else if (fieldKeyMap[key]) {
mappedData[fieldKeyMap[key]] = value;
}
// 영문 → 한글 별칭으로 시도 (API 응답 키 → structure field_name)
else if (fieldAliases[key] && fieldKeyMap[fieldAliases[key]]) {
mappedData[fieldKeyMap[fieldAliases[key]]] = value;
console.log(`[DynamicItemForm] 별칭 매핑: ${key}${fieldAliases[key]}${fieldKeyMap[fieldAliases[key]]}`);
}
// 매핑 없는 경우 그대로 유지
else {
mappedData[key] = value;
}
});
// 추가: 폼 구조의 모든 필드를 순회하면서, initialData에서 해당 값 직접 찾아서 설정
// (fieldKeyMap에 매핑이 없는 경우를 위한 fallback)
Object.entries(fieldKeyMap).forEach(([simpleName, fieldKey]) => {
// 아직 매핑 안된 필드인데 initialData에 값이 있으면 설정
if (mappedData[fieldKey] === undefined && initialData[simpleName] !== undefined) {
mappedData[fieldKey] = initialData[simpleName];
}
});
// 추가: 영문 별칭을 역으로 검색하여 매핑 (한글 field_name → 영문 API 키)
// 예: fieldKeyMap에 '단위'가 있고, initialData에 'unit'이 있으면 매핑
Object.entries(fieldAliases).forEach(([englishKey, koreanKey]) => {
const targetFieldKey = fieldKeyMap[koreanKey];
if (targetFieldKey && mappedData[targetFieldKey] === undefined && initialData[englishKey] !== undefined) {
mappedData[targetFieldKey] = initialData[englishKey];
console.log(`[DynamicItemForm] 별칭 fallback 매핑: ${englishKey}${koreanKey}${targetFieldKey}`);
}
});
console.log('========== [DynamicItemForm] Edit 모드 데이터 매핑 ==========');
console.log('specification 관련 키:', Object.keys(mappedData).filter(k => k.includes('specification') || k.includes('규격')));
console.log('is_active 관련 키:', Object.keys(mappedData).filter(k => k.includes('active') || k.includes('상태')));
console.log('매핑된 데이터:', mappedData);
console.log('==============================================================');
// 변환된 데이터로 폼 리셋
resetForm(mappedData);
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 handleFormSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// 밸리데이션 - 조건부 표시로 숨겨진 필드는 이미 allFields에서 제외됨
// 2025-12-03: 연동 드롭다운 로직 제거 - 단순화
const isValid = validateAll(allFields);
if (!isValid) {
return;
}
// field_key → 백엔드 필드명 매핑
// field_key 형식: "{id}_{key}" (예: "98_item_name", "110_품목명")
// 백엔드 필드명으로 변환 필요
// 2025-12-03: 한글 field_key 지원 추가
const fieldKeyToBackendKey: Record<string, string> = {
'item_name': 'name',
'productName': 'name', // FG(제품) 품목명 필드
'품목명': 'name', // 한글 field_key 지원
'specification': 'spec',
'standard': 'spec', // 규격 대체 필드명
'규격': 'spec', // 한글 field_key 지원
'사양': 'spec', // 한글 대체
'unit': 'unit',
'단위': 'unit', // 한글 field_key 지원
'note': 'note',
'비고': 'note', // 한글 field_key 지원
'description': 'description',
'설명': 'description', // 한글 field_key 지원
'part_type': 'part_type',
'부품유형': 'part_type', // 한글 field_key 지원
'부품 유형': 'part_type', // 공백 포함 한글
'is_active': 'is_active',
'status': 'is_active',
'active': 'is_active',
'품목상태': 'is_active', // 한글 field_key 지원
'품목 상태': 'is_active', // 공백 포함 한글
'상태': 'is_active', // 짧은 한글
};
// formData를 백엔드 필드명으로 변환
console.log('========== [DynamicItemForm] 저장 시 formData ==========');
console.log('specification 관련:', Object.entries(formData).filter(([k]) => k.includes('specification') || k.includes('규격')));
console.log('is_active 관련:', Object.entries(formData).filter(([k]) => k.includes('active') || k.includes('상태')));
console.log('전체 formData:', formData);
console.log('=========================================================');
const convertedData: Record<string, any> = {};
Object.entries(formData).forEach(([key, value]) => {
// "{id}_{fieldKey}" 형식 체크: 숫자로 시작하고 _가 있는 경우
// 예: "98_item_name" → true, "item_name" → false
const isFieldKeyFormat = /^\d+_/.test(key);
if (isFieldKeyFormat) {
// "{id}_{fieldKey}" 형식에서 fieldKey 추출
const underscoreIndex = key.indexOf('_');
const fieldKey = key.substring(underscoreIndex + 1);
const backendKey = fieldKeyToBackendKey[fieldKey] || fieldKey;
// is_active 필드는 boolean으로 변환
if (backendKey === 'is_active') {
// "활성", true, "true", "1", 1 등을 true로, 나머지는 false로
const isActive = value === true || value === 'true' || value === '1' ||
value === 1 || value === '활성' || value === 'active';
console.log(`[DynamicItemForm] is_active 변환: key=${key}, value=${value}(${typeof value}) → isActive=${isActive}`);
convertedData[backendKey] = isActive;
} else {
convertedData[backendKey] = value;
}
} else {
// field_key 형식이 아닌 경우, 매핑 테이블에서 변환 시도
const backendKey = fieldKeyToBackendKey[key] || key;
if (backendKey === 'is_active') {
const isActive = value === true || value === 'true' || value === '1' ||
value === 1 || value === '활성' || value === 'active';
console.log(`[DynamicItemForm] is_active 변환 (non-field_key): key=${key}, value=${value}(${typeof value}) → isActive=${isActive}`);
convertedData[backendKey] = isActive;
} else {
convertedData[backendKey] = value;
}
}
});
console.log('========== [DynamicItemForm] convertedData 결과 ==========');
console.log('is_active:', convertedData.is_active);
console.log('specification:', convertedData.spec || convertedData.specification);
console.log('전체:', convertedData);
console.log('===========================================================');
// 품목명 값 추출 (품목코드와 품목명 모두 필요)
// 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-04: 절곡 부품은 autoBendingItemCode 사용
// 2025-12-04: 구매 부품은 autoPurchasedItemCode 사용
let finalCode: string;
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: DynamicFormData = {
...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 데이터를 배열로 포함
bom: bomLines.map((line) => ({
child_item_code: line.childItemCode,
child_item_name: line.childItemName,
specification: line.specification || '',
material: line.material || '',
quantity: line.quantity,
unit: line.unit,
unit_price: line.unitPrice || 0,
note: line.note || '',
})),
// 절곡품 전개도 데이터 (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',
} : {}),
};
// console.log('[DynamicItemForm] 제출 데이터:', submitData);
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', {
bendingDetails: bendingDetails.length > 0 ? bendingDetails.map(d => ({
angle: d.angle || 0,
length: d.width || 0,
type: d.direction || '',
})) : 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');
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', {
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();
});
};
// 로딩 상태
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">
* &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' && (
<div className="mt-4 space-y-4">
{/* 시방서 파일 */}
<div>
<Label htmlFor="specification_file"> (PDF)</Label>
<div className="mt-1.5 space-y-2">
{/* 기존 파일 표시 (edit 모드) */}
{mode === 'edit' && existingSpecificationFile && !specificationFile && (
<div className="flex items-center gap-2 p-2 bg-gray-50 rounded border">
<FileText className="h-4 w-4 text-blue-600" />
<span className="text-sm flex-1 truncate">{existingSpecificationFileName}</span>
<a
href={getStorageUrl(existingSpecificationFile) || '#'}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800"
>
<Download className="h-3.5 w-3.5" />
</a>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleDeleteFile('specification')}
disabled={isDeletingFile === 'specification' || isSubmitting}
className="h-7 w-7 p-0 text-red-500 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)}
{/* 새 파일 업로드 */}
<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>
<Label htmlFor="certification_file"> (PDF)</Label>
<div className="mt-1.5 space-y-2">
{/* 기존 파일 표시 (edit 모드) */}
{mode === 'edit' && existingCertificationFile && !certificationFile && (
<div className="flex items-center gap-2 p-2 bg-gray-50 rounded border">
<FileText className="h-4 w-4 text-green-600" />
<span className="text-sm flex-1 truncate">{existingCertificationFileName}</span>
<a
href={getStorageUrl(existingCertificationFile) || '#'}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-green-600 hover:text-green-800"
>
<Download className="h-3.5 w-3.5" />
</a>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleDeleteFile('certification')}
disabled={isDeletingFile === 'certification' || isSubmitting}
className="h-7 w-7 p-0 text-red-500 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)}
{/* 새 파일 업로드 */}
<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>
);
})}
{/* 추가 섹션들 (기본 정보 카드 내에 하위 섹션으로 통합) */}
{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}
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}
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);
setIsDrawingOpen(false);
}}
initialImage={bendingDiagram}
title={isAssemblyPart ? "조립품 전개도" : "절곡품 전개도"}
description={isAssemblyPart
? "조립품 전개도(바라시)를 그리거나 편집합니다."
: "절곡품 전개도를 그리거나 편집합니다."
}
/>
</form>
);
}