feat: 품목관리 파일 업로드 기능 개선
- 파일 업로드 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 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||||
import DynamicItemForm from '@/components/items/DynamicItemForm';
|
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 type { ItemType } from '@/types/item';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
transformMaterialDataForSave,
|
transformMaterialDataForSave,
|
||||||
convertOptionsToStandardFields,
|
convertOptionsToStandardFields,
|
||||||
} from '@/lib/utils/materialTransform';
|
} from '@/lib/utils/materialTransform';
|
||||||
|
import { DuplicateCodeError } from '@/lib/api/error-handler';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API 응답 타입 (백엔드 Product 모델 기준)
|
* API 응답 타입 (백엔드 Product 모델 기준)
|
||||||
@@ -65,74 +66,42 @@ interface ItemApiResponse {
|
|||||||
/**
|
/**
|
||||||
* API 응답을 DynamicFormData로 변환
|
* API 응답을 DynamicFormData로 변환
|
||||||
*
|
*
|
||||||
* API snake_case 필드를 폼 field_key로 매핑
|
* 2025-12-10: field_key 통일로 변환 로직 간소화
|
||||||
* (품목기준관리 API의 field_key가 snake_case 형식)
|
* - 백엔드에서 주는 field_key 그대로 사용 (변환 불필요)
|
||||||
|
* - 기존 레거시 데이터(98_unit 형식)도 그대로 동작
|
||||||
|
* - 신규 데이터(unit 형식)도 그대로 동작
|
||||||
*/
|
*/
|
||||||
function mapApiResponseToFormData(data: ItemApiResponse): DynamicFormData {
|
function mapApiResponseToFormData(data: ItemApiResponse): DynamicFormData {
|
||||||
const formData: 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<string, unknown>;
|
const attributes = (data.attributes || {}) as Record<string, unknown>;
|
||||||
|
Object.entries(attributes).forEach(([key, value]) => {
|
||||||
// 백엔드 Product 모델 필드: code, name, product_type
|
if (value !== null && value !== undefined) {
|
||||||
// 프론트엔드 폼 필드: item_name, item_code 등 (snake_case)
|
// 이미 있는 필드는 덮어쓰지 않음
|
||||||
|
if (!(key in formData)) {
|
||||||
// 기본 필드 (백엔드 name → 폼 item_name)
|
formData[key] = value as DynamicFormData[string];
|
||||||
// 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);
|
|
||||||
|
|
||||||
// Material(SM, RM, CS) options 필드 매핑
|
// Material(SM, RM, CS) options 필드 매핑
|
||||||
// 백엔드에서 options: [{label: "standard_1", value: "옵션값"}, ...] 형태로 저장됨
|
// 백엔드에서 options: [{label: "standard_1", value: "옵션값"}, ...] 형태로 저장됨
|
||||||
// 프론트엔드 폼에서는 standard_1: "옵션값" 형태로 사용
|
// 프론트엔드 폼에서는 standard_1: "옵션값" 형태로 사용
|
||||||
// 2025-12-05: Edit 모드에서 Select 옵션 값 불러오기 위해 추가
|
|
||||||
if (data.options && Array.isArray(data.options)) {
|
if (data.options && Array.isArray(data.options)) {
|
||||||
(data.options as Array<{ label: string; value: string }>).forEach((opt) => {
|
(data.options as Array<{ label: string; value: string }>).forEach((opt) => {
|
||||||
if (opt.label && opt.value) {
|
if (opt.label && opt.value) {
|
||||||
@@ -141,23 +110,12 @@ function mapApiResponseToFormData(data: ItemApiResponse): DynamicFormData {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기타 동적 필드들 (API에서 받은 모든 필드를 포함)
|
// is_active 기본값 처리
|
||||||
Object.entries(data).forEach(([key, value]) => {
|
if (formData['is_active'] === undefined) {
|
||||||
// 이미 처리한 특수 필드들 제외 (백엔드 필드명 + 기존 필드명)
|
formData['is_active'] = true;
|
||||||
const excludeKeys = [
|
}
|
||||||
'id', 'code', 'name', 'product_type', // 백엔드 Product 모델 필드
|
|
||||||
'item_code', 'item_name', 'item_type', // 기존 호환 필드
|
console.log('[EditItem] mapApiResponseToFormData 결과:', formData);
|
||||||
'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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return formData;
|
return formData;
|
||||||
}
|
}
|
||||||
@@ -169,6 +127,7 @@ export default function EditItemPage() {
|
|||||||
const [itemId, setItemId] = useState<number | null>(null);
|
const [itemId, setItemId] = useState<number | null>(null);
|
||||||
const [itemType, setItemType] = useState<ItemType | null>(null);
|
const [itemType, setItemType] = useState<ItemType | null>(null);
|
||||||
const [initialData, setInitialData] = useState<DynamicFormData | null>(null);
|
const [initialData, setInitialData] = useState<DynamicFormData | null>(null);
|
||||||
|
const [initialBomLines, setInitialBomLines] = useState<BOMLine[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -199,11 +158,11 @@ export default function EditItemPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Materials (SM, RM, CS)는 item_type=MATERIAL 쿼리 파라미터 추가
|
// 2025-12-10: item_type 파라미터를 실제 코드(SM, RM, CS)로 전달
|
||||||
const isMaterial = isMaterialType(urlItemType);
|
const isMaterial = isMaterialType(urlItemType);
|
||||||
const queryParams = new URLSearchParams();
|
const queryParams = new URLSearchParams();
|
||||||
if (isMaterial) {
|
if (isMaterial) {
|
||||||
queryParams.append('item_type', 'MATERIAL');
|
queryParams.append('item_type', urlItemType); // SM, RM, CS 그대로 전달
|
||||||
} else {
|
} else {
|
||||||
queryParams.append('include_bom', 'true');
|
queryParams.append('include_bom', 'true');
|
||||||
}
|
}
|
||||||
@@ -251,6 +210,28 @@ export default function EditItemPage() {
|
|||||||
console.log('전체:', formData);
|
console.log('전체:', formData);
|
||||||
console.log('==========================================================');
|
console.log('==========================================================');
|
||||||
setInitialData(formData);
|
setInitialData(formData);
|
||||||
|
|
||||||
|
// BOM 데이터 별도 처리 (백엔드 expandBomData 응답 형식)
|
||||||
|
const bomData = apiData.bom as Array<Record<string, unknown>> | 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 {
|
} else {
|
||||||
setError(result.message || '품목 정보를 불러올 수 없습니다.');
|
setError(result.message || '품목 정보를 불러올 수 없습니다.');
|
||||||
}
|
}
|
||||||
@@ -333,6 +314,19 @@ export default function EditItemPage() {
|
|||||||
// console.log('[EditItem] Product submitData:', submitData);
|
// 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 호출
|
// API 호출
|
||||||
console.log('========== [EditItem] 수정 요청 데이터 ==========');
|
console.log('========== [EditItem] 수정 요청 데이터 ==========');
|
||||||
console.log('URL:', updateUrl);
|
console.log('URL:', updateUrl);
|
||||||
@@ -357,6 +351,17 @@ export default function EditItemPage() {
|
|||||||
// console.log('==========================================');
|
// console.log('==========================================');
|
||||||
|
|
||||||
if (!response.ok || !result.success) {
|
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 || '품목 수정에 실패했습니다.');
|
throw new Error(result.message || '품목 수정에 실패했습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,6 +416,7 @@ export default function EditItemPage() {
|
|||||||
itemType={itemType}
|
itemType={itemType}
|
||||||
itemId={itemId ?? undefined}
|
itemId={itemId ?? undefined}
|
||||||
initialData={initialData}
|
initialData={initialData}
|
||||||
|
initialBomLines={initialBomLines}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -66,8 +66,8 @@ function mapApiResponseToItemMaster(data: Record<string, unknown>): ItemMaster {
|
|||||||
guideRailModel: (data.guide_rail_model || attributes.guide_rail_model) ? String(data.guide_rail_model || attributes.guide_rail_model) : undefined,
|
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,
|
length: (data.length || attributes.length) ? String(data.length || attributes.length) : undefined,
|
||||||
// BOM (있으면)
|
// BOM (있으면)
|
||||||
bom: Array.isArray(data.bom) ? data.bom.map((bomItem: Record<string, unknown>) => ({
|
bom: Array.isArray(data.bom) ? data.bom.map((bomItem: Record<string, unknown>, index: number) => ({
|
||||||
id: String(bomItem.id || ''),
|
id: String(bomItem.id || bomItem.child_item_id || `bom-${index}`),
|
||||||
childItemCode: String(bomItem.child_item_code || bomItem.childItemCode || ''),
|
childItemCode: String(bomItem.child_item_code || bomItem.childItemCode || ''),
|
||||||
childItemName: String(bomItem.child_item_name || bomItem.childItemName || ''),
|
childItemName: String(bomItem.child_item_name || bomItem.childItemName || ''),
|
||||||
quantity: Number(bomItem.quantity || 1),
|
quantity: Number(bomItem.quantity || 1),
|
||||||
@@ -127,11 +127,11 @@ export default function ItemDetailPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Materials (SM, RM, CS)는 item_type=MATERIAL 쿼리 파라미터 추가
|
// 2025-12-10: item_type 파라미터를 실제 코드(SM, RM, CS)로 전달
|
||||||
const isMaterial = MATERIAL_TYPES.includes(itemType);
|
const isMaterial = MATERIAL_TYPES.includes(itemType);
|
||||||
const queryParams = new URLSearchParams();
|
const queryParams = new URLSearchParams();
|
||||||
if (isMaterial) {
|
if (isMaterial) {
|
||||||
queryParams.append('item_type', 'MATERIAL');
|
queryParams.append('item_type', itemType); // SM, RM, CS 그대로 전달
|
||||||
} else {
|
} else {
|
||||||
queryParams.append('include_bom', 'true');
|
queryParams.append('include_bom', 'true');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useState } from 'react';
|
|||||||
import DynamicItemForm from '@/components/items/DynamicItemForm';
|
import DynamicItemForm from '@/components/items/DynamicItemForm';
|
||||||
import type { DynamicFormData } from '@/components/items/DynamicItemForm/types';
|
import type { DynamicFormData } from '@/components/items/DynamicItemForm/types';
|
||||||
import { isMaterialType, transformMaterialDataForSave } from '@/lib/utils/materialTransform';
|
import { isMaterialType, transformMaterialDataForSave } from '@/lib/utils/materialTransform';
|
||||||
|
import { DuplicateCodeError } from '@/lib/api/error-handler';
|
||||||
|
|
||||||
// 기존 ItemForm (주석처리 - 롤백 시 사용)
|
// 기존 ItemForm (주석처리 - 롤백 시 사용)
|
||||||
// import ItemForm from '@/components/items/ItemForm';
|
// import ItemForm from '@/components/items/ItemForm';
|
||||||
@@ -21,43 +22,64 @@ export default function CreateItemPage() {
|
|||||||
const handleSubmit = async (data: DynamicFormData) => {
|
const handleSubmit = async (data: DynamicFormData) => {
|
||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
|
|
||||||
try {
|
// 필드명 변환: spec → specification (백엔드 API 규격)
|
||||||
// 필드명 변환: spec → specification (백엔드 API 규격)
|
const submitData = { ...data };
|
||||||
const submitData = { ...data };
|
if (submitData.spec !== undefined) {
|
||||||
if (submitData.spec !== undefined) {
|
submitData.specification = submitData.spec;
|
||||||
submitData.specification = submitData.spec;
|
delete 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에서 에러 처리
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
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 { cn } from '@/lib/utils';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -37,7 +37,18 @@ import {
|
|||||||
import type { DynamicItemFormProps, DynamicFormData, DynamicSection, DynamicFieldValue, BOMLine, BOMSearchState, ItemSaveResult } from './types';
|
import type { DynamicItemFormProps, DynamicFormData, DynamicSection, DynamicFieldValue, BOMLine, BOMSearchState, ItemSaveResult } from './types';
|
||||||
import type { ItemType, BendingDetail } from '@/types/item';
|
import type { ItemType, BendingDetail } from '@/types/item';
|
||||||
import type { ItemFieldResponse } from '@/types/item-master-api';
|
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와 동일한 디자인
|
* 헤더 컴포넌트 - 기존 FormHeader와 동일한 디자인
|
||||||
@@ -223,6 +234,7 @@ export default function DynamicItemForm({
|
|||||||
itemType: initialItemType,
|
itemType: initialItemType,
|
||||||
itemId: propItemId,
|
itemId: propItemId,
|
||||||
initialData,
|
initialData,
|
||||||
|
initialBomLines,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: DynamicItemFormProps) {
|
}: DynamicItemFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -264,29 +276,103 @@ export default function DynamicItemForm({
|
|||||||
|
|
||||||
// 기존 파일 URL 상태 (edit 모드에서 사용)
|
// 기존 파일 URL 상태 (edit 모드에서 사용)
|
||||||
const [existingBendingDiagram, setExistingBendingDiagram] = useState<string>('');
|
const [existingBendingDiagram, setExistingBendingDiagram] = useState<string>('');
|
||||||
|
const [existingBendingDiagramFileId, setExistingBendingDiagramFileId] = useState<number | null>(null);
|
||||||
const [existingSpecificationFile, setExistingSpecificationFile] = useState<string>('');
|
const [existingSpecificationFile, setExistingSpecificationFile] = useState<string>('');
|
||||||
const [existingSpecificationFileName, setExistingSpecificationFileName] = useState<string>('');
|
const [existingSpecificationFileName, setExistingSpecificationFileName] = useState<string>('');
|
||||||
|
const [existingSpecificationFileId, setExistingSpecificationFileId] = useState<number | null>(null);
|
||||||
const [existingCertificationFile, setExistingCertificationFile] = useState<string>('');
|
const [existingCertificationFile, setExistingCertificationFile] = useState<string>('');
|
||||||
const [existingCertificationFileName, setExistingCertificationFileName] = useState<string>('');
|
const [existingCertificationFileName, setExistingCertificationFileName] = useState<string>('');
|
||||||
|
const [existingCertificationFileId, setExistingCertificationFileId] = useState<number | null>(null);
|
||||||
const [isDeletingFile, setIsDeletingFile] = useState<string | null>(null);
|
const [isDeletingFile, setIsDeletingFile] = useState<string | null>(null);
|
||||||
|
|
||||||
// initialData에서 기존 파일 정보 로드 (edit 모드)
|
// 품목코드 중복 체크 상태 관리
|
||||||
|
const [showDuplicateDialog, setShowDuplicateDialog] = useState(false);
|
||||||
|
const [duplicateCheckResult, setDuplicateCheckResult] = useState<DuplicateCheckResult | null>(null);
|
||||||
|
const [pendingSubmitData, setPendingSubmitData] = useState<DynamicFormData | null>(null);
|
||||||
|
|
||||||
|
// initialData에서 기존 파일 정보 및 전개도 상세 데이터 로드 (edit 모드)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode === 'edit' && initialData) {
|
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);
|
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);
|
setExistingSpecificationFile(initialData.specification_file as string);
|
||||||
setExistingSpecificationFileName((initialData.specification_file_name 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);
|
setExistingCertificationFile(initialData.certification_file as string);
|
||||||
setExistingCertificationFileName((initialData.certification_file_name 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<string, unknown>, 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]);
|
}, [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로 변환
|
// Storage 경로를 전체 URL로 변환
|
||||||
const getStorageUrl = (path: string | undefined): string | null => {
|
const getStorageUrl = (path: string | undefined): string | null => {
|
||||||
if (!path) return null;
|
if (!path) return null;
|
||||||
@@ -1102,6 +1188,111 @@ export default function DynamicItemForm({
|
|||||||
setSelectedItemType(type);
|
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) => {
|
const handleFormSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1170,14 +1361,20 @@ export default function DynamicItemForm({
|
|||||||
// console.log('[DynamicItemForm] 품목명/규격 결정:', { finalName, finalSpec });
|
// console.log('[DynamicItemForm] 품목명/규격 결정:', { finalName, finalSpec });
|
||||||
|
|
||||||
// 품목코드 결정
|
// 품목코드 결정
|
||||||
// 2025-12-04: 절곡 부품은 autoBendingItemCode 사용
|
// 2025-12-11: 수정 모드에서는 기존 코드 유지 (자동생성으로 코드가 변경되는 버그 수정)
|
||||||
// 2025-12-04: 구매 부품은 autoPurchasedItemCode 사용
|
// 생성 모드에서만 자동생성 코드 사용
|
||||||
let finalCode: string;
|
let finalCode: string;
|
||||||
if (isBendingPart && autoBendingItemCode) {
|
if (mode === 'edit' && initialData?.code) {
|
||||||
|
// 수정 모드: DB에서 받은 기존 코드 유지
|
||||||
|
finalCode = initialData.code as string;
|
||||||
|
} else if (isBendingPart && autoBendingItemCode) {
|
||||||
|
// 생성 모드: 절곡 부품 자동생성
|
||||||
finalCode = autoBendingItemCode;
|
finalCode = autoBendingItemCode;
|
||||||
} else if (isPurchasedPart && autoPurchasedItemCode) {
|
} else if (isPurchasedPart && autoPurchasedItemCode) {
|
||||||
|
// 생성 모드: 구매 부품 자동생성
|
||||||
finalCode = autoPurchasedItemCode;
|
finalCode = autoPurchasedItemCode;
|
||||||
} else if (hasAutoItemCode && autoGeneratedItemCode) {
|
} else if (hasAutoItemCode && autoGeneratedItemCode) {
|
||||||
|
// 생성 모드: 일반 자동생성
|
||||||
finalCode = autoGeneratedItemCode;
|
finalCode = autoGeneratedItemCode;
|
||||||
} else {
|
} else {
|
||||||
finalCode = convertedData.code || itemNameValue;
|
finalCode = convertedData.code || itemNameValue;
|
||||||
@@ -1194,17 +1391,12 @@ export default function DynamicItemForm({
|
|||||||
name: finalName, // 조립 부품: 품목명 가로x세로, 절곡 부품: 품목명 필드값, 기타: 품목명 필드값
|
name: finalName, // 조립 부품: 품목명 가로x세로, 절곡 부품: 품목명 필드값, 기타: 품목명 필드값
|
||||||
spec: finalSpec, // 조립 부품: 가로x세로x길이, 기타: 규격 필드값
|
spec: finalSpec, // 조립 부품: 가로x세로x길이, 기타: 규격 필드값
|
||||||
code: finalCode, // 절곡 부품: autoBendingItemCode, 기타: autoGeneratedItemCode
|
code: finalCode, // 절곡 부품: autoBendingItemCode, 기타: autoGeneratedItemCode
|
||||||
// BOM 데이터를 배열로 포함
|
// BOM 데이터를 배열로 포함 (백엔드는 child_item_id, child_item_type, quantity만 저장)
|
||||||
bom: bomLines.map((line) => ({
|
bom: bomLines.map((line) => ({
|
||||||
child_item_code: line.childItemCode,
|
child_item_id: line.childItemId ? Number(line.childItemId) : null,
|
||||||
child_item_name: line.childItemName,
|
child_item_type: line.childItemType || 'PRODUCT', // PRODUCT(FG/PT) 또는 MATERIAL(SM/RM/CS)
|
||||||
specification: line.specification || '',
|
quantity: line.quantity || 1,
|
||||||
material: line.material || '',
|
})).filter(item => item.child_item_id !== null), // child_item_id 없는 항목 제외
|
||||||
quantity: line.quantity,
|
|
||||||
unit: line.unit,
|
|
||||||
unit_price: line.unitPrice || 0,
|
|
||||||
note: line.note || '',
|
|
||||||
})),
|
|
||||||
// 절곡품 전개도 데이터 (PT - 절곡 부품 전용)
|
// 절곡품 전개도 데이터 (PT - 절곡 부품 전용)
|
||||||
...(selectedItemType === 'PT' && isBendingPart ? {
|
...(selectedItemType === 'PT' && isBendingPart ? {
|
||||||
part_type: 'BENDING',
|
part_type: 'BENDING',
|
||||||
@@ -1229,83 +1421,51 @@ export default function DynamicItemForm({
|
|||||||
|
|
||||||
// console.log('[DynamicItemForm] 제출 데이터:', submitData);
|
// console.log('[DynamicItemForm] 제출 데이터:', submitData);
|
||||||
|
|
||||||
await handleSubmit(async () => {
|
// 2025-12-11: 품목코드 중복 체크 (조립/절곡 부품만 해당)
|
||||||
// 품목 저장 (ID 반환)
|
// PT-조립부품, PT-절곡부품은 품목코드가 자동생성되므로 중복 체크 필요
|
||||||
const result = await onSubmit(submitData);
|
const needsDuplicateCheck = selectedItemType === 'PT' && (isAssemblyPart || isBendingPart) && finalCode;
|
||||||
const itemId = result?.id;
|
|
||||||
|
|
||||||
// 파일 업로드 (품목 ID가 있을 때만)
|
if (needsDuplicateCheck) {
|
||||||
if (itemId) {
|
console.log('[DynamicItemForm] 품목코드 중복 체크:', finalCode);
|
||||||
const fileUploadErrors: string[] = [];
|
|
||||||
|
|
||||||
// PT (절곡/조립) 전개도 이미지 업로드
|
// 수정 모드에서는 자기 자신 제외 (propItemId)
|
||||||
if (selectedItemType === 'PT' && (isBendingPart || isAssemblyPart) && bendingDiagramFile) {
|
const excludeId = mode === 'edit' ? propItemId : undefined;
|
||||||
try {
|
const duplicateResult = await checkItemCodeDuplicate(finalCode, excludeId);
|
||||||
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('전개도 이미지');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FG (제품) 시방서 업로드
|
console.log('[DynamicItemForm] 중복 체크 결과:', duplicateResult);
|
||||||
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 (duplicateResult.isDuplicate) {
|
||||||
if (selectedItemType === 'FG' && certificationFile) {
|
// 중복 발견 → 다이얼로그 표시
|
||||||
try {
|
setDuplicateCheckResult(duplicateResult);
|
||||||
console.log('[DynamicItemForm] 인정서 파일 업로드 시작:', certificationFile.name);
|
setPendingSubmitData(submitData);
|
||||||
// formData에서 인정서 관련 필드 추출
|
setShowDuplicateDialog(true);
|
||||||
const certNumber = Object.entries(formData).find(([key]) =>
|
return; // 저장 중단, 사용자 선택 대기
|
||||||
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();
|
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({
|
|||||||
{/* 시방서 파일 */}
|
{/* 시방서 파일 */}
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="specification_file">시방서 (PDF)</Label>
|
<Label htmlFor="specification_file">시방서 (PDF)</Label>
|
||||||
<div className="mt-1.5 space-y-2">
|
<div className="mt-1.5">
|
||||||
{/* 기존 파일 표시 (edit 모드) */}
|
{/* 기존 파일이 있고, 새 파일 선택 안한 경우: 파일명 + 버튼들 */}
|
||||||
{mode === 'edit' && existingSpecificationFile && !specificationFile && (
|
{mode === 'edit' && existingSpecificationFile && !specificationFile ? (
|
||||||
<div className="flex items-center gap-2 p-2 bg-gray-50 rounded border">
|
<div className="flex items-center gap-2">
|
||||||
<FileText className="h-4 w-4 text-blue-600" />
|
<div className="flex items-center gap-2 flex-1 px-3 py-2 bg-gray-50 rounded-md border text-sm">
|
||||||
<span className="text-sm flex-1 truncate">{existingSpecificationFileName}</span>
|
<FileText className="h-4 w-4 text-blue-600 shrink-0" />
|
||||||
|
<span className="truncate">{existingSpecificationFileName}</span>
|
||||||
|
</div>
|
||||||
<a
|
<a
|
||||||
href={getStorageUrl(existingSpecificationFile) || '#'}
|
href={getStorageUrl(existingSpecificationFile) || '#'}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800"
|
className="inline-flex items-center justify-center h-9 w-9 rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground text-blue-600"
|
||||||
|
title="다운로드"
|
||||||
>
|
>
|
||||||
<Download className="h-3.5 w-3.5" />
|
<Download className="h-4 w-4" />
|
||||||
</a>
|
</a>
|
||||||
|
<label
|
||||||
|
htmlFor="specification_file_edit"
|
||||||
|
className="inline-flex items-center justify-center h-9 w-9 rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground text-gray-600 cursor-pointer"
|
||||||
|
title="수정"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
<input
|
||||||
|
id="specification_file_edit"
|
||||||
|
type="file"
|
||||||
|
accept=".pdf"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0] || null;
|
||||||
|
setSpecificationFile(file);
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="sm"
|
size="icon"
|
||||||
onClick={() => handleDeleteFile('specification')}
|
onClick={() => handleDeleteFile('specification')}
|
||||||
disabled={isDeletingFile === 'specification' || isSubmitting}
|
disabled={isDeletingFile === 'specification' || isSubmitting}
|
||||||
className="h-7 w-7 p-0 text-red-500 hover:text-red-700 hover:bg-red-50"
|
className="h-9 w-9 text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||||
|
title="삭제"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
{/* 새 파일 업로드 */}
|
<div className="space-y-2">
|
||||||
<Input
|
{/* 새 파일 업로드 (기존 파일 없거나, 새 파일 선택 중) */}
|
||||||
id="specification_file"
|
<Input
|
||||||
type="file"
|
id="specification_file"
|
||||||
accept=".pdf"
|
type="file"
|
||||||
onChange={(e) => {
|
accept=".pdf"
|
||||||
const file = e.target.files?.[0] || null;
|
onChange={(e) => {
|
||||||
setSpecificationFile(file);
|
const file = e.target.files?.[0] || null;
|
||||||
}}
|
setSpecificationFile(file);
|
||||||
disabled={isSubmitting}
|
}}
|
||||||
className="cursor-pointer"
|
disabled={isSubmitting}
|
||||||
/>
|
className="cursor-pointer"
|
||||||
{specificationFile && (
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
{specificationFile && (
|
||||||
선택된 파일: {specificationFile.name}
|
<p className="text-xs text-muted-foreground">
|
||||||
</p>
|
선택된 파일: {specificationFile.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 인정서 파일 */}
|
{/* 인정서 파일 */}
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="certification_file">인정서 (PDF)</Label>
|
<Label htmlFor="certification_file">인정서 (PDF)</Label>
|
||||||
<div className="mt-1.5 space-y-2">
|
<div className="mt-1.5">
|
||||||
{/* 기존 파일 표시 (edit 모드) */}
|
{/* 기존 파일이 있고, 새 파일 선택 안한 경우: 파일명 + 버튼들 */}
|
||||||
{mode === 'edit' && existingCertificationFile && !certificationFile && (
|
{mode === 'edit' && existingCertificationFile && !certificationFile ? (
|
||||||
<div className="flex items-center gap-2 p-2 bg-gray-50 rounded border">
|
<div className="flex items-center gap-2">
|
||||||
<FileText className="h-4 w-4 text-green-600" />
|
<div className="flex items-center gap-2 flex-1 px-3 py-2 bg-gray-50 rounded-md border text-sm">
|
||||||
<span className="text-sm flex-1 truncate">{existingCertificationFileName}</span>
|
<FileText className="h-4 w-4 text-green-600 shrink-0" />
|
||||||
|
<span className="truncate">{existingCertificationFileName}</span>
|
||||||
|
</div>
|
||||||
<a
|
<a
|
||||||
href={getStorageUrl(existingCertificationFile) || '#'}
|
href={getStorageUrl(existingCertificationFile) || '#'}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1 text-sm text-green-600 hover:text-green-800"
|
className="inline-flex items-center justify-center h-9 w-9 rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground text-green-600"
|
||||||
|
title="다운로드"
|
||||||
>
|
>
|
||||||
<Download className="h-3.5 w-3.5" />
|
<Download className="h-4 w-4" />
|
||||||
</a>
|
</a>
|
||||||
|
<label
|
||||||
|
htmlFor="certification_file_edit"
|
||||||
|
className="inline-flex items-center justify-center h-9 w-9 rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground text-gray-600 cursor-pointer"
|
||||||
|
title="수정"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
<input
|
||||||
|
id="certification_file_edit"
|
||||||
|
type="file"
|
||||||
|
accept=".pdf"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0] || null;
|
||||||
|
setCertificationFile(file);
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="sm"
|
size="icon"
|
||||||
onClick={() => handleDeleteFile('certification')}
|
onClick={() => handleDeleteFile('certification')}
|
||||||
disabled={isDeletingFile === 'certification' || isSubmitting}
|
disabled={isDeletingFile === 'certification' || isSubmitting}
|
||||||
className="h-7 w-7 p-0 text-red-500 hover:text-red-700 hover:bg-red-50"
|
className="h-9 w-9 text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||||
|
title="삭제"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
{/* 새 파일 업로드 */}
|
<div className="space-y-2">
|
||||||
<Input
|
{/* 새 파일 업로드 (기존 파일 없거나, 새 파일 선택 중) */}
|
||||||
id="certification_file"
|
<Input
|
||||||
type="file"
|
id="certification_file"
|
||||||
accept=".pdf"
|
type="file"
|
||||||
onChange={(e) => {
|
accept=".pdf"
|
||||||
const file = e.target.files?.[0] || null;
|
onChange={(e) => {
|
||||||
setCertificationFile(file);
|
const file = e.target.files?.[0] || null;
|
||||||
}}
|
setCertificationFile(file);
|
||||||
disabled={isSubmitting}
|
}}
|
||||||
className="cursor-pointer"
|
disabled={isSubmitting}
|
||||||
/>
|
className="cursor-pointer"
|
||||||
{certificationFile && (
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
{certificationFile && (
|
||||||
선택된 파일: {certificationFile.name}
|
<p className="text-xs text-muted-foreground">
|
||||||
</p>
|
선택된 파일: {certificationFile.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1724,6 +1934,7 @@ export default function DynamicItemForm({
|
|||||||
bendingDetails={bendingDetails}
|
bendingDetails={bendingDetails}
|
||||||
setBendingDetails={setBendingDetails}
|
setBendingDetails={setBendingDetails}
|
||||||
setWidthSum={setWidthSum}
|
setWidthSum={setWidthSum}
|
||||||
|
widthSumFieldKey={bendingFieldKeys.widthSum}
|
||||||
setValue={(key, value) => setFieldValue(key, value)}
|
setValue={(key, value) => setFieldValue(key, value)}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
/>
|
/>
|
||||||
@@ -1742,6 +1953,7 @@ export default function DynamicItemForm({
|
|||||||
bendingDetails={bendingDetails}
|
bendingDetails={bendingDetails}
|
||||||
setBendingDetails={setBendingDetails}
|
setBendingDetails={setBendingDetails}
|
||||||
setWidthSum={setWidthSum}
|
setWidthSum={setWidthSum}
|
||||||
|
widthSumFieldKey={bendingFieldKeys.widthSum}
|
||||||
setValue={(key, value) => setFieldValue(key, value)}
|
setValue={(key, value) => setFieldValue(key, value)}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
/>
|
/>
|
||||||
@@ -1804,6 +2016,29 @@ export default function DynamicItemForm({
|
|||||||
: "절곡품 전개도를 그리거나 편집합니다."
|
: "절곡품 전개도를 그리거나 편집합니다."
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 품목코드 중복 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={showDuplicateDialog} onOpenChange={setShowDuplicateDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>품목코드 중복</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
입력하신 조건의 품목코드가 이미 존재합니다.
|
||||||
|
<span className="block mt-2 font-medium text-foreground">
|
||||||
|
기존 품목을 수정하시겠습니까?
|
||||||
|
</span>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={handleCancelDuplicate}>
|
||||||
|
취소
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleGoToEditDuplicate}>
|
||||||
|
중복 품목 수정하러 가기
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,22 @@ import {
|
|||||||
import { Check, Package, Plus, Search, Trash2, Loader2 } from 'lucide-react';
|
import { Check, Package, Plus, Search, Trash2, Loader2 } from 'lucide-react';
|
||||||
import type { BOMLine, BOMSearchState, DynamicSection } from '../types';
|
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 {
|
interface SearchedItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -48,6 +64,7 @@ interface SearchedItem {
|
|||||||
unit: string;
|
unit: string;
|
||||||
partType?: string;
|
partType?: string;
|
||||||
bendingDiagram?: string;
|
bendingDiagram?: string;
|
||||||
|
itemType?: string; // FG, PT, SM, RM, CS 등 품목 유형
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debounce 훅
|
// Debounce 훅
|
||||||
@@ -83,8 +100,9 @@ export default function DynamicBOMSection({
|
|||||||
const [searchResults, setSearchResults] = useState<Record<string, SearchedItem[]>>({});
|
const [searchResults, setSearchResults] = useState<Record<string, SearchedItem[]>>({});
|
||||||
const [isSearching, setIsSearching] = useState<Record<string, boolean>>({});
|
const [isSearching, setIsSearching] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
// 품목 검색 API 호출
|
// 품목 검색 API 호출 (검색어 있을 때만)
|
||||||
const searchItems = useCallback(async (lineId: string, query: string) => {
|
const searchItems = useCallback(async (lineId: string, query: string) => {
|
||||||
|
// 검색어가 없으면 빈 결과 (사용자가 검색어 입력 필요)
|
||||||
if (!query || query.length < 1) {
|
if (!query || query.length < 1) {
|
||||||
setSearchResults((prev) => ({ ...prev, [lineId]: [] }));
|
setSearchResults((prev) => ({ ...prev, [lineId]: [] }));
|
||||||
return;
|
return;
|
||||||
@@ -113,6 +131,7 @@ export default function DynamicBOMSection({
|
|||||||
unit: (item.unit ?? 'EA') as string,
|
unit: (item.unit ?? 'EA') as string,
|
||||||
partType: (item.part_type ?? '') as string,
|
partType: (item.part_type ?? '') as string,
|
||||||
bendingDiagram: (item.bending_diagram ?? '') 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 }));
|
setSearchResults((prev) => ({ ...prev, [lineId]: mappedItems }));
|
||||||
@@ -180,7 +199,6 @@ export default function DynamicBOMSection({
|
|||||||
<TableHead className="w-[100px]">재질</TableHead>
|
<TableHead className="w-[100px]">재질</TableHead>
|
||||||
<TableHead className="w-20">수량</TableHead>
|
<TableHead className="w-20">수량</TableHead>
|
||||||
<TableHead className="w-16">단위</TableHead>
|
<TableHead className="w-16">단위</TableHead>
|
||||||
<TableHead className="w-24 text-right">단가</TableHead>
|
|
||||||
<TableHead className="w-[180px]">비고</TableHead>
|
<TableHead className="w-[180px]">비고</TableHead>
|
||||||
<TableHead className="w-16">삭제</TableHead>
|
<TableHead className="w-16">삭제</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -239,8 +257,9 @@ function BOMLineRow({
|
|||||||
const searchItemsRef = useRef(searchItems);
|
const searchItemsRef = useRef(searchItems);
|
||||||
searchItemsRef.current = searchItems;
|
searchItemsRef.current = searchItems;
|
||||||
|
|
||||||
|
// 검색어 변경 시 검색 실행
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (debouncedSearchValue && searchOpen) {
|
if (searchOpen) {
|
||||||
searchItemsRef.current(line.id, debouncedSearchValue);
|
searchItemsRef.current(line.id, debouncedSearchValue);
|
||||||
}
|
}
|
||||||
}, [debouncedSearchValue, searchOpen, line.id]);
|
}, [debouncedSearchValue, searchOpen, line.id]);
|
||||||
@@ -257,10 +276,6 @@ function BOMLineRow({
|
|||||||
...bomSearchStates,
|
...bomSearchStates,
|
||||||
[line.id]: { ...searchState, isOpen: open },
|
[line.id]: { ...searchState, isOpen: open },
|
||||||
});
|
});
|
||||||
// 팝오버 열릴 때 검색 실행
|
|
||||||
if (open && searchValue) {
|
|
||||||
searchItems(line.id, searchValue);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
@@ -328,11 +343,20 @@ function BOMLineRow({
|
|||||||
{isSearching ? (
|
{isSearching ? (
|
||||||
<div className="flex items-center justify-center py-6">
|
<div className="flex items-center justify-center py-6">
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
<span className="text-sm text-muted-foreground">검색 중...</span>
|
<span className="text-sm text-muted-foreground">
|
||||||
|
검색 중...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : !searchValue ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||||
|
<Search className="h-8 w-8 text-muted-foreground mb-2 opacity-40" />
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
품목코드 또는 품목명을 입력하세요
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : searchResults.length === 0 ? (
|
) : searchResults.length === 0 ? (
|
||||||
<CommandEmpty>
|
<CommandEmpty>
|
||||||
{searchValue ? '검색 결과가 없습니다.' : '품목코드 또는 품목명을 입력하세요.'}
|
검색 결과가 없습니다.
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
) : (
|
) : (
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
@@ -342,12 +366,16 @@ function BOMLineRow({
|
|||||||
value={`${item.itemCode} ${item.itemName}`}
|
value={`${item.itemCode} ${item.itemName}`}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
const isBendingPart = item.partType === 'BENDING';
|
const isBendingPart = item.partType === 'BENDING';
|
||||||
|
// 품목 유형에 따라 PRODUCT/MATERIAL 결정
|
||||||
|
const childItemType = getChildItemType(item.itemType);
|
||||||
|
|
||||||
setBomLines(
|
setBomLines(
|
||||||
bomLines.map((l) =>
|
bomLines.map((l) =>
|
||||||
l.id === line.id
|
l.id === line.id
|
||||||
? {
|
? {
|
||||||
...l,
|
...l,
|
||||||
|
childItemId: item.id || '',
|
||||||
|
childItemType, // PRODUCT 또는 MATERIAL
|
||||||
childItemCode: item.itemCode || '',
|
childItemCode: item.itemCode || '',
|
||||||
childItemName: item.itemName || '',
|
childItemName: item.itemName || '',
|
||||||
specification: item.specification || '',
|
specification: item.specification || '',
|
||||||
@@ -401,19 +429,8 @@ function BOMLineRow({
|
|||||||
<TableCell className="text-sm text-muted-foreground">
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
{line.specification || '-'}
|
{line.specification || '-'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm">
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
<Input
|
{line.material || '-'}
|
||||||
value={line.material || ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
setBomLines(
|
|
||||||
bomLines.map((l) =>
|
|
||||||
l.id === line.id ? { ...l, material: e.target.value } : l
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
placeholder="재질"
|
|
||||||
className="w-full text-xs"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Input
|
<Input
|
||||||
@@ -434,21 +451,6 @@ function BOMLineRow({
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="secondary">{line.unit}</Badge>
|
<Badge variant="secondary">{line.unit}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={line.unitPrice || 0}
|
|
||||||
onChange={(e) => {
|
|
||||||
setBomLines(
|
|
||||||
bomLines.map((l) =>
|
|
||||||
l.id === line.id ? { ...l, unitPrice: Number(e.target.value) } : l
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
min="0"
|
|
||||||
className="w-full text-right"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Input
|
<Input
|
||||||
value={line.note || ''}
|
value={line.note || ''}
|
||||||
@@ -480,7 +482,7 @@ function BOMLineRow({
|
|||||||
{/* 절곡품인 경우 전개도 정보 표시 */}
|
{/* 절곡품인 경우 전개도 정보 표시 */}
|
||||||
{line.isBending && line.bendingDiagram && (
|
{line.isBending && line.bendingDiagram && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={9} className="bg-blue-50 p-4">
|
<TableCell colSpan={8} className="bg-blue-50 p-4">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline" className="bg-blue-100 text-blue-700">
|
<Badge variant="outline" className="bg-blue-100 text-blue-700">
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ export interface DynamicBomItem {
|
|||||||
*/
|
*/
|
||||||
export interface BOMLine {
|
export interface BOMLine {
|
||||||
id: string;
|
id: string;
|
||||||
|
childItemId?: string; // 자품목 ID (API에서 받은 품목 id)
|
||||||
|
childItemType?: 'PRODUCT' | 'MATERIAL'; // 자품목 타입 (PRODUCT: FG/PT, MATERIAL: SM/RM/CS)
|
||||||
childItemCode: string;
|
childItemCode: string;
|
||||||
childItemName: string;
|
childItemName: string;
|
||||||
specification?: string;
|
specification?: string;
|
||||||
@@ -150,6 +152,8 @@ export interface DynamicItemFormProps {
|
|||||||
itemType?: 'FG' | 'PT' | 'SM' | 'RM' | 'CS';
|
itemType?: 'FG' | 'PT' | 'SM' | 'RM' | 'CS';
|
||||||
itemId?: number; // edit 모드에서 파일 업로드에 사용
|
itemId?: number; // edit 모드에서 파일 업로드에 사용
|
||||||
initialData?: DynamicFormData;
|
initialData?: DynamicFormData;
|
||||||
|
/** edit 모드에서 초기 BOM 데이터 */
|
||||||
|
initialBomLines?: BOMLine[];
|
||||||
/** 품목 저장 후 결과 반환 (create: id 필수, edit: id 선택) */
|
/** 품목 저장 후 결과 반환 (create: id 필수, edit: id 선택) */
|
||||||
onSubmit: (data: DynamicFormData) => Promise<ItemSaveResult | void>;
|
onSubmit: (data: DynamicFormData) => Promise<ItemSaveResult | void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,8 +214,8 @@ export default function ItemDetailClient({ item }: ItemDetailClientProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 제품(FG) 전용 정보 */}
|
{/* 제품(FG) 전용 정보 - 내용이 있을 때만 표시 */}
|
||||||
{item.itemType === 'FG' && (
|
{item.itemType === 'FG' && (item.productCategory || item.lotAbbreviation || item.note) && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>제품 정보</CardTitle>
|
<CardTitle>제품 정보</CardTitle>
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export interface BendingDiagramSectionProps {
|
|||||||
bendingDetails: BendingDetail[];
|
bendingDetails: BendingDetail[];
|
||||||
setBendingDetails: (details: BendingDetail[]) => void;
|
setBendingDetails: (details: BendingDetail[]) => void;
|
||||||
setWidthSum: (sum: string) => void;
|
setWidthSum: (sum: string) => void;
|
||||||
|
/** 동적 폼에서 폭 합계 필드의 키 (예: 'width_sum', 'field_123') */
|
||||||
|
widthSumFieldKey?: string;
|
||||||
setValue: UseFormSetValue<CreateItemFormData>;
|
setValue: UseFormSetValue<CreateItemFormData>;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
}
|
}
|
||||||
@@ -37,6 +39,7 @@ export default function BendingDiagramSection({
|
|||||||
bendingDetails,
|
bendingDetails,
|
||||||
setBendingDetails,
|
setBendingDetails,
|
||||||
setWidthSum,
|
setWidthSum,
|
||||||
|
widthSumFieldKey,
|
||||||
setValue,
|
setValue,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
}: BendingDiagramSectionProps) {
|
}: BendingDiagramSectionProps) {
|
||||||
@@ -46,8 +49,16 @@ export default function BendingDiagramSection({
|
|||||||
const calc = d.input + d.elongation;
|
const calc = d.input + d.elongation;
|
||||||
return acc + calc;
|
return acc + calc;
|
||||||
}, 0);
|
}, 0);
|
||||||
setWidthSum(totalSum.toString());
|
const sumString = totalSum.toString();
|
||||||
setValue('length', 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 (
|
return (
|
||||||
|
|||||||
@@ -176,12 +176,13 @@ export default function ItemListClient() {
|
|||||||
|
|
||||||
// Materials (SM, RM, CS)는 /products/materials 엔드포인트 사용
|
// Materials (SM, RM, CS)는 /products/materials 엔드포인트 사용
|
||||||
// Products (FG, PT)는 /items 엔드포인트 사용
|
// Products (FG, PT)는 /items 엔드포인트 사용
|
||||||
|
// 2025-12-10: item_type 파라미터를 실제 코드(SM, RM, CS)로 전달
|
||||||
const isMaterial = MATERIAL_TYPES.includes(itemToDelete.itemType);
|
const isMaterial = MATERIAL_TYPES.includes(itemToDelete.itemType);
|
||||||
const deleteUrl = isMaterial
|
const deleteUrl = isMaterial
|
||||||
? `/api/proxy/products/materials/${itemToDelete.id}`
|
? `/api/proxy/products/materials/${itemToDelete.id}?item_type=${itemToDelete.itemType}`
|
||||||
: `/api/proxy/items/${itemToDelete.id}`;
|
: `/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, {
|
const response = await fetch(deleteUrl, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
@@ -229,6 +230,7 @@ export default function ItemListClient() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 일괄 삭제 핸들러
|
// 일괄 삭제 핸들러
|
||||||
|
// 2025-12-10: item_type 파라미터를 실제 코드(SM, RM, CS)로 전달
|
||||||
const handleBulkDelete = async () => {
|
const handleBulkDelete = async () => {
|
||||||
const itemIds = Array.from(selectedItems);
|
const itemIds = Array.from(selectedItems);
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
@@ -239,9 +241,9 @@ export default function ItemListClient() {
|
|||||||
// 해당 품목의 itemType 찾기
|
// 해당 품목의 itemType 찾기
|
||||||
const item = items.find((i) => i.id === id);
|
const item = items.find((i) => i.id === id);
|
||||||
const isMaterial = item ? MATERIAL_TYPES.includes(item.itemType) : false;
|
const isMaterial = item ? MATERIAL_TYPES.includes(item.itemType) : false;
|
||||||
// Materials는 /products/materials 엔드포인트, Products는 /items 엔드포인트
|
// Materials는 /products/materials 엔드포인트 + item_type, Products는 /items 엔드포인트
|
||||||
const deleteUrl = isMaterial
|
const deleteUrl = isMaterial
|
||||||
? `/api/proxy/products/materials/${id}`
|
? `/api/proxy/products/materials/${id}?item_type=${item?.itemType}`
|
||||||
: `/api/proxy/items/${id}`;
|
: `/api/proxy/items/${id}`;
|
||||||
|
|
||||||
const response = await fetch(deleteUrl, {
|
const response = await fetch(deleteUrl, {
|
||||||
|
|||||||
@@ -178,10 +178,10 @@ export function ConditionalDisplayUI({
|
|||||||
이 값일 때 표시할 항목들 ({condition.targetFieldIds?.length || 0}개 선택됨):
|
이 값일 때 표시할 항목들 ({condition.targetFieldIds?.length || 0}개 선택됨):
|
||||||
</Label>
|
</Label>
|
||||||
<div className="space-y-1 max-h-40 overflow-y-auto bg-white rounded p-2">
|
<div className="space-y-1 max-h-40 overflow-y-auto bg-white rounded p-2">
|
||||||
{availableFields.map(field => {
|
{availableFields.map((field, fieldIdx) => {
|
||||||
const fieldIdStr = String(field.id);
|
const fieldIdStr = String(field.id);
|
||||||
return (
|
return (
|
||||||
<label key={field.id} className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded cursor-pointer">
|
<label key={`condition-${conditionIndex}-field-${field.id}-${fieldIdx}`} className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={condition.targetFieldIds?.includes(fieldIdStr) || false}
|
checked={condition.targetFieldIds?.includes(fieldIdStr) || false}
|
||||||
@@ -284,10 +284,10 @@ export function ConditionalDisplayUI({
|
|||||||
이 값일 때 표시할 섹션들 ({condition.targetSectionIds?.length || 0}개 선택됨):
|
이 값일 때 표시할 섹션들 ({condition.targetSectionIds?.length || 0}개 선택됨):
|
||||||
</Label>
|
</Label>
|
||||||
<div className="space-y-1 max-h-40 overflow-y-auto bg-white rounded p-2">
|
<div className="space-y-1 max-h-40 overflow-y-auto bg-white rounded p-2">
|
||||||
{availableSections.map(section => {
|
{availableSections.map((section, sectionIdx) => {
|
||||||
const sectionIdStr = String(section.id);
|
const sectionIdStr = String(section.id);
|
||||||
return (
|
return (
|
||||||
<label key={section.id} className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded cursor-pointer">
|
<label key={`condition-${conditionIndex}-section-${section.id}-${sectionIdx}`} className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={condition.targetSectionIds?.includes(sectionIdStr) || false}
|
checked={condition.targetSectionIds?.includes(sectionIdStr) || false}
|
||||||
|
|||||||
@@ -209,9 +209,9 @@ export function FieldDialog({
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{itemMasterFields.map(field => (
|
{itemMasterFields.map((field, index) => (
|
||||||
<div
|
<div
|
||||||
key={field.id}
|
key={`dialog-${field.id}-${index}`}
|
||||||
className={`p-3 border rounded cursor-pointer transition-colors ${
|
className={`p-3 border rounded cursor-pointer transition-colors ${
|
||||||
selectedMasterFieldId === String(field.id)
|
selectedMasterFieldId === String(field.id)
|
||||||
? 'bg-blue-50 border-blue-300'
|
? 'bg-blue-50 border-blue-300'
|
||||||
|
|||||||
@@ -185,9 +185,9 @@ export function FieldDrawer({
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{itemMasterFields.map(field => (
|
{itemMasterFields.map((field, index) => (
|
||||||
<div
|
<div
|
||||||
key={field.id}
|
key={`drawer-${field.id}-${index}`}
|
||||||
className={`p-3 border rounded cursor-pointer transition-colors ${
|
className={`p-3 border rounded cursor-pointer transition-colors ${
|
||||||
selectedMasterFieldId === String(field.id)
|
selectedMasterFieldId === String(field.id)
|
||||||
? 'bg-blue-50 border-blue-300'
|
? 'bg-blue-50 border-blue-300'
|
||||||
|
|||||||
@@ -185,9 +185,9 @@ export function TemplateFieldDialog({
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{itemMasterFields.map(field => (
|
{itemMasterFields.map((field, index) => (
|
||||||
<div
|
<div
|
||||||
key={field.id}
|
key={`master-${field.id}-${index}`}
|
||||||
className={`p-3 border rounded cursor-pointer transition-colors ${
|
className={`p-3 border rounded cursor-pointer transition-colors ${
|
||||||
selectedMasterFieldId === String(field.id)
|
selectedMasterFieldId === String(field.id)
|
||||||
? 'bg-blue-50 border-blue-300'
|
? 'bg-blue-50 border-blue-300'
|
||||||
|
|||||||
@@ -442,7 +442,7 @@ export function HierarchyTab({
|
|||||||
.sort((a, b) => (a.order_no ?? 0) - (b.order_no ?? 0))
|
.sort((a, b) => (a.order_no ?? 0) - (b.order_no ?? 0))
|
||||||
.map((field, fieldIndex) => (
|
.map((field, fieldIndex) => (
|
||||||
<DraggableField
|
<DraggableField
|
||||||
key={field.id}
|
key={`${section.id}-${field.id}-${fieldIndex}`}
|
||||||
field={field}
|
field={field}
|
||||||
index={fieldIndex}
|
index={fieldIndex}
|
||||||
moveField={(dragFieldId, hoverFieldId) => moveField(section.id, dragFieldId, hoverFieldId)}
|
moveField={(dragFieldId, hoverFieldId) => moveField(section.id, dragFieldId, hoverFieldId)}
|
||||||
|
|||||||
@@ -80,8 +80,8 @@ export function MasterFieldTab({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{itemMasterFields.map((field) => (
|
{itemMasterFields.map((field, index) => (
|
||||||
<div key={field.id} className="flex items-center justify-between p-4 border rounded hover:bg-gray-50 transition-colors">
|
<div key={`masterfield-${field.id}-${index}`} className="flex items-center justify-between p-4 border rounded hover:bg-gray-50 transition-colors">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{field.field_name}</span>
|
<span>{field.field_name}</span>
|
||||||
|
|||||||
@@ -240,9 +240,9 @@ export function SectionsTab({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{template.fields.map((field, _index) => (
|
{template.fields.map((field, index) => (
|
||||||
<div
|
<div
|
||||||
key={field.id}
|
key={`${template.id}-${field.id}-${index}`}
|
||||||
className="flex items-center justify-between p-3 border rounded hover:bg-gray-50 transition-colors"
|
className="flex items-center justify-between p-3 border rounded hover:bg-gray-50 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|||||||
@@ -16,6 +16,22 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 품목코드 중복 에러 클래스
|
||||||
|
* - 백엔드에서 400 에러와 함께 duplicate_id, duplicate_code 반환 시 사용
|
||||||
|
* - 2025-12-11: 백엔드 DuplicateCodeException 대응
|
||||||
|
*/
|
||||||
|
export class DuplicateCodeError extends ApiError {
|
||||||
|
constructor(
|
||||||
|
public message: string,
|
||||||
|
public duplicateId: number,
|
||||||
|
public duplicateCode: string
|
||||||
|
) {
|
||||||
|
super(400, message);
|
||||||
|
this.name = 'DuplicateCodeError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API 응답 에러를 처리하고 ApiError를 throw
|
* API 응답 에러를 처리하고 ApiError를 throw
|
||||||
* @param response - fetch Response 객체
|
* @param response - fetch Response 객체
|
||||||
@@ -25,9 +41,15 @@ export const handleApiError = async (response: Response): Promise<never> => {
|
|||||||
const data = await response.json().catch(() => ({}));
|
const data = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
// 401 Unauthorized - 토큰 만료 또는 인증 실패
|
// 401 Unauthorized - 토큰 만료 또는 인증 실패
|
||||||
// ✅ 자동 리다이렉트 제거: 각 페이지에서 에러를 직접 처리하도록 변경
|
// ✅ 자동으로 로그인 페이지로 리다이렉트
|
||||||
// 이를 통해 개발자가 Network 탭에서 에러를 확인할 수 있음
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
|
console.warn('⚠️ 401 Unauthorized - 로그인 페이지로 이동합니다.');
|
||||||
|
|
||||||
|
// 클라이언트 사이드에서만 리다이렉트
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
401,
|
401,
|
||||||
data.message || '인증이 필요합니다. 로그인 상태를 확인해주세요.',
|
data.message || '인증이 필요합니다. 로그인 상태를 확인해주세요.',
|
||||||
@@ -44,6 +66,21 @@ export const handleApiError = async (response: Response): Promise<never> => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 400 Bad Request - 품목코드 중복 에러 체크
|
||||||
|
// 백엔드 DuplicateCodeException이 duplicate_id, duplicate_code 반환
|
||||||
|
if (response.status === 400 && data.duplicate_id) {
|
||||||
|
console.warn('⚠️ 품목코드 중복 감지:', {
|
||||||
|
duplicateId: data.duplicate_id,
|
||||||
|
duplicateCode: data.duplicate_code,
|
||||||
|
message: data.message
|
||||||
|
});
|
||||||
|
throw new DuplicateCodeError(
|
||||||
|
data.message || '해당 품목코드가 이미 존재합니다.',
|
||||||
|
data.duplicate_id,
|
||||||
|
data.duplicate_code
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 422 Unprocessable Entity - Validation 에러
|
// 422 Unprocessable Entity - Validation 에러
|
||||||
if (response.status === 422) {
|
if (response.status === 422) {
|
||||||
// 상세 validation 에러 로그 출력
|
// 상세 validation 에러 로그 출력
|
||||||
|
|||||||
@@ -327,6 +327,10 @@ export type ItemFileType = 'specification' | 'certification' | 'bending_diagram'
|
|||||||
|
|
||||||
/** 파일 업로드 옵션 */
|
/** 파일 업로드 옵션 */
|
||||||
export interface UploadFileOptions {
|
export interface UploadFileOptions {
|
||||||
|
/** 필드 키 (백엔드에서 파일 식별용) - 예: specification_file, certification_file, bending_diagram */
|
||||||
|
fieldKey?: string;
|
||||||
|
/** 파일 ID (같은 field_key 내 여러 파일 구분용) - 0, 1, 2... (없으면 최초 등록, 있으면 덮어쓰기) */
|
||||||
|
fileId?: number;
|
||||||
/** 인증번호 (certification 타입일 때) */
|
/** 인증번호 (certification 타입일 때) */
|
||||||
certificationNumber?: string;
|
certificationNumber?: string;
|
||||||
/** 인증 시작일 (certification 타입일 때) */
|
/** 인증 시작일 (certification 타입일 때) */
|
||||||
@@ -384,6 +388,16 @@ export async function uploadItemFile(
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('type', fileType);
|
formData.append('type', fileType);
|
||||||
|
|
||||||
|
// field_key, file_id 추가 (백엔드에서 파일 식별용)
|
||||||
|
// - field_key: 파일 종류 식별자 (예: specification_file, certification_file, bending_diagram)
|
||||||
|
// - file_id: 같은 field_key 내 파일 순번 (없으면 최초 등록, 있으면 해당 파일 덮어쓰기)
|
||||||
|
if (options?.fieldKey) {
|
||||||
|
formData.append('field_key', options.fieldKey);
|
||||||
|
}
|
||||||
|
if (options?.fileId !== undefined) {
|
||||||
|
formData.append('file_id', String(options.fileId));
|
||||||
|
}
|
||||||
|
|
||||||
// certification 관련 추가 필드
|
// certification 관련 추가 필드
|
||||||
if (fileType === 'certification' && options) {
|
if (fileType === 'certification' && options) {
|
||||||
if (options.certificationNumber) {
|
if (options.certificationNumber) {
|
||||||
@@ -398,8 +412,13 @@ export async function uploadItemFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// bending_diagram 관련 추가 필드
|
// bending_diagram 관련 추가 필드
|
||||||
|
// 백엔드가 배열 형태를 기대하므로 각 항목을 개별적으로 append
|
||||||
if (fileType === 'bending_diagram' && options?.bendingDetails) {
|
if (fileType === 'bending_diagram' && options?.bendingDetails) {
|
||||||
formData.append('bending_details', JSON.stringify(options.bendingDetails));
|
options.bendingDetails.forEach((detail, index) => {
|
||||||
|
Object.entries(detail).forEach(([key, value]) => {
|
||||||
|
formData.append(`bending_details[${index}][${key}]`, String(value));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 프록시 경유: /api/proxy/items/{id}/files → /api/v1/items/{id}/files
|
// 프록시 경유: /api/proxy/items/{id}/files → /api/v1/items/{id}/files
|
||||||
@@ -550,6 +569,84 @@ export async function checkItemCodeAvailability(
|
|||||||
return data.data.available;
|
return data.data.available;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 품목 코드 중복 체크 결과 */
|
||||||
|
export interface DuplicateCheckResult {
|
||||||
|
/** 중복 여부 */
|
||||||
|
isDuplicate: boolean;
|
||||||
|
/** 중복된 품목 ID (중복인 경우에만 존재) */
|
||||||
|
duplicateId?: number;
|
||||||
|
/** 중복된 품목 타입 (중복인 경우에만 존재) */
|
||||||
|
duplicateItemType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 품목 코드 중복 체크 (ID 반환)
|
||||||
|
*
|
||||||
|
* GET /api/v1/items/code/{code} API를 활용하여 중복 체크
|
||||||
|
* - 200 OK: 중복 있음 (해당 품목의 id 반환)
|
||||||
|
* - 404 Not Found: 중복 없음
|
||||||
|
*
|
||||||
|
* @param itemCode - 체크할 품목 코드
|
||||||
|
* @param excludeId - 제외할 품목 ID (수정 시 자기 자신 제외)
|
||||||
|
* @returns 중복 체크 결과 (중복 여부 + 중복 품목 ID)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 등록 시
|
||||||
|
* const result = await checkItemCodeDuplicate('PT-ASM-001');
|
||||||
|
* if (result.isDuplicate) {
|
||||||
|
* // 중복! result.duplicateId로 수정 페이지 이동 가능
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // 수정 시 (자기 자신 제외)
|
||||||
|
* const result = await checkItemCodeDuplicate('PT-ASM-001', currentItemId);
|
||||||
|
*/
|
||||||
|
export async function checkItemCodeDuplicate(
|
||||||
|
itemCode: string,
|
||||||
|
excludeId?: number
|
||||||
|
): Promise<DuplicateCheckResult> {
|
||||||
|
try {
|
||||||
|
// 프록시 경유: /api/proxy/items/code/{code} → /api/v1/items/code/{code}
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/proxy/items/code/${encodeURIComponent(itemCode)}`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
// 404: 해당 코드의 품목이 없음 → 중복 아님
|
||||||
|
return { isDuplicate: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// 다른 에러는 중복 아님으로 처리 (안전한 방향)
|
||||||
|
console.warn('[checkItemCodeDuplicate] API 에러:', response.status);
|
||||||
|
return { isDuplicate: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 200 OK: 해당 코드의 품목이 존재함
|
||||||
|
const data = await response.json();
|
||||||
|
const duplicateItem = data.data;
|
||||||
|
|
||||||
|
// 수정 모드에서 자기 자신인 경우 제외
|
||||||
|
if (excludeId && duplicateItem.id === excludeId) {
|
||||||
|
return { isDuplicate: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDuplicate: true,
|
||||||
|
duplicateId: duplicateItem.id,
|
||||||
|
// 백엔드에서 product_type, item_type, type_code 등 다양한 필드명 사용 가능
|
||||||
|
duplicateItemType: duplicateItem.product_type || duplicateItem.item_type || duplicateItem.type_code,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[checkItemCodeDuplicate] 에러:', error);
|
||||||
|
// 에러 시 중복 아님으로 처리 (등록/수정 진행 허용)
|
||||||
|
return { isDuplicate: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 다음 품목 코드 생성 (서버에서 자동 생성)
|
* 다음 품목 코드 생성 (서버에서 자동 생성)
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -95,6 +95,28 @@ export interface ItemRevision {
|
|||||||
previousData: any; // 이전 버전의 전체 데이터
|
previousData: any; // 이전 버전의 전체 데이터
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 품목 파일 정보 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 품목 개별 파일 정보
|
||||||
|
* API 응답: files.specification[0], files.certification[0] 등
|
||||||
|
*/
|
||||||
|
export interface ItemFile {
|
||||||
|
id: number; // 파일 ID (file_id로 사용)
|
||||||
|
file_name: string; // 파일명
|
||||||
|
file_path: string; // 파일 경로/URL
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 품목 파일 목록 (타입별 배열)
|
||||||
|
* API 응답의 files 객체 구조
|
||||||
|
*/
|
||||||
|
export interface ItemFiles {
|
||||||
|
bending_diagram?: ItemFile[]; // 전개도 파일들
|
||||||
|
specification?: ItemFile[]; // 시방서 파일들
|
||||||
|
certification?: ItemFile[]; // 인정서 파일들
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 품목 마스터 (메인) =====
|
// ===== 품목 마스터 (메인) =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -173,6 +195,9 @@ export interface ItemMaster {
|
|||||||
certificationFile?: string; // 인정서 파일 URL
|
certificationFile?: string; // 인정서 파일 URL
|
||||||
certificationFileName?: string; // 인정서 파일명
|
certificationFileName?: string; // 인정서 파일명
|
||||||
|
|
||||||
|
// === 파일 정보 (새 API 구조) ===
|
||||||
|
files?: ItemFiles; // 파일 목록 (타입별 배열)
|
||||||
|
|
||||||
// === 메타데이터 ===
|
// === 메타데이터 ===
|
||||||
safetyStock?: number; // 안전재고
|
safetyStock?: number; // 안전재고
|
||||||
leadTime?: number; // 리드타임
|
leadTime?: number; // 리드타임
|
||||||
|
|||||||
Reference in New Issue
Block a user