From 7ec5a438d450df44f50b09abf3e0beaf48bf4974 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 4 Feb 2026 17:25:49 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=A0=88=EA=B1=B0=EC=8B=9C?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=A1=9C=EB=93=9C=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 테이블 관리 서비스에서 검색 옵션에 operator를 추가하여 정확한 일치(equals) 및 부분 일치(contains) 검색을 지원하도록 개선하였습니다. - 파일 업로드 컴포넌트에서 레거시 file-upload 기능을 통합하여 안정적인 파일 업로드를 제공하며, V2Media와의 호환성을 강화하였습니다. - DynamicComponentRenderer에서 파일 업로드 컴포넌트의 디버깅 로깅을 추가하여 문제 해결을 용이하게 하였습니다. - 웹 타입 매핑에서 파일 및 이미지 타입을 레거시 file-upload로 변경하여 일관성을 유지하였습니다. --- .../src/services/tableManagementService.ts | 23 +- .../app/(main)/screens/[screenId]/page.tsx | 3 +- .../screen/InteractiveScreenViewerDynamic.tsx | 11 +- .../screen/panels/ComponentsPanel.tsx | 2 +- frontend/components/v2/V2Media.tsx | 1528 +++++++++-------- .../lib/registry/DynamicComponentRenderer.tsx | 7 +- .../components/v2-media/V2MediaRenderer.tsx | 120 +- .../TableSearchWidget.tsx | 12 + frontend/lib/utils/webTypeMapping.ts | 20 +- frontend/types/v2-components.ts | 18 +- 10 files changed, 957 insertions(+), 787 deletions(-) diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index da7a3981..6e8d0b7b 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -3403,14 +3403,16 @@ export class TableManagementService { if (options.search) { for (const [key, value] of Object.entries(options.search)) { - // 검색값 추출 (객체 형태일 수 있음) + // 검색값 및 operator 추출 (객체 형태일 수 있음) let searchValue = value; + let operator = "contains"; // 기본값: 부분 일치 if ( typeof value === "object" && value !== null && "value" in value ) { searchValue = value.value; + operator = (value as any).operator || "contains"; } // 빈 값이면 스킵 @@ -3482,7 +3484,19 @@ export class TableManagementService { `🎯 Entity 조인 다중선택 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} IN (${multiValues.join(", ")}) (별칭: ${alias})` ); } + } else if (operator === "equals") { + // 🔧 equals 연산자: 정확히 일치 + whereConditions.push( + `${alias}.${joinConfig.displayColumn}::text = '${safeValue}'` + ); + entitySearchColumns.push( + `${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})` + ); + logger.info( + `🎯 Entity 조인 정확히 일치 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} = '${safeValue}' (별칭: ${alias})` + ); } else { + // 기본: 부분 일치 (ILIKE) whereConditions.push( `${alias}.${joinConfig.displayColumn} ILIKE '%${safeValue}%'` ); @@ -3543,7 +3557,14 @@ export class TableManagementService { `🔍 다중선택 컬럼 검색: ${key} → main.${key} IN (${multiValues.join(", ")})` ); } + } else if (operator === "equals") { + // 🔧 equals 연산자: 정확히 일치 + whereConditions.push(`main.${key}::text = '${safeValue}'`); + logger.info( + `🔍 정확히 일치 검색: ${key} → main.${key} = '${safeValue}'` + ); } else { + // 기본: 부분 일치 (ILIKE) whereConditions.push(`main.${key} ILIKE '%${safeValue}%'`); logger.info( `🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${safeValue}%'` diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 0ce2bae5..14230b14 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -239,7 +239,8 @@ function ScreenViewPage() { compType?.includes("textarea") || compType?.includes("v2-input") || compType?.includes("v2-select") || - compType?.includes("v2-media"); // 🆕 미디어 컴포넌트 추가 + compType?.includes("v2-media") || + compType?.includes("file-upload"); // 🆕 레거시 파일 업로드 포함 const hasColumnName = !!(comp as any).columnName; return isInputType && hasColumnName; }); diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 8dc5da89..5770a468 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -562,13 +562,18 @@ export const InteractiveScreenViewerDynamic: React.FC = {}; - // v2-media 컴포넌트의 columnName 목록 수집 + // 파일 업로드 컴포넌트의 columnName 목록 수집 (v2-media, file-upload 모두 포함) const mediaColumnNames = new Set( allComponents - .filter((c: any) => c.componentType === "v2-media" || c.url?.includes("v2-media")) + .filter((c: any) => + c.componentType === "v2-media" || + c.componentType === "file-upload" || + c.url?.includes("v2-media") || + c.url?.includes("file-upload") + ) .map((c: any) => c.columnName || c.componentConfig?.columnName) .filter(Boolean) ); diff --git a/frontend/components/screen/panels/ComponentsPanel.tsx b/frontend/components/screen/panels/ComponentsPanel.tsx index 8f055bc3..9464a204 100644 --- a/frontend/components/screen/panels/ComponentsPanel.tsx +++ b/frontend/components/screen/panels/ComponentsPanel.tsx @@ -80,7 +80,7 @@ export function ComponentsPanel({ "textarea-basic", // V2 컴포넌트로 대체됨 "image-widget", // → V2Media (image) - "file-upload", // → V2Media (file) + // "file-upload", // 🆕 레거시 컴포넌트 노출 (안정적인 파일 업로드) "entity-search-input", // → V2Select (entity 모드) "autocomplete-search-input", // → V2Select (autocomplete 모드) // DataFlow 전용 (일반 화면에서 불필요) diff --git a/frontend/components/v2/V2Media.tsx b/frontend/components/v2/V2Media.tsx index 6a154863..7321808f 100644 --- a/frontend/components/v2/V2Media.tsx +++ b/frontend/components/v2/V2Media.tsx @@ -3,650 +3,79 @@ /** * V2Media * - * 통합 미디어 컴포넌트 + * 통합 미디어 컴포넌트 (레거시 FileUploadComponent 기능 통합) * - file: 파일 업로드 * - image: 이미지 업로드/표시 * - video: 비디오 * - audio: 오디오 + * + * 핵심 기능: + * - FileViewerModal / FileManagerModal (자세히보기) + * - 대표 이미지 설정 + * - 레코드 모드 (테이블/레코드 연결) + * - 전역 파일 상태 관리 + * - 파일 다운로드/삭제 + * - DB에서 기존 파일 로드 */ import React, { forwardRef, useCallback, useRef, useState, useEffect } from "react"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; import { V2MediaProps } from "@/types/v2-components"; -import { Upload, X, File, Image as ImageIcon, Video, Music, Eye, Download, Trash2, Plus } from "lucide-react"; +import { + Upload, X, File, Image as ImageIcon, Video, Music, Eye, Download, Trash2, Plus, + FileText, Archive, Presentation, FileImage, FileVideo, FileAudio +} from "lucide-react"; import { apiClient } from "@/lib/api/client"; +import { toast } from "sonner"; +import { uploadFiles, downloadFile, deleteFile, getComponentFiles } from "@/lib/api/file"; +import { GlobalFileManager } from "@/lib/api/globalFile"; +import { formatFileSize } from "@/lib/utils"; +import { useAuth } from "@/hooks/useAuth"; + +// 레거시 모달 컴포넌트 import +import { FileViewerModal } from "@/lib/registry/components/file-upload/FileViewerModal"; +import { FileManagerModal } from "@/lib/registry/components/file-upload/FileManagerModal"; +import type { FileInfo, FileUploadConfig } from "@/lib/registry/components/file-upload/types"; /** - * 파일 크기 포맷팅 + * 파일 아이콘 매핑 */ -function formatFileSize(bytes: number): string { - if (bytes === 0) return "0 Bytes"; - const k = 1024; - const sizes = ["Bytes", "KB", "MB", "GB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; -} +const getFileIcon = (extension: string) => { + const ext = extension.toLowerCase().replace(".", ""); -/** - * 파일 타입 아이콘 가져오기 - */ -function getFileIcon(type: string) { - if (type.startsWith("image/")) return ImageIcon; - if (type.startsWith("video/")) return Video; - if (type.startsWith("audio/")) return Music; - return File; -} - -/** - * 파일 업로드 컴포넌트 - */ -const FileUploader = forwardRef void; - multiple?: boolean; - accept?: string; - maxSize?: number; - disabled?: boolean; - uploadEndpoint?: string; - className?: string; -}>(({ - value, - onChange, - multiple = false, - accept = "*", - maxSize = 10485760, // 10MB - disabled, - uploadEndpoint = "/files/upload", - className -}, ref) => { - const inputRef = useRef(null); - const [isDragging, setIsDragging] = useState(false); - const [isUploading, setIsUploading] = useState(false); - const [error, setError] = useState(null); - // 업로드 직후 미리보기를 위한 로컬 상태 - const [localPreviewUrls, setLocalPreviewUrls] = useState([]); - - // objid를 미리보기 URL로 변환 - const toPreviewUrl = (val: any): string => { - if (!val) return ""; - const strVal = String(val); - if (strVal.startsWith("/") || strVal.startsWith("http")) return strVal; - if (/^\d+$/.test(strVal)) return `/api/files/preview/${strVal}`; - return strVal; - }; - - // value를 URL 형태의 files 배열로 변환 - const rawFiles = Array.isArray(value) ? value : value ? [value] : []; - const filesFromValue = rawFiles.map(toPreviewUrl).filter(Boolean); - - console.log("[FileUploader] value:", value, "rawFiles:", rawFiles, "filesFromValue:", filesFromValue, "localPreviewUrls:", localPreviewUrls); - - // value가 변경되면 로컬 상태 초기화 - useEffect(() => { - if (filesFromValue.length > 0) { - setLocalPreviewUrls([]); - } - }, [filesFromValue.length]); - - // 최종 files: value에서 온 파일 + 로컬 미리보기 (중복 제거) - const files = filesFromValue.length > 0 ? filesFromValue : localPreviewUrls; - - console.log("[FileUploader] final files:", files); - - // 파일 선택 핸들러 - const handleFileSelect = useCallback(async (selectedFiles: FileList | null) => { - if (!selectedFiles || selectedFiles.length === 0) return; - - setError(null); - const fileArray = Array.from(selectedFiles); - - // 크기 검증 - for (const file of fileArray) { - if (file.size > maxSize) { - setError(`파일 크기가 ${formatFileSize(maxSize)}를 초과합니다: ${file.name}`); - return; - } - } - - setIsUploading(true); - - try { - const uploadedUrls: string[] = []; - - for (const file of fileArray) { - const formData = new FormData(); - formData.append("files", file); - - const response = await apiClient.post(uploadEndpoint, formData, { - headers: { - "Content-Type": "multipart/form-data", - }, - }); - - const data = response.data; - console.log("[FileUploader] 업로드 응답:", data); - // 백엔드 응답: { success: true, files: [{ filePath, objid, ... }] } - if (data.success && data.files && data.files.length > 0) { - const uploadedFile = data.files[0]; - const objid = String(uploadedFile.objid); - uploadedUrls.push(objid); - // 즉시 미리보기를 위해 로컬 상태에 URL 저장 - const previewUrl = `/api/files/preview/${objid}`; - setLocalPreviewUrls(prev => multiple ? [...prev, previewUrl] : [previewUrl]); - } else if (data.objid) { - const objid = String(data.objid); - uploadedUrls.push(objid); - const previewUrl = `/api/files/preview/${objid}`; - setLocalPreviewUrls(prev => multiple ? [...prev, previewUrl] : [previewUrl]); - } else if (data.url) { - uploadedUrls.push(data.url); - setLocalPreviewUrls(prev => multiple ? [...prev, data.url] : [data.url]); - } else if (data.filePath) { - uploadedUrls.push(data.filePath); - setLocalPreviewUrls(prev => multiple ? [...prev, data.filePath] : [data.filePath]); - } - } - - if (multiple) { - const newValue = [...filesFromValue, ...uploadedUrls]; - console.log("[FileUploader] onChange called with:", newValue); - onChange?.(newValue); - } else { - const newValue = uploadedUrls[0] || ""; - console.log("[FileUploader] onChange called with:", newValue); - onChange?.(newValue); - } - } catch (err) { - setError(err instanceof Error ? err.message : "업로드 중 오류가 발생했습니다"); - } finally { - setIsUploading(false); - } - }, [filesFromValue, multiple, maxSize, uploadEndpoint, onChange]); - - // 드래그 앤 드롭 핸들러 - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(true); - }, []); - - const handleDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(false); - }, []); - - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(false); - handleFileSelect(e.dataTransfer.files); - }, [handleFileSelect]); - - // 파일 삭제 핸들러 - const handleRemove = useCallback((index: number) => { - // 로컬 미리보기도 삭제 - setLocalPreviewUrls(prev => prev.filter((_, i) => i !== index)); - // value에서 온 파일 삭제 - const newFiles = filesFromValue.filter((_, i) => i !== index); - onChange?.(multiple ? newFiles : ""); - }, [filesFromValue, multiple, onChange]); - - // 첫 번째 파일이 이미지인지 확인 - const firstFile = files[0]; - const isFirstFileImage = firstFile && ( - /\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i.test(firstFile) || - firstFile.includes("/preview/") || - firstFile.includes("/api/files/preview/") - ); - - return ( -
- {/* 메인 업로드 박스 - 이미지가 있으면 박스 안에 표시 */} -
!disabled && !firstFile && inputRef.current?.click()} - onDragOver={handleDragOver} - onDragLeave={handleDragLeave} - onDrop={handleDrop} - > - handleFileSelect(e.target.files)} - className="hidden" - /> - - {firstFile ? ( - // 파일이 있으면 박스 안에 표시 -
- {isFirstFileImage ? ( - // 이미지 미리보기 - 업로드된 이미지 - ) : ( - // 일반 파일 -
- - - {firstFile.split("/").pop()} - -
- )} - {/* 호버 시 액션 버튼 */} -
- {isFirstFileImage && ( - - )} - - -
-
- ) : isUploading ? ( -
-
- 업로드 중... -
- ) : ( -
- -
- 클릭 - 또는 파일을 드래그하세요 -
-
- 최대 {formatFileSize(maxSize)} - {accept !== "*" && ` (${accept})`} -
-
- )} -
- - {/* 에러 메시지 */} - {error && ( -
{error}
- )} - - {/* 추가 파일 목록 (multiple일 때 2번째 파일부터) */} - {multiple && files.length > 1 && ( -
- {files.slice(1).map((file, index) => { - const isImage = /\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i.test(file) || - file.includes("/preview/") || - file.includes("/api/files/preview/"); - - return ( -
- {isImage ? ( - {`파일 - ) : ( - - )} -
- -
-
- ); - })} - {/* 추가 버튼 */} -
!disabled && inputRef.current?.click()} - > - -
-
- )} -
- ); -}); -FileUploader.displayName = "FileUploader"; - -/** - * 이미지 업로드/표시 컴포넌트 - */ -const ImageUploader = forwardRef void; - multiple?: boolean; - accept?: string; - maxSize?: number; - preview?: boolean; - disabled?: boolean; - uploadEndpoint?: string; - className?: string; -}>(({ - value, - onChange, - multiple = false, - accept = "image/*", - maxSize = 10485760, - preview = true, - disabled, - uploadEndpoint = "/files/upload", - className -}, ref) => { - const inputRef = useRef(null); - const [isDragging, setIsDragging] = useState(false); - const [isUploading, setIsUploading] = useState(false); - const [previewUrl, setPreviewUrl] = useState(null); - - // objid를 미리보기 URL로 변환 - const toPreviewUrl = (val: any): string => { - if (!val) return ""; - const strVal = String(val); - if (strVal.startsWith("/") || strVal.startsWith("http")) return strVal; - if (/^\d+$/.test(strVal)) return `/api/files/preview/${strVal}`; - return strVal; - }; - - // value를 URL 형태의 images 배열로 변환 - const rawImages = Array.isArray(value) ? value : value ? [value] : []; - const images = rawImages.map(toPreviewUrl).filter(Boolean); - - // 파일 선택 핸들러 - const handleFileSelect = useCallback(async (selectedFiles: FileList | null) => { - if (!selectedFiles || selectedFiles.length === 0) return; - - setIsUploading(true); - - try { - const fileArray = Array.from(selectedFiles); - const uploadedUrls: string[] = []; - - for (const file of fileArray) { - // 미리보기 생성 - if (preview) { - const reader = new FileReader(); - reader.onload = () => setPreviewUrl(reader.result as string); - reader.readAsDataURL(file); - } - - const formData = new FormData(); - formData.append("files", file); - - try { - const response = await apiClient.post(uploadEndpoint, formData, { - headers: { - "Content-Type": "multipart/form-data", - }, - }); - - const data = response.data; - // 백엔드 응답: { success: true, files: [{ filePath, objid, ... }] } - if (data.success && data.files && data.files.length > 0) { - const uploadedFile = data.files[0]; - // objid만 저장 (DB 저장용) - 표시는 V2MediaRenderer에서 URL로 변환 - uploadedUrls.push(String(uploadedFile.objid)); - } else if (data.objid) { - uploadedUrls.push(String(data.objid)); - } else if (data.url) { - uploadedUrls.push(data.url); - } else if (data.filePath) { - uploadedUrls.push(data.filePath); - } - } catch (err) { - console.error("이미지 업로드 실패:", err); - } - } - - if (multiple) { - onChange?.([...images, ...uploadedUrls]); - } else { - onChange?.(uploadedUrls[0] || ""); - } - } finally { - setIsUploading(false); - setPreviewUrl(null); - } - }, [images, multiple, preview, uploadEndpoint, onChange]); - - // 이미지 삭제 핸들러 - const handleRemove = useCallback((index: number) => { - const newImages = images.filter((_, i) => i !== index); - onChange?.(multiple ? newImages : ""); - }, [images, multiple, onChange]); - - // 첫 번째 이미지 (메인 박스에 표시) - const mainImage = images[0]; - // 추가 이미지들 (multiple일 때만) - const additionalImages = multiple ? images.slice(1) : []; - - return ( -
- {/* 메인 업로드 박스 - 첫 번째 이미지가 있으면 박스 안에 표시 */} -
!disabled && !mainImage && inputRef.current?.click()} - onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }} - onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }} - onDrop={(e) => { e.preventDefault(); setIsDragging(false); handleFileSelect(e.dataTransfer.files); }} - > - handleFileSelect(e.target.files)} - className="hidden" - /> - - {mainImage ? ( - // 이미지가 있으면 박스 안에 표시 -
- 업로드된 이미지 - {/* 호버 시 액션 버튼 */} -
- - - -
-
- ) : isUploading ? ( -
-
- 업로드 중... -
- ) : ( -
- - - 클릭 또는 파일을 드래그하세요 - - - 최대 {Math.round(maxSize / 1024 / 1024)} MB (*/*) - -
- )} -
- - {/* 추가 이미지 목록 (multiple일 때만) */} - {multiple && additionalImages.length > 0 && ( -
- {additionalImages.map((src, index) => ( -
- {`이미지 -
- -
-
- ))} - {/* 추가 버튼 */} -
!disabled && inputRef.current?.click()} - > - -
-
- )} -
- ); -}); -ImageUploader.displayName = "ImageUploader"; - -/** - * 비디오 컴포넌트 - */ -const VideoPlayer = forwardRef(({ value, className }, ref) => { - if (!value) { - return ( -
-
- ); + if (["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(ext)) { + return ; + } + if (["mp4", "avi", "mov", "wmv", "flv", "webm"].includes(ext)) { + return ; + } + if (["mp3", "wav", "flac", "aac", "ogg"].includes(ext)) { + return ; + } + if (["pdf"].includes(ext)) { + return ; + } + if (["doc", "docx", "hwp", "hwpx", "pages"].includes(ext)) { + return ; + } + if (["xls", "xlsx", "hcell", "numbers"].includes(ext)) { + return ; + } + if (["ppt", "pptx", "hanshow", "keynote"].includes(ext)) { + return ; + } + if (["zip", "rar", "7z", "tar", "gz"].includes(ext)) { + return ; } - return ( -
-
- ); -}); -VideoPlayer.displayName = "VideoPlayer"; + return ; +}; /** - * 오디오 컴포넌트 - */ -const AudioPlayer = forwardRef(({ value, className }, ref) => { - if (!value) { - return ( -
- -
- ); - } - - return ( -
-
- ); -}); -AudioPlayer.displayName = "AudioPlayer"; - -/** - * 메인 V2Media 컴포넌트 + * V2 미디어 컴포넌트 (레거시 기능 통합) */ export const V2Media = forwardRef( (props, ref) => { @@ -661,83 +90,660 @@ export const V2Media = forwardRef( config: configProp, value, onChange, + formData, + columnName, + tableName, + onFormDataChange, + isDesignMode = false, + isInteractive = true, + onUpdate, + ...restProps } = props; - // config가 없으면 기본값 사용 - const config = configProp || { type: "image" as const }; + // 인증 정보 + const { user } = useAuth(); + + // config 기본값 + const config = configProp || { type: "file" as const }; + const mediaType = config.type || "file"; + + // 파일 상태 + const [uploadedFiles, setUploadedFiles] = useState([]); + const [uploadStatus, setUploadStatus] = useState<"idle" | "uploading" | "success" | "error">("idle"); + const [dragOver, setDragOver] = useState(false); + const [representativeImageUrl, setRepresentativeImageUrl] = useState(null); - // objid를 미리보기 URL로 변환하는 함수 - const toPreviewUrl = (val: any): string => { - if (!val) return ""; - const strVal = String(val); - if (strVal.startsWith("/") || strVal.startsWith("http")) return strVal; - if (/^\d+$/.test(strVal)) return `/api/files/preview/${strVal}`; - return strVal; - }; + // 모달 상태 + const [viewerFile, setViewerFile] = useState(null); + const [isViewerOpen, setIsViewerOpen] = useState(false); + const [isFileManagerOpen, setIsFileManagerOpen] = useState(false); - // value를 URL로 변환 (배열 또는 단일 값) - const convertedValue = Array.isArray(value) - ? value.map(toPreviewUrl) - : value ? toPreviewUrl(value) : value; - - console.log("[V2Media] original value:", value, "-> converted:", convertedValue, "onChange:", typeof onChange); + const fileInputRef = useRef(null); - // 타입별 미디어 컴포넌트 렌더링 - const renderMedia = () => { - const isDisabled = disabled || readonly; - const mediaType = config.type || "image"; + // 레코드 모드 판단 + 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'); - switch (mediaType) { - case "file": - return ( - - ); - - case "image": - return ( - - ); - - case "video": - return ( - - ); - - case "audio": - return ( - - ); - - default: - return ( - - ); + // 레코드용 targetObjid 생성 + const getRecordTargetObjid = useCallback(() => { + if (isRecordMode && recordTableName && recordId) { + return `${recordTableName}:${recordId}:${effectiveColumnName}`; } + return null; + }, [isRecordMode, recordTableName, recordId, effectiveColumnName]); + + // 레코드별 고유 키 생성 + const getUniqueKey = useCallback(() => { + if (isRecordMode && recordTableName && recordId) { + return `v2media_${recordTableName}_${recordId}_${id}`; + } + return `v2media_${id}`; + }, [isRecordMode, recordTableName, recordId, id]); + + // 레코드 ID 변경 시 파일 목록 초기화 + const prevRecordIdRef = useRef(null); + useEffect(() => { + if (prevRecordIdRef.current !== recordId) { + prevRecordIdRef.current = recordId; + if (isRecordMode) { + setUploadedFiles([]); + } + } + }, [recordId, isRecordMode]); + + // 컴포넌트 마운트 시 localStorage에서 파일 복원 + useEffect(() => { + if (!id) return; + + try { + const backupKey = getUniqueKey(); + const backupFiles = localStorage.getItem(backupKey); + if (backupFiles) { + const parsedFiles = JSON.parse(backupFiles); + if (parsedFiles.length > 0) { + setUploadedFiles(parsedFiles); + + if (typeof window !== "undefined") { + (window as any).globalFileState = { + ...(window as any).globalFileState, + [backupKey]: parsedFiles, + }; + } + } + } + } catch (e) { + console.warn("파일 복원 실패:", e); + } + }, [id, getUniqueKey, recordId]); + + // DB에서 파일 목록 로드 + const loadComponentFiles = useCallback(async () => { + if (!id) return false; + + try { + let screenId = formData?.screenId; + + if (!screenId && typeof window !== "undefined") { + const pathname = window.location.pathname; + const screenMatch = pathname.match(/\/screens\/(\d+)/); + if (screenMatch) { + screenId = parseInt(screenMatch[1]); + } + } + + if (!screenId && isDesignMode) { + screenId = 999999; + } + + if (!screenId) { + screenId = 0; + } + + const params = { + screenId, + componentId: id, + tableName: recordTableName || formData?.tableName || tableName, + recordId: recordId || formData?.id, + columnName: effectiveColumnName, + }; + + const response = await getComponentFiles(params); + + if (response.success) { + const formattedFiles = response.totalFiles.map((file: any) => ({ + objid: file.objid || file.id, + savedFileName: file.savedFileName || file.saved_file_name, + realFileName: file.realFileName || file.real_file_name, + fileSize: file.fileSize || file.file_size, + fileExt: file.fileExt || file.file_ext, + regdate: file.regdate, + status: file.status || "ACTIVE", + uploadedAt: file.uploadedAt || new Date().toISOString(), + targetObjid: file.targetObjid || file.target_objid, + filePath: file.filePath || file.file_path, + ...file, + })); + + // localStorage와 병합 + let finalFiles = formattedFiles; + const uniqueKey = getUniqueKey(); + try { + const backupFiles = localStorage.getItem(uniqueKey); + if (backupFiles) { + const parsedBackupFiles = JSON.parse(backupFiles); + const serverObjIds = new Set(formattedFiles.map((f: any) => f.objid)); + const additionalFiles = parsedBackupFiles.filter((f: any) => !serverObjIds.has(f.objid)); + finalFiles = [...formattedFiles, ...additionalFiles]; + } + } catch (e) { + console.warn("파일 병합 오류:", e); + } + + setUploadedFiles(finalFiles); + + if (typeof window !== "undefined") { + (window as any).globalFileState = { + ...(window as any).globalFileState, + [uniqueKey]: finalFiles, + }; + + GlobalFileManager.registerFiles(finalFiles, { + uploadPage: window.location.pathname, + componentId: id, + screenId: formData?.screenId, + recordId: recordId, + }); + + try { + localStorage.setItem(uniqueKey, JSON.stringify(finalFiles)); + } catch (e) { + console.warn("localStorage 백업 실패:", e); + } + } + return true; + } + } catch (error) { + console.error("파일 조회 오류:", error); + } + return false; + }, [id, tableName, columnName, formData?.screenId, formData?.tableName, formData?.id, getUniqueKey, recordId, isRecordMode, recordTableName, effectiveColumnName, isDesignMode]); + + // 파일 동기화 + useEffect(() => { + loadComponentFiles(); + }, [loadComponentFiles]); + + // 전역 상태 변경 감지 + useEffect(() => { + const handleGlobalFileStateChange = (event: CustomEvent) => { + const { componentId, files, isRestore } = event.detail; + + if (componentId === id) { + setUploadedFiles(files); + + try { + const backupKey = getUniqueKey(); + localStorage.setItem(backupKey, JSON.stringify(files)); + } catch (e) { + console.warn("localStorage 백업 실패:", e); + } + } + }; + + if (typeof window !== "undefined") { + window.addEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener); + return () => { + window.removeEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener); + }; + } + }, [id, getUniqueKey]); + + // 파일 업로드 처리 + const handleFileUpload = useCallback( + async (files: File[]) => { + if (!files.length) return; + + // 중복 체크 + const existingFileNames = uploadedFiles.map((f) => f.realFileName.toLowerCase()); + const duplicates: string[] = []; + const uniqueFiles: File[] = []; + + files.forEach((file) => { + const fileName = file.name.toLowerCase(); + if (existingFileNames.includes(fileName)) { + duplicates.push(file.name); + } else { + uniqueFiles.push(file); + } + }); + + if (duplicates.length > 0) { + toast.error(`중복된 파일: ${duplicates.join(", ")}`); + if (uniqueFiles.length === 0) return; + toast.info(`${uniqueFiles.length}개의 새로운 파일만 업로드합니다.`); + } + + const filesToUpload = uniqueFiles.length > 0 ? uniqueFiles : files; + setUploadStatus("uploading"); + toast.loading("파일 업로드 중...", { id: "file-upload" }); + + try { + const effectiveTableName = recordTableName || formData?.tableName || tableName || "default_table"; + const effectiveRecordId = recordId || formData?.id; + + let screenId = formData?.screenId; + if (!screenId && typeof window !== "undefined") { + const pathname = window.location.pathname; + const screenMatch = pathname.match(/\/screens\/(\d+)/); + if (screenMatch) { + screenId = parseInt(screenMatch[1]); + } + } + + let targetObjid; + const effectiveIsRecordMode = isRecordMode || (effectiveRecordId && !String(effectiveRecordId).startsWith('temp_')); + + if (effectiveIsRecordMode && effectiveTableName && effectiveRecordId) { + targetObjid = `${effectiveTableName}:${effectiveRecordId}:${effectiveColumnName}`; + } else if (screenId) { + targetObjid = `screen_files:${screenId}:${id}:${effectiveColumnName}`; + } else { + targetObjid = `temp_${id}`; + } + + const userCompanyCode = user?.companyCode || (window as any).__user__?.companyCode; + + const finalLinkedTable = effectiveIsRecordMode + ? effectiveTableName + : (formData?.linkedTable || effectiveTableName); + + const uploadData = { + autoLink: formData?.autoLink || true, + linkedTable: finalLinkedTable, + recordId: effectiveRecordId || `temp_${id}`, + columnName: effectiveColumnName, + isVirtualFileColumn: formData?.isVirtualFileColumn || true, + docType: config?.docType || "DOCUMENT", + docTypeName: config?.docTypeName || "일반 문서", + companyCode: userCompanyCode, + tableName: effectiveTableName, + fieldName: effectiveColumnName, + targetObjid: targetObjid, + isRecordMode: effectiveIsRecordMode, + }; + + const response = await uploadFiles({ + files: filesToUpload, + ...uploadData, + }); + + if (response.success) { + const fileData = response.files || (response as any).data || []; + + if (fileData.length === 0) { + throw new Error("업로드된 파일 데이터를 받지 못했습니다."); + } + + const newFiles = fileData.map((file: any) => ({ + objid: file.objid || file.id, + savedFileName: file.saved_file_name || file.savedFileName, + realFileName: file.real_file_name || file.realFileName || file.name, + fileSize: file.file_size || file.fileSize || file.size, + fileExt: file.file_ext || file.fileExt || file.extension, + filePath: file.file_path || file.filePath || file.path, + docType: file.doc_type || file.docType, + docTypeName: file.doc_type_name || file.docTypeName, + targetObjid: file.target_objid || file.targetObjid, + parentTargetObjid: file.parent_target_objid || file.parentTargetObjid, + companyCode: file.company_code || file.companyCode, + writer: file.writer, + regdate: file.regdate, + status: file.status || "ACTIVE", + uploadedAt: new Date().toISOString(), + ...file, + })); + + const updatedFiles = [...uploadedFiles, ...newFiles]; + setUploadedFiles(updatedFiles); + setUploadStatus("success"); + + // localStorage 백업 + try { + const backupKey = getUniqueKey(); + localStorage.setItem(backupKey, JSON.stringify(updatedFiles)); + } catch (e) { + console.warn("localStorage 백업 실패:", e); + } + + // 전역 상태 업데이트 + if (typeof window !== "undefined") { + const globalFileState = (window as any).globalFileState || {}; + const uniqueKey = getUniqueKey(); + globalFileState[uniqueKey] = updatedFiles; + (window as any).globalFileState = globalFileState; + + GlobalFileManager.registerFiles(newFiles, { + uploadPage: window.location.pathname, + componentId: id, + screenId: formData?.screenId, + recordId: recordId, + }); + + const syncEvent = new CustomEvent("globalFileStateChanged", { + detail: { + componentId: id, + uniqueKey: uniqueKey, + recordId: recordId, + files: updatedFiles, + fileCount: updatedFiles.length, + timestamp: Date.now(), + }, + }); + window.dispatchEvent(syncEvent); + } + + // 부모 컴포넌트 업데이트 + if (onUpdate) { + onUpdate({ + uploadedFiles: updatedFiles, + lastFileUpdate: Date.now(), + }); + } + + // onChange 콜백 (objid 배열 또는 단일 값) + const fileIds = updatedFiles.map((f) => f.objid); + const finalValue = config.multiple ? fileIds : fileIds[0] || ""; + const targetColumn = columnName || effectiveColumnName; + + console.log("📤 [V2Media] 파일 업로드 완료 - 값 전달:", { + columnName: targetColumn, + fileIds, + finalValue, + hasOnChange: !!onChange, + hasOnFormDataChange: !!onFormDataChange, + }); + + if (onChange) { + onChange(finalValue); + } + + // 폼 데이터 업데이트 - 부모 컴포넌트 시그니처에 맞게 (fieldName, value) 형식 + if (onFormDataChange && targetColumn) { + console.log("📝 [V2Media] formData 업데이트:", { + columnName: targetColumn, + fileIds, + isRecordMode: effectiveIsRecordMode, + }); + // (fieldName: string, value: any) 형식으로 호출 + onFormDataChange(targetColumn, fileIds); + } + + // 그리드 파일 상태 새로고침 이벤트 발생 + if (typeof window !== "undefined") { + const refreshEvent = new CustomEvent("refreshFileStatus", { + detail: { + tableName: effectiveTableName, + recordId: effectiveRecordId, + columnName: targetColumn, + targetObjid: targetObjid, + fileCount: updatedFiles.length, + }, + }); + window.dispatchEvent(refreshEvent); + } + + toast.dismiss("file-upload"); + toast.success(`${newFiles.length}개 파일 업로드 완료`); + } else { + throw new Error(response.message || (response as any).error || "파일 업로드 실패"); + } + } catch (error) { + console.error("파일 업로드 오류:", error); + setUploadStatus("error"); + toast.dismiss("file-upload"); + toast.error(`업로드 오류: ${error instanceof Error ? error.message : "알 수 없는 오류"}`); + } + }, + [config, uploadedFiles, onChange, id, getUniqueKey, recordId, isRecordMode, recordTableName, effectiveColumnName, tableName, onUpdate, onFormDataChange, user, columnName], + ); + + // 파일 뷰어 열기/닫기 + const handleFileView = useCallback((file: FileInfo) => { + setViewerFile(file); + setIsViewerOpen(true); + }, []); + + const handleViewerClose = useCallback(() => { + setIsViewerOpen(false); + setViewerFile(null); + }, []); + + // 파일 다운로드 + const handleFileDownload = useCallback(async (file: FileInfo) => { + try { + await downloadFile({ + fileId: file.objid, + serverFilename: file.savedFileName, + originalName: file.realFileName, + }); + toast.success(`${file.realFileName} 다운로드 완료`); + } catch (error) { + console.error("파일 다운로드 오류:", error); + toast.error("파일 다운로드 실패"); + } + }, []); + + // 파일 삭제 + const handleFileDelete = useCallback( + async (file: FileInfo | string) => { + try { + const fileId = typeof file === "string" ? file : file.objid; + const fileName = typeof file === "string" ? "파일" : file.realFileName; + const serverFilename = typeof file === "string" ? "temp_file" : file.savedFileName; + + await deleteFile(fileId, serverFilename); + + const updatedFiles = uploadedFiles.filter((f) => f.objid !== fileId); + setUploadedFiles(updatedFiles); + + // localStorage 백업 + try { + const backupKey = getUniqueKey(); + localStorage.setItem(backupKey, JSON.stringify(updatedFiles)); + } catch (e) { + console.warn("localStorage 백업 실패:", e); + } + + // 전역 상태 업데이트 + if (typeof window !== "undefined") { + const globalFileState = (window as any).globalFileState || {}; + const uniqueKey = getUniqueKey(); + globalFileState[uniqueKey] = updatedFiles; + (window as any).globalFileState = globalFileState; + + const syncEvent = new CustomEvent("globalFileStateChanged", { + detail: { + componentId: id, + uniqueKey: uniqueKey, + recordId: recordId, + files: updatedFiles, + fileCount: updatedFiles.length, + timestamp: Date.now(), + action: "delete", + }, + }); + window.dispatchEvent(syncEvent); + } + + if (onUpdate) { + onUpdate({ + uploadedFiles: updatedFiles, + lastFileUpdate: Date.now(), + }); + } + + // onChange 콜백 + const fileIds = updatedFiles.map((f) => f.objid); + const finalValue = config.multiple ? fileIds : fileIds[0] || ""; + const targetColumn = columnName || effectiveColumnName; + + console.log("🗑️ [V2Media] 파일 삭제 완료 - 값 전달:", { + columnName: targetColumn, + fileIds, + finalValue, + }); + + if (onChange) { + onChange(finalValue); + } + + // 폼 데이터 업데이트 - 부모 컴포넌트 시그니처에 맞게 (fieldName, value) 형식 + if (onFormDataChange && targetColumn) { + console.log("🗑️ [V2Media] 삭제 후 formData 업데이트:", { + columnName: targetColumn, + fileIds, + }); + // (fieldName: string, value: any) 형식으로 호출 + onFormDataChange(targetColumn, fileIds); + } + + toast.success(`${fileName} 삭제 완료`); + } catch (error) { + console.error("파일 삭제 오류:", error); + toast.error("파일 삭제 실패"); + } + }, + [uploadedFiles, onUpdate, id, isRecordMode, onFormDataChange, recordTableName, recordId, effectiveColumnName, getUniqueKey, onChange, config.multiple, columnName], + ); + + // 대표 이미지 로드 + const loadRepresentativeImage = useCallback( + async (file: FileInfo) => { + try { + const isImage = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes( + file.fileExt.toLowerCase().replace(".", "") + ); + + if (!isImage) { + setRepresentativeImageUrl(null); + return; + } + + if (!file.objid || file.objid === "0" || file.objid === "") { + setRepresentativeImageUrl(null); + return; + } + + const response = await apiClient.get(`/files/download/${file.objid}`, { + params: { serverFilename: file.savedFileName }, + responseType: "blob", + }); + + const blob = new Blob([response.data]); + const url = window.URL.createObjectURL(blob); + + if (representativeImageUrl) { + window.URL.revokeObjectURL(representativeImageUrl); + } + + setRepresentativeImageUrl(url); + } catch (error) { + console.error("대표 이미지 로드 실패:", error); + setRepresentativeImageUrl(null); + } + }, + [representativeImageUrl], + ); + + // 대표 이미지 설정 + const handleSetRepresentative = useCallback( + async (file: FileInfo) => { + try { + const { setRepresentativeFile } = await import("@/lib/api/file"); + await setRepresentativeFile(file.objid); + + const updatedFiles = uploadedFiles.map((f) => ({ + ...f, + isRepresentative: f.objid === file.objid, + })); + + setUploadedFiles(updatedFiles); + loadRepresentativeImage(file); + } catch (e) { + console.error("대표 파일 설정 실패:", e); + } + }, + [uploadedFiles, loadRepresentativeImage] + ); + + // uploadedFiles 변경 시 대표 이미지 로드 + useEffect(() => { + const representativeFile = uploadedFiles.find(f => f.isRepresentative) || uploadedFiles[0]; + if (representativeFile) { + loadRepresentativeImage(representativeFile); + } else { + setRepresentativeImageUrl(null); + } + + return () => { + if (representativeImageUrl) { + window.URL.revokeObjectURL(representativeImageUrl); + } + }; + }, [uploadedFiles]); + + // 드래그 앤 드롭 핸들러 + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!readonly && !disabled) { + setDragOver(true); + } + }, [readonly, disabled]); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragOver(false); + }, []); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragOver(false); + + if (!readonly && !disabled) { + const files = Array.from(e.dataTransfer.files); + if (files.length > 0) { + handleFileUpload(files); + } + } + }, [readonly, disabled, handleFileUpload]); + + // 파일 선택 + const handleFileSelect = useCallback(() => { + if (fileInputRef.current) { + fileInputRef.current.click(); + } + }, []); + + const handleInputChange = useCallback((e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + if (files.length > 0) { + handleFileUpload(files); + } + e.target.value = ''; + }, [handleFileUpload]); + + // 파일 설정 + const fileConfig: FileUploadConfig = { + accept: config.accept || "*/*", + multiple: config.multiple || false, + maxSize: config.maxSize || 10 * 1024 * 1024, + disabled: disabled, + readonly: readonly, }; const showLabel = label && style?.labelDisplay !== false; @@ -749,11 +755,9 @@ export const V2Media = forwardRef( ref={ref} id={id} className="flex w-full flex-col" - style={{ - width: componentWidth, - // 🔧 높이는 컨테이너가 아닌 컨텐츠 영역에만 적용 (라벨 높이는 별도) - }} + style={{ width: componentWidth }} > + {/* 라벨 */} {showLabel && ( )} + + {/* 메인 컨테이너 */}
- {renderMedia()} +
+ {/* 숨겨진 파일 입력 */} + + + {/* 파일이 있는 경우: 대표 이미지/파일 표시 */} + {uploadedFiles.length > 0 ? (() => { + const representativeFile = uploadedFiles.find(f => f.isRepresentative) || uploadedFiles[0]; + const isImage = representativeFile && ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes( + representativeFile.fileExt.toLowerCase().replace(".", "") + ); + + return ( + <> + {isImage && representativeImageUrl ? ( +
+ {representativeFile.realFileName} +
+ ) : isImage && !representativeImageUrl ? ( +
+
+

이미지 로딩 중...

+
+ ) : ( +
+ {getFileIcon(representativeFile.fileExt)} +

+ {representativeFile.realFileName} +

+ + 대표 파일 + +
+ )} + + {/* 우측 하단 자세히보기 버튼 */} +
+ +
+ + ); + })() : ( + // 파일이 없는 경우: 업로드 안내 +
!disabled && !readonly && handleFileSelect()} + > + +

파일을 드래그하거나 클릭하세요

+

+ 최대 {formatFileSize(config.maxSize || 10 * 1024 * 1024)} + {config.accept && config.accept !== "*/*" && ` (${config.accept})`} +

+ +
+ )} +
+ + {/* 파일 뷰어 모달 */} + + + {/* 파일 관리 모달 */} + setIsFileManagerOpen(false)} + uploadedFiles={uploadedFiles} + onFileUpload={handleFileUpload} + onFileDownload={handleFileDownload} + onFileDelete={handleFileDelete} + onFileView={handleFileView} + onSetRepresentative={handleSetRepresentative} + config={fileConfig} + isDesignMode={isDesignMode} + />
); } @@ -785,4 +906,3 @@ export const V2Media = forwardRef( V2Media.displayName = "V2Media"; export default V2Media; - diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 9571abef..0ea82ad8 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -427,9 +427,10 @@ export const DynamicComponentRenderer: React.FC = // 컴포넌트의 columnName에 해당하는 formData 값 추출 const fieldName = (component as any).columnName || (component as any).componentConfig?.columnName || component.id; - // 🔍 V2Media 디버깅 - if (componentType === "v2-media") { - console.log("[DynamicComponentRenderer] v2-media:", { + // 🔍 파일 업로드 컴포넌트 디버깅 + if (componentType === "v2-media" || componentType === "file-upload") { + console.log("[DynamicComponentRenderer] 파일 업로드:", { + componentType, componentId: component.id, columnName: (component as any).columnName, configColumnName: (component as any).componentConfig?.columnName, diff --git a/frontend/lib/registry/components/v2-media/V2MediaRenderer.tsx b/frontend/lib/registry/components/v2-media/V2MediaRenderer.tsx index 0cfd5393..af923ec3 100644 --- a/frontend/lib/registry/components/v2-media/V2MediaRenderer.tsx +++ b/frontend/lib/registry/components/v2-media/V2MediaRenderer.tsx @@ -3,90 +3,86 @@ import React from "react"; import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; import { V2MediaDefinition } from "./index"; -import { V2Media } from "@/components/v2/V2Media"; +import FileUploadComponent from "../file-upload/FileUploadComponent"; /** * V2Media 렌더러 - * 파일, 이미지, 비디오, 오디오 등 다양한 미디어 타입을 지원 + * 레거시 FileUploadComponent를 사용하여 안정적인 파일 업로드 기능 제공 * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 */ export class V2MediaRenderer extends AutoRegisteringComponentRenderer { static componentDefinition = V2MediaDefinition; render(): React.ReactElement { - const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, ...restProps } = this.props; + const { + component, + formData, + onFormDataChange, + isDesignMode, + isSelected, + isInteractive, + onUpdate, + ...restProps + } = this.props; // 컴포넌트 설정 추출 const config = component.componentConfig || component.config || {}; const columnName = component.columnName; const tableName = component.tableName || this.props.tableName; - // formData에서 현재 값 가져오기 - const rawValue = formData?.[columnName] ?? component.value ?? ""; - - // objid를 미리보기 URL로 변환하는 함수 (number/string 모두 처리) - const convertToPreviewUrl = (val: any): string => { - if (val === null || val === undefined || val === "") return ""; - - // number면 string으로 변환 - const strVal = String(val); - - // 이미 URL 형태면 그대로 반환 - if (strVal.startsWith("/") || strVal.startsWith("http")) return strVal; - - // 숫자로만 이루어진 문자열이면 objid로 간주하고 미리보기 URL 생성 - if (/^\d+$/.test(strVal)) { - return `/api/files/preview/${strVal}`; - } - - return strVal; - }; - - // 배열 또는 단일 값 처리 - const currentValue = Array.isArray(rawValue) - ? rawValue.map(convertToPreviewUrl) - : convertToPreviewUrl(rawValue); - - console.log("[V2Media] rawValue:", rawValue, "-> currentValue:", currentValue); - - // 값 변경 핸들러 - const handleChange = (value: any) => { - if (isInteractive && onFormDataChange && columnName) { - onFormDataChange(columnName, value); - } - }; - - // V1 file-upload, image-widget에서 넘어온 설정 매핑 + // V1 file-upload에서 사용하는 형태로 설정 매핑 const mediaType = config.mediaType || config.type || this.getMediaTypeFromWebType(component.webType); - // maxSize: MB → bytes 변환 (V1은 bytes, V2는 MB 단위 사용) + // maxSize: MB → bytes 변환 const maxSizeBytes = config.maxSize ? (config.maxSize > 1000 ? config.maxSize : config.maxSize * 1024 * 1024) : 10 * 1024 * 1024; // 기본 10MB + // 레거시 컴포넌트 설정 형태로 변환 + const legacyComponentConfig = { + maxFileCount: config.multiple ? 10 : 1, + maxFileSize: maxSizeBytes, + accept: config.accept || this.getDefaultAccept(mediaType), + docType: config.docType || "DOCUMENT", + docTypeName: config.docTypeName || "일반 문서", + showFileList: config.showFileList ?? true, + dragDrop: config.dragDrop ?? true, + }; + + // 레거시 컴포넌트 형태로 변환 + const legacyComponent = { + ...component, + id: component.id, + columnName: columnName, + tableName: tableName, + componentConfig: legacyComponentConfig, + }; + + // onFormDataChange 래퍼: 레거시 컴포넌트는 객체를 전달하므로 변환 필요 + const handleFormDataChange = (data: any) => { + if (onFormDataChange) { + // 레거시 컴포넌트는 { [columnName]: value } 형태로 전달 + // 부모는 (fieldName, value) 형태를 기대 + Object.entries(data).forEach(([key, value]) => { + // __attachmentsUpdate 같은 메타 데이터는 건너뛰기 + if (!key.startsWith("__")) { + onFormDataChange(key, value); + } + }); + } + }; + return ( - ); } diff --git a/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx b/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx index a3bde9a4..94d0c742 100644 --- a/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx +++ b/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx @@ -475,9 +475,21 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table filterValue = filterValue.join("|"); } + // 🔧 filterType에 따라 operator 설정 + // - "select" 유형: 정확히 일치 (equals) + // - "text" 유형: 부분 일치 (contains) + // - "date", "number": 각각 적절한 처리 + let operator = "contains"; // 기본값 + if (filter.filterType === "select") { + operator = "equals"; // 선택 필터는 정확히 일치 + } else if (filter.filterType === "number") { + operator = "equals"; // 숫자도 정확히 일치 + } + return { ...filter, value: filterValue || "", + operator, // operator 추가 }; }) .filter((f) => { diff --git a/frontend/lib/utils/webTypeMapping.ts b/frontend/lib/utils/webTypeMapping.ts index ed4acba2..ebff5fb8 100644 --- a/frontend/lib/utils/webTypeMapping.ts +++ b/frontend/lib/utils/webTypeMapping.ts @@ -107,18 +107,18 @@ export const WEB_TYPE_V2_MAPPING: Record = { config: { mode: "dropdown", source: "category" }, }, - // 파일/이미지 → V2Media + // 파일/이미지 → 레거시 file-upload (안정적인 파일 업로드) file: { - componentType: "v2-media", - config: { type: "file", multiple: false }, + componentType: "file-upload", + config: { maxFileCount: 10, accept: "*/*" }, }, image: { - componentType: "v2-media", - config: { type: "image", showPreview: true }, + componentType: "file-upload", + config: { maxFileCount: 1, accept: "image/*" }, }, img: { - componentType: "v2-media", - config: { type: "image", showPreview: true }, + componentType: "file-upload", + config: { maxFileCount: 1, accept: "image/*" }, }, // 버튼은 V2 컴포넌트에서 제외 (기존 버튼 시스템 사용) @@ -157,9 +157,9 @@ export const WEB_TYPE_COMPONENT_MAPPING: Record = { code: "v2-select", entity: "v2-select", category: "v2-select", - file: "v2-media", - image: "v2-media", - img: "v2-media", + file: "file-upload", + image: "file-upload", + img: "file-upload", button: "button-primary", label: "v2-input", }; diff --git a/frontend/types/v2-components.ts b/frontend/types/v2-components.ts index d985699d..c0d6ca53 100644 --- a/frontend/types/v2-components.ts +++ b/frontend/types/v2-components.ts @@ -232,13 +232,27 @@ export interface V2MediaConfig { maxSize?: number; preview?: boolean; uploadEndpoint?: string; + // 레거시 FileUpload 호환 설정 + docType?: string; + docTypeName?: string; + showFileList?: boolean; + dragDrop?: boolean; } export interface V2MediaProps extends V2BaseProps { - v2Type: "V2Media"; - config: V2MediaConfig; + v2Type?: "V2Media"; + config?: V2MediaConfig; value?: string | string[]; // 파일 URL 또는 배열 onChange?: (value: string | string[]) => void; + // 레거시 FileUpload 호환 props + formData?: Record; + columnName?: string; + tableName?: string; + // 부모 컴포넌트 시그니처: (fieldName, value) 형식 + onFormDataChange?: (fieldName: string, value: any) => void; + isDesignMode?: boolean; + isInteractive?: boolean; + onUpdate?: (updates: Partial) => void; } // ===== V2List =====