- Replaced existing toast error messages with the new `showErrorToast` utility across multiple components, improving consistency in error reporting. - Updated error messages to provide more specific guidance for users, enhancing the overall user experience during error scenarios. - Ensured that all relevant error handling in batch management, external call configurations, cascading management, and screen management components now utilizes the new utility for better maintainability.
1320 lines
49 KiB
TypeScript
1320 lines
49 KiB
TypeScript
import React, { useState, useRef, useCallback, useEffect } from "react";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { toast } from "sonner";
|
|
import { showErrorToast } from "@/lib/utils/toastUtils";
|
|
import { uploadFiles, downloadFile, deleteFile, getComponentFiles } from "@/lib/api/file";
|
|
import { GlobalFileManager } from "@/lib/api/globalFile";
|
|
import { formatFileSize } from "@/lib/utils";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { FileViewerModal } from "./FileViewerModal";
|
|
import { FileManagerModal } from "./FileManagerModal";
|
|
import { FileUploadConfig, FileInfo, FileUploadStatus, FileUploadResponse } from "./types";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import {
|
|
Upload,
|
|
File,
|
|
FileText,
|
|
Image,
|
|
Video,
|
|
Music,
|
|
Archive,
|
|
Download,
|
|
Eye,
|
|
Trash2,
|
|
AlertCircle,
|
|
FileImage,
|
|
FileVideo,
|
|
FileAudio,
|
|
Presentation,
|
|
} from "lucide-react";
|
|
|
|
// 파일 아이콘 매핑
|
|
const getFileIcon = (extension: string) => {
|
|
const ext = extension.toLowerCase().replace(".", "");
|
|
|
|
if (["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(ext)) {
|
|
return <FileImage className="h-6 w-6 text-blue-500" />;
|
|
}
|
|
if (["mp4", "avi", "mov", "wmv", "flv", "webm"].includes(ext)) {
|
|
return <FileVideo className="h-6 w-6 text-purple-500" />;
|
|
}
|
|
if (["mp3", "wav", "flac", "aac", "ogg"].includes(ext)) {
|
|
return <FileAudio className="h-6 w-6 text-green-500" />;
|
|
}
|
|
if (["pdf"].includes(ext)) {
|
|
return <FileText className="h-6 w-6 text-red-500" />;
|
|
}
|
|
if (["doc", "docx", "hwp", "hwpx", "pages"].includes(ext)) {
|
|
return <FileText className="h-6 w-6 text-blue-600" />;
|
|
}
|
|
if (["xls", "xlsx", "hcell", "numbers"].includes(ext)) {
|
|
return <FileText className="h-6 w-6 text-green-600" />;
|
|
}
|
|
if (["ppt", "pptx", "hanshow", "keynote"].includes(ext)) {
|
|
return <Presentation className="h-6 w-6 text-orange-500" />;
|
|
}
|
|
if (["zip", "rar", "7z", "tar", "gz"].includes(ext)) {
|
|
return <Archive className="h-6 w-6 text-gray-500" />;
|
|
}
|
|
|
|
return <File className="h-6 w-6 text-gray-400" />;
|
|
};
|
|
|
|
export interface FileUploadComponentProps {
|
|
component: any;
|
|
componentConfig: FileUploadConfig;
|
|
componentStyle: React.CSSProperties;
|
|
className: string;
|
|
isInteractive: boolean;
|
|
isDesignMode: boolean;
|
|
formData: any;
|
|
onFormDataChange: (data: any) => void;
|
|
onClick?: () => void;
|
|
onDragStart?: (e: React.DragEvent) => void;
|
|
onDragEnd?: (e: React.DragEvent) => void;
|
|
onUpdate?: (updates: Partial<any>) => void;
|
|
autoGeneration?: any;
|
|
hidden?: boolean;
|
|
onConfigChange?: (config: any) => void;
|
|
}
|
|
|
|
const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|
component,
|
|
componentConfig,
|
|
componentStyle,
|
|
className,
|
|
isInteractive,
|
|
isDesignMode = false, // 기본값 설정
|
|
formData,
|
|
onFormDataChange,
|
|
onClick,
|
|
onDragStart,
|
|
onDragEnd,
|
|
onUpdate,
|
|
}) => {
|
|
// 🔑 인증 정보 가져오기
|
|
const { user } = useAuth();
|
|
|
|
const [uploadedFiles, setUploadedFiles] = useState<FileInfo[]>([]);
|
|
const [uploadStatus, setUploadStatus] = useState<FileUploadStatus>("idle");
|
|
const [dragOver, setDragOver] = useState(false);
|
|
const [viewerFile, setViewerFile] = useState<FileInfo | null>(null);
|
|
const [isViewerOpen, setIsViewerOpen] = useState(false);
|
|
const [isFileManagerOpen, setIsFileManagerOpen] = useState(false);
|
|
const [forceUpdate, setForceUpdate] = useState(0);
|
|
const [representativeImageUrl, setRepresentativeImageUrl] = useState<string | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// 🔑 레코드 모드 판단: formData에 id가 있으면 특정 행에 연결된 파일 관리
|
|
const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_'));
|
|
const recordTableName = formData?.tableName || component.tableName;
|
|
const recordId = formData?.id;
|
|
// 🔑 컬럼명 결정: 레코드 모드에서는 무조건 'attachments' 사용
|
|
// component.columnName이나 component.id는 '파일_업로드' 같은 한글 라벨일 수 있어서 DB 컬럼명으로 부적합
|
|
// 레코드 모드가 아닐 때만 component.columnName 또는 component.id 사용
|
|
const columnName = isRecordMode ? 'attachments' : (component.columnName || component.id || 'attachments');
|
|
|
|
// 🔑 레코드 모드용 targetObjid 생성
|
|
const getRecordTargetObjid = useCallback(() => {
|
|
if (isRecordMode && recordTableName && recordId) {
|
|
return `${recordTableName}:${recordId}:${columnName}`;
|
|
}
|
|
return null;
|
|
}, [isRecordMode, recordTableName, recordId, columnName]);
|
|
|
|
// 🔑 레코드별 고유 키 생성 (localStorage, 전역 상태용)
|
|
const getUniqueKey = useCallback(() => {
|
|
if (isRecordMode && recordTableName && recordId) {
|
|
// 레코드 모드: 테이블명:레코드ID:컴포넌트ID 형태로 고유 키 생성
|
|
return `fileUpload_${recordTableName}_${recordId}_${component.id}`;
|
|
}
|
|
// 기본 모드: 컴포넌트 ID만 사용
|
|
return `fileUpload_${component.id}`;
|
|
}, [isRecordMode, recordTableName, recordId, component.id]);
|
|
|
|
// 🔍 디버깅: 레코드 모드 상태 로깅
|
|
useEffect(() => {
|
|
console.log("📎 [FileUploadComponent] 모드 확인:", {
|
|
isRecordMode,
|
|
recordTableName,
|
|
recordId,
|
|
columnName,
|
|
targetObjid: getRecordTargetObjid(),
|
|
uniqueKey: getUniqueKey(),
|
|
formDataKeys: formData ? Object.keys(formData) : [],
|
|
// 🔍 추가 디버깅: 어디서 tableName이 오는지 확인
|
|
"formData.tableName": formData?.tableName,
|
|
"component.tableName": component.tableName,
|
|
"component.columnName": component.columnName,
|
|
"component.id": component.id,
|
|
});
|
|
}, [isRecordMode, recordTableName, recordId, columnName, getRecordTargetObjid, getUniqueKey, formData, component.tableName, component.columnName, component.id]);
|
|
|
|
// 🆕 레코드 ID 변경 시 파일 목록 초기화 및 새로 로드
|
|
const prevRecordIdRef = useRef<any>(null);
|
|
useEffect(() => {
|
|
if (prevRecordIdRef.current !== recordId) {
|
|
console.log("📎 [FileUploadComponent] 레코드 ID 변경 감지:", {
|
|
prev: prevRecordIdRef.current,
|
|
current: recordId,
|
|
isRecordMode,
|
|
});
|
|
prevRecordIdRef.current = recordId;
|
|
|
|
// 레코드 모드에서 레코드 ID가 변경되면 파일 목록 초기화
|
|
if (isRecordMode) {
|
|
setUploadedFiles([]);
|
|
}
|
|
}
|
|
}, [recordId, isRecordMode]);
|
|
|
|
// 컴포넌트 마운트 시 즉시 localStorage에서 파일 복원
|
|
useEffect(() => {
|
|
if (!component?.id) return;
|
|
|
|
try {
|
|
// 🔑 레코드별 고유 키 사용
|
|
const backupKey = getUniqueKey();
|
|
const backupFiles = localStorage.getItem(backupKey);
|
|
console.log("🔎 [DEBUG-MOUNT] localStorage 확인:", {
|
|
backupKey,
|
|
hasBackup: !!backupFiles,
|
|
componentId: component.id,
|
|
recordId: recordId,
|
|
formDataId: formData?.id,
|
|
stackTrace: new Error().stack?.split('\n').slice(1, 4).join(' <- '),
|
|
});
|
|
if (backupFiles) {
|
|
const parsedFiles = JSON.parse(backupFiles);
|
|
if (parsedFiles.length > 0) {
|
|
console.log("🚀 [DEBUG-MOUNT] 파일 즉시 복원:", {
|
|
uniqueKey: backupKey,
|
|
componentId: component.id,
|
|
recordId: recordId,
|
|
restoredFiles: parsedFiles.length,
|
|
files: parsedFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })),
|
|
});
|
|
setUploadedFiles(parsedFiles);
|
|
|
|
// 전역 상태에도 복원 (레코드별 고유 키 사용)
|
|
if (typeof window !== "undefined") {
|
|
(window as any).globalFileState = {
|
|
...(window as any).globalFileState,
|
|
[backupKey]: parsedFiles,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn("컴포넌트 마운트 시 파일 복원 실패:", e);
|
|
}
|
|
}, [component.id, getUniqueKey, recordId]); // 레코드별 고유 키 변경 시 재실행
|
|
|
|
// 🆕 모달 닫힘/저장 성공 시 localStorage 파일 캐시 정리 (등록 후 재등록 시 이전 파일 잔존 방지)
|
|
useEffect(() => {
|
|
const handleClearFileCache = (event: Event) => {
|
|
const backupKey = getUniqueKey();
|
|
const eventType = event.type;
|
|
console.log("🧹 [DEBUG-CLEAR] 파일 캐시 정리 이벤트 수신:", {
|
|
eventType,
|
|
backupKey,
|
|
componentId: component.id,
|
|
currentFiles: uploadedFiles.length,
|
|
hasLocalStorage: !!localStorage.getItem(backupKey),
|
|
});
|
|
try {
|
|
localStorage.removeItem(backupKey);
|
|
setUploadedFiles([]);
|
|
setRepresentativeImageUrl(null);
|
|
if (typeof window !== "undefined") {
|
|
const globalFileState = (window as any).globalFileState || {};
|
|
delete globalFileState[backupKey];
|
|
(window as any).globalFileState = globalFileState;
|
|
}
|
|
console.log("🧹 [DEBUG-CLEAR] 정리 완료:", backupKey);
|
|
} catch (e) {
|
|
console.warn("파일 캐시 정리 실패:", e);
|
|
}
|
|
};
|
|
|
|
// EditModal 닫힘, ScreenModal 연속 등록 저장 성공, 일반 저장 성공 모두 처리
|
|
window.addEventListener("closeEditModal", handleClearFileCache);
|
|
window.addEventListener("saveSuccess", handleClearFileCache);
|
|
window.addEventListener("saveSuccessInModal", handleClearFileCache);
|
|
|
|
console.log("🔎 [DEBUG-CLEAR] 이벤트 리스너 등록 완료:", {
|
|
componentId: component.id,
|
|
backupKey: getUniqueKey(),
|
|
});
|
|
|
|
return () => {
|
|
window.removeEventListener("closeEditModal", handleClearFileCache);
|
|
window.removeEventListener("saveSuccess", handleClearFileCache);
|
|
window.removeEventListener("saveSuccessInModal", handleClearFileCache);
|
|
};
|
|
}, [getUniqueKey]);
|
|
|
|
// 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너
|
|
useEffect(() => {
|
|
const handleDesignModeFileChange = (event: CustomEvent) => {
|
|
console.log("🎯🎯🎯 FileUploadComponent 화면설계 모드 파일 변경 이벤트 수신:", {
|
|
eventComponentId: event.detail.componentId,
|
|
currentComponentId: component.id,
|
|
isMatch: event.detail.componentId === component.id,
|
|
filesCount: event.detail.files?.length || 0,
|
|
action: event.detail.action,
|
|
source: event.detail.source,
|
|
eventDetail: event.detail,
|
|
});
|
|
|
|
// 현재 컴포넌트와 일치하고 화면설계 모드에서 온 이벤트인 경우
|
|
if (event.detail.componentId === component.id && event.detail.source === "designMode") {
|
|
// 파일 상태 업데이트
|
|
const newFiles = event.detail.files || [];
|
|
setUploadedFiles(newFiles);
|
|
|
|
// localStorage 백업 업데이트 (레코드별 고유 키 사용)
|
|
try {
|
|
const backupKey = getUniqueKey();
|
|
localStorage.setItem(backupKey, JSON.stringify(newFiles));
|
|
console.log("💾 화면설계 모드 동기화 후 localStorage 백업 업데이트:", {
|
|
uniqueKey: backupKey,
|
|
componentId: component.id,
|
|
recordId: recordId,
|
|
fileCount: newFiles.length,
|
|
});
|
|
} catch (e) {
|
|
console.warn("localStorage 백업 업데이트 실패:", e);
|
|
}
|
|
|
|
// 전역 상태 업데이트
|
|
if (typeof window !== "undefined") {
|
|
(window as any).globalFileState = {
|
|
...(window as any).globalFileState,
|
|
[component.id]: newFiles,
|
|
};
|
|
}
|
|
|
|
// onUpdate 콜백 호출 (부모 컴포넌트에 알림)
|
|
if (onUpdate) {
|
|
onUpdate({
|
|
uploadedFiles: newFiles,
|
|
lastFileUpdate: event.detail.timestamp,
|
|
});
|
|
}
|
|
|
|
console.log("🎉🎉🎉 화면설계 모드 → 실제 화면 동기화 완료:", {
|
|
componentId: component.id,
|
|
finalFileCount: newFiles.length,
|
|
});
|
|
}
|
|
};
|
|
|
|
if (typeof window !== "undefined") {
|
|
window.addEventListener("globalFileStateChanged", handleDesignModeFileChange as EventListener);
|
|
|
|
return () => {
|
|
window.removeEventListener("globalFileStateChanged", handleDesignModeFileChange as EventListener);
|
|
};
|
|
}
|
|
}, [component.id, onUpdate]);
|
|
|
|
// 템플릿 파일과 데이터 파일을 조회하는 함수
|
|
const loadComponentFiles = useCallback(async () => {
|
|
if (!component?.id) return false;
|
|
|
|
try {
|
|
// 🔑 레코드 모드: 해당 행의 파일만 조회
|
|
if (isRecordMode && recordTableName && recordId) {
|
|
console.log("📂 [FileUploadComponent] 레코드 모드 파일 조회:", {
|
|
tableName: recordTableName,
|
|
recordId: recordId,
|
|
columnName: columnName,
|
|
targetObjid: getRecordTargetObjid(),
|
|
});
|
|
}
|
|
|
|
// 1. formData에서 screenId 가져오기
|
|
let screenId = formData?.screenId;
|
|
|
|
// 2. URL에서 screenId 추출 (/screens/:id 패턴)
|
|
if (!screenId && typeof window !== "undefined") {
|
|
const pathname = window.location.pathname;
|
|
const screenMatch = pathname.match(/\/screens\/(\d+)/);
|
|
if (screenMatch) {
|
|
screenId = parseInt(screenMatch[1]);
|
|
}
|
|
}
|
|
|
|
// 3. 디자인 모드인 경우 임시 화면 ID 사용
|
|
if (!screenId && isDesignMode) {
|
|
screenId = 999999; // 디자인 모드 임시 ID
|
|
}
|
|
|
|
// 4. 화면 ID가 없으면 컴포넌트 ID만으로 조회 시도
|
|
if (!screenId) {
|
|
console.warn("⚠️ 화면 ID 없음, 컴포넌트 ID만으로 파일 조회:", {
|
|
componentId: component.id,
|
|
pathname: window.location.pathname,
|
|
formData: formData,
|
|
});
|
|
// screenId를 0으로 설정하여 컴포넌트 ID로만 조회
|
|
screenId = 0;
|
|
}
|
|
|
|
const params = {
|
|
screenId,
|
|
componentId: component.id,
|
|
tableName: recordTableName || formData?.tableName || component.tableName,
|
|
recordId: recordId || formData?.id,
|
|
columnName: columnName, // 🔑 레코드 모드에서 사용하는 columnName
|
|
};
|
|
|
|
console.log("📂 [FileUploadComponent] 파일 조회 파라미터:", params);
|
|
|
|
const response = await getComponentFiles(params);
|
|
|
|
if (response.success) {
|
|
|
|
// 파일 데이터 형식 통일
|
|
const formattedFiles = response.totalFiles.map((file: any) => ({
|
|
objid: file.objid || file.id,
|
|
savedFileName: file.savedFileName || file.saved_file_name,
|
|
realFileName: file.realFileName || file.real_file_name,
|
|
fileSize: file.fileSize || file.file_size,
|
|
fileExt: file.fileExt || file.file_ext,
|
|
regdate: file.regdate,
|
|
status: file.status || "ACTIVE",
|
|
uploadedAt: file.uploadedAt || new Date().toISOString(),
|
|
...file,
|
|
}));
|
|
|
|
|
|
// 🔄 localStorage의 기존 파일과 서버 파일 병합 (레코드별 고유 키 사용)
|
|
let finalFiles = formattedFiles;
|
|
const uniqueKey = getUniqueKey();
|
|
try {
|
|
const backupFiles = localStorage.getItem(uniqueKey);
|
|
if (backupFiles) {
|
|
const parsedBackupFiles = JSON.parse(backupFiles);
|
|
|
|
// 서버에 없는 localStorage 파일들을 추가 (objid 기준으로 중복 제거)
|
|
const serverObjIds = new Set(formattedFiles.map((f: any) => f.objid));
|
|
const additionalFiles = parsedBackupFiles.filter((f: any) => !serverObjIds.has(f.objid));
|
|
|
|
finalFiles = [...formattedFiles, ...additionalFiles];
|
|
console.log("📂 [FileUploadComponent] 파일 병합 완료:", {
|
|
uniqueKey,
|
|
serverFiles: formattedFiles.length,
|
|
localFiles: parsedBackupFiles.length,
|
|
finalFiles: finalFiles.length,
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.warn("파일 병합 중 오류:", e);
|
|
}
|
|
|
|
console.log("🔎 [DEBUG-LOAD] API 응답 후 파일 설정:", {
|
|
componentId: component.id,
|
|
serverFiles: formattedFiles.length,
|
|
finalFiles: finalFiles.length,
|
|
files: finalFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })),
|
|
});
|
|
setUploadedFiles(finalFiles);
|
|
|
|
// 전역 상태에도 저장 (레코드별 고유 키 사용)
|
|
if (typeof window !== "undefined") {
|
|
(window as any).globalFileState = {
|
|
...(window as any).globalFileState,
|
|
[uniqueKey]: finalFiles,
|
|
};
|
|
|
|
// 🌐 전역 파일 저장소에 등록 (페이지 간 공유용)
|
|
GlobalFileManager.registerFiles(finalFiles, {
|
|
uploadPage: window.location.pathname,
|
|
componentId: component.id,
|
|
screenId: formData?.screenId,
|
|
recordId: recordId,
|
|
});
|
|
|
|
// localStorage 백업도 병합된 파일로 업데이트 (레코드별 고유 키 사용)
|
|
try {
|
|
localStorage.setItem(uniqueKey, JSON.stringify(finalFiles));
|
|
} catch (e) {
|
|
console.warn("localStorage 백업 업데이트 실패:", e);
|
|
}
|
|
}
|
|
return true; // 새로운 로직 사용됨
|
|
}
|
|
} catch (error) {
|
|
console.error("파일 조회 오류:", error);
|
|
}
|
|
return false; // 기존 로직 사용
|
|
}, [component.id, component.tableName, component.columnName, formData?.screenId, formData?.tableName, formData?.id, getUniqueKey, recordId, isRecordMode, recordTableName, columnName]);
|
|
|
|
// 컴포넌트 파일 동기화 (DB 우선, localStorage는 보조)
|
|
useEffect(() => {
|
|
const componentFiles = (component as any)?.uploadedFiles || [];
|
|
const lastUpdate = (component as any)?.lastFileUpdate;
|
|
|
|
console.log("🔄 FileUploadComponent 파일 동기화 시작:", {
|
|
componentId: component.id,
|
|
componentFiles: componentFiles.length,
|
|
formData: formData,
|
|
screenId: formData?.screenId,
|
|
tableName: formData?.tableName, // 🔍 테이블명 확인
|
|
recordId: formData?.id, // 🔍 레코드 ID 확인
|
|
currentUploadedFiles: uploadedFiles.length,
|
|
});
|
|
|
|
// 🔒 항상 DB에서 최신 파일 목록을 조회 (멀티테넌시 격리)
|
|
loadComponentFiles().then((dbLoadSuccess) => {
|
|
if (dbLoadSuccess) {
|
|
return; // DB 로드 성공 시 localStorage 무시
|
|
}
|
|
|
|
// DB 로드 실패 시에만 기존 로직 사용 (하위 호환성)
|
|
|
|
// 전역 상태에서 최신 파일 정보 가져오기
|
|
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
|
|
const globalFiles = globalFileState[component.id] || [];
|
|
|
|
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
|
|
const currentFiles = globalFiles.length > 0 ? globalFiles : componentFiles;
|
|
|
|
|
|
// 최신 파일과 현재 파일 비교
|
|
if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) {
|
|
setUploadedFiles(currentFiles);
|
|
setForceUpdate((prev) => prev + 1);
|
|
}
|
|
});
|
|
}, [loadComponentFiles, component.id, (component as any)?.uploadedFiles, (component as any)?.lastFileUpdate]);
|
|
|
|
// 전역 상태 변경 감지 (모든 파일 컴포넌트 동기화 + 화면 복원)
|
|
useEffect(() => {
|
|
const handleGlobalFileStateChange = (event: CustomEvent) => {
|
|
const { componentId, files, fileCount, timestamp, isRestore } = event.detail;
|
|
|
|
console.log("🔄 FileUploadComponent 전역 상태 변경 감지:", {
|
|
currentComponentId: component.id,
|
|
eventComponentId: componentId,
|
|
isForThisComponent: componentId === component.id,
|
|
newFileCount: fileCount,
|
|
currentFileCount: uploadedFiles.length,
|
|
timestamp,
|
|
isRestore: !!isRestore,
|
|
});
|
|
|
|
// 같은 컴포넌트 ID인 경우에만 업데이트
|
|
if (componentId === component.id) {
|
|
const logMessage = isRestore ? "🔄 화면 복원으로 파일 상태 동기화" : "✅ 파일 상태 동기화 적용";
|
|
console.log(logMessage, {
|
|
componentId: component.id,
|
|
이전파일수: uploadedFiles?.length || 0,
|
|
새파일수: files?.length || 0,
|
|
files: files?.map((f: any) => ({ objid: f.objid, name: f.realFileName })) || [],
|
|
});
|
|
|
|
setUploadedFiles(files);
|
|
setForceUpdate((prev) => prev + 1);
|
|
|
|
// localStorage 백업도 업데이트 (레코드별 고유 키 사용)
|
|
try {
|
|
const backupKey = getUniqueKey();
|
|
localStorage.setItem(backupKey, JSON.stringify(files));
|
|
} catch (e) {
|
|
console.warn("localStorage 백업 실패:", e);
|
|
}
|
|
}
|
|
};
|
|
|
|
if (typeof window !== "undefined") {
|
|
window.addEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener);
|
|
|
|
return () => {
|
|
window.removeEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener);
|
|
};
|
|
}
|
|
}, [component.id, uploadedFiles.length]);
|
|
|
|
// 파일 업로드 설정 - componentConfig가 undefined일 수 있으므로 안전하게 처리
|
|
const safeComponentConfig = componentConfig || {};
|
|
const fileConfig = {
|
|
accept: safeComponentConfig.accept || "*/*",
|
|
multiple: safeComponentConfig.multiple || false,
|
|
maxSize: safeComponentConfig.maxSize || 10 * 1024 * 1024, // 10MB
|
|
maxFiles: safeComponentConfig.maxFiles || 5,
|
|
...safeComponentConfig,
|
|
} as FileUploadConfig;
|
|
|
|
// 파일 선택 핸들러
|
|
const handleFileSelect = useCallback(() => {
|
|
console.log("🎯 handleFileSelect 호출됨:", {
|
|
hasFileInputRef: !!fileInputRef.current,
|
|
fileInputRef: fileInputRef.current,
|
|
fileInputType: fileInputRef.current?.type,
|
|
fileInputHidden: fileInputRef.current?.className,
|
|
});
|
|
|
|
if (fileInputRef.current) {
|
|
console.log("✅ fileInputRef.current.click() 호출");
|
|
fileInputRef.current.click();
|
|
} else {
|
|
console.log("❌ fileInputRef.current가 null입니다");
|
|
}
|
|
}, []);
|
|
|
|
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = Array.from(e.target.files || []);
|
|
if (files.length > 0) {
|
|
handleFileUpload(files);
|
|
}
|
|
}, []);
|
|
|
|
// 파일 업로드 처리
|
|
const handleFileUpload = useCallback(
|
|
async (files: File[]) => {
|
|
if (!files.length) return;
|
|
|
|
// 중복 파일 체크
|
|
const existingFileNames = uploadedFiles.map((f) => f.realFileName.toLowerCase());
|
|
const duplicates: string[] = [];
|
|
const uniqueFiles: File[] = [];
|
|
|
|
files.forEach((file) => {
|
|
const fileName = file.name.toLowerCase();
|
|
if (existingFileNames.includes(fileName)) {
|
|
duplicates.push(file.name);
|
|
} else {
|
|
uniqueFiles.push(file);
|
|
}
|
|
});
|
|
|
|
if (duplicates.length > 0) {
|
|
toast.error(`중복된 파일이 있습니다: ${duplicates.join(", ")}`, {
|
|
description: "같은 이름의 파일이 이미 업로드되어 있습니다.",
|
|
duration: 4000,
|
|
});
|
|
|
|
if (uniqueFiles.length === 0) {
|
|
return; // 모든 파일이 중복이면 업로드 중단
|
|
}
|
|
|
|
// 일부만 중복인 경우 고유한 파일만 업로드
|
|
toast.info(`${uniqueFiles.length}개의 새로운 파일만 업로드합니다.`);
|
|
}
|
|
|
|
const filesToUpload = uniqueFiles.length > 0 ? uniqueFiles : files;
|
|
setUploadStatus("uploading");
|
|
toast.loading("파일을 업로드하는 중...", { id: "file-upload" });
|
|
|
|
try {
|
|
// 🔑 레코드 모드 우선 사용
|
|
const effectiveTableName = recordTableName || formData?.tableName || component.tableName || "default_table";
|
|
const effectiveRecordId = recordId || formData?.id;
|
|
const effectiveColumnName = columnName;
|
|
|
|
// screenId 추출 (우선순위: formData > URL)
|
|
let screenId = formData?.screenId;
|
|
if (!screenId && typeof window !== "undefined") {
|
|
const pathname = window.location.pathname;
|
|
const screenMatch = pathname.match(/\/screens\/(\d+)/);
|
|
if (screenMatch) {
|
|
screenId = parseInt(screenMatch[1]);
|
|
}
|
|
}
|
|
|
|
let targetObjid;
|
|
// 🔑 레코드 모드 판단 개선
|
|
const effectiveIsRecordMode = isRecordMode || (effectiveRecordId && !String(effectiveRecordId).startsWith('temp_'));
|
|
|
|
if (effectiveIsRecordMode && effectiveTableName && effectiveRecordId) {
|
|
// 🎯 레코드 모드: 특정 행에 파일 연결
|
|
targetObjid = `${effectiveTableName}:${effectiveRecordId}:${effectiveColumnName}`;
|
|
console.log("📁 [레코드 모드] 파일 업로드:", {
|
|
targetObjid,
|
|
tableName: effectiveTableName,
|
|
recordId: effectiveRecordId,
|
|
columnName: effectiveColumnName,
|
|
});
|
|
} else if (screenId) {
|
|
// 🔑 템플릿 파일 (백엔드 조회 형식과 동일하게)
|
|
targetObjid = `screen_files:${screenId}:${component.id}:${effectiveColumnName}`;
|
|
console.log("📝 [템플릿 모드] 파일 업로드:", targetObjid);
|
|
} else {
|
|
// 기본값 (화면관리에서 사용)
|
|
targetObjid = `temp_${component.id}`;
|
|
console.log("📝 [기본 모드] 파일 업로드:", targetObjid);
|
|
}
|
|
|
|
// 🔒 현재 사용자의 회사 코드 가져오기 (멀티테넌시 격리)
|
|
const userCompanyCode = user?.companyCode || (window as any).__user__?.companyCode;
|
|
|
|
console.log("📤 [FileUploadComponent] 파일 업로드 준비:", {
|
|
userCompanyCode,
|
|
isRecordMode: effectiveIsRecordMode,
|
|
tableName: effectiveTableName,
|
|
recordId: effectiveRecordId,
|
|
columnName: effectiveColumnName,
|
|
targetObjid,
|
|
});
|
|
|
|
// 🔑 레코드 모드일 때는 effectiveTableName을 우선 사용
|
|
// formData.linkedTable이 'screen_files' 같은 기본값일 수 있으므로 레코드 모드에서는 무시
|
|
const finalLinkedTable = effectiveIsRecordMode
|
|
? effectiveTableName
|
|
: (formData?.linkedTable || effectiveTableName);
|
|
|
|
const uploadData = {
|
|
// 🎯 formData에서 백엔드 API 설정 가져오기
|
|
autoLink: formData?.autoLink || true,
|
|
linkedTable: finalLinkedTable,
|
|
recordId: effectiveRecordId || `temp_${component.id}`,
|
|
columnName: effectiveColumnName,
|
|
isVirtualFileColumn: formData?.isVirtualFileColumn || true,
|
|
docType: component.fileConfig?.docType || "DOCUMENT",
|
|
docTypeName: component.fileConfig?.docTypeName || "일반 문서",
|
|
companyCode: userCompanyCode, // 🔒 멀티테넌시: 회사 코드 명시적 전달
|
|
// 호환성을 위한 기존 필드들
|
|
tableName: effectiveTableName,
|
|
fieldName: effectiveColumnName,
|
|
targetObjid: targetObjid, // InteractiveDataTable 호환을 위한 targetObjid 추가
|
|
// 🆕 레코드 모드 플래그
|
|
isRecordMode: effectiveIsRecordMode,
|
|
};
|
|
|
|
console.log("📤 [FileUploadComponent] uploadData 최종:", {
|
|
isRecordMode: effectiveIsRecordMode,
|
|
linkedTable: finalLinkedTable,
|
|
recordId: effectiveRecordId,
|
|
columnName: effectiveColumnName,
|
|
targetObjid,
|
|
});
|
|
|
|
|
|
console.log("🚀 [FileUploadComponent] uploadFiles API 호출 직전:", {
|
|
filesCount: filesToUpload.length,
|
|
uploadData,
|
|
});
|
|
|
|
const response = await uploadFiles({
|
|
files: filesToUpload,
|
|
...uploadData,
|
|
});
|
|
|
|
console.log("📥 [FileUploadComponent] uploadFiles API 응답:", response);
|
|
|
|
if (response.success) {
|
|
// FileUploadResponse 타입에 맞게 files 배열 사용
|
|
const fileData = response.files || (response as any).data || [];
|
|
|
|
if (fileData.length === 0) {
|
|
throw new Error("업로드된 파일 데이터를 받지 못했습니다.");
|
|
}
|
|
|
|
const newFiles = fileData.map((file: any) => ({
|
|
objid: file.objid || file.id,
|
|
savedFileName: file.saved_file_name || file.savedFileName,
|
|
realFileName: file.real_file_name || file.realFileName || file.name,
|
|
fileSize: file.file_size || file.fileSize || file.size,
|
|
fileExt: file.file_ext || file.fileExt || file.extension,
|
|
filePath: file.file_path || file.filePath || file.path,
|
|
docType: file.doc_type || file.docType,
|
|
docTypeName: file.doc_type_name || file.docTypeName,
|
|
targetObjid: file.target_objid || file.targetObjid,
|
|
parentTargetObjid: file.parent_target_objid || file.parentTargetObjid,
|
|
companyCode: file.company_code || file.companyCode,
|
|
writer: file.writer,
|
|
regdate: file.regdate,
|
|
status: file.status || "ACTIVE",
|
|
uploadedAt: new Date().toISOString(),
|
|
...file,
|
|
}));
|
|
|
|
|
|
const updatedFiles = [...uploadedFiles, ...newFiles];
|
|
|
|
setUploadedFiles(updatedFiles);
|
|
setUploadStatus("success");
|
|
|
|
// localStorage 백업 (레코드별 고유 키 사용)
|
|
try {
|
|
const backupKey = getUniqueKey();
|
|
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
|
|
} catch (e) {
|
|
console.warn("localStorage 백업 실패:", e);
|
|
}
|
|
|
|
// 전역 상태 업데이트 (모든 파일 컴포넌트 동기화)
|
|
if (typeof window !== "undefined") {
|
|
// 전역 파일 상태 업데이트 (레코드별 고유 키 사용)
|
|
const globalFileState = (window as any).globalFileState || {};
|
|
const uniqueKey = getUniqueKey();
|
|
globalFileState[uniqueKey] = updatedFiles;
|
|
(window as any).globalFileState = globalFileState;
|
|
|
|
// 🌐 전역 파일 저장소에 새 파일 등록 (페이지 간 공유용)
|
|
GlobalFileManager.registerFiles(newFiles, {
|
|
uploadPage: window.location.pathname,
|
|
componentId: component.id,
|
|
screenId: formData?.screenId,
|
|
recordId: recordId, // 🆕 레코드 ID 추가
|
|
});
|
|
|
|
// 모든 파일 컴포넌트에 동기화 이벤트 발생
|
|
const syncEvent = new CustomEvent("globalFileStateChanged", {
|
|
detail: {
|
|
componentId: component.id,
|
|
uniqueKey: uniqueKey, // 🆕 고유 키 추가
|
|
recordId: recordId, // 🆕 레코드 ID 추가
|
|
files: updatedFiles,
|
|
fileCount: updatedFiles.length,
|
|
timestamp: Date.now(),
|
|
},
|
|
});
|
|
window.dispatchEvent(syncEvent);
|
|
|
|
console.log("🌐 전역 파일 상태 업데이트 및 동기화 이벤트 발생:", {
|
|
componentId: component.id,
|
|
fileCount: updatedFiles.length,
|
|
globalState: Object.keys(globalFileState).map((id) => ({
|
|
id,
|
|
fileCount: globalFileState[id]?.length || 0,
|
|
})),
|
|
});
|
|
}
|
|
|
|
// 컴포넌트 업데이트
|
|
if (onUpdate) {
|
|
const timestamp = Date.now();
|
|
console.log("🔄 onUpdate 호출:", {
|
|
componentId: component.id,
|
|
uploadedFiles: updatedFiles.length,
|
|
timestamp: timestamp,
|
|
});
|
|
onUpdate({
|
|
uploadedFiles: updatedFiles,
|
|
lastFileUpdate: timestamp,
|
|
});
|
|
} else {
|
|
console.warn("⚠️ onUpdate 콜백이 없습니다!");
|
|
}
|
|
|
|
// 🆕 레코드 모드: attachments 컬럼 동기화 (formData 업데이트)
|
|
if (effectiveIsRecordMode && onFormDataChange) {
|
|
// 파일 정보를 간소화하여 attachments 컬럼에 저장할 형태로 변환
|
|
const attachmentsData = updatedFiles.map(file => ({
|
|
objid: file.objid,
|
|
realFileName: file.realFileName,
|
|
fileSize: file.fileSize,
|
|
fileExt: file.fileExt,
|
|
filePath: file.filePath,
|
|
regdate: file.regdate || new Date().toISOString(),
|
|
}));
|
|
|
|
console.log("📎 [레코드 모드] attachments 컬럼 동기화:", {
|
|
tableName: effectiveTableName,
|
|
recordId: effectiveRecordId,
|
|
columnName: effectiveColumnName,
|
|
fileCount: attachmentsData.length,
|
|
});
|
|
|
|
// onFormDataChange를 통해 부모 컴포넌트에 attachments 업데이트 알림
|
|
onFormDataChange({
|
|
[effectiveColumnName]: attachmentsData,
|
|
// 🆕 백엔드에서 attachments 컬럼 업데이트를 위한 메타 정보
|
|
__attachmentsUpdate: {
|
|
tableName: effectiveTableName,
|
|
recordId: effectiveRecordId,
|
|
columnName: effectiveColumnName,
|
|
files: attachmentsData,
|
|
}
|
|
});
|
|
}
|
|
|
|
// 그리드 파일 상태 새로고침 이벤트 발생
|
|
if (typeof window !== "undefined") {
|
|
const refreshEvent = new CustomEvent("refreshFileStatus", {
|
|
detail: {
|
|
tableName: effectiveTableName,
|
|
recordId: effectiveRecordId,
|
|
columnName: effectiveColumnName,
|
|
targetObjid: targetObjid,
|
|
fileCount: updatedFiles.length,
|
|
},
|
|
});
|
|
window.dispatchEvent(refreshEvent);
|
|
console.log("🔄 그리드 파일 상태 새로고침 이벤트 발생:", {
|
|
tableName: effectiveTableName,
|
|
recordId: effectiveRecordId,
|
|
columnName: effectiveColumnName,
|
|
targetObjid,
|
|
fileCount: updatedFiles.length,
|
|
});
|
|
}
|
|
|
|
// 폼 데이터 업데이트
|
|
if (onFormDataChange && component.columnName) {
|
|
const fileIds = updatedFiles.map((f) => f.objid);
|
|
onFormDataChange({
|
|
...formData,
|
|
[component.columnName]: fileIds,
|
|
});
|
|
}
|
|
|
|
// 컴포넌트 설정 콜백
|
|
if (safeComponentConfig.onFileUpload) {
|
|
safeComponentConfig.onFileUpload(newFiles);
|
|
}
|
|
|
|
// 성공 시 토스트 처리
|
|
setUploadStatus("idle");
|
|
toast.dismiss("file-upload");
|
|
toast.success(`${newFiles.length}개 파일 업로드 완료`);
|
|
} else {
|
|
console.error("❌ 파일 업로드 실패:", response);
|
|
throw new Error(response.message || (response as any).error || "파일 업로드에 실패했습니다.");
|
|
}
|
|
} catch (error) {
|
|
console.error("파일 업로드 오류:", error);
|
|
setUploadStatus("error");
|
|
toast.dismiss("file-upload");
|
|
showErrorToast("파일 업로드에 실패했습니다", error, { guidance: "파일 크기와 형식을 확인하고 다시 시도해 주세요." });
|
|
}
|
|
},
|
|
[safeComponentConfig, uploadedFiles, onFormDataChange, component.columnName, component.id, formData],
|
|
);
|
|
|
|
// 파일 뷰어 열기
|
|
const handleFileView = useCallback((file: FileInfo) => {
|
|
setViewerFile(file);
|
|
setIsViewerOpen(true);
|
|
}, []);
|
|
|
|
// 파일 뷰어 닫기
|
|
const handleViewerClose = useCallback(() => {
|
|
setIsViewerOpen(false);
|
|
setViewerFile(null);
|
|
}, []);
|
|
|
|
// 파일 다운로드
|
|
const handleFileDownload = useCallback(async (file: FileInfo) => {
|
|
try {
|
|
await downloadFile({
|
|
fileId: file.objid,
|
|
serverFilename: file.savedFileName,
|
|
originalName: file.realFileName,
|
|
});
|
|
toast.success(`${file.realFileName} 다운로드 완료`);
|
|
} catch (error) {
|
|
console.error("파일 다운로드 오류:", error);
|
|
toast.error("파일 다운로드에 실패했습니다.");
|
|
}
|
|
}, []);
|
|
|
|
// 파일 삭제
|
|
const handleFileDelete = useCallback(
|
|
async (file: FileInfo | string) => {
|
|
try {
|
|
const fileId = typeof file === "string" ? file : file.objid;
|
|
const fileName = typeof file === "string" ? "파일" : file.realFileName;
|
|
|
|
const serverFilename = typeof file === "string" ? "temp_file" : file.savedFileName;
|
|
await deleteFile(fileId, serverFilename);
|
|
|
|
const updatedFiles = uploadedFiles.filter((f) => f.objid !== fileId);
|
|
setUploadedFiles(updatedFiles);
|
|
|
|
// localStorage 백업 업데이트 (레코드별 고유 키 사용)
|
|
try {
|
|
const backupKey = getUniqueKey();
|
|
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
|
|
} catch (e) {
|
|
console.warn("localStorage 백업 업데이트 실패:", e);
|
|
}
|
|
|
|
// 전역 상태 업데이트 (모든 파일 컴포넌트 동기화)
|
|
if (typeof window !== "undefined") {
|
|
// 전역 파일 상태 업데이트 (레코드별 고유 키 사용)
|
|
const globalFileState = (window as any).globalFileState || {};
|
|
const uniqueKey = getUniqueKey();
|
|
globalFileState[uniqueKey] = updatedFiles;
|
|
(window as any).globalFileState = globalFileState;
|
|
|
|
// 모든 파일 컴포넌트에 동기화 이벤트 발생
|
|
const syncEvent = new CustomEvent("globalFileStateChanged", {
|
|
detail: {
|
|
componentId: component.id,
|
|
uniqueKey: uniqueKey, // 🆕 고유 키 추가
|
|
recordId: recordId, // 🆕 레코드 ID 추가
|
|
files: updatedFiles,
|
|
fileCount: updatedFiles.length,
|
|
timestamp: Date.now(),
|
|
source: "realScreen", // 🎯 실제 화면에서 온 이벤트임을 표시
|
|
action: "delete",
|
|
},
|
|
});
|
|
window.dispatchEvent(syncEvent);
|
|
|
|
console.log("🗑️ 파일 삭제 후 전역 상태 동기화:", {
|
|
componentId: component.id,
|
|
deletedFile: fileName,
|
|
remainingFiles: updatedFiles.length,
|
|
});
|
|
}
|
|
|
|
// 컴포넌트 업데이트
|
|
if (onUpdate) {
|
|
const timestamp = Date.now();
|
|
onUpdate({
|
|
uploadedFiles: updatedFiles,
|
|
lastFileUpdate: timestamp,
|
|
});
|
|
}
|
|
|
|
// 🆕 레코드 모드: attachments 컬럼 동기화 (파일 삭제 후)
|
|
if (isRecordMode && onFormDataChange && recordTableName && recordId) {
|
|
const attachmentsData = updatedFiles.map(f => ({
|
|
objid: f.objid,
|
|
realFileName: f.realFileName,
|
|
fileSize: f.fileSize,
|
|
fileExt: f.fileExt,
|
|
filePath: f.filePath,
|
|
regdate: f.regdate || new Date().toISOString(),
|
|
}));
|
|
|
|
console.log("📎 [레코드 모드] 파일 삭제 후 attachments 동기화:", {
|
|
tableName: recordTableName,
|
|
recordId: recordId,
|
|
columnName: columnName,
|
|
remainingFiles: attachmentsData.length,
|
|
});
|
|
|
|
onFormDataChange({
|
|
[columnName]: attachmentsData,
|
|
__attachmentsUpdate: {
|
|
tableName: recordTableName,
|
|
recordId: recordId,
|
|
columnName: columnName,
|
|
files: attachmentsData,
|
|
}
|
|
});
|
|
}
|
|
|
|
toast.success(`${fileName} 삭제 완료`);
|
|
} catch (error) {
|
|
console.error("파일 삭제 오류:", error);
|
|
showErrorToast("파일 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
|
|
}
|
|
},
|
|
[uploadedFiles, onUpdate, component.id, isRecordMode, onFormDataChange, recordTableName, recordId, columnName, getUniqueKey],
|
|
);
|
|
|
|
// 대표 이미지 Blob URL 로드
|
|
const loadRepresentativeImage = useCallback(
|
|
async (file: FileInfo) => {
|
|
try {
|
|
const isImage = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(
|
|
file.fileExt.toLowerCase().replace(".", "")
|
|
);
|
|
|
|
if (!isImage) {
|
|
setRepresentativeImageUrl(null);
|
|
return;
|
|
}
|
|
|
|
// objid가 없거나 유효하지 않으면 로드 중단
|
|
if (!file.objid || file.objid === "0" || file.objid === "") {
|
|
console.warn("⚠️ 대표 이미지 로드 실패: objid가 없음", file);
|
|
setRepresentativeImageUrl(null);
|
|
return;
|
|
}
|
|
|
|
console.log("🖼️ 대표 이미지 로드 시작:", {
|
|
objid: file.objid,
|
|
fileName: file.realFileName,
|
|
});
|
|
|
|
// API 클라이언트를 통해 Blob으로 다운로드 (인증 토큰 포함)
|
|
const response = await apiClient.get(`/files/download/${file.objid}`, {
|
|
params: {
|
|
serverFilename: file.savedFileName,
|
|
},
|
|
responseType: "blob",
|
|
});
|
|
|
|
// Blob URL 생성
|
|
const blob = new Blob([response.data]);
|
|
const url = window.URL.createObjectURL(blob);
|
|
|
|
// 이전 URL 정리
|
|
if (representativeImageUrl) {
|
|
window.URL.revokeObjectURL(representativeImageUrl);
|
|
}
|
|
|
|
setRepresentativeImageUrl(url);
|
|
console.log("✅ 대표 이미지 로드 성공:", url);
|
|
} catch (error: any) {
|
|
console.error("❌ 대표 이미지 로드 실패:", {
|
|
file: file.realFileName,
|
|
objid: file.objid,
|
|
error: error?.response?.status || error?.message,
|
|
});
|
|
setRepresentativeImageUrl(null);
|
|
}
|
|
},
|
|
[representativeImageUrl],
|
|
);
|
|
|
|
// 대표 이미지 설정 핸들러
|
|
const handleSetRepresentative = useCallback(
|
|
async (file: FileInfo) => {
|
|
try {
|
|
// API 호출하여 DB에 대표 파일 설정
|
|
const { setRepresentativeFile } = await import("@/lib/api/file");
|
|
await setRepresentativeFile(file.objid);
|
|
|
|
// 상태 업데이트
|
|
const updatedFiles = uploadedFiles.map((f) => ({
|
|
...f,
|
|
isRepresentative: f.objid === file.objid,
|
|
}));
|
|
|
|
setUploadedFiles(updatedFiles);
|
|
|
|
// 대표 이미지 로드
|
|
loadRepresentativeImage(file);
|
|
|
|
console.log("✅ 대표 파일 설정 완료:", {
|
|
componentId: component.id,
|
|
representativeFile: file.realFileName,
|
|
objid: file.objid,
|
|
});
|
|
} catch (e) {
|
|
console.error("❌ 대표 파일 설정 실패:", e);
|
|
}
|
|
},
|
|
[uploadedFiles, component.id, loadRepresentativeImage]
|
|
);
|
|
|
|
// uploadedFiles 변경 시 대표 이미지 로드
|
|
useEffect(() => {
|
|
const representativeFile = uploadedFiles.find(f => f.isRepresentative) || uploadedFiles[0];
|
|
if (representativeFile) {
|
|
loadRepresentativeImage(representativeFile);
|
|
} else {
|
|
setRepresentativeImageUrl(null);
|
|
}
|
|
|
|
// 컴포넌트 언마운트 시 Blob URL 정리
|
|
return () => {
|
|
if (representativeImageUrl) {
|
|
window.URL.revokeObjectURL(representativeImageUrl);
|
|
}
|
|
};
|
|
}, [uploadedFiles]);
|
|
|
|
// 드래그 앤 드롭 핸들러
|
|
const handleDragOver = useCallback(
|
|
(e: React.DragEvent) => {
|
|
console.log("🎯 드래그 오버 이벤트 감지:", {
|
|
readonly: safeComponentConfig.readonly,
|
|
disabled: safeComponentConfig.disabled,
|
|
dragOver: dragOver,
|
|
});
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!safeComponentConfig.readonly && !safeComponentConfig.disabled) {
|
|
setDragOver(true);
|
|
console.log("✅ 드래그 오버 활성화");
|
|
} else {
|
|
console.log("❌ 드래그 차단됨: readonly 또는 disabled");
|
|
}
|
|
},
|
|
[safeComponentConfig.readonly, safeComponentConfig.disabled, dragOver],
|
|
);
|
|
|
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setDragOver(false);
|
|
}, []);
|
|
|
|
const handleDrop = useCallback(
|
|
(e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setDragOver(false);
|
|
|
|
if (!safeComponentConfig.readonly && !safeComponentConfig.disabled) {
|
|
const files = Array.from(e.dataTransfer.files);
|
|
if (files.length > 0) {
|
|
handleFileUpload(files);
|
|
}
|
|
}
|
|
},
|
|
[safeComponentConfig.readonly, safeComponentConfig.disabled, handleFileUpload],
|
|
);
|
|
|
|
// 클릭 핸들러
|
|
const handleClick = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
console.log("🖱️ 파일 업로드 영역 클릭:", {
|
|
readonly: safeComponentConfig.readonly,
|
|
disabled: safeComponentConfig.disabled,
|
|
hasHandleFileSelect: !!handleFileSelect,
|
|
});
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!safeComponentConfig.readonly && !safeComponentConfig.disabled) {
|
|
console.log("✅ 파일 선택 함수 호출");
|
|
handleFileSelect();
|
|
} else {
|
|
console.log("❌ 클릭 차단됨: readonly 또는 disabled");
|
|
}
|
|
onClick?.();
|
|
},
|
|
[safeComponentConfig.readonly, safeComponentConfig.disabled, handleFileSelect, onClick],
|
|
);
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
...componentStyle,
|
|
width: "100%", // 🆕 부모 컨테이너 너비에 맞춤
|
|
height: "100%", // 🆕 부모 컨테이너 높이에 맞춤
|
|
border: "none !important",
|
|
boxShadow: "none !important",
|
|
outline: "none !important",
|
|
backgroundColor: "transparent !important",
|
|
padding: "0px !important",
|
|
borderRadius: "0px !important",
|
|
marginBottom: "8px !important",
|
|
}}
|
|
className={`${className} file-upload-container`}
|
|
>
|
|
{/* 라벨 렌더링 */}
|
|
{component.label && component.style?.labelDisplay !== false && (
|
|
<label
|
|
style={{
|
|
position: "absolute",
|
|
top: "-20px",
|
|
left: "0px",
|
|
fontSize: "12px",
|
|
color: "rgb(107, 114, 128)",
|
|
fontWeight: "400",
|
|
background: "transparent !important",
|
|
border: "none !important",
|
|
boxShadow: "none !important",
|
|
outline: "none !important",
|
|
padding: "0px !important",
|
|
margin: "0px !important"
|
|
}}
|
|
>
|
|
{component.label}
|
|
{component.required && (
|
|
<span style={{ color: "#ef4444" }}>*</span>
|
|
)}
|
|
</label>
|
|
)}
|
|
|
|
<div
|
|
className="border-border bg-card relative flex h-full w-full flex-col rounded-lg border overflow-hidden"
|
|
>
|
|
{/* 대표 이미지 전체 화면 표시 */}
|
|
{uploadedFiles.length > 0 ? (() => {
|
|
const representativeFile = uploadedFiles.find(f => f.isRepresentative) || uploadedFiles[0];
|
|
const isImage = representativeFile && ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(
|
|
representativeFile.fileExt.toLowerCase().replace(".", "")
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{isImage && representativeImageUrl ? (
|
|
<div className="relative h-full w-full flex items-center justify-center bg-muted/10">
|
|
<img
|
|
src={representativeImageUrl}
|
|
alt={representativeFile.realFileName}
|
|
className="h-full w-full object-contain"
|
|
/>
|
|
</div>
|
|
) : isImage && !representativeImageUrl ? (
|
|
<div className="flex h-full w-full flex-col items-center justify-center">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-2"></div>
|
|
<p className="text-sm text-muted-foreground">이미지 로딩 중...</p>
|
|
</div>
|
|
) : (
|
|
<div className="flex h-full w-full flex-col items-center justify-center">
|
|
{getFileIcon(representativeFile.fileExt)}
|
|
<p className="mt-3 text-sm font-medium text-center px-4">
|
|
{representativeFile.realFileName}
|
|
</p>
|
|
<Badge variant="secondary" className="mt-2">
|
|
대표 파일
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
|
|
{/* 우측 하단 자세히보기 버튼 */}
|
|
<div className="absolute bottom-3 right-3">
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
className="h-8 px-3 text-xs shadow-md"
|
|
onClick={() => setIsFileManagerOpen(true)}
|
|
>
|
|
자세히보기 ({uploadedFiles.length})
|
|
</Button>
|
|
</div>
|
|
</>
|
|
);
|
|
})() : (
|
|
<div className="flex h-full w-full flex-col items-center justify-center text-muted-foreground">
|
|
<File className="mb-3 h-12 w-12" />
|
|
<p className="text-sm font-medium">업로드된 파일이 없습니다</p>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="mt-4 h-8 px-3 text-xs"
|
|
onClick={() => setIsFileManagerOpen(true)}
|
|
>
|
|
파일 업로드
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 파일뷰어 모달 */}
|
|
<FileViewerModal
|
|
file={viewerFile}
|
|
isOpen={isViewerOpen}
|
|
onClose={handleViewerClose}
|
|
onDownload={handleFileDownload}
|
|
onDelete={!isDesignMode ? handleFileDelete : undefined}
|
|
/>
|
|
|
|
{/* 파일 관리 모달 */}
|
|
<FileManagerModal
|
|
isOpen={isFileManagerOpen}
|
|
onClose={() => setIsFileManagerOpen(false)}
|
|
uploadedFiles={uploadedFiles}
|
|
onFileUpload={handleFileUpload}
|
|
onFileDownload={handleFileDownload}
|
|
onFileDelete={handleFileDelete}
|
|
onFileView={handleFileView}
|
|
onSetRepresentative={handleSetRepresentative}
|
|
config={safeComponentConfig}
|
|
isDesignMode={isDesignMode}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export { FileUploadComponent };
|
|
export default FileUploadComponent;
|