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

1261 lines
47 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, getComponentFiles } 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 <FileImage className="h-6 w-6 text-blue-500" />;
}
if (["mp4", "avi", "mov", "wmv", "flv", "webm"].includes(ext)) {
return <FileVideo className="h-6 w-6 text-purple-500" />;
}
if (["mp3", "wav", "flac", "aac", "ogg"].includes(ext)) {
return <FileAudio className="h-6 w-6 text-green-500" />;
}
if (["pdf"].includes(ext)) {
return <FileText className="h-6 w-6 text-red-500" />;
}
if (["doc", "docx", "hwp", "hwpx", "pages"].includes(ext)) {
return <FileText className="h-6 w-6 text-blue-600" />;
}
if (["xls", "xlsx", "hcell", "numbers"].includes(ext)) {
return <FileText className="h-6 w-6 text-green-600" />;
}
if (["ppt", "pptx", "hanshow", "keynote"].includes(ext)) {
return <Presentation className="h-6 w-6 text-orange-500" />;
}
if (["zip", "rar", "7z", "tar", "gz"].includes(ext)) {
return <Archive className="h-6 w-6 text-gray-500" />;
}
return <File className="h-6 w-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;
}
const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
component,
componentConfig,
componentStyle,
className,
isInteractive,
isDesignMode = false, // 기본값 설정
formData,
onFormDataChange,
onClick,
onDragStart,
onDragEnd,
onUpdate,
}) => {
// 🔑 인증 정보 가져오기
const { user } = useAuth();
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 [isFileManagerOpen, setIsFileManagerOpen] = useState(false);
const [forceUpdate, setForceUpdate] = useState(0);
const [representativeImageUrl, setRepresentativeImageUrl] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// 🔑 레코드 모드 판단: formData에 id가 있으면 특정 행에 연결된 파일 관리
const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_'));
const recordTableName = formData?.tableName || component.tableName;
const recordId = formData?.id;
// 🔑 컬럼명 결정: 레코드 모드에서는 무조건 'attachments' 사용
// component.columnName이나 component.id는 '파일_업로드' 같은 한글 라벨일 수 있어서 DB 컬럼명으로 부적합
// 레코드 모드가 아닐 때만 component.columnName 또는 component.id 사용
const columnName = isRecordMode ? 'attachments' : (component.columnName || component.id || 'attachments');
// 🔑 레코드 모드용 targetObjid 생성
const getRecordTargetObjid = useCallback(() => {
if (isRecordMode && recordTableName && recordId) {
return `${recordTableName}:${recordId}:${columnName}`;
}
return null;
}, [isRecordMode, recordTableName, recordId, columnName]);
// 🔑 레코드별 고유 키 생성 (localStorage, 전역 상태용)
const getUniqueKey = useCallback(() => {
if (isRecordMode && recordTableName && recordId) {
// 레코드 모드: 테이블명:레코드ID:컴포넌트ID 형태로 고유 키 생성
return `fileUpload_${recordTableName}_${recordId}_${component.id}`;
}
// 기본 모드: 컴포넌트 ID만 사용
return `fileUpload_${component.id}`;
}, [isRecordMode, recordTableName, recordId, component.id]);
// 🔍 디버깅: 레코드 모드 상태 로깅
useEffect(() => {
console.log("📎 [FileUploadComponent] 모드 확인:", {
isRecordMode,
recordTableName,
recordId,
columnName,
targetObjid: getRecordTargetObjid(),
uniqueKey: getUniqueKey(),
formDataKeys: formData ? Object.keys(formData) : [],
// 🔍 추가 디버깅: 어디서 tableName이 오는지 확인
"formData.tableName": formData?.tableName,
"component.tableName": component.tableName,
"component.columnName": component.columnName,
"component.id": component.id,
});
}, [isRecordMode, recordTableName, recordId, columnName, getRecordTargetObjid, getUniqueKey, formData, component.tableName, component.columnName, component.id]);
// 🆕 레코드 ID 변경 시 파일 목록 초기화 및 새로 로드
const prevRecordIdRef = useRef<any>(null);
useEffect(() => {
if (prevRecordIdRef.current !== recordId) {
console.log("📎 [FileUploadComponent] 레코드 ID 변경 감지:", {
prev: prevRecordIdRef.current,
current: recordId,
isRecordMode,
});
prevRecordIdRef.current = recordId;
// 레코드 모드에서 레코드 ID가 변경되면 파일 목록 초기화
if (isRecordMode) {
setUploadedFiles([]);
}
}
}, [recordId, isRecordMode]);
// 컴포넌트 마운트 시 즉시 localStorage에서 파일 복원
useEffect(() => {
if (!component?.id) return;
try {
// 🔑 레코드별 고유 키 사용
const backupKey = getUniqueKey();
const backupFiles = localStorage.getItem(backupKey);
if (backupFiles) {
const parsedFiles = JSON.parse(backupFiles);
if (parsedFiles.length > 0) {
console.log("🚀 컴포넌트 마운트 시 파일 즉시 복원:", {
uniqueKey: backupKey,
componentId: component.id,
recordId: recordId,
restoredFiles: parsedFiles.length,
files: parsedFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })),
});
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]); // 레코드별 고유 키 변경 시 재실행
// 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너
useEffect(() => {
const handleDesignModeFileChange = (event: CustomEvent) => {
console.log("🎯🎯🎯 FileUploadComponent 화면설계 모드 파일 변경 이벤트 수신:", {
eventComponentId: event.detail.componentId,
currentComponentId: component.id,
isMatch: event.detail.componentId === component.id,
filesCount: event.detail.files?.length || 0,
action: event.detail.action,
source: event.detail.source,
eventDetail: event.detail,
});
// 현재 컴포넌트와 일치하고 화면설계 모드에서 온 이벤트인 경우
if (event.detail.componentId === component.id && event.detail.source === "designMode") {
// 파일 상태 업데이트
const newFiles = event.detail.files || [];
setUploadedFiles(newFiles);
// localStorage 백업 업데이트 (레코드별 고유 키 사용)
try {
const backupKey = getUniqueKey();
localStorage.setItem(backupKey, JSON.stringify(newFiles));
console.log("💾 화면설계 모드 동기화 후 localStorage 백업 업데이트:", {
uniqueKey: backupKey,
componentId: component.id,
recordId: recordId,
fileCount: newFiles.length,
});
} catch (e) {
console.warn("localStorage 백업 업데이트 실패:", e);
}
// 전역 상태 업데이트
if (typeof window !== "undefined") {
(window as any).globalFileState = {
...(window as any).globalFileState,
[component.id]: newFiles,
};
}
// onUpdate 콜백 호출 (부모 컴포넌트에 알림)
if (onUpdate) {
onUpdate({
uploadedFiles: newFiles,
lastFileUpdate: event.detail.timestamp,
});
}
console.log("🎉🎉🎉 화면설계 모드 → 실제 화면 동기화 완료:", {
componentId: component.id,
finalFileCount: newFiles.length,
});
}
};
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;
try {
// 🔑 레코드 모드: 해당 행의 파일만 조회
if (isRecordMode && recordTableName && recordId) {
console.log("📂 [FileUploadComponent] 레코드 모드 파일 조회:", {
tableName: recordTableName,
recordId: recordId,
columnName: columnName,
targetObjid: getRecordTargetObjid(),
});
}
// 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
};
console.log("📂 [FileUploadComponent] 파일 조회 파라미터:", params);
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];
console.log("📂 [FileUploadComponent] 파일 병합 완료:", {
uniqueKey,
serverFiles: formattedFiles.length,
localFiles: parsedBackupFiles.length,
finalFiles: finalFiles.length,
});
}
} 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;
console.log("🔄 FileUploadComponent 파일 동기화 시작:", {
componentId: component.id,
componentFiles: componentFiles.length,
formData: formData,
screenId: formData?.screenId,
tableName: formData?.tableName, // 🔍 테이블명 확인
recordId: formData?.id, // 🔍 레코드 ID 확인
currentUploadedFiles: uploadedFiles.length,
});
// 🔒 항상 DB에서 최신 파일 목록을 조회 (멀티테넌시 격리)
loadComponentFiles().then((dbLoadSuccess) => {
if (dbLoadSuccess) {
return; // DB 로드 성공 시 localStorage 무시
}
// DB 로드 실패 시에만 기존 로직 사용 (하위 호환성)
// 전역 상태에서 최신 파일 정보 가져오기
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
const globalFiles = 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]);
// 전역 상태 변경 감지 (모든 파일 컴포넌트 동기화 + 화면 복원)
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 || 0,
새파일수: files?.length || 0,
files: files?.map((f: any) => ({ objid: f.objid, name: f.realFileName })) || [],
});
setUploadedFiles(files);
setForceUpdate((prev) => prev + 1);
// localStorage 백업도 업데이트 (레코드별 고유 키 사용)
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);
};
}
}, [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(() => {
console.log("🎯 handleFileSelect 호출됨:", {
hasFileInputRef: !!fileInputRef.current,
fileInputRef: fileInputRef.current,
fileInputType: fileInputRef.current?.type,
fileInputHidden: fileInputRef.current?.className,
});
if (fileInputRef.current) {
console.log("✅ fileInputRef.current.click() 호출");
fileInputRef.current.click();
} else {
console.log("❌ fileInputRef.current가 null입니다");
}
}, []);
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[] = [];
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}`;
console.log("📁 [레코드 모드] 파일 업로드:", {
targetObjid,
tableName: effectiveTableName,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
});
} else if (screenId) {
// 🔑 템플릿 파일 (백엔드 조회 형식과 동일하게)
targetObjid = `screen_files:${screenId}:${component.id}:${effectiveColumnName}`;
console.log("📝 [템플릿 모드] 파일 업로드:", targetObjid);
} else {
// 기본값 (화면관리에서 사용)
targetObjid = `temp_${component.id}`;
console.log("📝 [기본 모드] 파일 업로드:", targetObjid);
}
// 🔒 현재 사용자의 회사 코드 가져오기 (멀티테넌시 격리)
const userCompanyCode = user?.companyCode || (window as any).__user__?.companyCode;
console.log("📤 [FileUploadComponent] 파일 업로드 준비:", {
userCompanyCode,
isRecordMode: effectiveIsRecordMode,
tableName: effectiveTableName,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
targetObjid,
});
// 🔑 레코드 모드일 때는 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,
};
console.log("📤 [FileUploadComponent] uploadData 최종:", {
isRecordMode: effectiveIsRecordMode,
linkedTable: finalLinkedTable,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
targetObjid,
});
console.log("🚀 [FileUploadComponent] uploadFiles API 호출 직전:", {
filesCount: filesToUpload.length,
uploadData,
});
const response = await uploadFiles({
files: filesToUpload,
...uploadData,
});
console.log("📥 [FileUploadComponent] uploadFiles API 응답:", response);
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 추가
});
// 모든 파일 컴포넌트에 동기화 이벤트 발생
const syncEvent = new CustomEvent("globalFileStateChanged", {
detail: {
componentId: component.id,
uniqueKey: uniqueKey, // 🆕 고유 키 추가
recordId: recordId, // 🆕 레코드 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 콜백이 없습니다!");
}
// 🆕 레코드 모드: attachments 컬럼 동기화 (formData 업데이트)
if (effectiveIsRecordMode && onFormDataChange) {
// 파일 정보를 간소화하여 attachments 컬럼에 저장할 형태로 변환
const attachmentsData = updatedFiles.map(file => ({
objid: file.objid,
realFileName: file.realFileName,
fileSize: file.fileSize,
fileExt: file.fileExt,
filePath: file.filePath,
regdate: file.regdate || new Date().toISOString(),
}));
console.log("📎 [레코드 모드] attachments 컬럼 동기화:", {
tableName: effectiveTableName,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
fileCount: attachmentsData.length,
});
// onFormDataChange를 통해 부모 컴포넌트에 attachments 업데이트 알림
onFormDataChange({
[effectiveColumnName]: attachmentsData,
// 🆕 백엔드에서 attachments 컬럼 업데이트를 위한 메타 정보
__attachmentsUpdate: {
tableName: effectiveTableName,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
files: attachmentsData,
}
});
}
// 그리드 파일 상태 새로고침 이벤트 발생
if (typeof window !== "undefined") {
const refreshEvent = new CustomEvent("refreshFileStatus", {
detail: {
tableName: effectiveTableName,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
targetObjid: targetObjid,
fileCount: updatedFiles.length,
},
});
window.dispatchEvent(refreshEvent);
console.log("🔄 그리드 파일 상태 새로고침 이벤트 발생:", {
tableName: effectiveTableName,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
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);
}
// 성공 시 토스트 처리
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;
// 모든 파일 컴포넌트에 동기화 이벤트 발생
const syncEvent = new CustomEvent("globalFileStateChanged", {
detail: {
componentId: component.id,
uniqueKey: uniqueKey, // 🆕 고유 키 추가
recordId: recordId, // 🆕 레코드 ID 추가
files: updatedFiles,
fileCount: updatedFiles.length,
timestamp: Date.now(),
source: "realScreen", // 🎯 실제 화면에서 온 이벤트임을 표시
action: "delete",
},
});
window.dispatchEvent(syncEvent);
console.log("🗑️ 파일 삭제 후 전역 상태 동기화:", {
componentId: component.id,
deletedFile: fileName,
remainingFiles: updatedFiles.length,
});
}
// 컴포넌트 업데이트
if (onUpdate) {
const timestamp = Date.now();
onUpdate({
uploadedFiles: updatedFiles,
lastFileUpdate: timestamp,
});
}
// 🆕 레코드 모드: attachments 컬럼 동기화 (파일 삭제 후)
if (isRecordMode && onFormDataChange && recordTableName && recordId) {
const attachmentsData = updatedFiles.map(f => ({
objid: f.objid,
realFileName: f.realFileName,
fileSize: f.fileSize,
fileExt: f.fileExt,
filePath: f.filePath,
regdate: f.regdate || new Date().toISOString(),
}));
console.log("📎 [레코드 모드] 파일 삭제 후 attachments 동기화:", {
tableName: recordTableName,
recordId: recordId,
columnName: columnName,
remainingFiles: attachmentsData.length,
});
onFormDataChange({
[columnName]: attachmentsData,
__attachmentsUpdate: {
tableName: recordTableName,
recordId: recordId,
columnName: columnName,
files: attachmentsData,
}
});
}
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;
}
console.log("🖼️ 대표 이미지 로드 시작:", {
objid: file.objid,
fileName: file.realFileName,
});
// API 클라이언트를 통해 Blob으로 다운로드 (인증 토큰 포함)
const response = await apiClient.get(`/files/download/${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);
console.log("✅ 대표 이미지 로드 성공:", 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);
console.log("✅ 대표 파일 설정 완료:", {
componentId: component.id,
representativeFile: file.realFileName,
objid: file.objid,
});
} 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) => {
console.log("🎯 드래그 오버 이벤트 감지:", {
readonly: safeComponentConfig.readonly,
disabled: safeComponentConfig.disabled,
dragOver: dragOver,
});
e.preventDefault();
e.stopPropagation();
if (!safeComponentConfig.readonly && !safeComponentConfig.disabled) {
setDragOver(true);
console.log("✅ 드래그 오버 활성화");
} else {
console.log("❌ 드래그 차단됨: readonly 또는 disabled");
}
},
[safeComponentConfig.readonly, safeComponentConfig.disabled, dragOver],
);
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) => {
console.log("🖱️ 파일 업로드 영역 클릭:", {
readonly: safeComponentConfig.readonly,
disabled: safeComponentConfig.disabled,
hasHandleFileSelect: !!handleFileSelect,
});
e.preventDefault();
e.stopPropagation();
if (!safeComponentConfig.readonly && !safeComponentConfig.disabled) {
console.log("✅ 파일 선택 함수 호출");
handleFileSelect();
} else {
console.log("❌ 클릭 차단됨: readonly 또는 disabled");
}
onClick?.();
},
[safeComponentConfig.readonly, safeComponentConfig.disabled, handleFileSelect, onClick],
);
return (
<div
style={{
...componentStyle,
width: "100%", // 🆕 부모 컨테이너 너비에 맞춤
height: "100%", // 🆕 부모 컨테이너 높이에 맞춤
border: "none !important",
boxShadow: "none !important",
outline: "none !important",
backgroundColor: "transparent !important",
padding: "0px !important",
borderRadius: "0px !important",
marginBottom: "8px !important",
}}
className={`${className} file-upload-container`}
>
{/* 라벨 렌더링 */}
{component.label && component.style?.labelDisplay !== false && (
<label
style={{
position: "absolute",
top: "-20px",
left: "0px",
fontSize: "12px",
color: "rgb(107, 114, 128)",
fontWeight: "400",
background: "transparent !important",
border: "none !important",
boxShadow: "none !important",
outline: "none !important",
padding: "0px !important",
margin: "0px !important"
}}
>
{component.label}
{component.required && (
<span style={{ color: "#ef4444" }}>*</span>
)}
</label>
)}
<div
className="border-border bg-card relative flex h-full w-full flex-col rounded-lg border overflow-hidden"
>
{/* 대표 이미지 전체 화면 표시 */}
{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 ? (
<div className="relative h-full w-full flex items-center justify-center bg-muted/10">
<img
src={representativeImageUrl}
alt={representativeFile.realFileName}
className="h-full w-full object-contain"
/>
</div>
) : isImage && !representativeImageUrl ? (
<div className="flex h-full w-full flex-col items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-2"></div>
<p className="text-sm text-muted-foreground"> ...</p>
</div>
) : (
<div className="flex h-full w-full flex-col items-center justify-center">
{getFileIcon(representativeFile.fileExt)}
<p className="mt-3 text-sm font-medium text-center px-4">
{representativeFile.realFileName}
</p>
<Badge variant="secondary" className="mt-2">
</Badge>
</div>
)}
{/* 우측 하단 자세히보기 버튼 */}
<div className="absolute bottom-3 right-3">
<Button
variant="secondary"
size="sm"
className="h-8 px-3 text-xs shadow-md"
onClick={() => setIsFileManagerOpen(true)}
>
({uploadedFiles.length})
</Button>
</div>
</>
);
})() : (
<div className="flex h-full w-full flex-col items-center justify-center text-muted-foreground">
<File className="mb-3 h-12 w-12" />
<p className="text-sm font-medium"> </p>
<Button
variant="outline"
size="sm"
className="mt-4 h-8 px-3 text-xs"
onClick={() => setIsFileManagerOpen(true)}
>
</Button>
</div>
)}
</div>
{/* 파일뷰어 모달 */}
<FileViewerModal
file={viewerFile}
isOpen={isViewerOpen}
onClose={handleViewerClose}
onDownload={handleFileDownload}
onDelete={!isDesignMode ? handleFileDelete : undefined}
/>
{/* 파일 관리 모달 */}
<FileManagerModal
isOpen={isFileManagerOpen}
onClose={() => setIsFileManagerOpen(false)}
uploadedFiles={uploadedFiles}
onFileUpload={handleFileUpload}
onFileDownload={handleFileDownload}
onFileDelete={handleFileDelete}
onFileView={handleFileView}
onSetRepresentative={handleSetRepresentative}
config={safeComponentConfig}
isDesignMode={isDesignMode}
/>
</div>
);
};
export { FileUploadComponent };
export default FileUploadComponent;