ERP-node/frontend/components/screen/panels/FileComponentConfigPanel.tsx

1236 lines
48 KiB
TypeScript
Raw Normal View History

2025-09-05 21:52:19 +09:00
"use client";
import React, { useState, useEffect, useCallback, useRef } from "react";
2025-09-05 21:52:19 +09:00
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { FileComponent, TableInfo } from "@/types/screen";
import { Plus, X, Upload, File, Image, FileText, Download, Trash2 } from "lucide-react";
2025-09-05 21:52:19 +09:00
import { Button } from "@/components/ui/button";
2025-09-29 13:29:03 +09:00
import { FileInfo, FileUploadResponse } from "@/lib/registry/components/file-upload/types";
import { uploadFiles, downloadFile, deleteFile } from "@/lib/api/file";
import { formatFileSize } from "@/lib/utils";
import { toast } from "sonner";
2025-09-05 21:52:19 +09:00
interface FileComponentConfigPanelProps {
component: FileComponent;
onUpdateProperty: (componentId: string, path: string, value: any) => void;
currentTable?: TableInfo;
currentTableName?: string;
2025-09-05 21:52:19 +09:00
}
export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> = ({
component,
onUpdateProperty,
currentTable,
currentTableName,
}) => {
2025-09-29 17:21:47 +09:00
console.log("🎨🎨🎨 FileComponentConfigPanel 렌더링:", {
componentId: component?.id,
componentType: component?.type,
hasOnUpdateProperty: !!onUpdateProperty,
currentTable,
currentTableName
});
// fileConfig가 없는 경우 초기화
React.useEffect(() => {
if (!component.fileConfig) {
const defaultFileConfig = {
docType: "DOCUMENT",
docTypeName: "일반 문서",
dragDropText: "파일을 드래그하거나 클릭하여 업로드하세요",
maxSize: 10,
maxFiles: 5,
multiple: true,
showPreview: true,
showProgress: true,
autoLink: false,
accept: [],
linkedTable: "",
linkedField: "",
};
onUpdateProperty(component.id, "fileConfig", defaultFileConfig);
}
}, [component.fileConfig, component.id, onUpdateProperty]);
2025-09-05 21:52:19 +09:00
// 로컬 상태
const [localInputs, setLocalInputs] = useState({
docType: component.fileConfig?.docType || "DOCUMENT",
docTypeName: component.fileConfig?.docTypeName || "일반 문서",
dragDropText: component.fileConfig?.dragDropText || "파일을 드래그하거나 클릭하여 업로드하세요",
maxSize: component.fileConfig?.maxSize || 10,
maxFiles: component.fileConfig?.maxFiles || 5,
newAcceptType: "",
linkedTable: component.fileConfig?.linkedTable || "",
linkedField: component.fileConfig?.linkedField || "",
2025-09-05 21:52:19 +09:00
});
const [localValues, setLocalValues] = useState({
multiple: component.fileConfig?.multiple ?? true,
showPreview: component.fileConfig?.showPreview ?? true,
showProgress: component.fileConfig?.showProgress ?? true,
autoLink: component.fileConfig?.autoLink ?? false,
2025-09-05 21:52:19 +09:00
});
const [acceptTypes, setAcceptTypes] = useState<string[]>(component.fileConfig?.accept || []);
// 전역 파일 상태 관리를 window 객체에 저장 (컴포넌트 언마운트 시에도 유지)
const getGlobalFileState = (): {[key: string]: FileInfo[]} => {
if (typeof window !== 'undefined') {
return (window as any).globalFileState || {};
}
return {};
};
const setGlobalFileState = (updater: (prev: {[key: string]: FileInfo[]}) => {[key: string]: FileInfo[]}) => {
if (typeof window !== 'undefined') {
const currentState = getGlobalFileState();
const newState = updater(currentState);
(window as any).globalFileState = newState;
console.log("🌐 전역 파일 상태 업데이트:", {
componentId: component.id,
newFileCount: newState[component.id]?.length || 0,
totalComponents: Object.keys(newState).length
});
// 강제 리렌더링을 위한 이벤트 발생
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
detail: { componentId: component.id, fileCount: newState[component.id]?.length || 0 }
}));
// 디버깅용 전역 함수 등록
(window as any).debugFileState = () => {
console.log("🔍 전역 파일 상태 디버깅:", {
globalState: (window as any).globalFileState,
localStorage: Object.keys(localStorage).filter(key => key.startsWith('fileComponent_')).map(key => ({
key,
data: JSON.parse(localStorage.getItem(key) || '[]')
}))
});
};
}
};
// 파일 업로드 관련 상태 - 초기화 시 전역 상태에서 복원
const initializeUploadedFiles = (): FileInfo[] => {
const componentFiles = component.uploadedFiles || [];
const globalFiles = getGlobalFileState()[component.id] || [];
2025-09-29 17:21:47 +09:00
// localStorage 백업에서 복원 (영구 저장된 파일 + 임시 파일 + FileUploadComponent 백업)
const backupKey = `fileComponent_${component.id}_files`;
const tempBackupKey = `fileComponent_${component.id}_files_temp`;
2025-09-29 17:21:47 +09:00
const fileUploadBackupKey = `fileUpload_${component.id}`; // 🎯 실제 화면과 동기화
const backupFiles = localStorage.getItem(backupKey);
const tempBackupFiles = localStorage.getItem(tempBackupKey);
2025-09-29 17:21:47 +09:00
const fileUploadBackupFiles = localStorage.getItem(fileUploadBackupKey); // 🎯 실제 화면 백업
let parsedBackupFiles: FileInfo[] = [];
let parsedTempFiles: FileInfo[] = [];
2025-09-29 17:21:47 +09:00
let parsedFileUploadFiles: FileInfo[] = []; // 🎯 실제 화면 파일
if (backupFiles) {
try {
parsedBackupFiles = JSON.parse(backupFiles);
} catch (error) {
console.error("백업 파일 파싱 실패:", error);
}
}
if (tempBackupFiles) {
try {
parsedTempFiles = JSON.parse(tempBackupFiles);
} catch (error) {
console.error("임시 파일 파싱 실패:", error);
}
}
2025-09-29 17:21:47 +09:00
// 🎯 실제 화면 FileUploadComponent 백업 파싱
if (fileUploadBackupFiles) {
try {
parsedFileUploadFiles = JSON.parse(fileUploadBackupFiles);
} catch (error) {
console.error("FileUploadComponent 백업 파일 파싱 실패:", error);
}
}
// 🎯 우선순위: 전역 상태 > FileUploadComponent 백업 > localStorage > 임시 파일 > 컴포넌트 속성
const finalFiles = globalFiles.length > 0 ? globalFiles :
2025-09-29 17:21:47 +09:00
parsedFileUploadFiles.length > 0 ? parsedFileUploadFiles : // 🎯 실제 화면 우선
parsedBackupFiles.length > 0 ? parsedBackupFiles :
parsedTempFiles.length > 0 ? parsedTempFiles :
componentFiles;
console.log("🚀 FileComponentConfigPanel 초기화:", {
componentId: component.id,
componentFiles: componentFiles.length,
globalFiles: globalFiles.length,
backupFiles: parsedBackupFiles.length,
tempFiles: parsedTempFiles.length,
2025-09-29 17:21:47 +09:00
fileUploadFiles: parsedFileUploadFiles.length, // 🎯 실제 화면 파일 수
finalFiles: finalFiles.length,
2025-09-29 17:21:47 +09:00
source: globalFiles.length > 0 ? 'global' :
parsedFileUploadFiles.length > 0 ? 'fileUploadComponent' : // 🎯 실제 화면 소스
parsedBackupFiles.length > 0 ? 'localStorage' :
parsedTempFiles.length > 0 ? 'temp' : 'component'
});
return finalFiles;
};
const [uploadedFiles, setUploadedFiles] = useState<FileInfo[]>(() => {
const initialFiles = initializeUploadedFiles();
// 초기화된 파일이 있고 컴포넌트 속성과 다르면 즉시 동기화
if (initialFiles.length > 0 && JSON.stringify(initialFiles) !== JSON.stringify(component.uploadedFiles || [])) {
setTimeout(() => {
onUpdateProperty(component.id, "uploadedFiles", initialFiles);
onUpdateProperty(component.id, "lastFileUpdate", Date.now());
console.log("🔄 초기화 시 컴포넌트 속성 동기화:", {
componentId: component.id,
fileCount: initialFiles.length
});
}, 0);
}
return initialFiles;
});
const [dragOver, setDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
// 이전 컴포넌트 ID 추적용 ref
const prevComponentIdRef = useRef(component.id);
// 파일 타입별 아이콘 반환
const getFileIcon = (fileExt: string) => {
const ext = fileExt.toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) {
return <Image className="w-5 h-5" />;
}
if (['pdf', 'doc', 'docx', 'txt', 'rtf'].includes(ext)) {
return <FileText className="w-5 h-5" />;
}
return <File className="w-5 h-5" />;
};
// 파일 업로드 처리
const handleFileUpload = useCallback(async (files: FileList | File[]) => {
2025-09-29 17:21:47 +09:00
console.log("🚀🚀🚀 FileComponentConfigPanel 파일 업로드 시작:", {
filesCount: files?.length || 0,
componentId: component?.id,
componentType: component?.type,
hasOnUpdateProperty: !!onUpdateProperty
});
if (!files || files.length === 0) {
console.log("❌ 파일이 없음");
return;
}
const fileArray = Array.from(files);
const validFiles: File[] = [];
// 파일 검증
for (const file of fileArray) {
if (file.size > localInputs.maxSize * 1024 * 1024) {
toast.error(`${file.name}: 파일 크기가 ${localInputs.maxSize}MB를 초과합니다.`);
continue;
}
// 파일 타입 검증 (acceptTypes가 설정된 경우에만)
if (acceptTypes.length > 0) {
const fileExt = '.' + file.name.split('.').pop()?.toLowerCase();
const isAllowed = acceptTypes.some(type =>
type === '*/*' ||
type === file.type ||
type === fileExt ||
(type.startsWith('.') && fileExt === type) ||
(type.includes('/*') && file.type.startsWith(type.split('/')[0]))
);
if (!isAllowed) {
toast.error(`${file.name}: 허용되지 않는 파일 형식입니다. (허용: ${acceptTypes.join(', ')})`);
console.log(`파일 검증 실패:`, {
fileName: file.name,
fileType: file.type,
fileExt,
acceptTypes,
isAllowed
});
continue;
}
}
console.log(`파일 검증 성공:`, {
fileName: file.name,
fileType: file.type,
fileSize: file.size,
acceptTypesCount: acceptTypes.length
});
validFiles.push(file);
}
if (validFiles.length === 0) return;
2025-09-26 17:12:03 +09:00
// 중복 파일 체크
const existingFiles = uploadedFiles;
const existingFileNames = existingFiles.map(f => f.realFileName.toLowerCase());
const duplicates: string[] = [];
const uniqueFiles: File[] = [];
console.log("🔍 중복 파일 체크:", {
uploadedFiles: existingFiles.length,
existingFileNames: existingFileNames,
newFiles: validFiles.map(f => f.name.toLowerCase())
});
validFiles.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 : validFiles;
try {
2025-09-26 17:12:03 +09:00
console.log("🔄 파일 업로드 시작:", {
originalFiles: validFiles.length,
filesToUpload: filesToUpload.length,
uploading
});
setUploading(true);
2025-09-26 17:12:03 +09:00
toast.loading(`${filesToUpload.length}개 파일 업로드 중...`);
2025-09-29 17:21:47 +09:00
// 🎯 여러 방법으로 screenId 확인
let screenId = (window as any).__CURRENT_SCREEN_ID__;
// 1차: 전역 변수에서 가져오기
if (!screenId) {
// 2차: URL에서 추출 시도
if (typeof window !== 'undefined' && window.location.pathname.includes('/screens/')) {
const pathScreenId = window.location.pathname.split('/screens/')[1];
if (pathScreenId && !isNaN(parseInt(pathScreenId))) {
screenId = parseInt(pathScreenId);
}
}
}
// 3차: 기본값 설정
if (!screenId) {
screenId = 40; // 기본 화면 ID (디자인 모드용)
console.warn("⚠️ screenId를 찾을 수 없어 기본값(40) 사용");
}
2025-09-26 17:12:03 +09:00
const componentId = component.id;
const fieldName = component.columnName || component.id || 'file_attachment';
2025-09-29 17:21:47 +09:00
console.log("📋 파일 업로드 기본 정보:", {
screenId,
screenIdSource: (window as any).__CURRENT_SCREEN_ID__ ? 'global' : 'url_or_default',
componentId,
fieldName,
docType: localInputs.docType,
docTypeName: localInputs.docTypeName,
currentPath: typeof window !== 'undefined' ? window.location.pathname : 'unknown'
});
const response = await uploadFiles({
2025-09-26 17:12:03 +09:00
files: filesToUpload,
2025-09-29 17:21:47 +09:00
// 🎯 백엔드 API가 기대하는 정확한 형식으로 설정
autoLink: true, // 자동 연결 활성화
linkedTable: 'screen_files', // 연결 테이블
recordId: screenId, // 레코드 ID
columnName: fieldName, // 컬럼명
isVirtualFileColumn: true, // 가상 파일 컬럼
docType: localInputs.docType,
docTypeName: localInputs.docTypeName,
});
console.log("📤 파일 업로드 응답:", response);
if (response.success && (response.data || response.files)) {
const filesData = response.data || response.files;
console.log("📁 업로드된 파일 데이터:", filesData);
const newFiles: FileInfo[] = filesData.map((file: any) => ({
objid: file.objid || `temp_${Date.now()}_${Math.random()}`,
savedFileName: file.saved_file_name || file.savedFileName,
realFileName: file.real_file_name || file.realFileName,
fileSize: file.file_size || file.fileSize,
fileExt: file.file_ext || file.fileExt,
filePath: file.file_path || file.filePath,
docType: localInputs.docType,
docTypeName: localInputs.docTypeName,
targetObjid: file.target_objid || file.targetObjid || component.id,
parentTargetObjid: file.parent_target_objid || file.parentTargetObjid,
companyCode: file.company_code || file.companyCode || 'DEFAULT',
writer: file.writer || 'user',
regdate: file.regdate || new Date().toISOString(),
status: file.status || 'ACTIVE',
path: file.file_path || file.filePath,
name: file.real_file_name || file.realFileName,
id: file.objid,
size: file.file_size || file.fileSize,
type: localInputs.docType,
uploadedAt: file.regdate || new Date().toISOString(),
}));
const updatedFiles = localValues.multiple ? [...uploadedFiles, ...newFiles] : newFiles;
setUploadedFiles(updatedFiles);
// 자동으로 영구 저장 (저장 버튼 없이 바로 저장)
const timestamp = Date.now();
// 전역 상태에 저장
setGlobalFileState(prev => ({
...prev,
[component.id]: updatedFiles
}));
// 컴포넌트 속성에 저장
onUpdateProperty(component.id, "uploadedFiles", updatedFiles);
onUpdateProperty(component.id, "lastFileUpdate", timestamp);
// localStorage에 영구 저장
const backupKey = `fileComponent_${component.id}_files`;
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
2025-09-29 13:29:03 +09:00
// 전역 파일 상태 변경 이벤트 발생 (RealtimePreview 업데이트용)
if (typeof window !== 'undefined') {
2025-09-29 17:21:47 +09:00
const eventDetail = {
componentId: component.id,
files: updatedFiles,
fileCount: updatedFiles.length,
action: 'upload',
timestamp: Date.now(),
source: 'designMode' // 🎯 화면설계 모드에서 온 이벤트임을 표시
};
console.log("🚀🚀🚀 FileComponentConfigPanel 이벤트 발생:", eventDetail);
console.log("🔍 현재 컴포넌트 ID:", component.id);
console.log("🔍 업로드된 파일 수:", updatedFiles.length);
console.log("🔍 파일 목록:", updatedFiles.map(f => f.name));
2025-09-29 13:29:03 +09:00
const event = new CustomEvent('globalFileStateChanged', {
2025-09-29 17:21:47 +09:00
detail: eventDetail
2025-09-29 13:29:03 +09:00
});
2025-09-29 17:21:47 +09:00
// 이벤트 리스너가 있는지 확인
const listenerCount = window.getEventListeners ?
window.getEventListeners(window)?.globalFileStateChanged?.length || 0 :
'unknown';
console.log("🔍 globalFileStateChanged 리스너 수:", listenerCount);
2025-09-29 13:29:03 +09:00
window.dispatchEvent(event);
2025-09-29 17:21:47 +09:00
console.log("✅✅✅ globalFileStateChanged 이벤트 발생 완료");
// 강제로 모든 RealtimePreview 컴포넌트에게 알림 (여러 번)
setTimeout(() => {
console.log("🔄 추가 이벤트 발생 (지연 100ms)");
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
detail: { ...eventDetail, delayed: true }
}));
}, 100);
setTimeout(() => {
console.log("🔄 추가 이벤트 발생 (지연 300ms)");
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
detail: { ...eventDetail, delayed: true, attempt: 2 }
}));
}, 300);
setTimeout(() => {
console.log("🔄 추가 이벤트 발생 (지연 500ms)");
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
detail: { ...eventDetail, delayed: true, attempt: 3 }
}));
}, 500);
// 직접 전역 상태 강제 업데이트
console.log("🔄 전역 상태 강제 업데이트 시도");
if ((window as any).forceRealtimePreviewUpdate) {
(window as any).forceRealtimePreviewUpdate(component.id, updatedFiles);
}
2025-09-29 13:29:03 +09:00
}
console.log("🔄 FileComponentConfigPanel 자동 저장:", {
componentId: component.id,
uploadedFiles: updatedFiles.length,
status: "자동 영구 저장됨",
onUpdatePropertyExists: typeof onUpdateProperty === 'function',
globalFileStateUpdated: getGlobalFileState()[component.id]?.length || 0,
localStorageBackup: localStorage.getItem(`fileComponent_${component.id}_files`) ? 'saved' : 'not saved'
});
2025-09-26 17:12:03 +09:00
// 그리드 파일 상태 새로고침 이벤트 발생
if (typeof window !== 'undefined') {
2025-09-29 13:29:03 +09:00
const tableName = component.tableName || currentTableName || 'unknown';
const columnName = component.columnName || component.id;
const recordId = component.id; // 임시로 컴포넌트 ID 사용
const targetObjid = component.id;
2025-09-26 17:12:03 +09:00
const refreshEvent = new CustomEvent('refreshFileStatus', {
detail: {
tableName: tableName,
recordId: recordId,
columnName: columnName,
targetObjid: targetObjid,
fileCount: updatedFiles.length
}
});
window.dispatchEvent(refreshEvent);
console.log("🔄 FileComponentConfigPanel 그리드 새로고침 이벤트 발생:", {
tableName,
recordId,
columnName,
targetObjid,
fileCount: updatedFiles.length
});
}
toast.dismiss();
toast.success(`${validFiles.length}개 파일이 성공적으로 업로드되었습니다.`);
console.log("✅ 파일 업로드 성공:", {
newFilesCount: newFiles.length,
totalFiles: updatedFiles.length,
componentId: component.id,
updatedFiles: updatedFiles.map(f => ({ objid: f.objid, name: f.realFileName }))
});
} else {
throw new Error(response.message || '파일 업로드에 실패했습니다.');
}
2025-09-29 17:21:47 +09:00
} catch (error: any) {
console.error('❌ 파일 업로드 오류:', {
error,
errorMessage: error?.message,
errorResponse: error?.response?.data,
errorStatus: error?.response?.status,
componentId: component?.id,
screenId,
fieldName
});
toast.dismiss();
2025-09-29 17:21:47 +09:00
toast.error(`파일 업로드에 실패했습니다: ${error?.message || '알 수 없는 오류'}`);
} finally {
console.log("🏁 파일 업로드 완료, 로딩 상태 해제");
setUploading(false);
}
}, [localInputs, localValues, uploadedFiles, onUpdateProperty, currentTableName, component, acceptTypes]);
// 파일 다운로드 처리
const handleFileDownload = useCallback(async (file: FileInfo) => {
try {
await downloadFile({
2025-09-29 13:29:03 +09:00
fileId: file.objid || file.id || '',
serverFilename: file.savedFileName,
originalName: file.realFileName || file.name || 'download',
});
toast.success(`${file.realFileName || file.name} 다운로드가 완료되었습니다.`);
} catch (error) {
console.error('파일 다운로드 오류:', error);
toast.error('파일 다운로드에 실패했습니다.');
}
}, []);
// 파일 삭제 처리
const handleFileDelete = useCallback(async (fileId: string) => {
2025-09-29 17:21:47 +09:00
console.log("🗑️🗑️🗑️ FileComponentConfigPanel 파일 삭제 시작:", {
fileId,
componentId: component?.id,
currentFilesCount: uploadedFiles.length,
hasOnUpdateProperty: !!onUpdateProperty
});
try {
2025-09-29 17:21:47 +09:00
console.log("📡 deleteFile API 호출 시작...");
2025-09-29 13:29:03 +09:00
await deleteFile(fileId, 'temp_record');
2025-09-29 17:21:47 +09:00
console.log("✅ deleteFile API 호출 성공");
const updatedFiles = uploadedFiles.filter(file => file.objid !== fileId && file.id !== fileId);
setUploadedFiles(updatedFiles);
// 전역 상태에도 업데이트
setGlobalFileState(prev => ({
...prev,
[component.id]: updatedFiles
}));
// 컴포넌트 속성 업데이트 (RealtimePreview 강제 리렌더링용)
const timestamp = Date.now();
onUpdateProperty(component.id, "uploadedFiles", updatedFiles);
onUpdateProperty(component.id, "lastFileUpdate", timestamp);
// localStorage 백업도 업데이트 (영구 저장소와 임시 저장소 모두)
const backupKey = `fileComponent_${component.id}_files`;
const tempBackupKey = `fileComponent_${component.id}_files_temp`;
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
localStorage.setItem(tempBackupKey, JSON.stringify(updatedFiles));
console.log("🗑️ FileComponentConfigPanel 파일 삭제:", {
componentId: component.id,
deletedFileId: fileId,
remainingFiles: updatedFiles.length,
timestamp: timestamp
});
2025-09-26 17:12:03 +09:00
2025-09-29 17:21:47 +09:00
// 🎯 RealtimePreview 동기화를 위한 전역 이벤트 발생
if (typeof window !== 'undefined') {
const eventDetail = {
componentId: component.id,
files: updatedFiles,
fileCount: updatedFiles.length,
action: 'delete',
timestamp: timestamp,
source: 'designMode' // 🎯 화면설계 모드에서 온 이벤트임을 표시
};
console.log("🚀🚀🚀 FileComponentConfigPanel 삭제 이벤트 발생:", eventDetail);
const event = new CustomEvent('globalFileStateChanged', {
detail: eventDetail
});
window.dispatchEvent(event);
console.log("✅✅✅ globalFileStateChanged 삭제 이벤트 발생 완료");
// 추가 지연 이벤트들
setTimeout(() => {
console.log("🔄 추가 삭제 이벤트 발생 (지연 100ms)");
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
detail: { ...eventDetail, delayed: true }
}));
}, 100);
setTimeout(() => {
console.log("🔄 추가 삭제 이벤트 발생 (지연 300ms)");
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
detail: { ...eventDetail, delayed: true, attempt: 2 }
}));
}, 300);
// 그리드 파일 상태 새로고침 이벤트도 유지
2025-09-26 17:12:03 +09:00
const tableName = currentTableName || 'screen_files';
const recordId = component.id;
const columnName = component.columnName || component.id || 'file_attachment';
const targetObjid = `${tableName}:${recordId}:${columnName}`;
const refreshEvent = new CustomEvent('refreshFileStatus', {
detail: {
tableName: tableName,
recordId: recordId,
columnName: columnName,
targetObjid: targetObjid,
fileCount: updatedFiles.length
}
});
window.dispatchEvent(refreshEvent);
console.log("🔄 FileComponentConfigPanel 파일 삭제 후 그리드 새로고침:", {
tableName,
recordId,
columnName,
targetObjid,
fileCount: updatedFiles.length
});
}
toast.success('파일이 삭제되었습니다.');
} catch (error) {
console.error('파일 삭제 오류:', error);
toast.error('파일 삭제에 실패했습니다.');
}
}, [uploadedFiles, onUpdateProperty, component.id]);
// 파일 저장 처리 (임시 → 영구 저장)
const handleSaveFiles = useCallback(() => {
try {
// 컴포넌트 속성에 영구 저장
const timestamp = Date.now();
onUpdateProperty(component.id, "uploadedFiles", uploadedFiles);
onUpdateProperty(component.id, "lastFileUpdate", timestamp);
// 전역 상태에도 저장
setGlobalFileState(prev => ({
...prev,
[component.id]: uploadedFiles
}));
// localStorage에도 백업
const backupKey = `fileComponent_${component.id}_files`;
localStorage.setItem(backupKey, JSON.stringify(uploadedFiles));
// 임시 파일 삭제
const tempBackupKey = `fileComponent_${component.id}_files_temp`;
localStorage.removeItem(tempBackupKey);
console.log("💾 파일 저장 완료:", {
componentId: component.id,
fileCount: uploadedFiles.length,
timestamp: timestamp,
files: uploadedFiles.map(f => ({ objid: f.objid, name: f.realFileName }))
});
toast.success(`${uploadedFiles.length}개 파일이 영구 저장되었습니다.`);
} catch (error) {
console.error('파일 저장 오류:', error);
toast.error('파일 저장에 실패했습니다.');
}
}, [uploadedFiles, onUpdateProperty, component.id, setGlobalFileState]);
// 드래그앤드롭 이벤트 핸들러
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const files = e.dataTransfer.files;
2025-09-29 17:21:47 +09:00
console.log("📂 드래그앤드롭 이벤트:", {
filesCount: files.length,
files: files.length > 0 ? Array.from(files).map(f => f.name) : [],
componentId: component?.id
});
if (files.length > 0) {
handleFileUpload(files);
}
2025-09-29 17:21:47 +09:00
}, [handleFileUpload, component?.id]);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
2025-09-29 17:21:47 +09:00
console.log("📁 파일 선택 이벤트:", {
filesCount: e.target.files?.length || 0,
files: e.target.files ? Array.from(e.target.files).map(f => f.name) : []
});
const files = e.target.files;
if (files && files.length > 0) {
handleFileUpload(files);
}
e.target.value = '';
}, [handleFileUpload]);
2025-09-05 21:52:19 +09:00
// 컴포넌트 변경 시 로컬 상태 동기화
useEffect(() => {
setLocalInputs({
docType: component.fileConfig?.docType || "DOCUMENT",
docTypeName: component.fileConfig?.docTypeName || "일반 문서",
dragDropText: component.fileConfig?.dragDropText || "파일을 드래그하거나 클릭하여 업로드하세요",
maxSize: component.fileConfig?.maxSize || 10,
maxFiles: component.fileConfig?.maxFiles || 5,
2025-09-05 21:52:19 +09:00
newAcceptType: "",
linkedTable: component.fileConfig?.linkedTable || "",
linkedField: component.fileConfig?.linkedField || "",
2025-09-05 21:52:19 +09:00
});
setLocalValues({
multiple: component.fileConfig?.multiple ?? true,
showPreview: component.fileConfig?.showPreview ?? true,
showProgress: component.fileConfig?.showProgress ?? true,
autoLink: component.fileConfig?.autoLink ?? false,
2025-09-05 21:52:19 +09:00
});
setAcceptTypes(component.fileConfig?.accept || []);
// 파일 목록 동기화 - 컴포넌트 ID가 변경되었을 때만 초기화
const componentFiles = component.uploadedFiles || [];
if (prevComponentIdRef.current !== component.id) {
// 새로운 컴포넌트로 변경된 경우
console.log("🔄 FileComponentConfigPanel 새 컴포넌트 선택:", {
prevComponentId: prevComponentIdRef.current,
newComponentId: component.id,
componentFiles: componentFiles.length,
action: "새 컴포넌트 → 상태 초기화",
globalFileStateExists: !!getGlobalFileState()[component.id],
globalFileStateLength: getGlobalFileState()[component.id]?.length || 0,
localStorageExists: !!localStorage.getItem(`fileComponent_${component.id}_files`),
onUpdatePropertyExists: typeof onUpdateProperty === 'function'
});
// 1순위: 전역 상태에서 파일 복원
const globalFileState = getGlobalFileState();
const globalFiles = globalFileState[component.id];
if (globalFiles && globalFiles.length > 0) {
console.log("🌐 전역 상태에서 파일 복원:", {
componentId: component.id,
globalFiles: globalFiles.length,
action: "전역 상태 → 상태 복원"
});
setUploadedFiles(globalFiles);
onUpdateProperty(component.id, "uploadedFiles", globalFiles);
}
// 2순위: localStorage에서 백업 파일 복원
else {
const backupKey = `fileComponent_${component.id}_files`;
const backupFiles = localStorage.getItem(backupKey);
if (backupFiles && componentFiles.length === 0) {
try {
const parsedBackupFiles = JSON.parse(backupFiles);
console.log("📂 localStorage에서 파일 복원:", {
componentId: component.id,
backupFiles: parsedBackupFiles.length,
action: "백업 → 상태 복원"
});
setUploadedFiles(parsedBackupFiles);
// 전역 상태에도 저장
setGlobalFileState(prev => ({
...prev,
[component.id]: parsedBackupFiles
}));
// 컴포넌트 속성에도 복원
onUpdateProperty(component.id, "uploadedFiles", parsedBackupFiles);
} catch (error) {
console.error("백업 파일 복원 실패:", error);
setUploadedFiles(componentFiles);
}
} else {
setUploadedFiles(componentFiles);
}
}
prevComponentIdRef.current = component.id;
} else if (componentFiles.length > 0 && JSON.stringify(componentFiles) !== JSON.stringify(uploadedFiles)) {
// 같은 컴포넌트에서 파일이 업데이트된 경우
console.log("🔄 FileComponentConfigPanel 파일 동기화:", {
componentId: component.id,
componentFiles: componentFiles.length,
currentFiles: uploadedFiles.length,
action: "컴포넌트 → 상태 동기화"
});
setUploadedFiles(componentFiles);
}
}, [component.id]); // 컴포넌트 ID가 변경될 때만 초기화
2025-09-05 21:52:19 +09:00
2025-09-26 17:12:03 +09:00
// 전역 파일 상태 변경 감지 (화면 복원 포함)
useEffect(() => {
const handleGlobalFileStateChange = (event: CustomEvent) => {
2025-09-29 17:21:47 +09:00
const { componentId, files, fileCount, isRestore, source } = event.detail;
2025-09-26 17:12:03 +09:00
if (componentId === component.id) {
console.log("🌐 FileComponentConfigPanel 전역 상태 변경 감지:", {
componentId,
fileCount,
isRestore: !!isRestore,
2025-09-29 17:21:47 +09:00
source: source || 'unknown',
2025-09-26 17:12:03 +09:00
files: files?.map((f: any) => ({ objid: f.objid, name: f.realFileName }))
});
if (files && Array.isArray(files)) {
setUploadedFiles(files);
2025-09-29 17:21:47 +09:00
// 🎯 실제 화면에서 온 이벤트이거나 화면 복원인 경우 컴포넌트 속성도 업데이트
if (isRestore || source === 'realScreen') {
console.log("✅✅✅ 실제 화면 → 화면설계 모드 동기화 적용:", {
componentId,
fileCount: files.length,
source: source || 'restore'
});
onUpdateProperty(component.id, "uploadedFiles", files);
onUpdateProperty(component.id, "lastFileUpdate", Date.now());
// localStorage 백업도 업데이트
try {
const backupKey = `fileComponent_${component.id}_files`;
localStorage.setItem(backupKey, JSON.stringify(files));
console.log("💾 실제 화면 동기화 후 localStorage 백업 업데이트:", {
componentId: component.id,
fileCount: files.length
});
} catch (e) {
console.warn("localStorage 백업 업데이트 실패:", e);
}
// 전역 상태 업데이트
setGlobalFileState(prev => ({
...prev,
[component.id]: files
}));
} else if (isRestore) {
2025-09-26 17:12:03 +09:00
console.log("✅ 파일 컴포넌트 설정 패널 데이터 복원 완료:", {
componentId,
restoredFileCount: files.length
});
}
}
}
};
if (typeof window !== 'undefined') {
window.addEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
return () => {
window.removeEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
};
}
2025-09-29 17:21:47 +09:00
}, [component.id, onUpdateProperty]);
2025-09-26 17:12:03 +09:00
2025-09-05 21:52:19 +09:00
// 미리 정의된 문서 타입들
const docTypeOptions = [
{ value: "CONTRACT", label: "계약서" },
{ value: "DRAWING", label: "도면" },
{ value: "PHOTO", label: "사진" },
{ value: "DOCUMENT", label: "일반 문서" },
{ value: "REPORT", label: "보고서" },
{ value: "SPECIFICATION", label: "사양서" },
{ value: "MANUAL", label: "매뉴얼" },
{ value: "CERTIFICATE", label: "인증서" },
{ value: "OTHER", label: "기타" },
];
// 미리 정의된 파일 타입들
const commonFileTypes = [
{ value: "image/*", label: "이미지" },
{ value: ".pdf", label: "PDF" },
{ value: ".doc,.docx", label: "Word" },
{ value: ".xls,.xlsx", label: "Excel" },
{ value: ".ppt,.pptx", label: "PowerPoint" },
{ value: ".hwp,.hwpx,.hwpml", label: "한글" },
{ value: ".hcdt", label: "한셀" },
{ value: ".hpt", label: "한쇼" },
{ value: ".pages", label: "Pages" },
{ value: ".numbers", label: "Numbers" },
{ value: ".keynote", label: "Keynote" },
{ value: ".txt,.md,.rtf", label: "텍스트" },
{ value: "video/*", label: "비디오" },
{ value: "audio/*", label: "오디오" },
{ value: ".zip,.rar,.7z", label: "압축파일" },
2025-09-05 21:52:19 +09:00
];
// 파일 타입 추가
const addCommonFileType = useCallback((fileType: string) => {
const types = fileType.split(',');
const newTypes = [...acceptTypes];
types.forEach(type => {
if (!newTypes.includes(type.trim())) {
newTypes.push(type.trim());
}
});
setAcceptTypes(newTypes);
onUpdateProperty(component.id, "fileConfig.accept", newTypes);
}, [acceptTypes, component.id, onUpdateProperty]);
2025-09-05 21:52:19 +09:00
// 파일 타입 제거
const removeAcceptType = useCallback((typeToRemove: string) => {
const newTypes = acceptTypes.filter(type => type !== typeToRemove);
setAcceptTypes(newTypes);
onUpdateProperty(component.id, "fileConfig.accept", newTypes);
}, [acceptTypes, component.id, onUpdateProperty]);
2025-09-05 21:52:19 +09:00
return (
<div className="space-y-4">
{/* 기본 정보 */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-gray-900"> </h4>
2025-09-05 21:52:19 +09:00
<div className="space-y-2">
<Label htmlFor="docType"> </Label>
<Select
value={localInputs.docType}
onValueChange={(value) => {
const selectedOption = docTypeOptions.find(option => option.value === value);
setLocalInputs((prev) => ({
...prev,
docType: value,
docTypeName: selectedOption?.label || value
}));
2025-09-05 21:52:19 +09:00
onUpdateProperty(component.id, "fileConfig.docType", value);
if (selectedOption) {
onUpdateProperty(component.id, "fileConfig.docTypeName", selectedOption.label);
}
}}
>
<SelectTrigger>
<SelectValue />
2025-09-05 21:52:19 +09:00
</SelectTrigger>
<SelectContent>
{docTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="docTypeName"> </Label>
2025-09-05 21:52:19 +09:00
<Input
id="docTypeName"
value={localInputs.docTypeName}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, docTypeName: newValue }));
onUpdateProperty(component.id, "fileConfig.docTypeName", newValue);
}}
/>
</div>
</div>
{/* 파일 업로드 제한 설정 */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-gray-900"> </h4>
2025-09-05 21:52:19 +09:00
<div className="grid grid-cols-2 gap-3">
2025-09-05 21:52:19 +09:00
<div className="space-y-2">
<Label htmlFor="maxSize"> (MB)</Label>
2025-09-05 21:52:19 +09:00
<Input
id="maxSize"
type="number"
min="1"
max="100"
value={localInputs.maxSize}
onChange={(e) => {
const newValue = parseInt(e.target.value) || 10;
setLocalInputs((prev) => ({ ...prev, maxSize: newValue }));
onUpdateProperty(component.id, "fileConfig.maxSize", newValue);
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxFiles"> </Label>
2025-09-05 21:52:19 +09:00
<Input
id="maxFiles"
type="number"
min="1"
max="20"
value={localInputs.maxFiles}
onChange={(e) => {
const newValue = parseInt(e.target.value) || 5;
setLocalInputs((prev) => ({ ...prev, maxFiles: newValue }));
onUpdateProperty(component.id, "fileConfig.maxFiles", newValue);
}}
/>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="multiple"
checked={localValues.multiple}
onCheckedChange={(checked) => {
setLocalValues((prev) => ({ ...prev, multiple: checked as boolean }));
onUpdateProperty(component.id, "fileConfig.multiple", checked);
}}
/>
<Label htmlFor="multiple"> </Label>
</div>
</div>
{/* 허용 파일 타입 설정 */}
<div className="space-y-3">
2025-09-05 21:52:19 +09:00
<h4 className="text-sm font-medium text-gray-900"> </h4>
<div className="flex flex-wrap gap-2">
{acceptTypes.map((type, index) => (
<Badge key={index} variant="secondary" className="flex items-center space-x-1">
<span>{type}</span>
<Button
variant="ghost"
size="sm"
onClick={() => removeAcceptType(type)}
className="h-4 w-4 p-0 hover:bg-transparent"
>
<X className="h-3 w-3" />
</Button>
</Badge>
))}
{acceptTypes.length === 0 && <span className="text-sm text-gray-500"> </span>}
</div>
<div className="flex flex-wrap gap-1">
{commonFileTypes.map((fileType) => (
<Button
key={fileType.value}
variant="outline"
size="sm"
onClick={() => addCommonFileType(fileType.value)}
className="text-xs h-7"
>
{fileType.label}
2025-09-05 21:52:19 +09:00
</Button>
))}
2025-09-05 21:52:19 +09:00
</div>
</div>
{/* 파일 업로드 영역 */}
2025-09-05 21:52:19 +09:00
<div className="space-y-2">
<h4 className="text-sm font-medium text-gray-900"> </h4>
2025-09-29 17:21:47 +09:00
<Card className="border-gray-200/60 shadow-sm">
<CardContent className="p-6">
<div
className={`
2025-09-29 17:21:47 +09:00
border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all duration-300
${dragOver ? 'border-blue-400 bg-gradient-to-br from-blue-50 to-indigo-50 shadow-sm' : 'border-gray-300/60'}
${uploading ? 'opacity-50 cursor-not-allowed' : 'hover:border-blue-400/60 hover:bg-gray-50/50 hover:shadow-sm'}
`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
2025-09-29 17:21:47 +09:00
onClick={() => {
console.log("🖱️ 파일 업로드 영역 클릭:", {
uploading,
inputElement: document.getElementById('file-input-config'),
componentId: component?.id
});
if (!uploading) {
const input = document.getElementById('file-input-config');
if (input) {
console.log("✅ 파일 input 클릭 실행");
input.click();
} else {
console.log("❌ 파일 input 요소를 찾을 수 없음");
}
}
}}
>
<input
id="file-input-config"
type="file"
multiple={localValues.multiple}
accept={acceptTypes.join(",")}
onChange={handleFileSelect}
className="hidden"
disabled={uploading}
/>
<div className="flex flex-col items-center space-y-2">
{uploading ? (
<>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p className="text-sm font-medium text-gray-700"> ...</p>
</>
) : (
<>
<Upload className="w-6 h-6 text-gray-400" />
<p className="text-sm text-gray-600"> </p>
<Button variant="outline" size="sm">
<Plus className="w-4 h-4 mr-2" />
</Button>
</>
)}
</div>
</div>
{/* 업로드된 파일 목록 */}
{uploadedFiles.length > 0 && (
<div className="mt-4 space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium"> ({uploadedFiles.length})</Label>
<Badge variant="secondary">
{formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))}
</Badge>
</div>
<div className="space-y-2 max-h-40 overflow-y-auto">
{uploadedFiles.map((file) => (
<div key={file.objid} className="flex items-center justify-between p-2 bg-gray-50 rounded-lg">
<div className="flex items-center space-x-2 flex-1 min-w-0">
<div className="flex-shrink-0">
{getFileIcon(file.fileExt)}
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-gray-900 truncate">
{file.realFileName}
</p>
<div className="flex items-center space-x-1 text-xs text-gray-500">
<span>{formatFileSize(file.fileSize)}</span>
<span></span>
<span>{file.fileExt.toUpperCase()}</span>
</div>
</div>
</div>
<div className="flex items-center space-x-1 flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => handleFileDownload(file)}
className="h-6 w-6 p-0"
title="다운로드"
>
<Download className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
2025-09-29 13:29:03 +09:00
onClick={() => handleFileDelete(file.objid || file.id || '')}
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
title="삭제"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* 저장 버튼 - 주석처리 (파일이 자동으로 유지됨) */}
{/*
{uploadedFiles.length > 0 && (
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-orange-500 rounded-full animate-pulse"></div>
<span className="text-sm font-medium text-blue-900">
{uploadedFiles.length}
</span>
</div>
<Button
onClick={handleSaveFiles}
className="bg-blue-600 hover:bg-blue-700 text-white"
size="sm"
>
</Button>
</div>
<p className="text-xs text-blue-700 mt-1">
.
</p>
</div>
)}
*/}
2025-09-05 21:52:19 +09:00
</div>
</div>
);
};