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:
byeongcheolryu
2025-12-16 11:01:25 +09:00
parent 8457dba0fc
commit b1587071f2
25 changed files with 3905 additions and 183 deletions

View File

@@ -79,6 +79,7 @@ function mapApiResponseToFormData(data: ItemApiResponse): DynamicFormData {
'id', 'tenant_id', 'category_id', 'category',
'created_at', 'updated_at', 'deleted_at',
'component_lines', 'bom',
'details', // details는 아래에서 펼쳐서 추가
];
// 백엔드 응답의 모든 필드를 그대로 복사
@@ -88,6 +89,18 @@ function mapApiResponseToFormData(data: ItemApiResponse): DynamicFormData {
}
});
// details 객체가 있으면 펼쳐서 추가 (item_details 테이블 필드)
// 2025-12-16: details 내의 최신 값을 최상위로 매핑
const details = (data as Record<string, unknown>).details as Record<string, unknown> | undefined;
if (details && typeof details === 'object') {
const detailExcludeKeys = ['id', 'item_id', 'created_at', 'updated_at'];
Object.entries(details).forEach(([key, value]) => {
if (!detailExcludeKeys.includes(key) && value !== null && value !== undefined) {
formData[key] = value as DynamicFormData[string];
}
});
}
// attributes 객체가 있으면 펼쳐서 추가 (조립부품 등의 동적 필드)
const attributes = (data.attributes || {}) as Record<string, unknown>;
Object.entries(attributes).forEach(([key, value]) => {
@@ -102,9 +115,14 @@ function mapApiResponseToFormData(data: ItemApiResponse): DynamicFormData {
// Material(SM, RM, CS) options 필드 매핑
// 백엔드에서 options: [{label: "standard_1", value: "옵션값"}, ...] 형태로 저장됨
// 프론트엔드 폼에서는 standard_1: "옵션값" 형태로 사용
// 2025-12-16: item_details 테이블 필드는 제외 (details에서 이미 매핑됨, options의 오래된 값이 덮어쓰는 버그 방지)
const detailsFieldsInOptions = [
'files', 'bending_details', 'bending_diagram',
'specification_file', 'certification_file',
];
if (data.options && Array.isArray(data.options)) {
(data.options as Array<{ label: string; value: string }>).forEach((opt) => {
if (opt.label && opt.value) {
if (opt.label && opt.value && !detailsFieldsInOptions.includes(opt.label)) {
formData[opt.label] = opt.value;
}
});
@@ -158,17 +176,16 @@ export default function EditItemPage() {
return;
}
// 2025-12-10: item_type 파라미터를 실제 코드(SM, RM, CS)로 전달
// 2025-12-15: 백엔드에서 id만으로 조회 가능 (item_type 불필요)
const isMaterial = isMaterialType(urlItemType);
const queryParams = new URLSearchParams();
if (isMaterial) {
queryParams.append('item_type', urlItemType); // SM, RM, CS 그대로 전달
} else {
if (!isMaterial) {
queryParams.append('include_bom', 'true');
}
console.log('[EditItem] Fetching:', { urlItemId, urlItemType, isMaterial });
response = await fetch(`/api/proxy/items/${urlItemId}?${queryParams.toString()}`);
const queryString = queryParams.toString();
response = await fetch(`/api/proxy/items/${urlItemId}${queryString ? `?${queryString}` : ''}`);
if (!response.ok) {
if (response.status === 404) {
@@ -191,6 +208,7 @@ export default function EditItemPage() {
console.log('specification:', apiData.specification);
console.log('unit:', apiData.unit);
console.log('is_active:', apiData.is_active);
console.log('files:', (apiData as any).files); // 파일 데이터 확인
console.log('전체:', apiData);
console.log('==============================================================');
@@ -207,30 +225,43 @@ export default function EditItemPage() {
console.log('specification:', formData['specification']);
console.log('unit:', formData['unit']);
console.log('is_active:', formData['is_active']);
console.log('files:', formData['files']); // 파일 데이터 확인
console.log('전체:', formData);
console.log('==========================================================');
setInitialData(formData);
// BOM 데이터 별도 처리 (백엔드 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);
// BOM 데이터 별도 API 호출 (expandBomItems로 품목 정보 포함)
// GET /api/proxy/items/{id}/bom - 품목 정보가 확장된 BOM 데이터 반환
if (!isMaterialType(urlItemType)) {
try {
const bomResponse = await fetch(`/api/proxy/items/${urlItemId}/bom`);
const bomResult = await bomResponse.json();
if (bomResult.success && bomResult.data && Array.isArray(bomResult.data)) {
const expandedBomData = bomResult.data as Array<Record<string, unknown>>;
const mappedBomLines: BOMLine[] = expandedBomData.map((b, index) => ({
id: (b.id as string) || `bom-${Date.now()}-${index}`,
childItemId: b.child_item_id ? String(b.child_item_id) : undefined,
childItemType: (b.child_item_type as 'PRODUCT' | 'MATERIAL') || 'PRODUCT',
childItemCode: (b.child_item_code as string) || '',
childItemName: (b.child_item_name as string) || '',
specification: (b.specification as string) || '',
material: (b.material as string) || '',
quantity: (b.quantity as number) ?? 1,
unit: (b.unit as string) || 'EA',
unitPrice: (b.unit_price as number) ?? 0,
note: (b.note as string) || '',
isBending: (b.is_bending as boolean) ?? false,
bendingDiagram: (b.bending_diagram as string) || undefined,
}));
setInitialBomLines(mappedBomLines);
console.log('[EditItem] BOM 데이터 로드 (expanded):', mappedBomLines.length, '건', mappedBomLines);
}
} catch (bomErr) {
console.error('[EditItem] BOM 조회 실패:', bomErr);
}
}
} else {
setError(result.message || '품목 정보를 불러올 수 없습니다.');
@@ -271,10 +302,11 @@ export default function EditItemPage() {
// console.log('itemType:', itemType, 'isMaterial:', isMaterial);
// console.log('data:', JSON.stringify(data, null, 2));
// console.log('====================================================');
const updateUrl = isMaterial
? `/api/proxy/products/materials/${itemId}`
: `/api/proxy/items/${itemId}`;
const method = isMaterial ? 'PATCH' : 'PUT';
// 2025-12-15: 백엔드 동적 테이블 라우팅 - 모든 품목이 /items 엔드포인트 사용
// /products/materials 라우트 삭제됨 (products/materials 테이블 삭제)
const updateUrl = `/api/proxy/items/${itemId}?item_type=${itemType}`;
const method = 'PUT';
// console.log('[EditItem] Update URL:', updateUrl, '(method:', method, ', isMaterial:', isMaterial, ')');
@@ -282,7 +314,8 @@ export default function EditItemPage() {
// - FG(제품): 품목코드 = 품목명
// - PT(부품): DynamicItemForm에서 자동계산한 code 사용 (조립/절곡/구매 각각 다른 규칙)
// - Material(SM, RM, CS): material_code = 품목명-규격
let submitData = { ...data };
// 2025-12-15: item_type은 Request Body에서 필수 (ItemUpdateRequest validation)
let submitData = { ...data, item_type: itemType };
if (itemType === 'FG') {
// FG는 품목명이 품목코드가 되므로 name 값으로 code 설정

View File

@@ -22,6 +22,12 @@ const MATERIAL_TYPES = ['SM', 'RM', 'CS'];
function mapApiResponseToItemMaster(data: Record<string, unknown>): ItemMaster {
// attributes 객체 추출 (조립부품 등의 동적 필드가 여기에 저장됨)
const attributes = (data.attributes || {}) as Record<string, unknown>;
// details 객체 추출 (PT 부품의 상세 정보가 여기에 저장됨)
const details = (data.details || {}) as Record<string, unknown>;
console.log('[mapApiResponseToItemMaster] data.details:', data.details);
console.log('[mapApiResponseToItemMaster] details.part_type:', details.part_type);
console.log('[mapApiResponseToItemMaster] details.bending_details:', details.bending_details);
return {
id: String(data.id || ''),
@@ -53,9 +59,9 @@ function mapApiResponseToItemMaster(data: Record<string, unknown>): ItemMaster {
isFinal: Boolean(data.is_final ?? false),
createdAt: String(data.created_at || data.createdAt || ''),
updatedAt: data.updated_at ? String(data.updated_at) : undefined,
// 부품 관련 - data attributes 둘 다에서 찾음
partType: (data.part_type || attributes.part_type) ? ((data.part_type || attributes.part_type) as PartType) : undefined,
partUsage: (data.part_usage || attributes.part_usage) ? ((data.part_usage || attributes.part_usage) as PartUsage) : undefined,
// 부품 관련 - details, data, attributes 순으로 찾음
partType: (details.part_type || data.part_type || attributes.part_type) ? ((details.part_type || data.part_type || attributes.part_type) as PartType) : undefined,
partUsage: (details.part_usage || data.part_usage || attributes.part_usage) ? ((details.part_usage || data.part_usage || attributes.part_usage) as PartUsage) : undefined,
installationType: (data.installation_type || attributes.installation_type) ? String(data.installation_type || attributes.installation_type) : undefined,
assemblyType: (data.assembly_type || attributes.assembly_type) ? String(data.assembly_type || attributes.assembly_type) : undefined,
assemblyLength: (data.assembly_length || attributes.assembly_length || attributes.length) ? String(data.assembly_length || attributes.assembly_length || attributes.length) : undefined,
@@ -77,13 +83,60 @@ function mapApiResponseToItemMaster(data: Record<string, unknown>): ItemMaster {
isBending: Boolean(bomItem.is_bending ?? false),
})) : undefined,
// 파일 관련 필드 (PT - 절곡/조립 부품)
bendingDiagram: data.bending_diagram ? String(data.bending_diagram) : undefined,
bendingDetails: Array.isArray(data.bending_details) ? data.bending_details : undefined,
// 파일 관련 필드 (FG - 제품)
specificationFile: data.specification_file ? String(data.specification_file) : undefined,
specificationFileName: data.specification_file_name ? String(data.specification_file_name) : undefined,
certificationFile: data.certification_file ? String(data.certification_file) : undefined,
certificationFileName: data.certification_file_name ? String(data.certification_file_name) : undefined,
// bending_diagram: data 또는 attributes에서 찾음
bendingDiagram: (() => {
const diagram = data.bending_diagram || attributes.bending_diagram;
return diagram ? String(diagram) : undefined;
})(),
// bending_diagram 파일 ID (프록시 이미지 로드용)
bendingDiagramFileId: (() => {
const files = data.files as { bending_diagram?: Array<{ id: number }> } | undefined;
const arr = files?.bending_diagram;
if (arr && arr.length > 0) return arr[arr.length - 1].id;
return undefined;
})(),
// bending_details: details.bending_details에서 찾음 (API 응답 구조)
bendingDetails: (() => {
const bendingDetails = details.bending_details || data.bending_details || attributes.bending_details;
return Array.isArray(bendingDetails) ? bendingDetails : undefined;
})(),
// 파일 관련 필드 (FG - 제품) - 배열의 마지막 파일 = 최신 파일
specificationFile: (() => {
const files = data.files as { specification_file?: Array<{ file_path: string }> } | undefined;
const arr = files?.specification_file;
if (arr && arr.length > 0) return arr[arr.length - 1].file_path;
return undefined;
})(),
specificationFileName: (() => {
const files = data.files as { specification_file?: Array<{ file_name: string }> } | undefined;
const arr = files?.specification_file;
if (arr && arr.length > 0) return arr[arr.length - 1].file_name;
return undefined;
})(),
specificationFileId: (() => {
const files = data.files as { specification_file?: Array<{ id: number }> } | undefined;
const arr = files?.specification_file;
if (arr && arr.length > 0) return arr[arr.length - 1].id;
return undefined;
})(),
certificationFile: (() => {
const files = data.files as { certification_file?: Array<{ file_path: string }> } | undefined;
const arr = files?.certification_file;
if (arr && arr.length > 0) return arr[arr.length - 1].file_path;
return undefined;
})(),
certificationFileName: (() => {
const files = data.files as { certification_file?: Array<{ file_name: string }> } | undefined;
const arr = files?.certification_file;
if (arr && arr.length > 0) return arr[arr.length - 1].file_name;
return undefined;
})(),
certificationFileId: (() => {
const files = data.files as { certification_file?: Array<{ id: number }> } | undefined;
const arr = files?.certification_file;
if (arr && arr.length > 0) return arr[arr.length - 1].id;
return undefined;
})(),
certificationNumber: data.certification_number ? String(data.certification_number) : undefined,
certificationStartDate: data.certification_start_date ? String(data.certification_start_date) : undefined,
certificationEndDate: data.certification_end_date ? String(data.certification_end_date) : undefined,
@@ -127,17 +180,16 @@ export default function ItemDetailPage() {
return;
}
// 2025-12-10: item_type 파라미터를 실제 코드(SM, RM, CS)로 전달
// 2025-12-15: 백엔드에서 id만으로 조회 가능 (item_type 불필요)
const isMaterial = MATERIAL_TYPES.includes(itemType);
const queryParams = new URLSearchParams();
if (isMaterial) {
queryParams.append('item_type', itemType); // SM, RM, CS 그대로 전달
} else {
if (!isMaterial) {
queryParams.append('include_bom', 'true');
}
console.log('[ItemDetail] Fetching:', { itemId, itemType, isMaterial });
response = await fetch(`/api/proxy/items/${itemId}?${queryParams.toString()}`);
const queryString = queryParams.toString();
response = await fetch(`/api/proxy/items/${itemId}${queryString ? `?${queryString}` : ''}`);
if (!response.ok) {
if (response.status === 404) {
@@ -154,7 +206,38 @@ export default function ItemDetailPage() {
console.log('[ItemDetail] API Response:', result);
if (result.success && result.data) {
const mappedItem = mapApiResponseToItemMaster(result.data);
let mappedItem = mapApiResponseToItemMaster(result.data);
// BOM 데이터 별도 API 호출 (expandBomItems로 품목 정보 포함)
// GET /api/proxy/items/{id}/bom - 품목 정보가 확장된 BOM 데이터 반환
if (!isMaterial) {
try {
const bomResponse = await fetch(`/api/proxy/items/${itemId}/bom`);
const bomResult = await bomResponse.json();
if (bomResult.success && bomResult.data && Array.isArray(bomResult.data)) {
const expandedBomData = bomResult.data as Array<Record<string, unknown>>;
mappedItem = {
...mappedItem,
bom: expandedBomData.map((bomItem, index) => ({
id: String(bomItem.id || bomItem.child_item_id || `bom-${index}`),
childItemCode: String(bomItem.child_item_code || ''),
childItemName: String(bomItem.child_item_name || ''),
quantity: Number(bomItem.quantity || 1),
unit: String(bomItem.unit || 'EA'),
unitPrice: bomItem.unit_price ? Number(bomItem.unit_price) : undefined,
quantityFormula: bomItem.quantity_formula ? String(bomItem.quantity_formula) : undefined,
isBending: Boolean(bomItem.is_bending ?? false),
})),
};
console.log('[ItemDetail] BOM 데이터 로드 (expanded):', mappedItem.bom?.length, '건');
}
} catch (bomErr) {
console.error('[ItemDetail] BOM 조회 실패:', bomErr);
}
}
setItem(mappedItem);
} else {
setError(result.message || '품목 정보를 불러올 수 없습니다.');

View File

@@ -29,8 +29,10 @@ export default function CreateItemPage() {
delete submitData.spec;
}
// Material(SM, RM, CS)인 경우 수정 페이지와 동일하게 transformMaterialDataForSave 사용
// 2025-12-15: item_type은 Request Body에서 필수 (ItemService.store validation)
// product_type과 item_type을 동일하게 설정
const itemType = submitData.product_type as string;
submitData.item_type = itemType;
// API 호출 전 이미지 데이터 제거 (파일 업로드는 별도 API 사용)
// bending_diagram이 base64 데이터인 경우 제거 (JSON에 포함시키면 안됨)

View File

@@ -21,12 +21,11 @@ import { cookies } from 'next/headers';
// 품목 API 응답 타입 (GET /api/v1/items)
interface ItemApiData {
id: number;
item_type: 'PRODUCT' | 'MATERIAL';
item_type: string; // FG, PT, SM, RM, CS (품목 유형)
code: string;
name: string;
unit: string;
category_id: number | null;
type_code: string; // FG, PT, SM, RM, CS
created_at: string;
deleted_at: string | null;
}
@@ -151,7 +150,7 @@ async function getItemsList(): Promise<ItemApiData[]> {
const headers = await getApiHeaders();
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/items?size=100`,
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/items?group_id=1&size=100`,
{
method: 'GET',
headers,
@@ -165,7 +164,6 @@ async function getItemsList(): Promise<ItemApiData[]> {
}
const result: ItemsApiResponse = await response.json();
console.log('[PricingPage] Items API Response count:', result.data?.data?.length || 0);
if (!result.success || !result.data?.data) {
console.warn('[PricingPage] No items data in response');
@@ -252,7 +250,7 @@ function mergeItemsWithPricing(
itemId: String(item.id),
itemCode: item.code,
itemName: item.name,
itemType: mapItemType(item.type_code),
itemType: mapItemType(item.item_type),
specification: undefined, // items API에서는 specification 미제공
unit: item.unit || 'EA',
purchasePrice: pricing.purchase_price ? parseFloat(pricing.purchase_price) : undefined,
@@ -272,7 +270,7 @@ function mergeItemsWithPricing(
itemId: String(item.id),
itemCode: item.code,
itemName: item.name,
itemType: mapItemType(item.type_code),
itemType: mapItemType(item.item_type),
specification: undefined,
unit: item.unit || 'EA',
purchasePrice: undefined,

View File

@@ -249,16 +249,46 @@ async function proxyRequest(
}
// 6. 응답 데이터 읽기
const responseData = await backendResponse.text();
console.log('🔵 [PROXY] Response status:', backendResponse.status);
const responseContentType = backendResponse.headers.get('content-type') || 'application/json';
// 7. 클라이언트로 응답 전달
const clientResponse = new NextResponse(responseData, {
status: backendResponse.status,
headers: {
'Content-Type': backendResponse.headers.get('content-type') || 'application/json',
},
});
// 7. 바이너리 파일 vs 텍스트/JSON 구분
// 파일 다운로드 (PDF, 이미지, 등)는 바이너리로 처리해야 손상되지 않음
const isBinaryResponse =
responseContentType.includes('application/pdf') ||
responseContentType.includes('application/octet-stream') ||
responseContentType.includes('image/') ||
responseContentType.includes('application/zip') ||
responseContentType.includes('application/vnd') ||
responseContentType.includes('application/msword') ||
responseContentType.includes('application/x-');
let clientResponse: NextResponse;
if (isBinaryResponse) {
// 바이너리 파일: arrayBuffer로 읽어서 그대로 전달
console.log('📄 [PROXY] Binary response detected:', responseContentType);
const binaryData = await backendResponse.arrayBuffer();
clientResponse = new NextResponse(binaryData, {
status: backendResponse.status,
headers: {
'Content-Type': responseContentType,
'Content-Disposition': backendResponse.headers.get('content-disposition') || '',
'Content-Length': backendResponse.headers.get('content-length') || '',
},
});
} else {
// JSON/텍스트: text로 읽어서 전달
const responseData = await backendResponse.text();
clientResponse = new NextResponse(responseData, {
status: backendResponse.status,
headers: {
'Content-Type': responseContentType,
},
});
}
// 8. 토큰이 갱신되었으면 새 쿠키 설정
if (newTokens && newTokens.accessToken) {

View File

@@ -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'}
/>
)}

View File

@@ -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();

View File

@@ -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>

View File

@@ -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>

View File

@@ -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',

View File

@@ -157,12 +157,12 @@ export function useItemList(): UseItemListResult {
try {
// 각 유형별로 병렬 조회
const [allResponse, fgResponse, ptResponse, smResponse, rmResponse, csResponse] = await Promise.all([
fetch('/api/proxy/items?size=1'), // 전체 (size=1로 최소 데이터만)
fetch('/api/proxy/items?type=FG&size=1'), // 제품
fetch('/api/proxy/items?type=PT&size=1'), // 부품
fetch('/api/proxy/items?type=SM&size=1'), // 부자재
fetch('/api/proxy/items?type=RM&size=1'), // 원자재
fetch('/api/proxy/items?type=CS&size=1'), // 소모품
fetch('/api/proxy/items?group_id=1&size=1'), // 전체 (품목관리 그룹)
fetch('/api/proxy/items?type=FG&size=1'), // 제품
fetch('/api/proxy/items?type=PT&size=1'), // 부품
fetch('/api/proxy/items?type=SM&size=1'), // 부자재
fetch('/api/proxy/items?type=RM&size=1'), // 원자재
fetch('/api/proxy/items?type=CS&size=1'), // 소모품
]);
const [allResult, fgResult, ptResult, smResult, rmResult, csResult] = await Promise.all([
@@ -196,9 +196,16 @@ export function useItemList(): UseItemListResult {
if (filters.search && filters.search.trim()) {
params.append('search', filters.search.trim());
}
// 타입별 조회 vs 전체 조회
if (filters.type && filters.type !== 'all') {
// 특정 타입 조회: type 파라미터 사용
params.append('type', filters.type);
} else {
// 전체 조회: group_id=1 (품목관리 그룹)
params.append('group_id', '1');
}
if (filters.page) {
params.append('page', String(filters.page));
}

View File

@@ -434,25 +434,27 @@ export async function uploadItemFile(
}
/**
* 품목 파일 삭제 (ID 기반, 프록시 사용)
* 품목 파일 삭제 (파일 ID 기반, 프록시 사용)
*
* @param itemId - 품목 ID (숫자)
* @param fileType - 파일 유형 (specification, certification, bending_diagram)
* @param fileId - 파일 ID (files 테이블의 id)
* @param itemType - 품목 유형 (FG, PT, SM 등) - 기본값 'FG'
*
* @example
* await deleteItemFile(123, 'specification');
* await deleteItemFile(123, 456, 'FG');
*/
export async function deleteItemFile(
itemId: number,
fileType: ItemFileType
): Promise<{ file_type: string; deleted: boolean; product: Record<string, unknown> }> {
// 프록시 경유: /api/proxy/items/{id}/files/{type} → /api/v1/items/{id}/files/{type}
const response = await fetch(`/api/proxy/items/${itemId}/files/${fileType}`, {
fileId: number,
itemType: string = 'FG'
): Promise<{ file_id: number; deleted: boolean }> {
// 프록시 경유: /api/proxy/items/{id}/files/{fileId} → /api/v1/items/{id}/files/{fileId}
const response = await fetch(`/api/proxy/items/${itemId}/files/${fileId}?item_type=${itemType}`, {
method: 'DELETE',
credentials: 'include',
});
const data = await handleApiResponse<ApiResponse<{ file_type: string; deleted: boolean; product: Record<string, unknown> }>>(response);
const data = await handleApiResponse<ApiResponse<{ file_id: number; deleted: boolean }>>(response);
return data.data;
}

View File

@@ -0,0 +1,71 @@
/**
* 파일 다운로드 유틸리티
*
* 백엔드 API: GET /api/v1/files/{id}/download
* 프록시: GET /api/proxy/files/{id}/download
*/
/**
* 파일 ID로 다운로드
* @param fileId 파일 ID
* @param fileName 저장할 파일명 (선택, 없으면 서버에서 제공하는 이름 사용)
*/
export async function downloadFileById(fileId: number, fileName?: string): Promise<void> {
try {
const response = await fetch(`/api/proxy/files/${fileId}/download`);
if (!response.ok) {
throw new Error(`다운로드 실패: ${response.status}`);
}
const blob = await response.blob();
// 파일명이 없으면 Content-Disposition 헤더에서 추출 시도
let downloadFileName = fileName;
if (!downloadFileName) {
const contentDisposition = response.headers.get('Content-Disposition');
if (contentDisposition) {
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
if (match && match[1]) {
downloadFileName = match[1].replace(/['"]/g, '');
// URL 디코딩 (한글 파일명 처리)
try {
downloadFileName = decodeURIComponent(downloadFileName);
} catch {
// 디코딩 실패 시 그대로 사용
}
}
}
}
// 그래도 없으면 기본 파일명
if (!downloadFileName) {
downloadFileName = `file_${fileId}`;
}
// Blob URL 생성 및 다운로드 트리거
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = downloadFileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('[fileDownload] 다운로드 오류:', error);
throw error;
}
}
/**
* 파일 경로로 새 탭에서 열기 (미리보기용)
* @param filePath 파일 경로
*/
export function openFileInNewTab(filePath: string): void {
// 백엔드 파일 서빙 URL 구성
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
const fileUrl = `${baseUrl}/storage/${filePath}`;
window.open(fileUrl, '_blank');
}

View File

@@ -99,7 +99,7 @@ export interface ItemRevision {
/**
* 품목 개별 파일 정보
* API 응답: files.specification[0], files.certification[0] 등
* API 응답: files.specification_files[0], files.certification_files[0] 등
*/
export interface ItemFile {
id: number; // 파일 ID (file_id로 사용)
@@ -113,8 +113,8 @@ export interface ItemFile {
*/
export interface ItemFiles {
bending_diagram?: ItemFile[]; // 전개도 파일들
specification?: ItemFile[]; // 시방서 파일들
certification?: ItemFile[]; // 인정서 파일들
specification_files?: ItemFile[]; // 시방서 파일들
certification_files?: ItemFile[]; // 인정서 파일들
}
// ===== 품목 마스터 (메인) =====
@@ -174,6 +174,7 @@ export interface ItemMaster {
// 절곡품 관련
bendingDiagram?: string; // 전개도 이미지 URL
bendingDiagramFileId?: number; // 전개도 파일 ID (다운로드/미리보기용)
bendingDetails?: BendingDetail[]; // 전개도 상세 데이터
material?: string; // 재질 (EGI 1.55T, SUS 1.2T)
length?: string; // 길이/목함 (mm)
@@ -192,8 +193,10 @@ export interface ItemMaster {
certificationEndDate?: string; // 인정 유효기간 종료일
specificationFile?: string; // 시방서 파일 URL
specificationFileName?: string; // 시방서 파일명
specificationFileId?: number; // 시방서 파일 ID (다운로드용)
certificationFile?: string; // 인정서 파일 URL
certificationFileName?: string; // 인정서 파일명
certificationFileId?: number; // 인정서 파일 ID (다운로드용)
// === 파일 정보 (새 API 구조) ===
files?: ItemFiles; // 파일 목록 (타입별 배열)