"use client"; /** * V2Media * * 통합 미디어 컴포넌트 (레거시 FileUploadComponent 기능 통합) * - file: 파일 업로드 * - image: 이미지 업로드/표시 * - video: 비디오 * - audio: 오디오 * * 핵심 기능: * - FileViewerModal / FileManagerModal (자세히보기) * - 대표 이미지 설정 * - 레코드 모드 (테이블/레코드 연결) * - 전역 파일 상태 관리 * - 파일 다운로드/삭제 * - DB에서 기존 파일 로드 */ import React, { forwardRef, useCallback, useRef, useState, useEffect } from "react"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; import { V2MediaProps } from "@/types/v2-components"; import { Upload, X, File, Image as ImageIcon, Video, Music, Eye, Download, Trash2, Plus, FileText, Archive, Presentation, FileImage, FileVideo, FileAudio } from "lucide-react"; import { apiClient } from "@/lib/api/client"; import { toast } from "sonner"; import { uploadFiles, downloadFile, deleteFile, getComponentFiles } from "@/lib/api/file"; import { GlobalFileManager } from "@/lib/api/globalFile"; import { formatFileSize } from "@/lib/utils"; import { useAuth } from "@/hooks/useAuth"; // 레거시 모달 컴포넌트 import import { FileViewerModal } from "@/lib/registry/components/file-upload/FileViewerModal"; import { FileManagerModal } from "@/lib/registry/components/file-upload/FileManagerModal"; import type { FileInfo, FileUploadConfig } from "@/lib/registry/components/file-upload/types"; /** * 파일 아이콘 매핑 */ 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 ; }; /** * V2 미디어 컴포넌트 (레거시 기능 통합) */ export const V2Media = forwardRef( (props, ref) => { const { id, label, required, readonly, disabled, style, size, config: configProp, value, onChange, formData, columnName, tableName, onFormDataChange, isDesignMode = false, isInteractive = true, onUpdate, ...restProps } = props; // 인증 정보 const { user } = useAuth(); // config 기본값 const config = configProp || { type: "file" as const }; const mediaType = config.type || "file"; // 파일 상태 const [uploadedFiles, setUploadedFiles] = useState([]); const [uploadStatus, setUploadStatus] = useState<"idle" | "uploading" | "success" | "error">("idle"); const [dragOver, setDragOver] = useState(false); const [representativeImageUrl, setRepresentativeImageUrl] = useState(null); // 모달 상태 const [viewerFile, setViewerFile] = useState(null); const [isViewerOpen, setIsViewerOpen] = useState(false); const [isFileManagerOpen, setIsFileManagerOpen] = useState(false); const fileInputRef = useRef(null); // 레코드 모드 판단 const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_')); const recordTableName = formData?.tableName || tableName; const recordId = formData?.id; const effectiveColumnName = isRecordMode ? 'attachments' : (columnName || id || 'attachments'); // 레코드용 targetObjid 생성 const getRecordTargetObjid = useCallback(() => { if (isRecordMode && recordTableName && recordId) { return `${recordTableName}:${recordId}:${effectiveColumnName}`; } return null; }, [isRecordMode, recordTableName, recordId, effectiveColumnName]); // 레코드별 고유 키 생성 const getUniqueKey = useCallback(() => { if (isRecordMode && recordTableName && recordId) { return `v2media_${recordTableName}_${recordId}_${id}`; } return `v2media_${id}`; }, [isRecordMode, recordTableName, recordId, id]); // 레코드 ID 변경 시 파일 목록 초기화 const prevRecordIdRef = useRef(null); useEffect(() => { if (prevRecordIdRef.current !== recordId) { prevRecordIdRef.current = recordId; if (isRecordMode) { setUploadedFiles([]); } } }, [recordId, isRecordMode]); // 컴포넌트 마운트 시 localStorage에서 파일 복원 useEffect(() => { if (!id) 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); } }, [id, getUniqueKey, recordId]); // DB에서 파일 목록 로드 const loadComponentFiles = useCallback(async () => { if (!id) return false; try { 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]); } } if (!screenId && isDesignMode) { screenId = 999999; } if (!screenId) { screenId = 0; } const params = { screenId, componentId: id, tableName: recordTableName || formData?.tableName || tableName, recordId: recordId || formData?.id, columnName: effectiveColumnName, }; 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(), targetObjid: file.targetObjid || file.target_objid, filePath: file.filePath || file.file_path, ...file, })); // localStorage와 병합 let finalFiles = formattedFiles; const uniqueKey = getUniqueKey(); try { const backupFiles = localStorage.getItem(uniqueKey); if (backupFiles) { const parsedBackupFiles = JSON.parse(backupFiles); 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: id, screenId: formData?.screenId, recordId: recordId, }); try { localStorage.setItem(uniqueKey, JSON.stringify(finalFiles)); } catch (e) { console.warn("localStorage 백업 실패:", e); } } return true; } } catch (error) { console.error("파일 조회 오류:", error); } return false; }, [id, tableName, columnName, formData?.screenId, formData?.tableName, formData?.id, getUniqueKey, recordId, isRecordMode, recordTableName, effectiveColumnName, isDesignMode]); // 파일 동기화 useEffect(() => { loadComponentFiles(); }, [loadComponentFiles]); // 전역 상태 변경 감지 useEffect(() => { const handleGlobalFileStateChange = (event: CustomEvent) => { const { componentId, files, isRestore } = event.detail; if (componentId === id) { setUploadedFiles(files); 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); }; } }, [id, getUniqueKey]); // 파일 업로드 처리 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(", ")}`); 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 || tableName || "default_table"; const effectiveRecordId = recordId || formData?.id; 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}:${id}:${effectiveColumnName}`; } else { targetObjid = `temp_${id}`; } const userCompanyCode = user?.companyCode || (window as any).__user__?.companyCode; const finalLinkedTable = effectiveIsRecordMode ? effectiveTableName : (formData?.linkedTable || effectiveTableName); const uploadData = { autoLink: formData?.autoLink || true, linkedTable: finalLinkedTable, recordId: effectiveRecordId || `temp_${id}`, columnName: effectiveColumnName, isVirtualFileColumn: formData?.isVirtualFileColumn || true, docType: config?.docType || "DOCUMENT", docTypeName: config?.docTypeName || "일반 문서", companyCode: userCompanyCode, tableName: effectiveTableName, fieldName: effectiveColumnName, targetObjid: targetObjid, isRecordMode: effectiveIsRecordMode, }; const response = await uploadFiles({ files: filesToUpload, ...uploadData, }); if (response.success) { 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: id, screenId: formData?.screenId, recordId: recordId, }); const syncEvent = new CustomEvent("globalFileStateChanged", { detail: { componentId: id, uniqueKey: uniqueKey, recordId: recordId, files: updatedFiles, fileCount: updatedFiles.length, timestamp: Date.now(), }, }); window.dispatchEvent(syncEvent); } // 부모 컴포넌트 업데이트 if (onUpdate) { onUpdate({ uploadedFiles: updatedFiles, lastFileUpdate: Date.now(), }); } // onChange 콜백 (objid 배열 또는 단일 값) const fileIds = updatedFiles.map((f) => f.objid); const finalValue = config.multiple ? fileIds : fileIds[0] || ""; const targetColumn = columnName || effectiveColumnName; console.log("📤 [V2Media] 파일 업로드 완료 - 값 전달:", { columnName: targetColumn, fileIds, finalValue, hasOnChange: !!onChange, hasOnFormDataChange: !!onFormDataChange, }); if (onChange) { onChange(finalValue); } // 폼 데이터 업데이트 - 부모 컴포넌트 시그니처에 맞게 (fieldName, value) 형식 if (onFormDataChange && targetColumn) { console.log("📝 [V2Media] formData 업데이트:", { columnName: targetColumn, fileIds, isRecordMode: effectiveIsRecordMode, }); // (fieldName: string, value: any) 형식으로 호출 onFormDataChange(targetColumn, fileIds); } // 그리드 파일 상태 새로고침 이벤트 발생 if (typeof window !== "undefined") { const refreshEvent = new CustomEvent("refreshFileStatus", { detail: { tableName: effectiveTableName, recordId: effectiveRecordId, columnName: targetColumn, targetObjid: targetObjid, fileCount: updatedFiles.length, }, }); window.dispatchEvent(refreshEvent); } toast.dismiss("file-upload"); toast.success(`${newFiles.length}개 파일 업로드 완료`); } else { 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 : "알 수 없는 오류"}`); } }, [config, uploadedFiles, onChange, id, getUniqueKey, recordId, isRecordMode, recordTableName, effectiveColumnName, tableName, onUpdate, onFormDataChange, user, columnName], ); // 파일 뷰어 열기/닫기 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: id, uniqueKey: uniqueKey, recordId: recordId, files: updatedFiles, fileCount: updatedFiles.length, timestamp: Date.now(), action: "delete", }, }); window.dispatchEvent(syncEvent); } if (onUpdate) { onUpdate({ uploadedFiles: updatedFiles, lastFileUpdate: Date.now(), }); } // onChange 콜백 const fileIds = updatedFiles.map((f) => f.objid); const finalValue = config.multiple ? fileIds : fileIds[0] || ""; const targetColumn = columnName || effectiveColumnName; console.log("🗑️ [V2Media] 파일 삭제 완료 - 값 전달:", { columnName: targetColumn, fileIds, finalValue, }); if (onChange) { onChange(finalValue); } // 폼 데이터 업데이트 - 부모 컴포넌트 시그니처에 맞게 (fieldName, value) 형식 if (onFormDataChange && targetColumn) { console.log("🗑️ [V2Media] 삭제 후 formData 업데이트:", { columnName: targetColumn, fileIds, }); // (fieldName: string, value: any) 형식으로 호출 onFormDataChange(targetColumn, fileIds); } toast.success(`${fileName} 삭제 완료`); } catch (error) { console.error("파일 삭제 오류:", error); toast.error("파일 삭제 실패"); } }, [uploadedFiles, onUpdate, id, isRecordMode, onFormDataChange, recordTableName, recordId, effectiveColumnName, getUniqueKey, onChange, config.multiple, columnName], ); // 대표 이미지 로드 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; } if (!file.objid || file.objid === "0" || file.objid === "") { setRepresentativeImageUrl(null); return; } const response = await apiClient.get(`/files/download/${file.objid}`, { params: { serverFilename: file.savedFileName }, responseType: "blob", }); const blob = new Blob([response.data]); const url = window.URL.createObjectURL(blob); if (representativeImageUrl) { window.URL.revokeObjectURL(representativeImageUrl); } setRepresentativeImageUrl(url); } catch (error) { console.error("대표 이미지 로드 실패:", error); setRepresentativeImageUrl(null); } }, [representativeImageUrl], ); // 대표 이미지 설정 const handleSetRepresentative = useCallback( async (file: FileInfo) => { try { 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, loadRepresentativeImage] ); // uploadedFiles 변경 시 대표 이미지 로드 useEffect(() => { const representativeFile = uploadedFiles.find(f => f.isRepresentative) || uploadedFiles[0]; if (representativeFile) { loadRepresentativeImage(representativeFile); } else { setRepresentativeImageUrl(null); } return () => { if (representativeImageUrl) { window.URL.revokeObjectURL(representativeImageUrl); } }; }, [uploadedFiles]); // 드래그 앤 드롭 핸들러 const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); if (!readonly && !disabled) { setDragOver(true); } }, [readonly, 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 (!readonly && !disabled) { const files = Array.from(e.dataTransfer.files); if (files.length > 0) { handleFileUpload(files); } } }, [readonly, disabled, handleFileUpload]); // 파일 선택 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); } e.target.value = ''; }, [handleFileUpload]); // 파일 설정 const fileConfig: FileUploadConfig = { accept: config.accept || "*/*", multiple: config.multiple || false, maxSize: config.maxSize || 10 * 1024 * 1024, disabled: disabled, readonly: readonly, }; const showLabel = label && style?.labelDisplay !== false; const componentWidth = size?.width || style?.width; const componentHeight = size?.height || style?.height; return ( {/* 라벨 */} {showLabel && ( {label} {required && *} )} {/* 메인 컨테이너 */} {/* 숨겨진 파일 입력 */} {/* 파일이 있는 경우: 대표 이미지/파일 표시 */} {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 ? ( ) : isImage && !representativeImageUrl ? ( 이미지 로딩 중... ) : ( {getFileIcon(representativeFile.fileExt)} {representativeFile.realFileName} 대표 파일 )} {/* 우측 하단 자세히보기 버튼 */} setIsFileManagerOpen(true)} > 자세히보기 ({uploadedFiles.length}) > ); })() : ( // 파일이 없는 경우: 업로드 안내 !disabled && !readonly && handleFileSelect()} > 파일을 드래그하거나 클릭하세요 최대 {formatFileSize(config.maxSize || 10 * 1024 * 1024)} {config.accept && config.accept !== "*/*" && ` (${config.accept})`} { e.stopPropagation(); setIsFileManagerOpen(true); }} disabled={disabled || readonly} > 파일 관리 )} {/* 파일 뷰어 모달 */} {/* 파일 관리 모달 */} setIsFileManagerOpen(false)} uploadedFiles={uploadedFiles} onFileUpload={handleFileUpload} onFileDownload={handleFileDownload} onFileDelete={handleFileDelete} onFileView={handleFileView} onSetRepresentative={handleSetRepresentative} config={fileConfig} isDesignMode={isDesignMode} /> ); } ); V2Media.displayName = "V2Media"; export default V2Media;
이미지 로딩 중...
{representativeFile.realFileName}
파일을 드래그하거나 클릭하세요
최대 {formatFileSize(config.maxSize || 10 * 1024 * 1024)} {config.accept && config.accept !== "*/*" && ` (${config.accept})`}