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