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:
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user