신규 페이지: - 회계관리: 거래처, 예상비용, 청구서, 발주서 - 게시판: 공지사항, 자료실, 커뮤니티 - 고객센터: 문의/FAQ - 설정: 계정, 알림, 출퇴근, 팝업, 구독, 결제내역 - 리포트 (차트 시각화) - 개발자 테스트 URL 페이지 기능 개선: - HR 직원관리/휴가관리/카드관리 강화 - IntegratedListTemplateV2 확장 - AuthenticatedLayout 패딩 표준화 - 로그인 페이지 UI 개선 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
444 lines
18 KiB
TypeScript
444 lines
18 KiB
TypeScript
/**
|
|
* 품목 수정 페이지
|
|
*
|
|
* API 연동:
|
|
* - GET /api/proxy/items/{id} (품목 조회 - id 기반 통일)
|
|
* - PUT /api/proxy/items/{id} (품목 수정)
|
|
*/
|
|
|
|
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { useParams, useRouter, useSearchParams } 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 { Loader2 } from 'lucide-react';
|
|
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('[EditItem] mapApiResponseToFormData 결과:', formData);
|
|
|
|
return formData;
|
|
}
|
|
|
|
export default function EditItemPage() {
|
|
const params = useParams();
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
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);
|
|
|
|
// URL에서 type과 id 쿼리 파라미터 읽기
|
|
const urlItemType = searchParams.get('type') || 'FG';
|
|
const urlItemId = searchParams.get('id');
|
|
|
|
// 품목 데이터 로드
|
|
useEffect(() => {
|
|
const fetchItem = async () => {
|
|
if (!params.id || typeof params.id !== 'string') {
|
|
setError('잘못된 품목 ID입니다.');
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsLoading(true);
|
|
const itemCode = decodeURIComponent(params.id);
|
|
// console.log('[EditItem] Fetching item:', { itemCode, urlItemType, urlItemId });
|
|
|
|
let response: Response;
|
|
|
|
// 모든 품목: 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('[EditItem] Fetching:', { urlItemId, urlItemType, isMaterial });
|
|
const queryString = queryParams.toString();
|
|
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();
|
|
// console.log('[EditItem] API Response:', result);
|
|
|
|
if (result.success && result.data) {
|
|
const apiData = result.data as ItemApiResponse;
|
|
console.log('========== [EditItem] 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;
|
|
// console.log('[EditItem] Resolved itemType:', resolvedItemType);
|
|
setItemType(resolvedItemType as ItemType);
|
|
|
|
// 폼 데이터로 변환
|
|
const formData = mapApiResponseToFormData(apiData);
|
|
console.log('========== [EditItem] 폼에 전달되는 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('[EditItem] BOM 데이터 로드 (expanded):', mappedBomLines.length, '건', mappedBomLines);
|
|
}
|
|
} catch (bomErr) {
|
|
console.error('[EditItem] BOM 조회 실패:', bomErr);
|
|
}
|
|
}
|
|
} else {
|
|
setError(result.message || '품목 정보를 불러올 수 없습니다.');
|
|
}
|
|
} catch (err) {
|
|
console.error('[EditItem] Error:', err);
|
|
setError('품목 정보를 불러오는 중 오류가 발생했습니다.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchItem();
|
|
}, [params.id, 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가 없습니다.');
|
|
}
|
|
|
|
// console.log('[EditItem] Submitting update:', { itemId, itemType, data });
|
|
|
|
// Materials (SM, RM, CS)는 /products/materials 엔드포인트 + PATCH 메서드 사용
|
|
// Products (FG, PT)는 /items 엔드포인트 + PUT 메서드 사용
|
|
const isMaterial = isMaterialType(itemType);
|
|
|
|
// 디버깅: material_code 생성 관련 변수 확인 (필요 시 주석 해제)
|
|
// console.log('========== [EditItem] handleSubmit 디버깅 ==========');
|
|
// console.log('itemType:', itemType, 'isMaterial:', isMaterial);
|
|
// console.log('data:', JSON.stringify(data, null, 2));
|
|
// console.log('====================================================');
|
|
|
|
// 2025-12-15: 백엔드 동적 테이블 라우팅 - 모든 품목이 /items 엔드포인트 사용
|
|
// /products/materials 라우트 삭제됨 (products/materials 테이블 삭제)
|
|
const updateUrl = `/api/proxy/items/${itemId}?item_type=${itemType}`;
|
|
const method = 'PUT';
|
|
|
|
// console.log('[EditItem] Update URL:', updateUrl, '(method:', method, ', isMaterial:', isMaterial, ')');
|
|
|
|
// 품목코드 자동생성 처리
|
|
// - FG(제품): 품목코드 = 품목명
|
|
// - PT(부품): DynamicItemForm에서 자동계산한 code 사용 (조립/절곡/구매 각각 다른 규칙)
|
|
// - Material(SM, RM, CS): material_code = 품목명-규격
|
|
// 2025-12-15: item_type은 Request Body에서 필수 (ItemUpdateRequest validation)
|
|
let submitData = { ...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');
|
|
// console.log('[EditItem] Material 변환 데이터:', submitData);
|
|
} else {
|
|
// Products (FG, PT)의 경우
|
|
// 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);
|
|
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();
|
|
// console.log('========== [EditItem] PUT 응답 ==========');
|
|
// console.log('Response:', JSON.stringify(result, null, 2));
|
|
// 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 || '품목 수정에 실패했습니다.');
|
|
}
|
|
|
|
// 성공 시 품목 ID 반환 (파일 업로드용)
|
|
return { id: itemId, ...result.data };
|
|
};
|
|
|
|
// 로딩 상태
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
|
<p className="text-muted-foreground">품목 정보 로딩 중...</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 에러 상태
|
|
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('/items')}
|
|
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('/items')}
|
|
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>
|
|
);
|
|
} |