/** * 품목 수정 컴포넌트 (Edit Mode) * * API 연동: * - GET /api/proxy/items/{id} (품목 조회 - id 기반 통일) * - PUT /api/proxy/items/{id} (품목 수정) */ 'use client'; import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import DynamicItemForm from '@/components/items/DynamicItemForm'; import type { DynamicFormData, BOMLine } from '@/components/items/DynamicItemForm/types'; import type { ItemType } from '@/types/item'; import { DetailPageSkeleton } from '@/components/ui/skeleton'; import { isMaterialType, transformMaterialDataForSave, } from '@/lib/utils/materialTransform'; import { DuplicateCodeError } from '@/lib/api/error-handler'; /** * API 응답 타입 (백엔드 Product 모델 기준) * * 백엔드 필드명: code, name, product_type (item_code, item_name, item_type 아님!) */ interface ItemApiResponse { id: number; // 백엔드 Product 모델 필드 code: string; name: string; product_type: string; // 기존 필드도 fallback으로 유지 item_code?: string; item_name?: string; item_type?: string; unit?: string; specification?: string; is_active?: boolean; description?: string; note?: string; remarks?: string; // Material 모델은 remarks 사용 material_code?: string; // Material 모델 코드 필드 material_type?: string; // Material 모델 타입 필드 part_type?: string; part_usage?: string; material?: string; length?: string; thickness?: string; installation_type?: string; assembly_type?: string; assembly_length?: string; side_spec_width?: string; side_spec_height?: string; product_category?: string; lot_abbreviation?: string; certification_number?: string; certification_start_date?: string; certification_end_date?: string; [key: string]: unknown; } /** * API 응답을 DynamicFormData로 변환 * * 2025-12-10: field_key 통일로 변환 로직 간소화 * - 백엔드에서 주는 field_key 그대로 사용 (변환 불필요) * - 기존 레거시 데이터(98_unit 형식)도 그대로 동작 * - 신규 데이터(unit 형식)도 그대로 동작 */ function mapApiResponseToFormData(data: ItemApiResponse): DynamicFormData { const formData: DynamicFormData = {}; // 제외할 시스템 필드 (프론트엔드 폼에서 사용하지 않는 필드) const excludeKeys = [ 'id', 'tenant_id', 'category_id', 'category', 'created_at', 'updated_at', 'deleted_at', 'component_lines', 'bom', 'details', // details는 아래에서 펼쳐서 추가 ]; // 백엔드 응답의 모든 필드를 그대로 복사 Object.entries(data).forEach(([key, value]) => { if (!excludeKeys.includes(key) && value !== null && value !== undefined) { formData[key] = value as DynamicFormData[string]; } }); // details 객체가 있으면 펼쳐서 추가 (item_details 테이블 필드) // 2025-12-16: details 내의 최신 값을 최상위로 매핑 const details = (data as Record).details as Record | undefined; if (details && typeof details === 'object') { const detailExcludeKeys = ['id', 'item_id', 'created_at', 'updated_at']; Object.entries(details).forEach(([key, value]) => { if (!detailExcludeKeys.includes(key) && value !== null && value !== undefined) { formData[key] = value as DynamicFormData[string]; } }); } // attributes 객체가 있으면 펼쳐서 추가 (조립부품 등의 동적 필드) const attributes = (data.attributes || {}) as Record; Object.entries(attributes).forEach(([key, value]) => { if (value !== null && value !== undefined) { // 이미 있는 필드는 덮어쓰지 않음 if (!(key in formData)) { formData[key] = value as DynamicFormData[string]; } } }); // 2025-12-16: options 매핑 로직 제거 // options는 백엔드가 품목기준관리 field_key 매핑용으로 내부적으로 사용하는 필드 // 프론트엔드는 백엔드가 정제해서 주는 필드(name, code, unit 등)만 사용 // options 내부 값을 직접 파싱하면 오래된 값과 최신 값이 꼬이는 버그 발생 // is_active 기본값 처리 if (formData['is_active'] === undefined) { formData['is_active'] = true; } console.log('[ItemDetailEdit] mapApiResponseToFormData 결과:', formData); return formData; } interface ItemDetailEditProps { itemCode: string; itemType: string; itemId: string; } export function ItemDetailEdit({ itemCode, itemType: urlItemType, itemId: urlItemId }: ItemDetailEditProps) { const router = useRouter(); 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); // 품목 데이터 로드 useEffect(() => { const fetchItem = async () => { if (!itemCode) { setError('잘못된 품목 ID입니다.'); setIsLoading(false); return; } try { setIsLoading(true); // 모든 품목: GET /api/proxy/items/{id} (id 기반 통일) if (!urlItemId) { setError('품목 ID가 없습니다.'); setIsLoading(false); return; } // 2025-12-15: 백엔드에서 id만으로 조회 가능 (item_type 불필요) const isMaterial = isMaterialType(urlItemType); const queryParams = new URLSearchParams(); if (!isMaterial) { queryParams.append('include_bom', 'true'); } console.log('[ItemDetailEdit] Fetching:', { urlItemId, urlItemType, isMaterial }); const queryString = queryParams.toString(); const response = await fetch(`/api/proxy/items/${urlItemId}${queryString ? `?${queryString}` : ''}`); if (!response.ok) { if (response.status === 404) { setError('품목을 찾을 수 없습니다.'); } else { const errorData = await response.json().catch(() => null); setError(errorData?.message || `오류 발생 (${response.status})`); } setIsLoading(false); return; } const result = await response.json(); if (result.success && result.data) { const apiData = result.data as ItemApiResponse; console.log('========== [ItemDetailEdit] API 원본 데이터 (백엔드 응답) =========='); console.log('id:', apiData.id); console.log('specification:', apiData.specification); console.log('unit:', apiData.unit); console.log('is_active:', apiData.is_active); console.log('files:', (apiData as any).files); // 파일 데이터 확인 console.log('전체:', apiData); console.log('=============================================================='); // ID, 품목 유형 저장 // Product: product_type, Material: material_type 또는 type_code setItemId(apiData.id); const resolvedItemType = apiData.product_type || (apiData as Record).material_type || (apiData as Record).type_code || apiData.item_type; setItemType(resolvedItemType as ItemType); // 폼 데이터로 변환 const formData = mapApiResponseToFormData(apiData); console.log('========== [ItemDetailEdit] 폼에 전달되는 initialData =========='); console.log('specification:', formData['specification']); console.log('unit:', formData['unit']); console.log('is_active:', formData['is_active']); console.log('files:', formData['files']); // 파일 데이터 확인 console.log('전체:', formData); console.log('=========================================================='); setInitialData(formData); // BOM 데이터 별도 API 호출 (expandBomItems로 품목 정보 포함) // GET /api/proxy/items/{id}/bom - 품목 정보가 확장된 BOM 데이터 반환 if (!isMaterialType(urlItemType)) { try { const bomResponse = await fetch(`/api/proxy/items/${urlItemId}/bom`); const bomResult = await bomResponse.json(); if (bomResult.success && bomResult.data && Array.isArray(bomResult.data)) { const expandedBomData = bomResult.data as Array>; const mappedBomLines: BOMLine[] = expandedBomData.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('[ItemDetailEdit] BOM 데이터 로드 (expanded):', mappedBomLines.length, '건', mappedBomLines); } } catch (bomErr) { console.error('[ItemDetailEdit] BOM 조회 실패:', bomErr); } } } else { setError(result.message || '품목 정보를 불러올 수 없습니다.'); } } catch (err) { console.error('[ItemDetailEdit] Error:', err); setError('품목 정보를 불러오는 중 오류가 발생했습니다.'); } finally { setIsLoading(false); } }; fetchItem(); }, [itemCode, urlItemType, urlItemId]); /** * 품목 수정 제출 핸들러 * * API 엔드포인트: * - Products (FG, PT): PUT /api/proxy/items/{id} * - Materials (SM, RM, CS): PATCH /api/proxy/products/materials/{id} * * 주의: 리다이렉트는 DynamicItemForm에서 처리하므로 여기서는 API 호출만 수행 */ const handleSubmit = async (data: DynamicFormData) => { if (!itemId) { throw new Error('품목 ID가 없습니다.'); } // Materials (SM, RM, CS)는 /products/materials 엔드포인트 + PATCH 메서드 사용 // Products (FG, PT)는 /items 엔드포인트 + PUT 메서드 사용 const isMaterial = isMaterialType(itemType); // 2025-12-15: 백엔드 동적 테이블 라우팅 - 모든 품목이 /items 엔드포인트 사용 // /products/materials 라우트 삭제됨 (products/materials 테이블 삭제) const updateUrl = `/api/proxy/items/${itemId}?item_type=${itemType}`; const method = 'PUT'; // 품목코드 자동생성 처리 // - FG(제품): 품목코드 = 품목명 // - PT(부품): DynamicItemForm에서 자동계산한 code 사용 (조립/절곡/구매 각각 다른 규칙) // - Material(SM, RM, CS): material_code = 품목명-규격 // 2025-12-15: item_type은 Request Body에서 필수 (ItemUpdateRequest validation) let submitData: DynamicFormData = { ...data, item_type: itemType }; if (itemType === 'FG') { // FG는 품목명이 품목코드가 되므로 name 값으로 code 설정 submitData.code = submitData.name; } else if (itemType === 'PT') { // PT는 DynamicItemForm에서 자동계산한 code를 그대로 사용 // (조립: GR-001, 절곡: RM30, 구매: 전동개폐기150KG380V) // code가 없으면 기본값으로 name 사용 if (!submitData.code) { submitData.code = submitData.name; } } // Material(SM, RM, CS)은 아래 isMaterial 블록에서 submitData.code를 material_code로 변환 // 2025-12-05: delete submitData.code 제거 - DynamicItemForm에서 조합된 code 값을 사용해야 함 // 공통: spec → specification 필드명 변환 (백엔드 API 규격) if (submitData.spec !== undefined) { submitData.specification = submitData.spec; delete submitData.spec; } if (isMaterial) { // Material(SM, RM, CS) 데이터 변환: standard_* → options 배열, specification 생성 // 2025-12-05: 공통 유틸 함수 사용 submitData = transformMaterialDataForSave(submitData, itemType || 'RM'); } // 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('========== [ItemDetailEdit] 수정 요청 데이터 =========='); console.log('URL:', updateUrl); console.log('Method:', method); console.log('specification:', submitData.specification); console.log('unit:', submitData.unit); console.log('is_active:', submitData.is_active); console.log('전체:', submitData); console.log('================================================='); const response = await fetch(updateUrl, { method, 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('[ItemDetailEdit] 품목코드 중복 에러:', result); throw new DuplicateCodeError( result.message || '해당 품목코드가 이미 존재합니다.', result.duplicate_id, result.duplicate_code ); } throw new Error(result.message || '품목 수정에 실패했습니다.'); } // 성공 시 품목 ID 반환 (파일 업로드용) return { id: itemId, ...result.data }; }; // 로딩 상태 if (isLoading) { return ; } // 에러 상태 if (error) { return (

{error}

); } // 데이터 없음 if (!itemType || !initialData) { return (

품목 정보를 불러올 수 없습니다.

); } return (
); }