feat: 품목관리 기능 개선 및 문서화 업데이트
- 품목 상세/수정 페이지 파일 다운로드 기능 개선 - DynamicItemForm 파일 업로드 UI/UX 개선 (시방서, 인정서) - BendingDiagramSection 조립/절곡 부품 전개도 통합 - API proxy route 품목 타입별 라우팅 개선 - ItemListClient 파일 다운로드 유틸리티 적용 - 품목코드 중복 체크 및 다이얼로그 추가 문서화: - DynamicItemForm 훅 분리 계획서 추가 (2161줄 → 900줄 목표) - 백엔드 API 마이그레이션 문서 추가 - 대용량 파일 처리 전략 가이드 추가 - 테넌트 데이터 격리 감사 문서 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <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, Pencil } from 'lucide-react';
|
||||
import { Package, Save, X, FileText, Trash2, Download, Pencil, Upload } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -38,6 +38,7 @@ import type { DynamicItemFormProps, DynamicFormData, DynamicSection, DynamicFiel
|
||||
import type { ItemType, BendingDetail } from '@/types/item';
|
||||
import type { ItemFieldResponse } from '@/types/item-master-api';
|
||||
import { uploadItemFile, deleteItemFile, ItemFileType, checkItemCodeDuplicate, DuplicateCheckResult } from '@/lib/api/items';
|
||||
import { downloadFileById } from '@/lib/utils/fileDownload';
|
||||
import { DuplicateCodeError } from '@/lib/api/error-handler';
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -293,43 +294,96 @@ export default function DynamicItemForm({
|
||||
// initialData에서 기존 파일 정보 및 전개도 상세 데이터 로드 (edit 모드)
|
||||
useEffect(() => {
|
||||
if (mode === 'edit' && initialData) {
|
||||
// 새 API 구조: files 객체에서 파일 정보 추출
|
||||
// files 객체에서 파일 정보 추출 (단수: specification_file, certification_file)
|
||||
// 2025-12-15: files가 JSON 문자열로 올 수 있으므로 파싱 처리
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const files = (initialData as any).files as {
|
||||
let filesRaw = (initialData as any).files;
|
||||
|
||||
// JSON 문자열인 경우 파싱
|
||||
if (typeof filesRaw === 'string') {
|
||||
try {
|
||||
filesRaw = JSON.parse(filesRaw);
|
||||
console.log('[DynamicItemForm] files JSON 문자열 파싱 완료');
|
||||
} catch (e) {
|
||||
console.error('[DynamicItemForm] files JSON 파싱 실패:', e);
|
||||
filesRaw = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const files = filesRaw 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 }>;
|
||||
specification_file?: Array<{ id: number; file_name: string; file_path: string }>;
|
||||
certification_file?: Array<{ id: number; file_name: string; file_path: string }>;
|
||||
} | undefined;
|
||||
|
||||
// 전개도 파일 (새 API 구조 우선, 기존 구조 폴백)
|
||||
if (files?.bending_diagram?.[0]) {
|
||||
const bendingFile = files.bending_diagram[0];
|
||||
// 2025-12-15: 파일 로드 디버깅
|
||||
console.log('[DynamicItemForm] 파일 로드 시작');
|
||||
console.log('[DynamicItemForm] initialData.files (raw):', (initialData as any).files);
|
||||
console.log('[DynamicItemForm] filesRaw 타입:', typeof filesRaw);
|
||||
console.log('[DynamicItemForm] files 변수:', files);
|
||||
console.log('[DynamicItemForm] specification_file:', files?.specification_file);
|
||||
console.log('[DynamicItemForm] certification_file:', files?.certification_file);
|
||||
|
||||
// 전개도 파일 (배열의 마지막 파일 = 최신 파일을 가져옴)
|
||||
// 2025-12-15: .at(-1) 대신 slice(-1)[0] 사용 (ES2022 이전 호환성)
|
||||
const bendingFileArr = files?.bending_diagram;
|
||||
const bendingFile = bendingFileArr && bendingFileArr.length > 0
|
||||
? bendingFileArr[bendingFileArr.length - 1]
|
||||
: undefined;
|
||||
if (bendingFile) {
|
||||
console.log('[DynamicItemForm] bendingFile 전체 객체:', bendingFile);
|
||||
console.log('[DynamicItemForm] bendingFile 키 목록:', Object.keys(bendingFile));
|
||||
setExistingBendingDiagram(bendingFile.file_path);
|
||||
setExistingBendingDiagramFileId(bendingFile.id);
|
||||
// API에서 id 또는 file_id로 올 수 있음
|
||||
const bendingFileId = (bendingFile as Record<string, unknown>).id || (bendingFile as Record<string, unknown>).file_id;
|
||||
console.log('[DynamicItemForm] bendingFile ID 추출:', { id: (bendingFile as Record<string, unknown>).id, file_id: (bendingFile as Record<string, unknown>).file_id, final: bendingFileId });
|
||||
setExistingBendingDiagramFileId(bendingFileId as number);
|
||||
} else if (initialData.bending_diagram) {
|
||||
setExistingBendingDiagram(initialData.bending_diagram as string);
|
||||
}
|
||||
|
||||
// 시방서 파일 (새 API 구조 우선, 기존 구조 폴백)
|
||||
if (files?.specification?.[0]) {
|
||||
const specFile = files.specification[0];
|
||||
// 시방서 파일 (배열의 마지막 파일 = 최신 파일을 가져옴)
|
||||
// 2025-12-15: .at(-1) 대신 배열 인덱스 사용 (ES2022 이전 호환성)
|
||||
const specFileArr = files?.specification_file;
|
||||
const specFile = specFileArr && specFileArr.length > 0
|
||||
? specFileArr[specFileArr.length - 1]
|
||||
: undefined;
|
||||
console.log('[DynamicItemForm] specFile 전체 객체:', specFile);
|
||||
console.log('[DynamicItemForm] specFile 키 목록:', specFile ? Object.keys(specFile) : 'undefined');
|
||||
if (specFile?.file_path) {
|
||||
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) || '시방서');
|
||||
setExistingSpecificationFileName(specFile.file_name || '시방서');
|
||||
// API에서 id 또는 file_id로 올 수 있음
|
||||
const specFileId = (specFile as Record<string, unknown>).id || (specFile as Record<string, unknown>).file_id;
|
||||
console.log('[DynamicItemForm] specFile ID 추출:', { id: (specFile as Record<string, unknown>).id, file_id: (specFile as Record<string, unknown>).file_id, final: specFileId });
|
||||
setExistingSpecificationFileId(specFileId as number || null);
|
||||
} else {
|
||||
// 파일이 없으면 상태 초기화 (이전 값 제거)
|
||||
setExistingSpecificationFile('');
|
||||
setExistingSpecificationFileName('');
|
||||
setExistingSpecificationFileId(null);
|
||||
}
|
||||
|
||||
// 인정서 파일 (새 API 구조 우선, 기존 구조 폴백)
|
||||
if (files?.certification?.[0]) {
|
||||
const certFile = files.certification[0];
|
||||
// 인정서 파일 (배열의 마지막 파일 = 최신 파일을 가져옴)
|
||||
// 2025-12-15: .at(-1) 대신 배열 인덱스 사용 (ES2022 이전 호환성)
|
||||
const certFileArr = files?.certification_file;
|
||||
const certFile = certFileArr && certFileArr.length > 0
|
||||
? certFileArr[certFileArr.length - 1]
|
||||
: undefined;
|
||||
console.log('[DynamicItemForm] certFile 전체 객체:', certFile);
|
||||
console.log('[DynamicItemForm] certFile 키 목록:', certFile ? Object.keys(certFile) : 'undefined');
|
||||
if (certFile?.file_path) {
|
||||
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) || '인정서');
|
||||
setExistingCertificationFileName(certFile.file_name || '인정서');
|
||||
// API에서 id 또는 file_id로 올 수 있음
|
||||
const certFileId = (certFile as Record<string, unknown>).id || (certFile as Record<string, unknown>).file_id;
|
||||
console.log('[DynamicItemForm] certFile ID 추출:', { id: (certFile as Record<string, unknown>).id, file_id: (certFile as Record<string, unknown>).file_id, final: certFileId });
|
||||
setExistingCertificationFileId(certFileId as number || null);
|
||||
} else {
|
||||
// 파일이 없으면 상태 초기화 (이전 값 제거)
|
||||
setExistingCertificationFile('');
|
||||
setExistingCertificationFileName('');
|
||||
setExistingCertificationFileId(null);
|
||||
}
|
||||
|
||||
// 전개도 상세 데이터 로드 (bending_details)
|
||||
@@ -342,15 +396,18 @@ export default function DynamicItemForm({
|
||||
|
||||
if (details.length > 0) {
|
||||
// BendingDetail 형식으로 변환
|
||||
// 2025-12-16: 명시적 Number() 변환 추가 - TypeScript 타입 캐스팅은 런타임 변환을 하지 않음
|
||||
// 백엔드에서 문자열로 올 수 있으므로 명시적 숫자 변환 필수
|
||||
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,
|
||||
no: Number(d.no) || index + 1,
|
||||
input: Number(d.input) || 0,
|
||||
// elongation은 0이 유효한 값이므로 NaN 체크 필요
|
||||
elongation: !isNaN(Number(d.elongation)) ? Number(d.elongation) : -1,
|
||||
calculated: Number(d.calculated) || 0,
|
||||
sum: Number(d.sum) || 0,
|
||||
shaded: Boolean(d.shaded),
|
||||
aAngle: d.aAngle !== undefined ? Number(d.aAngle) : undefined,
|
||||
}));
|
||||
setBendingDetails(mappedDetails);
|
||||
|
||||
@@ -373,19 +430,49 @@ export default function DynamicItemForm({
|
||||
}
|
||||
}, [mode, initialBomLines]);
|
||||
|
||||
// Storage 경로를 전체 URL로 변환
|
||||
const getStorageUrl = (path: string | undefined): string | null => {
|
||||
if (!path) return null;
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return path;
|
||||
// 파일 다운로드 핸들러 (Blob 방식)
|
||||
const handleFileDownload = async (fileId: number | null, fileName?: string) => {
|
||||
if (!fileId) return;
|
||||
try {
|
||||
await downloadFileById(fileId, fileName);
|
||||
} catch (error) {
|
||||
console.error('[DynamicItemForm] 다운로드 실패:', error);
|
||||
alert('파일 다운로드에 실패했습니다.');
|
||||
}
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
return `${apiUrl}/storage/${path}`;
|
||||
};
|
||||
|
||||
// 파일 삭제 핸들러
|
||||
const handleDeleteFile = async (fileType: ItemFileType) => {
|
||||
if (!propItemId) return;
|
||||
console.log('[DynamicItemForm] handleDeleteFile 호출:', {
|
||||
fileType,
|
||||
propItemId,
|
||||
existingBendingDiagramFileId,
|
||||
existingSpecificationFileId,
|
||||
existingCertificationFileId,
|
||||
});
|
||||
|
||||
if (!propItemId) {
|
||||
console.error('[DynamicItemForm] propItemId가 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 ID 가져오기
|
||||
let fileId: number | null = null;
|
||||
if (fileType === 'bending_diagram') {
|
||||
fileId = existingBendingDiagramFileId;
|
||||
} else if (fileType === 'specification') {
|
||||
fileId = existingSpecificationFileId;
|
||||
} else if (fileType === 'certification') {
|
||||
fileId = existingCertificationFileId;
|
||||
}
|
||||
|
||||
console.log('[DynamicItemForm] 삭제할 파일 ID:', fileId);
|
||||
|
||||
if (!fileId) {
|
||||
console.error('[DynamicItemForm] 파일 ID를 찾을 수 없습니다:', fileType);
|
||||
alert('파일 ID를 찾을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmMessage = fileType === 'bending_diagram' ? '전개도 이미지를' :
|
||||
fileType === 'specification' ? '시방서 파일을' : '인정서 파일을';
|
||||
@@ -394,18 +481,21 @@ export default function DynamicItemForm({
|
||||
|
||||
try {
|
||||
setIsDeletingFile(fileType);
|
||||
await deleteItemFile(propItemId, fileType);
|
||||
await deleteItemFile(propItemId, fileId, selectedItemType || 'FG');
|
||||
|
||||
// 상태 업데이트
|
||||
if (fileType === 'bending_diagram') {
|
||||
setExistingBendingDiagram('');
|
||||
setBendingDiagram('');
|
||||
setExistingBendingDiagramFileId(null);
|
||||
} else if (fileType === 'specification') {
|
||||
setExistingSpecificationFile('');
|
||||
setExistingSpecificationFileName('');
|
||||
setExistingSpecificationFileId(null);
|
||||
} else if (fileType === 'certification') {
|
||||
setExistingCertificationFile('');
|
||||
setExistingCertificationFileName('');
|
||||
setExistingCertificationFileId(null);
|
||||
}
|
||||
|
||||
alert('파일이 삭제되었습니다.');
|
||||
@@ -896,6 +986,35 @@ export default function DynamicItemForm({
|
||||
};
|
||||
}, [structure, selectedItemType, isBendingPart, formData, itemNameKey]);
|
||||
|
||||
// 2025-12-16: bendingDetails 로드 후 폭 합계를 formData에 동기화
|
||||
// bendingFieldKeys.widthSum이 결정된 후에 실행되어야 함
|
||||
const bendingWidthSumSyncedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
// edit 모드이고, bendingDetails가 있고, widthSum 필드 키가 결정되었을 때만 실행
|
||||
if (mode !== 'edit' || bendingDetails.length === 0 || !bendingFieldKeys.widthSum) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미 동기화했으면 스킵 (중복 실행 방지)
|
||||
if (bendingWidthSumSyncedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const totalSum = bendingDetails.reduce((acc, detail) => {
|
||||
return acc + detail.input + detail.elongation;
|
||||
}, 0);
|
||||
|
||||
const sumString = totalSum.toString();
|
||||
console.log('[DynamicItemForm] bendingDetails 폭 합계 → formData 동기화:', {
|
||||
widthSumKey: bendingFieldKeys.widthSum,
|
||||
totalSum,
|
||||
bendingDetailsCount: bendingDetails.length,
|
||||
});
|
||||
|
||||
setFieldValue(bendingFieldKeys.widthSum, sumString);
|
||||
bendingWidthSumSyncedRef.current = true;
|
||||
}, [mode, bendingDetails, bendingFieldKeys.widthSum, setFieldValue]);
|
||||
|
||||
// 2025-12-04: 품목명 변경 시 종류 필드 값 초기화
|
||||
// 품목명(A)→종류(A1) 선택 후 품목명(B)로 변경 시, 이전 종류(A1) 값이 남아있어서
|
||||
// 새 종류(B1) 선택해도 이전 값이 품목코드에 적용되는 버그 수정
|
||||
@@ -1206,7 +1325,8 @@ export default function DynamicItemForm({
|
||||
console.log('[DynamicItemForm] 전개도 파일 업로드 시작:', bendingDiagramFile.name);
|
||||
await uploadItemFile(itemId, bendingDiagramFile, 'bending_diagram', {
|
||||
fieldKey: 'bending_diagram',
|
||||
fileId: 0,
|
||||
// 수정 모드: 기존 파일 ID가 있으면 덮어쓰기, 없으면 새 파일 등록
|
||||
fileId: existingBendingDiagramFileId ?? undefined,
|
||||
bendingDetails: bendingDetails.length > 0 ? bendingDetails.map(d => ({
|
||||
angle: d.aAngle || 0,
|
||||
length: d.input || 0,
|
||||
@@ -1226,7 +1346,8 @@ export default function DynamicItemForm({
|
||||
console.log('[DynamicItemForm] 시방서 파일 업로드 시작:', specificationFile.name);
|
||||
await uploadItemFile(itemId, specificationFile, 'specification', {
|
||||
fieldKey: 'specification_file',
|
||||
fileId: 0,
|
||||
// 수정 모드: 기존 파일 ID가 있으면 덮어쓰기, 없으면 새 파일 등록
|
||||
fileId: existingSpecificationFileId ?? undefined,
|
||||
});
|
||||
console.log('[DynamicItemForm] 시방서 파일 업로드 성공');
|
||||
} catch (error) {
|
||||
@@ -1252,7 +1373,8 @@ export default function DynamicItemForm({
|
||||
|
||||
await uploadItemFile(itemId, certificationFile, 'certification', {
|
||||
fieldKey: 'certification_file',
|
||||
fileId: 0,
|
||||
// 수정 모드: 기존 파일 ID가 있으면 덮어쓰기, 없으면 새 파일 등록
|
||||
fileId: existingCertificationFileId ?? undefined,
|
||||
certificationNumber: certNumber,
|
||||
certificationStartDate: certStartDate,
|
||||
certificationEndDate: certEndDate,
|
||||
@@ -1708,15 +1830,14 @@ export default function DynamicItemForm({
|
||||
<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"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleFileDownload(existingSpecificationFileId, existingSpecificationFileName)}
|
||||
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-4 w-4" />
|
||||
</a>
|
||||
</button>
|
||||
<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"
|
||||
@@ -1747,10 +1868,37 @@ export default function DynamicItemForm({
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : specificationFile ? (
|
||||
/* 새 파일 선택된 경우: 커스텀 UI로 파일명 표시 */
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-1 px-3 py-2 bg-blue-50 rounded-md border border-blue-200 text-sm">
|
||||
<FileText className="h-4 w-4 text-blue-600 shrink-0" />
|
||||
<span className="truncate">{specificationFile.name}</span>
|
||||
<span className="text-xs text-blue-500">(새 파일)</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setSpecificationFile(null)}
|
||||
disabled={isSubmitting}
|
||||
className="h-9 w-9 text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
title="취소"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{/* 새 파일 업로드 (기존 파일 없거나, 새 파일 선택 중) */}
|
||||
<Input
|
||||
/* 파일 없는 경우: 파일 선택 버튼 */
|
||||
<div className="flex items-center gap-2">
|
||||
<label
|
||||
htmlFor="specification_file"
|
||||
className="flex items-center gap-2 flex-1 px-3 py-2 bg-gray-50 rounded-md border text-sm cursor-pointer hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<Upload className="h-4 w-4 text-gray-500 shrink-0" />
|
||||
<span className="text-gray-500">PDF 파일을 선택하세요</span>
|
||||
</label>
|
||||
<input
|
||||
id="specification_file"
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
@@ -1759,13 +1907,8 @@ export default function DynamicItemForm({
|
||||
setSpecificationFile(file);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="cursor-pointer"
|
||||
className="hidden"
|
||||
/>
|
||||
{specificationFile && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
선택된 파일: {specificationFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1781,15 +1924,14 @@ export default function DynamicItemForm({
|
||||
<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"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleFileDownload(existingCertificationFileId, existingCertificationFileName)}
|
||||
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-4 w-4" />
|
||||
</a>
|
||||
</button>
|
||||
<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"
|
||||
@@ -1820,10 +1962,37 @@ export default function DynamicItemForm({
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : certificationFile ? (
|
||||
/* 새 파일 선택된 경우: 커스텀 UI로 파일명 표시 */
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-1 px-3 py-2 bg-green-50 rounded-md border border-green-200 text-sm">
|
||||
<FileText className="h-4 w-4 text-green-600 shrink-0" />
|
||||
<span className="truncate">{certificationFile.name}</span>
|
||||
<span className="text-xs text-green-500">(새 파일)</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setCertificationFile(null)}
|
||||
disabled={isSubmitting}
|
||||
className="h-9 w-9 text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
title="취소"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{/* 새 파일 업로드 (기존 파일 없거나, 새 파일 선택 중) */}
|
||||
<Input
|
||||
/* 파일 없는 경우: 파일 선택 버튼 */
|
||||
<div className="flex items-center gap-2">
|
||||
<label
|
||||
htmlFor="certification_file"
|
||||
className="flex items-center gap-2 flex-1 px-3 py-2 bg-gray-50 rounded-md border text-sm cursor-pointer hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<Upload className="h-4 w-4 text-gray-500 shrink-0" />
|
||||
<span className="text-gray-500">PDF 파일을 선택하세요</span>
|
||||
</label>
|
||||
<input
|
||||
id="certification_file"
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
@@ -1832,13 +2001,8 @@ export default function DynamicItemForm({
|
||||
setCertificationFile(file);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="cursor-pointer"
|
||||
className="hidden"
|
||||
/>
|
||||
{certificationFile && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
선택된 파일: {certificationFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1937,6 +2101,10 @@ export default function DynamicItemForm({
|
||||
widthSumFieldKey={bendingFieldKeys.widthSum}
|
||||
setValue={(key, value) => setFieldValue(key, value)}
|
||||
isSubmitting={isSubmitting}
|
||||
existingBendingDiagram={existingBendingDiagram}
|
||||
existingBendingDiagramFileId={existingBendingDiagramFileId}
|
||||
onDeleteExistingFile={() => handleDeleteFile('bending_diagram')}
|
||||
isDeletingFile={isDeletingFile === 'bending_diagram'}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1956,6 +2124,10 @@ export default function DynamicItemForm({
|
||||
widthSumFieldKey={bendingFieldKeys.widthSum}
|
||||
setValue={(key, value) => setFieldValue(key, value)}
|
||||
isSubmitting={isSubmitting}
|
||||
existingBendingDiagram={existingBendingDiagram}
|
||||
existingBendingDiagramFileId={existingBendingDiagramFileId}
|
||||
onDeleteExistingFile={() => handleDeleteFile('bending_diagram')}
|
||||
isDeletingFile={isDeletingFile === 'bending_diagram'}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ export default function DynamicBOMSection({
|
||||
const params = new URLSearchParams();
|
||||
params.append('search', query);
|
||||
params.append('size', '20');
|
||||
params.append('group_id', '1'); // 전체 품목 조회 (품목관리 그룹)
|
||||
|
||||
const response = await fetch(`/api/proxy/items?${params.toString()}`);
|
||||
const result = await response.json();
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { ArrowLeft, Edit, Package, FileImage, Download, FileText, Check, Calendar } from 'lucide-react';
|
||||
import { downloadFileById } from '@/lib/utils/fileDownload';
|
||||
|
||||
interface ItemDetailClientProps {
|
||||
item: ItemMaster;
|
||||
@@ -61,7 +62,20 @@ function formatItemCodeForAssembly(item: ItemMaster): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage 경로를 전체 URL로 변환
|
||||
* 파일 다운로드 핸들러 (Blob 방식)
|
||||
*/
|
||||
async function handleFileDownload(fileId: number | undefined, fileName?: string): Promise<void> {
|
||||
if (!fileId) return;
|
||||
try {
|
||||
await downloadFileById(fileId, fileName);
|
||||
} catch (error) {
|
||||
console.error('[ItemDetailClient] 다운로드 실패:', error);
|
||||
alert('파일 다운로드에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage 경로를 전체 URL로 변환 (전개도 이미지용)
|
||||
* - 이미 전체 URL인 경우 그대로 반환
|
||||
* - 상대 경로인 경우 API URL + /storage/ 붙여서 반환
|
||||
*/
|
||||
@@ -371,18 +385,42 @@ export default function ItemDetailClient({ item }: ItemDetailClientProps) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 md:space-y-6 pt-0">
|
||||
{/* 전개도 이미지 */}
|
||||
{item.bendingDiagram ? (
|
||||
{/* 전개도 이미지 - 파일 ID가 있으면 프록시로 로드 */}
|
||||
{(item.bendingDiagramFileId || item.bendingDiagram) ? (
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs md:text-sm">전개도 이미지</Label>
|
||||
<div className="mt-2 p-2 md:p-4 border rounded-lg bg-gray-50">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getStorageUrl(item.bendingDiagram) || ''}
|
||||
src={item.bendingDiagramFileId
|
||||
? `/api/proxy/files/${item.bendingDiagramFileId}/download`
|
||||
: getStorageUrl(item.bendingDiagram) || ''
|
||||
}
|
||||
alt="전개도"
|
||||
className="max-w-full h-auto max-h-64 md:max-h-96 mx-auto border rounded"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
target.parentElement?.insertAdjacentHTML(
|
||||
'beforeend',
|
||||
'<p class="text-center text-gray-500 py-8">이미지를 불러올 수 없습니다</p>'
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{item.bendingDiagramFileId && (
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleFileDownload(item.bendingDiagramFileId, '전개도_이미지')}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
다운로드
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-6 md:py-8 text-xs md:text-sm text-muted-foreground border rounded-lg bg-gray-50">
|
||||
@@ -496,15 +534,14 @@ export default function ItemDetailClient({ item }: ItemDetailClientProps) {
|
||||
<span className="text-sm truncate flex-1">
|
||||
{item.specificationFileName || '시방서 파일'}
|
||||
</span>
|
||||
<a
|
||||
href={getStorageUrl(item.specificationFile) || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleFileDownload(item.specificationFileId, item.specificationFileName)}
|
||||
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
다운로드
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-muted-foreground">등록된 시방서가 없습니다.</p>
|
||||
@@ -520,15 +557,14 @@ export default function ItemDetailClient({ item }: ItemDetailClientProps) {
|
||||
<span className="text-sm truncate flex-1">
|
||||
{item.certificationFileName || '인정서 파일'}
|
||||
</span>
|
||||
<a
|
||||
href={getStorageUrl(item.certificationFile) || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleFileDownload(item.certificationFileId, item.certificationFileName)}
|
||||
className="inline-flex items-center gap-1 text-sm text-green-600 hover:text-green-800"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
다운로드
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-muted-foreground">등록된 인정서가 없습니다.</p>
|
||||
|
||||
@@ -6,10 +6,11 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { FileImage, Plus, Trash2, X } from 'lucide-react';
|
||||
import { FileImage, Plus, Trash2, X, Download, Loader2 } from 'lucide-react';
|
||||
import type { BendingDetail } from '@/types/item';
|
||||
import type { UseFormSetValue } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
import { downloadFileById } from '@/lib/utils/fileDownload';
|
||||
|
||||
export interface BendingDiagramSectionProps {
|
||||
selectedPartType: string;
|
||||
@@ -26,6 +27,16 @@ export interface BendingDiagramSectionProps {
|
||||
widthSumFieldKey?: string;
|
||||
setValue: UseFormSetValue<CreateItemFormData>;
|
||||
isSubmitting: boolean;
|
||||
/** 기존 전개도 이미지 URL (수정 모드) */
|
||||
existingBendingDiagram?: string;
|
||||
/** 기존 전개도 파일명 */
|
||||
existingBendingDiagramFileName?: string;
|
||||
/** 기존 전개도 파일 ID (삭제용) */
|
||||
existingBendingDiagramFileId?: number | null;
|
||||
/** 기존 파일 삭제 콜백 */
|
||||
onDeleteExistingFile?: () => void;
|
||||
/** 파일 삭제 중 상태 */
|
||||
isDeletingFile?: boolean;
|
||||
}
|
||||
|
||||
export default function BendingDiagramSection({
|
||||
@@ -42,7 +53,27 @@ export default function BendingDiagramSection({
|
||||
widthSumFieldKey,
|
||||
setValue,
|
||||
isSubmitting,
|
||||
existingBendingDiagram,
|
||||
existingBendingDiagramFileName,
|
||||
existingBendingDiagramFileId,
|
||||
onDeleteExistingFile,
|
||||
isDeletingFile,
|
||||
}: BendingDiagramSectionProps) {
|
||||
// 기존 파일 다운로드 핸들러
|
||||
const handleDownloadExistingFile = async () => {
|
||||
if (!existingBendingDiagramFileId) {
|
||||
alert('파일 ID가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileName = existingBendingDiagramFileName || '전개도_이미지';
|
||||
await downloadFileById(existingBendingDiagramFileId, fileName);
|
||||
} catch (error) {
|
||||
console.error('[BendingDiagramSection] 파일 다운로드 실패:', error);
|
||||
alert('파일 다운로드에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
// 폭 합계 업데이트 헬퍼
|
||||
const updateWidthSum = (details: BendingDetail[]) => {
|
||||
const totalSum = details.reduce((acc, d) => {
|
||||
@@ -108,6 +139,76 @@ export default function BendingDiagramSection({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 기존 전개도 이미지 (수정 모드) - 파일 ID가 있으면 프록시로 이미지 로드 */}
|
||||
{existingBendingDiagramFileId && !bendingDiagram && (
|
||||
<div className="p-4 border rounded-lg bg-blue-50 border-blue-200">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileImage className="h-5 w-5 text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-900">기존 전개도 이미지</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadExistingFile}
|
||||
className="text-blue-600 border-blue-300 hover:bg-blue-100"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
다운로드
|
||||
</Button>
|
||||
{onDeleteExistingFile && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onDeleteExistingFile}
|
||||
disabled={isDeletingFile}
|
||||
className="text-red-600 border-red-300 hover:bg-red-50"
|
||||
>
|
||||
{isDeletingFile ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
삭제 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
삭제
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{existingBendingDiagramFileName && (
|
||||
<p className="text-xs text-blue-700 mb-2">
|
||||
파일명: {existingBendingDiagramFileName}
|
||||
</p>
|
||||
)}
|
||||
<div className="border rounded bg-white p-2">
|
||||
<img
|
||||
src={`/api/proxy/files/${existingBendingDiagramFileId}/download`}
|
||||
alt="기존 전개도"
|
||||
className="max-w-full h-auto max-h-96 mx-auto"
|
||||
onError={(e) => {
|
||||
// 이미지 로드 실패 시 대체 텍스트 표시
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
target.parentElement?.insertAdjacentHTML(
|
||||
'beforeend',
|
||||
'<p class="text-center text-gray-500 py-8">이미지를 불러올 수 없습니다</p>'
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-blue-700 mt-2">
|
||||
* 새 파일을 업로드하면 기존 파일이 교체됩니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 파일 선택 방식 */}
|
||||
{bendingDiagramInputMethod === 'file' && (
|
||||
<div>
|
||||
|
||||
@@ -174,15 +174,11 @@ export default function ItemListClient() {
|
||||
try {
|
||||
console.log('[Delete] 삭제 요청:', itemToDelete);
|
||||
|
||||
// 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}?item_type=${itemToDelete.itemType}`
|
||||
: `/api/proxy/items/${itemToDelete.id}`;
|
||||
// 2025-12-15: 백엔드 동적 테이블 라우팅 - 모든 품목이 /items 엔드포인트 사용
|
||||
// /products/materials 라우트 삭제됨
|
||||
const deleteUrl = `/api/proxy/items/${itemToDelete.id}?item_type=${itemToDelete.itemType}`;
|
||||
|
||||
console.log('[Delete] URL:', deleteUrl, '(isMaterial:', isMaterial, ', itemType:', itemToDelete.itemType, ')');
|
||||
console.log('[Delete] URL:', deleteUrl, '(itemType:', itemToDelete.itemType, ')');
|
||||
|
||||
const response = await fetch(deleteUrl, {
|
||||
method: 'DELETE',
|
||||
@@ -230,7 +226,7 @@ export default function ItemListClient() {
|
||||
};
|
||||
|
||||
// 일괄 삭제 핸들러
|
||||
// 2025-12-10: item_type 파라미터를 실제 코드(SM, RM, CS)로 전달
|
||||
// 2025-12-15: 백엔드 동적 테이블 라우팅으로 모든 품목에 item_type 필수
|
||||
const handleBulkDelete = async () => {
|
||||
const itemIds = Array.from(selectedItems);
|
||||
let successCount = 0;
|
||||
@@ -240,11 +236,8 @@ export default function ItemListClient() {
|
||||
try {
|
||||
// 해당 품목의 itemType 찾기
|
||||
const item = items.find((i) => i.id === id);
|
||||
const isMaterial = item ? MATERIAL_TYPES.includes(item.itemType) : false;
|
||||
// Materials는 /products/materials 엔드포인트 + item_type, Products는 /items 엔드포인트
|
||||
const deleteUrl = isMaterial
|
||||
? `/api/proxy/products/materials/${id}?item_type=${item?.itemType}`
|
||||
: `/api/proxy/items/${id}`;
|
||||
// 2025-12-15: 백엔드 동적 테이블 라우팅 - 모든 품목이 /items 엔드포인트 사용
|
||||
const deleteUrl = `/api/proxy/items/${id}?item_type=${item?.itemType}`;
|
||||
|
||||
const response = await fetch(deleteUrl, {
|
||||
method: 'DELETE',
|
||||
|
||||
Reference in New Issue
Block a user