Files
sam-react-prod/src/components/items/ItemDetailEdit.tsx
유병철 19237be4aa refactor: UniversalListPage externalIsLoading 지원 및 스켈레톤 개선
- UniversalListPage에 externalIsLoading prop 추가
- CardTransactionDetailClient DevFill 자동입력 기능 추가
- 여러 컴포넌트 로딩 상태 처리 개선
- skeleton 컴포넌트 확장

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 20:54:16 +09:00

417 lines
16 KiB
TypeScript

/**
* 품목 수정 컴포넌트 (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<string, unknown>).details as Record<string, unknown> | 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<string, unknown>;
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<number | null>(null);
const [itemType, setItemType] = useState<ItemType | null>(null);
const [initialData, setInitialData] = useState<DynamicFormData | null>(null);
const [initialBomLines, setInitialBomLines] = useState<BOMLine[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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<string, unknown>).material_type || (apiData as Record<string, unknown>).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<Record<string, unknown>>;
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 <DetailPageSkeleton sections={2} fieldsPerSection={6} />;
}
// 에러 상태
if (error) {
return (
<div className="flex flex-col items-center justify-center py-12 gap-4">
<p className="text-destructive">{error}</p>
<button
onClick={() => router.push('/production/screen-production')}
className="text-primary hover:underline"
>
</button>
</div>
);
}
// 데이터 없음
if (!itemType || !initialData) {
return (
<div className="flex flex-col items-center justify-center py-12 gap-4">
<p className="text-muted-foreground"> .</p>
<button
onClick={() => router.push('/production/screen-production')}
className="text-primary hover:underline"
>
</button>
</div>
);
}
return (
<div className="p-6">
<DynamicItemForm
mode="edit"
itemType={itemType}
itemId={itemId ?? undefined}
initialData={initialData}
initialBomLines={initialBomLines}
onSubmit={handleSubmit}
/>
</div>
);
}