From c026130a65ce62e1d069172f6381384d39d05830 Mon Sep 17 00:00:00 2001 From: byeongcheolryu Date: Fri, 12 Dec 2025 18:35:43 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=92=88=EB=AA=A9=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 파일 업로드 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 --- .../(protected)/items/[id]/edit/page.tsx | 164 +++--- .../[locale]/(protected)/items/[id]/page.tsx | 8 +- .../(protected)/items/create/page.tsx | 94 ++-- .../items/DynamicItemForm/index.tsx | 531 +++++++++++++----- .../sections/DynamicBOMSection.tsx | 78 +-- src/components/items/DynamicItemForm/types.ts | 4 + src/components/items/ItemDetailClient.tsx | 4 +- .../items/ItemForm/BendingDiagramSection.tsx | 15 +- src/components/items/ItemListClient.tsx | 10 +- .../components/ConditionalDisplayUI.tsx | 8 +- .../dialogs/FieldDialog.tsx | 4 +- .../dialogs/FieldDrawer.tsx | 4 +- .../dialogs/TemplateFieldDialog.tsx | 4 +- .../tabs/HierarchyTab/index.tsx | 2 +- .../tabs/MasterFieldTab/index.tsx | 4 +- .../tabs/SectionsTab.tsx | 4 +- src/lib/api/error-handler.ts | 41 +- src/lib/api/items.ts | 99 +++- src/types/item.ts | 25 + 19 files changed, 772 insertions(+), 331 deletions(-) diff --git a/src/app/[locale]/(protected)/items/[id]/edit/page.tsx b/src/app/[locale]/(protected)/items/[id]/edit/page.tsx index 2c572c46..c43be0ad 100644 --- a/src/app/[locale]/(protected)/items/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/items/[id]/edit/page.tsx @@ -11,7 +11,7 @@ import { useEffect, useState } from 'react'; import { useParams, useRouter, useSearchParams } from 'next/navigation'; import DynamicItemForm from '@/components/items/DynamicItemForm'; -import type { DynamicFormData } from '@/components/items/DynamicItemForm/types'; +import type { DynamicFormData, BOMLine } from '@/components/items/DynamicItemForm/types'; import type { ItemType } from '@/types/item'; import { Loader2 } from 'lucide-react'; import { @@ -20,6 +20,7 @@ import { transformMaterialDataForSave, convertOptionsToStandardFields, } from '@/lib/utils/materialTransform'; +import { DuplicateCodeError } from '@/lib/api/error-handler'; /** * API 응답 타입 (백엔드 Product 모델 기준) @@ -65,74 +66,42 @@ interface ItemApiResponse { /** * API 응답을 DynamicFormData로 변환 * - * API snake_case 필드를 폼 field_key로 매핑 - * (품목기준관리 API의 field_key가 snake_case 형식) + * 2025-12-10: field_key 통일로 변환 로직 간소화 + * - 백엔드에서 주는 field_key 그대로 사용 (변환 불필요) + * - 기존 레거시 데이터(98_unit 형식)도 그대로 동작 + * - 신규 데이터(unit 형식)도 그대로 동작 */ function mapApiResponseToFormData(data: ItemApiResponse): DynamicFormData { const formData: DynamicFormData = {}; - // attributes 객체 추출 (조립부품 등의 동적 필드가 여기에 저장됨) + // 제외할 시스템 필드 (프론트엔드 폼에서 사용하지 않는 필드) + const excludeKeys = [ + 'id', 'tenant_id', 'category_id', 'category', + 'created_at', 'updated_at', 'deleted_at', + 'component_lines', 'bom', + ]; + + // 백엔드 응답의 모든 필드를 그대로 복사 + Object.entries(data).forEach(([key, value]) => { + if (!excludeKeys.includes(key) && value !== null && value !== undefined) { + formData[key] = value as DynamicFormData[string]; + } + }); + + // attributes 객체가 있으면 펼쳐서 추가 (조립부품 등의 동적 필드) const attributes = (data.attributes || {}) as Record; - - // 백엔드 Product 모델 필드: code, name, product_type - // 프론트엔드 폼 필드: item_name, item_code 등 (snake_case) - - // 기본 필드 (백엔드 name → 폼 item_name) - // Material의 경우 item_name 필드 사용, Product는 name 필드 사용 - const itemName = data.item_name || data.name; - if (itemName) formData['item_name'] = itemName; - if (data.unit) formData['unit'] = data.unit; - if (data.specification) formData['specification'] = data.specification; - if (data.description) formData['description'] = data.description; - // Material은 'remarks', Product는 'note' 사용 → 프론트엔드 폼은 'note' 기대 - if (data.note) formData['note'] = data.note; - if (data.remarks) formData['note'] = data.remarks; // Material remarks → note 매핑 - formData['is_active'] = data.is_active ?? true; - - // 부품 관련 필드 (PT) - data와 attributes 둘 다에서 찾음 - const partType = data.part_type || attributes.part_type; - const partUsage = data.part_usage || attributes.part_usage; - const material = data.material || attributes.material; - const length = data.length || attributes.length; - const thickness = data.thickness || attributes.thickness; - if (partType) formData['part_type'] = String(partType); - if (partUsage) formData['part_usage'] = String(partUsage); - if (material) formData['material'] = String(material); - if (length) formData['length'] = String(length); - if (thickness) formData['thickness'] = String(thickness); - - // 조립 부품 관련 - data와 attributes 둘 다에서 찾음 - const installationType = data.installation_type || attributes.installation_type; - const assemblyType = data.assembly_type || attributes.assembly_type; - const assemblyLength = data.assembly_length || attributes.assembly_length; - const sideSpecWidth = data.side_spec_width || attributes.side_spec_width; - const sideSpecHeight = data.side_spec_height || attributes.side_spec_height; - if (installationType) formData['installation_type'] = String(installationType); - if (assemblyType) formData['assembly_type'] = String(assemblyType); - if (assemblyLength) formData['assembly_length'] = String(assemblyLength); - if (sideSpecWidth) formData['side_spec_width'] = String(sideSpecWidth); - if (sideSpecHeight) formData['side_spec_height'] = String(sideSpecHeight); - - // 제품 관련 필드 (FG) - if (data.product_category) formData['product_category'] = data.product_category; - if (data.lot_abbreviation) formData['lot_abbreviation'] = data.lot_abbreviation; - - // 인정 정보 - if (data.certification_number) formData['certification_number'] = data.certification_number; - if (data.certification_start_date) formData['certification_start_date'] = data.certification_start_date; - if (data.certification_end_date) formData['certification_end_date'] = data.certification_end_date; - - // 파일 관련 필드 (edit 모드에서 기존 파일 표시용) - if (data.bending_diagram) formData['bending_diagram'] = String(data.bending_diagram); - if (data.specification_file) formData['specification_file'] = String(data.specification_file); - if (data.specification_file_name) formData['specification_file_name'] = String(data.specification_file_name); - if (data.certification_file) formData['certification_file'] = String(data.certification_file); - if (data.certification_file_name) formData['certification_file_name'] = String(data.certification_file_name); + Object.entries(attributes).forEach(([key, value]) => { + if (value !== null && value !== undefined) { + // 이미 있는 필드는 덮어쓰지 않음 + if (!(key in formData)) { + formData[key] = value as DynamicFormData[string]; + } + } + }); // Material(SM, RM, CS) options 필드 매핑 // 백엔드에서 options: [{label: "standard_1", value: "옵션값"}, ...] 형태로 저장됨 // 프론트엔드 폼에서는 standard_1: "옵션값" 형태로 사용 - // 2025-12-05: Edit 모드에서 Select 옵션 값 불러오기 위해 추가 if (data.options && Array.isArray(data.options)) { (data.options as Array<{ label: string; value: string }>).forEach((opt) => { if (opt.label && opt.value) { @@ -141,23 +110,12 @@ function mapApiResponseToFormData(data: ItemApiResponse): DynamicFormData { }); } - // 기타 동적 필드들 (API에서 받은 모든 필드를 포함) - Object.entries(data).forEach(([key, value]) => { - // 이미 처리한 특수 필드들 제외 (백엔드 필드명 + 기존 필드명) - const excludeKeys = [ - 'id', 'code', 'name', 'product_type', // 백엔드 Product 모델 필드 - 'item_code', 'item_name', 'item_type', // 기존 호환 필드 - 'material_code', 'material_type', 'remarks', // Material 모델 필드 (remarks → note 변환됨) - 'created_at', 'updated_at', 'deleted_at', 'bom', - 'tenant_id', 'category_id', 'category', 'component_lines', - ]; - if (!excludeKeys.includes(key) && value !== null && value !== undefined) { - // 아직 설정 안된 필드만 추가 - if (!(key in formData)) { - formData[key] = value as DynamicFormData[string]; - } - } - }); + // is_active 기본값 처리 + if (formData['is_active'] === undefined) { + formData['is_active'] = true; + } + + console.log('[EditItem] mapApiResponseToFormData 결과:', formData); return formData; } @@ -169,6 +127,7 @@ export default function EditItemPage() { const [itemId, setItemId] = useState(null); const [itemType, setItemType] = useState(null); const [initialData, setInitialData] = useState(null); + const [initialBomLines, setInitialBomLines] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -199,11 +158,11 @@ export default function EditItemPage() { return; } - // Materials (SM, RM, CS)는 item_type=MATERIAL 쿼리 파라미터 추가 + // 2025-12-10: item_type 파라미터를 실제 코드(SM, RM, CS)로 전달 const isMaterial = isMaterialType(urlItemType); const queryParams = new URLSearchParams(); if (isMaterial) { - queryParams.append('item_type', 'MATERIAL'); + queryParams.append('item_type', urlItemType); // SM, RM, CS 그대로 전달 } else { queryParams.append('include_bom', 'true'); } @@ -251,6 +210,28 @@ export default function EditItemPage() { console.log('전체:', formData); console.log('=========================================================='); setInitialData(formData); + + // BOM 데이터 별도 처리 (백엔드 expandBomData 응답 형식) + const bomData = apiData.bom as Array> | undefined; + if (bomData && Array.isArray(bomData) && bomData.length > 0) { + const mappedBomLines: BOMLine[] = bomData.map((b, index) => ({ + id: (b.id as string) || `bom-${Date.now()}-${index}`, + childItemId: b.child_item_id ? String(b.child_item_id) : undefined, + childItemType: (b.child_item_type as 'PRODUCT' | 'MATERIAL') || 'PRODUCT', + childItemCode: (b.child_item_code as string) || '', + childItemName: (b.child_item_name as string) || '', + specification: (b.specification as string) || '', + material: (b.material as string) || '', + quantity: (b.quantity as number) ?? 1, + unit: (b.unit as string) || 'EA', + unitPrice: (b.unit_price as number) ?? 0, + note: (b.note as string) || '', + isBending: (b.is_bending as boolean) ?? false, + bendingDiagram: (b.bending_diagram as string) || undefined, + })); + setInitialBomLines(mappedBomLines); + console.log('[EditItem] BOM 데이터 로드:', mappedBomLines.length, '건', mappedBomLines); + } } else { setError(result.message || '품목 정보를 불러올 수 없습니다.'); } @@ -333,6 +314,19 @@ export default function EditItemPage() { // console.log('[EditItem] Product submitData:', submitData); } + // API 호출 전 이미지 데이터 제거 (파일 업로드는 별도 API 사용) + // bending_diagram이 base64 데이터인 경우 제거 (JSON에 포함시키면 안됨) + if (submitData.bending_diagram && typeof submitData.bending_diagram === 'string' && submitData.bending_diagram.startsWith('data:')) { + delete submitData.bending_diagram; + } + // 시방서/인정서 파일 필드도 base64면 제거 + if (submitData.specification_file && typeof submitData.specification_file === 'string' && submitData.specification_file.startsWith('data:')) { + delete submitData.specification_file; + } + if (submitData.certification_file && typeof submitData.certification_file === 'string' && submitData.certification_file.startsWith('data:')) { + delete submitData.certification_file; + } + // API 호출 console.log('========== [EditItem] 수정 요청 데이터 =========='); console.log('URL:', updateUrl); @@ -357,6 +351,17 @@ export default function EditItemPage() { // console.log('=========================================='); if (!response.ok || !result.success) { + // 2025-12-11: 백엔드 중복 에러 처리 (DuplicateCodeException) + // duplicate_id가 있으면 DuplicateCodeError throw → DynamicItemForm에서 다이얼로그 표시 + if (response.status === 400 && result.duplicate_id) { + console.warn('[EditItem] 품목코드 중복 에러:', result); + throw new DuplicateCodeError( + result.message || '해당 품목코드가 이미 존재합니다.', + result.duplicate_id, + result.duplicate_code + ); + } + throw new Error(result.message || '품목 수정에 실패했습니다.'); } @@ -411,6 +416,7 @@ export default function EditItemPage() { itemType={itemType} itemId={itemId ?? undefined} initialData={initialData} + initialBomLines={initialBomLines} onSubmit={handleSubmit} /> diff --git a/src/app/[locale]/(protected)/items/[id]/page.tsx b/src/app/[locale]/(protected)/items/[id]/page.tsx index 4ce63f8a..5350d05f 100644 --- a/src/app/[locale]/(protected)/items/[id]/page.tsx +++ b/src/app/[locale]/(protected)/items/[id]/page.tsx @@ -66,8 +66,8 @@ function mapApiResponseToItemMaster(data: Record): ItemMaster { guideRailModel: (data.guide_rail_model || attributes.guide_rail_model) ? String(data.guide_rail_model || attributes.guide_rail_model) : undefined, length: (data.length || attributes.length) ? String(data.length || attributes.length) : undefined, // BOM (있으면) - bom: Array.isArray(data.bom) ? data.bom.map((bomItem: Record) => ({ - id: String(bomItem.id || ''), + bom: Array.isArray(data.bom) ? data.bom.map((bomItem: Record, index: number) => ({ + id: String(bomItem.id || bomItem.child_item_id || `bom-${index}`), childItemCode: String(bomItem.child_item_code || bomItem.childItemCode || ''), childItemName: String(bomItem.child_item_name || bomItem.childItemName || ''), quantity: Number(bomItem.quantity || 1), @@ -127,11 +127,11 @@ export default function ItemDetailPage() { return; } - // Materials (SM, RM, CS)는 item_type=MATERIAL 쿼리 파라미터 추가 + // 2025-12-10: item_type 파라미터를 실제 코드(SM, RM, CS)로 전달 const isMaterial = MATERIAL_TYPES.includes(itemType); const queryParams = new URLSearchParams(); if (isMaterial) { - queryParams.append('item_type', 'MATERIAL'); + queryParams.append('item_type', itemType); // SM, RM, CS 그대로 전달 } else { queryParams.append('include_bom', 'true'); } diff --git a/src/app/[locale]/(protected)/items/create/page.tsx b/src/app/[locale]/(protected)/items/create/page.tsx index 15a43426..fe9d93e5 100644 --- a/src/app/[locale]/(protected)/items/create/page.tsx +++ b/src/app/[locale]/(protected)/items/create/page.tsx @@ -10,6 +10,7 @@ import { useState } from 'react'; import DynamicItemForm from '@/components/items/DynamicItemForm'; import type { DynamicFormData } from '@/components/items/DynamicItemForm/types'; import { isMaterialType, transformMaterialDataForSave } from '@/lib/utils/materialTransform'; +import { DuplicateCodeError } from '@/lib/api/error-handler'; // 기존 ItemForm (주석처리 - 롤백 시 사용) // import ItemForm from '@/components/items/ItemForm'; @@ -21,43 +22,64 @@ export default function CreateItemPage() { const handleSubmit = async (data: DynamicFormData) => { setSubmitError(null); - try { - // 필드명 변환: spec → specification (백엔드 API 규격) - const submitData = { ...data }; - if (submitData.spec !== undefined) { - submitData.specification = submitData.spec; - delete submitData.spec; - } - - // Material(SM, RM, CS)인 경우 수정 페이지와 동일하게 transformMaterialDataForSave 사용 - const itemType = submitData.product_type as string; - - // API 호출: POST /api/proxy/items - // 백엔드에서 product_type에 따라 Product/Material 분기 처리 - const response = await fetch('/api/proxy/items', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(submitData), - }); - - const result = await response.json(); - - if (!response.ok || !result.success) { - throw new Error(result.message || '품목 등록에 실패했습니다.'); - } - - // 성공 시 DynamicItemForm 내부에서 /items로 리다이렉트 처리됨 - // console.log('[CreateItemPage] 품목 등록 성공:', result.data); - - // 생성된 품목 ID를 포함한 데이터 반환 (파일 업로드용) - return { id: result.data.id, ...result.data }; - } catch (error) { - console.error('[CreateItemPage] 품목 등록 실패:', error); - setSubmitError(error instanceof Error ? error.message : '품목 등록에 실패했습니다.'); - throw error; // DynamicItemForm에서 에러 처리 + // 필드명 변환: spec → specification (백엔드 API 규격) + const submitData = { ...data }; + if (submitData.spec !== undefined) { + submitData.specification = submitData.spec; + delete submitData.spec; } + + // Material(SM, RM, CS)인 경우 수정 페이지와 동일하게 transformMaterialDataForSave 사용 + const itemType = submitData.product_type as string; + + // API 호출 전 이미지 데이터 제거 (파일 업로드는 별도 API 사용) + // bending_diagram이 base64 데이터인 경우 제거 (JSON에 포함시키면 안됨) + if (submitData.bending_diagram && typeof submitData.bending_diagram === 'string' && (submitData.bending_diagram as string).startsWith('data:')) { + delete submitData.bending_diagram; + } + // 시방서/인정서 파일 필드도 base64면 제거 + if (submitData.specification_file && typeof submitData.specification_file === 'string' && (submitData.specification_file as string).startsWith('data:')) { + delete submitData.specification_file; + } + if (submitData.certification_file && typeof submitData.certification_file === 'string' && (submitData.certification_file as string).startsWith('data:')) { + delete submitData.certification_file; + } + + // API 호출: POST /api/proxy/items + // 백엔드에서 product_type에 따라 Product/Material 분기 처리 + const response = await fetch('/api/proxy/items', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(submitData), + }); + + const result = await response.json(); + + if (!response.ok || !result.success) { + // 2025-12-11: 백엔드 중복 에러 처리 (DuplicateCodeException) + // duplicate_id가 있으면 DuplicateCodeError throw → DynamicItemForm에서 다이얼로그 표시 + if (response.status === 400 && result.duplicate_id) { + console.warn('[CreateItemPage] 품목코드 중복 에러:', result); + throw new DuplicateCodeError( + result.message || '해당 품목코드가 이미 존재합니다.', + result.duplicate_id, + result.duplicate_code + ); + } + + const errorMessage = result.message || '품목 등록에 실패했습니다.'; + console.error('[CreateItemPage] 품목 등록 실패:', errorMessage); + setSubmitError(errorMessage); + throw new Error(errorMessage); + } + + // 성공 시 DynamicItemForm 내부에서 /items로 리다이렉트 처리됨 + // console.log('[CreateItemPage] 품목 등록 성공:', result.data); + + // 생성된 품목 ID를 포함한 데이터 반환 (파일 업로드용) + return { id: result.data.id, ...result.data }; }; return ( diff --git a/src/components/items/DynamicItemForm/index.tsx b/src/components/items/DynamicItemForm/index.tsx index 24621d86..a57b74c0 100644 --- a/src/components/items/DynamicItemForm/index.tsx +++ b/src/components/items/DynamicItemForm/index.tsx @@ -8,7 +8,7 @@ import { useState, useEffect, useMemo, useRef } from 'react'; import { useRouter } from 'next/navigation'; -import { Package, Save, X, FileText, Trash2, Download } from 'lucide-react'; +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'; @@ -37,7 +37,18 @@ import { 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'; +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와 동일한 디자인 @@ -223,6 +234,7 @@ export default function DynamicItemForm({ itemType: initialItemType, itemId: propItemId, initialData, + initialBomLines, onSubmit, }: DynamicItemFormProps) { const router = useRouter(); @@ -264,29 +276,103 @@ export default function DynamicItemForm({ // 기존 파일 URL 상태 (edit 모드에서 사용) const [existingBendingDiagram, setExistingBendingDiagram] = useState(''); + const [existingBendingDiagramFileId, setExistingBendingDiagramFileId] = useState(null); const [existingSpecificationFile, setExistingSpecificationFile] = useState(''); const [existingSpecificationFileName, setExistingSpecificationFileName] = useState(''); + const [existingSpecificationFileId, setExistingSpecificationFileId] = useState(null); const [existingCertificationFile, setExistingCertificationFile] = useState(''); const [existingCertificationFileName, setExistingCertificationFileName] = useState(''); + const [existingCertificationFileId, setExistingCertificationFileId] = useState(null); const [isDeletingFile, setIsDeletingFile] = useState(null); - // initialData에서 기존 파일 정보 로드 (edit 모드) + // 품목코드 중복 체크 상태 관리 + const [showDuplicateDialog, setShowDuplicateDialog] = useState(false); + const [duplicateCheckResult, setDuplicateCheckResult] = useState(null); + const [pendingSubmitData, setPendingSubmitData] = useState(null); + + // initialData에서 기존 파일 정보 및 전개도 상세 데이터 로드 (edit 모드) useEffect(() => { if (mode === 'edit' && initialData) { - if (initialData.bending_diagram) { + // 새 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); } - if (initialData.specification_file) { + + // 시방서 파일 (새 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) || '시방서'); } - if (initialData.certification_file) { + + // 인정서 파일 (새 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, 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; @@ -1102,6 +1188,111 @@ export default function DynamicItemForm({ 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(); @@ -1170,14 +1361,20 @@ export default function DynamicItemForm({ // console.log('[DynamicItemForm] 품목명/규격 결정:', { finalName, finalSpec }); // 품목코드 결정 - // 2025-12-04: 절곡 부품은 autoBendingItemCode 사용 - // 2025-12-04: 구매 부품은 autoPurchasedItemCode 사용 + // 2025-12-11: 수정 모드에서는 기존 코드 유지 (자동생성으로 코드가 변경되는 버그 수정) + // 생성 모드에서만 자동생성 코드 사용 let finalCode: string; - if (isBendingPart && autoBendingItemCode) { + 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; @@ -1194,17 +1391,12 @@ export default function DynamicItemForm({ name: finalName, // 조립 부품: 품목명 가로x세로, 절곡 부품: 품목명 필드값, 기타: 품목명 필드값 spec: finalSpec, // 조립 부품: 가로x세로x길이, 기타: 규격 필드값 code: finalCode, // 절곡 부품: autoBendingItemCode, 기타: autoGeneratedItemCode - // BOM 데이터를 배열로 포함 + // BOM 데이터를 배열로 포함 (백엔드는 child_item_id, child_item_type, quantity만 저장) 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 || '', - })), + 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', @@ -1229,83 +1421,51 @@ export default function DynamicItemForm({ // console.log('[DynamicItemForm] 제출 데이터:', submitData); - await handleSubmit(async () => { - // 품목 저장 (ID 반환) - const result = await onSubmit(submitData); - const itemId = result?.id; + // 2025-12-11: 품목코드 중복 체크 (조립/절곡 부품만 해당) + // PT-조립부품, PT-절곡부품은 품목코드가 자동생성되므로 중복 체크 필요 + const needsDuplicateCheck = selectedItemType === 'PT' && (isAssemblyPart || isBendingPart) && finalCode; - // 파일 업로드 (품목 ID가 있을 때만) - if (itemId) { - const fileUploadErrors: string[] = []; + if (needsDuplicateCheck) { + console.log('[DynamicItemForm] 품목코드 중복 체크:', finalCode); - // 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.aAngle || 0, - length: d.input || 0, - type: d.shaded ? 'shaded' : 'normal', - })) : undefined, - }); - console.log('[DynamicItemForm] 전개도 파일 업로드 성공'); - } catch (error) { - console.error('[DynamicItemForm] 전개도 파일 업로드 실패:', error); - fileUploadErrors.push('전개도 이미지'); - } - } + // 수정 모드에서는 자기 자신 제외 (propItemId) + const excludeId = mode === 'edit' ? propItemId : undefined; + const duplicateResult = await checkItemCodeDuplicate(finalCode, excludeId); - // 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('시방서'); - } - } + console.log('[DynamicItemForm] 중복 체크 결과:', duplicateResult); - // 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수정 화면에서 다시 업로드해 주세요.`); - } + if (duplicateResult.isDuplicate) { + // 중복 발견 → 다이얼로그 표시 + setDuplicateCheckResult(duplicateResult); + setPendingSubmitData(submitData); + setShowDuplicateDialog(true); + return; // 저장 중단, 사용자 선택 대기 } + } - router.push('/items'); - router.refresh(); - }); + // 중복 없음 → 바로 저장 + 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); }; // 로딩 상태 @@ -1540,96 +1700,146 @@ export default function DynamicItemForm({ {/* 시방서 파일 */}
-
- {/* 기존 파일 표시 (edit 모드) */} - {mode === 'edit' && existingSpecificationFile && !specificationFile && ( -
- - {existingSpecificationFileName} +
+ {/* 기존 파일이 있고, 새 파일 선택 안한 경우: 파일명 + 버튼들 */} + {mode === 'edit' && existingSpecificationFile && !specificationFile ? ( +
+
+ + {existingSpecificationFileName} +
- + +
- )} - {/* 새 파일 업로드 */} - { - const file = e.target.files?.[0] || null; - setSpecificationFile(file); - }} - disabled={isSubmitting} - className="cursor-pointer" - /> - {specificationFile && ( -

- 선택된 파일: {specificationFile.name} -

+ ) : ( +
+ {/* 새 파일 업로드 (기존 파일 없거나, 새 파일 선택 중) */} + { + const file = e.target.files?.[0] || null; + setSpecificationFile(file); + }} + disabled={isSubmitting} + className="cursor-pointer" + /> + {specificationFile && ( +

+ 선택된 파일: {specificationFile.name} +

+ )} +
)}
{/* 인정서 파일 */}
-
- {/* 기존 파일 표시 (edit 모드) */} - {mode === 'edit' && existingCertificationFile && !certificationFile && ( -
- - {existingCertificationFileName} +
+ {/* 기존 파일이 있고, 새 파일 선택 안한 경우: 파일명 + 버튼들 */} + {mode === 'edit' && existingCertificationFile && !certificationFile ? ( +
+
+ + {existingCertificationFileName} +
- + +
- )} - {/* 새 파일 업로드 */} - { - const file = e.target.files?.[0] || null; - setCertificationFile(file); - }} - disabled={isSubmitting} - className="cursor-pointer" - /> - {certificationFile && ( -

- 선택된 파일: {certificationFile.name} -

+ ) : ( +
+ {/* 새 파일 업로드 (기존 파일 없거나, 새 파일 선택 중) */} + { + const file = e.target.files?.[0] || null; + setCertificationFile(file); + }} + disabled={isSubmitting} + className="cursor-pointer" + /> + {certificationFile && ( +

+ 선택된 파일: {certificationFile.name} +

+ )} +
)}
@@ -1724,6 +1934,7 @@ export default function DynamicItemForm({ bendingDetails={bendingDetails} setBendingDetails={setBendingDetails} setWidthSum={setWidthSum} + widthSumFieldKey={bendingFieldKeys.widthSum} setValue={(key, value) => setFieldValue(key, value)} isSubmitting={isSubmitting} /> @@ -1742,6 +1953,7 @@ export default function DynamicItemForm({ bendingDetails={bendingDetails} setBendingDetails={setBendingDetails} setWidthSum={setWidthSum} + widthSumFieldKey={bendingFieldKeys.widthSum} setValue={(key, value) => setFieldValue(key, value)} isSubmitting={isSubmitting} /> @@ -1804,6 +2016,29 @@ export default function DynamicItemForm({ : "절곡품 전개도를 그리거나 편집합니다." } /> + + {/* 품목코드 중복 확인 다이얼로그 */} + + + + 품목코드 중복 + + 입력하신 조건의 품목코드가 이미 존재합니다. + + 기존 품목을 수정하시겠습니까? + + + + + + 취소 + + + 중복 품목 수정하러 가기 + + + + ); } diff --git a/src/components/items/DynamicItemForm/sections/DynamicBOMSection.tsx b/src/components/items/DynamicItemForm/sections/DynamicBOMSection.tsx index b86efad1..2bd2d15c 100644 --- a/src/components/items/DynamicItemForm/sections/DynamicBOMSection.tsx +++ b/src/components/items/DynamicItemForm/sections/DynamicBOMSection.tsx @@ -38,6 +38,22 @@ import { import { Check, Package, Plus, Search, Trash2, Loader2 } from 'lucide-react'; import type { BOMLine, BOMSearchState, DynamicSection } from '../types'; +/** + * 품목 유형(FG, PT, SM, RM, CS)을 BOM child_item_type으로 변환 + * - PRODUCT: FG(제품), PT(부품) + * - MATERIAL: SM(원자재), RM(원자재), CS(부자재) + */ +function getChildItemType(itemType: string | undefined): 'PRODUCT' | 'MATERIAL' { + if (!itemType) return 'PRODUCT'; + const upperType = itemType.toUpperCase(); + // SM, RM, CS는 MATERIAL + if (['SM', 'RM', 'CS'].includes(upperType)) { + return 'MATERIAL'; + } + // FG, PT 등은 PRODUCT + return 'PRODUCT'; +} + // 품목 검색 결과 타입 interface SearchedItem { id: string; @@ -48,6 +64,7 @@ interface SearchedItem { unit: string; partType?: string; bendingDiagram?: string; + itemType?: string; // FG, PT, SM, RM, CS 등 품목 유형 } // Debounce 훅 @@ -83,8 +100,9 @@ export default function DynamicBOMSection({ const [searchResults, setSearchResults] = useState>({}); const [isSearching, setIsSearching] = useState>({}); - // 품목 검색 API 호출 + // 품목 검색 API 호출 (검색어 있을 때만) const searchItems = useCallback(async (lineId: string, query: string) => { + // 검색어가 없으면 빈 결과 (사용자가 검색어 입력 필요) if (!query || query.length < 1) { setSearchResults((prev) => ({ ...prev, [lineId]: [] })); return; @@ -113,6 +131,7 @@ export default function DynamicBOMSection({ unit: (item.unit ?? 'EA') as string, partType: (item.part_type ?? '') as string, bendingDiagram: (item.bending_diagram ?? '') as string, + itemType: (item.product_type ?? item.item_type ?? '') as string, // FG, PT, SM, RM, CS })); setSearchResults((prev) => ({ ...prev, [lineId]: mappedItems })); @@ -180,7 +199,6 @@ export default function DynamicBOMSection({ 재질 수량 단위 - 단가 비고 삭제 @@ -239,8 +257,9 @@ function BOMLineRow({ const searchItemsRef = useRef(searchItems); searchItemsRef.current = searchItems; + // 검색어 변경 시 검색 실행 useEffect(() => { - if (debouncedSearchValue && searchOpen) { + if (searchOpen) { searchItemsRef.current(line.id, debouncedSearchValue); } }, [debouncedSearchValue, searchOpen, line.id]); @@ -257,10 +276,6 @@ function BOMLineRow({ ...bomSearchStates, [line.id]: { ...searchState, isOpen: open }, }); - // 팝오버 열릴 때 검색 실행 - if (open && searchValue) { - searchItems(line.id, searchValue); - } }} >
@@ -328,11 +343,20 @@ function BOMLineRow({ {isSearching ? (
- 검색 중... + + 검색 중... + +
+ ) : !searchValue ? ( +
+ + + 품목코드 또는 품목명을 입력하세요 +
) : searchResults.length === 0 ? ( - {searchValue ? '검색 결과가 없습니다.' : '품목코드 또는 품목명을 입력하세요.'} + 검색 결과가 없습니다. ) : ( @@ -342,12 +366,16 @@ function BOMLineRow({ value={`${item.itemCode} ${item.itemName}`} onSelect={() => { const isBendingPart = item.partType === 'BENDING'; + // 품목 유형에 따라 PRODUCT/MATERIAL 결정 + const childItemType = getChildItemType(item.itemType); setBomLines( bomLines.map((l) => l.id === line.id ? { ...l, + childItemId: item.id || '', + childItemType, // PRODUCT 또는 MATERIAL childItemCode: item.itemCode || '', childItemName: item.itemName || '', specification: item.specification || '', @@ -401,19 +429,8 @@ function BOMLineRow({ {line.specification || '-'} - - { - setBomLines( - bomLines.map((l) => - l.id === line.id ? { ...l, material: e.target.value } : l - ) - ); - }} - placeholder="재질" - className="w-full text-xs" - /> + + {line.material || '-'} {line.unit} - - { - setBomLines( - bomLines.map((l) => - l.id === line.id ? { ...l, unitPrice: Number(e.target.value) } : l - ) - ); - }} - min="0" - className="w-full text-right" - /> - - +
diff --git a/src/components/items/DynamicItemForm/types.ts b/src/components/items/DynamicItemForm/types.ts index b3d74dcf..c8c8dd38 100644 --- a/src/components/items/DynamicItemForm/types.ts +++ b/src/components/items/DynamicItemForm/types.ts @@ -91,6 +91,8 @@ export interface DynamicBomItem { */ export interface BOMLine { id: string; + childItemId?: string; // 자품목 ID (API에서 받은 품목 id) + childItemType?: 'PRODUCT' | 'MATERIAL'; // 자품목 타입 (PRODUCT: FG/PT, MATERIAL: SM/RM/CS) childItemCode: string; childItemName: string; specification?: string; @@ -150,6 +152,8 @@ export interface DynamicItemFormProps { itemType?: 'FG' | 'PT' | 'SM' | 'RM' | 'CS'; itemId?: number; // edit 모드에서 파일 업로드에 사용 initialData?: DynamicFormData; + /** edit 모드에서 초기 BOM 데이터 */ + initialBomLines?: BOMLine[]; /** 품목 저장 후 결과 반환 (create: id 필수, edit: id 선택) */ onSubmit: (data: DynamicFormData) => Promise; } diff --git a/src/components/items/ItemDetailClient.tsx b/src/components/items/ItemDetailClient.tsx index 2af1dbb0..992a371f 100644 --- a/src/components/items/ItemDetailClient.tsx +++ b/src/components/items/ItemDetailClient.tsx @@ -214,8 +214,8 @@ export default function ItemDetailClient({ item }: ItemDetailClientProps) { - {/* 제품(FG) 전용 정보 */} - {item.itemType === 'FG' && ( + {/* 제품(FG) 전용 정보 - 내용이 있을 때만 표시 */} + {item.itemType === 'FG' && (item.productCategory || item.lotAbbreviation || item.note) && ( 제품 정보 diff --git a/src/components/items/ItemForm/BendingDiagramSection.tsx b/src/components/items/ItemForm/BendingDiagramSection.tsx index 1867a8c2..64a57184 100644 --- a/src/components/items/ItemForm/BendingDiagramSection.tsx +++ b/src/components/items/ItemForm/BendingDiagramSection.tsx @@ -22,6 +22,8 @@ export interface BendingDiagramSectionProps { bendingDetails: BendingDetail[]; setBendingDetails: (details: BendingDetail[]) => void; setWidthSum: (sum: string) => void; + /** 동적 폼에서 폭 합계 필드의 키 (예: 'width_sum', 'field_123') */ + widthSumFieldKey?: string; setValue: UseFormSetValue; isSubmitting: boolean; } @@ -37,6 +39,7 @@ export default function BendingDiagramSection({ bendingDetails, setBendingDetails, setWidthSum, + widthSumFieldKey, setValue, isSubmitting, }: BendingDiagramSectionProps) { @@ -46,8 +49,16 @@ export default function BendingDiagramSection({ const calc = d.input + d.elongation; return acc + calc; }, 0); - setWidthSum(totalSum.toString()); - setValue('length', totalSum.toString()); + const sumString = totalSum.toString(); + setWidthSum(sumString); + + // 동적 폼 필드에 값 설정 (widthSumFieldKey가 있으면 해당 키로, 없으면 'length'로 폴백) + if (widthSumFieldKey) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setValue(widthSumFieldKey as any, sumString); + } else { + setValue('length', sumString); + } }; return ( diff --git a/src/components/items/ItemListClient.tsx b/src/components/items/ItemListClient.tsx index 30d82d15..5979b88a 100644 --- a/src/components/items/ItemListClient.tsx +++ b/src/components/items/ItemListClient.tsx @@ -176,12 +176,13 @@ export default function ItemListClient() { // Materials (SM, RM, CS)는 /products/materials 엔드포인트 사용 // Products (FG, PT)는 /items 엔드포인트 사용 + // 2025-12-10: item_type 파라미터를 실제 코드(SM, RM, CS)로 전달 const isMaterial = MATERIAL_TYPES.includes(itemToDelete.itemType); const deleteUrl = isMaterial - ? `/api/proxy/products/materials/${itemToDelete.id}` + ? `/api/proxy/products/materials/${itemToDelete.id}?item_type=${itemToDelete.itemType}` : `/api/proxy/items/${itemToDelete.id}`; - console.log('[Delete] URL:', deleteUrl, '(isMaterial:', isMaterial, ')'); + console.log('[Delete] URL:', deleteUrl, '(isMaterial:', isMaterial, ', itemType:', itemToDelete.itemType, ')'); const response = await fetch(deleteUrl, { method: 'DELETE', @@ -229,6 +230,7 @@ export default function ItemListClient() { }; // 일괄 삭제 핸들러 + // 2025-12-10: item_type 파라미터를 실제 코드(SM, RM, CS)로 전달 const handleBulkDelete = async () => { const itemIds = Array.from(selectedItems); let successCount = 0; @@ -239,9 +241,9 @@ export default function ItemListClient() { // 해당 품목의 itemType 찾기 const item = items.find((i) => i.id === id); const isMaterial = item ? MATERIAL_TYPES.includes(item.itemType) : false; - // Materials는 /products/materials 엔드포인트, Products는 /items 엔드포인트 + // Materials는 /products/materials 엔드포인트 + item_type, Products는 /items 엔드포인트 const deleteUrl = isMaterial - ? `/api/proxy/products/materials/${id}` + ? `/api/proxy/products/materials/${id}?item_type=${item?.itemType}` : `/api/proxy/items/${id}`; const response = await fetch(deleteUrl, { diff --git a/src/components/items/ItemMasterDataManagement/components/ConditionalDisplayUI.tsx b/src/components/items/ItemMasterDataManagement/components/ConditionalDisplayUI.tsx index 8fc5a0e9..e9858a7c 100644 --- a/src/components/items/ItemMasterDataManagement/components/ConditionalDisplayUI.tsx +++ b/src/components/items/ItemMasterDataManagement/components/ConditionalDisplayUI.tsx @@ -178,10 +178,10 @@ export function ConditionalDisplayUI({ 이 값일 때 표시할 항목들 ({condition.targetFieldIds?.length || 0}개 선택됨):
- {availableFields.map(field => { + {availableFields.map((field, fieldIdx) => { const fieldIdStr = String(field.id); return ( -