diff --git a/.gitignore b/.gitignore index ca80d8ab..c1697df6 100644 --- a/.gitignore +++ b/.gitignore @@ -274,3 +274,20 @@ out/ bin/ /src/generated/prisma + +# 업로드된 파일들 제외 +backend-node/uploads/ +uploads/ +*.jpg +*.jpeg +*.png +*.gif +*.pdf +*.doc +*.docx +*.xls +*.xlsx +*.ppt +*.pptx +*.hwp +*.hwpx diff --git a/backend-node/src/controllers/fileController.ts b/backend-node/src/controllers/fileController.ts index b0746677..60251f58 100644 --- a/backend-node/src/controllers/fileController.ts +++ b/backend-node/src/controllers/fileController.ts @@ -61,8 +61,41 @@ const storage = multer.diskStorage({ filename: (req, file, cb) => { // 타임스탬프_원본파일명 형태로 저장 (회사코드는 디렉토리로 분리됨) const timestamp = Date.now(); - const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, "_"); + + console.log("📁 파일명 처리:", { + originalname: file.originalname, + encoding: file.encoding, + mimetype: file.mimetype + }); + + // UTF-8 인코딩 문제 해결: Buffer를 통한 올바른 디코딩 + let decodedName; + try { + // 파일명이 깨진 경우 Buffer를 통해 올바르게 디코딩 + const buffer = Buffer.from(file.originalname, 'latin1'); + decodedName = buffer.toString('utf8'); + console.log("📁 파일명 디코딩:", { original: file.originalname, decoded: decodedName }); + } catch (error) { + // 디코딩 실패 시 원본 사용 + decodedName = file.originalname; + console.log("📁 파일명 디코딩 실패, 원본 사용:", file.originalname); + } + + // 한국어를 포함한 유니코드 문자 보존하면서 안전한 파일명 생성 + // 위험한 문자만 제거: / \ : * ? " < > | + const sanitizedName = decodedName + .replace(/[\/\\:*?"<>|]/g, "_") // 파일시스템에서 금지된 문자만 치환 + .replace(/\s+/g, "_") // 공백을 언더스코어로 치환 + .replace(/_{2,}/g, "_"); // 연속된 언더스코어를 하나로 축약 + const savedFileName = `${timestamp}_${sanitizedName}`; + + console.log("📁 파일명 변환:", { + original: file.originalname, + sanitized: sanitizedName, + saved: savedFileName + }); + cb(null, savedFileName); }, }); @@ -87,18 +120,64 @@ const upload = multer({ // 기본 허용 파일 타입 const defaultAllowedTypes = [ + // 이미지 파일 "image/jpeg", "image/png", "image/gif", - "text/html", // HTML 파일 추가 - "text/plain", // 텍스트 파일 추가 + "image/webp", + "image/svg+xml", + // 텍스트 파일 + "text/html", + "text/plain", + "text/markdown", + "text/csv", + "application/json", + "application/xml", + // PDF 파일 "application/pdf", - "application/msword", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "application/vnd.ms-excel", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "application/zip", // ZIP 파일 추가 - "application/x-zip-compressed", // ZIP 파일 (다른 MIME 타입) + // Microsoft Office 파일 + "application/msword", // .doc + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx + "application/vnd.ms-excel", // .xls + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx + "application/vnd.ms-powerpoint", // .ppt + "application/vnd.openxmlformats-officedocument.presentationml.presentation", // .pptx + // 한컴오피스 파일 + "application/x-hwp", // .hwp (한글) + "application/haansofthwp", // .hwp (다른 MIME 타입) + "application/vnd.hancom.hwp", // .hwp (또 다른 MIME 타입) + "application/vnd.hancom.hwpx", // .hwpx (한글 2014+) + "application/x-hwpml", // .hwpml (한글 XML) + "application/vnd.hancom.hcdt", // .hcdt (한셀) + "application/vnd.hancom.hpt", // .hpt (한쇼) + "application/octet-stream", // .hwp, .hwpx (일반적인 바이너리 파일) + // 압축 파일 + "application/zip", + "application/x-zip-compressed", + "application/x-rar-compressed", + "application/x-7z-compressed", + // 미디어 파일 + "video/mp4", + "video/webm", + "video/ogg", + "audio/mp3", + "audio/mpeg", + "audio/wav", + "audio/ogg", + // Apple/맥 파일 + "application/vnd.apple.pages", // .pages (Pages) + "application/vnd.apple.numbers", // .numbers (Numbers) + "application/vnd.apple.keynote", // .keynote (Keynote) + "application/x-iwork-pages-sffpages", // .pages (다른 MIME) + "application/x-iwork-numbers-sffnumbers", // .numbers (다른 MIME) + "application/x-iwork-keynote-sffkey", // .keynote (다른 MIME) + "application/vnd.apple.installer+xml", // .pkg (맥 설치 파일) + "application/x-apple-diskimage", // .dmg (맥 디스크 이미지) + // 기타 문서 + "application/rtf", // .rtf + "application/vnd.oasis.opendocument.text", // .odt + "application/vnd.oasis.opendocument.spreadsheet", // .ods + "application/vnd.oasis.opendocument.presentation", // .odp ]; if (defaultAllowedTypes.includes(file.mimetype)) { @@ -161,9 +240,20 @@ export const uploadFiles = async ( const savedFiles = []; for (const file of files) { + // 파일명 디코딩 (파일 저장 시와 동일한 로직) + let decodedOriginalName; + try { + const buffer = Buffer.from(file.originalname, 'latin1'); + decodedOriginalName = buffer.toString('utf8'); + console.log("💾 DB 저장용 파일명 디코딩:", { original: file.originalname, decoded: decodedOriginalName }); + } catch (error) { + decodedOriginalName = file.originalname; + console.log("💾 DB 저장용 파일명 디코딩 실패, 원본 사용:", file.originalname); + } + // 파일 확장자 추출 const fileExt = path - .extname(file.originalname) + .extname(decodedOriginalName) .toLowerCase() .replace(".", ""); @@ -196,7 +286,7 @@ export const uploadFiles = async ( ), target_objid: finalTargetObjid, saved_file_name: file.filename, - real_file_name: file.originalname, + real_file_name: decodedOriginalName, doc_type: docType, doc_type_name: docTypeName, file_size: file.size, diff --git a/backend-node/src/services/multiConnectionQueryService.ts b/backend-node/src/services/multiConnectionQueryService.ts index 167cc285..b4b080d5 100644 --- a/backend-node/src/services/multiConnectionQueryService.ts +++ b/backend-node/src/services/multiConnectionQueryService.ts @@ -8,11 +8,9 @@ import { ExternalDbConnectionService } from "./externalDbConnectionService"; import { TableManagementService } from "./tableManagementService"; import { ExternalDbConnection } from "../types/externalDbTypes"; import { ColumnTypeInfo, TableInfo } from "../types/tableManagement"; -import { PrismaClient } from "@prisma/client"; +import prisma from "../config/database"; import { logger } from "../utils/logger"; -const prisma = new PrismaClient(); - export interface ValidationResult { isValid: boolean; error?: string; diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 4ca5369d..757a23a2 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -1,4 +1,4 @@ -import { PrismaClient } from "@prisma/client"; +import prisma from "../config/database"; import { logger } from "../utils/logger"; import { cache, CacheKeys } from "../utils/cache"; import { @@ -14,8 +14,6 @@ import { WebType } from "../types/unified-web-types"; import { entityJoinService } from "./entityJoinService"; import { referenceCacheService } from "./referenceCacheService"; -const prisma = new PrismaClient(); - export class TableManagementService { constructor() {} diff --git a/frontend/components/screen/FileAttachmentDetailModal.tsx b/frontend/components/screen/FileAttachmentDetailModal.tsx new file mode 100644 index 00000000..83762912 --- /dev/null +++ b/frontend/components/screen/FileAttachmentDetailModal.tsx @@ -0,0 +1,611 @@ +"use client"; + +import React, { useState, useCallback, useEffect } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Upload, + Download, + Trash2, + Eye, + FileText, + Image, + FileVideo, + FileAudio, + File, + X, + Plus, + Save, + AlertCircle +} from "lucide-react"; +import { ComponentData, FileComponent } from "@/types/screen"; +import { FileInfo } from "@/lib/registry/components/file-upload/types"; +import { FileViewerModal } from "@/lib/registry/components/file-upload/FileViewerModal"; +import { uploadFiles, downloadFile, deleteFile } from "@/lib/api/file"; +import { formatFileSize } from "@/lib/utils"; +import { toast } from "sonner"; + +interface FileAttachmentDetailModalProps { + isOpen: boolean; + onClose: () => void; + component: FileComponent | null; + onUpdateComponent?: (updates: Partial) => void; + screenId?: string; + tableName?: string; + recordId?: string; +} + +/** + * 파일 타입별 아이콘 반환 + */ +const getFileIcon = (fileExt: string) => { + const ext = fileExt.toLowerCase(); + if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) { + return ; + } + if (['mp4', 'avi', 'mov', 'wmv', 'flv'].includes(ext)) { + return ; + } + if (['mp3', 'wav', 'aac', 'flac'].includes(ext)) { + return ; + } + if (['pdf', 'doc', 'docx', 'txt', 'rtf'].includes(ext)) { + return ; + } + return ; +}; + +/** + * 파일첨부 상세 모달 + * 화면관리에서 템플릿의 파일첨부 버튼을 클릭했을 때 열리는 상세 관리 모달 + */ +export const FileAttachmentDetailModal: React.FC = ({ + isOpen, + onClose, + component, + onUpdateComponent, + screenId, + tableName, + recordId, +}) => { + // State 관리 + const [uploadedFiles, setUploadedFiles] = useState([]); + const [dragOver, setDragOver] = useState(false); + const [uploading, setUploading] = useState(false); + const [viewerFile, setViewerFile] = useState(null); + const [isViewerOpen, setIsViewerOpen] = useState(false); + + // 파일 설정 상태 + const [fileConfig, setFileConfig] = useState({ + docType: "DOCUMENT", + docTypeName: "일반 문서", + accept: "*/*", + maxSize: 10 * 1024 * 1024, // 10MB + multiple: true, + }); + + // 컴포넌트가 변경될 때 파일 목록 초기화 + useEffect(() => { + if (component?.uploadedFiles) { + setUploadedFiles(component.uploadedFiles); + } else { + setUploadedFiles([]); + } + + if (component?.fileConfig) { + setFileConfig({ + docType: component.fileConfig.docType || "DOCUMENT", + docTypeName: component.fileConfig.docTypeName || "일반 문서", + accept: component.fileConfig.accept?.join(",") || "*/*", + maxSize: (component.fileConfig.maxSize || 10) * 1024 * 1024, + multiple: component.fileConfig.multiple !== false, + }); + } + }, [component]); + + // 파일 업로드 처리 + const handleFileUpload = useCallback(async (files: FileList | File[]) => { + if (!files || files.length === 0) return; + + const fileArray = Array.from(files); + + // 파일 검증 + const validFiles: File[] = []; + for (const file of fileArray) { + // 크기 체크 + if (file.size > fileConfig.maxSize) { + toast.error(`${file.name}: 파일 크기가 너무 큽니다. (최대 ${formatFileSize(fileConfig.maxSize)})`); + continue; + } + + // 타입 체크 + if (fileConfig.accept && fileConfig.accept !== "*/*") { + const acceptedTypes = fileConfig.accept.split(",").map(type => type.trim()); + const isValid = acceptedTypes.some(type => { + if (type.startsWith(".")) { + return file.name.toLowerCase().endsWith(type.toLowerCase()); + } else { + return file.type.includes(type); + } + }); + + if (!isValid) { + toast.error(`${file.name}: 지원하지 않는 파일 형식입니다.`); + continue; + } + } + + validFiles.push(file); + if (!fileConfig.multiple) break; + } + + if (validFiles.length === 0) return; + + try { + setUploading(true); + toast.loading(`${validFiles.length}개 파일 업로드 중...`); + + // API를 통한 파일 업로드 + const response = await uploadFiles({ + files: validFiles, + tableName: tableName || 'screen_files', + fieldName: component?.columnName || component?.id || 'file_attachment', + recordId: recordId || screenId, + }); + + if (response.success && response.data) { + const newFiles: FileInfo[] = response.data.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: fileConfig.docType, + docTypeName: fileConfig.docTypeName, + targetObjid: file.target_objid || file.targetObjid || recordId || screenId || '', + 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: fileConfig.docType, + uploadedAt: file.regdate || new Date().toISOString(), + })); + + const updatedFiles = fileConfig.multiple ? [...uploadedFiles, ...newFiles] : newFiles; + setUploadedFiles(updatedFiles); + + // 컴포넌트 업데이트 + if (onUpdateComponent) { + onUpdateComponent({ + uploadedFiles: updatedFiles, + fileConfig: { + ...component?.fileConfig, + docType: fileConfig.docType, + docTypeName: fileConfig.docTypeName, + } + }); + } + + toast.dismiss(); + toast.success(`${validFiles.length}개 파일이 성공적으로 업로드되었습니다.`); + } else { + throw new Error(response.message || '파일 업로드에 실패했습니다.'); + } + } catch (error) { + console.error('파일 업로드 오류:', error); + toast.dismiss(); + toast.error('파일 업로드에 실패했습니다.'); + } finally { + setUploading(false); + } + }, [fileConfig, uploadedFiles, onUpdateComponent, component, tableName, recordId, screenId]); + + // 파일 다운로드 처리 + const handleFileDownload = useCallback(async (file: FileInfo) => { + try { + toast.loading(`${file.realFileName} 다운로드 중...`); + + // 로컬 파일인 경우 + if (file._file) { + const url = URL.createObjectURL(file._file); + const link = document.createElement("a"); + link.href = url; + link.download = file.realFileName || file._file.name; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + toast.dismiss(); + toast.success(`${file.realFileName} 다운로드가 완료되었습니다.`); + return; + } + + // 서버 파일인 경우 + await downloadFile({ + fileId: file.objid, + serverFilename: file.savedFileName, + originalName: file.realFileName, + }); + + toast.dismiss(); + toast.success(`${file.realFileName} 다운로드가 완료되었습니다.`); + } catch (error) { + console.error('파일 다운로드 오류:', error); + toast.dismiss(); + toast.error('파일 다운로드에 실패했습니다.'); + } + }, []); + + // 파일 삭제 처리 + const handleFileDelete = useCallback(async (file: FileInfo) => { + if (!confirm(`${file.realFileName}을(를) 삭제하시겠습니까?`)) return; + + try { + toast.loading(`${file.realFileName} 삭제 중...`); + + // 서버 파일인 경우 API 호출 + if (!file._file) { + await deleteFile(file.objid, file.savedFileName); + } + + // 상태에서 파일 제거 + const updatedFiles = uploadedFiles.filter(f => f.objid !== file.objid); + setUploadedFiles(updatedFiles); + + // 컴포넌트 업데이트 + if (onUpdateComponent) { + onUpdateComponent({ + uploadedFiles: updatedFiles, + }); + } + + toast.dismiss(); + toast.success(`${file.realFileName}이 삭제되었습니다.`); + } catch (error) { + console.error('파일 삭제 오류:', error); + toast.dismiss(); + toast.error('파일 삭제에 실패했습니다.'); + } + }, [uploadedFiles, onUpdateComponent]); + + // 파일뷰어 열기 + 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(); + setDragOver(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragOver(false); + }, []); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragOver(false); + + const files = e.dataTransfer.files; + if (files && files.length > 0) { + handleFileUpload(files); + } + }, [handleFileUpload]); + + // 파일 선택 처리 + const handleFileSelect = useCallback((e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + handleFileUpload(files); + } + // 같은 파일을 다시 선택할 수 있도록 value 초기화 + e.target.value = ''; + }, [handleFileUpload]); + + // 설정 저장 + const handleSaveSettings = useCallback(() => { + if (onUpdateComponent) { + onUpdateComponent({ + fileConfig: { + ...component?.fileConfig, + docType: fileConfig.docType, + docTypeName: fileConfig.docTypeName, + accept: fileConfig.accept.split(",").map(type => type.trim()), + maxSize: Math.floor(fileConfig.maxSize / (1024 * 1024)), // MB로 변환 + multiple: fileConfig.multiple, + } + }); + } + toast.success('설정이 저장되었습니다.'); + }, [fileConfig, onUpdateComponent, component]); + + if (!component) return null; + + return ( + <> + + + +
+ + 파일 첨부 관리 - {component.label || component.id} + + +
+
+ + + + 파일 관리 + 설정 + + + {/* 파일 관리 탭 */} + + {/* 파일 업로드 영역 */} + + + + + 파일 업로드 + + + +
!uploading && document.getElementById('file-input')?.click()} + > + + +
+ {uploading ? ( + <> +
+

업로드 중...

+ + ) : ( + <> + +
+

+ 파일을 선택하거나 드래그하세요 +

+

+ {fileConfig.accept && `지원 형식: ${fileConfig.accept}`} + {fileConfig.maxSize && ` • 최대 ${formatFileSize(fileConfig.maxSize)}`} + {fileConfig.multiple && ' • 여러 파일 선택 가능'} +

+
+ + + )} +
+
+
+
+ + {/* 업로드된 파일 목록 */} + + +
+ + + 업로드된 파일 ({uploadedFiles.length}) + + {uploadedFiles.length > 0 && ( + + 총 {formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))} + + )} +
+
+ + {uploadedFiles.length === 0 ? ( +
+ +

업로드된 파일이 없습니다.

+

위의 업로드 영역을 사용해 파일을 추가하세요.

+
+ ) : ( +
+ {uploadedFiles.map((file) => ( +
+
+
+ {getFileIcon(file.fileExt)} +
+
+

+ {file.realFileName} +

+
+ {formatFileSize(file.fileSize)} + + {file.fileExt.toUpperCase()} + {file.uploadedAt && ( + <> + + {new Date(file.uploadedAt).toLocaleDateString()} + + )} +
+
+
+ +
+ {/* 파일뷰어 버튼 */} + + + {/* 다운로드 버튼 */} + + + {/* 삭제 버튼 */} + +
+
+ ))} +
+ )} +
+
+
+ + {/* 설정 탭 */} + + + + 파일 첨부 설정 + + +
+
+ + setFileConfig(prev => ({ ...prev, docType: e.target.value }))} + placeholder="예: DOCUMENT, IMAGE, etc." + /> +
+
+ + setFileConfig(prev => ({ ...prev, docTypeName: e.target.value }))} + placeholder="예: 일반 문서, 이미지 파일" + /> +
+
+ +
+ + setFileConfig(prev => ({ ...prev, accept: e.target.value }))} + placeholder="예: image/*,.pdf,.doc,.docx" + /> +

