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 { uploadFiles, downloadFile, deleteFile, getComponentFiles, getFileInfoByObjid } 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 ; } if (["mp4", "avi", "mov", "wmv", "flv", "webm"].includes(ext)) { return ; } if (["mp3", "wav", "flac", "aac", "ogg"].includes(ext)) { return ; } if (["pdf"].includes(ext)) { return ; } if (["doc", "docx", "hwp", "hwpx", "pages"].includes(ext)) { return ; } if (["xls", "xlsx", "hcell", "numbers"].includes(ext)) { return ; } if (["ppt", "pptx", "hanshow", "keynote"].includes(ext)) { return ; } if (["zip", "rar", "7z", "tar", "gz"].includes(ext)) { return ; } return ; }; export interface FileUploadComponentProps { component: any; componentConfig: FileUploadConfig; componentStyle: React.CSSProperties; className: string; isInteractive: boolean; isDesignMode: boolean; formData: any; onFormDataChange: (fieldName: string, value: any) => void; onClick?: () => void; onDragStart?: (e: React.DragEvent) => void; onDragEnd?: (e: React.DragEvent) => void; onUpdate?: (updates: Partial) => void; autoGeneration?: any; hidden?: boolean; onConfigChange?: (config: any) => void; } const FileUploadComponent: React.FC = ({ component, componentConfig, componentStyle, className, isInteractive, isDesignMode = false, // 기본값 설정 formData, onFormDataChange, onClick, onDragStart, onDragEnd, onUpdate, }) => { // 🔑 인증 정보 가져오기 const { user } = useAuth(); const [uploadedFiles, setUploadedFiles] = useState([]); const [uploadStatus, setUploadStatus] = useState("idle"); const [dragOver, setDragOver] = useState(false); const [viewerFile, setViewerFile] = useState(null); const [isViewerOpen, setIsViewerOpen] = useState(false); const [isFileManagerOpen, setIsFileManagerOpen] = useState(false); const [forceUpdate, setForceUpdate] = useState(0); const [representativeImageUrl, setRepresentativeImageUrl] = useState(null); const fileInputRef = useRef(null); // 🔑 레코드 모드 판단: formData에 id가 있으면 특정 행에 연결된 파일 관리 const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_')); const recordTableName = formData?.tableName || component.tableName; const recordId = formData?.id; // 🔑 컬럼명 결정: component.columnName을 우선 사용 (실제 DB 컬럼명) // image, file 등의 웹타입 컬럼에 URL이 저장되어야 함 const columnName = component.columnName || component.id || 'attachments'; // 🔑 레코드 모드용 targetObjid 생성 const getRecordTargetObjid = useCallback(() => { if (isRecordMode && recordTableName && recordId) { return `${recordTableName}:${recordId}:${columnName}`; } return null; }, [isRecordMode, recordTableName, recordId, columnName]); // 🔑 레코드별 고유 키 생성 (localStorage, 전역 상태용) // 🆕 columnName을 포함하여 같은 화면의 여러 파일 업로드 컴포넌트 구분 const getUniqueKey = useCallback(() => { if (isRecordMode && recordTableName && recordId) { // 레코드 모드: 테이블명:레코드ID:컴포넌트ID:컬럼명 형태로 고유 키 생성 return `fileUpload_${recordTableName}_${recordId}_${component.id}_${columnName}`; } // 기본 모드: 컴포넌트 ID + 컬럼명 사용 return `fileUpload_${component.id}_${columnName}`; }, [isRecordMode, recordTableName, recordId, component.id, columnName]); // 🆕 레코드 ID 변경 시 파일 목록 초기화 및 새로 로드 const prevRecordIdRef = useRef(null); const prevIsRecordModeRef = useRef(null); useEffect(() => { const recordIdChanged = prevRecordIdRef.current !== recordId; const modeChanged = prevIsRecordModeRef.current !== null && prevIsRecordModeRef.current !== isRecordMode; if (recordIdChanged || modeChanged) { prevRecordIdRef.current = recordId; prevIsRecordModeRef.current = isRecordMode; // 레코드 ID가 변경되거나 등록 모드(isRecordMode=false)로 전환되면 파일 목록 초기화 // 등록 모드에서는 항상 빈 상태로 시작해야 함 if (isRecordMode || !recordId) { setUploadedFiles([]); setRepresentativeImageUrl(null); } } else if (prevIsRecordModeRef.current === null) { // 초기 마운트 시 모드 저장 prevIsRecordModeRef.current = isRecordMode; } }, [recordId, isRecordMode]); // 컴포넌트 마운트 시 즉시 localStorage에서 파일 복원 // 🔑 등록 모드(recordId가 없는 경우)에서는 파일을 복원하지 않음 - 항상 빈 상태로 시작 useEffect(() => { if (!component?.id) return; // 등록 모드(새 레코드)인 경우 파일 복원 스킵 - 빈 상태 유지 if (!isRecordMode || !recordId) { return; } try { // 🔑 레코드별 고유 키 사용 (수정 모드에서만) const backupKey = getUniqueKey(); const backupFiles = localStorage.getItem(backupKey); if (backupFiles) { const parsedFiles = JSON.parse(backupFiles); if (parsedFiles.length > 0) { 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, isRecordMode]); // 레코드별 고유 키 변경 시 재실행 // 🔑 수정 모드: formData[columnName]에 저장된 objid로 이미지 로드 // 🆕 formData 전체가 아닌 특정 컬럼 값만 의존하도록 수정 (다른 컴포넌트 영향 방지) const imageObjidFromFormData = formData?.[columnName]; useEffect(() => { // 이미지 objid가 있고, 숫자 문자열인 경우에만 처리 if (imageObjidFromFormData && /^\d+$/.test(String(imageObjidFromFormData))) { const objidStr = String(imageObjidFromFormData); // 이미 같은 objid의 파일이 로드되어 있으면 스킵 const alreadyLoaded = uploadedFiles.some(f => String(f.objid) === objidStr); if (alreadyLoaded) { return; } const previewUrl = `/api/files/preview/${objidStr}`; // 🔑 실제 파일 정보 조회 (async () => { try { const fileInfoResponse = await getFileInfoByObjid(objidStr); if (fileInfoResponse.success && fileInfoResponse.data) { const { realFileName, fileSize, fileExt, regdate, isRepresentative } = fileInfoResponse.data; const fileInfo = { objid: objidStr, realFileName: realFileName, fileExt: fileExt, fileSize: fileSize, filePath: previewUrl, regdate: regdate, isImage: true, previewUrl: previewUrl, isRepresentative: isRepresentative, }; setUploadedFiles([fileInfo]); setRepresentativeImageUrl(previewUrl); } else { // 파일 정보 조회 실패 시 최소 정보로 추가 console.warn("🖼️ [FileUploadComponent] 파일 정보 조회 실패, 최소 정보 사용"); const minimalFileInfo = { objid: objidStr, realFileName: `image_${objidStr}.jpg`, fileExt: '.jpg', fileSize: 0, filePath: previewUrl, regdate: new Date().toISOString(), isImage: true, previewUrl: previewUrl, }; setUploadedFiles([minimalFileInfo]); setRepresentativeImageUrl(previewUrl); } } catch (error) { console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error); } })(); } }, [imageObjidFromFormData, columnName, component.id]); // 🆕 formData 대신 특정 컬럼 값만 의존 // 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너 // 🆕 columnName도 체크하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분 useEffect(() => { const handleDesignModeFileChange = (event: CustomEvent) => { const eventColumnName = event.detail.eventColumnName || event.detail.columnName; // 🆕 고유 키 또는 (컴포넌트ID + 컬럼명) 조합으로 체크 const isForThisComponent = (event.detail.uniqueKey && event.detail.uniqueKey === currentUniqueKey) || (event.detail.componentId === component.id && eventColumnName === columnName) || (event.detail.componentId === component.id && !eventColumnName); // 이전 호환성 // 🆕 현재 컴포넌트와 일치하고 화면설계 모드에서 온 이벤트인 경우 if (isForThisComponent && event.detail.source === "designMode") { // 파일 상태 업데이트 const newFiles = event.detail.files || []; setUploadedFiles(newFiles); // localStorage 백업 업데이트 (레코드별 고유 키 사용) try { const backupKey = currentUniqueKey; localStorage.setItem(backupKey, JSON.stringify(newFiles)); } catch (e) { console.warn("localStorage 백업 업데이트 실패:", e); } // 전역 상태 업데이트 (🆕 고유 키 사용) if (typeof window !== "undefined") { (window as any).globalFileState = { ...(window as any).globalFileState, [currentUniqueKey]: newFiles, }; } // onUpdate 콜백 호출 (부모 컴포넌트에 알림) if (onUpdate) { onUpdate({ uploadedFiles: newFiles, lastFileUpdate: event.detail.timestamp, }); } } }; 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; // 🔑 등록 모드(새 레코드)인 경우 파일 조회 스킵 - 빈 상태 유지 if (!isRecordMode || !recordId) { return false; } try { // 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 }; 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]; } } catch (e) { console.warn("파일 병합 중 오류:", e); } 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; // 🔒 항상 DB에서 최신 파일 목록을 조회 (멀티테넌시 격리) loadComponentFiles().then((dbLoadSuccess) => { if (dbLoadSuccess) { return; // DB 로드 성공 시 localStorage 무시 } // DB 로드 실패 시에만 기존 로직 사용 (하위 호환성) // 전역 상태에서 최신 파일 정보 가져오기 (🆕 고유 키 사용) const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; const uniqueKeyForFallback = getUniqueKey(); const globalFiles = globalFileState[uniqueKeyForFallback] || 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]); // 전역 상태 변경 감지 (모든 파일 컴포넌트 동기화 + 화면 복원) // 🆕 columnName을 포함한 고유 키로 구분하여 다른 파일 업로드 컴포넌트에 영향 방지 const currentUniqueKey = getUniqueKey(); useEffect(() => { const handleGlobalFileStateChange = (event: CustomEvent) => { const { componentId, files, fileCount, timestamp, isRestore, uniqueKey: eventUniqueKey, eventColumnName } = event.detail; // 🆕 고유 키 또는 (컴포넌트ID + 컬럼명) 조합으로 체크 const isForThisComponent = (eventUniqueKey && eventUniqueKey === currentUniqueKey) || (componentId === component.id && eventColumnName === columnName); // 🆕 같은 고유 키인 경우에만 업데이트 (componentId + columnName 조합) if (isForThisComponent) { setUploadedFiles(files); setForceUpdate((prev) => prev + 1); // localStorage 백업도 업데이트 (레코드별 고유 키 사용) try { const backupKey = currentUniqueKey; 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, columnName, currentUniqueKey, 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(() => { if (fileInputRef.current) { fileInputRef.current.click(); } }, []); const handleInputChange = useCallback((e: React.ChangeEvent) => { 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}`; } else if (screenId) { // 🔑 템플릿 파일 (백엔드 조회 형식과 동일하게) targetObjid = `screen_files:${screenId}:${component.id}:${effectiveColumnName}`; } else { // 기본값 (화면관리에서 사용) targetObjid = `temp_${component.id}`; } // 🔒 현재 사용자의 회사 코드 가져오기 (멀티테넌시 격리) const userCompanyCode = user?.companyCode || (window as any).__user__?.companyCode; // 🔑 레코드 모드일 때는 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, }; const response = await uploadFiles({ files: filesToUpload, ...uploadData, }); 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 추가 }); // 모든 파일 컴포넌트에 동기화 이벤트 발생 // 🆕 columnName 추가하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분 const syncEvent = new CustomEvent("globalFileStateChanged", { detail: { componentId: component.id, eventColumnName: columnName, // 🆕 컬럼명 추가 uniqueKey: uniqueKey, // 🆕 고유 키 추가 recordId: recordId, // 🆕 레코드 ID 추가 files: updatedFiles, fileCount: updatedFiles.length, timestamp: Date.now(), }, }); window.dispatchEvent(syncEvent); } // 컴포넌트 업데이트 if (onUpdate) { const timestamp = Date.now(); onUpdate({ uploadedFiles: updatedFiles, lastFileUpdate: timestamp, }); } else { console.warn("⚠️ onUpdate 콜백이 없습니다!"); } // 🆕 이미지/파일 컬럼에 objid 저장 (formData 업데이트) if (onFormDataChange && effectiveColumnName) { // 🎯 이미지/파일 타입 컬럼: 첫 번째 파일의 objid를 저장 (그리드에서 표시용) // 단일 파일인 경우 단일 값, 복수 파일인 경우 콤마 구분 문자열 const fileObjids = updatedFiles.map(file => file.objid); const columnValue = fileConfig.multiple ? fileObjids.join(',') // 복수 파일: 콤마 구분 : (fileObjids[0] || ''); // 단일 파일: 첫 번째 파일 ID // onFormDataChange를 (fieldName, value) 형태로 호출 (SaveModal 호환) onFormDataChange(effectiveColumnName, columnValue); } // 그리드 파일 상태 새로고침 이벤트 발생 if (typeof window !== "undefined") { const refreshEvent = new CustomEvent("refreshFileStatus", { detail: { tableName: effectiveTableName, recordId: effectiveRecordId, columnName: effectiveColumnName, targetObjid: targetObjid, fileCount: updatedFiles.length, }, }); window.dispatchEvent(refreshEvent); } // 컴포넌트 설정 콜백 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"); toast.error(`파일 업로드 오류: ${error instanceof Error ? error.message : "알 수 없는 오류"}`); } }, [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; // 모든 파일 컴포넌트에 동기화 이벤트 발생 // 🆕 columnName 추가하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분 const syncEvent = new CustomEvent("globalFileStateChanged", { detail: { componentId: component.id, eventColumnName: columnName, // 🆕 컬럼명 추가 uniqueKey: uniqueKey, // 🆕 고유 키 추가 recordId: recordId, // 🆕 레코드 ID 추가 files: updatedFiles, fileCount: updatedFiles.length, timestamp: Date.now(), source: "realScreen", // 🎯 실제 화면에서 온 이벤트임을 표시 action: "delete", }, }); window.dispatchEvent(syncEvent); } // 컴포넌트 업데이트 if (onUpdate) { const timestamp = Date.now(); onUpdate({ uploadedFiles: updatedFiles, lastFileUpdate: timestamp, }); } // 🆕 파일 삭제 후 컬럼 데이터 동기화 if (onFormDataChange && columnName) { // 🎯 삭제 후 남은 파일들의 objid로 컬럼 값 업데이트 const fileObjids = updatedFiles.map(f => f.objid); const columnValue = fileConfig.multiple ? fileObjids.join(',') : (fileObjids[0] || ''); // onFormDataChange를 (fieldName, value) 형태로 호출 (SaveModal 호환) onFormDataChange(columnName, columnValue); } toast.success(`${fileName} 삭제 완료`); } catch (error) { console.error("파일 삭제 오류:", error); toast.error("파일 삭제에 실패했습니다."); } }, [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; } // 🔑 이미 previewUrl이 설정된 경우 바로 사용 (API 호출 스킵) if (file.previewUrl) { setRepresentativeImageUrl(file.previewUrl); return; } // API 클라이언트를 통해 Blob으로 다운로드 (인증 토큰 포함) // 🔑 download 대신 preview 사용 (공개 접근) const response = await apiClient.get(`/files/preview/${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); } 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); } 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) => { e.preventDefault(); e.stopPropagation(); if (!safeComponentConfig.readonly && !safeComponentConfig.disabled) { setDragOver(true); } }, [safeComponentConfig.readonly, safeComponentConfig.disabled], ); 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) => { e.preventDefault(); e.stopPropagation(); if (!safeComponentConfig.readonly && !safeComponentConfig.disabled) { handleFileSelect(); } onClick?.(); }, [safeComponentConfig.readonly, safeComponentConfig.disabled, handleFileSelect, onClick], ); return (
{/* 라벨 렌더링 */} {component.label && component.style?.labelDisplay !== false && ( )}
{/* 대표 이미지 전체 화면 표시 */} {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 ? (
{representativeFile.realFileName}
) : isImage && !representativeImageUrl ? (

이미지 로딩 중...

) : (
{getFileIcon(representativeFile.fileExt)}

{representativeFile.realFileName}

대표 파일
)} {/* 우측 하단 자세히보기 버튼 */}
); })() : (

업로드된 파일이 없습니다

)}
{/* 파일뷰어 모달 */} {/* 파일 관리 모달 */} setIsFileManagerOpen(false)} uploadedFiles={uploadedFiles} onFileUpload={handleFileUpload} onFileDownload={handleFileDownload} onFileDelete={handleFileDelete} onFileView={handleFileView} onSetRepresentative={handleSetRepresentative} config={safeComponentConfig} isDesignMode={isDesignMode} />
); }; export { FileUploadComponent }; export default FileUploadComponent;