ERP-node/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx

704 lines
25 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 { uploadFiles, downloadFile, deleteFile } from "@/lib/api/file";
import { formatFileSize } from "@/lib/utils";
import { FileViewerModal } from "./FileViewerModal";
import { FileUploadConfig, FileInfo, FileUploadStatus, FileUploadResponse } from "./types";
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="w-6 h-6 text-blue-500" />;
}
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'].includes(ext)) {
return <FileVideo className="w-6 h-6 text-purple-500" />;
}
if (['mp3', 'wav', 'flac', 'aac', 'ogg'].includes(ext)) {
return <FileAudio className="w-6 h-6 text-green-500" />;
}
if (['pdf'].includes(ext)) {
return <FileText className="w-6 h-6 text-red-500" />;
}
if (['doc', 'docx', 'hwp', 'hwpx', 'pages'].includes(ext)) {
return <FileText className="w-6 h-6 text-blue-600" />;
}
if (['xls', 'xlsx', 'hcell', 'numbers'].includes(ext)) {
return <FileText className="w-6 h-6 text-green-600" />;
}
if (['ppt', 'pptx', 'hanshow', 'keynote'].includes(ext)) {
return <Presentation className="w-6 h-6 text-orange-500" />;
}
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) {
return <Archive className="w-6 h-6 text-gray-500" />;
}
return <File className="w-6 h-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;
}
export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
component,
componentConfig,
componentStyle,
className,
isInteractive,
isDesignMode,
formData,
onFormDataChange,
onClick,
onDragStart,
onDragEnd,
onUpdate,
}) => {
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 [forceUpdate, setForceUpdate] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
// 컴포넌트 파일 동기화
useEffect(() => {
const componentFiles = (component as any)?.uploadedFiles || [];
const lastUpdate = (component as any)?.lastFileUpdate;
// 전역 상태에서 최신 파일 정보 가져오기
const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {};
const globalFiles = globalFileState[component.id] || [];
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
const currentFiles = globalFiles.length > 0 ? globalFiles : componentFiles;
console.log("🔄 FileUploadComponent 파일 동기화:", {
componentId: component.id,
componentFiles: componentFiles.length,
globalFiles: globalFiles.length,
currentFiles: currentFiles.length,
uploadedFiles: uploadedFiles.length,
lastUpdate: lastUpdate
});
// localStorage에서 백업 파일 복원
try {
const backupKey = `fileUpload_${component.id}`;
const backupFiles = localStorage.getItem(backupKey);
if (backupFiles && currentFiles.length === 0) {
const parsedFiles = JSON.parse(backupFiles);
setUploadedFiles(parsedFiles);
return;
}
} catch (e) {
console.warn("localStorage 백업 복원 실패:", e);
}
// 최신 파일과 현재 파일 비교
if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) {
console.log("🔄 useEffect에서 파일 목록 변경 감지:", {
currentFiles: currentFiles.length,
uploadedFiles: uploadedFiles.length,
currentFilesData: currentFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })),
uploadedFilesData: uploadedFiles.map(f => ({ objid: f.objid, name: f.realFileName }))
});
setUploadedFiles(currentFiles);
setForceUpdate(prev => prev + 1);
}
}, [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,
새파일수: files.length,
files: files.map((f: any) => ({ objid: f.objid, name: f.realFileName }))
});
setUploadedFiles(files);
setForceUpdate(prev => prev + 1);
// localStorage 백업도 업데이트
try {
const backupKey = `fileUpload_${component.id}`;
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(() => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
}, []);
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[] = [];
console.log("🔍 중복 파일 체크:", {
uploadedFiles: uploadedFiles.length,
existingFileNames: existingFileNames,
newFiles: files.map(f => f.name.toLowerCase())
});
files.forEach(file => {
const fileName = file.name.toLowerCase();
if (existingFileNames.includes(fileName)) {
duplicates.push(file.name);
console.log("❌ 중복 파일 발견:", file.name);
} else {
uniqueFiles.push(file);
console.log("✅ 새로운 파일:", file.name);
}
});
console.log("🔍 중복 체크 결과:", {
duplicates: duplicates,
uniqueFiles: uniqueFiles.map(f => f.name)
});
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 {
// targetObjid 생성 (InteractiveDataTable과 호환)
const tableName = formData?.tableName || component.tableName || 'default_table';
const recordId = formData?.id || 'temp_record';
const columnName = component.columnName || component.id;
const targetObjid = `${tableName}:${recordId}:${columnName}`;
const uploadData = {
tableName: tableName,
fieldName: columnName,
recordId: recordId,
docType: component.fileConfig?.docType || 'DOCUMENT',
docTypeName: component.fileConfig?.docTypeName || '일반 문서',
targetObjid: targetObjid, // InteractiveDataTable 호환을 위한 targetObjid 추가
columnName: columnName, // 가상 파일 컬럼 지원
isVirtualFileColumn: true, // 가상 파일 컬럼으로 처리
};
console.log("📤 파일 업로드 시작:", {
originalFiles: files.length,
filesToUpload: filesToUpload.length,
files: filesToUpload.map(f => ({ name: f.name, size: f.size })),
uploadData
});
const response = await uploadFiles({
files: filesToUpload,
...uploadData
});
console.log("📤 파일 업로드 API 응답:", response);
if (response.success) {
// FileUploadResponse 타입에 맞게 files 배열 사용
const fileData = response.files || (response as any).data || [];
console.log("📁 파일 데이터 확인:", {
hasFiles: !!response.files,
hasData: !!(response as any).data,
fileDataLength: fileData.length,
fileData: fileData,
responseKeys: Object.keys(response)
});
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
}));
console.log("📁 변환된 파일 데이터:", newFiles);
const updatedFiles = [...uploadedFiles, ...newFiles];
console.log("🔄 파일 상태 업데이트:", {
이전파일수: uploadedFiles.length,
새파일수: newFiles.length,
총파일수: updatedFiles.length,
updatedFiles: updatedFiles.map(f => ({ objid: f.objid, name: f.realFileName }))
});
setUploadedFiles(updatedFiles);
setUploadStatus('success');
// localStorage 백업
try {
const backupKey = `fileUpload_${component.id}`;
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
} catch (e) {
console.warn("localStorage 백업 실패:", e);
}
// 전역 상태 업데이트 (모든 파일 컴포넌트 동기화)
if (typeof window !== 'undefined') {
// 전역 파일 상태 업데이트
const globalFileState = (window as any).globalFileState || {};
globalFileState[component.id] = updatedFiles;
(window as any).globalFileState = globalFileState;
// 모든 파일 컴포넌트에 동기화 이벤트 발생
const syncEvent = new CustomEvent('globalFileStateChanged', {
detail: {
componentId: component.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 콜백이 없습니다!");
}
// 그리드 파일 상태 새로고침 이벤트 발생
if (typeof window !== 'undefined') {
const refreshEvent = new CustomEvent('refreshFileStatus', {
detail: {
tableName: tableName,
recordId: recordId,
columnName: columnName,
targetObjid: targetObjid,
fileCount: updatedFiles.length
}
});
window.dispatchEvent(refreshEvent);
console.log("🔄 그리드 파일 상태 새로고침 이벤트 발생:", {
tableName,
recordId,
columnName,
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);
}
} else {
console.error("❌ 파일 업로드 실패:", response);
throw new Error(response.message || (response as any).error || '파일 업로드에 실패했습니다.');
}
} catch (error) {
console.error('파일 업로드 오류:', error);
setUploadStatus('error');
toast.dismiss();
toast.error(`파일 업로드 오류: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
}
}, [safeComponentConfig, uploadedFiles, onFormDataChange, component.columnName, component.id, formData]);
// 파일 다운로드
const handleFileDownload = useCallback(async (file: FileInfo) => {
try {
await downloadFile(file.objid, 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;
await deleteFile(fileId);
const updatedFiles = uploadedFiles.filter(f => f.objid !== fileId);
setUploadedFiles(updatedFiles);
// localStorage 백업 업데이트
try {
const backupKey = `fileUpload_${component.id}`;
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
} catch (e) {
console.warn("localStorage 백업 업데이트 실패:", e);
}
// 전역 상태 업데이트 (모든 파일 컴포넌트 동기화)
if (typeof window !== 'undefined') {
// 전역 파일 상태 업데이트
const globalFileState = (window as any).globalFileState || {};
globalFileState[component.id] = updatedFiles;
(window as any).globalFileState = globalFileState;
// 모든 파일 컴포넌트에 동기화 이벤트 발생
const syncEvent = new CustomEvent('globalFileStateChanged', {
detail: {
componentId: component.id,
files: updatedFiles,
fileCount: updatedFiles.length,
timestamp: Date.now()
}
});
window.dispatchEvent(syncEvent);
console.log("🗑️ 파일 삭제 후 전역 상태 동기화:", {
componentId: component.id,
deletedFile: fileName,
remainingFiles: updatedFiles.length
});
}
// 컴포넌트 업데이트
if (onUpdate) {
const timestamp = Date.now();
onUpdate({
uploadedFiles: updatedFiles,
lastFileUpdate: timestamp
});
}
toast.success(`${fileName} 삭제 완료`);
} catch (error) {
console.error('파일 삭제 오류:', error);
toast.error('파일 삭제에 실패했습니다.');
}
}, [uploadedFiles, onUpdate, component.id]);
// 파일 뷰어
const handleFileView = useCallback((file: FileInfo) => {
setViewerFile(file);
setIsViewerOpen(true);
}, []);
const handleViewerClose = useCallback(() => {
setIsViewerOpen(false);
setViewerFile(null);
}, []);
// 드래그 앤 드롭 핸들러
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 (
<div style={componentStyle} className={className}>
{/* 라벨 렌더링 */}
{component.label && component.style?.labelDisplay !== false && (
<label
style={{
position: "absolute",
top: "-25px",
left: "0px",
fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#3b83f6",
fontWeight: "500",
...(isInteractive && component.style ? component.style : {}),
}}
>
{component.label}
{component.required && (
<span style={{ color: "#ef4444" }}>*</span>
)}
</label>
)}
<div className="w-full h-full flex flex-col space-y-2">
{/* 디자인 모드가 아닐 때만 파일 업로드 영역 표시 */}
{!isDesignMode && (
<div
className={`
border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors
${dragOver ? 'border-blue-400 bg-blue-50' : 'border-gray-300'}
${safeComponentConfig.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-gray-400'}
${uploadStatus === 'uploading' ? 'opacity-75' : ''}
`}
onClick={handleClick}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<input
ref={fileInputRef}
type="file"
multiple={safeComponentConfig.multiple}
accept={safeComponentConfig.accept}
onChange={handleInputChange}
className="hidden"
disabled={safeComponentConfig.disabled}
/>
{uploadStatus === 'uploading' ? (
<div className="flex flex-col items-center space-y-2">
<div className="flex items-center space-x-2">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="text-blue-600 font-medium"> ...</span>
</div>
</div>
) : (
<>
<div>
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
<p className="text-lg font-medium text-gray-900 mb-2">
{safeComponentConfig.dragDropText || "파일을 드래그하거나 클릭하여 업로드하세요"}
</p>
<p className="text-xs text-gray-500 mt-1">
{safeComponentConfig.accept && `지원 형식: ${safeComponentConfig.accept}`}
{safeComponentConfig.maxSize && ` • 최대 ${formatFileSize(safeComponentConfig.maxSize)}`}
{safeComponentConfig.multiple && ' • 여러 파일 선택 가능'}
</p>
</div>
</>
)}
</div>
)}
{/* 업로드된 파일 목록 - 디자인 모드에서는 항상 표시 */}
{(uploadedFiles.length > 0 || isDesignMode) && (
<div className="flex-1 overflow-y-auto">
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-gray-700">
({uploadedFiles.length})
</h4>
{uploadedFiles.length > 0 && (
<Badge variant="secondary" className="text-xs">
{formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))}
</Badge>
)}
</div>
{uploadedFiles.length > 0 ? (
<div className="space-y-1">
{uploadedFiles.map((file) => (
<div key={file.objid} className="flex items-center space-x-2 p-2 bg-gray-50 rounded text-sm">
<div className="flex-shrink-0">
{getFileIcon(file.fileExt)}
</div>
<span className="flex-1 truncate text-gray-900">
{file.realFileName}
</span>
<span className="text-xs text-gray-500">
{formatFileSize(file.fileSize)}
</span>
</div>
))}
<div className="text-xs text-gray-500 mt-2 text-center">
💡
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
<File className="w-12 h-12 mb-3 text-gray-300" />
<p className="text-sm font-medium"> </p>
<p className="text-xs text-gray-400 mt-1"> </p>
</div>
)}
</div>
</div>
)}
{/* 도움말 텍스트 */}
{safeComponentConfig.helperText && (
<p className="text-xs text-gray-500 mt-1">
{safeComponentConfig.helperText}
</p>
)}
</div>
{/* 파일뷰어 모달 */}
<FileViewerModal
file={viewerFile}
isOpen={isViewerOpen}
onClose={handleViewerClose}
onDownload={handleFileDownload}
/>
</div>
);
};
export { FileUploadComponent };
export default FileUploadComponent;