diff --git a/backend-node/src/controllers/fileController.ts b/backend-node/src/controllers/fileController.ts index a648a4f9..28a46232 100644 --- a/backend-node/src/controllers/fileController.ts +++ b/backend-node/src/controllers/fileController.ts @@ -1261,5 +1261,56 @@ export const setRepresentativeFile = async ( } }; +/** + * 파일 정보 조회 (메타데이터만, 파일 내용 없음) + * 공개 접근 허용 + */ +export const getFileInfo = async (req: Request, res: Response) => { + try { + const { objid } = req.params; + + if (!objid) { + return res.status(400).json({ + success: false, + message: "파일 ID가 필요합니다.", + }); + } + + // 파일 정보 조회 + const fileRecord = await queryOne( + `SELECT objid, real_file_name, file_size, file_ext, file_path, regdate, is_representative + FROM attach_file_info + WHERE objid = $1 AND status = 'ACTIVE'`, + [parseInt(objid)] + ); + + if (!fileRecord) { + return res.status(404).json({ + success: false, + message: "파일을 찾을 수 없습니다.", + }); + } + + res.json({ + success: true, + data: { + objid: fileRecord.objid.toString(), + realFileName: fileRecord.real_file_name, + fileSize: fileRecord.file_size, + fileExt: fileRecord.file_ext, + filePath: fileRecord.file_path, + regdate: fileRecord.regdate, + isRepresentative: fileRecord.is_representative, + }, + }); + } catch (error) { + console.error("파일 정보 조회 오류:", error); + res.status(500).json({ + success: false, + message: "파일 정보 조회 중 오류가 발생했습니다.", + }); + } +}; + // Multer 미들웨어 export export const uploadMiddleware = upload.array("files", 10); // 최대 10개 파일 diff --git a/backend-node/src/routes/fileRoutes.ts b/backend-node/src/routes/fileRoutes.ts index 4514e37f..562a0b7f 100644 --- a/backend-node/src/routes/fileRoutes.ts +++ b/backend-node/src/routes/fileRoutes.ts @@ -11,6 +11,7 @@ import { generateTempToken, getFileByToken, setRepresentativeFile, + getFileInfo, } from "../controllers/fileController"; import { authenticateToken } from "../middleware/authMiddleware"; @@ -31,6 +32,13 @@ router.get("/public/:token", getFileByToken); */ router.get("/preview/:objid", previewFile); +/** + * @route GET /api/files/info/:objid + * @desc 파일 정보 조회 (메타데이터만, 파일 내용 없음) - 공개 접근 허용 + * @access Public + */ +router.get("/info/:objid", getFileInfo); + // 모든 파일 API는 인증 필요 router.use(authenticateToken); diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index 2c400df5..582aa413 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -43,7 +43,7 @@ import { } from "lucide-react"; import { tableTypeApi } from "@/lib/api/screen"; import { commonCodeApi } from "@/lib/api/commonCode"; -import { apiClient, getCurrentUser, UserInfo } from "@/lib/api/client"; +import { apiClient, getCurrentUser, UserInfo, getFullImageUrl } from "@/lib/api/client"; import { useAuth } from "@/hooks/useAuth"; import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup"; import { cn } from "@/lib/utils"; @@ -2224,6 +2224,37 @@ export const InteractiveDataTable: React.FC = ({ // 파일 타입 컬럼 처리 (가상 파일 컬럼 포함) const isFileColumn = actualWebType === "file" || column.columnName === "file_path" || column.isVirtualFileColumn; + // 🖼️ 이미지 타입 컬럼: 썸네일로 표시 + const isImageColumn = actualWebType === "image" || actualWebType === "img"; + if (isImageColumn && value) { + // value가 objid (숫자 또는 숫자 문자열)인 경우 파일 API URL 사용 + // 🔑 download 대신 preview 사용 (공개 접근 허용) + const isObjid = /^\d+$/.test(String(value)); + const imageUrl = isObjid + ? `/api/files/preview/${value}` + : getFullImageUrl(String(value)); + + return ( +
+ 이미지 { + e.stopPropagation(); + // 이미지 클릭 시 크게 보기 (새 탭에서 열기) + window.open(imageUrl, "_blank"); + }} + onError={(e) => { + // 이미지 로드 실패 시 기본 아이콘 표시 + (e.target as HTMLImageElement).style.display = "none"; + }} + /> +
+ ); + } + // 파일 타입 컬럼은 파일 아이콘으로 표시 (컬럼별 파일 관리) if (isFileColumn && rowData) { // 현재 행의 기본키 값 가져오기 diff --git a/frontend/components/v2/V2Media.tsx b/frontend/components/v2/V2Media.tsx index 7321808f..733d6657 100644 --- a/frontend/components/v2/V2Media.tsx +++ b/frontend/components/v2/V2Media.tsx @@ -124,7 +124,8 @@ export const V2Media = forwardRef( const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_')); const recordTableName = formData?.tableName || tableName; const recordId = formData?.id; - const effectiveColumnName = isRecordMode ? 'attachments' : (columnName || id || 'attachments'); + // 🔑 columnName 우선 사용 (실제 DB 컬럼명), 없으면 id, 최후에 attachments + const effectiveColumnName = columnName || id || 'attachments'; // 레코드용 targetObjid 생성 const getRecordTargetObjid = useCallback(() => { @@ -471,13 +472,21 @@ export const V2Media = forwardRef( // 폼 데이터 업데이트 - 부모 컴포넌트 시그니처에 맞게 (fieldName, value) 형식 if (onFormDataChange && targetColumn) { + // 🔑 단일 파일: 첫 번째 objid만 전달 (DB 컬럼에 저장될 값) + // 복수 파일: 콤마 구분 문자열로 전달 + const formValue = config.multiple + ? fileIds.join(',') + : (fileIds[0] || ''); + console.log("📝 [V2Media] formData 업데이트:", { columnName: targetColumn, fileIds, + formValue, + isMultiple: config.multiple, isRecordMode: effectiveIsRecordMode, }); // (fieldName: string, value: any) 형식으로 호출 - onFormDataChange(targetColumn, fileIds); + onFormDataChange(targetColumn, formValue); } // 그리드 파일 상태 새로고침 이벤트 발생 @@ -601,12 +610,19 @@ export const V2Media = forwardRef( // 폼 데이터 업데이트 - 부모 컴포넌트 시그니처에 맞게 (fieldName, value) 형식 if (onFormDataChange && targetColumn) { + // 🔑 단일 파일: 첫 번째 objid만 전달 (DB 컬럼에 저장될 값) + // 복수 파일: 콤마 구분 문자열로 전달 + const formValue = config.multiple + ? fileIds.join(',') + : (fileIds[0] || ''); + console.log("🗑️ [V2Media] 삭제 후 formData 업데이트:", { columnName: targetColumn, fileIds, + formValue, }); // (fieldName: string, value: any) 형식으로 호출 - onFormDataChange(targetColumn, fileIds); + onFormDataChange(targetColumn, formValue); } toast.success(`${fileName} 삭제 완료`); diff --git a/frontend/lib/api/file.ts b/frontend/lib/api/file.ts index e6cab8ae..f848c7e6 100644 --- a/frontend/lib/api/file.ts +++ b/frontend/lib/api/file.ts @@ -298,3 +298,31 @@ export const setRepresentativeFile = async (objid: string): Promise<{ throw new Error("대표 파일 설정에 실패했습니다."); } }; + +/** + * 파일 정보 조회 (메타데이터만, objid로 조회) + */ +export const getFileInfoByObjid = async (objid: string): Promise<{ + success: boolean; + data?: { + objid: string; + realFileName: string; + fileSize: number; + fileExt: string; + filePath: string; + regdate: string; + isRepresentative: boolean; + }; + message?: string; +}> => { + try { + const response = await apiClient.get(`/files/info/${objid}`); + return response.data; + } catch (error) { + console.error("파일 정보 조회 오류:", error); + return { + success: false, + message: "파일 정보 조회에 실패했습니다.", + }; + } +}; diff --git a/frontend/lib/registry/components/file-upload/index.ts b/frontend/lib/registry/components/file-upload/index.ts index fcca65cc..3f059ae1 100644 --- a/frontend/lib/registry/components/file-upload/index.ts +++ b/frontend/lib/registry/components/file-upload/index.ts @@ -14,22 +14,23 @@ import { FileUploadConfig } from "./types"; */ export const FileUploadDefinition = createComponentDefinition({ id: "file-upload", - name: "파일 업로드", - nameEng: "FileUpload Component", - description: "파일 업로드를 위한 파일 선택 컴포넌트", + name: "파일 업로드 (레거시)", + nameEng: "FileUpload Component (Legacy)", + description: "파일 업로드를 위한 파일 선택 컴포넌트 (레거시)", category: ComponentCategory.INPUT, webType: "file", component: FileUploadComponent, defaultConfig: { placeholder: "입력하세요", }, - defaultSize: { width: 350, height: 240 }, // 40 * 6 (파일 선택 + 목록 표시) + defaultSize: { width: 350, height: 240 }, configPanel: FileUploadConfigPanel, icon: "Edit", tags: [], version: "1.0.0", author: "개발팀", documentation: "https://docs.example.com/components/file-upload", + hidden: true, // v2-file-upload 사용으로 패널에서 숨김 }); // 타입 내보내기 diff --git a/frontend/lib/registry/components/image-widget/index.ts b/frontend/lib/registry/components/image-widget/index.ts index 67abbc80..aee663e8 100644 --- a/frontend/lib/registry/components/image-widget/index.ts +++ b/frontend/lib/registry/components/image-widget/index.ts @@ -13,9 +13,9 @@ import { ImageWidgetConfigPanel } from "./ImageWidgetConfigPanel"; */ export const ImageWidgetDefinition = createComponentDefinition({ id: "image-widget", - name: "이미지 위젯", - nameEng: "Image Widget", - description: "이미지 표시 및 업로드", + name: "이미지 위젯 (레거시)", + nameEng: "Image Widget (Legacy)", + description: "이미지 표시 및 업로드 (레거시)", category: ComponentCategory.INPUT, webType: "image", component: ImageWidget, @@ -32,6 +32,7 @@ export const ImageWidgetDefinition = createComponentDefinition({ version: "1.0.0", author: "개발팀", documentation: "https://docs.example.com/components/image-widget", + hidden: true, // v2-file-upload 사용으로 패널에서 숨김 }); // 컴포넌트 내보내기 diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 6519230d..172f0067 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -111,6 +111,7 @@ import "./v2-timeline-scheduler/TimelineSchedulerRenderer"; // 타임라인 스 import "./v2-input/V2InputRenderer"; // V2 통합 입력 컴포넌트 import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트 import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트 +import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트 /** * 컴포넌트 초기화 함수 diff --git a/frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx b/frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx new file mode 100644 index 00000000..de838fbf --- /dev/null +++ b/frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx @@ -0,0 +1,421 @@ +"use client"; + +import React, { useState, useRef } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { FileInfo, FileUploadConfig } from "./types"; +import { + Upload, + Download, + Trash2, + Eye, + File, + FileText, + Image as ImageIcon, + Video, + Music, + Archive, + Presentation, + X, + Star +} from "lucide-react"; +import { formatFileSize } from "@/lib/utils"; +import { FileViewerModal } from "./FileViewerModal"; + +interface FileManagerModalProps { + isOpen: boolean; + onClose: () => void; + uploadedFiles: FileInfo[]; + onFileUpload: (files: File[]) => Promise; + onFileDownload: (file: FileInfo) => void; + onFileDelete: (file: FileInfo) => void; + onFileView: (file: FileInfo) => void; + onSetRepresentative?: (file: FileInfo) => void; // 대표 이미지 설정 콜백 + config: FileUploadConfig; + isDesignMode?: boolean; +} + +export const FileManagerModal: React.FC = ({ + isOpen, + onClose, + uploadedFiles, + onFileUpload, + onFileDownload, + onFileDelete, + onFileView, + onSetRepresentative, + config, + isDesignMode = false, +}) => { + const [dragOver, setDragOver] = useState(false); + const [uploading, setUploading] = useState(false); + const [viewerFile, setViewerFile] = useState(null); + const [isViewerOpen, setIsViewerOpen] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); // 선택된 파일 (좌측 미리보기용) + const [previewImageUrl, setPreviewImageUrl] = useState(null); // 이미지 미리보기 URL + const fileInputRef = useRef(null); + + // 파일 아이콘 가져오기 + const getFileIcon = (fileExt: string) => { + const ext = fileExt.toLowerCase(); + + if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) { + return ; + } else if (['pdf', 'doc', 'docx', 'txt', 'rtf'].includes(ext)) { + return ; + } else if (['xls', 'xlsx', 'csv'].includes(ext)) { + return ; + } else if (['ppt', 'pptx'].includes(ext)) { + return ; + } else if (['mp4', 'avi', 'mov', 'webm'].includes(ext)) { + return