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 [localUploadedFiles, setLocalUploadedFiles] = useState(component.uploadedFiles || []); const fileInputRef = useRef(null); 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); formData.append("docType", fileConfig.docType); formData.append("docTypeName", 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); // 컴포넌트 업데이트 (옵셔널) 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 호출 (논리적 삭제) const response = await fetch(`/api/files/${fileInfo.objid}`, { method: "DELETE", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ writer: fileInfo.writer || "current_user", }), }); if (!response.ok) { throw new Error(`파일 삭제 실패: ${response.status}`); } const result = await response.json(); console.log("📡 파일 삭제 API 응답:", result); if (!result.success) { throw new Error(result.message || "파일 삭제 실패"); } const filteredFiles = uploadedFiles.filter((f) => f.objid !== fileInfo.objid); // 로컬 상태 업데이트 setLocalUploadedFiles(filteredFiles); 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}MB | 최대 개수: {fileConfig.maxFiles}개

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

첨부된 파일 ({uploadedFiles.length}/{fileConfig.maxFiles})

{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}
); }