- Added support for custom border, background, and text styles in V2Input and V2Select components, allowing for greater flexibility in styling based on user-defined configurations. - Updated the input and select components to conditionally apply styles based on the presence of custom properties, improving the overall user experience and visual consistency. - Refactored the FileUploadComponent to handle chunked file uploads, enhancing the file upload process by allowing multiple files to be uploaded in batches, improving performance and user feedback during uploads.
1173 lines
44 KiB
TypeScript
1173 lines
44 KiB
TypeScript
import React, { useState, useRef, useCallback, useEffect } from "react";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { toast } from "sonner";
|
|
import { uploadFiles, downloadFile, deleteFile, getComponentFiles, getFileInfoByObjid, getFilePreviewUrl } from "@/lib/api/file";
|
|
import { GlobalFileManager } from "@/lib/api/globalFile";
|
|
import { formatFileSize, cn } from "@/lib/utils";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { FileViewerModal } from "./FileViewerModal";
|
|
import { FileManagerModal } from "./FileManagerModal";
|
|
import { FileUploadConfig, FileInfo, FileUploadStatus, FileUploadResponse } from "./types";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import {
|
|
Upload,
|
|
File,
|
|
FileText,
|
|
Image,
|
|
Video,
|
|
Music,
|
|
Archive,
|
|
Download,
|
|
Eye,
|
|
Trash2,
|
|
AlertCircle,
|
|
FileImage,
|
|
FileVideo,
|
|
FileAudio,
|
|
Presentation,
|
|
} from "lucide-react";
|
|
|
|
// 파일 아이콘 매핑
|
|
const getFileIcon = (extension: string) => {
|
|
const ext = extension.toLowerCase().replace(".", "");
|
|
|
|
if (["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(ext)) {
|
|
return <FileImage className="h-6 w-6 text-blue-500" />;
|
|
}
|
|
if (["mp4", "avi", "mov", "wmv", "flv", "webm"].includes(ext)) {
|
|
return <FileVideo className="h-6 w-6 text-purple-500" />;
|
|
}
|
|
if (["mp3", "wav", "flac", "aac", "ogg"].includes(ext)) {
|
|
return <FileAudio className="h-6 w-6 text-green-500" />;
|
|
}
|
|
if (["pdf"].includes(ext)) {
|
|
return <FileText className="h-6 w-6 text-red-500" />;
|
|
}
|
|
if (["doc", "docx", "hwp", "hwpx", "pages"].includes(ext)) {
|
|
return <FileText className="h-6 w-6 text-blue-600" />;
|
|
}
|
|
if (["xls", "xlsx", "hcell", "numbers"].includes(ext)) {
|
|
return <FileText className="h-6 w-6 text-green-600" />;
|
|
}
|
|
if (["ppt", "pptx", "hanshow", "keynote"].includes(ext)) {
|
|
return <Presentation className="h-6 w-6 text-orange-500" />;
|
|
}
|
|
if (["zip", "rar", "7z", "tar", "gz"].includes(ext)) {
|
|
return <Archive className="h-6 w-6 text-gray-500" />;
|
|
}
|
|
|
|
return <File className="h-6 w-6 text-gray-400" />;
|
|
};
|
|
|
|
export interface FileUploadComponentProps {
|
|
component: any;
|
|
componentConfig: FileUploadConfig;
|
|
componentStyle: React.CSSProperties;
|
|
className: string;
|
|
isInteractive: boolean;
|
|
isDesignMode: boolean;
|
|
formData: any;
|
|
onFormDataChange: (fieldName: string, value: any) => void;
|
|
onClick?: () => void;
|
|
onDragStart?: (e: React.DragEvent) => void;
|
|
onDragEnd?: (e: React.DragEvent) => void;
|
|
onUpdate?: (updates: Partial<any>) => void;
|
|
autoGeneration?: any;
|
|
hidden?: boolean;
|
|
onConfigChange?: (config: any) => void;
|
|
}
|
|
|
|
const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|
component,
|
|
componentConfig,
|
|
componentStyle,
|
|
className,
|
|
isInteractive,
|
|
isDesignMode = false, // 기본값 설정
|
|
formData,
|
|
onFormDataChange,
|
|
onClick,
|
|
onDragStart,
|
|
onDragEnd,
|
|
onUpdate,
|
|
}) => {
|
|
// 🔑 인증 정보 가져오기
|
|
const { user } = useAuth();
|
|
|
|
const [uploadedFiles, setUploadedFiles] = useState<FileInfo[]>([]);
|
|
const [uploadStatus, setUploadStatus] = useState<FileUploadStatus>("idle");
|
|
const [dragOver, setDragOver] = useState(false);
|
|
const [viewerFile, setViewerFile] = useState<FileInfo | null>(null);
|
|
const [isViewerOpen, setIsViewerOpen] = useState(false);
|
|
const [isFileManagerOpen, setIsFileManagerOpen] = useState(false);
|
|
const [forceUpdate, setForceUpdate] = useState(0);
|
|
const [representativeImageUrl, setRepresentativeImageUrl] = useState<string | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// 🔑 레코드 모드 판단: formData에 id가 있으면 특정 행에 연결된 파일 관리
|
|
const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_'));
|
|
const recordTableName = formData?.tableName || component.tableName;
|
|
const recordId = formData?.id;
|
|
// 🔑 컬럼명 결정: component.columnName을 우선 사용 (실제 DB 컬럼명)
|
|
// image, file 등의 웹타입 컬럼에 URL이 저장되어야 함
|
|
const columnName = component.columnName || component.id || 'attachments';
|
|
|
|
// 🔑 레코드 모드용 targetObjid 생성
|
|
const getRecordTargetObjid = useCallback(() => {
|
|
if (isRecordMode && recordTableName && recordId) {
|
|
return `${recordTableName}:${recordId}:${columnName}`;
|
|
}
|
|
return null;
|
|
}, [isRecordMode, recordTableName, recordId, columnName]);
|
|
|
|
// 🔑 레코드별 고유 키 생성 (localStorage, 전역 상태용)
|
|
// 🆕 columnName을 포함하여 같은 화면의 여러 파일 업로드 컴포넌트 구분
|
|
const getUniqueKey = useCallback(() => {
|
|
if (isRecordMode && recordTableName && recordId) {
|
|
// 레코드 모드: 테이블명:레코드ID:컴포넌트ID:컬럼명 형태로 고유 키 생성
|
|
return `fileUpload_${recordTableName}_${recordId}_${component.id}_${columnName}`;
|
|
}
|
|
// 기본 모드: 컴포넌트 ID + 컬럼명 사용
|
|
return `fileUpload_${component.id}_${columnName}`;
|
|
}, [isRecordMode, recordTableName, recordId, component.id, columnName]);
|
|
|
|
|
|
// 🆕 레코드 ID 변경 시 파일 목록 초기화 및 새로 로드
|
|
const prevRecordIdRef = useRef<any>(null);
|
|
const prevIsRecordModeRef = useRef<boolean | null>(null);
|
|
useEffect(() => {
|
|
const recordIdChanged = prevRecordIdRef.current !== recordId;
|
|
const modeChanged = prevIsRecordModeRef.current !== null && prevIsRecordModeRef.current !== isRecordMode;
|
|
|
|
if (recordIdChanged || modeChanged) {
|
|
prevRecordIdRef.current = recordId;
|
|
prevIsRecordModeRef.current = isRecordMode;
|
|
|
|
// 레코드 ID가 변경되거나 등록 모드(isRecordMode=false)로 전환되면 파일 목록 초기화
|
|
// 등록 모드에서는 항상 빈 상태로 시작해야 함
|
|
if (isRecordMode || !recordId) {
|
|
setUploadedFiles([]);
|
|
setRepresentativeImageUrl(null);
|
|
}
|
|
} else if (prevIsRecordModeRef.current === null) {
|
|
// 초기 마운트 시 모드 저장
|
|
prevIsRecordModeRef.current = isRecordMode;
|
|
}
|
|
}, [recordId, isRecordMode]);
|
|
|
|
// 컴포넌트 마운트 시 즉시 localStorage에서 파일 복원
|
|
// 🔑 등록 모드(recordId가 없는 경우)에서는 파일을 복원하지 않음 - 항상 빈 상태로 시작
|
|
useEffect(() => {
|
|
if (!component?.id) return;
|
|
|
|
// 등록 모드(새 레코드)인 경우 파일 복원 스킵 - 빈 상태 유지
|
|
if (!isRecordMode || !recordId) {
|
|
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);
|
|
}
|
|
}, [component.id, getUniqueKey, recordId, isRecordMode]); // 레코드별 고유 키 변경 시 재실행
|
|
|
|
// 🔑 수정 모드: formData[columnName]에 저장된 objid로 이미지 로드
|
|
// 🆕 formData 전체가 아닌 특정 컬럼 값만 의존하도록 수정 (다른 컴포넌트 영향 방지)
|
|
const imageObjidFromFormData = formData?.[columnName];
|
|
|
|
useEffect(() => {
|
|
// 이미지 objid가 있고, 숫자 문자열인 경우에만 처리
|
|
if (imageObjidFromFormData && /^\d+$/.test(String(imageObjidFromFormData))) {
|
|
const objidStr = String(imageObjidFromFormData);
|
|
|
|
// 이미 같은 objid의 파일이 로드되어 있으면 스킵
|
|
const alreadyLoaded = uploadedFiles.some(f => String(f.objid) === objidStr);
|
|
if (alreadyLoaded) {
|
|
return;
|
|
}
|
|
|
|
// 🔑 실제 파일 정보 조회 (previewUrl 제거 - apiClient blob 다운로드 방식으로 통일)
|
|
(async () => {
|
|
try {
|
|
const fileInfoResponse = await getFileInfoByObjid(objidStr);
|
|
|
|
if (fileInfoResponse.success && fileInfoResponse.data) {
|
|
const { realFileName, fileSize, fileExt, regdate, isRepresentative } = fileInfoResponse.data;
|
|
|
|
const fileInfo = {
|
|
objid: objidStr,
|
|
realFileName: realFileName,
|
|
fileExt: fileExt,
|
|
fileSize: fileSize,
|
|
filePath: getFilePreviewUrl(objidStr),
|
|
regdate: regdate,
|
|
isImage: true,
|
|
isRepresentative: isRepresentative,
|
|
};
|
|
|
|
setUploadedFiles([fileInfo]);
|
|
// representativeImageUrl은 loadRepresentativeImage에서 blob으로 로드됨
|
|
} else {
|
|
// 파일 정보 조회 실패 시 최소 정보로 추가
|
|
console.warn("🖼️ [FileUploadComponent] 파일 정보 조회 실패, 최소 정보 사용");
|
|
const minimalFileInfo = {
|
|
objid: objidStr,
|
|
realFileName: `image_${objidStr}.jpg`,
|
|
fileExt: '.jpg',
|
|
fileSize: 0,
|
|
filePath: getFilePreviewUrl(objidStr),
|
|
regdate: new Date().toISOString(),
|
|
isImage: true,
|
|
};
|
|
|
|
setUploadedFiles([minimalFileInfo]);
|
|
// representativeImageUrl은 loadRepresentativeImage에서 blob으로 로드됨
|
|
}
|
|
} catch (error) {
|
|
console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error);
|
|
}
|
|
})();
|
|
}
|
|
}, [imageObjidFromFormData, columnName, component.id]); // 🆕 formData 대신 특정 컬럼 값만 의존
|
|
|
|
// 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너
|
|
// 🆕 columnName도 체크하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분
|
|
useEffect(() => {
|
|
const handleDesignModeFileChange = (event: CustomEvent) => {
|
|
const eventColumnName = event.detail.eventColumnName || event.detail.columnName;
|
|
|
|
// 🆕 고유 키 또는 (컴포넌트ID + 컬럼명) 조합으로 체크
|
|
const isForThisComponent =
|
|
(event.detail.uniqueKey && event.detail.uniqueKey === currentUniqueKey) ||
|
|
(event.detail.componentId === component.id && eventColumnName === columnName) ||
|
|
(event.detail.componentId === component.id && !eventColumnName); // 이전 호환성
|
|
|
|
// 🆕 현재 컴포넌트와 일치하고 화면설계 모드에서 온 이벤트인 경우
|
|
if (isForThisComponent && event.detail.source === "designMode") {
|
|
// 파일 상태 업데이트
|
|
const newFiles = event.detail.files || [];
|
|
setUploadedFiles(newFiles);
|
|
|
|
// localStorage 백업 업데이트 (레코드별 고유 키 사용)
|
|
try {
|
|
const backupKey = currentUniqueKey;
|
|
localStorage.setItem(backupKey, JSON.stringify(newFiles));
|
|
} catch (e) {
|
|
console.warn("localStorage 백업 업데이트 실패:", e);
|
|
}
|
|
|
|
// 전역 상태 업데이트 (🆕 고유 키 사용)
|
|
if (typeof window !== "undefined") {
|
|
(window as any).globalFileState = {
|
|
...(window as any).globalFileState,
|
|
[currentUniqueKey]: newFiles,
|
|
};
|
|
}
|
|
|
|
// onUpdate 콜백 호출 (부모 컴포넌트에 알림)
|
|
if (onUpdate) {
|
|
onUpdate({
|
|
uploadedFiles: newFiles,
|
|
lastFileUpdate: event.detail.timestamp,
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
if (typeof window !== "undefined") {
|
|
window.addEventListener("globalFileStateChanged", handleDesignModeFileChange as EventListener);
|
|
|
|
return () => {
|
|
window.removeEventListener("globalFileStateChanged", handleDesignModeFileChange as EventListener);
|
|
};
|
|
}
|
|
}, [component.id, onUpdate]);
|
|
|
|
// 템플릿 파일과 데이터 파일을 조회하는 함수
|
|
const loadComponentFiles = useCallback(async () => {
|
|
if (!component?.id) return false;
|
|
|
|
// 🔑 등록 모드(새 레코드)인 경우 파일 조회 스킵 - 빈 상태 유지
|
|
if (!isRecordMode || !recordId) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
// 1. formData에서 screenId 가져오기
|
|
let screenId = formData?.screenId;
|
|
|
|
// 2. URL에서 screenId 추출 (/screens/:id 패턴)
|
|
if (!screenId && typeof window !== "undefined") {
|
|
const pathname = window.location.pathname;
|
|
const screenMatch = pathname.match(/\/screens\/(\d+)/);
|
|
if (screenMatch) {
|
|
screenId = parseInt(screenMatch[1]);
|
|
}
|
|
}
|
|
|
|
// 3. 디자인 모드인 경우 임시 화면 ID 사용
|
|
if (!screenId && isDesignMode) {
|
|
screenId = 999999; // 디자인 모드 임시 ID
|
|
}
|
|
|
|
// 4. 화면 ID가 없으면 컴포넌트 ID만으로 조회 시도
|
|
if (!screenId) {
|
|
console.warn("⚠️ 화면 ID 없음, 컴포넌트 ID만으로 파일 조회:", {
|
|
componentId: component.id,
|
|
pathname: window.location.pathname,
|
|
formData: formData,
|
|
});
|
|
// screenId를 0으로 설정하여 컴포넌트 ID로만 조회
|
|
screenId = 0;
|
|
}
|
|
|
|
const params = {
|
|
screenId,
|
|
componentId: component.id,
|
|
tableName: recordTableName || formData?.tableName || component.tableName,
|
|
recordId: recordId || formData?.id,
|
|
columnName: columnName, // 🔑 레코드 모드에서 사용하는 columnName
|
|
};
|
|
|
|
const response = await getComponentFiles(params);
|
|
|
|
if (response.success) {
|
|
|
|
// 파일 데이터 형식 통일
|
|
const formattedFiles = response.totalFiles.map((file: any) => ({
|
|
objid: file.objid || file.id,
|
|
savedFileName: file.savedFileName || file.saved_file_name,
|
|
realFileName: file.realFileName || file.real_file_name,
|
|
fileSize: file.fileSize || file.file_size,
|
|
fileExt: file.fileExt || file.file_ext,
|
|
regdate: file.regdate,
|
|
status: file.status || "ACTIVE",
|
|
uploadedAt: file.uploadedAt || new Date().toISOString(),
|
|
...file,
|
|
}));
|
|
|
|
|
|
// 🔄 localStorage의 기존 파일과 서버 파일 병합 (레코드별 고유 키 사용)
|
|
let finalFiles = formattedFiles;
|
|
const uniqueKey = getUniqueKey();
|
|
try {
|
|
const backupFiles = localStorage.getItem(uniqueKey);
|
|
if (backupFiles) {
|
|
const parsedBackupFiles = JSON.parse(backupFiles);
|
|
|
|
// 서버에 없는 localStorage 파일들을 추가 (objid 기준으로 중복 제거)
|
|
const serverObjIds = new Set(formattedFiles.map((f: any) => f.objid));
|
|
const additionalFiles = parsedBackupFiles.filter((f: any) => !serverObjIds.has(f.objid));
|
|
|
|
finalFiles = [...formattedFiles, ...additionalFiles];
|
|
}
|
|
} catch (e) {
|
|
console.warn("파일 병합 중 오류:", e);
|
|
}
|
|
|
|
setUploadedFiles(finalFiles);
|
|
|
|
// 전역 상태에도 저장 (레코드별 고유 키 사용)
|
|
if (typeof window !== "undefined") {
|
|
(window as any).globalFileState = {
|
|
...(window as any).globalFileState,
|
|
[uniqueKey]: finalFiles,
|
|
};
|
|
|
|
// 🌐 전역 파일 저장소에 등록 (페이지 간 공유용)
|
|
GlobalFileManager.registerFiles(finalFiles, {
|
|
uploadPage: window.location.pathname,
|
|
componentId: component.id,
|
|
screenId: formData?.screenId,
|
|
recordId: recordId,
|
|
});
|
|
|
|
// localStorage 백업도 병합된 파일로 업데이트 (레코드별 고유 키 사용)
|
|
try {
|
|
localStorage.setItem(uniqueKey, JSON.stringify(finalFiles));
|
|
} catch (e) {
|
|
console.warn("localStorage 백업 업데이트 실패:", e);
|
|
}
|
|
}
|
|
return true; // 새로운 로직 사용됨
|
|
}
|
|
} catch (error) {
|
|
console.error("파일 조회 오류:", error);
|
|
}
|
|
return false; // 기존 로직 사용
|
|
}, [component.id, component.tableName, component.columnName, formData?.screenId, formData?.tableName, formData?.id, getUniqueKey, recordId, isRecordMode, recordTableName, columnName]);
|
|
|
|
// 컴포넌트 파일 동기화 (DB 우선, localStorage는 보조)
|
|
useEffect(() => {
|
|
const componentFiles = (component as any)?.uploadedFiles || [];
|
|
const lastUpdate = (component as any)?.lastFileUpdate;
|
|
|
|
// 🔒 항상 DB에서 최신 파일 목록을 조회 (멀티테넌시 격리)
|
|
loadComponentFiles().then((dbLoadSuccess) => {
|
|
if (dbLoadSuccess) {
|
|
return; // DB 로드 성공 시 localStorage 무시
|
|
}
|
|
|
|
// 🆕 등록 모드(새 레코드)인 경우 fallback 로드도 스킵 - 항상 빈 상태 유지
|
|
if (!isRecordMode || !recordId) {
|
|
return;
|
|
}
|
|
|
|
// DB 로드 실패 시에만 기존 로직 사용 (하위 호환성)
|
|
|
|
// 전역 상태에서 최신 파일 정보 가져오기 (🆕 고유 키 사용)
|
|
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
|
|
const uniqueKeyForFallback = getUniqueKey();
|
|
const globalFiles = globalFileState[uniqueKeyForFallback] || globalFileState[component.id] || [];
|
|
|
|
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
|
|
const currentFiles = globalFiles.length > 0 ? globalFiles : componentFiles;
|
|
|
|
|
|
// 최신 파일과 현재 파일 비교
|
|
if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) {
|
|
setUploadedFiles(currentFiles);
|
|
setForceUpdate((prev) => prev + 1);
|
|
}
|
|
});
|
|
}, [loadComponentFiles, component.id, (component as any)?.uploadedFiles, (component as any)?.lastFileUpdate]);
|
|
|
|
// 전역 상태 변경 감지 (모든 파일 컴포넌트 동기화 + 화면 복원)
|
|
// 🆕 columnName을 포함한 고유 키로 구분하여 다른 파일 업로드 컴포넌트에 영향 방지
|
|
const currentUniqueKey = getUniqueKey();
|
|
|
|
useEffect(() => {
|
|
const handleGlobalFileStateChange = (event: CustomEvent) => {
|
|
const { componentId, files, fileCount, timestamp, isRestore, uniqueKey: eventUniqueKey, eventColumnName } = event.detail;
|
|
|
|
// 🆕 고유 키 또는 (컴포넌트ID + 컬럼명) 조합으로 체크
|
|
const isForThisComponent =
|
|
(eventUniqueKey && eventUniqueKey === currentUniqueKey) ||
|
|
(componentId === component.id && eventColumnName === columnName);
|
|
|
|
// 🆕 같은 고유 키인 경우에만 업데이트 (componentId + columnName 조합)
|
|
if (isForThisComponent) {
|
|
|
|
setUploadedFiles(files);
|
|
setForceUpdate((prev) => prev + 1);
|
|
|
|
// localStorage 백업도 업데이트 (레코드별 고유 키 사용)
|
|
try {
|
|
const backupKey = currentUniqueKey;
|
|
localStorage.setItem(backupKey, JSON.stringify(files));
|
|
} catch (e) {
|
|
console.warn("localStorage 백업 실패:", e);
|
|
}
|
|
}
|
|
};
|
|
|
|
if (typeof window !== "undefined") {
|
|
window.addEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener);
|
|
|
|
return () => {
|
|
window.removeEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener);
|
|
};
|
|
}
|
|
}, [component.id, columnName, currentUniqueKey, uploadedFiles.length]);
|
|
|
|
// 파일 업로드 설정 - componentConfig가 undefined일 수 있으므로 안전하게 처리
|
|
const safeComponentConfig = componentConfig || {};
|
|
const fileConfig = {
|
|
...safeComponentConfig,
|
|
accept: safeComponentConfig.accept || "*/*",
|
|
multiple: safeComponentConfig.multiple || false,
|
|
maxSize: safeComponentConfig.maxSize || 10 * 1024 * 1024, // 10MB
|
|
maxFiles: safeComponentConfig.maxFiles || 5,
|
|
} as FileUploadConfig;
|
|
|
|
// 파일 선택 핸들러
|
|
const handleFileSelect = useCallback(() => {
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.click();
|
|
}
|
|
}, []);
|
|
|
|
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = Array.from(e.target.files || []);
|
|
if (files.length > 0) {
|
|
handleFileUpload(files);
|
|
}
|
|
}, []);
|
|
|
|
// 백엔드 multer 제한에 맞춘 1회 요청당 최대 파일 수
|
|
const CHUNK_SIZE = 10;
|
|
|
|
// 파일 업로드 처리 (10개 초과 시 자동 분할 업로드)
|
|
const handleFileUpload = useCallback(
|
|
async (files: File[]) => {
|
|
if (!files.length) return;
|
|
|
|
// 중복 파일 체크
|
|
const existingFileNames = uploadedFiles.map((f) => f.realFileName.toLowerCase());
|
|
const duplicates: string[] = [];
|
|
const uniqueFiles: File[] = [];
|
|
|
|
files.forEach((file) => {
|
|
const fileName = file.name.toLowerCase();
|
|
if (existingFileNames.includes(fileName)) {
|
|
duplicates.push(file.name);
|
|
} else {
|
|
uniqueFiles.push(file);
|
|
}
|
|
});
|
|
|
|
if (duplicates.length > 0) {
|
|
toast.error(`중복된 파일이 있습니다: ${duplicates.join(", ")}`, {
|
|
description: "같은 이름의 파일이 이미 업로드되어 있습니다.",
|
|
duration: 4000,
|
|
});
|
|
|
|
if (uniqueFiles.length === 0) {
|
|
return; // 모든 파일이 중복이면 업로드 중단
|
|
}
|
|
|
|
// 일부만 중복인 경우 고유한 파일만 업로드
|
|
toast.info(`${uniqueFiles.length}개의 새로운 파일만 업로드합니다.`);
|
|
}
|
|
|
|
const filesToUpload = uniqueFiles.length > 0 ? uniqueFiles : files;
|
|
setUploadStatus("uploading");
|
|
|
|
// 분할 업로드 여부 판단
|
|
const totalFiles = filesToUpload.length;
|
|
const totalChunks = Math.ceil(totalFiles / CHUNK_SIZE);
|
|
const isChunked = totalChunks > 1;
|
|
|
|
if (isChunked) {
|
|
toast.loading(`파일 업로드 준비 중... (총 ${totalFiles}개, ${totalChunks}회 분할)`, { id: "file-upload" });
|
|
} else {
|
|
toast.loading("파일을 업로드하는 중...", { id: "file-upload" });
|
|
}
|
|
|
|
try {
|
|
// 🔑 레코드 모드 우선 사용
|
|
const effectiveTableName = recordTableName || formData?.tableName || component.tableName || "default_table";
|
|
const effectiveRecordId = recordId || formData?.id;
|
|
const effectiveColumnName = columnName;
|
|
|
|
// screenId 추출 (우선순위: formData > URL)
|
|
let screenId = formData?.screenId;
|
|
if (!screenId && typeof window !== "undefined") {
|
|
const pathname = window.location.pathname;
|
|
const screenMatch = pathname.match(/\/screens\/(\d+)/);
|
|
if (screenMatch) {
|
|
screenId = parseInt(screenMatch[1]);
|
|
}
|
|
}
|
|
|
|
let targetObjid;
|
|
// 🔑 레코드 모드 판단 개선
|
|
const effectiveIsRecordMode = isRecordMode || (effectiveRecordId && !String(effectiveRecordId).startsWith('temp_'));
|
|
|
|
if (effectiveIsRecordMode && effectiveTableName && effectiveRecordId) {
|
|
// 🎯 레코드 모드: 특정 행에 파일 연결
|
|
targetObjid = `${effectiveTableName}:${effectiveRecordId}:${effectiveColumnName}`;
|
|
} else if (screenId) {
|
|
// 🔑 템플릿 파일 (백엔드 조회 형식과 동일하게)
|
|
targetObjid = `screen_files:${screenId}:${component.id}:${effectiveColumnName}`;
|
|
} else {
|
|
// 기본값 (화면관리에서 사용)
|
|
targetObjid = `temp_${component.id}`;
|
|
}
|
|
|
|
// 🔒 현재 사용자의 회사 코드 가져오기 (멀티테넌시 격리)
|
|
const userCompanyCode = user?.companyCode || (window as any).__user__?.companyCode;
|
|
|
|
// 🔑 레코드 모드일 때는 effectiveTableName을 우선 사용
|
|
const finalLinkedTable = effectiveIsRecordMode
|
|
? effectiveTableName
|
|
: (formData?.linkedTable || effectiveTableName);
|
|
|
|
const uploadData = {
|
|
autoLink: formData?.autoLink || true,
|
|
linkedTable: finalLinkedTable,
|
|
recordId: effectiveRecordId || `temp_${component.id}`,
|
|
columnName: effectiveColumnName,
|
|
isVirtualFileColumn: formData?.isVirtualFileColumn || true,
|
|
docType: component.fileConfig?.docType || "DOCUMENT",
|
|
docTypeName: component.fileConfig?.docTypeName || "일반 문서",
|
|
companyCode: userCompanyCode,
|
|
tableName: effectiveTableName,
|
|
fieldName: effectiveColumnName,
|
|
targetObjid: targetObjid,
|
|
isRecordMode: effectiveIsRecordMode,
|
|
};
|
|
|
|
// 🔄 파일을 CHUNK_SIZE(10개)씩 나눠서 순차 업로드
|
|
const allNewFiles: any[] = [];
|
|
let failedChunks = 0;
|
|
|
|
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
|
const start = chunkIndex * CHUNK_SIZE;
|
|
const end = Math.min(start + CHUNK_SIZE, totalFiles);
|
|
const chunk = filesToUpload.slice(start, end);
|
|
|
|
// 분할 업로드 시 진행 상태 토스트 업데이트
|
|
if (isChunked) {
|
|
toast.loading(
|
|
`업로드 중... ${chunkIndex + 1}/${totalChunks} 배치 (${start + 1}~${end}번째 파일)`,
|
|
{ id: "file-upload" }
|
|
);
|
|
}
|
|
|
|
try {
|
|
const response = await uploadFiles({
|
|
files: chunk,
|
|
...uploadData,
|
|
});
|
|
|
|
if (response.success) {
|
|
const fileData = response.files || (response as any).data || [];
|
|
const chunkFiles = 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,
|
|
}));
|
|
allNewFiles.push(...chunkFiles);
|
|
} else {
|
|
console.error(`❌ ${chunkIndex + 1}번째 배치 업로드 실패:`, response);
|
|
failedChunks++;
|
|
}
|
|
} catch (chunkError) {
|
|
console.error(`❌ ${chunkIndex + 1}번째 배치 업로드 오류:`, chunkError);
|
|
failedChunks++;
|
|
}
|
|
}
|
|
|
|
// 모든 배치 처리 완료 후 결과 처리
|
|
if (allNewFiles.length === 0) {
|
|
throw new Error("업로드된 파일 데이터를 받지 못했습니다.");
|
|
}
|
|
|
|
const updatedFiles = [...uploadedFiles, ...allNewFiles];
|
|
|
|
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(allNewFiles, {
|
|
uploadPage: window.location.pathname,
|
|
componentId: component.id,
|
|
screenId: formData?.screenId,
|
|
recordId: recordId,
|
|
});
|
|
|
|
const syncEvent = new CustomEvent("globalFileStateChanged", {
|
|
detail: {
|
|
componentId: component.id,
|
|
eventColumnName: columnName,
|
|
uniqueKey: uniqueKey,
|
|
recordId: recordId,
|
|
files: updatedFiles,
|
|
fileCount: updatedFiles.length,
|
|
timestamp: Date.now(),
|
|
},
|
|
});
|
|
window.dispatchEvent(syncEvent);
|
|
}
|
|
|
|
// 컴포넌트 업데이트
|
|
if (onUpdate) {
|
|
const timestamp = Date.now();
|
|
onUpdate({
|
|
uploadedFiles: updatedFiles,
|
|
lastFileUpdate: timestamp,
|
|
});
|
|
} else {
|
|
console.warn("⚠️ onUpdate 콜백이 없습니다!");
|
|
}
|
|
|
|
// 이미지/파일 컬럼에 objid 저장 (formData 업데이트)
|
|
if (onFormDataChange && effectiveColumnName) {
|
|
const fileObjids = updatedFiles.map(file => file.objid);
|
|
const columnValue = fileConfig.multiple
|
|
? fileObjids.join(',')
|
|
: (fileObjids[0] || '');
|
|
onFormDataChange(effectiveColumnName, columnValue);
|
|
}
|
|
|
|
// 그리드 파일 상태 새로고침 이벤트 발생
|
|
if (typeof window !== "undefined") {
|
|
const refreshEvent = new CustomEvent("refreshFileStatus", {
|
|
detail: {
|
|
tableName: effectiveTableName,
|
|
recordId: effectiveRecordId,
|
|
columnName: effectiveColumnName,
|
|
targetObjid: targetObjid,
|
|
fileCount: updatedFiles.length,
|
|
},
|
|
});
|
|
window.dispatchEvent(refreshEvent);
|
|
}
|
|
|
|
// 컴포넌트 설정 콜백
|
|
if (safeComponentConfig.onFileUpload) {
|
|
safeComponentConfig.onFileUpload(allNewFiles);
|
|
}
|
|
|
|
// 성공/부분 성공 토스트 처리
|
|
setUploadStatus("idle");
|
|
toast.dismiss("file-upload");
|
|
|
|
if (failedChunks > 0) {
|
|
toast.warning(
|
|
`${allNewFiles.length}개 업로드 완료, 일부 파일 실패`,
|
|
{ description: "일부 파일이 업로드되지 않았습니다. 다시 시도해주세요." }
|
|
);
|
|
} else {
|
|
toast.success(`${allNewFiles.length}개 파일 업로드 완료`);
|
|
}
|
|
} catch (error) {
|
|
console.error("파일 업로드 오류:", error);
|
|
setUploadStatus("error");
|
|
toast.dismiss("file-upload");
|
|
toast.error(`파일 업로드 오류: ${error instanceof Error ? error.message : "알 수 없는 오류"}`);
|
|
}
|
|
},
|
|
[safeComponentConfig, uploadedFiles, onFormDataChange, component.columnName, component.id, formData],
|
|
);
|
|
|
|
// 파일 뷰어 열기
|
|
const handleFileView = useCallback((file: FileInfo) => {
|
|
setViewerFile(file);
|
|
setIsViewerOpen(true);
|
|
}, []);
|
|
|
|
// 파일 뷰어 닫기
|
|
const handleViewerClose = useCallback(() => {
|
|
setIsViewerOpen(false);
|
|
setViewerFile(null);
|
|
}, []);
|
|
|
|
// 파일 다운로드
|
|
const handleFileDownload = useCallback(async (file: FileInfo) => {
|
|
try {
|
|
await downloadFile({
|
|
fileId: file.objid,
|
|
serverFilename: file.savedFileName,
|
|
originalName: file.realFileName,
|
|
});
|
|
toast.success(`${file.realFileName} 다운로드 완료`);
|
|
} catch (error) {
|
|
console.error("파일 다운로드 오류:", error);
|
|
toast.error("파일 다운로드에 실패했습니다.");
|
|
}
|
|
}, []);
|
|
|
|
// 파일 삭제
|
|
const handleFileDelete = useCallback(
|
|
async (file: FileInfo | string) => {
|
|
try {
|
|
const fileId = typeof file === "string" ? file : file.objid;
|
|
const fileName = typeof file === "string" ? "파일" : file.realFileName;
|
|
|
|
const serverFilename = typeof file === "string" ? "temp_file" : file.savedFileName;
|
|
await deleteFile(fileId, serverFilename);
|
|
|
|
const updatedFiles = uploadedFiles.filter((f) => f.objid !== fileId);
|
|
setUploadedFiles(updatedFiles);
|
|
|
|
// localStorage 백업 업데이트 (레코드별 고유 키 사용)
|
|
try {
|
|
const backupKey = getUniqueKey();
|
|
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
|
|
} catch (e) {
|
|
console.warn("localStorage 백업 업데이트 실패:", e);
|
|
}
|
|
|
|
// 전역 상태 업데이트 (모든 파일 컴포넌트 동기화)
|
|
if (typeof window !== "undefined") {
|
|
// 전역 파일 상태 업데이트 (레코드별 고유 키 사용)
|
|
const globalFileState = (window as any).globalFileState || {};
|
|
const uniqueKey = getUniqueKey();
|
|
globalFileState[uniqueKey] = updatedFiles;
|
|
(window as any).globalFileState = globalFileState;
|
|
|
|
// 모든 파일 컴포넌트에 동기화 이벤트 발생
|
|
// 🆕 columnName 추가하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분
|
|
const syncEvent = new CustomEvent("globalFileStateChanged", {
|
|
detail: {
|
|
componentId: component.id,
|
|
eventColumnName: columnName, // 🆕 컬럼명 추가
|
|
uniqueKey: uniqueKey, // 🆕 고유 키 추가
|
|
recordId: recordId, // 🆕 레코드 ID 추가
|
|
files: updatedFiles,
|
|
fileCount: updatedFiles.length,
|
|
timestamp: Date.now(),
|
|
source: "realScreen", // 🎯 실제 화면에서 온 이벤트임을 표시
|
|
action: "delete",
|
|
},
|
|
});
|
|
window.dispatchEvent(syncEvent);
|
|
}
|
|
|
|
// 컴포넌트 업데이트
|
|
if (onUpdate) {
|
|
const timestamp = Date.now();
|
|
onUpdate({
|
|
uploadedFiles: updatedFiles,
|
|
lastFileUpdate: timestamp,
|
|
});
|
|
}
|
|
|
|
// 🆕 파일 삭제 후 컬럼 데이터 동기화
|
|
if (onFormDataChange && columnName) {
|
|
// 🎯 삭제 후 남은 파일들의 objid로 컬럼 값 업데이트
|
|
const fileObjids = updatedFiles.map(f => f.objid);
|
|
const columnValue = fileConfig.multiple
|
|
? fileObjids.join(',')
|
|
: (fileObjids[0] || '');
|
|
|
|
// onFormDataChange를 (fieldName, value) 형태로 호출 (SaveModal 호환)
|
|
onFormDataChange(columnName, columnValue);
|
|
}
|
|
|
|
toast.success(`${fileName} 삭제 완료`);
|
|
} catch (error) {
|
|
console.error("파일 삭제 오류:", error);
|
|
toast.error("파일 삭제에 실패했습니다.");
|
|
}
|
|
},
|
|
[uploadedFiles, onUpdate, component.id, isRecordMode, onFormDataChange, recordTableName, recordId, columnName, getUniqueKey],
|
|
);
|
|
|
|
// 대표 이미지 Blob URL 로드
|
|
const loadRepresentativeImage = useCallback(
|
|
async (file: FileInfo) => {
|
|
try {
|
|
const isImage = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(
|
|
file.fileExt.toLowerCase().replace(".", "")
|
|
);
|
|
|
|
if (!isImage) {
|
|
setRepresentativeImageUrl(null);
|
|
return;
|
|
}
|
|
|
|
// objid가 없거나 유효하지 않으면 로드 중단
|
|
if (!file.objid || file.objid === "0" || file.objid === "") {
|
|
console.warn("⚠️ 대표 이미지 로드 실패: objid가 없음", file);
|
|
setRepresentativeImageUrl(null);
|
|
return;
|
|
}
|
|
|
|
// API 클라이언트를 통해 Blob으로 다운로드 (인증 토큰 포함)
|
|
// 🔑 previewUrl 상대 경로 대신 apiClient를 사용하여 Docker 환경에서도 정상 동작
|
|
const response = await apiClient.get(`/files/preview/${file.objid}`, {
|
|
params: {
|
|
serverFilename: file.savedFileName,
|
|
},
|
|
responseType: "blob",
|
|
});
|
|
|
|
// Blob URL 생성
|
|
const blob = new Blob([response.data]);
|
|
const url = window.URL.createObjectURL(blob);
|
|
|
|
// 이전 URL 정리
|
|
if (representativeImageUrl) {
|
|
window.URL.revokeObjectURL(representativeImageUrl);
|
|
}
|
|
|
|
setRepresentativeImageUrl(url);
|
|
} catch (error: any) {
|
|
console.error("❌ 대표 이미지 로드 실패:", {
|
|
file: file.realFileName,
|
|
objid: file.objid,
|
|
error: error?.response?.status || error?.message,
|
|
});
|
|
setRepresentativeImageUrl(null);
|
|
}
|
|
},
|
|
[representativeImageUrl],
|
|
);
|
|
|
|
// 대표 이미지 설정 핸들러
|
|
const handleSetRepresentative = useCallback(
|
|
async (file: FileInfo) => {
|
|
try {
|
|
// API 호출하여 DB에 대표 파일 설정
|
|
const { setRepresentativeFile } = await import("@/lib/api/file");
|
|
await setRepresentativeFile(file.objid);
|
|
|
|
// 상태 업데이트
|
|
const updatedFiles = uploadedFiles.map((f) => ({
|
|
...f,
|
|
isRepresentative: f.objid === file.objid,
|
|
}));
|
|
|
|
setUploadedFiles(updatedFiles);
|
|
|
|
// 대표 이미지 로드
|
|
loadRepresentativeImage(file);
|
|
} catch (e) {
|
|
console.error("❌ 대표 파일 설정 실패:", e);
|
|
}
|
|
},
|
|
[uploadedFiles, component.id, loadRepresentativeImage]
|
|
);
|
|
|
|
// uploadedFiles 변경 시 대표 이미지 로드
|
|
useEffect(() => {
|
|
const representativeFile = uploadedFiles.find(f => f.isRepresentative) || uploadedFiles[0];
|
|
if (representativeFile) {
|
|
loadRepresentativeImage(representativeFile);
|
|
} else {
|
|
setRepresentativeImageUrl(null);
|
|
}
|
|
|
|
// 컴포넌트 언마운트 시 Blob URL 정리
|
|
return () => {
|
|
if (representativeImageUrl) {
|
|
window.URL.revokeObjectURL(representativeImageUrl);
|
|
}
|
|
};
|
|
}, [uploadedFiles]);
|
|
|
|
// 드래그 앤 드롭 핸들러
|
|
const handleDragOver = useCallback(
|
|
(e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!safeComponentConfig.readonly && !safeComponentConfig.disabled) {
|
|
setDragOver(true);
|
|
}
|
|
},
|
|
[safeComponentConfig.readonly, safeComponentConfig.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 (!safeComponentConfig.readonly && !safeComponentConfig.disabled) {
|
|
const files = Array.from(e.dataTransfer.files);
|
|
if (files.length > 0) {
|
|
handleFileUpload(files);
|
|
}
|
|
}
|
|
},
|
|
[safeComponentConfig.readonly, safeComponentConfig.disabled, handleFileUpload],
|
|
);
|
|
|
|
// 클릭 핸들러
|
|
const handleClick = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!safeComponentConfig.readonly && !safeComponentConfig.disabled) {
|
|
handleFileSelect();
|
|
}
|
|
onClick?.();
|
|
},
|
|
[safeComponentConfig.readonly, safeComponentConfig.disabled, handleFileSelect, onClick],
|
|
);
|
|
|
|
// 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 값)
|
|
const customStyle = component.style || {};
|
|
const hasCustomBorder = !!(customStyle.borderWidth || customStyle.borderColor || customStyle.borderStyle || customStyle.border);
|
|
const hasCustomBackground = !!customStyle.backgroundColor;
|
|
const hasCustomRadius = !!customStyle.borderRadius;
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
...componentStyle,
|
|
width: "100%",
|
|
height: "100%",
|
|
// 🔧 !important 제거 - 커스텀 스타일이 없을 때만 기본값 적용
|
|
border: hasCustomBorder ? undefined : "none",
|
|
boxShadow: "none",
|
|
outline: "none",
|
|
backgroundColor: hasCustomBackground ? undefined : "transparent",
|
|
padding: "0px",
|
|
borderRadius: hasCustomRadius ? undefined : "0px",
|
|
marginBottom: "8px",
|
|
}}
|
|
className={`${className} file-upload-container`}
|
|
>
|
|
{/* 라벨 렌더링 */}
|
|
{component.label && component.style?.labelDisplay !== false && (
|
|
<label
|
|
style={{
|
|
position: "absolute",
|
|
top: "-20px",
|
|
left: "0px",
|
|
fontSize: customStyle.labelFontSize || "12px",
|
|
color: customStyle.labelColor || "rgb(107, 114, 128)",
|
|
fontWeight: customStyle.labelFontWeight || "400",
|
|
background: "transparent",
|
|
border: "none",
|
|
boxShadow: "none",
|
|
outline: "none",
|
|
padding: "0px",
|
|
margin: "0px"
|
|
}}
|
|
>
|
|
{component.label}
|
|
{component.required && (
|
|
<span style={{ color: "#ef4444" }}>*</span>
|
|
)}
|
|
</label>
|
|
)}
|
|
|
|
<div
|
|
className={cn(
|
|
"relative flex h-full w-full flex-col overflow-hidden",
|
|
// 커스텀 테두리가 없을 때만 기본 테두리 표시
|
|
!hasCustomBorder && "border-border rounded-lg border",
|
|
// 커스텀 배경이 없을 때만 기본 배경 표시
|
|
!hasCustomBackground && "bg-card",
|
|
)}
|
|
>
|
|
{/* 대표 이미지 전체 화면 표시 */}
|
|
{uploadedFiles.length > 0 ? (() => {
|
|
const representativeFile = uploadedFiles.find(f => f.isRepresentative) || uploadedFiles[0];
|
|
const isImage = representativeFile && ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(
|
|
representativeFile.fileExt.toLowerCase().replace(".", "")
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{isImage && representativeImageUrl ? (
|
|
<div className="relative h-full w-full flex items-center justify-center bg-muted/10">
|
|
<img
|
|
src={representativeImageUrl}
|
|
alt={representativeFile.realFileName}
|
|
className="h-full w-full object-contain"
|
|
/>
|
|
</div>
|
|
) : isImage && !representativeImageUrl ? (
|
|
<div className="flex h-full w-full flex-col items-center justify-center">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-2"></div>
|
|
<p className="text-sm text-muted-foreground">이미지 로딩 중...</p>
|
|
</div>
|
|
) : (
|
|
<div className="flex h-full w-full flex-col items-center justify-center">
|
|
{getFileIcon(representativeFile.fileExt)}
|
|
<p className="mt-3 text-sm font-medium text-center px-4">
|
|
{representativeFile.realFileName}
|
|
</p>
|
|
<Badge variant="secondary" className="mt-2">
|
|
대표 파일
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
|
|
{/* 우측 하단 자세히보기 버튼 */}
|
|
<div className="absolute bottom-3 right-3">
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
className="h-8 px-3 text-xs shadow-md"
|
|
onClick={() => setIsFileManagerOpen(true)}
|
|
>
|
|
자세히보기 ({uploadedFiles.length})
|
|
</Button>
|
|
</div>
|
|
</>
|
|
);
|
|
})() : (
|
|
<div className="flex h-full w-full flex-col items-center justify-center text-muted-foreground">
|
|
<File className="mb-3 h-12 w-12" />
|
|
<p className="text-sm font-medium">업로드된 파일이 없습니다</p>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="mt-4 h-8 px-3 text-xs"
|
|
onClick={() => setIsFileManagerOpen(true)}
|
|
>
|
|
파일 업로드
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 파일뷰어 모달 */}
|
|
<FileViewerModal
|
|
file={viewerFile}
|
|
isOpen={isViewerOpen}
|
|
onClose={handleViewerClose}
|
|
onDownload={handleFileDownload}
|
|
onDelete={!isDesignMode ? handleFileDelete : undefined}
|
|
/>
|
|
|
|
{/* 파일 관리 모달 */}
|
|
<FileManagerModal
|
|
isOpen={isFileManagerOpen}
|
|
onClose={() => setIsFileManagerOpen(false)}
|
|
uploadedFiles={uploadedFiles}
|
|
onFileUpload={handleFileUpload}
|
|
onFileDownload={handleFileDownload}
|
|
onFileDelete={handleFileDelete}
|
|
onFileView={handleFileView}
|
|
onSetRepresentative={handleSetRepresentative}
|
|
config={fileConfig}
|
|
isDesignMode={isDesignMode}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export { FileUploadComponent };
|
|
export default FileUploadComponent;
|