import React, { useState, useCallback, useRef, useEffect } from "react"; import { Upload, X, File, Image, Eye, Download, AlertCircle, CheckCircle, Loader2 } from "lucide-react"; import { FileComponent, AttachedFileInfo } from "@/types/screen"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { apiClient } from "@/lib/api/client"; import { useAuth } from "@/hooks/useAuth"; interface FileUploadProps { component: FileComponent; onUpdateComponent?: (updates: Partial) => void; onFileUpload?: (files: AttachedFileInfo[]) => void; // 파일 업로드 완료 콜백 userInfo?: any; // 사용자 정보 (선택적) } /** * 독립적인 File 컴포넌트 * attach_file_info 테이블 기반 파일 관리 */ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInfo }: FileUploadProps) { const [isDragOver, setIsDragOver] = useState(false); const [uploadQueue, setUploadQueue] = useState([]); // 전역 파일 상태 관리 함수들 const getGlobalFileState = (): { [key: string]: any[] } => { if (typeof window !== "undefined") { return (window as any).globalFileState || {}; } return {}; }; const setGlobalFileState = (updater: (prev: { [key: string]: any[] }) => { [key: string]: any[] }) => { if (typeof window !== "undefined") { const currentState = getGlobalFileState(); const newState = updater(currentState); (window as any).globalFileState = newState; // console.log("🌐 FileUpload 전역 파일 상태 업데이트:", { // componentId: component.id, // newFileCount: newState[component.id]?.length || 0 // }); // 강제 리렌더링을 위한 이벤트 발생 window.dispatchEvent( new CustomEvent("globalFileStateChanged", { detail: { componentId: component.id, fileCount: newState[component.id]?.length || 0 }, }), ); } }; // 초기 파일 상태 설정 (전역 상태 우선) const initializeFiles = () => { const globalFiles = getGlobalFileState()[component.id] || []; const componentFiles = component.uploadedFiles || []; const finalFiles = globalFiles.length > 0 ? globalFiles : componentFiles; // console.log("🚀 FileUpload 파일 상태 초기화:", { // componentId: component.id, // globalFiles: globalFiles.length, // componentFiles: componentFiles.length, // finalFiles: finalFiles.length // }); return finalFiles; }; const [localUploadedFiles, setLocalUploadedFiles] = useState(initializeFiles()); const fileInputRef = useRef(null); // 전역 상태 변경 감지 useEffect(() => { const handleGlobalFileStateChange = (event: CustomEvent) => { if (event.detail.componentId === component.id) { const globalFiles = getGlobalFileState()[component.id] || []; // console.log("🔄 FileUpload 전역 상태 변경 감지:", { // componentId: component.id, // newFileCount: globalFiles.length // }); setLocalUploadedFiles(globalFiles); } }; if (typeof window !== "undefined") { window.addEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener); return () => { window.removeEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener); }; } }, [component.id]); const { fileConfig = {} } = component; const { user: authUser, isLoading, isLoggedIn } = useAuth(); // 인증 상태도 함께 가져오기 // props로 받은 userInfo를 우선 사용, 없으면 useAuth에서 가져온 user 사용 const user = userInfo || authUser; // 초기화 시점의 사용자 정보를 저장 (타이밍 문제 해결) const [initialUser, setInitialUser] = useState(user); // 🎯 최신 사용자 정보를 추적하는 ref (useCallback 내부에서 접근 가능) const userRef = useRef(user); // 사용자 정보 디버깅 useEffect(() => { // console.log("👤 File 컴포넌트 인증 상태 및 사용자 정보:", { // isLoading, // isLoggedIn, // hasUser: !!user, // user: user, // userId: user?.userId, // company_code: user?.company_code, // companyCode: user?.companyCode, // userType: typeof user, // userKeys: user ? Object.keys(user) : "no user", // userValues: user ? Object.entries(user) : "no user", // }); // 사용자 정보가 유효하면 initialUser와 userRef 업데이트 if (user && user.userId) { setInitialUser(user); userRef.current = user; // 🎯 ref에도 최신 정보 저장 // console.log("✅ 초기 사용자 정보 업데이트:", { userId: user.userId, companyCode: user.companyCode }); } // 회사 관련 필드들 확인 if (user) { // console.log("🔍 회사 관련 필드 검색:", { // company_code: user.company_code, // companyCode: user.companyCode, // company: user.company, // deptCode: user.deptCode, // partnerCd: user.partnerCd, // 모든 필드에서 company 관련된 것들 찾기 // allFields: Object.keys(user).filter( // (key) => // key.toLowerCase().includes("company") || // key.toLowerCase().includes("corp") || // key.toLowerCase().includes("code"), // ), // }); } else { // console.warn("⚠️ 사용자 정보가 없습니다. 인증 상태 확인 필요"); } }, [user, isLoading, isLoggedIn]); // 컴포넌트 props가 변경될 때 로컬 상태 동기화 useEffect(() => { // console.log("🔄 File 컴포넌트 props 변경:", { // propsUploadedFiles: component.uploadedFiles?.length || 0, // localUploadedFiles: localUploadedFiles.length, // }); setLocalUploadedFiles(component.uploadedFiles || []); }, [component.uploadedFiles]); // 실제 사용할 uploadedFiles는 로컬 상태 const uploadedFiles = localUploadedFiles; // 파일 크기 포맷팅 const formatFileSize = (bytes: number): string => { if (bytes === 0) return "0 Bytes"; const k = 1024; const sizes = ["Bytes", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; }; // 파일 타입 아이콘 결정 const getFileIcon = (fileExt: string) => { const ext = fileExt.toLowerCase(); if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext)) { return ; } return ; }; // 파일 확장자 검증 const isFileTypeAllowed = (file: File): boolean => { const fileName = file.name.toLowerCase(); // console.log("🔍 파일 타입 검증:", { // fileName: file.name, // fileType: file.type, // acceptRules: fileConfig.accept, // }); const result = fileConfig.accept.some((accept) => { // 모든 파일 허용 (와일드카드) if (accept === "*/*" || accept === "*") { // console.log("✅ 와일드카드 매칭:", accept); return true; } // 확장자 기반 검증 (.jpg, .png 등) if (accept.startsWith(".")) { const matches = fileName.endsWith(accept.toLowerCase()); // console.log(`${matches ? "✅" : "❌"} 확장자 검증:`, accept, "→", matches); return matches; } // MIME 타입 기반 검증 (image/*, text/* 등) if (accept.includes("/*")) { const type = accept.split("/")[0]; const matches = file.type.startsWith(type); // console.log(`${matches ? "✅" : "❌"} MIME 타입 검증:`, accept, "→", matches); return matches; } // 정확한 MIME 타입 매칭 (image/jpeg, application/pdf 등) const matches = file.type === accept; // console.log(`${matches ? "✅" : "❌"} 정확한 MIME 매칭:`, accept, "→", matches); return matches; }); // console.log(`🎯 최종 검증 결과:`, result); return result; }; // 파일 선택 핸들러 const handleFileSelect = useCallback( (files: FileList | null) => { // console.log("📁 파일 선택됨:", files ? Array.from(files).map((f) => f.name) : "없음"); if (!files) return; const fileArray = Array.from(files); const validFiles: File[] = []; const errors: string[] = []; // console.log("🔍 파일 검증 시작:", { // totalFiles: fileArray.length, // currentUploadedCount: uploadedFiles.length, // maxFiles: fileConfig.maxFiles, // maxSize: fileConfig.maxSize, // allowedTypes: fileConfig.accept, // }); // 파일 검증 fileArray.forEach((file) => { // console.log(`📄 파일 검증: ${file.name} (${file.size} bytes, ${file.type})`); // 파일 타입 검증 if (!isFileTypeAllowed(file)) { errors.push(`${file.name}: 허용되지 않는 파일 타입입니다.`); // console.log(`❌ 파일 타입 거부: ${file.name}`); return; } // 파일 크기 검증 if (file.size > fileConfig.maxSize * 1024 * 1024) { errors.push(`${file.name}: 파일 크기가 ${fileConfig.maxSize}MB를 초과합니다.`); // console.log(`❌ 파일 크기 초과: ${file.name} (${file.size} > ${fileConfig.maxSize * 1024 * 1024})`); return; } // 최대 파일 수 검증 if (uploadedFiles.length + validFiles.length >= fileConfig.maxFiles) { errors.push(`최대 ${fileConfig.maxFiles}개까지만 업로드할 수 있습니다.`); // console.log(`❌ 최대 파일 수 초과`); return; } validFiles.push(file); // console.log(`✅ 파일 검증 통과: ${file.name}`); }); // 에러가 있으면 알림 if (errors.length > 0) { // console.error("💥 파일 업로드 오류:", errors); // TODO: Toast 알림 표시 } // 유효한 파일들을 업로드 큐에 추가 if (validFiles.length > 0) { // console.log( // "✅ 유효한 파일들 업로드 큐에 추가:", // validFiles.map((f) => f.name), // ); setUploadQueue((prev) => [...prev, ...validFiles]); if (fileConfig.autoUpload) { // console.log("🚀 자동 업로드 시작:", { // autoUpload: fileConfig.autoUpload, // filesCount: validFiles.length, // fileNames: validFiles.map((f) => f.name), // }); // 자동 업로드 실행 validFiles.forEach(uploadFile); } else { // console.log("⏸️ 자동 업로드 비활성화:", { // autoUpload: fileConfig.autoUpload, // filesCount: validFiles.length, // }); } } else { // console.log("❌ 업로드할 유효한 파일이 없음"); } }, [fileConfig, uploadedFiles.length], ); // 파일 업로드 함수 (실시간 상태 조회로 타이밍 문제 해결) const uploadFile = useCallback( async (file: File) => { // console.log("📤 파일 업로드 시작:", file.name); const formData = new FormData(); formData.append("files", file); // 🎯 컴포넌트 ID를 doc_type으로 사용하여 파일 컴포넌트별로 구분 formData.append("docType", component.id); formData.append("docTypeName", component.label || fileConfig.docTypeName); // 🎯 최신 사용자 정보 참조 (ref를 통해 실시간 값 접근) const currentUser = userRef.current; // 실시간 사용자 정보 디버깅 // console.log("🔍 FileUpload - uploadFile ref를 통한 실시간 상태:", { // hasCurrentUser: !!currentUser, // currentUser: currentUser // ? { // userId: currentUser.userId, // companyCode: currentUser.companyCode, // company_code: currentUser.company_code, // } // : null, // 기존 상태와 비교 // originalUser: user, // originalInitialUser: initialUser, // refExists: !!userRef.current, // }); // 사용자 정보가 로드되지 않은 경우 잠시 대기 if (isLoading) { // console.log("⏳ 사용자 정보 로딩 중... 업로드 대기"); setTimeout(() => uploadFile(file), 500); // 500ms 후 재시도 return; } // 사용자 정보가 없는 경우 - 무한루프 방지로 재시도 제한 if (!user && isLoggedIn) { // console.warn("⚠️ 로그인은 되어 있지만 사용자 정보가 없음. DEFAULT로 진행"); // 무한루프 방지: 재시도하지 않고 DEFAULT로 진행 // setTimeout(() => uploadFile(file), 1000); // 1초 후 재시도 // return; } // 사용자 정보 추가 (실시간 currentUser 사용으로 타이밍 문제 해결) const effectiveUser = currentUser || user || initialUser; const companyCode = effectiveUser?.companyCode || effectiveUser?.company_code || effectiveUser?.deptCode; if (companyCode) { // "*"는 실제 회사코드이므로 그대로 사용 formData.append("companyCode", companyCode); // console.log("✅ 회사코드 추가:", companyCode); } else { // console.warn("⚠️ 회사코드가 없음, DEFAULT 사용. 사용자 정보:", { // user: user, // initialUser: initialUser, // effectiveUser: effectiveUser, // companyCode: effectiveUser?.companyCode, // company_code: effectiveUser?.company_code, // deptCode: effectiveUser?.deptCode, // isLoading, // isLoggedIn, // allUserKeys: effectiveUser ? Object.keys(effectiveUser) : "no user", // }); formData.append("companyCode", "DEFAULT"); } if (effectiveUser?.userId) { formData.append("writer", effectiveUser.userId); // console.log("✅ 작성자 추가:", effectiveUser.userId); } else { // console.warn("⚠️ 사용자ID가 없음, system 사용"); formData.append("writer", "system"); } // 프론트엔드 파일 타입 설정을 백엔드로 전송 if (fileConfig.accept && fileConfig.accept.length > 0) { const acceptString = fileConfig.accept.join(","); formData.append("accept", acceptString); // console.log("✅ 허용 파일 타입 추가:", acceptString); } // 자동 연결 정보 추가 if (fileConfig.autoLink) { formData.append("autoLink", "true"); // console.log("✅ 자동 연결 활성화: true"); if (fileConfig.linkedTable) { formData.append("linkedTable", fileConfig.linkedTable); // console.log("✅ 연결 테이블 추가:", fileConfig.linkedTable); } if (fileConfig.linkedField) { formData.append("linkedField", fileConfig.linkedField); // console.log("✅ 연결 필드 추가:", fileConfig.linkedField); } if (fileConfig.recordId) { formData.append("recordId", fileConfig.recordId); // console.log("✅ 레코드 ID 추가:", fileConfig.recordId); } // 가상 파일 컬럼 정보 추가 if (fileConfig.isVirtualFileColumn) { formData.append("isVirtualFileColumn", "true"); // console.log("✅ 가상 파일 컬럼 활성화: true"); if (fileConfig.columnName) { formData.append("columnName", fileConfig.columnName); // console.log("✅ 컬럼명 추가:", fileConfig.columnName); } } } // FormData 내용 디버깅 // console.log("📋 FormData 내용 확인:"); for (const [key, value] of formData.entries()) { // console.log(` ${key}:`, value); } try { // 업로드 중 상태 표시를 위한 임시 파일 정보 생성 const tempFileInfo: AttachedFileInfo = { objid: `temp_${Date.now()}`, savedFileName: "", realFileName: file.name, fileSize: file.size, fileExt: file.name.split(".").pop() || "", filePath: "", docType: fileConfig.docType, docTypeName: fileConfig.docTypeName, targetObjid: "", companyCode: "", writer: "", regdate: new Date().toISOString(), status: "UPLOADING", uploadProgress: 0, isUploading: true, }; // console.log("📋 임시 파일 정보 생성:", tempFileInfo); const newUploadedFiles = [...uploadedFiles, tempFileInfo]; // console.log("📊 업데이트 전 파일 목록:", uploadedFiles.length, "개"); // console.log("📊 업데이트 후 파일 목록:", newUploadedFiles.length, "개"); // 로컬 상태 즉시 업데이트 setLocalUploadedFiles(newUploadedFiles); // 임시 파일 정보를 업로드된 파일 목록에 추가 // console.log("🔄 onUpdateComponent 호출 중..."); onUpdateComponent({ uploadedFiles: newUploadedFiles, }); // console.log("✅ onUpdateComponent 호출 완료"); // console.log("🚀 API 호출 시작 - /files/upload"); // 실제 API 호출 (apiClient 사용으로 자동 JWT 토큰 추가) // FormData 사용 시 Content-Type을 삭제하여 boundary가 자동 설정되도록 함 const response = await apiClient.post("/files/upload", formData, { headers: { "Content-Type": undefined, // axios가 자동으로 multipart/form-data를 설정하도록 }, }); const result = response.data; // console.log("📡 API 응답 성공:", result); if (!result.success || !result.files || result.files.length === 0) { throw new Error(result.message || "파일 업로드 실패"); } // API 응답에서 실제 파일 정보 받아오기 const uploadedFileInfo = result.files[0]; // 현재는 하나씩 업로드 const successFileInfo: AttachedFileInfo = { objid: uploadedFileInfo.objid, savedFileName: uploadedFileInfo.savedFileName, realFileName: uploadedFileInfo.realFileName, fileSize: uploadedFileInfo.fileSize, fileExt: uploadedFileInfo.fileExt, filePath: uploadedFileInfo.filePath, docType: uploadedFileInfo.docType, docTypeName: uploadedFileInfo.docTypeName, targetObjid: uploadedFileInfo.targetObjid, parentTargetObjid: uploadedFileInfo.parentTargetObjid, companyCode: uploadedFileInfo.companyCode, writer: uploadedFileInfo.writer, regdate: uploadedFileInfo.regdate, status: uploadedFileInfo.status, uploadProgress: 100, isUploading: false, }; // console.log("✅ 실제 파일 업로드 완료 (attach_file_info 저장됨):", successFileInfo); const updatedFiles = uploadedFiles.map((f) => (f.objid === tempFileInfo.objid ? successFileInfo : f)); // 로컬 상태 업데이트 setLocalUploadedFiles(updatedFiles); // 전역 상태 업데이트 setGlobalFileState((prev) => ({ ...prev, [component.id]: updatedFiles, })); // RealtimePreview 동기화를 위한 추가 이벤트 발생 if (typeof window !== "undefined") { const eventDetail = { componentId: component.id, files: updatedFiles, fileCount: updatedFiles.length, action: "upload", timestamp: Date.now(), }; // console.log("🚀 FileUpload 위젯 이벤트 발생:", eventDetail); const event = new CustomEvent("globalFileStateChanged", { detail: eventDetail, }); window.dispatchEvent(event); // console.log("✅ FileUpload globalFileStateChanged 이벤트 발생 완료"); } // 컴포넌트 업데이트 (옵셔널) if (onUpdateComponent) { onUpdateComponent({ uploadedFiles: updatedFiles, }); } // 파일 업로드 완료 콜백 호출 (모달에서 사용) if (onFileUpload) { onFileUpload(updatedFiles); } // 업로드 큐에서 제거 setUploadQueue((prev) => prev.filter((f) => f !== file)); } catch (error) { // console.error("❌ 파일 업로드 실패:", { // error, // errorMessage: error instanceof Error ? error.message : "알 수 없는 오류", // errorStack: error instanceof Error ? error.stack : undefined, // user: user ? { userId: user.userId, companyCode: user.companyCode, hasUser: true } : "no user", // authState: { isLoading, isLoggedIn }, // }); // API 응답 에러인 경우 상세 정보 출력 if ((error as any)?.response) { // console.error("📡 API 응답 에러:", { // status: (error as any).response.status, // statusText: (error as any).response.statusText, // data: (error as any).response.data, // }); } // 에러 상태로 업데이트 const errorFiles = uploadedFiles.map((f) => f.objid === `temp_${file.name}` ? { ...f, hasError: true, errorMessage: "업로드 실패", isUploading: false } : f, ); // 로컬 상태 업데이트 setLocalUploadedFiles(errorFiles); onUpdateComponent({ uploadedFiles: errorFiles, }); } }, [fileConfig, uploadedFiles, onUpdateComponent], // ref는 의존성에 포함하지 않음 ); // 파일 삭제 const deleteFile = async (fileInfo: AttachedFileInfo) => { // console.log("🗑️ 파일 삭제:", fileInfo.realFileName); try { // 실제 API 호출 (논리적 삭제) - apiClient 사용으로 JWT 토큰 자동 추가 const response = await apiClient.delete(`/files/${fileInfo.objid}`, { data: { writer: fileInfo.writer || "current_user", }, }); const result = response.data; // console.log("📡 파일 삭제 API 응답:", result); if (!result.success) { throw new Error(result.message || "파일 삭제 실패"); } const filteredFiles = uploadedFiles.filter((f) => f.objid !== fileInfo.objid); // 로컬 상태 업데이트 setLocalUploadedFiles(filteredFiles); // 전역 상태 업데이트 setGlobalFileState((prev) => ({ ...prev, [component.id]: filteredFiles, })); // 🎯 화면설계 모드와 동기화를 위한 전역 이벤트 발생 if (typeof window !== "undefined") { try { const eventDetail = { componentId: component.id, files: filteredFiles, fileCount: filteredFiles.length, action: "delete", timestamp: Date.now(), source: "realScreen", // 실제 화면에서 온 이벤트임을 표시 }; // console.log("🚀🚀🚀 FileUpload 위젯 삭제 이벤트 발생:", eventDetail); const event = new CustomEvent("globalFileStateChanged", { detail: eventDetail, }); window.dispatchEvent(event); // console.log("✅✅✅ FileUpload 위젯 → 화면설계 모드 동기화 이벤트 발생 완료"); // 추가 지연 이벤트들 setTimeout(() => { try { // console.log("🔄 FileUpload 위젯 추가 삭제 이벤트 발생 (지연 100ms)"); window.dispatchEvent( new CustomEvent("globalFileStateChanged", { detail: { ...eventDetail, delayed: true }, }), ); } catch (error) { // console.warn("FileUpload 지연 이벤트 발생 실패:", error); } }, 100); setTimeout(() => { try { // console.log("🔄 FileUpload 위젯 추가 삭제 이벤트 발생 (지연 300ms)"); window.dispatchEvent( new CustomEvent("globalFileStateChanged", { detail: { ...eventDetail, delayed: true, attempt: 2 }, }), ); } catch (error) { // console.warn("FileUpload 지연 이벤트 발생 실패:", error); } }, 300); } catch (error) { // console.warn("FileUpload 이벤트 발생 실패:", error); } } onUpdateComponent({ uploadedFiles: filteredFiles, }); // console.log("✅ 파일 삭제 완료 (attach_file_info.status = DELETED)"); } catch (error) { // console.error("파일 삭제 실패:", error); } }; // 드래그 앤 드롭 핸들러 const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragOver(true); }, []); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragOver(false); }, []); const handleDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); setIsDragOver(false); handleFileSelect(e.dataTransfer.files); }, [handleFileSelect], ); // 파일 입력 클릭 const handleFileInputClick = () => { fileInputRef.current?.click(); }; // 파일 미리보기 const previewFile = (fileInfo: AttachedFileInfo) => { const isImage = ["jpg", "jpeg", "png", "gif", "webp"].includes(fileInfo.fileExt.toLowerCase()); if (isImage) { // TODO: 이미지 미리보기 모달 열기 // console.log("이미지 미리보기:", fileInfo); } else { // TODO: 파일 다운로드 // console.log("파일 다운로드:", fileInfo); } }; return (
{/* 드래그 앤 드롭 영역 */}

{fileConfig.dragDropText || "파일을 드래그하여 업로드하세요"}

또는 클릭하여 파일을 선택하세요

허용 파일: {(fileConfig?.accept || ["*/*"]).join(", ")}

최대 크기: {fileConfig?.maxSize || 10}MB | 최대 개수: {fileConfig?.maxFiles || 5}개

handleFileSelect(e.target.files)} className="hidden" />
{/* 업로드된 파일 목록 */} {uploadedFiles.length > 0 && (

업로드된 파일 ({uploadedFiles.length}/{fileConfig.maxFiles})

총 {formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))}

{uploadedFiles.map((fileInfo) => (
{getFileIcon(fileInfo.fileExt)}

{fileInfo.realFileName}

{formatFileSize(fileInfo.fileSize)}
{fileInfo.fileExt.toUpperCase()}
{fileInfo.writer && (
{fileInfo.writer}
)}
{/* 업로드 진행률 */} {fileInfo.isUploading && fileConfig.showProgress && (
)} {/* 에러 메시지 */} {fileInfo.hasError && (
{fileInfo.errorMessage}
)}
{/* 상태 표시 */} {fileInfo.isUploading && (
업로드 중...
)} {fileInfo.status === "ACTIVE" && (
완료
)} {fileInfo.hasError && (
오류
)} {/* 액션 버튼 */} {!fileInfo.isUploading && !fileInfo.hasError && (
{fileConfig.showPreview && ( )}
)}
))}
)} {/* 문서 타입 정보 */}
파일명 클릭으로 미리보기 또는 "전체 자세히보기"로 파일 관리
{fileConfig.docTypeName}
); }