diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index 967e43ca..9b5b7aea 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -34,7 +34,11 @@ const getApiBaseUrl = (): string => { export const API_BASE_URL = getApiBaseUrl(); // 이미지 URL을 완전한 URL로 변환하는 함수 +// 주의: 모듈 로드 시점이 아닌 런타임에 hostname을 확인해야 SSR 문제 방지 export const getFullImageUrl = (imagePath: string): string => { + // 빈 값 체크 + if (!imagePath) return ""; + // 이미 전체 URL인 경우 그대로 반환 if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) { return imagePath; @@ -42,8 +46,29 @@ export const getFullImageUrl = (imagePath: string): string => { // /uploads로 시작하는 상대 경로인 경우 API 서버 주소 추가 if (imagePath.startsWith("/uploads")) { - const baseUrl = API_BASE_URL.replace("/api", ""); // /api 제거 - return `${baseUrl}${imagePath}`; + // 런타임에 현재 hostname 확인 (SSR 시점이 아닌 클라이언트에서 실행될 때) + if (typeof window !== "undefined") { + const currentHost = window.location.hostname; + + // 프로덕션 환경: v1.vexplor.com → api.vexplor.com + if (currentHost === "v1.vexplor.com") { + return `https://api.vexplor.com${imagePath}`; + } + + // 로컬 개발환경 + if (currentHost === "localhost" || currentHost === "127.0.0.1") { + return `http://localhost:8080${imagePath}`; + } + } + + // SSR 또는 알 수 없는 환경에서는 API_BASE_URL 사용 (fallback) + const baseUrl = API_BASE_URL.replace("/api", ""); + if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) { + return `${baseUrl}${imagePath}`; + } + + // 최종 fallback + return imagePath; } return imagePath; diff --git a/frontend/lib/api/file.ts b/frontend/lib/api/file.ts index 4908b381..e6cab8ae 100644 --- a/frontend/lib/api/file.ts +++ b/frontend/lib/api/file.ts @@ -247,10 +247,40 @@ export const getFileDownloadUrl = (fileId: string): string => { /** * 직접 파일 경로 URL 생성 (정적 파일 서빙) + * 주의: 모듈 로드 시점이 아닌 런타임에 hostname을 확인해야 SSR 문제 방지 */ export const getDirectFileUrl = (filePath: string): string => { + // 빈 값 체크 + if (!filePath) return ""; + + // 이미 전체 URL인 경우 그대로 반환 + if (filePath.startsWith("http://") || filePath.startsWith("https://")) { + return filePath; + } + + // 런타임에 현재 hostname 확인 (SSR 시점이 아닌 클라이언트에서 실행될 때) + if (typeof window !== "undefined") { + const currentHost = window.location.hostname; + + // 프로덕션 환경: v1.vexplor.com → api.vexplor.com + if (currentHost === "v1.vexplor.com") { + return `https://api.vexplor.com${filePath}`; + } + + // 로컬 개발환경 + if (currentHost === "localhost" || currentHost === "127.0.0.1") { + return `http://localhost:8080${filePath}`; + } + } + + // SSR 또는 알 수 없는 환경에서는 환경변수 사용 (fallback) const baseUrl = process.env.NEXT_PUBLIC_API_URL?.replace("/api", "") || ""; - return `${baseUrl}${filePath}`; + if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) { + return `${baseUrl}${filePath}`; + } + + // 최종 fallback + return filePath; }; /** diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index e91d34f4..19073e39 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -434,20 +434,50 @@ export const SelectedItemsDetailInputComponent: React.FC { // 각 그룹의 엔트리 배열들을 준비 - const groupEntriesArrays: GroupEntry[][] = groups.map((group) => item.fieldGroups[group.id] || []); + // 🔧 빈 엔트리 필터링: id만 있고 실제 필드 값이 없는 엔트리는 제외 + const groupEntriesArrays: GroupEntry[][] = groups.map((group) => { + const entries = item.fieldGroups[group.id] || []; + const groupFields = additionalFields.filter((f) => f.groupId === group.id); + + // 실제 필드 값이 하나라도 있는 엔트리만 포함 + return entries.filter((entry) => { + const hasAnyFieldValue = groupFields.some((field) => { + const value = entry[field.name]; + return value !== undefined && value !== null && value !== ""; + }); + + if (!hasAnyFieldValue && Object.keys(entry).length <= 1) { + console.log("⏭️ [generateCartesianProduct] 빈 엔트리 필터링:", { + entryId: entry.id, + groupId: group.id, + entryKeys: Object.keys(entry), + }); + } + + return hasAnyFieldValue; + }); + }); // 🆕 모든 그룹이 비어있는지 확인 const allGroupsEmpty = groupEntriesArrays.every((arr) => arr.length === 0); if (allGroupsEmpty) { - // 🆕 모든 그룹이 비어있으면 품목 기본 정보만으로 레코드 생성 - // (거래처 품번/품명, 기간별 단가 없이도 저장 가능) - console.log("📝 [generateCartesianProduct] 모든 그룹이 비어있음 - 품목 기본 레코드 생성", { - itemIndex, - itemId: item.id, - }); - // 빈 객체를 추가하면 parentKeys와 합쳐져서 기본 레코드가 됨 - allRecords.push({}); + // 🔧 아이템이 1개뿐이면 기본 레코드 생성 (첫 저장 시) + // 아이템이 여러 개면 빈 아이템은 건너뛰기 (불필요한 NULL 레코드 방지) + if (itemsList.length === 1) { + console.log("📝 [generateCartesianProduct] 단일 아이템, 모든 그룹 비어있음 - 기본 레코드 생성", { + itemIndex, + itemId: item.id, + }); + // 빈 객체를 추가하면 parentKeys와 합쳐져서 기본 레코드가 됨 + allRecords.push({}); + } else { + console.log("⏭️ [generateCartesianProduct] 다중 아이템 중 빈 아이템 - 건너뜀", { + itemIndex, + itemId: item.id, + totalItems: itemsList.length, + }); + } return; } @@ -471,6 +501,11 @@ export const SelectedItemsDetailInputComponent: React.FC { const newCombination = { ...currentCombination }; + // 🆕 기존 레코드의 id가 있으면 포함 (UPDATE를 위해) + if (entry.id) { + newCombination.id = entry.id; + } + // 현재 그룹의 필드들을 조합에 추가 const groupFields = additionalFields.filter((f) => f.groupId === groups[currentIndex].id); groupFields.forEach((field) => { @@ -573,6 +608,27 @@ export const SelectedItemsDetailInputComponent: React.FC v === null || v === undefined || v === ""); + + if (hasEmptyParentKey) { + console.error("❌ [SelectedItemsDetailInput] parentKeys가 비어있거나 유효하지 않습니다!", parentKeys); + window.dispatchEvent( + new CustomEvent("formSaveError", { + detail: { message: "부모 키 값이 비어있어 저장할 수 없습니다. 먼저 상위 데이터를 선택해주세요." }, + }), + ); + + // 🔧 기본 저장 건너뛰기 - event.detail 객체 직접 수정 + if (event instanceof CustomEvent && event.detail) { + (event.detail as any).skipDefaultSave = true; + console.log("🚫 [SelectedItemsDetailInput] skipDefaultSave = true (parentKeys 검증 실패)"); + } + return; + } + // items를 Cartesian Product로 변환 const records = generateCartesianProduct(items); @@ -591,24 +647,21 @@ export const SelectedItemsDetailInputComponent: React.FC setTimeout(resolve, 100)); + // 🔧 skipDefaultSave 플래그 확인 - SelectedItemsDetailInput 등에서 자체 UPSERT 처리 시 기본 저장 건너뛰기 + if (beforeSaveEventDetail.skipDefaultSave) { + console.log("🚫 [handleSave] skipDefaultSave=true - 기본 저장 로직 건너뛰기 (컴포넌트에서 자체 처리)"); + return true; + } + console.log("📦 [handleSave] beforeFormSave 이벤트 후 formData:", context.formData); // 🆕 렉 구조 컴포넌트 일괄 저장 감지