+ 쉼표로 구분하여 입력 (예: image/*,.pdf,.doc) +

+
+ +
+ + setFileConfig(prev => ({ + ...prev, + maxSize: parseInt(e.target.value) * 1024 * 1024 + }))} + min="1" + max="100" + /> +
+ +
+ setFileConfig(prev => ({ ...prev, multiple: e.target.checked }))} + className="rounded border-gray-300" + /> + +
+ +
+ +
+
+
+
+
+
+
+ + {/* 파일뷰어 모달 */} + + + ); +}; diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 1f2e18eb..320203cf 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -8,6 +8,7 @@ import { useAuth } from "@/hooks/useAuth"; import { uploadFilesAndCreateData } from "@/lib/api/file"; import { toast } from "sonner"; import { ComponentData, WidgetComponent, DataTableComponent, FileComponent, ButtonTypeConfig } from "@/types/screen"; +import { FileUploadComponent } from "@/lib/registry/components/file-upload/FileUploadComponent"; import { InteractiveDataTable } from "./InteractiveDataTable"; import { DynamicWebTypeRenderer } from "@/lib/registry"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; @@ -412,40 +413,30 @@ export const InteractiveScreenViewerDynamic: React.FC { - if (!screenInfo?.tableName) { - toast.error("테이블명이 설정되지 않았습니다."); - return; - } - - try { - const uploadData = { - files, - tableName: screenInfo.tableName, - fieldName, - recordId: formData.id || undefined, - }; - - const response = await uploadFilesAndCreateData(uploadData); - - if (response.success) { - toast.success("파일이 성공적으로 업로드되었습니다."); - handleFormDataChange(fieldName, response.data); - } else { - toast.error("파일 업로드에 실패했습니다."); - } - } catch (error) { - console.error("파일 업로드 오류:", error); - toast.error("파일 업로드 중 오류가 발생했습니다."); - } - }; - return (
- {/* 파일 업로드 컴포넌트는 기존 구현 사용 */} -
- 파일 업로드 영역 (동적 렌더링 예정) -
+ {/* 실제 FileUploadComponent 사용 */} + { + console.log("📝 파일 업로드 완료:", { fieldName, value }); + handleFormDataChange(fieldName, value); + }} + />
); }; diff --git a/frontend/components/screen/RealtimePreview.tsx b/frontend/components/screen/RealtimePreview.tsx index 91e84947..2c746b2d 100644 --- a/frontend/components/screen/RealtimePreview.tsx +++ b/frontend/components/screen/RealtimePreview.tsx @@ -43,6 +43,12 @@ import { SidebarOpen, Folder, ChevronUp, + Image as ImageIcon, + FileText, + Video, + Music, + Archive, + Presentation, } from "lucide-react"; interface RealtimePreviewProps { @@ -303,17 +309,92 @@ export const RealtimePreviewDynamic: React.FC = ({ )} {/* 파일 타입 */} - {type === "file" && ( -
-
-
- -

파일 업로드 영역

-

미리보기 모드

+ {type === "file" && (() => { + const fileComponent = component as any; + const uploadedFiles = fileComponent.uploadedFiles || []; + + // 전역 상태에서 최신 파일 정보 가져오기 + const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {}; + const globalFiles = globalFileState[component.id] || []; + + // 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성) + const currentFiles = globalFiles.length > 0 ? globalFiles : uploadedFiles; + + console.log("🔍 RealtimePreview 파일 컴포넌트 렌더링:", { + componentId: component.id, + uploadedFilesCount: uploadedFiles.length, + globalFilesCount: globalFiles.length, + currentFilesCount: currentFiles.length, + currentFiles: currentFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName || f.name })), + componentType: component.type, + timestamp: new Date().toISOString() + }); + + return ( +
+
+ {currentFiles.length > 0 ? ( +
+
+ 업로드된 파일 ({currentFiles.length}) +
+
+ {currentFiles.map((file: any, index: number) => { + // 파일 확장자에 따른 아이콘 선택 + const getFileIcon = (fileName: string) => { + const ext = fileName.split('.').pop()?.toLowerCase() || ''; + if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) { + return ; + } + if (['pdf', 'doc', 'docx', 'txt', 'rtf', 'hwp', 'hwpx', 'hwpml', 'pages'].includes(ext)) { + return ; + } + if (['ppt', 'pptx', 'hpt', 'keynote'].includes(ext)) { + return ; + } + if (['xls', 'xlsx', 'hcdt', 'numbers'].includes(ext)) { + return ; + } + if (['mp4', 'avi', 'mov', 'wmv', 'webm', 'ogg'].includes(ext)) { + return
+
+ ) : ( +
+ +

업로드된 파일 (0)

+

파일 업로드 영역

+

상세설정에서 파일을 업로드하세요

+
+ )}
-
- )} + ); + })()}
{/* 선택된 컴포넌트 정보 표시 */} diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index 6ebd8b5a..2e1bb86f 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -25,6 +25,7 @@ interface RealtimePreviewProps { isSelected?: boolean; isDesignMode?: boolean; // 편집 모드 여부 onClick?: (e?: React.MouseEvent) => void; + onDoubleClick?: (e?: React.MouseEvent) => void; // 더블클릭 핸들러 추가 onDragStart?: (e: React.DragEvent) => void; onDragEnd?: () => void; onGroupToggle?: (groupId: string) => void; // 그룹 접기/펼치기 @@ -67,6 +68,7 @@ export const RealtimePreviewDynamic: React.FC = ({ isSelected = false, isDesignMode = true, // 기본값은 편집 모드 onClick, + onDoubleClick, onDragStart, onDragEnd, onGroupToggle, @@ -106,6 +108,11 @@ export const RealtimePreviewDynamic: React.FC = ({ onClick?.(e); }; + const handleDoubleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onDoubleClick?.(e); + }; + const handleDragStart = (e: React.DragEvent) => { e.stopPropagation(); onDragStart?.(e); @@ -121,6 +128,7 @@ export const RealtimePreviewDynamic: React.FC = ({ className="absolute cursor-pointer" style={{ ...baseStyle, ...selectionStyle }} onClick={handleClick} + onDoubleClick={handleDoubleClick} draggable onDragStart={handleDragStart} onDragEnd={handleDragEnd} diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index a0f6ad09..dbe1f68e 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -39,6 +39,7 @@ import { GroupingToolbar } from "./GroupingToolbar"; import { screenApi, tableTypeApi } from "@/lib/api/screen"; import { toast } from "sonner"; import { MenuAssignmentModal } from "./MenuAssignmentModal"; +import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal"; import { initializeComponents } from "@/lib/registry/components"; import StyleEditor from "./StyleEditor"; @@ -157,6 +158,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // 메뉴 할당 모달 상태 const [showMenuAssignmentModal, setShowMenuAssignmentModal] = useState(false); + // 파일첨부 상세 모달 상태 + const [showFileAttachmentModal, setShowFileAttachmentModal] = useState(false); + const [selectedFileComponent, setSelectedFileComponent] = useState(null); + // 해상도 설정 상태 const [screenResolution, setScreenResolution] = useState( SCREEN_RESOLUTIONS[0], // 기본값: Full HD @@ -188,6 +193,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD justFinishedDrag: false, // 드래그 종료 직후 클릭 방지용 }); + // 전역 파일 상태 변경 시 강제 리렌더링을 위한 상태 + const [forceRenderTrigger, setForceRenderTrigger] = useState(0); + // 드래그 선택 상태 const [selectionDrag, setSelectionDrag] = useState({ isSelecting: false, @@ -646,6 +654,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD initComponents(); }, []); + // 전역 파일 상태 변경 이벤트 리스너 + useEffect(() => { + const handleGlobalFileStateChange = (event: CustomEvent) => { + console.log("🔄 ScreenDesigner: 전역 파일 상태 변경 감지", event.detail); + setForceRenderTrigger(prev => prev + 1); + }; + + if (typeof window !== 'undefined') { + window.addEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener); + + return () => { + window.removeEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener); + }; + } + }, []); + // 테이블 데이터 로드 (성능 최적화: 선택된 테이블만 조회) useEffect(() => { if (selectedScreen?.tableName && selectedScreen.tableName.trim()) { @@ -1986,6 +2010,56 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD [layout, gridInfo, saveToHistory, openPanel], ); + // 파일 컴포넌트 업데이트 처리 + const handleFileComponentUpdate = useCallback( + (updates: Partial) => { + if (!selectedFileComponent) return; + + const updatedComponents = layout.components.map(comp => + comp.id === selectedFileComponent.id + ? { ...comp, ...updates } + : comp + ); + + const newLayout = { ...layout, components: updatedComponents }; + setLayout(newLayout); + saveToHistory(newLayout); + + // selectedFileComponent도 업데이트 + setSelectedFileComponent(prev => prev ? { ...prev, ...updates } : null); + + // selectedComponent가 같은 컴포넌트라면 업데이트 + if (selectedComponent?.id === selectedFileComponent.id) { + setSelectedComponent(prev => prev ? { ...prev, ...updates } : null); + } + }, + [selectedFileComponent, layout, saveToHistory, selectedComponent], + ); + + // 파일첨부 모달 닫기 + const handleFileAttachmentModalClose = useCallback(() => { + setShowFileAttachmentModal(false); + setSelectedFileComponent(null); + }, []); + + // 컴포넌트 더블클릭 처리 + const handleComponentDoubleClick = useCallback( + (component: ComponentData, event?: React.MouseEvent) => { + event?.stopPropagation(); + + // 파일 컴포넌트인 경우 상세 모달 열기 + if (component.type === "file") { + setSelectedFileComponent(component); + setShowFileAttachmentModal(true); + return; + } + + // 다른 컴포넌트 타입의 더블클릭 처리는 여기에 추가 + console.log("더블클릭된 컴포넌트:", component.type, component.id); + }, + [], + ); + // 컴포넌트 클릭 처리 (다중선택 지원) const handleComponentClick = useCallback( (component: ComponentData, event?: React.MouseEvent) => { @@ -3276,15 +3350,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD } } + // 전역 파일 상태도 key에 포함하여 실시간 리렌더링 + const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {}; + const globalFiles = globalFileState[component.id] || []; + const componentFiles = (component as any).uploadedFiles || []; + const fileStateKey = `${globalFiles.length}-${JSON.stringify(globalFiles.map((f: any) => f.objid) || [])}-${componentFiles.length}`; + return ( handleComponentClick(component, e)} + onDoubleClick={(e) => handleComponentDoubleClick(component, e)} onDragStart={(e) => startComponentDrag(component, e)} onDragEnd={endDrag} selectedScreen={selectedScreen} @@ -3389,13 +3470,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD return ( f.objid) || [])}`} component={relativeChildComponent} isSelected={ selectedComponent?.id === child.id || groupState.selectedComponents.includes(child.id) } isDesignMode={true} // 편집 모드로 설정 onClick={(e) => handleComponentClick(child, e)} + onDoubleClick={(e) => handleComponentDoubleClick(child, e)} onDragStart={(e) => startComponentDrag(child, e)} onDragEnd={endDrag} selectedScreen={selectedScreen} @@ -3725,6 +3807,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD }} onBackToList={onBackToList} /> + + {/* 파일첨부 상세 모달 */} +
); } diff --git a/frontend/components/screen/panels/DetailSettingsPanel.tsx b/frontend/components/screen/panels/DetailSettingsPanel.tsx index ebf450c4..02897f9b 100644 --- a/frontend/components/screen/panels/DetailSettingsPanel.tsx +++ b/frontend/components/screen/panels/DetailSettingsPanel.tsx @@ -908,7 +908,7 @@ export const DetailSettingsPanel: React.FC = ({ } // 파일 컴포넌트인 경우 FileComponentConfigPanel 렌더링 - if (selectedComponent.type === "file") { + if (selectedComponent.type === "file" || (selectedComponent.type === "widget" && selectedComponent.widgetType === "file")) { const fileComponent = selectedComponent as FileComponent; return ( @@ -923,7 +923,9 @@ export const DetailSettingsPanel: React.FC = ({ 타입: 파일 업로드 -
문서 타입: {fileComponent.fileConfig.docTypeName}
+
+ {selectedComponent.type === "widget" ? `위젯타입: ${selectedComponent.widgetType}` : `문서 타입: ${fileComponent.fileConfig?.docTypeName || "일반 문서"}`} +
{/* 파일 컴포넌트 설정 영역 */} diff --git a/frontend/components/screen/panels/FileComponentConfigPanel.tsx b/frontend/components/screen/panels/FileComponentConfigPanel.tsx index ae5f9190..e65558b6 100644 --- a/frontend/components/screen/panels/FileComponentConfigPanel.tsx +++ b/frontend/components/screen/panels/FileComponentConfigPanel.tsx @@ -1,21 +1,25 @@ "use client"; -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; 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 { Textarea } from "@/components/ui/textarea"; import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; import { FileComponent, TableInfo } from "@/types/screen"; -import { Plus, X } from "lucide-react"; +import { Plus, X, Upload, File, Image, FileText, Download, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { FileInfo } from "@/lib/registry/components/file-upload/types"; +import { uploadFiles, downloadFile, deleteFile } from "@/lib/api/file"; +import { formatFileSize } from "@/lib/utils"; +import { toast } from "sonner"; interface FileComponentConfigPanelProps { component: FileComponent; onUpdateProperty: (componentId: string, path: string, value: any) => void; - currentTable?: TableInfo; // 현재 화면의 테이블 정보 - currentTableName?: string; // 현재 화면의 테이블명 + currentTable?: TableInfo; + currentTableName?: string; } export const FileComponentConfigPanel: React.FC = ({ @@ -24,49 +28,518 @@ export const FileComponentConfigPanel: React.FC = 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]); + // 로컬 상태 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 || "", // 연결 필드 + 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 || "", }); const [localValues, setLocalValues] = useState({ - multiple: component.fileConfig.multiple ?? true, - showPreview: component.fileConfig.showPreview ?? true, - showProgress: component.fileConfig.showProgress ?? true, - autoLink: component.fileConfig.autoLink ?? false, // 자동 연결 + multiple: component.fileConfig?.multiple ?? true, + showPreview: component.fileConfig?.showPreview ?? true, + showProgress: component.fileConfig?.showProgress ?? true, + autoLink: component.fileConfig?.autoLink ?? false, }); - const [acceptTypes, setAcceptTypes] = useState(component.fileConfig.accept || []); + const [acceptTypes, setAcceptTypes] = useState(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] || []; + + // localStorage 백업에서 복원 (영구 저장된 파일 + 임시 파일) + const backupKey = `fileComponent_${component.id}_files`; + const tempBackupKey = `fileComponent_${component.id}_files_temp`; + const backupFiles = localStorage.getItem(backupKey); + const tempBackupFiles = localStorage.getItem(tempBackupKey); + let parsedBackupFiles: FileInfo[] = []; + let parsedTempFiles: 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); + } + } + + // 우선순위: 전역 상태 > localStorage > 임시 파일 > 컴포넌트 속성 + const finalFiles = globalFiles.length > 0 ? globalFiles : + 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, + finalFiles: finalFiles.length, + source: globalFiles.length > 0 ? 'global' : parsedBackupFiles.length > 0 ? 'localStorage' : parsedTempFiles.length > 0 ? 'temp' : 'component' + }); + + return finalFiles; + }; + + const [uploadedFiles, setUploadedFiles] = useState(() => { + 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 ; + } + if (['pdf', 'doc', 'docx', 'txt', 'rtf'].includes(ext)) { + return ; + } + return ; + }; + + // 파일 업로드 처리 + const handleFileUpload = useCallback(async (files: FileList | File[]) => { + if (!files || files.length === 0) 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; + + try { + console.log("🔄 파일 업로드 시작:", { fileCount: validFiles.length, uploading }); + setUploading(true); + toast.loading(`${validFiles.length}개 파일 업로드 중...`); + + const response = await uploadFiles({ + files: validFiles, + tableName: currentTableName || 'screen_files', + fieldName: component.columnName || component.id || 'file_attachment', + recordId: component.id, + 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)); + + 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' + }); + + 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 || '파일 업로드에 실패했습니다.'); + } + } catch (error) { + console.error('❌ 파일 업로드 오류:', error); + toast.dismiss(); + toast.error('파일 업로드에 실패했습니다.'); + } finally { + console.log("🏁 파일 업로드 완료, 로딩 상태 해제"); + setUploading(false); + } + }, [localInputs, localValues, uploadedFiles, onUpdateProperty, currentTableName, component, acceptTypes]); + + // 파일 다운로드 처리 + const handleFileDownload = useCallback(async (file: FileInfo) => { + try { + await downloadFile({ + 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) => { + try { + await deleteFile(fileId); + 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 + }); + + 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; + if (files.length > 0) { + handleFileUpload(files); + } + }, [handleFileUpload]); + + const handleFileSelect = useCallback((e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + handleFileUpload(files); + } + e.target.value = ''; + }, [handleFileUpload]); // 컴포넌트 변경 시 로컬 상태 동기화 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, + 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 || "", + linkedTable: component.fileConfig?.linkedTable || "", + linkedField: component.fileConfig?.linkedField || "", }); setLocalValues({ - multiple: component.fileConfig.multiple ?? true, - showPreview: component.fileConfig.showPreview ?? true, - showProgress: component.fileConfig.showProgress ?? true, - autoLink: component.fileConfig.autoLink ?? false, + multiple: component.fileConfig?.multiple ?? true, + showPreview: component.fileConfig?.showPreview ?? true, + showProgress: component.fileConfig?.showProgress ?? true, + autoLink: component.fileConfig?.autoLink ?? false, }); - setAcceptTypes(component.fileConfig.accept || []); - }, [component.fileConfig]); + 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가 변경될 때만 초기화 // 미리 정의된 문서 타입들 const docTypeOptions = [ @@ -83,79 +556,70 @@ export const FileComponentConfigPanel: React.FC = // 미리 정의된 파일 타입들 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: ".txt", label: "텍스트 파일" }, - { value: ".zip,.rar", label: "압축 파일" }, - { value: ".dwg,.dxf", label: "CAD 파일" }, + { 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: "압축파일" }, ]; // 파일 타입 추가 - const addAcceptType = useCallback(() => { - const newType = localInputs.newAcceptType.trim(); - if (newType && !acceptTypes.includes(newType)) { - const newAcceptTypes = [...acceptTypes, newType]; - setAcceptTypes(newAcceptTypes); - onUpdateProperty(component.id, "fileConfig.accept", newAcceptTypes); - setLocalInputs((prev) => ({ ...prev, newAcceptType: "" })); - } - }, [localInputs.newAcceptType, acceptTypes, component.id, onUpdateProperty]); + 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]); // 파일 타입 제거 - const removeAcceptType = useCallback( - (typeToRemove: string) => { - const newAcceptTypes = acceptTypes.filter((type) => type !== typeToRemove); - setAcceptTypes(newAcceptTypes); - onUpdateProperty(component.id, "fileConfig.accept", newAcceptTypes); - }, - [acceptTypes, component.id, onUpdateProperty], - ); - - // 미리 정의된 파일 타입 추가 - const addCommonFileType = useCallback( - (fileType: string) => { - const types = fileType.split(","); - const newAcceptTypes = [...acceptTypes]; - - types.forEach((type) => { - if (!newAcceptTypes.includes(type.trim())) { - newAcceptTypes.push(type.trim()); - } - }); - - setAcceptTypes(newAcceptTypes); - onUpdateProperty(component.id, "fileConfig.accept", newAcceptTypes); - }, - [acceptTypes, component.id, onUpdateProperty], - ); + const removeAcceptType = useCallback((typeToRemove: string) => { + const newTypes = acceptTypes.filter(type => type !== typeToRemove); + setAcceptTypes(newTypes); + onUpdateProperty(component.id, "fileConfig.accept", newTypes); + }, [acceptTypes, component.id, onUpdateProperty]); return ( -
- {/* 문서 분류 설정 */}
-

문서 분류 설정

+ {/* 기본 정보 */} +
+

기본 설정

= setLocalInputs((prev) => ({ ...prev, docTypeName: newValue })); onUpdateProperty(component.id, "fileConfig.docTypeName", newValue); }} - placeholder="문서 타입 표시명" />
{/* 파일 업로드 제한 설정 */} -
-

파일 업로드 제한

+
+

업로드 제한

-
+
- + =
- + =
{/* 허용 파일 타입 설정 */} -
+

허용 파일 타입

- {/* 미리 정의된 파일 타입 버튼들 */} -
- -
- {commonFileTypes.map((fileType) => ( - - ))} -
-
- - {/* 현재 설정된 파일 타입들 */} -
-
{acceptTypes.map((type, index) => ( @@ -274,186 +715,151 @@ export const FileComponentConfigPanel: React.FC = ))} {acceptTypes.length === 0 && 모든 파일 타입 허용} -
- {/* 사용자 정의 파일 타입 추가 */} -
- -
- { - setLocalInputs((prev) => ({ ...prev, newAcceptType: e.target.value })); - }} - placeholder="예: .dwg, image/*, .custom" - onKeyPress={(e) => { - if (e.key === "Enter") { - addAcceptType(); - } - }} - /> - -
+ ))}
- {/* UI 설정 */} -
-

UI 설정

- + {/* 파일 업로드 영역 */}
- -